From b79b767629a3da923600d39123ab22756c48b20d Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 6 Jun 2026 16:27:05 -0400 Subject: [PATCH 1/2] refactor(bin): replace process.exit() in CLI entrypoints + ratchet rule to error (#738) (#741) Part 2 of 2 of the n/no-process-exit cleanup (completes umbrella #738; part 1 was #739/scripts). Converts the 20 flagged process.exit() calls in the three hand-written gsd-core/bin CLI entrypoints and flips n/no-process-exit to error. - New src/cli-exit.cts -> gsd-core/bin/lib/cli-exit.cjs (ExitError + runMain), the gsd-core-side equivalent of scripts/lib/cli-exit.cjs; registered in .gitignore, eslint ignores, and the inventory manifest like its siblings. - gsd-tools.cjs: 13 apply-prompt-budget exits -> throw ExitError; main()->runMain. - verify-reapply-patches.cjs: 6 exits -> throw ExitError / return verdict; runMain. - check-latest-version.cjs: 1 exit -> return verdict; runMain. - eslint.config.mjs: n/no-process-exit warn -> error. Scope note: the gsd-core/bin/lib/*.cjs modules (core, state, profile-pipeline, roadmap-command-router, adr-parser, ui-safety-gate) are tsc-generated and eslint-ignored (ADR-457), so their process.exit calls were never flagged and are intentionally left untouched. Only the linted hand-written entrypoints are in scope. Exit codes verified unchanged for all three entrypoints. Closes #738 Co-authored-by: Claude Opus 4.8 --- .gitignore | 1 + docs/INVENTORY-MANIFEST.json | 3 +- docs/INVENTORY.md | 3 +- eslint.config.mjs | 3 +- gsd-core/bin/check-latest-version.cjs | 5 +-- gsd-core/bin/gsd-tools.cjs | 41 +++++++++---------------- gsd-core/bin/verify-reapply-patches.cjs | 19 +++++------- src/cli-exit.cts | 41 +++++++++++++++++++++++++ 8 files changed, 74 insertions(+), 42 deletions(-) create mode 100644 src/cli-exit.cts diff --git a/.gitignore b/.gitignore index 6059a5dba..fbaf91d64 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,7 @@ build/ /gsd-core/bin/lib/package-legitimacy.cjs /gsd-core/bin/lib/semver-compare.cjs /gsd-core/bin/lib/config-types.cjs +/gsd-core/bin/lib/cli-exit.cjs /gsd-core/bin/lib/code-review-flags.cjs /gsd-core/bin/lib/context-utilization.cjs /gsd-core/bin/lib/artifacts.cjs diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index ba8f4efdb..4abfd5dcb 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -1,5 +1,5 @@ { - "generated": "2026-06-05", + "generated": "2026-06-06", "families": { "agents": [ "gsd-advisor-researcher", @@ -272,6 +272,7 @@ "audit.cjs", "check-command-router.cjs", "cjs-command-router-adapter.cjs", + "cli-exit.cjs", "clock.cjs", "clusters.cjs", "code-review-flags.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 78e5dca6c..e346ed83a 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -370,7 +370,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (85 shipped) +## CLI Modules (86 shipped) Full listing: `gsd-core/bin/lib/*.cjs`. @@ -382,6 +382,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `artifacts.cjs` | Canonical artifact registry — known `.planning/` root file names; used by `gsd-health` W019 lint | | `audit.cjs` | Audit dispatch, audit open sessions, audit storage helpers | | `check-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools check` | +| `cli-exit.cjs` | `ExitError` class and `runMain()` helper — CLI entrypoints throw `ExitError` instead of calling `process.exit()`; `runMain()` translates the outcome into `process.exitCode` so output flushes cleanly | | `cjs-command-router-adapter.cjs` | Shared compatibility adapter for manifest-backed CJS command-family routers | | `clock.cjs` | Injectable clock seam (now/sleep) for deterministic lock testing | | `clusters.cjs` | Skill cluster definitions for the runtime surface module (ADR-0011 Phase 2) | diff --git a/eslint.config.mjs b/eslint.config.mjs index 52e045fe2..ccd7a87ca 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -35,6 +35,7 @@ export default tseslint.config( '**/*.generated.cjs', // ADR-457: tsc-generated runtime artifact — lint the src/*.cts source, not the emitted .cjs. 'gsd-core/bin/lib/semver-compare.cjs', + 'gsd-core/bin/lib/cli-exit.cjs', 'gsd-core/bin/lib/code-review-flags.cjs', 'gsd-core/bin/lib/context-utilization.cjs', 'gsd-core/bin/lib/artifacts.cjs', @@ -174,7 +175,7 @@ export default tseslint.config( 'no-useless-escape': 'warn', 'no-unsafe-finally': 'warn', // eslint-plugin-n rules - 'n/no-process-exit': 'warn', + 'n/no-process-exit': 'error', // Local rules — warn for now; flip to error after cleanup phases 'local/no-source-grep': 'warn', }, diff --git a/gsd-core/bin/check-latest-version.cjs b/gsd-core/bin/check-latest-version.cjs index 59fdbca70..d5f889e53 100755 --- a/gsd-core/bin/check-latest-version.cjs +++ b/gsd-core/bin/check-latest-version.cjs @@ -21,6 +21,7 @@ */ const { execNpm } = require('./lib/shell-command-projection.cjs'); +const { runMain } = require('./lib/cli-exit.cjs'); // Sourced from the single Package Identity seam (#498), not re-typed. The seam // bakes the value from package.json at build time, so it is a code constant — @@ -98,9 +99,9 @@ function main() { } else { process.stderr.write(`check-latest-version: ${r.reason}: ${r.detail}\n`); } - process.exit(r.ok ? 0 : 1); + return r.ok ? 0 : 1; } -if (require.main === module) main(); +if (require.main === module) runMain(main); module.exports = { checkLatestVersion, CHECK_REASON, PACKAGE_NAME }; diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index 7e33347e6..d12f9e69c 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -170,6 +170,7 @@ const fs = require('fs'); const path = require('path'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); const core = require('./lib/core.cjs'); const { error, ERROR_REASON } = core; // Resolve findProjectRoot lazily at call time rather than binding it at module @@ -1527,33 +1528,26 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand // ── Validate required args ───────────────────────────────────────── if (!budgetStr) { - process.stderr.write('Error: --budget is required\n'); - process.exit(1); + throw new ExitError(1, 'Error: --budget is required'); } const budget = parseInt(budgetStr, 10); if (!Number.isFinite(budget) || budget <= 0) { - process.stderr.write('Error: --budget must be a positive integer\n'); - process.exit(1); + throw new ExitError(1, 'Error: --budget must be a positive integer'); } if (!instructionsFile) { - process.stderr.write('Error: --instructions-file is required\n'); - process.exit(1); + throw new ExitError(1, 'Error: --instructions-file is required'); } if (!roadmapFile) { - process.stderr.write('Error: --roadmap-file is required\n'); - process.exit(1); + throw new ExitError(1, 'Error: --roadmap-file is required'); } if (planFiles.length === 0) { - process.stderr.write('Error: at least one --plan-file is required\n'); - process.exit(1); + throw new ExitError(1, 'Error: at least one --plan-file is required'); } if (!outputPromptFile) { - process.stderr.write('Error: --output-prompt is required\n'); - process.exit(1); + throw new ExitError(1, 'Error: --output-prompt is required'); } if (!outputMetadataFile) { - process.stderr.write('Error: --output-metadata is required\n'); - process.exit(1); + throw new ExitError(1, 'Error: --output-metadata is required'); } // ── Validate and read required files ────────────────────────────── @@ -1563,11 +1557,9 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand return await fs.promises.readFile(resolved, 'utf8'); } catch (err) { if (err && err.code === 'ENOENT') { - process.stderr.write(`Error: file not found for ${flagName}: ${resolved}\n`); - process.exit(1); + throw new ExitError(1, `Error: file not found for ${flagName}: ${resolved}`); } - process.stderr.write(`Error: cannot read file for ${flagName}: ${resolved}\n`); - process.exit(1); + throw new ExitError(1, `Error: cannot read file for ${flagName}: ${resolved}`); } } @@ -1578,8 +1570,7 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand return await fs.promises.readFile(resolved, 'utf8'); } catch (err) { if (err && err.code === 'ENOENT') return null; - process.stderr.write(`Error: cannot read optional file: ${resolved}\n`); - process.exit(1); + throw new ExitError(1, `Error: cannot read optional file: ${resolved}`); } } @@ -1592,11 +1583,9 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand return { file: path.basename(p), content }; } catch (err) { if (err && err.code === 'ENOENT') { - process.stderr.write(`Error: plan file not found: ${resolved}\n`); - process.exit(1); + throw new ExitError(1, `Error: plan file not found: ${resolved}`); } - process.stderr.write(`Error: cannot read plan file: ${resolved}\n`); - process.exit(1); + throw new ExitError(1, `Error: cannot read plan file: ${resolved}`); } })); @@ -1625,7 +1614,7 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand await fs.promises.writeFile(path.resolve(outputPromptFile), prompt); if (metadata.hardFailed) { - process.exit(2); + throw new ExitError(2); } break; } @@ -1895,4 +1884,4 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand } } -main(); +runMain(main); diff --git a/gsd-core/bin/verify-reapply-patches.cjs b/gsd-core/bin/verify-reapply-patches.cjs index 2bd44d696..79e7965bb 100755 --- a/gsd-core/bin/verify-reapply-patches.cjs +++ b/gsd-core/bin/verify-reapply-patches.cjs @@ -32,6 +32,7 @@ const fs = require('node:fs'); const path = require('node:path'); const crypto = require('node:crypto'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); const SIGNIFICANT_MIN_CHARS = 12; const GSD_HOOK_VERSION_LINE_RE = /^(?:\/\/|#)\s*gsd-hook-version:\s*\S+\s*$/i; @@ -48,10 +49,9 @@ function parseArgs(argv) { process.stdout.write( 'usage: verify-reapply-patches.cjs --patches-dir --config-dir [--pristine-dir ] [--json]\n', ); - process.exit(0); + throw new ExitError(0); } else { - process.stderr.write(`unknown argument: ${arg}\n`); - process.exit(2); + throw new ExitError(2, `unknown argument: ${arg}`); } } return opts; @@ -281,16 +281,13 @@ function verifyFile({ relPath, patchesDir, configDir, pristineDir, pristineHashe function main() { const opts = parseArgs(process.argv.slice(2)); if (!opts.patchesDir || !opts.configDir) { - process.stderr.write('--patches-dir and --config-dir are required\n'); - process.exit(2); + throw new ExitError(2, '--patches-dir and --config-dir are required'); } if (!fs.existsSync(opts.patchesDir)) { - process.stderr.write(`patches dir not found: ${opts.patchesDir}\n`); - process.exit(2); + throw new ExitError(2, `patches dir not found: ${opts.patchesDir}`); } if (!fs.existsSync(opts.configDir)) { - process.stderr.write(`config dir not found: ${opts.configDir}\n`); - process.exit(2); + throw new ExitError(2, `config dir not found: ${opts.configDir}`); } const files = walk(opts.patchesDir).filter((f) => !f.endsWith('backup-meta.json')); @@ -342,11 +339,11 @@ function main() { } } - process.exit(failures.length > 0 ? 1 : 0); + return failures.length > 0 ? 1 : 0; } if (require.main === module) { - main(); + runMain(main); } module.exports = { computeUserAddedLines, isSignificantLine, verifyFile, walk, REASON, readPristineHashes, sha256 }; diff --git a/src/cli-exit.cts b/src/cli-exit.cts new file mode 100644 index 000000000..7e4a0afda --- /dev/null +++ b/src/cli-exit.cts @@ -0,0 +1,41 @@ +/** + * Error carrying a process exit code. CLI logic throws this instead of calling + * process.exit() (banned by n/no-process-exit); runMain() translates it into + * process.exitCode at the entrypoint. + */ +class ExitError extends Error { + code: number; + hasUserMessage: boolean; + constructor(code = 1, message?: string) { + super(message === undefined ? `process exit ${code}` : message); + this.name = 'ExitError'; + this.code = code; + this.hasUserMessage = message !== undefined; + } +} + +/** + * Run a CLI main and translate its outcome into process.exitCode (never + * process.exit, so n/no-process-exit stays satisfied; output flushes and + * process.on('exit') cleanup still fires). main may be sync or async: + * number return -> process.exitCode = it + * thrown ExitError -> process.exitCode = err.code (+ stderr err.message if hasUserMessage && code!=0) + * other throw -> stderr stack + process.exitCode = 1 + */ +function runMain(main: () => number | void | Promise): void { + Promise.resolve() + .then(() => main()) + .then((code) => { if (typeof code === 'number') process.exitCode = code; }) + .catch((err: unknown) => { + if (err instanceof ExitError) { + if (err.hasUserMessage && err.code !== 0) process.stderr.write(`${err.message}\n`); + process.exitCode = err.code; + return; + } + const e = err as Error; + process.stderr.write(`${e && e.stack ? e.stack : String(err)}\n`); + process.exitCode = 1; + }); +} + +export = { ExitError, runMain }; From 1bea220d586a65fd15a8e32b3e263697cc0e3da1 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 6 Jun 2026 18:23:17 -0400 Subject: [PATCH 2/2] refactor(#720): lazy-load MVP-only reference bodies on non-MVP runs (#746) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(#720): lazy-load MVP-only reference bodies (eager @-import → gated Read) Convert eager @-imports of MVP-only reference bodies into lazy "Read" instructions gated on MVP_MODE / WALKING_SKELETON / MVP+TDD, so non-MVP planning/execution runs no longer pull MVP guidance into context. Covers both the workflow files and the planner/executor agent definitions (the dominant context-cost path): - workflows/plan-phase.md: planner-mvp-mode.md + skeleton-template.md (L146/936/937/941) - workflows/execute-phase.md: execute-mvp-tdd.md halt-report ref, now gated on gate-trip (L191) - agents/gsd-planner.md: planner-mvp-mode.md, user-story-template.md, skeleton-template.md - agents/gsd-executor.md: execute-mvp-tdd.md The dedicated always-MVP mvp-phase workflow keeps its eager imports (intentional). Behaviour is unchanged; non-MVP runs simply carry less loaded context. Adds a regression guard mirroring the discuss-phase lazy-load test, and documents the conformance in docs/ARCHITECTURE.md. Refs #720 Co-Authored-By: Claude Opus 4.8 * docs(#720): add changeset fragment (pr #746) Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../lazy-mvp-bodies-progressive-disclosure.md | 5 + agents/gsd-executor.md | 2 +- agents/gsd-planner.md | 6 +- docs/ARCHITECTURE.md | 10 ++ gsd-core/workflows/execute-phase.md | 2 +- gsd-core/workflows/plan-phase.md | 8 +- tests/workflow-size-budget.test.cjs | 147 ++++++++++++++++++ 7 files changed, 171 insertions(+), 9 deletions(-) create mode 100644 .changeset/lazy-mvp-bodies-progressive-disclosure.md diff --git a/.changeset/lazy-mvp-bodies-progressive-disclosure.md b/.changeset/lazy-mvp-bodies-progressive-disclosure.md new file mode 100644 index 000000000..2dad6c698 --- /dev/null +++ b/.changeset/lazy-mvp-bodies-progressive-disclosure.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 746 +--- +**`/gsd:plan-phase` and `/gsd:execute-phase` no longer eagerly load MVP-only guidance on non-MVP runs** — the MVP planner rules, user-story template, Walking-Skeleton template, and MVP+TDD halt-report reference are now Read lazily by the planner/executor only when MVP / Walking-Skeleton / MVP+TDD mode is active, in both the workflow files and the `gsd-planner`/`gsd-executor` agent definitions, instead of being `@`-imported into every run. Behaviour is unchanged; non-MVP planning/execution simply carries less context. (#720) diff --git a/agents/gsd-executor.md b/agents/gsd-executor.md index 1deac8ca3..2a70dbea5 100644 --- a/agents/gsd-executor.md +++ b/agents/gsd-executor.md @@ -385,7 +385,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`. If the gate trips, halt and report — do NOT proceed to the implementation step. +**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. **Halt-and-report protocol:** diff --git a/agents/gsd-planner.md b/agents/gsd-planner.md index 2a6a9aaac..6eeabea88 100644 --- a/agents/gsd-planner.md +++ b/agents/gsd-planner.md @@ -309,7 +309,7 @@ Exceptions where `tdd="true"` is not needed: `type="checkpoint:*"` tasks, config ## MVP Mode Detection -**When `MVP_MODE` is enabled (passed by the plan-phase orchestrator):** Decompose tasks as **vertical feature slices**, not horizontal layers. Required reading: `@~/.claude/gsd-core/references/planner-mvp-mode.md` (loaded conditionally by the orchestrator). +**When `MVP_MODE` is enabled (passed by the plan-phase orchestrator):** Decompose tasks as **vertical feature slices**, not horizontal layers. Required reading: Read `~/.claude/gsd-core/references/planner-mvp-mode.md` for the vertical-slice rules (lazy — only on MVP runs). **Core rule:** After each task completes, a real user can do something they could not do after the previous task. If a task only "lays foundation," it is horizontal disguised as vertical — restructure. @@ -323,7 +323,7 @@ Exceptions where `tdd="true"` is not needed: `type="checkpoint:*"` tasks, config **As a** [user role], **I want to** [capability], **so that** [outcome]. ``` - Format rules from `@~/.claude/gsd-core/references/user-story-template.md`: + Format rules (Read `~/.claude/gsd-core/references/user-story-template.md`): - All three slots required. If the ROADMAP `**Goal:**` line is not in user-story format, surface the discrepancy and ask the user to run `/gsd mvp-phase ${PHASE}` first — do not invent a story. - Bold the three keywords (`**As a**`, `**I want to**`, `**so that**`) when emitting to PLAN.md. The ROADMAP form does not use bolded keywords; the PLAN form does. 2. First task: failing end-to-end test for the happy path. @@ -332,7 +332,7 @@ Exceptions where `tdd="true"` is not needed: `type="checkpoint:*"` tasks, config **Mode is all-or-nothing per phase** (PRD decision Q1). Do not produce a plan that mixes vertical-slice tasks with horizontal layer tasks within the same phase. -**Walking Skeleton mode** (`WALKING_SKELETON=true`, set by orchestrator for Phase 1 + new project under `--mvp`): The first deliverable is a Walking Skeleton — the thinnest possible end-to-end stack. In addition to `PLAN.md`, produce `SKELETON.md` using the template at `@~/.claude/gsd-core/references/skeleton-template.md`. `SKELETON.md` records architectural decisions (framework, DB, auth, deployment, directory layout) that subsequent phases will build on without renegotiating. +**Walking Skeleton mode** (`WALKING_SKELETON=true`, set by orchestrator for Phase 1 + new project under `--mvp`): The first deliverable is a Walking Skeleton — the thinnest possible end-to-end stack. In addition to `PLAN.md`, produce `SKELETON.md` using the template at `~/.claude/gsd-core/references/skeleton-template.md` (Read it now). `SKELETON.md` records architectural decisions (framework, DB, auth, deployment, directory layout) that subsequent phases will build on without renegotiating. **Compatibility with TDD detection:** When both `MVP_MODE=true` and `workflow.tdd_mode=true`, every behavior-adding task uses `tdd="true"` and a `` block, AND the task ordering follows the vertical-slice structure above. The first task is always a failing end-to-end test. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0443991e5..48380d3b2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -193,6 +193,16 @@ parent dispatches, modes/ holds per-flag behavior (`power.md`, `all.md`, checkpoint.json schemas that are read only when the corresponding output file is being written. +`workflows/plan-phase.md`, `workflows/execute-phase.md`, and the +`gsd-planner` / `gsd-executor` agent definitions apply the same discipline +to their MVP-only reference bodies — `planner-mvp-mode.md`, +`user-story-template.md`, `skeleton-template.md`, and `execute-mvp-tdd.md` +are referenced for the planner/executor to Read only on MVP, +Walking-Skeleton, or MVP+TDD paths, rather than eagerly `@`-imported, so +non-MVP runs do not pay their context cost (guards against the "`@`-import +behind a conditional still loads eagerly" leak; see #720). The dedicated +`mvp-phase` workflow keeps its eager imports, since it is always MVP. + ### Agents (`agents/*.md`) Specialized agent definitions with frontmatter specifying: diff --git a/gsd-core/workflows/execute-phase.md b/gsd-core/workflows/execute-phase.md index 96ca1d190..b1af74812 100644 --- a/gsd-core/workflows/execute-phase.md +++ b/gsd-core/workflows/execute-phase.md @@ -188,7 +188,7 @@ if [ "$MVP_MODE" = "true" ] && [ "$TDD_MODE" = "true" ]; then fi fi ``` -Pure doc-only / config-only / test-only tasks return `is_behavior_adding=false` and are exempt. See `execute-mvp-tdd.md` for the halt report format. +Pure doc-only / config-only / test-only tasks return `is_behavior_adding=false` and are exempt. When the gate trips, Read `~/.claude/gsd-core/references/execute-mvp-tdd.md` for the exact halt report format. diff --git a/gsd-core/workflows/plan-phase.md b/gsd-core/workflows/plan-phase.md index cc93aa5ce..f578042e8 100644 --- a/gsd-core/workflows/plan-phase.md +++ b/gsd-core/workflows/plan-phase.md @@ -143,7 +143,7 @@ fi ``` When `WALKING_SKELETON=true`: -- Planner is instructed to produce `SKELETON.md` in the phase directory alongside `PLAN.md`. The template lives at `@~/.claude/gsd-core/references/skeleton-template.md`. +- Planner is instructed to produce `SKELETON.md` in the phase directory alongside `PLAN.md`. The template lives at `~/.claude/gsd-core/references/skeleton-template.md` — the planner reads it when producing SKELETON.md (lazy; not loaded on non-skeleton runs). - The plan must scaffold project + routing + one real DB read/write + one real UI interaction + dev deployment — the thinnest possible end-to-end working slice. **Interaction with `--prd `.** `--mvp` and `--prd` compose. The PRD express path (Step 3.5) creates `CONTEXT.md` from the PRD file and continues to research; the Walking Skeleton gate fires independently from the conditions above. When both are active on Phase 1 of a new project, the planner receives `WALKING_SKELETON=true` and PRD-derived context simultaneously — the PRD informs *what the skeleton should prove*. No precedence is needed; the two signals are orthogonal. See [`references/mvp-concepts.md`](../references/mvp-concepts.md) for the broader interaction map. @@ -933,12 +933,12 @@ Each TDD plan gets one feature with RED/GREEN/REFACTOR gate sequence. ` : ''} -**MVP_MODE:** ${MVP_MODE} (when true, follow vertical-slice rules from `@~/.claude/gsd-core/references/planner-mvp-mode.md`; when false, ignore MVP guidance entirely.) -**WALKING_SKELETON:** ${WALKING_SKELETON} (when true, the first deliverable must be a Walking Skeleton — produce SKELETON.md alongside PLAN.md.) +**MVP_MODE:** ${MVP_MODE} (when true, follow vertical-slice rules from `~/.claude/gsd-core/references/planner-mvp-mode.md`; when false, ignore MVP guidance entirely.) +**WALKING_SKELETON:** ${WALKING_SKELETON} (when true, the first deliverable must be a Walking Skeleton — Read the template at `~/.claude/gsd-core/references/skeleton-template.md` and produce SKELETON.md alongside PLAN.md.) ${MVP_MODE === 'true' ? ` -**MVP Mode is ENABLED.** Follow vertical-slice planning rules from @~/.claude/gsd-core/references/planner-mvp-mode.md. Each plan must deliver a complete vertical slice — thin end-to-end functionality rather than horizontal layers. +**MVP Mode is ENABLED.** Read `~/.claude/gsd-core/references/planner-mvp-mode.md` now and follow its vertical-slice planning rules. Each plan must deliver a complete vertical slice — thin end-to-end functionality rather than horizontal layers. ` : ''} diff --git a/tests/workflow-size-budget.test.cjs b/tests/workflow-size-budget.test.cjs index 65da82e37..18673661f 100644 --- a/tests/workflow-size-budget.test.cjs +++ b/tests/workflow-size-budget.test.cjs @@ -402,3 +402,150 @@ describe('SIZE: discuss-phase progressive disclosure (issue #2551)', () => { ); }); }); + +const AGENTS_DIR = path.join(__dirname, '..', 'agents'); + +describe('workflow progressive disclosure — MVP bodies lazy-loaded (#720)', () => { + // MVP-only reference bodies (planner-mvp-mode.md, skeleton-template.md, + // execute-mvp-tdd.md) must NOT be eagerly @-imported at the top level of the + // always-loaded workflow files or agent definitions. An @-prefixed path is + // expanded into context the moment the file loads — regardless of whether + // MVP_MODE is true — inflating every session's token cost. Use a plain + // backtick path or a conditional "Read ..." instruction instead. See issue #720. + + test('plan-phase.md does not eagerly @-import planner-mvp-mode.md', () => { + const planPhaseContent = fs.readFileSync(path.join(WORKFLOWS_DIR, 'plan-phase.md'), 'utf-8'); + assert.ok( + !/@[~./\w-]*planner-mvp-mode\.md/.test(planPhaseContent), + 'plan-phase.md contains an eager @-import of planner-mvp-mode.md — ' + + 'this loads the MVP body into context for every session, even when MVP_MODE is false. ' + + 'Replace with a conditional Read instruction or a plain backtick path. See #720.' + ); + }); + + test('plan-phase.md does not eagerly @-import skeleton-template.md', () => { + const planPhaseContent = fs.readFileSync(path.join(WORKFLOWS_DIR, 'plan-phase.md'), 'utf-8'); + assert.ok( + !/@[~./\w-]*skeleton-template\.md/.test(planPhaseContent), + 'plan-phase.md contains an eager @-import of skeleton-template.md — ' + + 'this loads the template into context on every plan-phase invocation. ' + + 'Replace with a conditional Read instruction or a plain backtick path. See #720.' + ); + }); + + test('plan-phase.md still references both MVP bodies (lazy reference preserved)', () => { + const planPhaseContent = fs.readFileSync(path.join(WORKFLOWS_DIR, 'plan-phase.md'), 'utf-8'); + assert.ok( + /planner-mvp-mode\.md/.test(planPhaseContent) && /skeleton-template\.md/.test(planPhaseContent), + 'plan-phase.md must still reference planner-mvp-mode.md and skeleton-template.md ' + + '(as lazy backtick paths or Read instructions) so agents know where to find them. ' + + 'Do not delete the references — only remove the leading @ sigil. See #720.' + ); + }); + + test('plan-phase.md does not list MVP bodies in ', () => { + const planPhaseContent = fs.readFileSync(path.join(WORKFLOWS_DIR, 'plan-phase.md'), 'utf-8'); + const requiredReadingMatch = planPhaseContent.match(/([\s\S]*?)<\/required_reading>/); + if (requiredReadingMatch) { + const block = requiredReadingMatch[1]; + assert.ok( + !/planner-mvp-mode\.md/.test(block), + 'planner-mvp-mode.md must NOT appear in plan-phase.md — ' + + 'that block is always loaded regardless of MVP_MODE. See #720.' + ); + assert.ok( + !/skeleton-template\.md/.test(block), + 'skeleton-template.md must NOT appear in plan-phase.md — ' + + 'that block is always loaded regardless of MVP_MODE. See #720.' + ); + } + }); + + test('execute-phase.md does not eagerly @-import execute-mvp-tdd.md', () => { + const executePhaseContent = fs.readFileSync(path.join(WORKFLOWS_DIR, 'execute-phase.md'), 'utf-8'); + assert.ok( + !/@[~./\w-]*execute-mvp-tdd\.md/.test(executePhaseContent), + 'execute-phase.md contains an eager @-import of execute-mvp-tdd.md — ' + + 'this loads the MVP TDD body into context for every session. ' + + 'Replace with a conditional Read instruction or a plain backtick path. See #720.' + ); + }); + + test('execute-phase.md still references execute-mvp-tdd.md (lazy reference preserved)', () => { + const executePhaseContent = fs.readFileSync(path.join(WORKFLOWS_DIR, 'execute-phase.md'), 'utf-8'); + assert.ok( + /execute-mvp-tdd\.md/.test(executePhaseContent), + 'execute-phase.md must still reference execute-mvp-tdd.md (as a lazy backtick path ' + + 'or Read instruction) so agents know where to find it. ' + + 'Do not delete the reference — only ensure there is no leading @ sigil. See #720.' + ); + }); + + test('gsd-planner.md does not eagerly @-import planner-mvp-mode.md', () => { + const content = fs.readFileSync(path.join(AGENTS_DIR, 'gsd-planner.md'), 'utf-8'); + assert.ok( + !/@[~./\w-]*planner-mvp-mode\.md/.test(content), + 'gsd-planner.md contains an eager @-import of planner-mvp-mode.md — ' + + 'this loads the MVP body into context for every session, even when MVP_MODE is false. ' + + 'Replace with a conditional Read instruction or a plain backtick path. See #720.' + ); + }); + + test('gsd-planner.md does not eagerly @-import skeleton-template.md', () => { + const content = fs.readFileSync(path.join(AGENTS_DIR, 'gsd-planner.md'), 'utf-8'); + assert.ok( + !/@[~./\w-]*skeleton-template\.md/.test(content), + 'gsd-planner.md contains an eager @-import of skeleton-template.md — ' + + 'this loads the template into context on every planner invocation. ' + + 'Replace with a conditional Read instruction or a plain backtick path. See #720.' + ); + }); + + test('gsd-planner.md does not eagerly @-import user-story-template.md', () => { + const content = fs.readFileSync(path.join(AGENTS_DIR, 'gsd-planner.md'), 'utf-8'); + assert.ok( + !/@[~./\w-]*user-story-template\.md/.test(content), + 'gsd-planner.md contains an eager @-import of user-story-template.md — ' + + 'this loads the template into context on every planner invocation. ' + + 'Replace with a conditional Read instruction or a plain backtick path. See #720.' + ); + }); + + test('gsd-planner.md still references the three MVP bodies', () => { + const content = fs.readFileSync(path.join(AGENTS_DIR, 'gsd-planner.md'), 'utf-8'); + assert.ok( + /planner-mvp-mode\.md/.test(content), + 'gsd-planner.md must still reference planner-mvp-mode.md (as a lazy path or Read instruction). ' + + 'Do not delete the reference — only remove the leading @ sigil. See #720.' + ); + assert.ok( + /skeleton-template\.md/.test(content), + 'gsd-planner.md must still reference skeleton-template.md (as a lazy path or Read instruction). ' + + 'Do not delete the reference — only remove the leading @ sigil. See #720.' + ); + assert.ok( + /user-story-template\.md/.test(content), + 'gsd-planner.md must still reference user-story-template.md (as a lazy path or Read instruction). ' + + 'Do not delete the reference — only remove the leading @ sigil. See #720.' + ); + }); + + test('gsd-executor.md does not eagerly @-import execute-mvp-tdd.md', () => { + const content = fs.readFileSync(path.join(AGENTS_DIR, 'gsd-executor.md'), 'utf-8'); + assert.ok( + !/@[~./\w-]*execute-mvp-tdd\.md/.test(content), + 'gsd-executor.md contains an eager @-import of execute-mvp-tdd.md — ' + + 'this loads the MVP TDD body into context for every session. ' + + 'Replace with a conditional Read instruction or a plain backtick path. See #720.' + ); + }); + + test('gsd-executor.md still references execute-mvp-tdd.md', () => { + const content = fs.readFileSync(path.join(AGENTS_DIR, 'gsd-executor.md'), 'utf-8'); + assert.ok( + /execute-mvp-tdd\.md/.test(content), + 'gsd-executor.md must still reference execute-mvp-tdd.md (as a lazy path or Read instruction). ' + + 'Do not delete the reference — only remove the leading @ sigil. See #720.' + ); + }); +});