feat: /gsd-settings-advanced — power-user config tuning command (closes #2528) (#2603)

* feat: /gsd-settings-advanced — power-user config tuning command (closes #2528)

Adds a second-tier interactive configuration command covering the power-user
knobs that don't belong in the common-case /gsd-settings prompt. Six sectioned
AskUserQuestion batches cover planning, execution, discussion, cross-AI, git,
and runtime settings (19 config keys total). Current values are pre-selected;
numeric fields reject non-numeric input; writes route through
gsd-sdk query config-set so unrelated keys are preserved.

- commands/gsd/settings-advanced.md — command entry
- get-shit-done/workflows/settings-advanced.md — six-section workflow
- get-shit-done/workflows/settings.md — advertise advanced command
- get-shit-done/bin/lib/config-schema.cjs — add context_window to VALID_CONFIG_KEYS
- docs/COMMANDS.md, docs/CONFIGURATION.md, docs/INVENTORY.md — docs + inventory
- tests/gsd-settings-advanced.test.cjs — 81 tests (files, frontmatter,
  field coverage, pre-selection, merge-preserves-siblings, VALID_CONFIG_KEYS
  membership, confirmation table, /gsd-settings cross-link, negative scenarios)

All 5073 tests pass; coverage 88.66% (>= 70% threshold).

* docs(settings-advanced): clarify per-field numeric bounds and label fenced blocks

Addresses CodeRabbit review on PR #2603:
- Numeric-input rule now states min is field-specific: plan_bounce_passes
  and max_discuss_passes require >= 1; other numeric fields accept >= 0.
  Resolves the inconsistency between the global rule and the field-level
  prompts (CodeRabbit comment 3127136557).
- Adds 'text' fence language to seven previously unlabeled code blocks in
  the workflow (six AskUserQuestion sections plus the confirmation banner)
  to satisfy markdownlint MD040 (CodeRabbit comment 3127136561).

* test(settings-advanced): tighten section assertion, fix misleading test name, add executable numeric-input coverage

Addresses CodeRabbit review on PR #2603:
- Required section list now asserts the full 'Runtime / Output' heading
  rather than the looser 'Runtime' substring (comment 3127136564).
- Renames the subagent_timeout coercion test to match the actual key
  under test (was titled 'context_window' but exercised
  workflow.subagent_timeout — comment 3127136573).
- Adds two executable behavioral tests at the config-set boundary
  (comment 3127136579):
  * Non-numeric input on a numeric key currently lands as a string —
    locks in that the workflow's AskUserQuestion re-prompt loop is the
    layer responsible for type rejection. If a future change adds CLI-side
    numeric validation, the assertion flips and the test surfaces it.
  * Numeric string on workflow.max_discuss_passes is coerced to Number —
    locks in the parser invariant for a second numeric key.
This commit is contained in:
Tom Boucher
2026-04-22 20:50:15 -04:00
committed by GitHub
parent 86c5863afb
commit 9c0a153a5f
9 changed files with 885 additions and 4 deletions

View File

@@ -0,0 +1,39 @@
---
name: gsd:settings-advanced
description: Power-user configuration — plan bounce, timeouts, branch templates, cross-AI execution, runtime knobs
allowed-tools:
- Read
- Write
- Bash
- AskUserQuestion
---
<objective>
Interactive configuration of GSD power-user knobs that don't belong in the common-case `/gsd:settings` prompt.
Routes to the settings-advanced workflow which handles:
- Config existence ensuring (workstream-aware path resolution)
- Current settings reading and parsing
- Sectioned prompts: Planning Tuning, Execution Tuning, Discussion Tuning, Cross-AI Execution, Git Customization, Runtime / Output
- Config merging that preserves every unrelated key
- Confirmation table display
Use `/gsd:settings` for the common-case toggles (model profile, research/plan_check/verifier, branching strategy, context warnings). Use `/gsd:settings-advanced` once those are set and you want to tune the internals.
</objective>
<execution_context>
@~/.claude/get-shit-done/workflows/settings-advanced.md
</execution_context>
<process>
**Follow the settings-advanced workflow** from `@~/.claude/get-shit-done/workflows/settings-advanced.md`.
The workflow handles all logic including:
1. Config file creation with defaults if missing (via `gsd-sdk query config-ensure-section`)
2. Current config reading
3. Six sectioned AskUserQuestion batches with current values pre-selected
4. Numeric-input validation (non-numeric rejected, empty input keeps current)
5. Answer parsing and config merging (preserves unrelated keys)
6. File writing (atomic)
7. Confirmation table display
</process>

View File

@@ -1052,6 +1052,29 @@ All answers are merged via `gsd-sdk query config-set` into the resolved project
/gsd-settings # Interactive config
```
### `/gsd-settings-advanced`
Interactive configuration of power-user knobs — plan bounce, subagent timeouts, branch templates, cross-AI delegation, context window, and runtime output. Use after `/gsd-settings` once the common-case toggles are dialed in.
Six sections, each a focused prompt batch:
| Section | Keys |
|---------|------|
| Planning Tuning | `workflow.plan_bounce`, `workflow.plan_bounce_passes`, `workflow.plan_bounce_script`, `workflow.subagent_timeout`, `workflow.inline_plan_threshold` |
| Execution Tuning | `workflow.node_repair`, `workflow.node_repair_budget`, `workflow.auto_prune_state` |
| Discussion Tuning | `workflow.max_discuss_passes` |
| Cross-AI Execution | `workflow.cross_ai_execution`, `workflow.cross_ai_command`, `workflow.cross_ai_timeout` |
| Git Customization | `git.base_branch`, `git.phase_branch_template`, `git.milestone_branch_template` |
| Runtime / Output | `response_language`, `context_window`, `search_gitignored`, `graphify.build_timeout` |
Current values are pre-selected; an empty input keeps the existing value. Numeric fields reject non-numeric input and re-prompt. Null-allowed fields (`plan_bounce_script`, `cross_ai_command`, `response_language`) accept an empty input as a clear. Writes route through `gsd-sdk query config-set`, which preserves every unrelated key.
```bash
/gsd-settings-advanced # Six-section interactive config
```
See [CONFIGURATION.md](CONFIGURATION.md) for the full schema and defaults.
### `/gsd-set-profile`
Quick profile switch.

View File

@@ -114,6 +114,7 @@ GSD stores project settings in `.planning/config.json`. Created during `/gsd-new
| `model_profile` | enum | `quality`, `balanced`, `budget`, `inherit` | `balanced` | Model tier for each agent (see [Model Profiles](#model-profiles)) |
| `project_code` | string | any short string | (none) | Prefix for phase directory names (e.g., `"ABC"` produces `ABC-01-setup/`). Added in v1.31 |
| `response_language` | string | language code | (none) | Language for agent responses (e.g., `"pt"`, `"ko"`, `"ja"`). Propagates to all spawned agents for cross-phase language consistency. Added in v1.32 |
| `context_window` | number | any integer | `200000` | Context window size in tokens. Set `1000000` for 1M-context models (e.g., `claude-opus-4-7[1m]`). Values `>= 500000` enable adaptive context enrichment (full-body reads of prior SUMMARY.md, deeper anti-pattern reads). Configured via `/gsd-settings-advanced`. |
| `context_profile` | string | `dev`, `research`, `review` | (none) | Execution context preset that applies a pre-configured bundle of mode, model, and workflow settings for the current type of work. Added in v1.34 |
| `claude_md_path` | string | any file path | `./CLAUDE.md` | Custom output path for the generated CLAUDE.md file. Useful for monorepos or projects that need CLAUDE.md in a non-root location. Defaults to `./CLAUDE.md` at the project root. Added in v1.36 |
| `claude_md_assembly.mode` | enum | `embed`, `link` | `embed` | Controls how managed sections are written into CLAUDE.md. `embed` (default) inlines content between GSD markers. `link` writes `@.planning/<source-path>` instead — Claude Code expands the reference at runtime, reducing CLAUDE.md size by ~65% on typical projects. `link` only applies to sections that have a real source file; `workflow` and fallback sections always embed. Per-block overrides: `claude_md_assembly.blocks.<section>` (e.g. `claude_md_assembly.blocks.architecture: link`). Added in v1.38 |

View File

@@ -1,5 +1,5 @@
{
"generated": "2026-04-20",
"generated": "2026-04-22",
"families": {
"agents": [
"gsd-advisor-researcher",
@@ -103,6 +103,7 @@
"/gsd-session-report",
"/gsd-set-profile",
"/gsd-settings",
"/gsd-settings-advanced",
"/gsd-ship",
"/gsd-sketch",
"/gsd-sketch-wrap-up",
@@ -110,11 +111,11 @@
"/gsd-spike",
"/gsd-spike-wrap-up",
"/gsd-stats",
"/gsd-sync-skills",
"/gsd-thread",
"/gsd-ui-phase",
"/gsd-ui-review",
"/gsd-ultraplan-phase",
"/gsd-sync-skills",
"/gsd-undo",
"/gsd-update",
"/gsd-validate-phase",
@@ -185,6 +186,7 @@
"scan.md",
"secure-phase.md",
"session-report.md",
"settings-advanced.md",
"settings.md",
"ship.md",
"sketch-wrap-up.md",

View File

@@ -54,7 +54,7 @@ Full roster at `agents/gsd-*.md`. The "Primary doc" column flags whether [`docs/
---
## Commands (83 shipped)
## Commands (84 shipped)
Full roster at `commands/gsd/*.md`. The groupings below mirror `docs/COMMANDS.md` section order; each row carries the command name, a one-line role derived from the command's frontmatter `description:`, and a link to the source file. `tests/command-count-sync.test.cjs` locks the count against the filesystem.
@@ -163,6 +163,7 @@ Full roster at `commands/gsd/*.md`. The groupings below mirror `docs/COMMANDS.md
| `/gsd-sketch-wrap-up` | Package sketch design findings into a persistent project skill for future build conversations. | [commands/gsd/sketch-wrap-up.md](../commands/gsd/sketch-wrap-up.md) |
| `/gsd-profile-user` | Generate developer behavioral profile and Claude-discoverable artifacts. | [commands/gsd/profile-user.md](../commands/gsd/profile-user.md) |
| `/gsd-settings` | Configure GSD workflow toggles and model profile. | [commands/gsd/settings.md](../commands/gsd/settings.md) |
| `/gsd-settings-advanced` | Power-user configuration — plan bounce, timeouts, branch templates, cross-AI execution, runtime knobs. | [commands/gsd/settings-advanced.md](../commands/gsd/settings-advanced.md) |
| `/gsd-set-profile` | Switch model profile for GSD agents (quality/balanced/budget/inherit). | [commands/gsd/set-profile.md](../commands/gsd/set-profile.md) |
| `/gsd-pr-branch` | Create a clean PR branch by filtering out `.planning/` commits. | [commands/gsd/pr-branch.md](../commands/gsd/pr-branch.md) |
| `/gsd-sync-skills` | Sync managed GSD skill directories across runtime roots for multi-runtime users. | [commands/gsd/sync-skills.md](../commands/gsd/sync-skills.md) |
@@ -173,7 +174,7 @@ Full roster at `commands/gsd/*.md`. The groupings below mirror `docs/COMMANDS.md
---
## Workflows (81 shipped)
## Workflows (82 shipped)
Full roster at `get-shit-done/workflows/*.md`. Workflows are thin orchestrators that commands reference internally; most are not read directly by end users. Rows below map each workflow file to its role (derived from the `<purpose>` block) and, where applicable, to the command that invokes it.
@@ -243,6 +244,7 @@ Full roster at `get-shit-done/workflows/*.md`. Workflows are thin orchestrators
| `secure-phase.md` | Retroactive threat-mitigation audit for a completed phase. | `/gsd-secure-phase` |
| `session-report.md` | Session report — token usage, work summary, outcomes. | `/gsd-session-report` |
| `settings.md` | Configure GSD workflow toggles and model profile. | `/gsd-settings`, `/gsd-set-profile` |
| `settings-advanced.md` | Configure GSD power-user knobs — plan bounce, timeouts, branch templates, cross-AI execution, runtime knobs. | `/gsd-settings-advanced` |
| `ship.md` | Create PR, run review, and prepare for merge after verification. | `/gsd-ship` |
| `sketch.md` | Explore design directions through throwaway HTML mockups with 2-3 variants per sketch. | `/gsd-sketch` |
| `sketch-wrap-up.md` | Curate sketch findings and package them as a persistent `sketch-findings-[project]` skill. | `/gsd-sketch-wrap-up` |

View File

@@ -54,6 +54,7 @@ const VALID_CONFIG_KEYS = new Set([
'project_code', 'phase_naming',
'manager.flags.discuss', 'manager.flags.plan', 'manager.flags.execute',
'response_language',
'context_window',
'intel.enabled',
'graphify.enabled',
'graphify.build_timeout',

View File

@@ -0,0 +1,435 @@
<purpose>
Interactive configuration of GSD power-user knobs — plan bounce, node repair, subagent timeouts,
inline plan threshold, cross-AI execution, base branch, branch templates, response language,
context window, gitignored search, and graphify build timeout.
This is a companion to `/gsd:settings` — the common-case prompt there covers model profile,
research/plan_check/verifier toggles, branching strategy, UI/AI phase gates, and worktree
isolation. This advanced command covers everything else that is user-settable, grouped into
six sections so each prompt batch stays cognitively scoped. Every answer pre-selects the
current value; numeric-input answers that are non-numeric are rejected and re-prompted.
</purpose>
<required_reading>
Read all files referenced by the invoking prompt's execution_context before starting.
</required_reading>
<process>
<step name="ensure_and_load_config">
Ensure config exists and resolve the workstream-aware config path (mirrors `settings.md`):
```bash
gsd-sdk query config-ensure-section
if [[ -z "${GSD_CONFIG_PATH:-}" ]]; then
if [[ -f .planning/active-workstream ]]; then
WS=$(tr -d '\n\r' < .planning/active-workstream)
GSD_CONFIG_PATH=".planning/workstreams/${WS}/config.json"
else
GSD_CONFIG_PATH=".planning/config.json"
fi
fi
```
All subsequent reads and writes go through `$GSD_CONFIG_PATH`. Never hardcode
`.planning/config.json` — workstream installs must route to their own config file.
</step>
<step name="read_current">
```bash
cat "$GSD_CONFIG_PATH"
```
Parse the following current values. If a key is absent, fall back to the documented default
shown in parentheses:
Planning Tuning:
- `workflow.plan_bounce` (default: `false`)
- `workflow.plan_bounce_passes` (default: `2`)
- `workflow.plan_bounce_script` (default: `null`)
- `workflow.subagent_timeout` (default: `600`)
- `workflow.inline_plan_threshold` (default: `3`)
Execution Tuning:
- `workflow.node_repair` (default: `true`)
- `workflow.node_repair_budget` (default: `2`)
- `workflow.auto_prune_state` (default: `false`)
Discussion Tuning:
- `workflow.max_discuss_passes` (default: `3`)
Cross-AI Execution:
- `workflow.cross_ai_execution` (default: `false`)
- `workflow.cross_ai_command` (default: `null`)
- `workflow.cross_ai_timeout` (default: `300`)
Git Customization:
- `git.base_branch` (default: `main`)
- `git.phase_branch_template` (default: `gsd/phase-{phase}-{slug}`)
- `git.milestone_branch_template` (default: `gsd/{milestone}-{slug}`)
Runtime / Output:
- `response_language` (default: `null`)
- `context_window` (default: `200000`)
- `search_gitignored` (default: `false`)
- `graphify.build_timeout` (default: `300`)
Each field's **current value is pre-selected** in the prompt rendering below. When the
current value is absent from the config, render the documented default as the pre-selected
option so the user sees what the effective value is.
</step>
<step name="present_settings">
**Text mode (`workflow.text_mode: true` or `--text` flag):** Set `TEXT_MODE=true` if `--text` is
in `$ARGUMENTS` OR `text_mode` is true in config. When `TEXT_MODE=true`, replace every
`AskUserQuestion` call below with a plain-text numbered list and ask the user to type the
choice number or free-text value.
**Numeric-input validation.** For any numeric field (`*_passes`, `*_budget`, `*_timeout`,
`*_threshold`, `context_window`, `graphify.build_timeout`), if the user types a value that
is not a non-negative integer, the workflow MUST reject it, state which value was invalid,
and re-prompt that single field. The minimum accepted value is field-specific and is stated
in each field's prompt below — `workflow.plan_bounce_passes` and `workflow.max_discuss_passes`
require `>= 1`; all other numeric fields accept `>= 0`. An empty input means "keep current"
— the existing value is retained. Non-numeric input is never silently coerced.
**Free-text validation.** For branch template fields (`git.phase_branch_template`,
`git.milestone_branch_template`), if the user supplies a non-default value, it MUST be
non-empty and SHOULD contain at least one `{placeholder}`. A template missing placeholders
is rejected with a message explaining the available variables (`{phase}`, `{slug}`,
`{milestone}`) and re-prompted. An empty input means "keep current."
**Null-allowed fields.** For `response_language`, `workflow.plan_bounce_script`,
`workflow.cross_ai_command`: an empty input clears the field (`null`). A non-empty input is
stored verbatim as a string.
---
### Section 1 — Planning Tuning
```text
AskUserQuestion([
{
question: "Run external plan-bounce validator against generated PLAN.md? (current: <value or false>)",
header: "Plan Bounce",
multiSelect: false,
options: [
{ label: "No (default: false)", description: "Skip external plan validation." },
{ label: "Yes", description: "Pipe each PLAN.md through `plan_bounce_script` and block on non-zero exit." }
]
},
{
question: "How many plan-bounce passes? (current: <value or 2>)",
header: "Bounce Passes",
multiSelect: false,
options: [
{ label: "Keep current", description: "Leave the existing value unchanged." },
{ label: "Enter number", description: "Type an integer >= 1. Non-numeric input is rejected and re-prompted. Default: 2" }
]
},
{
question: "Path to plan-bounce validation script? (current: <value or null>)",
header: "Bounce Script",
multiSelect: false,
options: [
{ label: "Keep current", description: "Leave existing path unchanged." },
{ label: "Clear (null)", description: "Unset the script path." },
{ label: "Enter path", description: "Type an absolute or repo-relative path. Receives PLAN.md path as first argument." }
]
},
{
question: "Subagent timeout (seconds)? (current: <value or 600>)",
header: "Subagent Timeout",
multiSelect: false,
options: [
{ label: "Keep current", description: "Leave timeout unchanged." },
{ label: "Enter seconds", description: "Integer number of seconds. Non-numeric rejected. Default: 600" }
]
},
{
question: "Inline plan threshold — tasks allowed inline before splitting to PLAN.md? (current: <value or 3>)",
header: "Inline Plan Threshold",
multiSelect: false,
options: [
{ label: "Keep current", description: "Leave threshold unchanged." },
{ label: "Enter number", description: "Integer count. Non-numeric rejected. Default: 3" }
]
}
])
```
### Section 2 — Execution Tuning
```text
AskUserQuestion([
{
question: "Enable autonomous node repair on verification failure? (current: <value or true>)",
header: "Node Repair",
multiSelect: false,
options: [
{ label: "Yes (default: true)", description: "Executor retries failed tasks up to the repair budget." },
{ label: "No", description: "Stop on first verification failure." }
]
},
{
question: "Maximum node-repair attempts per failed task? (current: <value or 2>)",
header: "Repair Budget",
multiSelect: false,
options: [
{ label: "Keep current", description: "Leave existing budget unchanged." },
{ label: "Enter number", description: "Integer >= 0. Non-numeric rejected. Default: 2" }
]
},
{
question: "Auto-prune stale STATE.md entries at phase boundaries? (current: <value or false>)",
header: "Auto Prune",
multiSelect: false,
options: [
{ label: "No (default: false)", description: "Prompt before pruning." },
{ label: "Yes", description: "Prune stale entries without prompting." }
]
}
])
```
### Section 3 — Discussion Tuning
```text
AskUserQuestion([
{
question: "Maximum discuss-phase question rounds? (current: <value or 3>)",
header: "Max Discuss Passes",
multiSelect: false,
options: [
{ label: "Keep current", description: "Leave existing value unchanged." },
{ label: "Enter number", description: "Integer >= 1. Non-numeric rejected. Default: 3. Prevents infinite discussion loops in headless mode." }
]
}
])
```
### Section 4 — Cross-AI Execution
```text
AskUserQuestion([
{
question: "Delegate phase execution to an external AI CLI? (current: <value or false>)",
header: "Cross-AI",
multiSelect: false,
options: [
{ label: "No (default: false)", description: "Use local executor agents." },
{ label: "Yes", description: "Pipe phase prompt to `cross_ai_command` via stdin. Requires command to be set." }
]
},
{
question: "Cross-AI command template? (current: <value or null>)",
header: "Cross-AI Command",
multiSelect: false,
options: [
{ label: "Keep current", description: "Leave command unchanged." },
{ label: "Clear (null)", description: "Unset the command." },
{ label: "Enter command", description: "Shell command receiving phase prompt via stdin. Must produce SUMMARY.md-compatible output." }
]
},
{
question: "Cross-AI timeout (seconds)? (current: <value or 300>)",
header: "Cross-AI Timeout",
multiSelect: false,
options: [
{ label: "Keep current", description: "Leave timeout unchanged." },
{ label: "Enter seconds", description: "Integer seconds. Non-numeric rejected. Default: 300" }
]
}
])
```
### Section 5 — Git Customization
```text
AskUserQuestion([
{
question: "Git base branch? (current: <value or main>)",
header: "Base Branch",
multiSelect: false,
options: [
{ label: "Keep current", description: "Leave base branch unchanged." },
{ label: "Enter branch name", description: "e.g., main, master, develop. Integration branch for phase/milestone branches." }
]
},
{
question: "Phase branch template? (current: <value or gsd/phase-{phase}-{slug}>)",
header: "Phase Template",
multiSelect: false,
options: [
{ label: "Keep current", description: "Leave template unchanged." },
{ label: "Enter template", description: "Non-empty string with at least one placeholder. Available: {phase}, {slug}. Non-default values missing placeholders are rejected." }
]
},
{
question: "Milestone branch template? (current: <value or gsd/{milestone}-{slug}>)",
header: "Milestone Template",
multiSelect: false,
options: [
{ label: "Keep current", description: "Leave template unchanged." },
{ label: "Enter template", description: "Non-empty string. Available placeholders: {milestone}, {slug}. Non-default values missing placeholders are rejected." }
]
}
])
```
### Section 6 — Runtime / Output
```text
AskUserQuestion([
{
question: "Response language for agent output? (current: <value or null>)",
header: "Language",
multiSelect: false,
options: [
{ label: "Keep current", description: "Leave unchanged." },
{ label: "Clear (null)", description: "Use Claude default (English)." },
{ label: "Enter language", description: "Free-text language name or code (e.g., Japanese, pt, ko). Propagates to spawned agents." }
]
},
{
question: "Context window size (tokens)? (current: <value or 200000>)",
header: "Context Window",
multiSelect: false,
options: [
{ label: "Keep current", description: "Leave unchanged." },
{ label: "Enter number", description: "Integer. Non-numeric rejected. Default: 200000. Use 1000000 for 1M-context models. Values >= 500000 enable adaptive enrichment." }
]
},
{
question: "Include gitignored files in broad searches? (current: <value or false>)",
header: "Search Gitignored",
multiSelect: false,
options: [
{ label: "No (default: false)", description: "Respect .gitignore during searches." },
{ label: "Yes", description: "Add --no-ignore to broad searches (includes .planning/)." }
]
},
{
question: "Graphify build timeout (seconds)? (current: <value or 300>)",
header: "Graphify Timeout",
multiSelect: false,
options: [
{ label: "Keep current", description: "Leave timeout unchanged." },
{ label: "Enter seconds", description: "Integer seconds. Non-numeric rejected. Default: 300" }
]
}
])
```
</step>
<step name="update_config">
Merge the new settings into the existing config at `$GSD_CONFIG_PATH`. This merge is the
core correctness invariant: **preserve every unrelated key** — do not clobber siblings.
Apply each selected value via `gsd-sdk query config-set <key> <value>` so the central
validator (`isValidConfigKey`) accepts the write and the deep-merge preserves unrelated
keys and sibling sub-objects.
```bash
# Example — only write keys the user changed. "Keep current" selections are skipped.
gsd-sdk query config-set workflow.plan_bounce_passes 5
gsd-sdk query config-set workflow.subagent_timeout 900
gsd-sdk query config-set git.base_branch main
gsd-sdk query config-set context_window 1000000
```
Conceptual shape after merge (unchanged top-level keys like `model_profile`,
`granularity`, `mode`, `brave_search`, `agent_skills.*`, `hooks.context_warnings`, and
anything not listed in Sections 1–6 MUST survive the update):
```json
{
...existing_config,
"workflow": {
...existing_workflow,
"plan_bounce": <new|existing>,
"plan_bounce_passes": <new|existing>,
"plan_bounce_script": <new|existing|null>,
"subagent_timeout": <new|existing>,
"inline_plan_threshold": <new|existing>,
"node_repair": <new|existing>,
"node_repair_budget": <new|existing>,
"auto_prune_state": <new|existing>,
"max_discuss_passes": <new|existing>,
"cross_ai_execution": <new|existing>,
"cross_ai_command": <new|existing|null>,
"cross_ai_timeout": <new|existing>
},
"git": {
...existing_git,
"base_branch": <new|existing>,
"phase_branch_template": <new|existing>,
"milestone_branch_template": <new|existing>
},
"response_language": <new|existing|null>,
"context_window": <new|existing>,
"search_gitignored": <new|existing>,
"graphify": {
...existing_graphify,
"build_timeout": <new|existing>
}
}
```
Never emit a full overwrite of the file that omits keys the user did not touch. Always
route each write through `gsd-sdk query config-set` so sibling preservation is handled by
the central setter.
</step>
<step name="confirm">
Display:
```text
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
GSD ► ADVANCED SETTINGS UPDATED
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
| Setting | Value |
|--------------------------------|-------|
| workflow.plan_bounce | {on/off} |
| workflow.plan_bounce_passes | {n} |
| workflow.plan_bounce_script | {path/null} |
| workflow.subagent_timeout | {seconds} |
| workflow.inline_plan_threshold | {n} |
| workflow.node_repair | {on/off} |
| workflow.node_repair_budget | {n} |
| workflow.auto_prune_state | {on/off} |
| workflow.max_discuss_passes | {n} |
| workflow.cross_ai_execution | {on/off} |
| workflow.cross_ai_command | {cmd/null} |
| workflow.cross_ai_timeout | {seconds} |
| git.base_branch | {branch} |
| git.phase_branch_template | {template} |
| git.milestone_branch_template | {template} |
| response_language | {lang/null} |
| context_window | {tokens} |
| search_gitignored | {on/off} |
| graphify.build_timeout | {seconds} |
These settings apply to future /gsd:plan-phase, /gsd:execute-phase, /gsd:discuss-phase,
and /gsd:ship runs.
For common-case toggles (model profile, research/plan_check/verifier, branching strategy,
UI/AI phase gates), use /gsd:settings.
```
</step>
</process>
<success_criteria>
- [ ] Current config read from resolved `$GSD_CONFIG_PATH`
- [ ] Six sections rendered (Planning, Execution, Discussion, Cross-AI, Git, Runtime)
- [ ] Every field pre-selected to its current value (or documented default if absent)
- [ ] Numeric inputs validated — non-numeric rejected and re-prompted
- [ ] Branch-template inputs validated — non-default must contain a placeholder
- [ ] Null-allowed fields accept an empty input as a clear
- [ ] Writes routed through `gsd-sdk query config-set` so unrelated keys are preserved
- [ ] Confirmation table rendered listing all 19 fields
</success_criteria>

View File

@@ -455,6 +455,7 @@ Quick commands:
- /gsd:plan-phase --research — force research
- /gsd:plan-phase --skip-research — skip research
- /gsd:plan-phase --skip-verify — skip plan check
- /gsd:settings-advanced — power-user tuning (plan bounce, timeouts, branch templates, cross-AI, context window)
```
</step>

View File

@@ -0,0 +1,377 @@
'use strict';
/**
* Tests for `/gsd-settings-advanced` — power-user configuration command (#2528).
*
* Covers:
* - Command file exists with correct frontmatter
* - Workflow file exists with required section structure
* - Every field in the issue spec is rendered in the workflow with its default
* - Current values are pre-selected in prompts
* - Config merge preserves unrelated keys (sibling preservation)
* - Confirmation table is rendered after save
* - Every field is accepted by VALID_CONFIG_KEYS
* - /gsd-settings confirmation output advertises /gsd-settings-advanced
* - Negative: non-numeric value rejected for numeric field via config-set
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs');
const { VALID_CONFIG_KEYS } = require('../get-shit-done/bin/lib/config-schema.cjs');
const ROOT = path.resolve(__dirname, '..');
const COMMAND_PATH = path.join(ROOT, 'commands', 'gsd', 'settings-advanced.md');
const WORKFLOW_PATH = path.join(ROOT, 'get-shit-done', 'workflows', 'settings-advanced.md');
const SETTINGS_WORKFLOW_PATH = path.join(ROOT, 'get-shit-done', 'workflows', 'settings.md');
// ─── Spec — every field the advanced command must expose ──────────────────────
const SPEC_FIELDS = {
planning: [
{ key: 'workflow.plan_bounce', default: 'false' },
{ key: 'workflow.plan_bounce_passes', default: '2' },
{ key: 'workflow.plan_bounce_script', default: 'null' },
{ key: 'workflow.subagent_timeout', default: '600' },
{ key: 'workflow.inline_plan_threshold', default: '3' },
],
execution: [
{ key: 'workflow.node_repair', default: 'true' },
{ key: 'workflow.node_repair_budget', default: '2' },
{ key: 'workflow.auto_prune_state', default: 'false' },
],
discussion: [
{ key: 'workflow.max_discuss_passes', default: '3' },
],
cross_ai: [
{ key: 'workflow.cross_ai_execution', default: 'false' },
{ key: 'workflow.cross_ai_command', default: 'null' },
{ key: 'workflow.cross_ai_timeout', default: '300' },
],
git: [
{ key: 'git.base_branch', default: 'main' },
{ key: 'git.phase_branch_template', default: 'gsd/phase-{phase}-{slug}' },
{ key: 'git.milestone_branch_template', default: 'gsd/{milestone}-{slug}' },
],
runtime: [
{ key: 'response_language', default: 'null' },
{ key: 'context_window', default: '200000' },
{ key: 'search_gitignored', default: 'false' },
{ key: 'graphify.build_timeout', default: '300' },
],
};
const ALL_SPEC_KEYS = Object.values(SPEC_FIELDS).flat().map((f) => f.key);
// ─── File existence + frontmatter ─────────────────────────────────────────────
describe('gsd-settings-advanced — file scaffolding', () => {
test('command file exists at commands/gsd/settings-advanced.md', () => {
assert.ok(fs.existsSync(COMMAND_PATH), `missing ${COMMAND_PATH}`);
});
test('workflow file exists at get-shit-done/workflows/settings-advanced.md', () => {
assert.ok(fs.existsSync(WORKFLOW_PATH), `missing ${WORKFLOW_PATH}`);
});
test('command frontmatter has name, description, allowed-tools', () => {
const text = fs.readFileSync(COMMAND_PATH, 'utf-8');
const fmMatch = text.match(/^---\n([\s\S]*?)\n---/);
assert.ok(fmMatch, 'command file missing frontmatter block');
const fm = fmMatch[1];
assert.match(fm, /name:\s*gsd:settings-advanced/, 'frontmatter missing name');
assert.match(fm, /description:\s*\S/, 'frontmatter missing non-empty description');
assert.match(fm, /allowed-tools:/, 'frontmatter missing allowed-tools');
});
test('command routes to the settings-advanced workflow', () => {
const text = fs.readFileSync(COMMAND_PATH, 'utf-8');
assert.ok(
text.includes('workflows/settings-advanced.md'),
'command file must reference workflows/settings-advanced.md'
);
});
});
// ─── Workflow content — sections and fields ───────────────────────────────────
describe('gsd-settings-advanced — workflow structure', () => {
let workflow;
try {
workflow = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
} catch { workflow = ''; }
const requiredSteps = [
'ensure_and_load_config',
'read_current',
'present_settings',
'update_config',
'confirm',
];
for (const step of requiredSteps) {
test(`workflow defines <step name="${step}">`, () => {
assert.ok(
workflow.includes(`<step name="${step}">`),
`workflow missing step ${step}`
);
});
}
const requiredSections = [
'Planning Tuning',
'Execution Tuning',
'Discussion Tuning',
'Cross-AI Execution',
'Git Customization',
'Runtime / Output',
];
for (const section of requiredSections) {
test(`workflow renders section "${section}"`, () => {
assert.ok(
workflow.includes(section),
`workflow missing section heading "${section}"`
);
});
}
for (const field of Object.values(SPEC_FIELDS).flat()) {
test(`workflow mentions key \`${field.key}\``, () => {
assert.ok(
workflow.includes(field.key),
`workflow missing field ${field.key}`
);
});
test(`workflow documents default for \`${field.key}\` (${field.default})`, () => {
// Search for the default token in proximity to the key. Keep this
// forgiving: same line, or within ~200 chars after the key.
const idx = workflow.indexOf(field.key);
assert.ok(idx >= 0, `key ${field.key} not found`);
const window = workflow.slice(idx, idx + 400);
assert.ok(
window.includes(field.default),
`default "${field.default}" not found near key ${field.key}. Window:\n${window}`
);
});
}
test('workflow pre-selects current values from loaded config', () => {
assert.match(
workflow,
/pre-selected|current value|Current:/i,
'workflow must document that current values are pre-selected'
);
});
test('confirmation step renders a table with saved settings', () => {
const confirmStart = workflow.indexOf('<step name="confirm">');
assert.ok(confirmStart >= 0, 'confirm step missing');
const confirmBlock = workflow.slice(confirmStart);
assert.ok(
confirmBlock.includes('|') && /\|[^\n]*Setting[^\n]*\|/.test(confirmBlock),
'confirm step must render a markdown table with a Setting column'
);
});
test('update_config step describes merge-preserving-siblings behavior', () => {
assert.match(
workflow,
/(preserv(e|ing) (unrelated|sibling)|do not clobber|merge .*existing|...existing_config)/i,
'update_config step must describe preserving unrelated keys'
);
});
});
// ─── VALID_CONFIG_KEYS membership ─────────────────────────────────────────────
describe('gsd-settings-advanced — VALID_CONFIG_KEYS coverage', () => {
for (const key of ALL_SPEC_KEYS) {
test(`VALID_CONFIG_KEYS contains "${key}"`, () => {
assert.ok(
VALID_CONFIG_KEYS.has(key),
`VALID_CONFIG_KEYS missing ${key} — add it to get-shit-done/bin/lib/config-schema.cjs`
);
});
}
});
// ─── /gsd-settings mentions /gsd-settings-advanced ────────────────────────────
describe('/gsd-settings advertises /gsd-settings-advanced', () => {
test('settings workflow confirmation mentions gsd-settings-advanced', () => {
const text = fs.readFileSync(SETTINGS_WORKFLOW_PATH, 'utf-8');
assert.ok(
text.includes('gsd-settings-advanced') || text.includes('gsd:settings-advanced'),
'get-shit-done/workflows/settings.md must mention /gsd-settings-advanced or /gsd:settings-advanced'
);
});
});
// ─── Sibling-preservation via config-set ──────────────────────────────────────
describe('gsd-settings-advanced — config merge preserves unrelated keys', () => {
test('setting workflow.plan_bounce_passes does not clobber model_profile or git.branching_strategy', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
// Seed config
const configPath = path.join(tmpDir, '.planning', 'config.json');
const initial = {
model_profile: 'quality',
git: {
branching_strategy: 'phase',
phase_branch_template: 'feature/{phase}-{slug}',
},
workflow: {
research: true,
plan_check: false,
},
hooks: {
context_warnings: true,
},
};
fs.writeFileSync(configPath, JSON.stringify(initial, null, 2), 'utf-8');
const result = runGsdTools(
['config-set', 'workflow.plan_bounce_passes', '5'],
tmpDir,
{ HOME: tmpDir }
);
assert.ok(result.success, `config-set failed: ${result.error || result.output}`);
const updated = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
assert.strictEqual(updated.model_profile, 'quality', 'model_profile clobbered');
assert.strictEqual(updated.git.branching_strategy, 'phase', 'git.branching_strategy clobbered');
assert.strictEqual(updated.git.phase_branch_template, 'feature/{phase}-{slug}', 'git.phase_branch_template clobbered');
assert.strictEqual(updated.workflow.research, true, 'workflow.research clobbered');
assert.strictEqual(updated.workflow.plan_check, false, 'workflow.plan_check clobbered');
assert.strictEqual(updated.hooks.context_warnings, true, 'hooks.context_warnings clobbered');
assert.strictEqual(updated.workflow.plan_bounce_passes, 5, 'new value not written');
});
test('setting context_window preserves existing top-level keys', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, JSON.stringify({
model_profile: 'balanced',
response_language: 'Japanese',
search_gitignored: true,
}, null, 2));
const result = runGsdTools(
['config-set', 'context_window', '1000000'],
tmpDir,
{ HOME: tmpDir }
);
assert.ok(result.success, `config-set context_window failed: ${result.error || result.output}`);
const updated = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
assert.strictEqual(updated.context_window, 1000000);
assert.strictEqual(updated.model_profile, 'balanced');
assert.strictEqual(updated.response_language, 'Japanese');
assert.strictEqual(updated.search_gitignored, true);
});
});
// ─── Negative: non-numeric for numeric field / unknown key rejected ───────────
describe('gsd-settings-advanced — negative scenarios', () => {
test('config-set rejects an unknown key with a helpful error', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const result = runGsdTools(
['config-set', 'workflow.no_such_knob_at_all', 'true'],
tmpDir,
{ HOME: tmpDir }
);
assert.ok(!result.success, 'config-set should reject unknown keys');
const combined = (result.error || '') + (result.output || '');
assert.match(combined, /Unknown config key/i);
});
test('workflow.subagent_timeout numeric input is coerced and stored as Number', (t) => {
// The config-set parser coerces numeric-looking strings to Number.
// This test locks in the coercion so users can't accidentally save
// a string for a numeric knob. A non-numeric string would be stored
// verbatim — we assert the parser prefers Number for numeric literals.
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, '{}');
const okNum = runGsdTools(
['config-set', 'workflow.subagent_timeout', '900'],
tmpDir,
{ HOME: tmpDir }
);
assert.ok(okNum.success);
const c1 = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
assert.strictEqual(typeof c1.workflow.subagent_timeout, 'number');
assert.strictEqual(c1.workflow.subagent_timeout, 900);
});
test('workflow documents numeric-input rejection for non-numeric answers', () => {
const workflow = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
assert.match(
workflow,
/(non-numeric|must be a number|integer|numeric input|re-?prompt)/i,
'workflow must document how non-numeric input is handled for numeric fields'
);
});
// Behavioral coverage for numeric-key inputs at the config-set boundary.
// The /gsd-settings-advanced workflow promises non-numeric input is never
// silently coerced — that promise is enforced by the AskUserQuestion
// re-prompt loop in the workflow runner, not by config-set itself. The
// CLI parser passes numeric-looking strings through Number() and stores
// anything else verbatim. These tests lock in both behaviors so a future
// regression that changes either layer surfaces immediately.
test('config-set on a numeric key stores non-numeric input verbatim as string (workflow layer must reject before reaching here)', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, '{}');
const result = runGsdTools(
['config-set', 'workflow.subagent_timeout', 'not-a-number'],
tmpDir,
{ HOME: tmpDir }
);
// The CLI layer accepts the write — type validation lives in the
// /gsd-settings-advanced workflow. If a future change adds a numeric
// type-check at config-set, flip this assertion to !result.success.
assert.ok(result.success, `config-set should accept the raw value at the CLI boundary: ${result.error || result.output}`);
const stored = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
assert.strictEqual(
typeof stored.workflow.subagent_timeout,
'string',
'non-numeric input on a numeric key currently lands as a string at the CLI boundary'
);
assert.strictEqual(stored.workflow.subagent_timeout, 'not-a-number');
});
test('config-set on a numeric key coerces a numeric string to Number (parser invariant)', (t) => {
const tmpDir = createTempProject();
t.after(() => cleanup(tmpDir));
const configPath = path.join(tmpDir, '.planning', 'config.json');
fs.writeFileSync(configPath, '{}');
const result = runGsdTools(
['config-set', 'workflow.max_discuss_passes', '7'],
tmpDir,
{ HOME: tmpDir }
);
assert.ok(result.success, `config-set failed: ${result.error || result.output}`);
const stored = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
assert.strictEqual(typeof stored.workflow.max_discuss_passes, 'number');
assert.strictEqual(stored.workflow.max_discuss_passes, 7);
});
});