fix(#3827): new-mode routing gate — classify approval no longer authorizes scaffold writes (#4037)

* 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 <sim@local>
This commit is contained in:
Tom Boucher
2026-08-29 09:41:00 -04:00
committed by GitHub
parent 331747ea99
commit 3c08315a5e
3 changed files with 86 additions and 3 deletions

View File

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

View File

@@ -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.
</step>
@@ -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`
</step>

View File

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