From 647365faf170f27edfcde8911641ff17855226e4 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 2 Sep 2026 04:24:52 -0400 Subject: [PATCH] fix(#4011): key the TDD runtime gate on TDD_MODE alone (#4180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(#4011): TDD gate keys on TDD_MODE alone, not the MVP intersection Contract updates: no shipped line may conjoin MVP_MODE with TDD_MODE as a gate condition, the end-of-phase escalation must not require MVP, the executor agent's gate section triggers on TDD_MODE alone, and the gate semantics reference loads without MVP_MODE. * fix(#4011): key the TDD runtime gate on TDD_MODE alone The RED-commit gate shipped as #76's MVP slice kept the paired invocation's conjunct, so workflow.tdd_mode=true was silently inert on every non-MVP phase, contradicting references/tdd.md's own contract. Drops the MVP conjunct from the per-task gate and the end-of-phase review escalation; rescopes execute-mvp-tdd.md's load condition, gsd-executor's gate section, and mvp-concepts' intersection claim. MVP remains free to imply TDD; the file is not renamed (stated assumption in the PR body). * test(#4011): scope no-conjunct detector to shell conditions; clean stale MVP+TDD phrasing Review follow-ups: the detector now only inspects if/[ condition lines so explanatory prose mentioning both flags cannot trip it; remaining 'under/outside MVP+TDD' phrases in execute-phase.md, the gate reference, and docs/INVENTORY.md now describe TDD-mode semantics. Emitted-Drift-Ack-Growth: execute-phase.md — TDD-gate decoupling comment + escalation rescoping (#4011) Emitted-Drift-Ack-Growth: gsd-executor.md — gate section trigger rescoped to TDD_MODE alone (#4011) * chore(#4011): backfill changeset pr number --------- Co-authored-by: sim --- .changeset/clever-elks-sing.md | 5 ++++ agents/gsd-executor.md | 2 +- docs/INVENTORY.md | 2 +- gsd-core/references/execute-mvp-tdd.md | 19 ++++++------- gsd-core/references/mvp-concepts.md | 4 +-- gsd-core/workflows/execute-phase.md | 14 ++++----- tests/execute-mvp-tdd-gate.test.cjs | 38 ++++++++++++++++++++----- tests/executor-mvp-tdd-section.test.cjs | 12 ++++++-- 8 files changed, 66 insertions(+), 30 deletions(-) create mode 100644 .changeset/clever-elks-sing.md diff --git a/.changeset/clever-elks-sing.md b/.changeset/clever-elks-sing.md new file mode 100644 index 000000000..18f9b188c --- /dev/null +++ b/.changeset/clever-elks-sing.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4180 +--- +**`workflow.tdd_mode: true` now actually enforces TDD** — the RED-commit runtime gate no longer requires MVP mode, so the obvious TDD opt-in stops being silently inert on non-MVP phases; the end-of-phase TDD review escalation follows the same decoupling. (#4011) diff --git a/agents/gsd-executor.md b/agents/gsd-executor.md index b7794a979..ae65a855e 100644 --- a/agents/gsd-executor.md +++ b/agents/gsd-executor.md @@ -423,7 +423,7 @@ If RED or GREEN gate commits are missing, add a warning to SUMMARY.md under a `# ## MVP+TDD Gate -**When the orchestrator passes both `MVP_MODE=true` and `TDD_MODE=true`:** Before running the implementation step of any task with `tdd="true"`, run the runtime gate from `~/.claude/gsd-core/references/execute-mvp-tdd.md` (Read it). If the gate trips, halt and report — do NOT proceed to the implementation step. +**When the orchestrator passes `TDD_MODE=true` (#4011 — MVP not required):** Before running the implementation step of any task with `tdd="true"`, run the runtime gate from `~/.claude/gsd-core/references/execute-mvp-tdd.md` (Read it). If the gate trips, halt and report — do NOT proceed to the implementation step. **Halt-and-report protocol:** diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 8a36ccd6c..bd09aa99e 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -381,7 +381,7 @@ Full roster at `gsd-core/references/*.md`. References are shared knowledge docum | `executor-examples.md` | Worked examples for the gsd-executor agent. | | `plan-checker-examples.md` | Worked example for the gsd-plan-checker agent (Scope Exceeded), moved out of the inline `` block to keep worked examples structurally separate from the agent contract, matching the other agents' reference layout. `@`-inlined at load (eager), so this costs ~0.6 KB of runtime context versus keeping it inline — a readability trade, not a size-cap remedy (#3724). | | `doc-conflict-engine.md` | Shared conflict-detection contract for ingest/import workflows. | -| `execute-mvp-tdd.md` | Runtime gate semantics for execute-phase under MVP+TDD — pre-task failing-test verification, end-of-phase blocking review. | +| `execute-mvp-tdd.md` | Runtime gate semantics for execute-phase under TDD mode — pre-task failing-test verification, end-of-phase blocking review. | | `mvp-concepts.md` | Cross-reference index for the six MVP-related reference files; maps each file to its purpose and which workflow loads it. | | `verify-mvp-mode.md` | UAT framing rules for MVP-mode phases — user-flow-first ordering, deferred technical checks, user-story-format guard. | diff --git a/gsd-core/references/execute-mvp-tdd.md b/gsd-core/references/execute-mvp-tdd.md index 0beb9fc81..25bd1118a 100644 --- a/gsd-core/references/execute-mvp-tdd.md +++ b/gsd-core/references/execute-mvp-tdd.md @@ -1,11 +1,10 @@ -# Execute-Phase — MVP+TDD Gate (Runtime Enforcement) +# Execute-Phase — TDD Gate (Runtime Enforcement) -> Loaded by `execute-phase` workflow and `gsd-executor` agent only when **both** `MVP_MODE=true` AND `TDD_MODE=true` for the phase. Defines the runtime gate that blocks behavior-adding tasks until a failing-test commit exists. +> Loaded by `execute-phase` workflow and `gsd-executor` agent when `TDD_MODE=true` for the phase (#4011 — the gate no longer requires MVP mode; MVP may imply TDD, but TDD never requires MVP). Defines the runtime gate that blocks behavior-adding tasks until a failing-test commit exists. ## When this gate fires -- `MVP_MODE` is `true` (resolved from CLI flag → ROADMAP `**Mode:**` field → config; see `gsd-core/references/planner-mvp-mode.md`). -- `TDD_MODE` is `true` (resolved from `--tdd` flag → `workflow.tdd_mode` config). +- `TDD_MODE` is `true` (resolved from `--tdd` flag → `workflow.tdd_mode` config). MVP mode is NOT required (#4011). - The current task being executed has `tdd="true"` in its `` frontmatter (set by the planner per Phase 1). - The task's `` block lists at least one expected behavior. @@ -13,7 +12,7 @@ If any of these is false, the gate is inactive — execution proceeds normally. ## What the gate checks -For each task gated by MVP+TDD, the executor MUST verify (before running the implementation step): +For each task gated by TDD, the executor MUST verify (before running the implementation step): 1. **A failing-test commit exists.** Search git log on the current branch for a commit matching `test({phase}-{plan})` whose subject mentions the same plan as the current task. The commit must touch a test file (`*.test.*`, `*.spec.*`, `tests/**`). 2. **The test was actually red.** The commit message body or the executor's recent shell history must show the test failed when first run. Acceptable evidence: @@ -40,7 +39,7 @@ The executor MUST: 2. Emit a structured halt report: ``` -### MVP+TDD GATE TRIPPED — Plan {plan_id}, Task {task_id} +### TDD GATE TRIPPED — Plan {plan_id}, Task {task_id} Reason: {missing_red_commit | red_commit_not_failing | feat_before_test} @@ -56,14 +55,14 @@ The executor MUST: 3. Exit the current execution wave cleanly. Do NOT roll back any prior commits in the same wave. 4. Update `STATE.md` with `last_gate_trip: {plan_id}/{task_id}` so the user can resume after writing the test. -## Escalation: end-of-phase TDD review under MVP+TDD +## Escalation: end-of-phase TDD review under TDD The existing end-of-phase TDD review (in `workflows/execute-phase.md`'s `tdd_review_checkpoint` step) is normally **advisory** — it surfaces gate violations but does not block phase completion. -Under MVP+TDD, escalate this to **blocking**: +Under TDD mode, escalate this to **blocking**: - If any TDD plan is missing a RED or GREEN commit, the executor MUST refuse to mark the phase complete. - The user is shown the same review table, but the verdict line reads: - > "Phase blocked: {N} TDD plan(s) violate the RED→GREEN gate sequence under MVP+TDD. Resolve and re-run /gsd execute-phase, or override with `/gsd execute-phase {phase} --force-mvp-gate` to ship anyway." + > "Phase blocked: {N} TDD plan(s) violate the RED→GREEN gate sequence under TDD. Resolve and re-run /gsd execute-phase, or override with `/gsd execute-phase {phase} --force-mvp-gate` to ship anyway." The `--force-mvp-gate` flag is documented but not introduced by this plan — it is the escape hatch the spec mentions; if the user later builds it, the workflow already references the contract. @@ -76,4 +75,4 @@ The `--force-mvp-gate` flag is documented but not introduced by this plan — it ## Compatibility with existing TDD discipline -This gate is additive to `gsd-core/references/tdd.md`. Tasks not under MVP+TDD continue to use the existing advisory TDD discipline (RED/GREEN/REFACTOR commits with end-of-phase review checkpoint). Only the runtime gate and the blocking escalation are new. +This gate is additive to `gsd-core/references/tdd.md`. Tasks not under TDD mode continue to use the existing advisory TDD discipline (RED/GREEN/REFACTOR commits with end-of-phase review checkpoint). Only the runtime gate and the blocking escalation are new. diff --git a/gsd-core/references/mvp-concepts.md b/gsd-core/references/mvp-concepts.md index f2eab24eb..fa636ab50 100644 --- a/gsd-core/references/mvp-concepts.md +++ b/gsd-core/references/mvp-concepts.md @@ -12,7 +12,7 @@ Canonical domain terms for the concepts named below live in [CONTEXT.md](../../C | `gsd-core/references/skeleton-template.md` | **Template.** Shape of `SKELETON.md` for new-project Phase 1 under `--mvp`. | `gsd-planner` agent when the Walking Skeleton gate fires | | `gsd-core/references/user-story-template.md` | **Template.** Format and slot definitions for `As a / I want to / So that`. | `gsd-mvp-phase` workflow during interactive prompting; `gsd-planner` when emitting the `## Phase Goal` header | | `gsd-core/references/spidr-splitting.md` | **Splitting discipline.** Five-axis decomposition (Spike, Paths, Interfaces, Data, Rules) for stories too large for one phase. | `gsd-mvp-phase` workflow when the user story exceeds size threshold | -| `gsd-core/references/execute-mvp-tdd.md` | **Gate.** MVP+TDD runtime gate semantics: when it fires, what it checks, halt-and-report protocol, end-of-phase blocking escalation, Behavior-Adding Task definition. | `gsd-executor` agent when `MVP_MODE=true && TDD_MODE=true` | +| `gsd-core/references/execute-mvp-tdd.md` | **Gate.** TDD runtime gate semantics: when it fires, what it checks, halt-and-report protocol, end-of-phase blocking escalation, Behavior-Adding Task definition. | `gsd-executor` agent when `TDD_MODE=true` (#4011) | | `gsd-core/references/verify-mvp-mode.md` | **UAT framing.** Three-section UAT structure (user-flow → technical → coverage), anti-patterns, `User Flow Coverage` section in VERIFICATION.md. | `gsd-verifier` agent when the phase under verification has `mode: mvp` | ## Concept-to-file map @@ -33,7 +33,7 @@ If you're looking for the canonical statement of a concept, this is where to fin - **`--mvp` and `--prd ` together on Phase 1.** Both paths converge at the planner spawn. The PRD express path creates `CONTEXT.md` from the PRD file and continues to the research step; the Walking Skeleton gate fires independently when Phase 1 + new project + `--mvp`. The planner therefore receives both `WALKING_SKELETON=true` and PRD-derived context. This is intentional: the PRD informs what the skeleton should prove. - **`MVP_MODE` is all-or-nothing per phase, not per task.** A phase is either MVP-mode or standard. Mixed-mode phases are not supported (PRD #2826 Q1). -- **`TDD_MODE` is independent of `MVP_MODE`.** TDD can be on without MVP, MVP can be on without TDD. Only the *intersection* (both true) activates the MVP+TDD Gate. +- **`TDD_MODE` is independent of `MVP_MODE`.** TDD can be on without MVP, MVP can be on without TDD. The TDD runtime gate activates on `TDD_MODE` alone (#4011); MVP mode remains free to imply TDD without being required by it. - **The `gsd-roadmapper` agent makes the MVP/standard decision once at project init** based on `PROJECT_MODE`. Per-phase opt-in/out happens later via `/gsd:mvp-phase` or `/gsd-edit-phase`. ## Tests diff --git a/gsd-core/workflows/execute-phase.md b/gsd-core/workflows/execute-phase.md index d5828be01..02b716dc9 100644 --- a/gsd-core/workflows/execute-phase.md +++ b/gsd-core/workflows/execute-phase.md @@ -194,15 +194,15 @@ Offer these recovery options: - `mark-and-skip` — record the anomaly and move on only with explicit confirmation. -**MVP+TDD gate.** Task-scoped enforcement runs inside plan execution (immediately before each implementation step), where `TASK_FILE`, `PLAN_ID`, and `TASK_ID` are defined. Keep the same predicate and RED-commit contract: +**TDD gate.** Task-scoped enforcement runs inside plan execution (immediately before each implementation step), where `TASK_FILE`, `PLAN_ID`, and `TASK_ID` are defined. #4011: the gate keys on `TDD_MODE` ALONE — a discipline gate coupled to the product-scope `MVP_MODE` flag was silently inert on every non-MVP phase, contradicting `gsd-core/references/tdd.md`'s contract that `workflow.tdd_mode` binds for all `type: tdd` plans. MVP mode remains free to imply TDD; it is no longer required by it. Keep the same predicate and RED-commit contract: ```bash -if [ "$MVP_MODE" = "true" ] && [ "$TDD_MODE" = "true" ]; then +if [ "$TDD_MODE" = "true" ]; then IS_BEHAVIOR_ADDING=$(gsd_run query task.is-behavior-adding "$TASK_FILE" --pick is_behavior_adding) if [ "$IS_BEHAVIOR_ADDING" = "true" ]; then RED_COMMIT=$(git log --oneline --grep="^test(${PHASE_NUMBER}-${PLAN_ID}):" -- "**/*.test.*" "**/*.spec.*" "tests/" | head -1) if [ -z "$RED_COMMIT" ]; then gsd_run query state.update last_gate_trip "${PLAN_ID}/${TASK_ID}" || true - echo "MVP+TDD GATE TRIPPED: missing RED commit for ${PLAN_ID}/${TASK_ID}" + echo "TDD GATE TRIPPED: missing RED commit for ${PLAN_ID}/${TASK_ID}" exit 1 fi fi @@ -1245,16 +1245,16 @@ CHECK_EXIT=$? **Gate evaluation** uses the same two-step contract as `execute:wave:post` above. -**TDD review escalation (overrides the advisory default for the `tdd.review-checkpoint` gate only).** The tdd `execute:post` gate is declared `blocking: false`, so by the generic contract above it displays its `message`/table and continues. There is ONE documented exception (see `~/.claude/gsd-core/references/execute-mvp-tdd.md`): when `MVP_MODE=true` AND `TDD_MODE=true` AND `GATE_RESULT.block == true` (one or more TDD plans miss a RED or GREEN gate commit), the end-of-phase TDD review escalates from advisory to **blocking under MVP+TDD** — refuse to mark the phase complete and present: +**TDD review escalation (overrides the advisory default for the `tdd.review-checkpoint` gate only).** The tdd `execute:post` gate is declared `blocking: false`, so by the generic contract above it displays its `message`/table and continues. There is ONE documented exception (see `~/.claude/gsd-core/references/execute-mvp-tdd.md`): when `TDD_MODE=true` AND `GATE_RESULT.block == true` (one or more TDD plans miss a RED or GREEN gate commit; #4011 — no MVP condition), the end-of-phase TDD review escalates from advisory to **blocking under TDD** — refuse to mark the phase complete and present: ``` -Phase blocked: {N} TDD plan(s) violate the RED→GREEN gate sequence under MVP+TDD. +Phase blocked: {N} TDD plan(s) violate the RED→GREEN gate sequence under TDD. Resolve and re-run /gsd execute-phase, or override with /gsd execute-phase {phase} --force-mvp-gate to ship anyway. ``` -(`--force-mvp-gate` is the documented, not-yet-implemented escape hatch.) Outside MVP+TDD, TDD-review violations remain advisory (table shown, execution continues). +(`--force-mvp-gate` is the documented, not-yet-implemented escape hatch.) Outside TDD mode, TDD-review violations remain advisory (table shown, execution continues). -**Proceed rule:** If `MVP_MODE && TDD_MODE && GATE_RESULT.block == true` for `tdd.review-checkpoint`: STOP — do NOT proceed to `close_parent_artifacts`, `regression_gate`, `verify_phase_goal`, or `phase.complete`. Otherwise proceed normally. +**Proceed rule:** If `TDD_MODE && GATE_RESULT.block == true` for `tdd.review-checkpoint`: STOP — do NOT proceed to `close_parent_artifacts`, `regression_gate`, `verify_phase_goal`, or `phase.complete`. Otherwise proceed normally. diff --git a/tests/execute-mvp-tdd-gate.test.cjs b/tests/execute-mvp-tdd-gate.test.cjs index e3c3156ed..a7a9d02c9 100644 --- a/tests/execute-mvp-tdd-gate.test.cjs +++ b/tests/execute-mvp-tdd-gate.test.cjs @@ -34,14 +34,21 @@ function parseGateContract(content) { return { hasMvpModeVariable: lowerLines.some(line => line.includes('mvp_mode')), hasRoadmapModeResolution: lowerLines.some(line => line.includes('phase.mvp-mode') || line.includes('roadmap') && line.includes('mode')), - hasDualGateCondition: lowerLines.some(line => line.includes('mvp_mode') && line.includes('tdd_mode')), - hasGateLabel: lowerLines.some(line => line.includes('mvp+tdd gate') || line.includes('mvp-tdd gate')), + // #4011: the gate keys on TDD_MODE ALONE. Any line conjoining MVP_MODE and + // TDD_MODE re-couples a discipline gate to a product-scope flag and makes + // workflow.tdd_mode=true silently inert on non-MVP phases. + hasTddOnlyGateCondition: lines.some(line => /\$TDD_MODE.{0,40}=.{0,20}"true"/.test(line) && !/MVP_MODE/.test(line)), + // Scoped to shell-condition lines only — explanatory prose legitimately + // mentions both flags on one line. + hasNoMvpTddConjunct: !lines.some(line => + (/if \[|\] &&|&& \[/.test(line)) && /MVP_MODE/.test(line) && /TDD_MODE/.test(line)), + hasTddOnlyEscalation: !/mvp_mode\s*(=|&&|and)/i.test((content.split('tdd review escalation')[1] || '') ), + hasGateLabel: lowerLines.some(line => line.includes('mvp+tdd gate') || line.includes('mvp-tdd gate') || line.includes('tdd gate')), hasRedCommitRule: lowerLines.some(line => line.includes('failing-test commit') || line.includes('missing red commit') || line.includes('test(')), // Must assert the REAL refusal semantics, not merely the words "blocking" + "mvp+tdd" // (which "advisory (blocking: false) ... under MVP+TDD" would satisfy as a false green). hasBlockingEscalation: - content.toLowerCase().includes('mvp+tdd') - && (content.toLowerCase().includes('refuse to mark the phase complete') + (content.toLowerCase().includes('refuse to mark the phase complete') || content.toLowerCase().includes('phase blocked')), hasReferenceDoc: lowerLines.some(line => line.includes('execute-mvp-tdd.md')), // Must NOT have an unconditional "proceed regardless of gate results" that overrides the block. @@ -58,8 +65,10 @@ describe('execute-phase — MVP+TDD gate', () => { assert.ok(contract.hasRoadmapModeResolution, 'must consult phase mode from roadmap'); }); - test('gate fires when both MVP_MODE and TDD_MODE are true', () => { - assert.ok(contract.hasDualGateCondition, 'workflow must combine MVP_MODE and TDD_MODE for the gate'); + test('gate fires on TDD_MODE alone — no MVP conjunct (#4011)', () => { + assert.ok(contract.hasTddOnlyGateCondition, 'the per-task gate must key on TDD_MODE alone'); + assert.ok(contract.hasNoMvpTddConjunct, + 'no shipped line may conjoin MVP_MODE with TDD_MODE as a gate condition — workflow.tdd_mode=true must enforce TDD on non-MVP phases too (#4011)'); }); test('per-task gate is documented before behavior-adding task execution', () => { @@ -67,8 +76,9 @@ describe('execute-phase — MVP+TDD gate', () => { assert.ok(contract.hasRedCommitRule, 'must reference failing-test commit check'); }); - test('end-of-phase TDD review escalates to blocking under MVP+TDD', () => { + test('end-of-phase TDD review escalates to blocking under TDD (#4011)', () => { assert.ok(contract.hasBlockingEscalation, 'must escalate end-of-phase review to blocking'); + assert.ok(contract.hasTddOnlyEscalation, 'the escalation must not require MVP_MODE (#4011)'); }); test('proceed past TDD escalation is conditional — not an unconditional override', () => { @@ -115,3 +125,17 @@ describe('execute-phase MVP+TDD — resolution chain integration', () => { } }); }); + +// ─── #4011: the gate semantics reference loads on TDD_MODE alone ───────────── + +describe('execute-mvp-tdd.md — load condition decoupled from MVP (#4011)', () => { + const REF = path.join(__dirname, '..', 'gsd-core', 'references', 'execute-mvp-tdd.md'); + const content = fs.readFileSync(REF, 'utf-8'); + + test('reference no longer requires MVP_MODE to load', () => { + const head = content.slice(0, 1200); + assert.ok(!/MVP_MODE\s*=\s*true\s*(AND|&&|and)\s*TDD_MODE|both.{0,20}MVP_MODE\s*=\s*true\s*AND/i.test(head), + 'execute-mvp-tdd.md must load on TDD_MODE alone (#4011)'); + assert.ok(/TDD_MODE\s*=\s*true/i.test(head), 'load condition must still name TDD_MODE'); + }); +}); diff --git a/tests/executor-mvp-tdd-section.test.cjs b/tests/executor-mvp-tdd-section.test.cjs index bd3fabbb5..c3c69338c 100644 --- a/tests/executor-mvp-tdd-section.test.cjs +++ b/tests/executor-mvp-tdd-section.test.cjs @@ -14,8 +14,16 @@ const REF = path.join(__dirname, '..', 'gsd-core', 'references', 'execute-mvp-td describe('gsd-executor — MVP+TDD gate section', () => { const content = fs.readFileSync(AGENT, 'utf-8'); - test('agent defines an MVP+TDD Gate section', () => { - assert.match(content, /MVP\+TDD\s*Gate|MVP[\s-]?TDD[\s-]?gate/i, 'must label the gate'); + test('agent defines a TDD Gate section keyed on TDD_MODE alone (#4011)', () => { + assert.match(content, /MVP\+TDD\s*Gate|MVP[\s-]?TDD[\s-]?gate|TDD\s*Gate/i, 'must label the gate'); + // The gate's trigger must not require MVP_MODE (#4011): a discipline gate + // keyed to a product-scope flag is silently inert on non-MVP phases. + const gateSection = content.slice( + content.search(/## (?:MVP\+TDD )?TDD Gate/i), + content.indexOf('##', content.search(/## (?:MVP\+TDD )?TDD Gate/i) + 3), + ); + assert.ok(!/MVP_MODE\s*=\s*"?"?true"?.{0,80}TDD_MODE|both .MVP_MODE.= true and .TDD_MODE.= true/i.test(gateSection), + 'the executor gate section must trigger on TDD_MODE alone, not the MVP intersection (#4011)'); }); test('agent instructs halt-and-report when gate trips', () => {