fix(#17): cap AskUserQuestion options at 4 across workflows (#243)

* fix(#17): enforce AskUserQuestion 4-option cap across workflows

* chore(changeset): add fixed entry for #17
This commit is contained in:
Tom Boucher
2026-05-24 20:26:53 -04:00
committed by GitHub
parent 81a4d1c091
commit b2a8411e4d
5 changed files with 241 additions and 23 deletions

View 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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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>

View 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')
);
});
});