* fix(#17): enforce AskUserQuestion 4-option cap across workflows * chore(changeset): add fixed entry for #17
This commit is contained in:
5
.changeset/jolly-quails-caper.md
Normal file
5
.changeset/jolly-quails-caper.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 17
|
||||
---
|
||||
Fix AskUserQuestion option-cap violations by splitting >4 option prompts across workflows and add a regression guard.
|
||||
@@ -567,19 +567,66 @@ Which settings do you want to change? (enter numbers, comma-separated)
|
||||
8. Verifier — Currently: [Yes|No]
|
||||
```
|
||||
|
||||
**Otherwise** (Claude runtime with AskUserQuestion): use multiSelect:
|
||||
**Otherwise** (Claude runtime with AskUserQuestion): use a two-block split
|
||||
to stay within the 4-option runtime cap.
|
||||
|
||||
```text
|
||||
AskUserQuestion([
|
||||
{
|
||||
question: "Which settings do you want to change?",
|
||||
header: "Change Settings",
|
||||
question: "Do you want to change any core workflow settings (Mode, Granularity, Execution, Git Tracking)?",
|
||||
header: "Core Settings",
|
||||
multiSelect: false,
|
||||
options: [
|
||||
{ label: "Yes", description: "Choose from core workflow settings" },
|
||||
{ label: "No", description: "Skip core workflow settings" }
|
||||
]
|
||||
}
|
||||
])
|
||||
```
|
||||
|
||||
If "Yes", ask:
|
||||
|
||||
```text
|
||||
AskUserQuestion([
|
||||
{
|
||||
question: "Which core workflow settings do you want to change?",
|
||||
header: "Core Select",
|
||||
multiSelect: true,
|
||||
options: [
|
||||
{ label: "Mode", description: "Currently: [value]" },
|
||||
{ label: "Granularity", description: "Currently: [value]" },
|
||||
{ label: "Execution", description: "Currently: [Parallel|Sequential]" },
|
||||
{ label: "Git Tracking", description: "Currently: [Yes|No]" },
|
||||
{ label: "Git Tracking", description: "Currently: [Yes|No]" }
|
||||
]
|
||||
}
|
||||
])
|
||||
```
|
||||
|
||||
Then ask:
|
||||
|
||||
```text
|
||||
AskUserQuestion([
|
||||
{
|
||||
question: "Do you want to change any model/agent settings (AI Models, Research, Plan Check, Verifier)?",
|
||||
header: "Agent Settings",
|
||||
multiSelect: false,
|
||||
options: [
|
||||
{ label: "Yes", description: "Choose from model/agent settings" },
|
||||
{ label: "No", description: "Skip model/agent settings" }
|
||||
]
|
||||
}
|
||||
])
|
||||
```
|
||||
|
||||
If "Yes", ask:
|
||||
|
||||
```text
|
||||
AskUserQuestion([
|
||||
{
|
||||
question: "Which model/agent settings do you want to change?",
|
||||
header: "Agent Select",
|
||||
multiSelect: true,
|
||||
options: [
|
||||
{ label: "AI Models", description: "Currently: [value]" },
|
||||
{ label: "Research", description: "Currently: [Yes|No]" },
|
||||
{ label: "Plan Check", description: "Currently: [Yes|No]" },
|
||||
@@ -589,7 +636,10 @@ AskUserQuestion([
|
||||
])
|
||||
```
|
||||
|
||||
For each selected setting, ask only that question using the option set from Round 1 / Round 2 below. Merge user answers over the saved defaults — unchanged settings retain their saved values. Then skip to **Commit config.json**.
|
||||
For each selected setting across both blocks, ask only that question using the
|
||||
option set from Round 1 / Round 2 below. Merge user answers over the saved
|
||||
defaults — unchanged settings retain their saved values. Then skip to
|
||||
**Commit config.json**.
|
||||
|
||||
**If "Configure fresh" or `~/.gsd/defaults.json` doesn't exist:** proceed with the questions below.
|
||||
|
||||
|
||||
@@ -382,25 +382,55 @@ For Group B runtimes (those without a built-in default), show `(no built-in defa
|
||||
```text
|
||||
AskUserQuestion([
|
||||
{
|
||||
question: "Which runtime do you want to configure tier overrides for? (current: <runtime or 'claude'>)",
|
||||
header: "Runtime Selection",
|
||||
question: "Which runtime group do you want to configure tier overrides for? (current: <runtime or 'claude'>)",
|
||||
header: "Runtime Group",
|
||||
multiSelect: false,
|
||||
options: [
|
||||
{ label: "Keep current (<runtime>)", description: "Configure overrides for the current runtime." },
|
||||
{ label: "claude", description: "Claude Code / Anthropic CLI." },
|
||||
{ label: "codex", description: "OpenAI Codex CLI." },
|
||||
{ label: "gemini", description: "Gemini CLI." },
|
||||
{ label: "qwen", description: "Qwen CLI." },
|
||||
{ label: "opencode", description: "OpenCode (uses anthropic/ prefix)." },
|
||||
{ label: "copilot", description: "GitHub Copilot." },
|
||||
{ label: "hermes", description: "Hermes (uses anthropic/ prefix)." },
|
||||
{ label: "Other (Group B or custom)", description: "kilo, cline, cursor, windsurf, augment, trae, codebuddy, antigravity, or a custom runtime string. Overrides are honored even though no built-in map exists." }
|
||||
{ label: "Common runtimes", description: "claude, codex, gemini, qwen" },
|
||||
{ label: "Additional runtimes", description: "opencode, copilot, hermes" },
|
||||
{ label: "Other (Group B or custom)", description: "kilo, cline, cursor, windsurf, augment, trae, codebuddy, antigravity, or a custom runtime string." }
|
||||
]
|
||||
}
|
||||
])
|
||||
```
|
||||
|
||||
If "Other" is selected, prompt the user to enter the runtime name as a free-text string.
|
||||
If "Common runtimes" is selected, ask:
|
||||
|
||||
```text
|
||||
AskUserQuestion([
|
||||
{
|
||||
question: "Choose the runtime:",
|
||||
header: "Common",
|
||||
multiSelect: false,
|
||||
options: [
|
||||
{ label: "claude", description: "Claude Code / Anthropic CLI." },
|
||||
{ label: "codex", description: "OpenAI Codex CLI." },
|
||||
{ label: "gemini", description: "Gemini CLI." },
|
||||
{ label: "qwen", description: "Qwen CLI." }
|
||||
]
|
||||
}
|
||||
])
|
||||
```
|
||||
|
||||
If "Additional runtimes" is selected, ask:
|
||||
|
||||
```text
|
||||
AskUserQuestion([
|
||||
{
|
||||
question: "Choose the runtime:",
|
||||
header: "Additional",
|
||||
multiSelect: false,
|
||||
options: [
|
||||
{ label: "opencode", description: "OpenCode (uses anthropic/ prefix)." },
|
||||
{ label: "copilot", description: "GitHub Copilot." },
|
||||
{ label: "hermes", description: "Hermes (uses anthropic/ prefix)." }
|
||||
]
|
||||
}
|
||||
])
|
||||
```
|
||||
|
||||
If "Other (Group B or custom)" is selected, prompt the user to enter the runtime name as a free-text string.
|
||||
If the selected runtime differs from the stored `runtime` key, update `runtime` via
|
||||
`gsd-sdk query config-set runtime <value>` before proceeding to Step C.
|
||||
|
||||
|
||||
@@ -108,9 +108,7 @@ AskUserQuestion([
|
||||
{ label: "Leave (**** already set)", description: "Keep current value" },
|
||||
{ label: "Replace", description: "Enter a new API key" },
|
||||
{ label: "Clear", description: "Remove the stored key" }
|
||||
// When unset:
|
||||
// { label: "Skip", description: "Leave unset" },
|
||||
// { label: "Set", description: "Enter an API key" }
|
||||
// When unset, use the two-option shape: Skip / Set.
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -161,6 +159,22 @@ $GSD_SDK query config-set brave_search null
|
||||
shell command to invoke for a given reviewer flavor. Supported flavors:
|
||||
`claude`, `codex`, `gemini`, `opencode`.
|
||||
|
||||
```text
|
||||
AskUserQuestion([
|
||||
{
|
||||
question: "Review model CLI mapping — what next?",
|
||||
header: "Review",
|
||||
multiSelect: false,
|
||||
options: [
|
||||
{ label: "Configure CLI", description: "Pick a reviewer flavor and set/clear its command" },
|
||||
{ label: "Done", description: "Finish this section" }
|
||||
]
|
||||
}
|
||||
])
|
||||
```
|
||||
|
||||
If "Configure CLI" is selected, ask:
|
||||
|
||||
```text
|
||||
AskUserQuestion([
|
||||
{
|
||||
@@ -171,8 +185,7 @@ AskUserQuestion([
|
||||
{ label: "Claude", description: "review.models.claude — defaults to session model when unset" },
|
||||
{ label: "Codex", description: "review.models.codex — e.g. 'codex exec --model gpt-5'" },
|
||||
{ label: "Gemini", description: "review.models.gemini — e.g. 'gemini -m gemini-2.5-pro'" },
|
||||
{ label: "OpenCode", description: "review.models.opencode — e.g. 'opencode run --model claude-sonnet-4'" },
|
||||
{ label: "Done", description: "Skip — finish this section" }
|
||||
{ label: "OpenCode", description: "review.models.opencode — e.g. 'opencode run --model claude-sonnet-4'" }
|
||||
]
|
||||
}
|
||||
])
|
||||
@@ -186,6 +199,7 @@ string. Write via:
|
||||
$GSD_SDK query config-set review.models.<cli> "<command string>"
|
||||
```
|
||||
|
||||
After each update, return to the "Review model CLI mapping — what next?" question.
|
||||
Loop until the user selects "Done".
|
||||
|
||||
The `review.models.<cli>` key is validated by the dynamic pattern
|
||||
@@ -200,6 +214,22 @@ frontmatter. The slug is user-extensible, so input is free-text validated
|
||||
against `^[a-zA-Z0-9_-]+$`. Inputs with path separators, spaces, or shell
|
||||
metacharacters are rejected.
|
||||
|
||||
```text
|
||||
AskUserQuestion([
|
||||
{
|
||||
question: "Agent skills mapping — what next?",
|
||||
header: "Agent Skills",
|
||||
multiSelect: false,
|
||||
options: [
|
||||
{ label: "Configure agent", description: "Pick an agent type and set/clear skills" },
|
||||
{ label: "Done", description: "Finish this section" }
|
||||
]
|
||||
}
|
||||
])
|
||||
```
|
||||
|
||||
If "Configure agent" is selected, ask:
|
||||
|
||||
```text
|
||||
AskUserQuestion([
|
||||
{
|
||||
@@ -210,8 +240,7 @@ AskUserQuestion([
|
||||
{ label: "gsd-executor", description: "Skills injected when spawning executor agents" },
|
||||
{ label: "gsd-planner", description: "Skills injected when spawning planner agents" },
|
||||
{ label: "gsd-verifier", description: "Skills injected when spawning verifier agents" },
|
||||
{ label: "Custom…", description: "Enter a custom agent-type slug" },
|
||||
{ label: "Done", description: "Skip — finish this section" }
|
||||
{ label: "Custom…", description: "Enter a custom agent-type slug" }
|
||||
]
|
||||
}
|
||||
])
|
||||
@@ -234,6 +263,7 @@ Show the current value if any, offer Leave / Replace / Clear. Write via:
|
||||
$GSD_SDK query config-set agent_skills.<slug> "<skill-a,skill-b,skill-c>"
|
||||
```
|
||||
|
||||
After each update, return to the "Agent skills mapping — what next?" question.
|
||||
Loop until "Done".
|
||||
</step>
|
||||
|
||||
|
||||
103
tests/bug-17-askuserquestion-option-cap.test.cjs
Normal file
103
tests/bug-17-askuserquestion-option-cap.test.cjs
Normal file
@@ -0,0 +1,103 @@
|
||||
'use strict';
|
||||
|
||||
// allow-test-rule: source-text-is-the-product
|
||||
// Workflow markdown is the shipped runtime contract; validating its AskUserQuestion
|
||||
// option limits is a behavioral guard, not an implementation-detail assertion.
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', 'get-shit-done', 'workflows');
|
||||
const ASK_USER_QUESTION_OPTION_CAP = 4;
|
||||
|
||||
function walkMarkdownFiles(dir, out = []) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walkMarkdownFiles(full, out);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && full.endsWith('.md')) out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function findBalancedClose(text, openIndex, openCh, closeCh) {
|
||||
let depth = 0;
|
||||
for (let i = openIndex; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (ch === openCh) depth++;
|
||||
else if (ch === closeCh) {
|
||||
depth--;
|
||||
if (depth === 0) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function getLineNumber(text, index) {
|
||||
return text.slice(0, index).split('\n').length;
|
||||
}
|
||||
|
||||
function collectOptionCapViolations(file, text) {
|
||||
const violations = [];
|
||||
const askRe = /\bAskUserQuestion\s*\(\s*\[/g;
|
||||
let askMatch;
|
||||
|
||||
while ((askMatch = askRe.exec(text)) !== null) {
|
||||
const askStart = askMatch.index;
|
||||
const arrayOpen = text.indexOf('[', askStart);
|
||||
if (arrayOpen === -1) continue;
|
||||
const arrayClose = findBalancedClose(text, arrayOpen, '[', ']');
|
||||
if (arrayClose === -1) continue;
|
||||
const askBlock = text.slice(arrayOpen, arrayClose + 1);
|
||||
const blockOffset = arrayOpen;
|
||||
|
||||
const optionsRe = /\boptions\s*:\s*\[/g;
|
||||
let optionsMatch;
|
||||
while ((optionsMatch = optionsRe.exec(askBlock)) !== null) {
|
||||
const openInBlock = optionsMatch.index + optionsMatch[0].length - 1;
|
||||
const closeInBlock = findBalancedClose(askBlock, openInBlock, '[', ']');
|
||||
if (closeInBlock === -1) continue;
|
||||
const optionsBody = askBlock.slice(openInBlock, closeInBlock + 1);
|
||||
const labelCount = (optionsBody.match(/\blabel\s*:\s*"[^"]+"/g) || []).length;
|
||||
if (labelCount > ASK_USER_QUESTION_OPTION_CAP) {
|
||||
const globalIdx = blockOffset + optionsMatch.index;
|
||||
violations.push({
|
||||
file,
|
||||
line: getLineNumber(text, globalIdx),
|
||||
count: labelCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return violations;
|
||||
}
|
||||
|
||||
describe('bug #17: AskUserQuestion options arrays respect runtime cap', () => {
|
||||
test('every AskUserQuestion options array in workflows has at most 4 options', () => {
|
||||
const files = walkMarkdownFiles(ROOT);
|
||||
const violations = [];
|
||||
|
||||
for (const file of files) {
|
||||
const text = fs.readFileSync(file, 'utf8');
|
||||
violations.push(...collectOptionCapViolations(file, text));
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
violations.length,
|
||||
0,
|
||||
[
|
||||
`Found ${violations.length} AskUserQuestion options-array cap violation(s).`,
|
||||
`Runtime cap is ${ASK_USER_QUESTION_OPTION_CAP} options per question.`,
|
||||
...violations.map((v) => {
|
||||
const rel = path.relative(path.join(__dirname, '..'), v.file);
|
||||
return ` ${rel}:${v.line} -> ${v.count} options`;
|
||||
}),
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user