From 3c08315a5e1bc8eabda740fe746c384741587d0a Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 29 Aug 2026 09:41:00 -0400 Subject: [PATCH] =?UTF-8?q?fix(#3827):=20new-mode=20routing=20gate=20?= =?UTF-8?q?=E2=80=94=20classify=20approval=20no=20longer=20authorizes=20sc?= =?UTF-8?q?affold=20writes=20(#4037)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(#3827): new-mode routing approval gate before roadmapper (failing first) * fix(#3827): new-mode routing gate — classify approval no longer authorizes scaffold writes The route_new_mode step delegated to gsd-roadmapper (PROJECT.md, REQUIREMENTS.md, ROADMAP.md, STATE.md creation + commit) with no gate: the discovery gate approved classification only, and the zero-conflict branch said 'proceed to routing silently'. Merge mode previews its diff and gates via approve-revise-abort; new mode now shows the exact destinations and requires Create planning setup | Keep synthesized intel only | Abort, per the skill contract's routing-gate requirement. The keep-intel-only choice is the analysis-only path the issue asks for: no roadmapper, no destination writes, intel preserved; finalize labels it 'new (intel only)' and points at /gsd:new-project instead of plan-phase. Ambiguous gate answers re-ask once, then treat as Abort — never infer Create. Zero-conflict wording is mode-aware: silence about conflicts is not authorization to write. Emitted-Drift-Ack-Growth: ingest-docs.md — #3827: +routing gate display block, AskUserQuestion contract, three disposition branches (incl. the intel-only no-write path), ambiguous-answer rule, intel-only finalize line, mode-aware zero-conflict pointer; a real behavioral gate, not incidental bloat. * chore(#3827): changeset fragment (pr number backfilled after PR creation) * chore(#3827): backfill changeset PR number (4037) --------- Co-authored-by: sim --- .changeset/vivid-pumas-squeak.md | 5 +++ gsd-core/workflows/ingest-docs.md | 32 +++++++++++++++++-- tests/ingest-docs.test.cjs | 52 +++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 .changeset/vivid-pumas-squeak.md diff --git a/.changeset/vivid-pumas-squeak.md b/.changeset/vivid-pumas-squeak.md new file mode 100644 index 000000000..5656395bd --- /dev/null +++ b/.changeset/vivid-pumas-squeak.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4037 +--- +gsd-ingest-docs new mode now requires an explicit routing approval (Create planning setup | Keep synthesized intel only | Abort) before creating the planning scaffold — approving document classification no longer also authorizes scaffold creation and commit (#3827) diff --git a/gsd-core/workflows/ingest-docs.md b/gsd-core/workflows/ingest-docs.md index 805e3ddc6..318206ea7 100644 --- a/gsd-core/workflows/ingest-docs.md +++ b/gsd-core/workflows/ingest-docs.md @@ -256,7 +256,7 @@ On Abort: exit cleanly with "Ingest cancelled. Staged intel preserved at `.plann **If BLOCKERS = 0 and WARNINGS = 0:** -Proceed to routing silently, or optionally display `GSD > No conflicts. Auto-resolved: {N}.` +Optionally display `GSD > No conflicts. Auto-resolved: {N}.` Absence of conflicts is not authorization to write: proceed to the routing gate for the active mode — new mode's routing gate (next step) or merge mode's merge-diff approve-revise-abort gate — which decides whether destination files are created. @@ -266,6 +266,32 @@ Proceed to routing silently, or optionally display `GSD > No conflicts. Auto-res Audit PROJECT.md field requirements that `gsd-roadmapper` expects. For fields derivable from `.planning/intel/SYNTHESIS.md` (project scope, goals/non-goals, constraints, locked decisions), synthesize from the intel. For fields NOT derivable (project name, developer-facing success metric, target runtime), prompt via `AskUserQuestion` one at a time — minimal question set, no interrogation. +**Routing gate (#3827): approval to classify documents is not approval to write the planning scaffold.** Before delegating, display the exact destinations and require an explicit choice: + +``` +Routing — create the planning setup now? + + .planning/PROJECT.md (new) + .planning/REQUIREMENTS.md (new) + .planning/ROADMAP.md (new) + .planning/STATE.md (new) +``` + +Use `AskUserQuestion`: +- question: "Routing — create the planning setup now?" +- header: "Routing" +- options: Create planning setup | Keep synthesized intel only | Abort + +**Text mode:** numbered list (1/2/3) with the same three choices. + +On **Create planning setup**: continue to the `gsd-roadmapper` delegation below. + +On **Keep synthesized intel only**: analysis-only ingest. Do NOT invoke `gsd-roadmapper`; write no destination files. The staged intel under `.planning/intel/` is preserved and committed by `finalize` (substitute its actual file set — no PROJECT.md/REQUIREMENTS.md/ROADMAP.md/STATE.md lines). Display the completion banner with mode `new (intel only)`. + +On **Abort**: exit cleanly with "Ingest cancelled. Staged intel preserved at `.planning/intel/`." + +Any other response (freeform/"Other"): re-ask once; if still ambiguous, treat as Abort. Never infer Create from an ambiguous answer. + Delegate to `gsd-roadmapper` (runs in a subagent — no output until it returns, ~1–5 min; expected, not a freeze): ``` @@ -334,11 +360,11 @@ Display completion: ``` Show: -- Mode ran (new or merge) +- Mode ran (new, new (intel only), or merge) - Docs ingested (count + type breakdown) - Decisions locked, requirements created, constraints captured - Conflict report path (`.planning/INGEST-CONFLICTS.md`) -- Next step: `/gsd:plan-phase 1` (new mode) or `/gsd:plan-phase N` (merge, pointing at the first newly-added phase) +- Next step: `/gsd:plan-phase 1` (new) or `/gsd:plan-phase N` (merge, pointing at the first newly-added phase); for intel-only runs there is no roadmap yet — point at `/gsd:new-project` (or a later re-run with `--mode merge` once scaffold files exist), never `/gsd:plan-phase` diff --git a/tests/ingest-docs.test.cjs b/tests/ingest-docs.test.cjs index 209f07ddd..08f2ae8ff 100644 --- a/tests/ingest-docs.test.cjs +++ b/tests/ingest-docs.test.cjs @@ -188,6 +188,58 @@ describe('ingest-docs workflow content', () => { ); }); + test('#3827 gate: new mode requires a routing approval BEFORE the roadmapper delegation', () => { + // The gate text and the delegation must both exist, and the gate must + // come first — an approval gate placed after the subagent call would + // authorize nothing. + const gateIdx = content.indexOf('Routing — create the planning setup now?'); + const delegateIdx = content.indexOf('subagent_type: "gsd-roadmapper"'); + assert.ok(gateIdx !== -1, 'new mode must display a routing gate question before delegating'); + assert.ok(delegateIdx !== -1, 'new mode must still delegate to gsd-roadmapper'); + assert.ok(gateIdx < delegateIdx, 'the routing gate must precede the roadmapper delegation'); + + // The gate display must show the user exactly what will be written + // (#3827: the classification approval must not also authorize scaffold + // creation). Window is the DISPLAY BLOCK only (gate question → the + // AskUserQuestion spec), so the assertions can't pass off text from the + // option-disposition branches below. + const askIdx = content.indexOf('Use `AskUserQuestion`', gateIdx); + assert.ok(askIdx !== -1 && askIdx < delegateIdx, 'gate must specify its AskUserQuestion contract'); + const displayBlock = content.slice(gateIdx, askIdx); + for (const dest of ['.planning/PROJECT.md', '.planning/REQUIREMENTS.md', '.planning/ROADMAP.md', '.planning/STATE.md']) { + assert.ok( + displayBlock.includes(dest), + `routing gate display block must name destination file ${dest}` + ); + } + + // Three-way choice, including the analysis-only exit the issue asks for. + const gateWindow = content.slice(gateIdx, delegateIdx); + assert.ok(gateWindow.includes('Create planning setup'), 'gate must offer to create the scaffold'); + assert.ok(gateWindow.includes('Keep synthesized intel only'), 'gate must offer an analysis-only path'); + assert.ok(gateWindow.includes('Abort'), 'gate must offer abort'); + // "Keep intel only" must skip the roadmapper (no scaffold writes). + const keepIdx = content.indexOf('Keep synthesized intel only'); + assert.ok( + keepIdx !== -1 && content.slice(keepIdx, delegateIdx).includes('Do NOT invoke'), + 'the keep-intel-only branch must explicitly skip the roadmapper delegation' + ); + }); + + test('#3827 gate: zero-conflict branch routes to the gate, never silently into writes', () => { + const zeroIdx = content.indexOf('If BLOCKERS = 0 and WARNINGS = 0'); + assert.ok(zeroIdx !== -1, 'workflow must keep the zero-conflict branch'); + const zeroWindow = content.slice(zeroIdx, zeroIdx + 400); + assert.ok( + !zeroWindow.toLowerCase().includes('silently'), + 'the zero-conflict branch must not authorize silent routing; it hands control to the routing gate' + ); + assert.ok( + zeroWindow.includes('proceed to the routing gate'), + 'zero-conflict branch must hand control to the routing gate' + ); + }); + test('rejects --resolve interactive in v1', () => { const lower = content.toLowerCase(); assert.ok(