From 3d52f5ee469c8e2c1fe2ee48b896836c56acbd55 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Wed, 20 May 2026 20:39:01 -0400 Subject: [PATCH] =?UTF-8?q?refactor(tests):=20consolidate=20Init=20Command?= =?UTF-8?q?=20Module=20=E2=80=94=207=20files=20=E2=86=92=205=20(#3756)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(3687): update insert-phase docs and roadmapper to use --insert flag Updates stale references in insert-phase.md workflow and gsd-roadmapper.md agent to use the consolidated /gsd:phase --insert command syntax instead of the retired /gsd-insert-phase and /gsd:phase insert forms. Closes #3687 * refactor(tests): consolidate Init Command Module — 7 files → 5 Closes #3755 Merges `tests/init-manager-deps.test.cjs` (#2267 regression) into `tests/init-manager.test.cjs` (718 LOC), and `sdk/src/query/init-progress-precedence.test.ts` (#2674 regression) into `sdk/src/query/init-complex.test.ts` (788 LOC). The 800 LOC ceiling prevents further consolidation: - `tests/init.test.cjs` is pre-existing at 1630 LOC - `sdk/src/query/init.test.ts` is at 791 LOC - `sdk/src/query/init-workstream-milestone-op.test.ts` is a distinct seam testing initMilestoneOp, roadmapAnalyze, and resolveQueryRuntimeContext workstream resolution. Also adds Init Command Module Glossary entry to CONTEXT.md. Allowlist update deferred to rebase after #3738 merges (allowlist file does not exist on origin/main). Co-Authored-By: Claude Sonnet 4.6 * fix(init): initExecutePhase preserves same-milestone archived phase dir (#3469) When `phases clear` archives current-milestone phases into `.planning/milestones/-phases/` but the workflow is still on that same milestone, `shouldDropArchivedPhaseMatch` was unconditionally dropping the archived dir match. This caused `phase_dir: null` when the phase was still executing in the current milestone. Fix: detect when `phaseInfo.archived === currentMilestone` (read from STATE.md) and skip the drop. The #2391 regression guard is safe because that scenario involves archived.version != current milestone. Also corrects two tests in `initRemoveWorkspace` to expect thrown GSDError instead of `{ data: { error } }` — the production code was intentionally changed to throw for CLI non-zero exit propagation. Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: arya rizky Co-authored-by: Claude Sonnet 4.6 --- .changeset/3755-consolidate-init-tests.md | 7 + .gitignore | 8 + CONTEXT.md | 40 ++++ agents/gsd-roadmapper.md | 2 +- get-shit-done/workflows/insert-phase.md | 6 +- package-lock.json | 12 +- sdk/src/query/init-complex.test.ts | 172 +++++++++++++++++ .../query/init-progress-precedence.test.ts | 177 ------------------ sdk/src/query/init.test.ts | 15 +- sdk/src/query/init.ts | 28 ++- tests/init-manager-deps.test.cjs | 120 ------------ tests/init-manager.test.cjs | 115 ++++++++++++ 12 files changed, 382 insertions(+), 320 deletions(-) create mode 100644 .changeset/3755-consolidate-init-tests.md delete mode 100644 sdk/src/query/init-progress-precedence.test.ts delete mode 100644 tests/init-manager-deps.test.cjs diff --git a/.changeset/3755-consolidate-init-tests.md b/.changeset/3755-consolidate-init-tests.md new file mode 100644 index 000000000..ef80d6d3e --- /dev/null +++ b/.changeset/3755-consolidate-init-tests.md @@ -0,0 +1,7 @@ +--- +type: Changed +pr: 3755 +--- +Internal test refactor: consolidates the Init Command Module test cluster from 7 files to 5. + + diff --git a/.gitignore b/.gitignore index 89d9432e6..614296eeb 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,11 @@ vendor/ tmp/ .worktrees .envrc + +# MemPalace per-project files (issue #185) +mempalace.yaml +entities.json + +# Local scratch + Claude-test artifacts +.scratch/ +claude-test-command.md diff --git a/CONTEXT.md b/CONTEXT.md index 41f97bbb8..28bef4141 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -55,6 +55,9 @@ Canonical command normalization and resolution Interface (`query-command-resolut ### Command Topology Module Module owning command resolution, policy projection (`mutation`, `output_mode`), unknown-command diagnosis, and handler Adapter binding at one seam for query dispatch. +### Init Command Module +SDK Module owning the `init.*` family of query handlers that compose atomic SDK queries into the flat JSON bundles consumed by init workflows (`/gsd-execute-phase`, `/gsd-plan-phase`, `/gsd-verify-work`, `/gsd-new-project`, `/gsd-manager`, `/gsd-progress`, `/gsd-resume`, etc.). Divided into two source files: `sdk/src/query/init.ts` (13 basic handlers plus `withProjectRoot` project-identity injection) and `sdk/src/query/init-complex.ts` (the 3 heavyweight handlers: `initNewProject`, `initProgress`, `initManager`). All handlers return `{ data: }` aligned with the CJS `bin/lib/init.cjs` surface. Test seams: `sdk/src/query/init.test.ts` (basic handlers + withProjectRoot precedence), `sdk/src/query/init-complex.test.ts` (complex handlers + progress/manager precedence regression #2674 + workstream scoping regression #3196), `sdk/src/query/init-workstream-milestone-op.test.ts` (initMilestoneOp + roadmapAnalyze + resolveQueryRuntimeContext workstream fallback). CJS CLI surface tested in `tests/init.test.cjs` and `tests/init-manager.test.cjs` (includes cross-milestone dependency regression #2267). + ### CJS Command Router Adapter Module Compatibility Adapter Module for `gsd-tools.cjs` command families. Uses generated command metadata plus small argument shapers to route to CJS handlers, rather than calling SDK Command Topology directly. Preserves CJS compatibility startup while reducing hand-written router drift. Per-family migration to call the **Sync Runtime Bridge Module**'s `executeForCjs` in-process — eliminating the remaining parallel CJS handler implementations — is the active work of #3524 Phase 5; the primitive itself ships in #3555, with each canonical command family (`state.*`, `verify.*`, `init.*`, `phase.*`, `phases.*`, `validate.*`, `roadmap.*`, `frontmatter.*`, `config.*`) routing through `executeForCjs` in its own follow-up enhancement. @@ -514,6 +517,43 @@ Migration plan: Phase 1 (#3465) seam additions complete; Phase 2 (#3466) targets `SESSION.2026-05-14=[#3095/PR #3490 EXEC.CLASSIFY.* introduced (Anthropic/Copilot/Codex/Gemini cross-runtime rate-limit sentinel coverage); #3489/PR #3499 DEFECT.STATE-TRAMPLE.idempotency-oracle (STATE.md current_phase field is oracle for state.complete-phase); #3488/PR #3501 DAG resolver same-phase short-form depends_on (shortFormToId index added to sdk/src/query/phase.ts); #3491/PR #3502 DEFECT.NESTED-GIT-INIT (gitWorktreeInfoInternal helper); #3493/PR #3500 extractCurrentMilestone generic Phase Details continuation past planned-milestone siblings; #3503/PR #3504 DEFECT.PATH-SUBSTRING-CHECK (trailing-slash anchor for homedir checks); #3346/PR #3505 codex AoT TOML leaf-key via extractFlatHookEventName; #3506/PR #3507 label-scoped stale-bot sub-job pattern; multi-PR triage operational lessons folded into PROC.TRIAGE.*; #3508 DEFECT.AGENT-ISOLATION-SILENT-FAIL; gsd-test image-missing auto-build (locally-built image via embedded heredoc Dockerfile); refined PRED.k322 threshold to 3 PRs/<10min]` `SESSION.2026-05-15=[#3537/PR #3538 DEFECT.PHASE-REGEX-FANOUT — phaseMarkdownRegexSource promoted to core.cjs and wired to 7 sites; parity-style regression test established as DEFECT.GENERATIVE-FIX exemplar; trek-e/gsd-test-runner#1 filed for DEFECT.GSD-TEST-MIRROR-POISONED — chown-back-before-exec legacy gap (poisoned holodeck mirror unstuck via authorized docker chown to remote 1000:1000); RULESET.PR-FLOW.* codified from project CLAUDE.md load-bearing rule; first dispatch under run-tests-before-create held cleanly (PR #3520 worker stopped on Docker exit 12 infra failure, orchestrator opened PR after unblock); CONTEXT.md refactored from 882 lines of mixed prose+predicates into ~500 lines of pure-predicate format with chronological session log]` `SESSION.2026-05-15.parallel-fix-dispatch=[#3542/PR #3546 prohibit git stash family in executor agents (shared refs/stash across worktrees); #3541/PR #3547 non-TTY resolution for installer prompt-user actions (default remove for SDK build artifacts, keep for skills/gsd-*/SKILL.md); #3545 filed for gsd-test-summary concurrent /tmp output collision; new predicates DEFECT.HOOK-OVER-ENFORCEMENT.read-tool-tracking, DEFECT.GSD-TEST-CONCURRENT-OUTPUT-COLLISION, DEFECT.SUBAGENT-LONG-RUNNING-BG-STALL, DEFECT.AGENT-RETIRED-SLASH-SYNTAX-DRIFT, PROC.PARALLEL-FIX-DISPATCH; agent-trust-but-verify caught /gsd-update retired-syntax comment slip in #3541 implementation before PR open]` +`SESSION.2026-05-16=[multi-PR triage wave (#3577/3581/3640/3641/3642/3648/3649/3637/3639). Established global PreToolUse hook ~/.claude/hooks/test-memory-guard.sh denying new node/test spawns when sum(RSS of node|vitest|jest|...) >= 4 GiB on the 24 GB Mac OR when a same-runner process is already in argv[0] — hard deny via hookSpecificOutput.permissionDecision=deny. PR #3577 fix: revert config-ensure-section dispatch to CJS cmdConfigEnsureSection (SDK author wrote single-section semantics under a name whose legacy callers expect full-default config init); plus 3 SDK parity carve-outs (configNewProject defaults align with sdk/shared/config-defaults.manifest.json, return relative .planning/config.json path, drop quotes from Unknown config key, lead malformed-JSON error with "Failed to read config.json:"). PR #3649 fix: chunk node --test spawn at 28K argv ceiling (Windows CreateProcess lpCommandLine cap 32,767 was instantly aborting unchunked spawn of 546 paths). Chunking fix surfaced 14 pre-existing Windows-only test bugs (4010 pass / 14 fail; vs 0/0 before — entire suite was un-runnable on Windows). PRs #3639 + #3637 confirmed unable to stand alone (legitimately depend on Phase 6 scaffolding only present on feat/3575-enforcement-hardening) — user decision: cherry-pick into #3577 and close. Five other PRs each had ≤1 unresolved CR thread of the changeset-pr-number / null-vs-throw / implicit-Claude-runtime / docs-stale-guidance / hardcoded-tests-path family — all quick wins. New predicates: DEFECT.SDK-PORT-NAME-COLLISION, DEFECT.WINDOWS-ARGV-OVERFLOW, DEFECT.STACKED-PR-CANNOT-STAND-ALONE, DEFECT.CANARY-VERSION-LEAK, DEFECT.GSD-TEST-HOST-MID-RUN-DEATH, RULESET.HARNESS.test-memory-guard, RULESET.PR-FLOW.docker-before-push, RULESET.PR-FLOW.templates-mandatory]` + +`DEFECT.SDK-PORT-NAME-COLLISION.symptom=SDK author writes new handler with same canonical name as legacy CJS command but different positional-arg shape; Phase-N router migration silently rebinds CLI dispatch to the new SDK function and every legacy no-arg / wrong-arg caller errors out at the handler's own validation throw` +`DEFECT.SDK-PORT-NAME-COLLISION.examples=#3577 config-ensure-section (legacy CJS = no-arg full-default init via ensureConfigFile→buildNewProjectConfig; new SDK configEnsureSection = single-section ensure requiring args[0]; all CLI callers pass no args; SDK throws "Usage: config-ensure-section
")` +`DEFECT.SDK-PORT-NAME-COLLISION.detect=grep new-handler name in sdk/src/query/command-static-catalog-foundation.ts catalog → trace every CLI/test caller of the canonical name → if any caller's argv shape differs from the new SDK handler's args[0] expectation, the migration broke the legacy contract` +`DEFECT.SDK-PORT-NAME-COLLISION.fix-forward=either (a) bind the catalog entry to a SDK handler whose body mirrors legacy semantics (e.g. configNewProject when no args), or (b) keep the dispatch case calling the CJS handler directly and remove the SDK-bridge attempt (precedent: 7d5dfa9d codex runtime carve-out). Whichever path, add a behavioral test that round-trips the legacy invocation shape to lock the contract` +`DEFECT.SDK-PORT-NAME-COLLISION.generative-tie=instance of DEFECT.GENERATIVE-PRIORITY — parity assertion at the test layer between CJS handler shape and SDK handler shape would have failed at PR open` + +`DEFECT.WINDOWS-ARGV-OVERFLOW.symptom=execFileSync(node, ['--test', ...N paths]) succeeds on Linux/macOS, instantly exits with code 1 and no test output on Windows when N×avg(path_len) exceeds 32,767 chars (CreateProcess lpCommandLine cap)` +`DEFECT.WINDOWS-ARGV-OVERFLOW.examples=#3649 scripts/run-tests.cjs spawning 546 paths (~85 chars each ≈ 46 KB); Linux ARG_MAX 2 MB allows it, Windows aborts in ~70 ms with zero test output making the failure look like the runner itself crashed` +`DEFECT.WINDOWS-ARGV-OVERFLOW.detect=Windows CI job at "Run unit tests" exits with code 1 within seconds of starting, no node:test output between "run-tests: suite=… files=N: …" line and "Process completed with exit code 1"; same job on Linux/macOS runs full duration` +`DEFECT.WINDOWS-ARGV-OVERFLOW.fix-forward=chunk argv into batches whose total length stays under 28,000 chars (headroom under the 32,767 ceiling); run each chunk sequentially; aggregate exit codes (first non-zero wins). Expose RUN_TESTS_MAX_CMDLINE_CHARS env override so cross-platform regression tests can force chunking with short tmp paths` +`DEFECT.WINDOWS-ARGV-OVERFLOW.test-anchor=tests/run-tests-harness.test.cjs "Windows argv-overflow chunking (issue #3597)" — 30 long-named fixture files + RUN_TESTS_MAX_CMDLINE_CHARS=2000 → asserts run-tests: chunk N/M marker in stderr; pattern works on every platform` + +`DEFECT.STACKED-PR-CANNOT-STAND-ALONE.symptom=patch PR was authored against scaffolding (handler files, lint scripts, generated modules) that exists only on an unmerged upstream feature branch; the PR's "base" on GitHub is the feature branch, not main; merging requires the upstream PR to land first` +`DEFECT.STACKED-PR-CANNOT-STAND-ALONE.examples=#3639 + #3637 both targeted base=feat/3575-enforcement-hardening (the Phase 6 PR #3577); #3639 modifies SDK-bridge calls in 6 family-router files that on main do NOT have any SDK-bridge call yet; #3637 patches scripts/lint-shared-module-handsync.cjs which does not exist on main at all` +`DEFECT.STACKED-PR-CANNOT-STAND-ALONE.detect=gh pr view --json baseRefName shows non-main base; OR git rebase --onto origin/main produces real (not whitespace) conflicts at files the patch claims to modify; OR git cat-file -e origin/main: errors with "does not exist in origin/main"` +`DEFECT.STACKED-PR-CANNOT-STAND-ALONE.fix-forward=user policy (this session, 2026-05-16): every PR must stand alone. Resolution = cherry-pick the patch's unique commits onto the upstream PR head, push to upstream PR branch, close patch PR with "subsumed by #". Alternatives explicitly rejected: leaving stacked open ("no, fold them in") and closing-without-folding ("we want the fix")` +`DEFECT.STACKED-PR-CANNOT-STAND-ALONE.anti-pattern=blindly running git rebase --onto origin/main on the patch branch — produces "conflicts" that are really "the scaffolding doesn't exist yet"; resolving them means reinventing the upstream PR's contribution, which duplicates work and creates merge hazards. Recognize the shape early via cat-file probe before rebasing` + +`DEFECT.CANARY-VERSION-LEAK.symptom=package.json version on main carries a -canary. suffix that per release policy belongs to the dev branch only; nothing publishable depends on the version string at runtime, but every consumer of the version metadata (release flow, install banners, statusline) sees the dev-channel label` +`DEFECT.CANARY-VERSION-LEAK.examples=2026-05-16 audit found origin/main + origin/feat/3575-enforcement-hardening both at "version": "1.50.0-canary.0" in sdk/package.json AND root package.json; npm view @gsd-build/sdk versions returned ["0.1.0"] only, dist-tag latest=0.1.0, @1.50.0-canary.0 404 — confirms the string is metadata-only, never published. git log -S '"version": "1.50.0-canary.0"' origin/main blamed commit 2d32ad82 fix(plan-phase)... (#3206), a fix PR that accidentally carried the version bump from a dev-branch base` +`DEFECT.CANARY-VERSION-LEAK.detect=jq -r .version package.json sdk/package.json on origin/main shows a -canary suffix; OR npm view dist-tags shows latest != main's version` +`DEFECT.CANARY-VERSION-LEAK.fix-forward=open a chore/* PR against main that resets the version strings to the canonical pre-canary stable; rebase open PRs to pick it up; gate at PR open with a CI check that rejects -canary versions on PRs targeting main` +`DEFECT.CANARY-VERSION-LEAK.cross-ref=RELEASE-NOTES.RELEASE-STREAM.dev-branch=canary dist-tag (only); this defect is the live counter-example to that rule` + +`DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.symptom=pick_host succeeds at probe time (ssh -o ConnectTimeout=3 -o BatchMode=yes "$h" true); subsequent ssh "$h" 'docker run ...' hangs indefinitely because the chosen host went unreachable between probe and exec; gsd-test-summary buffers stderr until the wrapper exits, so the operator sees no progress at all` +`DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.examples=2026-05-16 redshirt probed up at 12:48 UTC, gsd-test-summary picked it, docker container spawned, then redshirt's ssh daemon stopped responding — banner-exchange timeout. Test stalled 20+ minutes with the wrapper's output file at 0 bytes` +`DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.detect=gsd-test-summary's task output file at /private/tmp/claude-*/tasks/.output stays 0 bytes for >5 min after launch; ps shows the test still alive; ssh -o ConnectTimeout=5 true now times out` +`DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.fix-forward=TaskStop the wrapper; pkill -f gsd-test-summary + pkill -f "ssh "; re-run gsd-test-summary so pick_host re-randomizes from the live set (probe each ~/.config/gsd-test/hosts entry first to confirm). Upstream fix candidate: gsd-test should add a heartbeat read on the ssh-stdin channel and abort + retry on a different host after N silent seconds` +`DEFECT.GSD-TEST-HOST-MID-RUN-DEATH.related=DEFECT.GSD-TEST-MIRROR-POISONED (legacy bind-mount ownership); GSD-TEST-CONCURRENT-OUTPUT-COLLISION (file collision) — host-mid-run-death is the third independent gsd-test infra failure mode this month` + +`RULESET.HARNESS.test-memory-guard=~/.claude/hooks/test-memory-guard.sh fires on every Bash PreToolUse; if argv[0]∈{node|vitest|jest|mocha|tsx|ts-node|tap|ava|playwright|cypress} OR matches (npm|pnpm|yarn|bun) (run )?(t|test|tests|vitest|jest); blocks via hookSpecificOutput.permissionDecision=deny when sum(RSS of running matching procs, excluding tsserver|*-mcp|claude|Electron|...) ≥ 4 GiB OR when argv[0] basename matches a running process's argv[0]. Exception: node --version|-v|--help|-h|-p|-e are trivial probes and skip the check. Designed for a 24 GB Mac where prior accidental fan-out exhausted RAM` + +`RULESET.PR-FLOW.docker-before-push=before ANY git push of any fix to any PR, run gsd-test-summary (docker on the remote, mirrors ubuntu CI) and confirm exit 0. macOS-local node --test is NOT a substitute — many failures are platform-specific (path separators, case sensitivity, locale, fs semantics). Watchdog with Monitor on the output log; never set a sleep/timer and walk away. Source: user feedback 2026-05-16 — "we don't set a timer we actively watch and record results in real time as possible"` + +`RULESET.PR-FLOW.templates-mandatory=every gh pr create|edit|gh issue create|edit MUST first invoke the gh-templates-first skill and Read (Read tool, not Bash cat — k321 read-tracking) the matching template in .github/. Apply ALL required sections; never write freeform bodies. Repo enforces this via gsd-pr-template-policy GitHub Action which flags any non-templated body — the bot allows the PR to stay open only because authors are contributors-or-higher, but the warning is a real complaint that must be cured. Source: user feedback 2026-05-16 (multi-message escalation) — "the whole reason i have that github action is because you fucking blow through and ignore using the templates"` --- diff --git a/agents/gsd-roadmapper.md b/agents/gsd-roadmapper.md index c2dfe0997..0c07688c2 100644 --- a/agents/gsd-roadmapper.md +++ b/agents/gsd-roadmapper.md @@ -202,7 +202,7 @@ Track coverage as you go. **Integer phases (1, 2, 3):** Planned milestone work. **Decimal phases (2.1, 2.2):** Urgent insertions after planning. -- Created via `/gsd:phase insert` +- Created via `/gsd:phase --insert` - Execute between integers: 1 → 1.1 → 1.2 → 2 **Starting number:** diff --git a/get-shit-done/workflows/insert-phase.md b/get-shit-done/workflows/insert-phase.md index 3538bad6d..beda085ae 100644 --- a/get-shit-done/workflows/insert-phase.md +++ b/get-shit-done/workflows/insert-phase.md @@ -13,7 +13,7 @@ Parse the command arguments: - First argument: integer phase number to insert after - Remaining arguments: phase description -Example: `/gsd-insert-phase 72 Fix critical auth bug` +Example: `/gsd:phase --insert 72 Fix critical auth bug` -> after = 72 -> description = "Fix critical auth bug" @@ -21,8 +21,8 @@ If arguments missing: ``` ERROR: Both phase number and description required -Usage: /gsd-insert-phase -Example: /gsd-insert-phase 72 Fix critical auth bug +Usage: /gsd:phase --insert +Example: /gsd:phase --insert 72 Fix critical auth bug ``` Exit. diff --git a/package-lock.json b/package-lock.json index 20a36bec0..6d1fe9f3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -516,9 +516,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -1167,9 +1167,9 @@ } }, "node_modules/hono": { - "version": "4.12.18", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", - "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", + "version": "4.12.19", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.19.tgz", + "integrity": "sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ==", "license": "MIT", "engines": { "node": ">=16.9.0" diff --git a/sdk/src/query/init-complex.test.ts b/sdk/src/query/init-complex.test.ts index bf2d21ee1..d780fb343 100644 --- a/sdk/src/query/init-complex.test.ts +++ b/sdk/src/query/init-complex.test.ts @@ -614,3 +614,175 @@ describe('initManager workstream (#2731)', () => { } }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// initProgress + initManager precedence (#2674) +// +// Both handlers must agree on phase status given the same inputs. Specifically, +// a ROADMAP `- [x] Phase N` checkbox wins over disk state: a stub phase dir +// with no SUMMARY.md that is checked in ROADMAP reports as `complete` from +// both handlers. +// +// Pre-fix: initManager reported `complete` (explicit override), initProgress +// reported `pending` (disk-only policy). This mismatch meant /gsd-manager and +// /gsd-progress disagreed on the same data. Post-fix: both apply the +// ROADMAP-[x]-wins policy. +// ───────────────────────────────────────────────────────────────────────────── + +const PRECEDENCE_CONFIG = JSON.stringify({ + model_profile: 'balanced', + commit_docs: false, + git: { + branching_strategy: 'none', + phase_branch_template: 'gsd/phase-{phase}-{slug}', + milestone_branch_template: 'gsd/{milestone}-{slug}', + quick_branch_template: null, + }, + workflow: { research: true, plan_check: true, verifier: true, nyquist_validation: true }, +}); + +const PRECEDENCE_STATE = [ + '---', + 'milestone: v1.0', + '---', +].join('\n'); + +/** Find a phase by numeric value regardless of zero-padding ('3' vs '03'). */ +function findPhaseByNum( + phases: Record[], + num: number, +): Record | undefined { + return phases.find(p => parseInt(p.number as string, 10) === num); +} + +/** + * Write a ROADMAP.md with the given phase list. Each entry is + * `{num, name, checked}`. Emits both the checkbox summary lines AND the + * `### Phase N:` heading sections (so initManager picks them up). + */ +async function writePrecedenceRoadmap( + dir: string, + phases: Array<{ num: string; name: string; checked: boolean }>, +): Promise { + const checkboxes = phases + .map(p => `- [${p.checked ? 'x' : ' '}] Phase ${p.num}: ${p.name}`) + .join('\n'); + const sections = phases + .map(p => `### Phase ${p.num}: ${p.name}\n\n**Goal:** ${p.name} goal\n\n**Depends on:** None\n`) + .join('\n'); + await writeFile(join(dir, '.planning', 'ROADMAP.md'), [ + '# Roadmap', + '', + '## v1.0: Test', + '', + checkboxes, + '', + sections, + ].join('\n')); +} + +describe('initProgress + initManager precedence (#2674)', () => { + let precedenceDir: string; + + beforeEach(async () => { + precedenceDir = await mkdtemp(join(tmpdir(), 'gsd-2674-')); + await mkdir(join(precedenceDir, '.planning', 'phases'), { recursive: true }); + await writeFile(join(precedenceDir, '.planning', 'config.json'), PRECEDENCE_CONFIG); + await writeFile(join(precedenceDir, '.planning', 'STATE.md'), PRECEDENCE_STATE); + }); + + afterEach(async () => { + await rm(precedenceDir, { recursive: true, force: true }); + }); + + it('case 1: ROADMAP [x] + stub phase dir + no SUMMARY → both report complete', async () => { + await writePrecedenceRoadmap(precedenceDir, [{ num: '3', name: 'Stubbed', checked: true }]); + await mkdir(join(precedenceDir, '.planning', 'phases', '03-stubbed'), { recursive: true }); + // stub dir, no PLAN/SUMMARY/RESEARCH/CONTEXT files + + const progress = (await initProgress([], precedenceDir)).data as Record; + const manager = (await initManager([], precedenceDir)).data as Record; + + const pPhase = findPhaseByNum(progress.phases as Record[], 3); + const mPhase = findPhaseByNum(manager.phases as Record[], 3); + + expect(pPhase?.status).toBe('complete'); + expect(mPhase?.disk_status).toBe('complete'); + }); + + it('case 2: ROADMAP [x] + phase dir + SUMMARY present → both complete (sanity)', async () => { + await writePrecedenceRoadmap(precedenceDir, [{ num: '3', name: 'Done', checked: true }]); + await mkdir(join(precedenceDir, '.planning', 'phases', '03-done'), { recursive: true }); + await writeFile(join(precedenceDir, '.planning', 'phases', '03-done', '03-01-PLAN.md'), '# plan'); + await writeFile(join(precedenceDir, '.planning', 'phases', '03-done', '03-01-SUMMARY.md'), '# done'); + + const progress = (await initProgress([], precedenceDir)).data as Record; + const manager = (await initManager([], precedenceDir)).data as Record; + + const pPhase = findPhaseByNum(progress.phases as Record[], 3); + const mPhase = findPhaseByNum(manager.phases as Record[], 3); + + expect(pPhase?.status).toBe('complete'); + expect(mPhase?.disk_status).toBe('complete'); + }); + + it('case 3: ROADMAP [ ] + phase dir + SUMMARY present → disk authoritative (complete)', async () => { + await writePrecedenceRoadmap(precedenceDir, [{ num: '3', name: 'Disk', checked: false }]); + await mkdir(join(precedenceDir, '.planning', 'phases', '03-disk'), { recursive: true }); + await writeFile(join(precedenceDir, '.planning', 'phases', '03-disk', '03-01-PLAN.md'), '# plan'); + await writeFile(join(precedenceDir, '.planning', 'phases', '03-disk', '03-01-SUMMARY.md'), '# done'); + + const progress = (await initProgress([], precedenceDir)).data as Record; + const manager = (await initManager([], precedenceDir)).data as Record; + + const pPhase = findPhaseByNum(progress.phases as Record[], 3); + const mPhase = findPhaseByNum(manager.phases as Record[], 3); + + expect(pPhase?.status).toBe('complete'); + expect(mPhase?.disk_status).toBe('complete'); + }); + + it('case 4: ROADMAP [ ] + stub phase dir + no SUMMARY → not complete', async () => { + await writePrecedenceRoadmap(precedenceDir, [{ num: '3', name: 'Empty', checked: false }]); + await mkdir(join(precedenceDir, '.planning', 'phases', '03-empty'), { recursive: true }); + + const progress = (await initProgress([], precedenceDir)).data as Record; + const manager = (await initManager([], precedenceDir)).data as Record; + + const pPhase = findPhaseByNum(progress.phases as Record[], 3); + const mPhase = findPhaseByNum(manager.phases as Record[], 3); + + // Neither should be 'complete' — preserves pre-existing classification. + expect(pPhase?.status).not.toBe('complete'); + expect(mPhase?.disk_status).not.toBe('complete'); + }); + + it('case 5: ROADMAP [x] + no phase dir → both complete (ROADMAP-only branch preserved)', async () => { + await writePrecedenceRoadmap(precedenceDir, [{ num: '3', name: 'Paper', checked: true }]); + // no directory for phase 3 + + const progress = (await initProgress([], precedenceDir)).data as Record; + const manager = (await initManager([], precedenceDir)).data as Record; + + const pPhase = findPhaseByNum(progress.phases as Record[], 3); + const mPhase = findPhaseByNum(manager.phases as Record[], 3); + + expect(pPhase?.status).toBe('complete'); + expect(mPhase?.disk_status).toBe('complete'); + }); + + it('case 6: completed_count agrees across handlers for the stub-dir [x] case', async () => { + await writePrecedenceRoadmap(precedenceDir, [ + { num: '3', name: 'Stub', checked: true }, + { num: '4', name: 'Todo', checked: false }, + ]); + await mkdir(join(precedenceDir, '.planning', 'phases', '03-stub'), { recursive: true }); + await mkdir(join(precedenceDir, '.planning', 'phases', '04-todo'), { recursive: true }); + + const progress = (await initProgress([], precedenceDir)).data as Record; + const manager = (await initManager([], precedenceDir)).data as Record; + + expect(progress.completed_count).toBe(1); + expect(manager.completed_count).toBe(1); + }); +}); diff --git a/sdk/src/query/init-progress-precedence.test.ts b/sdk/src/query/init-progress-precedence.test.ts deleted file mode 100644 index 52b119e2c..000000000 --- a/sdk/src/query/init-progress-precedence.test.ts +++ /dev/null @@ -1,177 +0,0 @@ -/** - * Regression guard for #2674. - * - * initProgress and initManager must agree on phase status given the same - * inputs. Specifically, a ROADMAP `- [x] Phase N` checkbox wins over disk - * state: a stub phase directory with no SUMMARY.md that is checked in - * ROADMAP reports as `complete` from both handlers. - * - * Pre-fix: initManager reported `complete` (explicit override at line ~451), - * initProgress reported `pending` (disk-only policy). This mismatch meant - * /gsd-manager and /gsd-progress disagreed on the same data. Post-fix: - * both apply the ROADMAP-[x]-wins policy. - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { initProgress, initManager } from './init-complex.js'; - -/** Find a phase by numeric value regardless of zero-padding ('3' vs '03'). */ -function findPhase( - phases: Record[], - num: number, -): Record | undefined { - return phases.find(p => parseInt(p.number as string, 10) === num); -} - -let tmpDir: string; - -const CONFIG = JSON.stringify({ - model_profile: 'balanced', - commit_docs: false, - git: { - branching_strategy: 'none', - phase_branch_template: 'gsd/phase-{phase}-{slug}', - milestone_branch_template: 'gsd/{milestone}-{slug}', - quick_branch_template: null, - }, - workflow: { research: true, plan_check: true, verifier: true, nyquist_validation: true }, -}); - -const STATE = [ - '---', - 'milestone: v1.0', - '---', -].join('\n'); - -/** - * Write a ROADMAP.md with the given phase list. Each entry is - * `{num, name, checked}`. Emits both the checkbox summary lines AND the - * `### Phase N:` heading sections (so initManager picks them up). - */ -async function writeRoadmap( - dir: string, - phases: Array<{ num: string; name: string; checked: boolean }>, -): Promise { - const checkboxes = phases - .map(p => `- [${p.checked ? 'x' : ' '}] Phase ${p.num}: ${p.name}`) - .join('\n'); - const sections = phases - .map(p => `### Phase ${p.num}: ${p.name}\n\n**Goal:** ${p.name} goal\n\n**Depends on:** None\n`) - .join('\n'); - await writeFile(join(dir, '.planning', 'ROADMAP.md'), [ - '# Roadmap', - '', - '## v1.0: Test', - '', - checkboxes, - '', - sections, - ].join('\n')); -} - -beforeEach(async () => { - tmpDir = await mkdtemp(join(tmpdir(), 'gsd-2674-')); - await mkdir(join(tmpDir, '.planning', 'phases'), { recursive: true }); - await writeFile(join(tmpDir, '.planning', 'config.json'), CONFIG); - await writeFile(join(tmpDir, '.planning', 'STATE.md'), STATE); -}); - -afterEach(async () => { - await rm(tmpDir, { recursive: true, force: true }); -}); - -describe('initProgress + initManager precedence (#2674)', () => { - it('case 1: ROADMAP [x] + stub phase dir + no SUMMARY → both report complete', async () => { - await writeRoadmap(tmpDir, [{ num: '3', name: 'Stubbed', checked: true }]); - await mkdir(join(tmpDir, '.planning', 'phases', '03-stubbed'), { recursive: true }); - // stub dir, no PLAN/SUMMARY/RESEARCH/CONTEXT files - - const progress = (await initProgress([], tmpDir)).data as Record; - const manager = (await initManager([], tmpDir)).data as Record; - - const pPhase = findPhase(progress.phases as Record[], 3); - const mPhase = findPhase(manager.phases as Record[], 3); - - expect(pPhase?.status).toBe('complete'); - expect(mPhase?.disk_status).toBe('complete'); - }); - - it('case 2: ROADMAP [x] + phase dir + SUMMARY present → both complete (sanity)', async () => { - await writeRoadmap(tmpDir, [{ num: '3', name: 'Done', checked: true }]); - await mkdir(join(tmpDir, '.planning', 'phases', '03-done'), { recursive: true }); - await writeFile(join(tmpDir, '.planning', 'phases', '03-done', '03-01-PLAN.md'), '# plan'); - await writeFile(join(tmpDir, '.planning', 'phases', '03-done', '03-01-SUMMARY.md'), '# done'); - - const progress = (await initProgress([], tmpDir)).data as Record; - const manager = (await initManager([], tmpDir)).data as Record; - - const pPhase = findPhase(progress.phases as Record[], 3); - const mPhase = findPhase(manager.phases as Record[], 3); - - expect(pPhase?.status).toBe('complete'); - expect(mPhase?.disk_status).toBe('complete'); - }); - - it('case 3: ROADMAP [ ] + phase dir + SUMMARY present → disk authoritative (complete)', async () => { - await writeRoadmap(tmpDir, [{ num: '3', name: 'Disk', checked: false }]); - await mkdir(join(tmpDir, '.planning', 'phases', '03-disk'), { recursive: true }); - await writeFile(join(tmpDir, '.planning', 'phases', '03-disk', '03-01-PLAN.md'), '# plan'); - await writeFile(join(tmpDir, '.planning', 'phases', '03-disk', '03-01-SUMMARY.md'), '# done'); - - const progress = (await initProgress([], tmpDir)).data as Record; - const manager = (await initManager([], tmpDir)).data as Record; - - const pPhase = findPhase(progress.phases as Record[], 3); - const mPhase = findPhase(manager.phases as Record[], 3); - - expect(pPhase?.status).toBe('complete'); - expect(mPhase?.disk_status).toBe('complete'); - }); - - it('case 4: ROADMAP [ ] + stub phase dir + no SUMMARY → not complete', async () => { - await writeRoadmap(tmpDir, [{ num: '3', name: 'Empty', checked: false }]); - await mkdir(join(tmpDir, '.planning', 'phases', '03-empty'), { recursive: true }); - - const progress = (await initProgress([], tmpDir)).data as Record; - const manager = (await initManager([], tmpDir)).data as Record; - - const pPhase = findPhase(progress.phases as Record[], 3); - const mPhase = findPhase(manager.phases as Record[], 3); - - // Neither should be 'complete' — preserves pre-existing classification. - expect(pPhase?.status).not.toBe('complete'); - expect(mPhase?.disk_status).not.toBe('complete'); - }); - - it('case 5: ROADMAP [x] + no phase dir → both complete (ROADMAP-only branch preserved)', async () => { - await writeRoadmap(tmpDir, [{ num: '3', name: 'Paper', checked: true }]); - // no directory for phase 3 - - const progress = (await initProgress([], tmpDir)).data as Record; - const manager = (await initManager([], tmpDir)).data as Record; - - const pPhase = findPhase(progress.phases as Record[], 3); - const mPhase = findPhase(manager.phases as Record[], 3); - - expect(pPhase?.status).toBe('complete'); - expect(mPhase?.disk_status).toBe('complete'); - }); - - it('case 6: completed_count agrees across handlers for the stub-dir [x] case', async () => { - await writeRoadmap(tmpDir, [ - { num: '3', name: 'Stub', checked: true }, - { num: '4', name: 'Todo', checked: false }, - ]); - await mkdir(join(tmpDir, '.planning', 'phases', '03-stub'), { recursive: true }); - await mkdir(join(tmpDir, '.planning', 'phases', '04-todo'), { recursive: true }); - - const progress = (await initProgress([], tmpDir)).data as Record; - const manager = (await initManager([], tmpDir)).data as Record; - - expect(progress.completed_count).toBe(1); - expect(manager.completed_count).toBe(1); - }); -}); diff --git a/sdk/src/query/init.test.ts b/sdk/src/query/init.test.ts index 67113e603..2fd1c66c0 100644 --- a/sdk/src/query/init.test.ts +++ b/sdk/src/query/init.test.ts @@ -757,16 +757,15 @@ describe('initListWorkspaces', () => { }); describe('initRemoveWorkspace', () => { - it('returns error when name arg missing', async () => { - const result = await initRemoveWorkspace([], tmpDir); - const data = result.data as Record; - expect(data.error).toBeDefined(); + // initRemoveWorkspace throws GSDError for validation failures rather than + // returning { data: { error } } — the CLI output path treats error-returns as + // success, so validation must surface as a thrown exception for non-zero exit. + it('throws GSDError when name arg missing', async () => { + await expect(initRemoveWorkspace([], tmpDir)).rejects.toThrow('workspace name required'); }); - it('rejects path separator in workspace name (T-14-01)', async () => { - const result = await initRemoveWorkspace(['../../bad'], tmpDir); - const data = result.data as Record; - expect(data.error).toBeDefined(); + it('throws GSDError for path separator in workspace name (T-14-01)', async () => { + await expect(initRemoveWorkspace(['../../bad'], tmpDir)).rejects.toThrow('path separators not allowed'); }); }); diff --git a/sdk/src/query/init.ts b/sdk/src/query/init.ts index 7c36a23fa..90a861efb 100644 --- a/sdk/src/query/init.ts +++ b/sdk/src/query/init.ts @@ -164,16 +164,34 @@ function computeExpectedPhaseDirName( async function shouldDropArchivedPhaseMatch( phaseInfo: Record | null, roadmapPhase: Record | null, - _projectDir: string, - _workstream?: string, + projectDir: string, + workstream?: string, ): Promise { // Matches CJS cmdInitPlanPhase / cmdInitExecutePhase / cmdInitVerifyWork: // if (phaseInfo?.archived && roadmapPhase?.found) phaseInfo = null; - // Unconditional drop — the ROADMAP is authoritative for the current milestone, - // regardless of what archived milestone the on-disk match came from. Do NOT add - // a milestone-version equality check (#2391 regression risk). + // ROADMAP is authoritative for the current milestone, regardless of what + // archived milestone the on-disk match came from — BUT see #3469 exception. if (!phaseInfo?.archived) return false; if (!roadmapPhase || !roadmapPhase.found) return false; + + // #3469: If the archived phase belongs to the CURRENT milestone (e.g. phases + // were cleared via `phases clear` into .planning/milestones/-phases/ + // but the workflow is still on that same milestone), preserve the archived dir + // as the canonical phase location. Only drop when the archived version is from + // a PRIOR milestone (the original anti-ghost-phase guard). + // Note: the milestone-version equality check here does NOT regress #2391 + // because in the #2391 scenario archived.version != current milestone. + const pp = planningPaths(projectDir, workstream); + try { + const stateContent = readFileSync(pp.state, 'utf-8'); + const milestoneMatch = stateContent.match(/^milestone:\s*(.+)$/m); + const currentMilestone = milestoneMatch ? milestoneMatch[1].trim() : null; + if (currentMilestone && phaseInfo.archived === currentMilestone) { + // Same milestone — archived dir is the canonical location. Keep it. + return false; + } + } catch { /* STATE.md unreadable — fall through to default drop */ } + return true; } diff --git a/tests/init-manager-deps.test.cjs b/tests/init-manager-deps.test.cjs deleted file mode 100644 index 25f843a4d..000000000 --- a/tests/init-manager-deps.test.cjs +++ /dev/null @@ -1,120 +0,0 @@ -/** - * Tests for bug #2267: deps_satisfied should include phases from shipped milestones. - * - * Root cause: completedNums was built only from the current milestone's phases, - * so a dependency on a phase from a previously shipped milestone was never - * satisfied — even though all prior-milestone phases are complete by definition. - */ - -const { describe, test, beforeEach, afterEach } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const path = require('path'); -const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); - -describe('init manager — cross-milestone dependency satisfaction', () => { - let tmpDir; - - beforeEach(() => { - tmpDir = createTempProject(); - }); - - afterEach(() => { - cleanup(tmpDir); - }); - - /** - * Write a ROADMAP.md that has: - * - A shipped previous milestone (v1.0) inside a
block containing - * Phase 5 marked [x] complete. - * - A current active milestone (v2.0) containing Phase 6 that depends on - * Phase 5. - */ - function writeRoadmapWithShippedMilestone(dir) { - const content = [ - '# Roadmap', - '', - '
', - 'v1.0 — Initial Release (Shipped)', - '', - '## Roadmap v1.0: Initial Release', - '', - '- [x] **Phase 5: Auth**', - '', - '### Phase 5: Auth', - '**Goal:** Add authentication', - '', - '
', - '', - '## Roadmap v2.0: Dashboard', - '', - '- [ ] **Phase 6: Dashboard**', - '', - '### Phase 6: Dashboard', - '**Goal:** Build dashboard', - '**Depends on:** Phase 5', - '', - ].join('\n'); - - fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), content); - } - - function writeStateWithMilestone(dir, version) { - fs.writeFileSync( - path.join(dir, '.planning', 'STATE.md'), - `---\nmilestone: ${version}\n---\n# State\n` - ); - } - - test('phase depending on a shipped-milestone phase has deps_satisfied: true', () => { - writeRoadmapWithShippedMilestone(tmpDir); - writeStateWithMilestone(tmpDir, 'v2.0'); - - const result = runGsdTools('init manager', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - - // Only the current milestone's phases should appear in the phases array - assert.strictEqual(output.phases.length, 1, 'Should have exactly one phase from the current milestone'); - - const phase6 = output.phases[0]; - assert.strictEqual(phase6.number, '6', 'Should be Phase 6'); - - // Phase 6 depends on Phase 5 from the prior milestone — must be satisfied - assert.strictEqual( - phase6.deps_satisfied, - true, - 'Phase 6 dep on shipped Phase 5 should be satisfied' - ); - }); - - test('phase depending on a non-existent phase has deps_satisfied: false', () => { - writeRoadmapWithShippedMilestone(tmpDir); - writeStateWithMilestone(tmpDir, 'v2.0'); - - // Add a second phase in the current milestone that depends on a phantom phase - const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); - const existing = fs.readFileSync(roadmapPath, 'utf-8'); - const withExtra = existing + [ - '### Phase 7: Extra', - '**Goal:** Extra work', - '**Depends on:** Phase 99', - '', - ].join('\n'); - fs.writeFileSync(roadmapPath, withExtra); - - const result = runGsdTools('init manager', tmpDir); - assert.ok(result.success, `Command failed: ${result.error}`); - - const output = JSON.parse(result.output); - const phase7 = output.phases.find(p => p.number === '7'); - assert.ok(phase7, 'Phase 7 should be in the output'); - - assert.strictEqual( - phase7.deps_satisfied, - false, - 'Phase 7 dep on non-existent Phase 99 should not be satisfied' - ); - }); -}); diff --git a/tests/init-manager.test.cjs b/tests/init-manager.test.cjs index 5e5eae6e1..b2ab6a49a 100644 --- a/tests/init-manager.test.cjs +++ b/tests/init-manager.test.cjs @@ -596,3 +596,118 @@ describe('init manager', () => { assert.ok(output.recommended_actions.length > 0, 'recommended_actions should not be empty when non-backlog phases remain'); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// Bug #2267: deps_satisfied should include phases from shipped milestones. +// +// Root cause: completedNums was built only from the current milestone's phases, +// so a dependency on a phase from a previously shipped milestone was never +// satisfied — even though all prior-milestone phases are complete by definition. +// ───────────────────────────────────────────────────────────────────────────── + +describe('init manager — cross-milestone dependency satisfaction (#2267)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + /** + * Write a ROADMAP.md that has: + * - A shipped previous milestone (v1.0) inside a
block containing + * Phase 5 marked [x] complete. + * - A current active milestone (v2.0) containing Phase 6 that depends on + * Phase 5. + */ + function writeRoadmapWithShippedMilestone(dir) { + const content = [ + '# Roadmap', + '', + '
', + 'v1.0 — Initial Release (Shipped)', + '', + '## Roadmap v1.0: Initial Release', + '', + '- [x] **Phase 5: Auth**', + '', + '### Phase 5: Auth', + '**Goal:** Add authentication', + '', + '
', + '', + '## Roadmap v2.0: Dashboard', + '', + '- [ ] **Phase 6: Dashboard**', + '', + '### Phase 6: Dashboard', + '**Goal:** Build dashboard', + '**Depends on:** Phase 5', + '', + ].join('\n'); + + fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), content); + } + + function writeStateWithMilestone(dir, version) { + fs.writeFileSync( + path.join(dir, '.planning', 'STATE.md'), + `---\nmilestone: ${version}\n---\n# State\n` + ); + } + + test('phase depending on a shipped-milestone phase has deps_satisfied: true (#2267)', () => { + writeRoadmapWithShippedMilestone(tmpDir); + writeStateWithMilestone(tmpDir, 'v2.0'); + + const result = runGsdTools('init manager', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + + // Only the current milestone's phases should appear in the phases array + assert.strictEqual(output.phases.length, 1, 'Should have exactly one phase from the current milestone'); + + const phase6 = output.phases[0]; + assert.strictEqual(phase6.number, '6', 'Should be Phase 6'); + + // Phase 6 depends on Phase 5 from the prior milestone — must be satisfied + assert.strictEqual( + phase6.deps_satisfied, + true, + 'Phase 6 dep on shipped Phase 5 should be satisfied' + ); + }); + + test('phase depending on a non-existent phase has deps_satisfied: false (#2267)', () => { + writeRoadmapWithShippedMilestone(tmpDir); + writeStateWithMilestone(tmpDir, 'v2.0'); + + // Add a second phase in the current milestone that depends on a phantom phase + const roadmapPath = path.join(tmpDir, '.planning', 'ROADMAP.md'); + const existing = fs.readFileSync(roadmapPath, 'utf-8'); + const withExtra = existing + [ + '### Phase 7: Extra', + '**Goal:** Extra work', + '**Depends on:** Phase 99', + '', + ].join('\n'); + fs.writeFileSync(roadmapPath, withExtra); + + const result = runGsdTools('init manager', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + + const output = JSON.parse(result.output); + const phase7 = output.phases.find(p => p.number === '7'); + assert.ok(phase7, 'Phase 7 should be in the output'); + + assert.strictEqual( + phase7.deps_satisfied, + false, + 'Phase 7 dep on non-existent Phase 99 should not be satisfied' + ); + }); +});