From b2a8411e4d9b3227f75de487d6b8d0aab70536c4 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 24 May 2026 20:26:53 -0400 Subject: [PATCH] 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 --- .changeset/jolly-quails-caper.md | 5 + get-shit-done/workflows/new-project.md | 60 +++++++++- get-shit-done/workflows/settings-advanced.md | 52 +++++++-- .../workflows/settings-integrations.md | 44 ++++++-- ...bug-17-askuserquestion-option-cap.test.cjs | 103 ++++++++++++++++++ 5 files changed, 241 insertions(+), 23 deletions(-) create mode 100644 .changeset/jolly-quails-caper.md create mode 100644 tests/bug-17-askuserquestion-option-cap.test.cjs diff --git a/.changeset/jolly-quails-caper.md b/.changeset/jolly-quails-caper.md new file mode 100644 index 000000000..526d7f84e --- /dev/null +++ b/.changeset/jolly-quails-caper.md @@ -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. diff --git a/get-shit-done/workflows/new-project.md b/get-shit-done/workflows/new-project.md index b6b8a8b38..2f17bd5b8 100644 --- a/get-shit-done/workflows/new-project.md +++ b/get-shit-done/workflows/new-project.md @@ -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. diff --git a/get-shit-done/workflows/settings-advanced.md b/get-shit-done/workflows/settings-advanced.md index e0941ceed..129fc92f1 100644 --- a/get-shit-done/workflows/settings-advanced.md +++ b/get-shit-done/workflows/settings-advanced.md @@ -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: )", - header: "Runtime Selection", + question: "Which runtime group do you want to configure tier overrides for? (current: )", + header: "Runtime Group", multiSelect: false, options: [ { label: "Keep current ()", 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 ` before proceeding to Step C. diff --git a/get-shit-done/workflows/settings-integrations.md b/get-shit-done/workflows/settings-integrations.md index 1d0943bf7..447b0bad0 100644 --- a/get-shit-done/workflows/settings-integrations.md +++ b/get-shit-done/workflows/settings-integrations.md @@ -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. "" ``` +After each update, return to the "Review model CLI mapping — what next?" question. Loop until the user selects "Done". The `review.models.` 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. "" ``` +After each update, return to the "Agent skills mapping — what next?" question. Loop until "Done". diff --git a/tests/bug-17-askuserquestion-option-cap.test.cjs b/tests/bug-17-askuserquestion-option-cap.test.cjs new file mode 100644 index 000000000..f0313bc54 --- /dev/null +++ b/tests/bug-17-askuserquestion-option-cap.test.cjs @@ -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') + ); + }); +});