* fix(#663): resolve open CodeQL/Dependabot security alerts (ReDoS, prototype pollution, workflow perms, qs DoS) (#665) * fix(#663): resolve open CodeQL/Dependabot security alerts - ReDoS: collapse ambiguous nested quantifiers in phase-heading regexes (verify/validate/commands) and the plan-filename lookahead (phase) to provably-equivalent non-backtracking forms - prototype pollution: guard __proto__/constructor/prototype in setConfigValue - remove dead no-op .replace(/-/g,'-') in phase.cts - escape all regex metachars in bug-2839 test - add contents:read permissions to security-scan + install-smoke workflows - pin qs >= 6.15.2 via overrides (DoS GHSA) - broaden prompt-injection allowlist to translated security-model docs Closes #663 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#663): regression tests for prototype-pollution guard and roadmap-phase ReDoS Behavioral test that config-set rejects __proto__/constructor/prototype keys without polluting Object.prototype, plus a ReDoS guard (timing-bound) and behavior-preservation assertions for the collapsed phase-heading regexes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#663): make ReDoS regression assert structured result, not elapsed time Replace elapsed-time assertions (which tripped local/no-elapsed-assertion ESLint rule and were unsound for synchronous ReDoS) with structured-result assertions on adversarial inputs: assert that malformed phase headings/ unchecked-item lines without a terminating colon/space yield an empty Set, which is both the correct behavior and an exercise of the fixed linear regex on the catastrophic-backtracking input shape. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#663): add Security changeset fragment for #665 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#663): fold prototype-pollution regression into config.test.cjs The standalone bug-663-config-prototype-pollution.test.cjs was a 9th config-module test file, tripping lint-test-file-count (the allowlist is ratcheted and must not grow). Consolidated into config.test.cjs instead. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * chore(#660): bump next to 1.3.1-dev.0 (-dev stream per ADR 660) (#672) After the 1.3.0 release, next moves onto the -dev prerelease stream so the trunk self-identifies as unreleased (floor = next patch). First manual exercise of the ADR-660 release model. Refs #660 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * ci(#660): use scoped GSD_BOT_PR_TOKEN for backmerge & release merge-back PR creation (#673) The open-gsd org blocks the Actions GITHUB_TOKEN from creating PRs, so auto-backmerge and the release finalize merge-back PR steps can't open their PRs (must be done manually). Point those two steps at a scoped secret (pull-requests:write + contents:write), falling back to GITHUB_TOKEN so behavior is unchanged until the secret is added. Refs #660 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix: accept published installer migration checksums * fix(#670): self-healing recovery for installer-migration checksum drift (#675) Editing the body of an already-released installer migration drifts its computed checksum (it hashes plan.toString()). The integrity guard then hard-aborted every prior install on upgrade with "applied migration checksum changed" — a 100% reproducible blocker (v1.3.0, all platforms). Already-applied migrations are filtered out of `pending` and never re-run, so a drifted checksum is functionally inert. ADR-0008 anticipates checksum-mismatch state as something the install-state layer must handle gracefully (plan -> apply -> recover/report), not abort on. This supersedes the published-checksum allowlist merged in #674 (per-release maintenance debt — every historical checksum hand-pinned, still throws for any unregistered value) with a general, self-healing recovery: - Replace the throwing guard with non-fatal `collectAppliedChecksumDrift`, surfaced on `plan.checksumDrift`. - Reconcile drifted stored checksums durably on the next state write (`reconcileDriftedChecksums`), idempotently (no perpetual writes). - Relocate the "shipped migration bodies are immutable" rule to a CI baseline test that locks every shipped migration's checksum and fails on body drift — where #615 should have been caught, instead of blocking users. Removes #674's legacyChecksums field, per-migration checksum pins, published-checksums.json fixture, and compat test. Fixes #670 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix(#676): consolidate hotfix into release.yml (delete standalone hotfix workflow) (#678) * fix(#676): consolidate hotfix into release.yml; delete standalone hotfix workflow npm allows only one trusted publisher per package and it is release.yml, so the standalone hotfix.yml (token-auth) could never publish via OIDC (ENEEDAUTH on the v1.3.1 finalize). Fold the patch/hotfix path into release.yml — the sole OIDC trusted publisher — and delete hotfix.yml. - validate-version accepts X.Y.Z (Z>0) → hotfix/X.Y.Z + base_tag; rejects rc for hotfixes; X.Y.0 still → release/X.Y.0. - create branches hotfix/X.Y.Z from the base tag with optional auto-cherry-pick (default on) of fix:/chore: from next; release path unchanged. - finalize is branch-agnostic already and publishes @latest via the existing OIDC trusted publisher (no NODE_AUTH_TOKEN). - CONTRIBUTING branching table updated; hotfix.yml removed. Fixes #676 Supersedes #677. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#676): add hotfix/patch path to release.yml (OIDC trusted publisher) The companion to the hotfix.yml deletion: release.yml now handles patch versions (X.Y.Z) via hotfix/X.Y.Z branches and publishes @latest through the existing OIDC trusted publisher. CONTRIBUTING branching table updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix(#676): update tests/docs referencing deleted hotfix.yml (#680) hotfix.yml was deleted (folded into release.yml). Remove the now-broken release-coverage-scope and policy-release-no-npm-self-upgrade assertions that readFileSync'd hotfix.yml (release.yml equivalents retained), drop the dead install-smoke.yml path trigger, and update VERSIONING.md / docs/branching.md prose to describe hotfixes via the Release workflow with a patch version. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix: bump hono to 4.12.23 on next to clear moderate advisory Same moderate hono advisory (GHSA-3hrh-pfw6-9m5x et al.) that blocked the 1.3.1 hotfix is present on next (was 4.12.19); bump to keep the npm-audit gate green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#690): promote CHANGELOG 1.3.x + gate release-notes promotion (#694) * fix(#690): promote CHANGELOG 1.3.x + gate release-notes promotion /gsd:update showed an empty "What's New" preview after updating to 1.3.1 because CHANGELOG.md's 1.3.x content was never promoted out of [Unreleased] into dated sections, so `scripts/changeset/cli.cjs extract` returned exit 2 ("no releases in range"). - CHANGELOG.md: split [Unreleased] into dated [1.3.0] and [1.3.1] sections (1.3.1 = hono advisory bump + installer-migration checksum self-heal, #670; 1.3.0 = the feature release), restoring an empty [Unreleased]. - scripts/changeset/cli.cjs: new `verify` subcommand that exits non-zero when CHANGELOG has no dated `## [x.y.z]` heading for a version; hoist shared stripV/resolveChangelogPath helpers used by extract + verify. - .github/workflows/release.yml: gate the finalize job on `verify` (after the build, before tag/publish) so an unpromoted CHANGELOG can never ship again. - gsd-core/workflows/update.md: move `rm -f $CHANGELOG_TMP` after the human-readable extract re-run so the preview no longer degrades to "(changelog unavailable)". - tests: regression guard for the 1.3.x headings + extract range + verify command coverage (present/absent/undated/v-prefixed/--json/prerelease). Closes #690 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#690): add changeset fragment for #694 Fixed-type fragment for the user-facing /gsd:update preview fix and the release-notes promotion gate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix(#698): make auto-backmerge main→next actually land (admin-merge via PAT) (#699) The Auto Back-Merge workflow could open a back-merge PR but never landed it, and silently reported success on failure. Fixes: - Merge via admin bypass using GSD_BOT_PR_TOKEN (the PAT) instead of auto-merge, since back-merge PRs structurally can't satisfy next's required checks (Issue-link / PR-template / changeset-lint). - Stop swallowing create/merge failures with "|| echo ::warning" — real failures now fail the job. (That greenwashing hid the whole bug.) - Resolve the PR number with `--jq '.[0].number // empty'` (a no-match returns the string "null", not empty) and capture a freshly-created PR's number from the create URL to avoid GitHub API eventual-consistency races. - Merge the exact PR number (env-bound) rather than by branch name. - Force-push the disposable SHA-named bot branch, guarded by a chore/backmerge-main-to-next-* name check so a mislabeled PR can't redirect the force-push. - Apply labels non-fatally so a missing label can't abort PR creation. - On a genuine merge conflict, fail loudly (::error + exit 1) instead of pushing an empty branch and opening a PR with no diff. Closes #698 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix(#637): route 3 more workflows through gsd_run launcher (hardcoded $HOME sweep) (#642) * fix(#637): route 3 more workflows through gsd_run launcher (hardcoded $HOME sweep) The hardcoded `node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs"` invocation form fixed in plan-phase.md (#621) survived in three more workflows. Same bug class: on a global/shim-only install with no project-local runtime, the hardcoded path can miss a working install, so the step reports the tool "not found" instead of resolving it via the launcher. #3668 introduced gsd_run resolution; these sites were missed. - plan-review-convergence.md: convert the 3 hardcoded invocations (init, roadmap get-phase, state planned-phase) to gsd_run. File already carried the canonical preamble (first gsd_run is the earlier convergence-enabled check). - ingest-docs.md, spec-phase.md: convert their hardcoded invocations to gsd_run and inject the canonical launcher preamble via `node scripts/sync-runtime-launcher.cjs` (these files previously had no gsd_run and no preamble). The injected preamble is byte-equal to _runtime-launcher.snippet.sh and precedes the first gsd_run call, per runtime-launcher-parity invariant (B). - Add tests/bug-637-workflow-no-hardcoded-home-tool.test.cjs: repo-wide regression guard asserting NO workflow .md invokes gsd-tools via a hardcoded $HOME path. Generalizes the plan-phase-only guard from #621 — the parity test guards retired $GSD_SDK / bare /gsd-tools tokens but not this form, which is how it survived across four files. Fails on the pre-fix files, passes after. runtime-launcher-parity 7/7; full unit suite green (3477 pass / 0 fail). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(#637): add changeset fragment for PR #642 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(#637): update stale bug-2801 assertion to expect gsd_run bug-2801 pinned ingest-docs.md to the hardcoded node "$HOME/.../gsd-tools.cjs" init form, which #637 replaces with the gsd_run launcher. Flip the assertion to expect gsd_run init ingest-docs; the bare-gsd-tools rejection and CLI-handler tests are unchanged, and bug-637's repo-wide guard now owns the no-hardcoded-$HOME invariant. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Tom Boucher <trekkie@nomorestars.com> * fix(#705): route hardcoded $HOME gsd-tools invocations in agents/commands through gsd_run (#707) * fix(#705): route hardcoded $HOME gsd-tools invocations in agents/commands through gsd_run The hardcoded `node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" <cmd>` form (fixed for workflows in #621/#637) survived in agent/command surfaces and misresolves on global/shim-only installs. Route every agent-executed invocation through the resolved `gsd_run` launcher in gsd-phase-researcher, gsd-planner (load_graph_context extracted to a shared reference to stay under the planner size budget), import, and graphify. Add a regression guard over agents/ + commands/ + gsd-core/references/ bash blocks. User-facing display messages and docs are intentionally left untouched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#705): use repo changeset fragment format (type: Fixed, pr: 707) The hand-written fragment used the standard changesets package format (package: bump) which lacks the type:/pr: frontmatter the repo's docs-required lint consumes (fail_malformed_fragment / missing_type). Regenerated via scripts/changeset/new.cjs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix(#685): set windowsHide on all Windows child-process spawns (#688) * fix(#685): set windowsHide on all Windows child-process spawns A visible "gsd-core" console window flashed on Windows whenever a gsd-core child process spawned without `windowsHide: true`. The most visible offenders fire on every SessionStart / `/clear` (execNpm's `shell:true` npm view via the update-check worker) and on every Edit/Write/MultiEdit in a worktree (the worktree-path guard's git probe). Add `windowsHide: true` to every external-binary spawn in the runtime source: - hooks/gsd-context-monitor.js (record-session spawn) - hooks/gsd-worktree-path-guard.js (SPAWNOPT) - hooks/gsd-workflow-guard.js (git branch --show-current) - src/shell-command-projection.cts (execGit / execNpm / execTool) - src/check-command-router.cts (git log execFileSync) - src/roadmap-upgrade.cts (git status/rev-parse/reset/clean execSync) gsd-check-update.js already had it (the precedent). probeTty's tty call is POSIX-only and intentionally untouched. Adds a regression test that asserts each site plus a repo-wide completeness guard so a future external-binary spawn that omits windowsHide fails CI. No behavior change off-Windows. Closes #685 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#685): set changeset pr number to 688 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix(#687): bound agy print mode with its native --print-timeout (#689) `/gsd-review --agy` hung indefinitely on large prompts. agy's print mode runs the full tool-enabled agent, and on a big, file-path-rich prompt its agentic Cascade loops on the code_search/grep tool and never converges; the transcript fallback only runs after agy exits, so it can't recover a run that never exits. The agy CLI exposes no per-tool deny (that lives in the Antigravity SDK), but it does expose --print-timeout — agy's native print-mode cap. Pass it explicitly so a stalled run self-terminates through the tool's own mechanism; a non-zero exit discards any partial output so the existing transcript fallback / "review failed" stub take over. Adds a regression test. Closes #687 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix(#669): /gsd-review --cursor actually invokes cursor-agent (#686) * fix(#669): /gsd-review --cursor actually invokes cursor-agent The Cursor reviewer branch in review.md never ran the agent: - detection probed `cursor` (the IDE launcher) instead of the headless `cursor-agent` binary - the invocation used the two-token `cursor agent` (the IDE treats `agent` as a file-path argument, so the agent never starts) - the prompt was piped via stdin, but `cursor-agent -p` reads the prompt from a command-line argument, and `2>/dev/null` hid the empty result Probe `cursor-agent`; invoke `cursor-agent -p --mode ask --trust --output-format text` with the prompt passed as a file-path-reference argument (avoids the OS arg-length limit on large prompts); capture stderr so failures are diagnosable. Invert tests/cursor-reviewer.test.cjs to assert the corrected contract, with negative guards against the two-token form and the stdin pipe. The sibling `agy` reviewer already used the argument form. Closes #669 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#669): set changeset pr number to 686 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * chore: archive 463 shipped changeset fragments before wiring render (#714) CHANGELOG promotion was a manual operator step that was never run, so 463 fragments for work already shipped in <=1.3.1 accumulated in .changeset/. Their notes were already hand-curated into the dated [1.2.0]/[1.3.0]/[1.3.1] CHANGELOG sections (#690 backfill, PR #694). Rendering them now would duplicate and mis-attribute shipped work. Move them to .changeset/archived/ (read non-recursively by all changeset tooling, so never rendered), keeping only the 3 genuinely-unreleased fragments at the top level. Prep for wiring `render` into the release finalize job (#690 follow-up). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * chore(release): wire CHANGELOG render into release finalize job (#690 follow-up) (#715) * feat(#690): wire CHANGELOG render into release finalize job CHANGELOG promotion has always been a manual operator step, which is why 1.3.0/1.3.1 shipped unpromoted (#690). PR #694 added a `verify` latch that fails a release lacking a dated heading, but nothing performed the promotion. Wire `changeset render` into the finalize job, after build/test and before the verify gate, committing the promoted CHANGELOG so it ships with the release. Add a `--allow-empty` flag to cmdRender so a zero-fragment release still emits a dated heading (with a '_No notable changes._' placeholder) instead of writing nothing and tripping the verify gate. Note: requires the changeset-archive cleanup (separate PR) to land first, so the first render consumes only genuinely-unreleased fragments. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#713): set changeset pr number to 715 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * docs: document --only and --text flags for /gsd-autonomous (#695) (#716) Adds --only N and --text to the COMMANDS.md reference table and the run-phases-autonomously how-to guide, revised to fit the Diataxis framework (reference: factual/parallel rows; how-to: goal-framed sections). * feat(#656): Research module — content-addressed cache + provider seam + registry-API legitimacy (#664) * feat(#656): add Research Store module (content-addressed cache, TTL staleness) Content-addressed research cache behind a clock seam: researchKey (sha256, deterministic), putResearch/getResearch ({hit,stale}, never throws), ttlForSource (curated HIGH 30d / MED 7d / web LOW 1d), two-tier resolveStorePath (curated -> ~/.gsd/research-cache, web/synthesis -> project .planning/research/.cache). 28 behavioral + property tests; boundary coverage at ttl-1/ttl/ttl+1. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(#656): add Research Provider module (waterfall + confidence + plan) Single source of truth for the Balanced provider waterfall (docs Context7->Ref->Jina, web Exa+Tavily, fallback Perplexity/Brave, Firecrawl scrape-only). classifyConfidence stamps HIGH|MEDIUM|LOW by provider (never throws). providerAvailability maps config flags to usable providers. planResearch checks the Research Store (injected seam) and returns cache-hits + a per-question fetch plan, falling through the waterfall to the always-available websearch terminal. 22 behavioral + property tests. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(#656): add Package Legitimacy module (registry-API verdicts, slopcheck optional) Replaces the pip-install-or-degrade slopcheck prose gate with code: classifyPackage (pure, never throws) computes OK|SUS|SLOP from tunable thresholds (minAgeDays 30, minWeeklyDownloads 1000, requireRepo). checkPackages queries injectable npm/PyPI/crates registry adapters (real https with 5s timeout, degraded-not-thrown on failure); slopcheck is one optional adapter that can only escalate severity, never degrade to [ASSUMED]. 34 behavioral + property tests; boundary coverage on age and downloads (limit-1/limit/limit+1). Known follow-up: real npm adapter must add api.npmjs.org last-week downloads fetch (currently null -> unknown-downloads). Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(#656): detect Tavily/Ref/Perplexity/Jina provider keys; complete npm downloads adapter config: add tavily_search/ref_search/perplexity/jina availability flags (env var or ~/.gsd/<x>_api_key), mirroring brave_search/exa_search/firecrawl, so the Research Provider waterfall can gate them. package-legitimacy: real npm adapter now fetches api.npmjs.org last-week downloads (bounded, degraded-not-thrown) so weeklyDownloads is populated. +12 config tests; 34 legitimacy tests unchanged. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(#656): expose Research seam via gsd-tools query (research-plan, research-store, package-legitimacy) Routes the L2-hybrid surface so agents reach it as CLI: 'query research-store get/put' (cache, HOME-sandboxable), 'query research-plan --input' (cache-hits + fetch plan from planResearch), 'query package-legitimacy check --ecosystem' (async registry verdicts). Commands skip .planning root resolution and appear in top-level usage. 5 behavioral runGsdTools tests; command-contract unchanged (335). Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(#656): document Research module (CONTEXT predicates, ADR-0656, architecture, changeset) Adds GSD-RESEARCH.* + DEFECT.RESEARCH-PROVIDER-PROSE-DRIFT predicates to CONTEXT.md, ADR-0656 recording the L2-hybrid seam decision, a docs/ARCHITECTURE.md Research Module subsection, and an Added changeset fragment (pr:0, backfill on PR). Notes the #657 deferrals (agent collapse + install.js MCP mapping). Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(#656): sync inventory for research modules Regenerate INVENTORY-MANIFEST.json and bump docs/INVENTORY.md CLI Modules count 82->85 with rows for research-store/research-provider/package-legitimacy (DEFECT.INVENTORY-DRIFT). Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(#656): eslint-ignore generated research .cjs artifacts (ADR-457) research-store/research-provider/package-legitimacy .cjs are tsc-generated from src/*.cts, so they belong in the ESLint ignore block (lint the .cts source, not the emitted .cjs). Fixes tests/551-eslint-bin-lib-coverage. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(#656): backfill changeset pr number to #664 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(#656): satisfy eslint lint-tests gate Fix 20 eslint errors in the new research files: use helpers.cleanup() instead of raw fs.rmSync() in tests (local/no-raw-rmsync-in-tests, Windows-EBUSY retry budget); drop redundant '| string' union members and unnecessary type assertions; deterministic object normalization in researchKey (no-base-to-string). Logic unchanged; 6180 tests still green. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#656): harden package legitimacy per review (W1/W2/I3/I4) W1: httpsGet now reads statusCode; npm/PyPI/crates map 404 -> exists:false -> SLOP (registry-existence is the #1 slopsquatting defense; previously only npm caught it). Transport made injectable (_setHttpGet) for hermetic 404 tests. W2: suspicious-postinstall is now terminal SLOP independent of the optional slopcheck adapter, and the regex drops the bare https?:// arm (over-fired on esbuild/sharp/node-gyp) for shell-exec/download-exec signatures only. I3: checkPackages now threads version to registry.lookup and adapters verify that specific version exists. I4: moreServerVerdict -> moreSevereVerdict. +11 regression tests (all RED-first); 45 total green. Addresses review by @davesienkowski on #664. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#656): research-store tier coherence + freshness + version TTL (W4/I1/I2/I4) I1: tier now derives from source (curated -> user ~/.gsd, else -> project .planning), not kind, so put-tier and get-tier can't diverge; kind is a key component only. W4: getResearch searches both tiers and returns the freshest (non-stale preferred), never letting a stale curated entry shadow a fresh web one; blank version caps TTL at 1 day (no 30d on version-blind keys). I2: atomic platformWriteSync instead of raw fs.writeFileSync on the shared global path. I4: dropped the dead ttlForSource arm. CLI get now searches both tiers. +5 RED-first regression tests; 38 green. Addresses review by @davesienkowski on #664. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#656): expose classifyConfidence as a CLI route, killing dead code (W3) Adds 'gsd-tools query classify-confidence --provider X [--verified]' so research agents get the confidence tier FROM CODE (provider waterfall + verification lever) instead of asserting it in prose. classifyConfidence previously had no runtime caller. HIGH means 'trusted provider'; --verified raises web results to MEDIUM (verification semantics documented in ADR-0656). +4 behavioral tests. Addresses review by @davesienkowski on #664 (W3). Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#656): close Codex adversarial-review findings (path-traversal, version-age, malformed-cache) HIGH: research key must be 64-hex sha256 (isValidResearchKey) + resolved-path containment check in put/get + CLI validation -> blocks '../../x' arbitrary-file-write. HIGH: package legitimacy now derives publishedAt from the REQUESTED version (npm time[version], PyPI releases[version] upload_time, crates versions[].created_at) so a new malicious version of an old package can't inherit old age and evade 'too-new'. MEDIUM: getResearch validates entry shape (finite fetched_at + positive ttl + required fields) -> malformed cache entry is a miss, not fresh-forever. +regression tests (RED-first); 111 green. Codex adversarial review (required pre-PR gate). Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#656): close code-review correctness findings (1) package-legitimacy CLI now rejects unknown --flags instead of silently consuming the following package as a flag value; only --ecosystem takes a value. (2) crates recent_downloads (90-day) normalized to a weekly figure before the minWeeklyDownloads threshold (was ~13x too lenient). (3) research-plan --input validates parsed JSON is an object with an Array questions before destructuring -> clean usage error instead of an uncaught TypeError on null/bad input. (4) research-store put rejects a flag value that is itself a --flag (no more storing '--source' as content). (5) planResearch skips questions whose text is not a non-empty string instead of emitting question:undefined. +13 RED-first regression tests; 143 green. Code-review gate. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(#657): extract researcher documentation_lookup to shared @-reference 6 researcher agents carried a near-duplicate <documentation_lookup> block; consolidate into gsd-core/references/research-documentation-lookup.md (@-included). Unifies the ctx7 CLI fallback to the safer 'command -v ctx7' guard (drops silent 'npx --yes ctx7@latest' execution in 5 agents). Behavior-preserving dedup; inventory 63->64 references. Phase A of the agent collapse. Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(#657): extract researcher philosophy + verification-protocol to shared @-references philosophy and the pitfalls+pre-submission-checklist common-core were near-duplicated in project/phase researchers; consolidate into gsd-core/references/research-{philosophy,verification-protocol}.md (@-included). phase-researcher keeps its 3 extra checklist items inline. Pre-submission domains checklist made agent-agnostic so project-researcher doesn't lose features/architecture coverage. Write-contract intentionally left inline (bug-214 tests assert it verbatim). Inventory 64->66 refs. Behavior-preserving. Phase A. Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(#657): wire gsd-phase-researcher to the Research seam (Phase B / S1) The phase researcher now CALLS the code seam instead of carrying inline mechanics: provider waterfall -> 'gsd-tools query research-plan' (+ research-store put to cache digests); confidence-tier prose -> 'gsd-tools query classify-confidence'; slopcheck pip-install protocol -> 'gsd-tools query package-legitimacy check'. This makes the Research module a real runtime consumer (validates the seam end-to-end, addresses reviewer S1) and removes the duplicated waterfall/confidence/slopcheck prose. RESEARCH.md output contract, commit step, structured returns, and Phase-A @-includes unchanged. package-legitimacy-gate.test.cjs rewritten prose-grep -> behavioral (asserts the seam invocation). Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(#657): wire gsd-project-researcher to the seam + add tavily/ref/jina MCP tools (Phase C.1) project-researcher now calls gsd-tools query research-plan / classify-confidence (+ research-store put) instead of the inline provider waterfall + confidence-tier prose (mirrors the phase-researcher rewire; no package-legitimacy — phase-only). Output contract (STACK/FEATURES/ARCHITECTURE/PITFALLS/SUMMARY.md + sections, no-commit, structured returns, Phase-A @-includes) unchanged. Adds mcp__tavily/ref/jina__* to the project/phase/ui researcher tools frontmatter (Balanced provider set) so install.js MCP mapping (C.2) has a consumer. Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(#657): cover tavily/ref/jina MCP install handling + frontmatter parity guard (Phase C.2) Investigation: exa/firecrawl have no explicit per-runtime tool-mapping — every mcp__<server>__* except context7 rides the generic passthrough (Copilot lowercases; OpenCode/Cursor/Windsurf/Augment keep as-is; Gemini auto-discovers). tavily/ref/jina are handled identically, no install path broken. Added 12 copilot-install passthrough tests + a mcp-tool-inheritance parity guard (tavily co-declared with exa, jina with firecrawl, ref present across the 3 web researchers) so the MCP set can't drift. No io.github registry ids invented (none sourceable in-repo); documented as a follow-up. 488 tests green. Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(#657): profiles as source of truth for researcher agents + drift-guard (Phase C.3) scripts/research-profiles.cjs declares each of the 7 researcher agents' identity + contract (name, description, color, tools, required @-includes, required gsd-tools seam calls, output-contract markers). scripts/gen-research-agents.cjs --check validates every committed agent against its profile; --write regenerates ONLY the frontmatter from profiles (body untouched) and is a verified no-op against the current agents (zero diff = fidelity). tests/research-agent-profiles.test.cjs is the DEFECT.GENERATIVE-FIX drift guard. Design note: profiles govern the generatable/contract surface rather than destructively regenerating the disparate operational prose bodies (those were deduped via @-includes in Phase A). scripts/ is not inventoried (no inventory change). Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#657): complete agent provider-dispatch + parity guard; align legitimacy field; validate profiles Adversarial-review findings: (HIGH) the seam-wired agents' Step-C dispatch only mapped 6 providers, so a planResearch result of jina/ref/perplexity/brave (reachable via the waterfall fallbacks) had no handling -> agent stall; completed both agents' dispatch to all 9 PROVIDER_WATERFALL ids + a catch-all, and added a parity test asserting agent dispatch stays in sync with research-provider PROVIDER_WATERFALL (DEFECT.GENERATIVE-FIX). (MEDIUM) phase-researcher package-legitimacy JSON example used 'package' but the module returns 'name' -> aligned. (LOW) gen-research-agents checkAgent now returns a clear failure for a malformed profile instead of throwing. +parity/validation tests (RED-first). Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(#656): make classifyConfidence verification-evidence-driven (W3) Confidence conflated provider authority with claim verification — context7/ref stamped HIGH purely by provider identity, and the only verification lever was a self-set --verified flag. Split into two axes: provider authority (static) + verification evidence (code-computed). HIGH now requires ground-truth corroboration (legitimacyVerdict OK), independent of provider; authority alone caps at MEDIUM; SLOP caps at LOW; the self-reported --verified is demoted to a MEDIUM-only web lever. HIGH = corroborated-against-authoritative-source, not a correctness guarantee. Adds --legitimacy-verdict to the classify-confidence CLI; updates CONTEXT.md predicate + ADR-0656 (tier set unchanged, ADR-consistent). Addresses davesienkowski's W3 review on #664. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#656): bind classify-confidence verdict to code, closing CLI self-grading Adversarial review found the new --legitimacy-verdict flag was caller-supplied, so an agent could self-assert OK->HIGH without any real legitimacy check — reintroducing the exact self-grading hole W3 closes. Remove the free flag; the CLI now computes the verdict via checkPackages only when --package/--ecosystem is given (code-computed, not agent-asserted). Update the stale CLI test (context7 alone -> MEDIUM) and extend the property test to vary legitimacyVerdict. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(#717): re-base workflow size budget on bytes + document quality rationale (#719) * feat(#717): re-base workflow size budget on bytes + document quality rationale Re-base tests/workflow-size-budget.test.cjs from line counts to byte counts (matches Codex's 32,768-byte project_doc_max_bytes cap; deterministic, no tokenizer). Tier ceilings: XL=90000, LARGE=54000, DEFAULT=38000, GRACE=3000; discuss-phase target re-expressed as <30 KB. The #597 tighten-only ratchet and per-file budget semantics are preserved unchanged — only the unit swaps. byteCount() uses fs.statSync().size to match `wc -c` (includes trailing newline), deliberately not lineCount()'s newline-stripping. Document the context-rot / attention-budget QUALITY rationale (independent of prompt caching) in the test JSDoc and docs/ARCHITECTURE.md, plus the Goodhart caveat: the byte budget measures one file, so the real goal is bounded *loaded* context — eager @-imports game the proxy; legitimate extraction is lazy. Update CONTEXT.md RULESET.WORKFLOW_SIZE_BUDGET to bytes and remove a stale duplicate ruleset entry that still said "1800 lines". Defers the #3182 MVP-mode split (tracked separately): MVP is a cross-cutting concern woven through plan-phase/execute-phase, not a discrete extractable mode. Closes #717 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(#717): add changeset fragment for byte-budget re-base Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * feat(#159): auto-use existing RESEARCH.md in /gsd:plan-phase --research-phase (#718) * feat(#159): auto-use existing RESEARCH.md in /gsd:plan-phase --research-phase When RESEARCH.md already exists in research-only mode and neither --research nor --view is passed, emit a one-line notice and exit cleanly instead of prompting update/view/skip. This matches the promptless auto-use of standard /gsd:plan-phase <N> (§5.1) and removes the §5.0/§5.1 inconsistency, making AI-agent and CLI invocations non-interactive in the common case. The two explicit-flag escape hatches (--research to refresh, --view to print) cover any deviation. Closes #159 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#159): point changeset fragment at PR #718 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(#159): tighten research-phase reference register (Diataxis) Make the 'no modifier' research-phase entries descriptive rather than imperative and drop the trailing 'pass --research/--view' clauses, which duplicated the adjacent --research/--view documentation. Reference docs describe; the recovery flags are documented in their own entries. The emitted runtime notice in the workflow keeps naming the flags (in-band recovery), unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * chore: clean up clear-cut ESLint warnings (#732) (#734) Pay down pre-existing error→warn lint debt. Removes dead imports/vars, unused functions, redundant regex/string escapes, and stale eslint-disable directives; converts unused `catch (_e)` to optional catch binding (src/*.cts). No behavior change. Lint 345→125 warnings (0 errors); deferred categories (n/no-process-exit, test-sleeps, control-regex) tracked in #732 for follow-up. Full test suite green (0 failures); code-review verified all removals unused and all escape fixes semantics-preserving. Closes #732 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * chore(lint): justify intentional no-control-regex (ANSI strip) + ratchet to error (#737) The 5 no-control-regex warnings are all the same intentional ANSI-color-strip pattern /\x1b\[[0-9;]*m/g across 5 test files. The \x1b (ESC) control char is the required leading byte of an ANSI SGR sequence, so matching it is the whole point of stripping color codes from captured CLI/console output. Add an inline eslint-disable-next-line with justification at each site (not a refactor — the control char is essential, not accidental), then flip no-control-regex from warn to error so the debt can't regrow. Refs #736 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * chore(lint): refactor magic-sleep tests to async waits + ratchet rules to error (#735) Replace raw setTimeout/Atomics.wait synchronization sleeps in 4 test files with a shared async delay()/waitFor() poll-for-condition helper in tests/helpers.cjs, then flip local/no-magic-sleep-in-tests and no-restricted-syntax from warn to error so the debt can't regrow. - tests/helpers.cjs: add delay(ms) + waitFor(predicate, opts), exported - bug-1974: setTimeout backoff -> await delay() - config.test: drop Atomics.wait sleep(); async retry via await delay() - graphify: waitForBuildStatus/cleanupHookRepo async via await delay() - locking-bugs: 3 Atomics.wait poll loops -> await waitFor() - eslint.config.mjs: ratchet both rules warn -> error Refs #733 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix(#704): exclude } and ) from Codex path-rewrite lookbehind (no literal $gsd-core in installs) (#710) * fix(#704): exclude } and ) from Codex path-rewrite lookbehind Shell variable expressions like \${VAR}/gsd-core/ and command-substitution paths like \$(cmd)/gsd-local-patches were being rewritten to \$gsd-core and \$gsd-local-patches respectively because the negative lookbehind in convertSlashCommandsToCodexSkillMentions did not include } or ). Add both characters to the lookbehind set: (?<![a-zA-Z0-9./})]) Also adds regression test: tests/bug-704-codex-launcher-path-corruption.test.cjs Closes #704 * chore: add changeset for #704 * test: use RUNTIME_ROOT_PATH in assertion to eliminate dead-code lint warning Replace the partial hard-coded fragment '}/gsd-core/bin/' with the existing RUNTIME_ROOT_PATH const so the assertion both compiles clean (no unused variable) and self-documents which canonical launcher path must survive Codex conversion intact (#704). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: link changeset to PR #710 --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(#706): skip rescue of already-committed SUMMARY to avoid worktree cleanup merge_failed (#709) * fix(#706): skip rescueSummaryArtifacts when SUMMARY is already committed rescueSummaryArtifacts now probes `git cat-file -e HEAD:<path>` before copying a SUMMARY.md into the main checkout. When the file is already committed on the worktree branch, copying it as an untracked file causes `git merge --no-ff` to abort with "untracked working tree files would be overwritten by merge" — a permanent merge_failed cleanup-wave failure. Fail-closed on timeout: if cat-file is unreliable we skip rescue (the merge will surface the collision as it did before, which is recoverable). Adds 4 new test cases in worktree-safety.test.cjs covering: - committed SUMMARY skipped, merge succeeds (#706 regression case) - committed SUMMARY skipped even when timeout (fail-closed) - uncommitted SUMMARY still rescued (existing contract preserved) - rescue failure on ENOSPC still propagates (unchanged) Closes #706 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: add changeset for #706 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(#706): treat cat-file exit 128 as uncertain — skip rescue (fail-closed) The previous guard skipped rescue only when `exitCode === 0` (committed) or `timedOut`. Any other non-zero exit, including `128` (fatal git error: corrupt object store, unborn HEAD, missing repo), fell through and PROCEEDED with rescue — potentially re-creating the #706 untracked-file merge collision. Fix: rescue ONLY when `exitCode === 1` (cat-file definitively reports the object absent). All other outcomes — 0 (committed), 128 (fatal), null/SIGTERM (timeout), or any other code — are treated as "uncertain → skip rescue". Also corrects the JSDoc bullet that still referenced `git ls-files --error-unmatch` (the old mechanism); updated to `git cat-file -e HEAD:<relPath>`. Regression test added: asserts rescue is SKIPPED when cat-file returns exit 128, leaving the merge to surface the issue safely rather than silently copying an already-committed file. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: link changeset to PR #709 --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(scripts): replace process.exit() with ExitError + runMain handler (#739) (#740) Part 1 of 2 of the n/no-process-exit cleanup (umbrella #738): convert every process.exit() call in standalone scripts/** CLIs to the rule-compliant pattern. - New shared helper scripts/lib/cli-exit.cjs: ExitError(code,message) + runMain() which translates a thrown ExitError / returned number into process.exitCode (never process.exit()), flushing output and still firing process.on('exit'). - main()-based entrypoints: throw new ExitError(code) for errors, return <code> for verdicts; invoked via runMain(main). Child exit codes preserved via return. - top-level-only scripts: imperative body extracted into main() so mid-flow aborts (throw ExitError) actually halt; pure consts/helpers stay at module scope. - diff-touches-shipped-paths.cjs: stdin event handling restructured to an async read so the whole flow runs under runMain; uncaughtException/unhandledRejection nets replaced by an in-band catch that preserves EXIT_ERROR=2. Exit codes verified unchanged for every converted script (success/error/help and the 0/1/2 semantic codes in diff-touches). Rule stays warn here; flipped to error in part 2 (#738) once gsd-core/bin/** is also clean. Refs #739 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * refactor(#720): lazy-load MVP-only reference bodies on non-MVP runs (#746) * 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 <noreply@anthropic.com> * docs(#720): add changeset fragment (pr #746) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * refactor(#712): replace Codex slash-command denylist lookbehind with positive-boundary match (#747) * refactor(#712): replace Codex slash-command denylist lookbehind with positive-boundary match The hyphen-style /gsd-<cmd> -> $gsd-<cmd> conversion in convertSlashCommandsToCodexSkillMentions used a negative-lookbehind DENYLIST enumerating characters that must NOT precede a real mention. #637 -> #704 showed this is an unbounded treadmill: each new unanticipated preceding char (/, ., word chars, then }, )) leaked the same path-corruption bug class, and a backtick-wrapped path (`/gsd-core/workflows/update.md`) still leaked through. Replace it with a POSITIVE two-boundary definition of a mention: 1. Left: opens at start-of-string, whitespace, or an inline-prose delimiter (backtick/quote/paren/bracket). 2. Right: the command token is not followed by a path separator `/` (a path continues, a command does not). The (?![a-z0-9/-]) lookahead also blocks regex backtracking to a shorter command. This closes the whole class by construction (no preceding-char denylist to maintain) and fixes the backtick-wrapped-path corruption the #704 test documented as a pre-existing gap, while preserving conversion of legitimate backtick-wrapped mentions (e.g. CONTEXT.md's `/gsd-execute-phase` lists). The colon-style /gsd: replace is intentionally left unguarded (it never appears as a filesystem path segment) and is annotated as such. Tests assert the regex directly (function now exported) across a convert/ don't-convert matrix plus one end-to-end pipeline assertion for the headline backtick-path case. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#712): add changeset fragment for PR #747 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix(#730): scope current-milestone Phase Details section in roadmap parser (#748) `extractCurrentMilestone()` scoped the current-milestone window to its `## Phases` checklist subsection and terminated at the milestone's own `## Milestone … (Phase Details)` heading, so the `### Phase N:` detail headers fell outside scope. Every parser-backed command — `init.phase-op` (and thus `/gsd:discuss-phase`, `/gsd:plan-phase`), `state`, `roadmap list`, and `validate health` (W006) — therefore could not resolve phases of any milestone after the first until a `.planning/phases/` directory already existed, blocking discuss/plan. The parser now additionally includes the current milestone's `(Phase Details)` section in scope, located via the already-computed version matches and anchored (boundary-aware) to the selected milestone's version token so sibling sub-milestones sharing a version prefix do not cross-pollinate. The existing heading selection and primary window are unchanged. Adds tests/bug-730-milestone-phase-details-scope.test.cjs covering the two-milestone reproduction, first-milestone non-regression, direct getRoadmapPhaseInternal resolution, validate-health W006 visibility, a three-milestone roadmap, and the closed-sibling sub-milestone case. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix(#683): auto-degrade phase execution to sequential on worktree base mismatch (#749) * fix(#683): auto-degrade phase execution to sequential on worktree base mismatch Claude Code forks worktree-isolated executors off the repository default branch (origin/HEAD), not the orchestrator's HEAD. Running /gsd-execute-phase on a branch diverged from the default (unmerged milestone/feature branch) left every executor without the phase's plan files and tripped the worktree-branch-check guard with `exit 42` — 100% reproducible, all OSes. - New module src/worktree-base-ref.cts: HEAD-vs-fork-base drift detection (origin/HEAD with symbolic-ref fallback) and no-clobber worktree.baseRef management, exposed as `worktree base-check` / `worktree set-baseref`. - execute-phase.md: pre-dispatch, for Claude Code with worktrees enabled, auto-degrades the run to sequential on the main tree when a base mismatch is detected, recommending worktree.baseRef:"head". The exit-42 guard stays as a backstop. - Installer: fresh local Claude installs set worktree.baseRef:"head" in .claude/settings.local.json (no-clobber, respecting an explicit shared settings.json value); upgrades print an opt-in notice pointing at `gsd-tools worktree set-baseref`. - Docs: how-to guide, CLI/config reference, planning-config cross-ref. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#683): auto-apply worktree.baseRef on upgrade; gate fresh+upgrade on use_worktrees Per maintainer direction: on a local Claude Code UPGRADE, set worktree.baseRef:"head" automatically (no opt-in notice) when the project's workflow.use_worktrees is enabled, instead of merely printing a remediation notice. For consistency the FRESH path is now gated the same way: both paths compute worktrees-enabled once (bounded walk-up read of .planning/config.json, default enabled unless workflow.use_worktrees === false) and apply the no-clobber baseRef only when enabled — never overwriting an explicit value in settings.local.json or a shared settings.json. gsd-tools worktree set-baseref remains for manual use. Docs + changeset updated; tests hardened (file-exists assertions, fresh+disabled case, upgrade idempotency). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#683): measure workflow byte-budget on LF, fixing Windows-only CI failure The workflow-size-budget test failed only on Windows: git checks out the .md files as CRLF (no eol=lf in .gitattributes) and byteCount used fs.statSync().size (raw on-disk bytes), counting an extra \r per line. That inflated execute-phase.md — the XL high-water-mark file pinned near its ceiling by the tighten-only ratchet — from 88492 LF bytes to ~90245 on Windows, over the 90000 XL ceiling, while passing on the LF-checkout Mac/Linux runners. The ceilings are explicitly "calibrated against raw `wc -c`" on an LF checkout, so the measurement should be LF-based on every platform. byteCount now reads the file and counts Buffer.byteLength after stripping CR, making the budget platform-independent (a no-op on LF checkouts; verified statSync === normalized for all 88 workflow files). No ceilings changed. Added a regression test asserting CRLF and LF content of the same file count identically. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#683): make worktree-base-ref test path mocks Windows-safe (path.join) tests/worktree-base-ref.test.cjs keyed its injected readFile/writeFile mocks (and a few expected `file` values) with forward-slash template literals like `${claudeDir}/settings.local.json`. The module composes those paths with path.join(), which emits backslashes on Windows, so the mock keys never matched the module's lookup → readFile returned null → resolveEffectiveBaseRef / cmdWorktreeBaseCheck / cmdWorktreeSetBaseRef (and the JSONC variants) failed on the Windows full-test runner only (they passed on Mac/Linux, and the install tests passed because they use the real filesystem). The module is correct; only the test fixtures hardcoded '/'. All mock keys and path assertions now use path.join(base, ...) mirroring the module, so they match on every platform (no-op on POSIX). 19 path references across 16 lines. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * feat(#703): add --granularity override flag to /gsd:plan-phase (#750) * feat(#703): add --granularity override flag to /gsd:plan-phase Add a `--granularity <coarse|standard|fine>` flag to /gsd:plan-phase that overrides the configured planning granularity for a single invocation. The override is a new highest-priority tier above the existing precedence chain (granularities[phaseType] -> granularity -> planning.granularity -> 'standard') in resolveGranularityInternal; when the flag is absent, resolution is byte-for-byte unchanged. cmdInitPlanPhase now resolves with phaseType 'planning' so granularities.planning participates, and emits the resolved value in the init JSON, which the plan-phase workflow forwards to the planner prompt. Invalid values are rejected at the CLI boundary via a shared assertValidGranularityOverride helper. Closes #703 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#703): set changeset pr to 750 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix(#751): recognise config-set prototype-pollution guard in CodeQL + test dynamic-key vectors (#752) CodeQL alert #26 (js/prototype-pollution-utility) kept firing on setConfigValue because its dataflow does not trace the #663 Set-based, pre-loop keys.some(...) forbidden-key check as a sanitising barrier on the write site. - src/config.cts: replace the Set + pre-loop check with inline literal comparisons (key === '__proto__' || 'prototype' || 'constructor') on the exact key used to index `current`, immediately before each write (intermediate keys in the descent loop, plus the final key). Same forbidden set, same error message and ERROR_REASON.CONFIG_PARSE_FAILED — behaviour unchanged from #663, but the barrier is now CodeQL-recognised. - tests/config.test.cjs: add regression tests for schema-valid dynamic-prefix keys (agent_skills.__proto__, agent_skills.constructor, agent_skills.prototype, features.__proto__, review.models.constructor) that pass the isValidConfigKey schema gate and reach the guard. Each asserts the guard's own message fires (not the schema gate's "Unknown config key") and Object.prototype is not polluted. The prior #663 tests never reached the guard — their keys are rejected by the schema gate first — so the guard's real attack surface was untested. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * feat(#25): scope gsd-verifier Step 7b to enumerate-or-single-test; forbid full-suite re-runs (#753) * feat(#25): scope gsd-verifier Step 7b to enumerate-or-single-test; forbid full-suite re-runs Step 7b's lone test example (`npm test -- --grep "$PHASE_TEST_PATTERN"`) is mocha/vitest/jest-specific, where `--grep` filters which tests *execute*. Models generalized it to `cargo test --workspace 2>&1 | grep X` (runs the whole suite, filters only *output*) and repeated it once per must-have, adding minutes per verification with no new evidence after the first run. Replace the example with language-agnostic guidance: prove a test EXISTS via enumeration (`cargo test -- --list` / `pytest --collect-only` / `npx vitest list` / `go test -list`), and prove it PASSES via a single named test (`cargo test <name> -- --exact` / `pytest -k` / `npx vitest run -t`). Add a Spot-check constraint forbidding more than one full-suite run per verification or piping a full run through grep per must-have, while still permitting one saved run + grep when a full run is genuinely required. docs/AGENTS.md gains a one-line Key-behaviors note, and a new test asserts the Step 7b content. Scoped per the maintainer decision on the issue: folded into Step 7b (no new top-level Step 7a) with no VERIFICATION.md label changes. Closes #25 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#25): add Changed changeset fragment for PR #753 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * feat(#52): add agent_skills_security.trusted_global_roots allowlist for global skills (#754) * feat(#52): add agent_skills_security.trusted_global_roots allowlist Opt-in allowlist so a global: agent skill whose SKILL.md realpath resolves outside the default global skills base (e.g. ~/.claude/skills) is accepted when its real target lies under a user-declared trusted root. Default [] is byte-identical to prior behavior; the symlink-escape guard is preserved and simply re-applied against each declared root. - src/security.cts: loadTrustedGlobalRoots — tilde-expand (~ and ~/), reject project-relative and dangerously broad roots (filesystem/UNC root, homedir), realpath-canonicalize each root every run and drop non-existent ones. - src/init.cts: on base-check failure the guard consults the trusted roots (hoisted out of the loop); emits a stderr NOTE when a skill is accepted via a trusted root so the widened boundary is visible. - src/core.cts: thread agent_skills_security through loadConfig. - config-schema.manifest.json: allow the new key path. - docs/CONFIGURATION.md: document the option and its security model. - tests/agent-skills.test.cjs: unit + end-to-end CLI coverage (regression, feature, negative, broad-root hardening, stderr NOTE). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(#52): add changeset fragment for trusted_global_roots (#754) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * refactor(#651): consolidate verification-status routing into one queryable seam (#755) * refactor(#651): consolidate verification-status routing into one queryable seam The passed/gaps_found/human_needed verification status was re-encoded as bare strings across three prose surfaces (gsd-verifier emits, execute-phase routes, ship gates), each independently deciding the per-status next action with no parity coupling — the DEFECT.GENERATIVE-FIX class. Give the enum one home: src/verification.cts (-> bin/lib/verification.cjs) exposing `gsd_run query verification.status <phaseDir>` returning a typed {status, next_action, next_command}. ship.md and execute-phase.md now consume the query instead of re-deriving the routing in prose; gsd-verifier.md points at the shared vocabulary as the single emitter (values unchanged). Also fixes the latent broad-grep status misread (DEFECT.FRONTMATTER-SCALAR- BROAD-GREP): execute-phase.md read `grep "^status:"` over the whole report, so a body `status:` line could misroute a valid phase. Extraction is now frontmatter-scoped in one place. A parity test fails if a verifier status gains no route. Lands the two CONTEXT.md DEFECT entries captured on the issue. Closes #651 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#651): set changeset pr to 755 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix(#758): trigger draft-PR auto-close on pull_request_target (#760) Bare `pull_request` hands fork PRs a read-only GITHUB_TOKEN, so the close/comment API calls 403 and a first-time/external contributor's draft PR survives — bypassing the auto-close for exactly the population the job targets. Switch to `pull_request_target`, which runs in the base-repo context with a write-capable token even for fork PRs. Safe because the job never checks out or executes PR-supplied code; it only reads event metadata and calls the GitHub API. The minimal `permissions: pull-requests: write` block still constrains the token. Add a regression guard in tests/workflow-maintainer-skip.test.cjs asserting the workflow triggers on pull_request_target and not bare pull_request. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * docs(#58): add ADR for Runtime Install Policy Module boundary (#762) Record the Runtime Install Policy Module decision and ownership boundary: install policy projects a pure, typed install plan by composing artifact placements (ADR-3660) and command text (ADR-0009) plus per-runtime config intentions, with no filesystem IO; runtime adapters consume the plan and execute concrete file mutations and format-specific config rendering. Explicitly records what stays outside the policy module (TOML/JSON/Markdown serialization, merge semantics, filesystem effects). Adds the ADR index row in docs/adr/README.md and a glossary entry in CONTEXT.md. Leads the installer-refactor chain (#58 -> #60 -> #56). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * feat(#759): non-destructive CHANGELOG preview in the rc release job (#763) The rc action publishes a release candidate to @next for testing but never surfaces the curated CHANGELOG section for the version under test — render only runs destructively at finalize (#715), so there was no safe way to preview the upcoming notes during the RC window. Add a --preview mode to scripts/changeset/cli.cjs cmdRender: it renders the dated release section to stdout via the existing renderChangelog/ serializeChangelog path (with priorChangelog: null, so only the new section is emitted), reuses the shared injectEmptyPlaceholder helper for zero-fragment releases, and returns WITHOUT writing CHANGELOG.md or deleting any .changeset fragment. Wire a "Preview CHANGELOG" step into the rc job that renders to a file (standalone command, so a malformed fragment fails the step) and cats it to the job summary and log. Closes #759 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * feat(#761): add scheduled base-context sweep to close SHA-branch-evading draft PRs (#765) close-draft-prs.yml (on pull_request_target after #760) cannot close fork draft PRs whose head branch name looks like a Git SHA — GitHub never dispatches pull_request_target for such branches, and a pull_request run from a fork gets a read-only token. So a draft PR on a SHA-named fork branch evades the auto-close. Add close-draft-prs-sweep.yml: a schedule (every 6h) + workflow_dispatch sweep running in base-repo context with pull-requests: write that paginates open PRs, filters to non-OWNER/MEMBER/COLLABORATOR drafts, and closes + comments them with the identical policy/message as the event-driven workflow. Re-fetches each candidate before mutating (TOCTOU guard), closes before commenting so enforcement is never gated on the explanatory comment, and core.setFailed on partial failures. The per-PR workflow remains the fast path; this is the safety net for the documented residual bypass. Extends tests/workflow-maintainer-skip.test.cjs with structural guards locking the triggers, write permission, maintainer carve-out, pagination, and message. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix discord release changelog announcements * test(#339): regression guard for gsd-sdk refs in runtime surfaces (#691) * test(#339): add regression guard for gsd-sdk refs in runtime surfaces Lock in the already-clean runtime surface so a retired `gsd-sdk`/`GSD_SDK` reference cannot creep back into a shipped prompt or hook. Scans gsd-core/workflows, gsd-core/references, commands/gsd, agents, and hooks (excluding the gitignored dist/ build artifact). Complements gsd-tools-path-refs.test.cjs, which only catches the `gsd-sdk query` binary form; this catches any runtime reference. A second test guards against an empty sweep so a future dir rename can't silently turn the guard into a no-op. Intentionally does NOT touch bin/install.js (live stale-package-detection mechanics per #339 triage) or CI/lint scripts (legitimate stale detection). Refs #339 * test(#339): split file content on /\r?\n/ for Windows CRLF parity The windows-test-parity-guard lint requires test files that readFileSync + split to use /\r?\n/, not '\n', so CRLF files don't leave a trailing \r. New test files are not in the PR #3649 allowlist. * test(#339): cover .sh/.json runtime files in workflows surface Address #691 review: the gsd-core/workflows surface scanned only .md, silently skipping two deployed runtime files — _runtime-launcher.snippet.sh (synced into every hook) and discuss-phase/templates/checkpoint.json. Add .sh/.json so the guard covers all 290 deployed runtime files (was 288/290), not just the .md subset. * test(#339): cover templates/contexts surfaces + per-ext empty-sweep guard Address PR #691 review (trek-e): - Add gsd-core/templates and gsd-core/contexts to RUNTIME_SURFACES — both are deep-copied by the installer and runtime-loaded via @~/.claude/gsd-core/templates/*.md anchors, so a reintroduced gsd-sdk ref there would have slipped past the guard. (major) - Reword the bin/install.js exclusion rationale: it has zero gsd-sdk refs today (subsystem removed in #515, shim retired in #522); the real reason it is excluded is that it is installer code, not a deployed prompt/hook surface. (minor) - Make the empty-sweep guard assert coverage per configured extension, not per surface — .md files alone kept gsd-core/workflows green even if .sh/.json were dropped, silently un-covering _runtime-launcher.snippet.sh and discuss-phase/templates/*.json. (low) --------- Co-authored-by: Tom Boucher <trekkie@nomorestars.com> * refactor(#60): make runtime config adapter registry explicit (#795) * refactor(#60): make runtime config adapter registry explicit Replace scattered inline `runtime === '...'` config-mutation branching in bin/install.js with an explicit, typed adapter registry. The new src/runtime-config-adapter-registry.cts maps each of the 15 supported runtimes to a config intent { installSurface, writesSharedSettings, finishPermissionWriter }; install()/finishInstall() dispatch by resolved intent instead of runtime-name checks (cursor/windsurf/trae collapse to one profile-marker-only branch). Behavior-preserving: the same config files are written for the same runtimes (opencode still writes both settings.json and its permissions; kilo writes only its permissions; codex minimal-mode and opencode GSD_TEST_MODE guards unchanged). Unknown runtimes fail loudly via TypeError, with an Object.hasOwn barrier so prototype-chain keys (__proto__/constructor) also throw rather than returning a bogus intent. Leads the installer-refactor chain (#58 -> #60 -> #56), building on ADR-58. Closes #60 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#60): add changeset for runtime config adapter registry Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(#60): register Runtime Config Adapter Registry in CONTEXT.md glossary Per docs/contributor-standards.md, every new Module/seam must get a `### <Name>` entry under the domain glossary. Adds the entry for the runtime-config-adapter-registry seam introduced in this PR (interface, policy boundary, source file, ADR-58 / #60 cross-references). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * feat(#764): skip cross-platform test matrix for docs-only and inert-CI PRs (#798) test.yml had no paths filter and the ci-test-scope classifier treated docs/ and every .github/workflows/* as code_changed, so documentation edits and product-irrelevant automation tweaks still spun up the full Linux/Windows/macOS matrix. Narrow the heavy matrix to changes that can actually affect the product or the test pipeline. - ci-test-scope.cjs: drop docs/ from code_changed (docs-only -> full skip; the required-tests fan-in still reports green). Add src/ to code_changed (it was missing -> a source-only PR previously skipped all tests). Add INERT_WORKFLOWS allowlist + isInertCi() + an "inert CI" rule, and a product_changed output that gates the heavy test/coverage jobs. Fail-safe: any workflow not on the inert allowlist defaults to the full matrix. A module-load assertion throws if a PROTECTED_WORKFLOWS entry (test/install-smoke/mutation/security-scan/release) is ever added to the inert set, so a weakening edit fails CI loudly. - test.yml: keep the static 3-lane matrix (so the H1 shell-policy linter can still statically verify the Windows lane), gate test/coverage on product_changed, add a lightweight ubuntu-only test-inert job, and branch the required-tests fan-in on product_changed. - docs-required.yml: run docs-parity-live-registry (gated on docs/ changes) so pure-docs PRs still catch live-registry drift without the matrix. - tests: cover docs-only, inert-only, src/, pipeline, unknown-workflow fail-safe, mixed escalation, the code_changed=false -> no-lanes invariant, and protected- workflow tamper-evidence. Closes #764 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * feat(#766): distribute gsd-core as a native Claude Code plugin (#797) * feat(#766): distribute gsd-core as a native Claude Code plugin Add an additive .claude-plugin/plugin.json manifest plus hooks/hooks.json so gsd-core can be installed as a first-class Claude Code plugin (marketplace or zero-friction @skills-dir), with /gsd-core: namespaced commands and lifecycle management — alongside the unchanged npm/file-copy installer. - .claude-plugin/plugin.json: validated with 'claude plugin validate --strict' - hooks/hooks.json: mirrors the installer's always-on Claude hook wiring via ${CLAUDE_PLUGIN_ROOT} - package.json: ship .claude-plugin in the npm tarball - tests/issue-766-plugin-manifest.test.cjs: manifest + always-on-hook-contract drift guards - docs: install-on-your-runtime.md + FEATURES.md Closes #766 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(#766): add ADR-766 + glossary entry for Claude Code Plugin Manifest Module Record the plugin manifest as the Seam projecting gsd-core's artifact surfaces onto the Claude Code plugin contract (sibling of the Runtime Artifact Layout Module, ADR-3660), with the defined kind->field mapping and the always-on hook projection rule. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * refactor(#56): retire legacy runtime directory helpers into runtime-homes projection (#802) * refactor(#56): retire legacy runtime directory helpers into runtime-homes projection Consolidate per-runtime global config-dir resolution onto the single canonical projection runtime-homes:getGlobalConfigDir. Extend it with the explicitDir override (CLI --config-dir) and the opencode/kilo OPENCODE_CONFIG/KILO_CONFIG file-path precedence the installer helpers had, making it byte-for-behavior equivalent to the old getGlobalDir across all 15 install runtimes. Delete bin/install.js's getGlobalDir/getOpencodeGlobalDir/getKiloGlobalDir (and the orphaned local expandTilde), repoint all 9 call-sites, and remove getGlobalDir from module.exports (net -242 lines in the installer). Migrate the 5 test importers to the canonical projection; harden default/XDG assertions against ambient *_CONFIG env vars. getAgentsDir now respects OPENCODE_CONFIG/KILO_CONFIG consistently with the installer (intentional convergence). Update CONTEXT.md Installer Module entry. Completes the installer-refactor chain #58 -> #60 -> #56. Closes #56 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#56): add changeset for runtime directory helper retirement Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * feat(#786): elevate GitHub Copilot installer — lifecycle hook + AGENTS.md (#804) * feat(#786): elevate Copilot installer with lifecycle hook + AGENTS.md Emit a self-contained sessionStart hook config (.github/hooks/gsd-session.json local, ~/.copilot/hooks/gsd-session.json global) and write AGENTS.md at the repo root (Copilot CLI reads it as primary instructions) alongside copilot-instructions.md. The hook is an inline `command` hook (no separate hook script), so it cannot dangle. Uninstall removes both and preserves user content. Verified against GitHub Copilot CLI primary docs: hooks-configuration (camelCase events, version+hooks shape, inline bash/powershell command hooks) and add-custom-instructions (AGENTS.md read at repo root as primary instructions). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#786): set changeset pr number to 804 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix(#783): resolve Kilo global skills base to ~/.kilo/skills (#806) * fix(#783): resolve Kilo global skills base to ~/.kilo/skills getGlobalSkillsBase('kilo') returned ~/.config/kilo/skills (the XDG config dir), but Kilo Code discovers global skills from ~/.kilo/skills/ (the .kilo dir in HOME), independent of the kilo.jsonc config dir. Add a HOME-relative special case so the resolver matches Kilo's actual discovery path. The config dir (~/.config/kilo) and the installer's command/ path are correct and unchanged. This corrects the path used by doctor/status and agent-skills-block resolution; the installer writes commands (not skills) for Kilo, so no files were being written to the wrong location. Closes #783 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#783): set changeset pr to 806 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * feat(#785): write .cursor/commands/ Cursor 1.6 slash-command surface (#805) * feat(#785): write .cursor/commands/ as Cursor 1.6 slash-command surface Cursor 1.6 (released 2025-09-12) introduced plain-markdown slash commands in `.cursor/commands/<name>.md` — no frontmatter, invocable via `/` in the Agent input. GSD previously emitted only `~/.cursor/skills/` for Cursor. This PR wires a second artifact kind for `cursor` in `runtime-artifact-layout.cts`: `convertedCommandsKind('commands', 'gsd-', 'convertClaudeCommandToCursorCommand', configDir)`. The new kind applies the same `convertClaudeToCursorMarkdown` transforms (tool renames, brand substitution, slash-command normalisation) and then strips YAML frontmatter so the output is plain prose. Skills output is unchanged. `stageCommandsForRuntimeFlat` in `install-profiles.cts` stages each source `.md` as a flat `<stem>.md` in a temp dir; the existing `_copyStaged` commands path then prefixes and copies to `<configDir>/commands/`. `.cursor/mcp.json` is explicitly OUT OF SCOPE: GSD ships no MCP server; the `mcpServers` schema cannot be usefully populated by the installer. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(#785): address review nit --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(#787): elevate Cline — .clinerules/ dir form, PreToolUse hook, AGENTS.md (#803) * feat(#787): elevate Cline — .clinerules/ dir form, PreToolUse hook, AGENTS.md Migrate the installer's Cline output from a single-file .clinerules to the .clinerules/ directory form (.clinerules/gsd.md), which is the prerequisite for Cline's v3.36 hooks (a path cannot be both a file and a directory). Add a .clinerules/hooks/PreToolUse lifecycle hook implementing Cline's JSON stdin -> {cancel,errorMessage,contextModification} protocol; it guards .planning/ artifacts and fails open. On global installs, merge GSD instructions into the cross-tool ~/.agents/AGENTS.md target (marker-delimited, merge-safe). A legacy single-file .clinerules is migrated in place; --uninstall removes the new artifacts and strips the AGENTS.md GSD block. Also fixes the uninstall targetDir for Cline local installs (it pointed at ./.cline instead of the project root) and re-runs writeManifest after the Cline artifacts are written so they are hash-tracked. Self-contained: implemented independently of the #782 Cline skills work. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#787): address review findings - Scope PreToolUse hook path-walk to PATH_KEY fields only (eliminates false positive when doc body content mentions .planning/) - Use lstatSync + isSymbolicLink() for migration guard so GSD never writes through a user's symlinked .clinerules into an external directory - Add regression tests for both cases Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(#787): set changeset pr: 803 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix(#812): honor COPILOT_HOME in Copilot global config-dir resolution (#814) * fix(#812): honor COPILOT_HOME in Copilot global config-dir resolution getGlobalConfigDir('copilot') resolved the global config directory using only --config-dir > COPILOT_CONFIG_DIR > ~/.copilot, ignoring the COPILOT_HOME env var. Per GitHub's Copilot CLI docs, COPILOT_HOME overrides the default ~/.copilot location (and user-level hooks are read from $COPILOT_HOME/hooks/), so a global --copilot install wrote all artifacts (skills, agents, copilot-instructions.md, the gsd-session.json hook) to ~/.copilot even when the user relocated their Copilot home, making them undiscoverable by Copilot CLI. Mirror the codex/CODEX_HOME branch: precedence is now --config-dir > COPILOT_CONFIG_DIR > COPILOT_HOME > ~/.copilot. Uninstall uses the same resolver, so it stays symmetric. Also: document COPILOT_HOME in the installer --help notes, the USER-GUIDE env-var table, and the installer-migrations Copilot row; and clear COPILOT_HOME in the two default-path test suites so they stay hermetic now that the resolver honors it. Closes #812 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#812): add changeset for PR #814 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * feat(#788): expand Qwen Code hook-event coverage (#807) * feat(#788): expand Qwen Code hook-event coverage to 4 new events Register SubagentStop, Stop, PreCompact (gsd-context-monitor.js) and UserPromptSubmit (gsd-prompt-guard.js) in the Qwen Code installer. Guard is isQwen-only — Claude Code and all other runtimes are unchanged. Uninstall loop extended to include the 4 new event names. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(#788): reconcile to 3 Qwen-only events — defer UserPromptSubmit gsd-prompt-guard exits unless tool_name is Write|Edit (PreToolUse payload shape); UserPromptSubmit carries raw user-prompt text with no tool_name field, so wiring it would be a silent no-op. Deferred to a follow-on issue. Artifacts made consistent: - bin/install.js: drop UserPromptSubmit registration block; uninstall loop drops UPS from event list - .changeset/788-qwen-hook-events.md: corrected to 3 events + rationale - docs/how-to/install-on-your-runtime.md: remove UPS row from hook table - tests/enh-788-qwen-hook-events.test.cjs: assert UPS NOT registered; fix idempotency suite to persist settings between installs; drop UPS-specific assertions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(#788): update changeset PR number to #807 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(#788): prune stale install-bucket allowlist entry for enh-788 test --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * enhancement(#782): emit gsd skills to ~/.cline/skills for Cline >= v3.48 (#809) Cline added a global skills system (~/.cline/skills/<name>/SKILL.md) in v3.48.0, but gsd treated Cline as rules-only and emitted zero skills (getGlobalSkillsBase('cline')=null, empty artifact kinds). This makes gsd emit skills for Cline at global scope, alongside the existing .clinerules. - runtime-homes: getGlobalSkillsBase('cline') -> ~/.cline/skills (was null) - runtime-artifact-layout: cline emits a skills kind for GLOBAL scope only (local stays .clinerules-only), mirroring claude's scope dispatch - install.js: convertClaudeCommandToClineSkill emits name+description-only SKILL.md frontmatter (Cline/agentskills.io spec; no Claude-specific allowed-tools/argument-hint/agent), hyphen-normalized + .cline/-rewritten body; global cline routed through the skills path while .clinerules is still written; _applyRuntimeRewrites cline case handles custom CLINE_CONFIG_DIR; convertClaudeToCliineMarkdown also rewrites bare ~/.claude and CLAUDE_CONFIG_DIR - docs: install-on-your-runtime.md documents Cline global skills vs local rules - tests: converter (name+description-only), global emission, skills+.clinerules coexistence, scope-aware layout, custom-dir paths, idempotency Closes #782 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * feat(#790): emit Augment slash commands (~/.augment/commands/) (#808) Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * enh(#784): emit native skills for OpenCode + Kilo runtimes (#810) * feat(#784): emit native skills for OpenCode + Kilo runtimes OpenCode and Kilo share a config schema and both discover on-demand skills from skills/<name>/SKILL.md. The installer previously emitted only flat commands (command/) and file-based agents (agents/) for these runtimes. Add a shared OpenCode-family skill writer that stages each GSD command as a spec-compliant SKILL.md (name matching the directory, description 1-1024 chars), wired through the runtime artifact layout so uninstall cleans skills/ automatically. Skills respect the active install profile. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#784): correct skill body paths + preserve user dev-preferences Address adversarial-review findings: - Add opencode/kilo cases to _applyRuntimeRewrites so staged SKILL.md bodies are re-pointed from the converter's hardcoded default config dir to the actual install target (fixes --local / --config-dir installs; commands/agents already did this by applying pathPrefix pre-conversion). - Preserve user-owned skills/gsd-dev-preferences across reinstall in installOpencodeFamilySkills (snapshot+restore around the gsd-* prune), matching installRuntimeArtifacts. - Export installOpencodeFamilySkills and add regression tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#784): guarantee command/skill body parity, fix kilo-alt double-rewrite Follow-up adversarial-review found the post-conversion path rewrite could double-rewrite custom Kilo dirs (kilo -> kilo-alt -> kilo-alt-alt) because the kilo pathPrefix is a $HOME (non-absolute) superset of the hardcoded default base. Restructure so OpenCode/Kilo skills mirror copyFlattenedCommands exactly: stage raw commands, apply pathPrefix BEFORE conversion via a new shared applyOpencodeFamilyPathPrefix() helper (now used by both the command and skill writers), then convert. This guarantees byte-for-byte command/ skill body parity for global, --local, and --config-dir installs and removes the prefix-overlap hazard. Drop the fragile _applyRuntimeRewrites opencode/ kilo case. Strengthen the path regression test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(#784): derive opencode/kilo skills from the same staged command set Pass the installer's _stageSkills() output directly to installOpencodeFamilySkills instead of re-staging via the layout, so the command/ and skills/ surfaces always cover the identical profile-resolved set — including the --minimal/--core-only alias path, which stages differently from a plain --profile=core. Verified: minimal install now emits 8 commands and 8 skills. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#784): set changeset PR number to 810 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#784): fully escape backslashes in test helper (CodeQL js/incomplete-string-escaping) Replace the dot-only escape `replace(/[.]/g, '\\.')` with a complete regex-escape pattern `replace(/[\\.*+?^${}()|[\]]/g, '\\$&')` so all regex metacharacters (including backslash itself) in `defaultBase` are safely escaped before interpolation into `new RegExp(...)`. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix(#813): apply per-runtime skill path rewrites in applySurface (#817) * fix(#813): apply per-runtime skill path rewrites in applySurface applySurface() re-staged skill artifacts but, unlike installRuntimeArtifacts(), never applied the per-runtime path rewrites. So /gsd:surface (profile/enable/disable/reset) overwrote installed SKILL.md bodies with the converter's default ~/.claude paths instead of the install target (pathPrefix), silently regressing skill path references for every skillsKind runtime until the next reinstall. applySurface now mirrors installRuntimeArtifacts: for kind.kind === 'skills' it derives pathPrefix the same way and applies applyRuntimeContentRewritesInPlace on the staged dir before syncing. - bin/install.js: export applyRuntimeContentRewritesInPlace - runtime-artifact-layout.cts: carry resolved scope on Layout; export getInstallExports; type computePathPrefix/applyRuntimeContentRewritesInPlace on InstallExports - surface.cts: lazily derive pathPrefix (only when a skills kind exists) and apply the rewrite via the shared getInstallExports accessor — single source of truth with install, only skills kinds rewritten (matches install) - tests: regression test parameterized over cursor + codex asserting post-applySurface bodies carry the install pathPrefix, not ~/.claude - CONTEXT.md: glossary updated for the applySurface rewrite parity + scope seam Closes #813 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#813): add changeset fragment for PR #817 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#813): normalize configDir prefix to forward slashes for Windows CI The #813 regression assertion compared skill bodies against a raw ${configDir}/ prefix, but production derives pathPrefix via path.resolve(configDir).replace(/\\/g, '/'). On Windows, mkdtempSync returns backslash paths while the rewritten body uses forward slashes, so the assertion would fail Windows-only (not covered by local gsd-test). Normalize the expected prefix the same way production does. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix(#816): mirror install command-prefix handling in _syncGsdDir (#822) * fix(#816): mirror install command-prefix handling in _syncGsdDir applySurface() via _syncGsdDir handled command artifacts differently from a fresh install. For flat command dirs (cursor/augment/opencode/kilo) install's _copyStaged adds kind.prefix (gsd-<stem>.md) and _removeGsdEntries prunes prefix-scoped, but _syncGsdDir copied staged files verbatim (unprefixed) and pruned by exact name. So every /gsd:surface toggle wrote wrong filenames, orphaned the installed gsd-*.md, and deleted user-authored command files. _syncGsdDir's commands/agents branch now mirrors install: - flat command dirs get the gsd- prefix on copy; namespaced dirs (commands/gsd) and agents keep staged names, using install's namespacedByDir rule - prune is prefix-scoped so user files in shared flat dirs are preserved; namespaced commands/gsd stays membership-pruned so superseded commands are still removed on profile shrink The naming rule is intentionally re-implemented (not via require('bin/install.js') to avoid its module-load banner side-effect); a strict parity test asserts applySurface and installRuntimeArtifacts produce identical command filenames for opencode/kilo/cursor/augment/gemini, guarding against drift. Closes #816 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#816): add changeset fragment for PR #822 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * chore(#771): convert agent color: hex/magenta values to documented named colors (#823) * chore(#771): convert agent color: hex/magenta values to documented named colors Claude Code's sub-agent `color:` field documents only 8 named colors (red, blue, green, yellow, purple, orange, pink, cyan). Twelve agent files used hex values and two used the undocumented `magenta`; convert each to the nearest documented named color so the intended per-agent TUI color differentiation is spec-compliant. - agents/*.md: 14 color values hex/magenta -> nearest named color - scripts/research-profiles.cjs: update the 3 generated research-agent profiles (source of truth) so gen-research-agents stays in sync - docs/AGENTS.md: update documented colors; add missing Color rows for gsd-nyquist-auditor, gsd-project-researcher, gsd-phase-researcher - tests/agent-frontmatter.test.cjs: add regression guard asserting every agent color: is in the documented named-color set Closes #771 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#771): add changeset Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * feat(#773): add --ephemeral and --dangerously-bypass-hook-trust to automated codex exec wrappers (#824) * feat(#773): add --ephemeral and --dangerously-bypass-hook-trust to automated codex exec invocations Automated codex exec calls in the review workflow now carry --ephemeral (no session-state accumulation across CI runs) and --dangerously-bypass-hook-trust (skip hook-trust prompts for hooks whose provenance gsd-core already controls). Both flags were verified present in the installed codex CLI (codex exec --help). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#773): correct changeset pr: reference to #824 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * feat(#775): ship a gemini-extension.json extension package (#818) Add a Gemini CLI extension package so users can install, update, and remove GSD through Gemini's own extension lifecycle and have it appear in `gemini extensions list`: gemini extensions install https://github.com/open-gsd/gsd-core gemini extensions update gsd-core gemini extensions uninstall gsd-core gemini extensions link /path/to/gsd-core # dev This mirrors the additive Claude Code plugin manifest (#766): a thin, version-stamped manifest enforced by an in-repo drift test. The extension ships the context-file payload (GEMINI.md), loaded into every Gemini session; slash-command/agent/hook TOML projection into the extension is a documented follow-up. The manual `npx gsd-core --gemini` installer (which provides the /gsd:* commands) is unchanged — purely additive, no breaking change. - gemini-extension.json: name=binName, version tracks package.json, description, contextFileName=GEMINI.md (minimal; no mcpServers — gsd ships no MCP server) - GEMINI.md: Gemini-session context payload - package.json: add both artifacts to files[] so they publish - CONTEXT.md: add "Gemini Extension Package" glossary entry - docs: USER-GUIDE + install-on-your-runtime how-to - tests/issue-775-gemini-extension.test.cjs: manifest validity, version parity with package.json, contextFileName existence, files[] publication Closes #775 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * feat(#768): pre-populate settings.json permissions.allow/deny for Claude Code (#819) * feat(#768): pre-populate settings.json permissions.allow/deny for Claude Code Adds mergeClaudePermissions() to bin/install.js which non-destructively appends GSD's known-safe tool-call patterns to permissions.allow and defense-in-depth credential-file patterns to permissions.deny during Claude Code installs. Merge is idempotent (no duplicates on reinstall) and additive (existing user entries preserved). Uninstall removes only the exact GSD-owned entries. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: update changeset pr number to 819 --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * feat(#774): emit service_tier/model_verbosity in Codex agent TOML + agents/openai.yaml skill chip (#828) * feat(#774): emit service_tier/model_verbosity in Codex agent TOML + agents/openai.yaml skill chip - Add service_tier = "flex" and model_verbosity = "low" to the Codex ConfigProfile TOML for light-tier agents (gsd-research-synthesizer, gsd-codebase-mapper, gsd-plan-checker, and 8 others identified via AGENT_DEFAULT_TIERS). Field names/values verified against Codex schema (profile_toml.rs / config_types.rs Verbosity enum). Non-light agents are unaffected. - Add generateCodexSkillMetadataYaml() and writeCodexSkillMetadataFiles(): after installRuntimeArtifacts, iterate every gsd-* skill directory, read the short-description already emitted in the SKILL.md frontmatter by convertClaudeCommandToCodexSkill, and write agents/openai.yaml with interface.display_name and interface.short_description for the Codex TUI skill picker chip. - yamlQuote (JSON.stringify) handles all YAML-unsafe chars. - User-owned gsd-dev-preferences dir is never overwritten. - Errors per-skill are swallowed so a bad SKILL.md can't abort install. - agents/openai.yaml is covered by the snapshot/rollback system and manifest hash (writeManifest hashes skill dirs recursively). - Uninstall symmetry: _removeGsdEntries removes whole gsd-* dirs. - 21 new tests in codex-config.test.cjs covering service_tier/verbosity TOML emission, YAML generation (round-trip via js-yaml), and writeCodexSkillMetadataFiles including an e2e integration test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#774): correct docs-lint coverage — proper changeset format + USER-GUIDE entry Rewrite the changeset fragment from old @opengsd/gsd-core:patch format to the required type:/pr: schema so the docs-lint parser can consume it. Add a new "Codex skill picker and agent scheduling (#774)" section to docs/USER-GUIDE.md describing the flex-tier scheduling and /skills TUI chip enrichments — both are user-visible and belong in docs rather than behind a docs-exempt marker. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * feat(#769): adopt context:fork + effort on heavy workflow skills (#820) * feat(#769): emit context:fork + effort: frontmatter on heavy workflow skills Add `context: fork` and `effort: xhigh` to the three heaviest workflow commands (plan-phase, execute-phase, autonomous) and `effort: low` to the two quick-status commands (progress, stats). On Claude Code, `context: fork` runs the skill in an isolated subagent context window so the main session's context budget is protected. `effort: xhigh` / `effort: low` signal the appropriate token-budget tier to the runtime. Both fields are silently ignored by runtimes that do not recognise them (Gemini, Codex, Cursor, etc.) — no behaviour change outside Claude Code. Update convertClaudeCommandToClaudeSkill in bin/install.js to preserve `context:` and `effort:` when rewriting source command files to SKILL.md for a Claude global install. Add install-suite tests to assert the fields are present in both source commands and the installed SKILL.md output. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#769): tighten regex assertions + add execute/plan-phase effort coverage Fix low-severity adversarial finding: tighten test regex patterns from `\s*` to `[ \t]*` so they cannot match across newlines (CRLF parity). Add missing effort: xhigh assertions for gsd-execute-phase and gsd-plan-phase SKILL.md install output to complete the black-box coverage gap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * feat(#772): adopt stable Codex hook events + commandWindows for Windows parity (#827) * feat(#772): adopt stable Codex hook events + commandWindows for Windows parity Register three new stable Codex hook events (SubagentStart, Stop, PostToolUse) wired to gsd-context-monitor.js so Codex installs get the same context-headroom tracking at subagent and session boundaries that Claude/Qwen already have. Add commandWindows field to the SessionStart hook entry on Windows so Codex uses the .cmd shim directly (Git Bash/MSYS cannot POSIX-exec node.exe). commandWindows is only emitted on win32; POSIX is unchanged. Refactor reconcileCodexHooksJsonSessionStart into a generic reconcileCodexHooksJsonEvent so any event name can be reconciled with the same dedup/preserve-user-entries logic. Add gsd-context-monitor.js and .cmd to MANAGED_HOOK_COMMAND_BASENAMES _BY_SURFACE so idempotent re-runs de-duplicate entries correctly. 30 new tests covering: export surface, event registration for each of the three events, commandWindows parity (POSIX vs win32), idempotency, uninstall, and user-entry preservation. Closes #772 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#772): windows path normalization + docs-lint - Normalize scriptPath backslashes to forward slashes in ensureCodexHooksJsonEvent and ensureCodexHooksJsonSessionStart so that isManagedHookCommand can match stored commands against configDir on Windows CI runners. path.resolve returns backslash paths on Windows, but when platform is not 'win32' (e.g. platform:'linux' in tests), projectManagedHookCommand skips normalization — producing a mismatch that breaks idempotency deduplication (the same hook entry appended twice on re-register). Forward-slash paths are always valid in both Node.js and Codex, so the normalization is safe for all platforms. - Fix changeset pr: 0 → 827 to resolve fail_malformed_fragment. - Add Codex hook coverage table to docs/how-to/install-on-your-runtime.md documenting the SubagentStart/Stop/PostToolUse events + commandWindows Windows-parity field added by this enhancement. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * feat(#778): cross-runtime command enrichment (Gemini {{args}}/!{}, Qwen priority) (#825) * feat(#778): cross-runtime command enrichment (Gemini {{args}}/!{}, Qwen priority) Enrich the installer's per-runtime command/skill generators with native, verified, additive fields: - Gemini CLI: map Claude's $ARGUMENTS -> Gemini's {{args}} in generated TOML commands so typed arguments interpolate; inject live .planning/STATE.md into /gsd:progress via a fixed, injection-safe !{cat .planning/STATE.md 2>/dev/null} shell block (no interpolated input). - Qwen Code: emit the optional numeric `priority` field on main-loop skills so the most-used workflows sort first in the /skills list (higher = earlier per the Qwen skills spec; the issue's inverse numbering was corrected). OpenCode per-command model/agent/subtask/variant enrichment was evaluated and intentionally not implemented: `model` reintroduces the #1156 ProviderModelNotFoundError regression for non-Anthropic providers (the converter deliberately strips model:), `subtask`/`agent` change execution semantics for GSD's interactive commands, and `variant` is not in the OpenCode command schema. Schemas verified against primary docs (Gemini custom-commands, Qwen skills, OpenCode commands/skills). Adds tests/enh-778-* and how-to + USER-GUIDE docs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#778): set changeset PR number to 825 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * feat(#789): elevate CodeBuddy — slash commands (#830) * feat(#789): elevate CodeBuddy — emit slash commands (+ document subagent/MCP scope) Emit a CodeBuddy slash-command surface so GSD workflows appear in the '/' menu, reaching parity with other elevated runtimes. - Add convertClaudeCommandToCodebuddyCommand and register a commands/ artifact kind for the codebuddy runtime (commands/gsd-<name>.md), consistent with the Cursor (#785) and Augment (#790) commands surfaces. - Mark emitted skills user-invocable:false so the commands surface is the sole '/' entry point (no duplicate /gsd-* entries); skills stay model-invocable. CodeBuddy's SKILL.md supports this field. - Normalize $HOME/.codebuddy (bare + slash) path forms in runtime rewrites so --config-dir/local installs don't leak the default home. - Report installed commands/ count on install; uninstall prunes gsd-* commands while preserving user-owned commands. Scope: subagents (~/.codebuddy/agents/) are already emitted by the generic agents block (unchanged); no mcp.json is written (gsd ships no MCP server, and CodeBuddy's mcp.json registers only external servers). Closes #789 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#789): set changeset pr number to 830 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * feat(#776): Gemini hook events (BeforeAgent/AfterAgent/BeforeModel) + hooksConfig.enabled check (#829) * feat(#776): Gemini hook events (BeforeAgent/AfterAgent/BeforeModel) + hooksConfig.enabled check Register three new Gemini-CLI hook events on install: - BeforeAgent: fires before agent planning; wired to gsd-context-monitor - AfterAgent: fires after final response generation; wired to gsd-context-monitor - BeforeModel: fires before each LLM call (per-turn); wired to gsd-context-monitor All three reuse gsd-context-monitor.js (no new hook files). Uninstall cleanup loop extended to remove the new events. Non-array guard added for robustness against malformed settings. Also detect hooksConfig.enabled:false in Gemini settings and emit a clear warning — without this check, all registered hooks silently do nothing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: update changeset pr: 829 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(#776): document Gemini hook events Add hook coverage table to the Gemini CLI section of install-on-your-runtime.md, covering the three new events (BeforeAgent/AfterAgent/BeforeModel wired to gsd-context-monitor) plus a callout for the hooksConfig.enabled:false silent failure mode detected by the installer. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * feat(#777): register Cursor-native hooks (.cursor/hooks.json) for session-start/post-tool parity (#831) * feat(#777): register Cursor-native hooks (.cursor/hooks.json) for session-start/post-tool parity - Add gsd-cursor-session-start.js: injects STATE.md presence reminder (or new-project nudge) into Cursor sessions via the sessionStart hook event - Add gsd-cursor-post-tool.js: emits an additional_context nudge when write-class tool calls touch .planning/ files (postToolUse hook event) - Add 'cursor-hooks-json' installSurface to runtime-config-adapter-registry; writeCursorHooksJson/reconcileCursorHooksJson write the canonical { version: 1, hooks: { sessionStart, postToolUse } } JSON shape with idempotent reconciliation that preserves user-owned hook entries - Hook scripts are copied with /gsd:→gsd- rewrite so installed files contain no colon-form slash-command refs (bug-376 invariant) - 20 new tests in tests/cursor-hooks.test.cjs cover all reconciler paths, entry helpers, removal, runtime adapter surface, and hook script behavior - Update CONTEXT.md, ARCHITECTURE.md, installer-migrations.md, and 000-first-time-baseline.cts to include Cursor hooks.json surface Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#777): build hooks/dist on demand in bug-376 test for scoped/windows CI hooks/dist is gitignored and only produced by `npm run build:hooks`. The CI scoped (ubuntu-latest/node-22) and windows (windows-latest/node-24) test jobs do NOT run build:hooks before executing tests, so bug-376's prerequisite suite was failing with "hooks/dist not found" on both legs. Add ensureHooksDist() helper (mirrors bug-3357 pattern) that builds hooks/dist on demand in the before() hooks of prerequisite and Suite 3. Also add ensureHooksDist() call to Suite 3's before() so the snapshot step is also hermetic. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * docs(#832): add how-to guide for minimal install / skill profiles (#835) Add docs/how-to/install-minimal-and-add-skills.md covering the --minimal / --core-only / --profile=core install, the core/standard/full profiles, and growing the surface live via /gsd:surface or on reinstall. Register it in the docs/README.md How-to guides index. Closes #832 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * feat(#815): add /gsd-update --next to install the @next RC channel (#839) Adds an opt-in --next (alias --rc) flag to /gsd-update targeting the @next RC dist-tag (ADR #660), with a {latest,next} allowlist enforced at three layers, channel-aware version check + banner, and byte-for-byte unchanged default @latest behavior. Closes #815 * fix(#837): three-dot diff in ci-test-scope so docs-only PRs skip the heavy matrix (#841) CI test-scope detection diffed changed files with a two-dot `git diff --name-only base head`, where base is the moving tip of `next`. A PR branch cut from a slightly older `next` surfaced every product file `next` had gained since the merge-base, flipping product_changed/full_matrix and running the full Windows/macOS matrix + coverage on docs-only PRs. Switch to a three-dot `git diff --name-only base...head` (vs the merge-base), matching GitHub's PR "Files changed" semantics. Add a regression test that builds a stale-base topology, plus a guard test pinning `fetch-depth: 0` on the `changes` job (required for the merge-base to be locally available). Closes #837 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * feat(#836): no-LLM duplicate-issue detection + challenge + 1-day auto-close (#843) * feat(#836): no-LLM duplicate-issue detection + challenge + 1-day auto-close Adds a deterministic (no-LLM) duplicate-issue governance lifecycle: - scripts/issue-dedupe.cjs: pure, unit-tested module (tokenize, Sørensen–Dice title similarity, scoreCandidates, renderChallengeComment, shouldClose) with fail-safe destructive-action guards. - duplicate-check.yml (issues:opened): scores new-issue title against open issues, posts a challenge comment + applies the pending `possible-duplicate` label on a clear match. - duplicate-sweep.yml (daily cron): closes possible-duplicate issues whose challenge comment is >24h old with no human reply and no 👎 veto; honors exempt labels; re-checks the label immediately before close (TOCTOU guard); strips the label on close to avoid reopen loops. - remove-duplicate-label.yml (issue_comment:created): clears the label and applies needs-maintainer-review when any human responds. - bug_report.yml / docs_issue.yml: add the required "I searched existing issues" preflight checkbox so all five forms force a pre-search attestation. - docs/agents/triage-labels.md: document the label + lifecycle. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#836): add changeset fragment for duplicate-issue detection Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * feat(#770): register Claude Code lifecycle hooks (SubagentStop/Stop/PreCompact/FileChanged) (#821) * feat(#770): register Claude Code lifecycle hooks (SubagentStop/Stop/PreCompact/FileChanged) Wire three new context-tracking events (SubagentStop, Stop, PreCompact) to gsd-context-monitor so context-headroom warnings surface at model-stop and subagent-finalisation moments — not just on PostToolUse. Add a new FileChanged hook (gsd-config-reload.js) that hot-reloads .planning/config.json context mid-session when the user edits it, injecting a config summary as hookSpecificOutput.additionalContext. Updates plugin manifest hooks.json, managed-hooks-registry, installer-migration-report allowlist, and shell-command-projection cleanup tables. Tests: 21 new assertions in enh-770-claude-hook-events.test.cjs; enh-788 and issue-766 test suites updated. Closes #770 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(#770): document newly-registered Claude Code lifecycle hooks Add a Hook coverage table to the Claude Code npm installer section of docs/how-to/install-on-your-runtime.md describing SubagentStop, Stop, PreCompact, and the new FileChanged (gsd-config-reload.js) hook that hot-reloads .planning/config.json mid-session. Also fixes the changeset frontmatter (adds type: Added + pr: 821) so docs-lint can consume the fragment. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#770): add gsd-config-reload.js to INVENTORY.md and regenerate manifest The feat commit added hooks/gsd-config-reload.js but did not bump the Hooks count in docs/INVENTORY.md (14→15) or add the new row, and did not regenerate docs/INVENTORY-MANIFEST.json. Both inventory-counts and inventory-manifest-sync tests failed across the full CI matrix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#770): make lifecycle-hook tests deterministic on scoped runner Replace the shared hooks/dist/ ensemble setup (ensureHooksDist / teardownHooksDist) in the Claude hook tests with per-test isolation: pre-populate each test's own tmpDir/.claude/hooks/ with stub files and pass installerMigrations:[] to install() so the first-time-baseline migration does not remove the stubs before the copy step can run. Root cause: hooks/dist/ is gitignored and absent on a fresh npm ci. ensureHooksDist() created it and teardownHooksDist() deleted it, but with --test-concurrency=4 both test files ran concurrently as separate Node.js worker processes sharing the same filesystem. One file's afterEach teardown deleted hooks/dist/ while the other file's install() was copying from it, producing an ENOENT (reproduced 2/10 runs locally). The additional issue: even with pre-placed stubs surviving the copy race, the 000-first-time-baseline migration classified hooks/gsd-*.js as bundled-gsd-hook artifacts, auto-removed them, and the copy step never re-ran (hooks/dist/ absent) — leaving contextMonitorFile missing and all hook registrations silently skipped (the 'got: []' symptom). Fix: pre-populate targetDir/hooks/ per-test (isolated temp dir) AND pass installerMigrations:[] so the baseline scan is skipped. The Qwen suites already used this pattern correctly; the Claude suites are aligned to it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#770): ship gsd-config-reload.js by adding it to build-hooks HOOKS_TO_COPY The #770 feature added hooks/gsd-config-reload.js and registered it in MANAGED_HOOKS, the installer, INVENTORY, and the test EXPECTED_ALL_HOOKS list — but never added it to scripts/build-hooks.js HOOKS_TO_COPY. As a result the hook was never copied into hooks/dist/ during the build, so: - the hook would never ship to users (real production bug — the FileChanged config-reload feature was dead-on-arrival), and - install-minimal-hooks.test.cjs #1755 ("all expected hooks are copied from hooks/dist/ to target", ".js hooks are executable after copy", "manifest contains .js hook entries") failed on any environment with a clean checkout (no pre-existing hooks/dist/): coverage, full test macos-22/macos-24, test ubuntu-24. The failures were masked locally only by a stale hooks/dist/ left from a prior build (build-hooks copies into dist without clearing it). On CI's fresh `npm ci` there is no dist, so the omission surfaced. Fix: add 'gsd-config-reload.js' to HOOKS_TO_COPY so build-hooks stages it into hooks/dist/ alongside the other JS hooks. Verified by removing hooks/dist/ and rerunning the full suite green (0 fail). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#770): make config prototype-pollution beforeEach deterministic on scoped runner Root cause: the #663 and alert-#26 prototype-pollution describe blocks seeded .planning/config.json in beforeEach via a bare runGsdTools('config-ensure-section') whose result was discarded. That command runs in a spawned gsd-tools child; on the scoped CI lane (--test-concurrency=4, config.test.cjs scheduled alongside the heavy install/tarball suites that #770 pulled into the targeted set) the child can be transiently killed under resource pressure (non-zero exit, empty stderr — an OS-level kill, not an app error). The swallowed failure left config.json absent, so the first subtest's readConfig() threw ENOENT opening <tmp>/.planning/config.json. Only 1 of 4 subtests failed, confirming a per-invocation transient, not a deterministic miss; the full suite schedules files differently so config.test.cjs did not collide with those heavy neighbors → passed there. Fix: add ensureConfigReady(tmpDir) which retries config-ensure-section on ANY failure or missing file and throws a clear diagnostic if it still cannot create config.json, then use it in both prototype-pollution beforeEach blocks. Setup is now deterministic under load; the #663/alert-#26 security assertions are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * fix(#844): sync runtime manifest versions on npm version bump (#845) * fix(#844): sync runtime manifest versions on npm version bump The release workflow bumps package.json via `npm version` but never stamped the runtime-integration manifests that must track it (.claude-plugin/plugin.json #766, gemini-extension.json #775), so the first RC/finalize whose version diverged from the -dev stream failed the test suite before tagging/publishing. Add scripts/sync-manifest-versions.cjs (single VERSIONED_MANIFESTS registry) wired to a `version` npm lifecycle hook that stamps + stages the manifests on every `npm version` — covering all four release bump sites and local bumps with no workflow edits. A regression guard test fails if any repo JSON whose version matches package.json is not registered, forcing future version-bearing manifests into the sync. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(#844): add changeset for manifest version sync fix Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * chore: bump to 1.4.0-rc.2 * chore: finalize v1.4.0 * chore: promote CHANGELOG for v1.4.0 --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Colin <colin@solvely.net> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Joe <44273333+jslitzkerttcu@users.noreply.github.com> Co-authored-by: Solvely-Colin <211764741+Solvely-Colin@users.noreply.github.com>
4891 lines
189 KiB
JavaScript
4891 lines
189 KiB
JavaScript
// allow-test-rule: source-text-is-the-product
|
|
// Reads .md/.json/.yml product files whose deployed text IS what the
|
|
// runtime loads — testing text content tests the deployed contract.
|
|
// allow-test-rule: state-md-is-the-runtime-contract — regression tests for
|
|
// bug #3517 assert the exact STATE.md fields written by phase.complete;
|
|
// STATE.md IS the product surface being verified, not source code.
|
|
// Migration to typed-IR parser tracked in #2974.
|
|
|
|
/**
|
|
* GSD Tools Tests - Phase
|
|
*
|
|
* Consolidated from 20 → 4 test files (issue #3740). This file covers the
|
|
* CJS phase CLI layer (phase.cjs + gsd-tools.cjs): add, remove, complete,
|
|
* list, insert, and all regression tests for bugs in that layer.
|
|
*/
|
|
|
|
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const os = require('node:os');
|
|
const { execFileSync } = require('node:child_process');
|
|
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
|
|
|
|
const GSD_TOOLS_BIN = path.resolve(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
|
|
|
describe('phases list command', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('empty phases directory returns empty array', () => {
|
|
const result = runGsdTools('phases list', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.deepStrictEqual(output.directories, [], 'directories should be empty');
|
|
assert.strictEqual(output.count, 0, 'count should be 0');
|
|
});
|
|
|
|
test('lists phase directories sorted numerically', () => {
|
|
// Create out-of-order directories
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '10-final'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
|
|
|
|
const result = runGsdTools('phases list', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.count, 3, 'should have 3 directories');
|
|
assert.deepStrictEqual(
|
|
output.directories,
|
|
['01-foundation', '02-api', '10-final'],
|
|
'should be sorted numerically'
|
|
);
|
|
});
|
|
|
|
test('handles decimal phases in sort order', () => {
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02.1-hotfix'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02.2-patch'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-ui'), { recursive: true });
|
|
|
|
const result = runGsdTools('phases list', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.deepStrictEqual(
|
|
output.directories,
|
|
['02-api', '02.1-hotfix', '02.2-patch', '03-ui'],
|
|
'decimal phases should sort correctly between whole numbers'
|
|
);
|
|
});
|
|
|
|
test('--type plans lists only PLAN.md files', () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan 1');
|
|
fs.writeFileSync(path.join(phaseDir, '01-02-PLAN.md'), '# Plan 2');
|
|
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary');
|
|
fs.writeFileSync(path.join(phaseDir, 'RESEARCH.md'), '# Research');
|
|
|
|
const result = runGsdTools('phases list --type plans', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.deepStrictEqual(
|
|
output.files.sort(),
|
|
['01-01-PLAN.md', '01-02-PLAN.md'],
|
|
'should list only PLAN files'
|
|
);
|
|
});
|
|
|
|
test('--type summaries lists only SUMMARY.md files', () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary 1');
|
|
fs.writeFileSync(path.join(phaseDir, '01-02-SUMMARY.md'), '# Summary 2');
|
|
|
|
const result = runGsdTools('phases list --type summaries', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.deepStrictEqual(
|
|
output.files.sort(),
|
|
['01-01-SUMMARY.md', '01-02-SUMMARY.md'],
|
|
'should list only SUMMARY files'
|
|
);
|
|
});
|
|
|
|
test('--phase filters to specific phase directory', () => {
|
|
const phase01 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
|
const phase02 = path.join(tmpDir, '.planning', 'phases', '02-api');
|
|
fs.mkdirSync(phase01, { recursive: true });
|
|
fs.mkdirSync(phase02, { recursive: true });
|
|
fs.writeFileSync(path.join(phase01, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(phase02, '02-01-PLAN.md'), '# Plan');
|
|
|
|
const result = runGsdTools('phases list --type plans --phase 01', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.deepStrictEqual(output.files, ['01-01-PLAN.md'], 'should only list phase 01 plans');
|
|
assert.strictEqual(output.phase_dir, 'foundation', 'should report phase name without number prefix');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// roadmap get-phase command
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
describe('phase next-decimal command', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('returns X.1 when no decimal phases exist', () => {
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06-feature'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '07-next'), { recursive: true });
|
|
|
|
const result = runGsdTools('phase next-decimal 06', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.next, '06.1', 'should return 06.1');
|
|
assert.deepStrictEqual(output.existing, [], 'no existing decimals');
|
|
});
|
|
|
|
test('increments from existing decimal phases', () => {
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06-feature'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06.1-hotfix'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06.2-patch'), { recursive: true });
|
|
|
|
const result = runGsdTools('phase next-decimal 06', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.next, '06.3', 'should return 06.3');
|
|
assert.deepStrictEqual(output.existing, ['06.1', '06.2'], 'lists existing decimals');
|
|
});
|
|
|
|
test('handles gaps in decimal sequence', () => {
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06-feature'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06.1-first'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06.3-third'), { recursive: true });
|
|
|
|
const result = runGsdTools('phase next-decimal 06', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
// Should take next after highest, not fill gap
|
|
assert.strictEqual(output.next, '06.4', 'should return 06.4, not fill gap at 06.2');
|
|
});
|
|
|
|
test('handles single-digit phase input', () => {
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06-feature'), { recursive: true });
|
|
|
|
const result = runGsdTools('phase next-decimal 6', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.next, '06.1', 'should normalize to 06.1');
|
|
assert.strictEqual(output.base_phase, '06', 'base phase should be padded');
|
|
});
|
|
|
|
test('returns error if base phase does not exist', () => {
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-start'), { recursive: true });
|
|
|
|
const result = runGsdTools('phase next-decimal 06', tmpDir);
|
|
assert.ok(result.success, `Command should succeed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.found, false, 'base phase not found');
|
|
assert.strictEqual(output.next, '06.1', 'should still suggest 06.1');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// phase-plan-index command
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
describe('phase-plan-index command', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('empty phase directory returns empty plans array', () => {
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-api'), { recursive: true });
|
|
|
|
const result = runGsdTools('phase-plan-index 03', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase, '03', 'phase number correct');
|
|
assert.deepStrictEqual(output.plans, [], 'plans should be empty');
|
|
assert.deepStrictEqual(output.waves, {}, 'waves should be empty');
|
|
assert.deepStrictEqual(output.incomplete, [], 'incomplete should be empty');
|
|
assert.strictEqual(output.has_checkpoints, false, 'no checkpoints');
|
|
assert.ok(output.warning === undefined, 'truly empty dir must not emit a warning');
|
|
});
|
|
|
|
// #2893 — when the planner produces filenames that don't match the canonical
|
|
// `{padded_phase}-{NN}-PLAN.md` contract, the executor used to silently see
|
|
// plan_count: 0 with no signal. Now the response must include a `warning`
|
|
// field naming every offender, so the user gets an actionable error instead
|
|
// of "execute-phase blocked, no clue why".
|
|
test('non-canonical plan filenames surface a warning naming each offender (#2893)', () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
|
|
// The reporter's exact symptom: planner wrote `{phase-id}-PLAN-{N}-{slug}.md`.
|
|
fs.writeFileSync(path.join(phaseDir, '01-PLAN-01-foundation.md'), '---\n---\n');
|
|
fs.writeFileSync(path.join(phaseDir, '01-PLAN-02-api.md'), '---\n---\n');
|
|
|
|
const result = runGsdTools('phase-plan-index 03', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.plans.length, 0, 'non-canonical files are not silently accepted');
|
|
assert.ok(typeof output.warning === 'string', 'warning field must be present');
|
|
assert.ok(output.warning.includes('01-PLAN-01-foundation.md'), 'warning names the first offender');
|
|
assert.ok(output.warning.includes('01-PLAN-02-api.md'), 'warning names the second offender');
|
|
assert.ok(
|
|
output.warning.includes('{padded_phase}-{NN}-PLAN.md'),
|
|
'warning cites the canonical pattern so user knows what to rename to',
|
|
);
|
|
});
|
|
|
|
test('canonical plans suppress the warning even alongside derivative files (#2893)', () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
|
|
// Canonical plan + the legitimate derivative artifacts the planner emits.
|
|
fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '---\nwave: 1\n---\n');
|
|
fs.writeFileSync(path.join(phaseDir, '03-PLAN-OUTLINE.md'), '# outline\n');
|
|
fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.pre-bounce.md'), '---\n---\n');
|
|
|
|
const result = runGsdTools('phase-plan-index 03', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.plans.length, 1, 'canonical plan detected');
|
|
assert.ok(
|
|
output.warning === undefined,
|
|
`outline and pre-bounce files must not trigger the warning, got: ${output.warning}`,
|
|
);
|
|
});
|
|
|
|
// #2893 parity — find-phase reads the same phase directory and applies the
|
|
// same canonical filter, so it must emit the same warning shape. Without
|
|
// these tests the two code paths could silently diverge.
|
|
test('find-phase: non-canonical plan filenames surface the same warning (#2893 parity)', () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
fs.writeFileSync(path.join(phaseDir, '01-PLAN-01-foundation.md'), '---\n---\n');
|
|
fs.writeFileSync(path.join(phaseDir, '01-PLAN-02-api.md'), '---\n---\n');
|
|
|
|
const result = runGsdTools('find-phase 03', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.found, true, 'phase directory found');
|
|
assert.deepStrictEqual(output.plans, [], 'non-canonical files are not silently accepted');
|
|
assert.ok(typeof output.warning === 'string', 'warning field must be present');
|
|
assert.ok(output.warning.includes('01-PLAN-01-foundation.md'), 'warning names the first offender');
|
|
assert.ok(output.warning.includes('01-PLAN-02-api.md'), 'warning names the second offender');
|
|
assert.ok(
|
|
output.warning.includes('{padded_phase}-{NN}-PLAN.md'),
|
|
'warning cites the canonical pattern',
|
|
);
|
|
});
|
|
|
|
test('find-phase: canonical plans + derivatives suppress the warning (#2893 parity)', () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '---\nwave: 1\n---\n');
|
|
fs.writeFileSync(path.join(phaseDir, '03-PLAN-OUTLINE.md'), '# outline\n');
|
|
fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.pre-bounce.md'), '---\n---\n');
|
|
|
|
const result = runGsdTools('find-phase 03', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.deepStrictEqual(output.plans, ['03-01-PLAN.md'], 'canonical plan detected');
|
|
assert.ok(
|
|
output.warning === undefined,
|
|
`outline and pre-bounce files must not trigger the warning, got: ${output.warning}`,
|
|
);
|
|
});
|
|
|
|
// #2893 parity — `phases list --type plans` aggregates across phase dirs
|
|
// and prefixes each warning with `${dir}: ` so the user can locate the
|
|
// offending phase. Test mirrors the find-phase pair but accounts for that
|
|
// prefix in the assertion.
|
|
test('phases list --type plans: non-canonical filenames surface a per-dir warning (#2893 parity)', () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
fs.writeFileSync(path.join(phaseDir, '01-PLAN-01-foundation.md'), '---\n---\n');
|
|
fs.writeFileSync(path.join(phaseDir, '01-PLAN-02-api.md'), '---\n---\n');
|
|
|
|
const result = runGsdTools('phases list --type plans --phase 03', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.deepStrictEqual(output.files, [], 'non-canonical files are not silently accepted');
|
|
assert.ok(typeof output.warning === 'string', 'warning field must be present');
|
|
assert.ok(output.warning.includes('03-api:'), 'warning is prefixed with the offending phase dir');
|
|
assert.ok(output.warning.includes('01-PLAN-01-foundation.md'), 'warning names the first offender');
|
|
assert.ok(output.warning.includes('01-PLAN-02-api.md'), 'warning names the second offender');
|
|
assert.ok(
|
|
output.warning.includes('{padded_phase}-{NN}-PLAN.md'),
|
|
'warning cites the canonical pattern',
|
|
);
|
|
});
|
|
|
|
test('phases list --type plans: canonical plans suppress the warning (#2893 parity)', () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), '---\nwave: 1\n---\n');
|
|
fs.writeFileSync(path.join(phaseDir, '03-PLAN-OUTLINE.md'), '# outline\n');
|
|
fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.pre-bounce.md'), '---\n---\n');
|
|
|
|
const result = runGsdTools('phases list --type plans --phase 03', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.deepStrictEqual(output.files, ['03-01-PLAN.md'], 'canonical plan detected');
|
|
assert.ok(
|
|
output.warning === undefined,
|
|
`outline and pre-bounce files must not trigger the warning, got: ${output.warning}`,
|
|
);
|
|
});
|
|
|
|
test('extracts single plan with frontmatter', () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '03-01-PLAN.md'),
|
|
`---
|
|
wave: 1
|
|
autonomous: true
|
|
objective: Set up database schema
|
|
files-modified: [prisma/schema.prisma, src/lib/db.ts]
|
|
---
|
|
|
|
## Task 1: Create schema
|
|
## Task 2: Generate client
|
|
`
|
|
);
|
|
|
|
const result = runGsdTools('phase-plan-index 03', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.plans.length, 1, 'should have 1 plan');
|
|
assert.strictEqual(output.plans[0].id, '03-01', 'plan id correct');
|
|
assert.strictEqual(output.plans[0].wave, 1, 'wave extracted');
|
|
assert.strictEqual(output.plans[0].autonomous, true, 'autonomous extracted');
|
|
assert.strictEqual(output.plans[0].objective, 'Set up database schema', 'objective extracted');
|
|
assert.deepStrictEqual(output.plans[0].files_modified, ['prisma/schema.prisma', 'src/lib/db.ts'], 'files extracted');
|
|
assert.strictEqual(output.plans[0].task_count, 2, 'task count correct');
|
|
assert.strictEqual(output.plans[0].has_summary, false, 'no summary yet');
|
|
});
|
|
|
|
test('groups multiple plans by wave (DAG-bucketing: 03-03 depends on 03-01 and 03-02)', () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '03-01-PLAN.md'),
|
|
[
|
|
'---',
|
|
'wave: 1',
|
|
'autonomous: true',
|
|
'objective: Database setup',
|
|
'depends_on: []',
|
|
'---',
|
|
'',
|
|
'## Task 1: Schema',
|
|
].join('\n')
|
|
);
|
|
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '03-02-PLAN.md'),
|
|
[
|
|
'---',
|
|
'wave: 1',
|
|
'autonomous: true',
|
|
'objective: Auth setup',
|
|
'depends_on: []',
|
|
'---',
|
|
'',
|
|
'## Task 1: JWT',
|
|
].join('\n')
|
|
);
|
|
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '03-03-PLAN.md'),
|
|
[
|
|
'---',
|
|
'wave: 2',
|
|
'autonomous: false',
|
|
'objective: API routes',
|
|
'depends_on:',
|
|
' - 03-01',
|
|
' - 03-02',
|
|
'---',
|
|
'',
|
|
'## Task 1: Routes',
|
|
].join('\n')
|
|
);
|
|
|
|
const result = runGsdTools('phase-plan-index 03', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.plans.length, 3, 'should have 3 plans');
|
|
assert.deepStrictEqual(output.waves['1'], ['03-01', '03-02'], 'wave 1 has 2 plans');
|
|
assert.deepStrictEqual(output.waves['2'], ['03-03'], 'wave 2 has 1 plan');
|
|
// No mismatch warning: declared wave 2 matches topo level 2
|
|
assert.strictEqual(output.warnings, undefined, 'no warnings when declared wave matches DAG');
|
|
});
|
|
|
|
test('detects incomplete plans (no matching summary)', () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
|
|
// Plan with summary
|
|
fs.writeFileSync(path.join(phaseDir, '03-01-PLAN.md'), `---\nwave: 1\n---\n## Task 1`);
|
|
fs.writeFileSync(path.join(phaseDir, '03-01-SUMMARY.md'), `# Summary`);
|
|
|
|
// Plan without summary
|
|
fs.writeFileSync(path.join(phaseDir, '03-02-PLAN.md'), `---\nwave: 2\n---\n## Task 1`);
|
|
|
|
const result = runGsdTools('phase-plan-index 03', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.plans[0].has_summary, true, 'first plan has summary');
|
|
assert.strictEqual(output.plans[1].has_summary, false, 'second plan has no summary');
|
|
assert.deepStrictEqual(output.incomplete, ['03-02'], 'incomplete list correct');
|
|
});
|
|
|
|
test('phase-plan-index matches descriptive plan with prefix summary (#3101)', () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
|
|
fs.writeFileSync(path.join(phaseDir, '03-01-auth-hardening-PLAN.md'), `---\nwave: 1\n---\n## Task 1`);
|
|
fs.writeFileSync(path.join(phaseDir, '03-01-SUMMARY.md'), `# Summary`);
|
|
|
|
const result = runGsdTools('phase-plan-index 03', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.plans[0].has_summary, true, 'descriptive plan should match prefix summary');
|
|
assert.deepStrictEqual(output.incomplete, [], 'plan should not be marked incomplete');
|
|
});
|
|
|
|
// #3266 CR — depends_on canonical-id mismatch: a plan named
|
|
// '03-01-auth-hardening-PLAN.md' is stored with id '03-01-auth-hardening',
|
|
// but a dependency declared as '03-01' was never resolving to it, silently
|
|
// putting the dependent plan in the same wave as its prerequisite.
|
|
test('depends_on short canonical prefix resolves against descriptive plan filename (#3266)', () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
|
|
// Plan 01: descriptive filename — id becomes '03-01-auth-hardening'
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '03-01-auth-hardening-PLAN.md'),
|
|
`---\nwave: 1\n---\n## Task 1\n`,
|
|
);
|
|
// Plan 02: depends on the canonical prefix '03-01' (not the full stem)
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '03-02-followup-PLAN.md'),
|
|
`---\ndepends_on:\n - '03-01'\n---\n## Task 1\n`,
|
|
);
|
|
|
|
const result = runGsdTools('phase-plan-index 03', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
const waves = output.waves;
|
|
|
|
// Plan 01 must be in an earlier wave than plan 02
|
|
const wave01 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('03-01')));
|
|
const wave02 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('03-02')));
|
|
assert.ok(wave01 !== undefined, 'plan 03-01-auth-hardening should appear in waves');
|
|
assert.ok(wave02 !== undefined, 'plan 03-02-followup should appear in waves');
|
|
assert.ok(
|
|
Number(wave01) < Number(wave02),
|
|
`03-02 must be in a later wave than 03-01 (got wave01=${wave01}, wave02=${wave02})`,
|
|
);
|
|
});
|
|
|
|
test('detects checkpoints (autonomous: false)', () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '03-01-PLAN.md'),
|
|
`---
|
|
wave: 1
|
|
autonomous: false
|
|
objective: Manual review needed
|
|
---
|
|
|
|
## Task 1: Review
|
|
`
|
|
);
|
|
|
|
const result = runGsdTools('phase-plan-index 03', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.has_checkpoints, true, 'should detect checkpoint');
|
|
assert.strictEqual(output.plans[0].autonomous, false, 'plan marked non-autonomous');
|
|
});
|
|
|
|
test('phase not found returns error', () => {
|
|
const result = runGsdTools('phase-plan-index 99', tmpDir);
|
|
assert.ok(result.success, `Command should succeed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.error, 'Phase not found', 'should report phase not found');
|
|
});
|
|
|
|
// #3785 — case-insensitive depends_on resolution
|
|
test('#3785: depends_on reference with different case resolves to correct plan', () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '20-case-insensitive');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
|
|
// Plan A: filename uses uppercase suffix — plan ID becomes '20-01-Auth'
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '20-01-Auth-PLAN.md'),
|
|
`---\nwave: 1\nautonomous: true\ndepends_on: []\n---\n<objective>Plan A.</objective>\n`,
|
|
);
|
|
// Plan B: depends_on uses lowercase — must still resolve to Plan A
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '20-02-PLAN.md'),
|
|
`---\nwave: 2\nautonomous: true\ndepends_on:\n - 20-01-auth\n---\n<objective>Plan B.</objective>\n`,
|
|
);
|
|
|
|
const result = runGsdTools('phase-plan-index 20', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
const waves = output.waves;
|
|
|
|
const wave01 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('20-01')));
|
|
const wave02 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('20-02')));
|
|
assert.ok(wave01 !== undefined, 'plan 20-01-Auth should appear in waves');
|
|
assert.ok(wave02 !== undefined, 'plan 20-02 should appear in waves');
|
|
assert.ok(
|
|
Number(wave01) < Number(wave02),
|
|
`20-02 must be in a later wave than 20-01 (got wave01=${wave01}, wave02=${wave02}) — DAG edge dropped (case mismatch)`,
|
|
);
|
|
// Lock canonical casing: plan ID must be preserved as-is from the filename, not lowercased.
|
|
const planA = output.plans.find(p => p.id.startsWith('20-01'));
|
|
assert.strictEqual(planA.id, '20-01-Auth', 'canonical casing must be preserved in plan ID');
|
|
// depends_on output must use canonical plan ID, not the user-typed casing.
|
|
const planB = output.plans.find(p => p.id === '20-02');
|
|
assert.deepStrictEqual(planB.depends_on, ['20-01-Auth'], 'depends_on output must resolve to canonical ID casing');
|
|
// No unresolved-dep warning
|
|
const warnings = output.warnings ?? [];
|
|
assert.ok(
|
|
!warnings.some(w => /unresolved/i.test(w)),
|
|
`Unexpected unresolved-dep warning: ${JSON.stringify(warnings)}`,
|
|
);
|
|
});
|
|
|
|
// #3785 adversarial: two plan IDs that are identical when case-folded must
|
|
// fail fast with a clear error instead of silently routing edges to the wrong plan.
|
|
// This test can only run on Linux where the filesystem is case-sensitive.
|
|
// On macOS/Windows (case-insensitive FS), writing both files silently collapses
|
|
// them to one file, so the collision scenario cannot be triggered via disk.
|
|
test('#3785 adversarial: two plan IDs differing only by case produce a collision error', {
|
|
skip: process.platform !== 'linux' ? 'case-insensitive filesystem — collision test requires Linux' : false,
|
|
}, () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '21-collision');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
|
|
// '21-01-auth-PLAN.md' → id '21-01-auth'
|
|
// '21-01-Auth-PLAN.md' → id '21-01-Auth'
|
|
// Both lowercase to '21-01-auth' — collision.
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '21-01-auth-PLAN.md'),
|
|
`---\nautonomous: true\ndepends_on: []\n---\n<objective>lowercase.</objective>\n`,
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '21-01-Auth-PLAN.md'),
|
|
`---\nautonomous: true\ndepends_on: []\n---\n<objective>uppercase.</objective>\n`,
|
|
);
|
|
|
|
const result = runGsdTools('phase-plan-index 21', tmpDir);
|
|
// The command must exit with an error (non-success) naming the collision.
|
|
assert.ok(!result.success, 'phase-plan-index must fail when two plan IDs collide under case-folding');
|
|
assert.ok(
|
|
/collision/i.test(result.error ?? result.output ?? ''),
|
|
`Error output must mention 'collision', got: ${result.error ?? result.output}`,
|
|
);
|
|
});
|
|
|
|
// #3785 — all-uppercase depends_on value resolves to an all-lowercase plan ID
|
|
test('#3785: all-uppercase depends_on ref resolves to lowercase plan ID', () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '22-uppercase-dep');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
|
|
// Plan A: all-lowercase plan ID
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '22-01-setup-PLAN.md'),
|
|
`---\nwave: 1\nautonomous: true\ndepends_on: []\n---\n<objective>Plan A.</objective>\n`,
|
|
);
|
|
// Plan B: depends_on uses ALL-UPPERCASE — must still route the DAG edge to Plan A
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '22-02-PLAN.md'),
|
|
`---\nwave: 2\nautonomous: true\ndepends_on:\n - 22-01-SETUP\n---\n<objective>Plan B.</objective>\n`,
|
|
);
|
|
|
|
const result = runGsdTools('phase-plan-index 22', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
const waves = output.waves;
|
|
|
|
const wave01 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('22-01')));
|
|
const wave02 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('22-02')));
|
|
assert.ok(wave01 !== undefined, 'plan 22-01-setup should appear in waves');
|
|
assert.ok(wave02 !== undefined, 'plan 22-02 should appear in waves');
|
|
assert.ok(
|
|
Number(wave01) < Number(wave02),
|
|
`22-02 must be in a later wave than 22-01 — all-uppercase dep should route correctly (got wave01=${wave01}, wave02=${wave02})`,
|
|
);
|
|
// depends_on output must use canonical plan ID (lowercase as-on-disk), not the uppercase ref
|
|
const planB = output.plans.find(p => p.id === '22-02');
|
|
assert.deepStrictEqual(planB.depends_on, ['22-01-setup'], 'depends_on output must resolve to canonical lowercase ID');
|
|
});
|
|
|
|
// #3785 — external (cross-phase) depends_on reference is kept as-is in output
|
|
// The Pass 3 mapping must return the original dep string when planMap has no entry for it.
|
|
test('#3785: external cross-phase depends_on ref is preserved as-is in output', () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '23-external-dep');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
|
|
// Plan A: references a plan in a different phase (01-some-other-phase) — planMap won't have it
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '23-01-PLAN.md'),
|
|
`---\nwave: 1\nautonomous: true\ndepends_on:\n - 01-01-prereq\n---\n<objective>Plan A.</objective>\n`,
|
|
);
|
|
|
|
const result = runGsdTools('phase-plan-index 23', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
const planA = output.plans.find(p => p.id.startsWith('23-01'));
|
|
assert.ok(planA !== undefined, 'plan 23-01 should appear in output');
|
|
// External dep must be preserved verbatim — not dropped, not resolved
|
|
assert.deepStrictEqual(planA.depends_on, ['01-01-prereq'], 'external cross-phase dep must be kept as-is in output');
|
|
});
|
|
|
|
// #3785 — mixed-case canonical prefix in depends_on resolves via canonicalToId lookup
|
|
// e.g. depends_on: '22-01-SETUP' where extractCanonicalPlanId gives '22-01' keyed lowercase
|
|
test('#3785: mixed-case short canonical prefix in depends_on resolves via canonicalToId', () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '24-canon-case');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
|
|
// Plan A: descriptive filename — id becomes '24-01-auth-hardening'
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '24-01-auth-hardening-PLAN.md'),
|
|
`---\nwave: 1\nautonomous: true\ndepends_on: []\n---\n<objective>Plan A.</objective>\n`,
|
|
);
|
|
// Plan B: depends_on uses an uppercase short prefix '24-01' — canonicalToId maps '24-01' → '24-01-auth-hardening'
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '24-02-followup-PLAN.md'),
|
|
`---\nwave: 2\nautonomous: true\ndepends_on:\n - '24-01'\n---\n<objective>Plan B.</objective>\n`,
|
|
);
|
|
|
|
const result = runGsdTools('phase-plan-index 24', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
const waves = output.waves;
|
|
|
|
const wave01 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('24-01')));
|
|
const wave02 = Object.keys(waves).find(w => waves[w].some(id => id.startsWith('24-02')));
|
|
assert.ok(wave01 !== undefined, '24-01-auth-hardening should appear in waves');
|
|
assert.ok(wave02 !== undefined, '24-02-followup should appear in waves');
|
|
assert.ok(
|
|
Number(wave01) < Number(wave02),
|
|
`24-02 must be in a later wave than 24-01 via canonicalToId lookup (got wave01=${wave01}, wave02=${wave02})`,
|
|
);
|
|
// depends_on output: '24-01' is the canonical prefix, not in planMap directly, so falls back to dep as-is
|
|
const planB = output.plans.find(p => p.id === '24-02-followup');
|
|
assert.ok(planB !== undefined, '24-02-followup plan must be in output');
|
|
// The dep '24-01' is not a planMap key (full id is '24-01-auth-hardening'), so output keeps '24-01'
|
|
assert.deepStrictEqual(planB.depends_on, ['24-01'], 'short canonical prefix dep falls through to as-is in Pass 3 output');
|
|
});
|
|
|
|
// #3785 — plans with no depends_on (empty array) still emit correct output without errors
|
|
test('#3785: plans with undefined/empty depends_on emit empty array without errors', () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '25-no-deps');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
|
|
// Plan with no depends_on key at all
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '25-01-PLAN.md'),
|
|
`---\nwave: 1\nautonomous: true\n---\n<objective>Plan A, no deps.</objective>\n`,
|
|
);
|
|
// Plan with explicit empty depends_on array
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '25-02-PLAN.md'),
|
|
`---\nwave: 1\nautonomous: true\ndepends_on: []\n---\n<objective>Plan B, explicit empty deps.</objective>\n`,
|
|
);
|
|
|
|
const result = runGsdTools('phase-plan-index 25', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
const plan01 = output.plans.find(p => p.id === '25-01');
|
|
const plan02 = output.plans.find(p => p.id === '25-02');
|
|
assert.ok(plan01 !== undefined, 'plan 25-01 should appear in output');
|
|
assert.ok(plan02 !== undefined, 'plan 25-02 should appear in output');
|
|
assert.deepStrictEqual(plan01.depends_on, [], 'plan with no depends_on key must emit empty array');
|
|
assert.deepStrictEqual(plan02.depends_on, [], 'plan with explicit empty depends_on must emit empty array');
|
|
// Both independent plans land in the same wave
|
|
assert.deepStrictEqual(output.waves['1'], ['25-01', '25-02'], 'both no-dep plans should be in wave 1');
|
|
});
|
|
});
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// phase-plan-index — canonical XML format (template-aligned)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('phase-plan-index canonical format', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('files_modified: underscore key is parsed correctly', () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '04-ui');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '04-01-PLAN.md'),
|
|
`---
|
|
wave: 1
|
|
autonomous: true
|
|
files_modified: [src/App.tsx, src/index.ts]
|
|
---
|
|
|
|
<objective>
|
|
Build main application shell
|
|
|
|
Purpose: Entry point
|
|
Output: App component
|
|
</objective>
|
|
|
|
<tasks>
|
|
<task type="auto">
|
|
<name>Task 1: Create App component</name>
|
|
<files>src/App.tsx</files>
|
|
<action>Create component</action>
|
|
<verify>npm run build</verify>
|
|
<done>Component renders</done>
|
|
</task>
|
|
</tasks>
|
|
`
|
|
);
|
|
|
|
const result = runGsdTools('phase-plan-index 04', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.deepStrictEqual(
|
|
output.plans[0].files_modified,
|
|
['src/App.tsx', 'src/index.ts'],
|
|
'files_modified with underscore should be parsed'
|
|
);
|
|
});
|
|
|
|
test('objective: extracted from <objective> XML tag, not frontmatter', () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '04-ui');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '04-01-PLAN.md'),
|
|
`---
|
|
wave: 1
|
|
autonomous: true
|
|
files_modified: []
|
|
---
|
|
|
|
<objective>
|
|
Build main application shell
|
|
|
|
Purpose: Entry point for the SPA
|
|
Output: App.tsx with routing
|
|
</objective>
|
|
|
|
<tasks>
|
|
<task type="auto">
|
|
<name>Task 1: Scaffold</name>
|
|
<files>src/App.tsx</files>
|
|
<action>Create shell</action>
|
|
<verify>build passes</verify>
|
|
<done>App renders</done>
|
|
</task>
|
|
</tasks>
|
|
`
|
|
);
|
|
|
|
const result = runGsdTools('phase-plan-index 04', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(
|
|
output.plans[0].objective,
|
|
'Build main application shell',
|
|
'objective should come from <objective> XML tag first line'
|
|
);
|
|
});
|
|
|
|
test('task_count: counts <task> XML tags', () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '04-ui');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '04-01-PLAN.md'),
|
|
`---
|
|
wave: 1
|
|
autonomous: true
|
|
files_modified: []
|
|
---
|
|
|
|
<objective>
|
|
Create UI components
|
|
</objective>
|
|
|
|
<tasks>
|
|
<task type="auto">
|
|
<name>Task 1: Header</name>
|
|
<files>src/Header.tsx</files>
|
|
<action>Create header</action>
|
|
<verify>build</verify>
|
|
<done>Header renders</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Footer</name>
|
|
<files>src/Footer.tsx</files>
|
|
<action>Create footer</action>
|
|
<verify>build</verify>
|
|
<done>Footer renders</done>
|
|
</task>
|
|
|
|
<task type="checkpoint:human-verify" gate="blocking">
|
|
<what-built>UI components</what-built>
|
|
<how-to-verify>Visit localhost:3000</how-to-verify>
|
|
<resume-signal>Type approved</resume-signal>
|
|
</task>
|
|
</tasks>
|
|
`
|
|
);
|
|
|
|
const result = runGsdTools('phase-plan-index 04', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(
|
|
output.plans[0].task_count,
|
|
3,
|
|
'should count all 3 <task> XML tags'
|
|
);
|
|
});
|
|
|
|
test('all three fields work together in canonical plan format', () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '04-ui');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
|
|
fs.writeFileSync(
|
|
path.join(phaseDir, '04-01-PLAN.md'),
|
|
`---
|
|
phase: 04-ui
|
|
plan: 01
|
|
type: execute
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified: [src/components/Chat.tsx, src/app/api/chat/route.ts]
|
|
autonomous: true
|
|
requirements: [R1, R2]
|
|
---
|
|
|
|
<objective>
|
|
Implement complete Chat feature as vertical slice.
|
|
|
|
Purpose: Self-contained chat that can run parallel to other features.
|
|
Output: Chat component, API endpoints.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@~/.claude/gsd-core/workflows/execute-plan.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
</context>
|
|
|
|
<tasks>
|
|
<task type="auto">
|
|
<name>Task 1: Create Chat component</name>
|
|
<files>src/components/Chat.tsx</files>
|
|
<action>Build chat UI with message list and input</action>
|
|
<verify>npm run build</verify>
|
|
<done>Chat component renders messages</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Create Chat API</name>
|
|
<files>src/app/api/chat/route.ts</files>
|
|
<action>GET /api/chat and POST /api/chat endpoints</action>
|
|
<verify>curl tests pass</verify>
|
|
<done>CRUD operations work</done>
|
|
</task>
|
|
</tasks>
|
|
|
|
<verification>
|
|
- [ ] npm run build succeeds
|
|
- [ ] API endpoints respond correctly
|
|
</verification>
|
|
`
|
|
);
|
|
|
|
const result = runGsdTools('phase-plan-index 04', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
const plan = output.plans[0];
|
|
assert.strictEqual(plan.objective, 'Implement complete Chat feature as vertical slice.', 'objective from XML tag');
|
|
assert.deepStrictEqual(plan.files_modified, ['src/components/Chat.tsx', 'src/app/api/chat/route.ts'], 'files_modified with underscore');
|
|
assert.strictEqual(plan.task_count, 2, 'task_count from <task> XML tags');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// state-snapshot command
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
describe('phase add command', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('adds phase after highest existing', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap v1.0
|
|
|
|
### Phase 1: Foundation
|
|
**Goal:** Setup
|
|
|
|
### Phase 2: API
|
|
**Goal:** Build API
|
|
|
|
---
|
|
`
|
|
);
|
|
|
|
const result = runGsdTools('phase add User Dashboard', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_number, 3, 'should be phase 3');
|
|
assert.strictEqual(output.slug, 'user-dashboard');
|
|
|
|
// Verify directory created
|
|
assert.ok(
|
|
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '03-user-dashboard')),
|
|
'directory should be created'
|
|
);
|
|
|
|
// Verify ROADMAP updated
|
|
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
assert.ok(roadmap.includes('### Phase 3: User Dashboard'), 'roadmap should include new phase');
|
|
assert.ok(roadmap.includes('**Depends on:** Phase 2'), 'should depend on previous');
|
|
});
|
|
|
|
test('handles empty roadmap', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap v1.0\n`
|
|
);
|
|
|
|
const result = runGsdTools('phase add Initial Setup', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_number, 1, 'should be phase 1');
|
|
});
|
|
|
|
test('phase add includes **Requirements**: TBD in new ROADMAP entry', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n`
|
|
);
|
|
|
|
const result = runGsdTools('phase add User Dashboard', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
assert.ok(roadmap.includes('**Requirements**: TBD'), 'new phase entry should include Requirements TBD');
|
|
});
|
|
|
|
test('phase add ignores --raw instead of persisting it in the description', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n`
|
|
);
|
|
|
|
const result = runGsdTools(['phase', 'add', '--raw', 'User', 'Dashboard'], tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
assert.ok(roadmap.includes('### Phase 2: User Dashboard'), 'description should exclude --raw');
|
|
assert.ok(!roadmap.includes('--raw'), 'raw flag must not be persisted into ROADMAP.md');
|
|
});
|
|
|
|
test('phase add rejects unsupported flags and dangling --id', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n`
|
|
);
|
|
|
|
const unsupported = runGsdTools(['phase', 'add', '--unknown', 'Dashboard'], tmpDir);
|
|
assert.ok(!unsupported.success, 'unsupported flags should fail');
|
|
assert.match(unsupported.error, /phase add does not support --unknown/);
|
|
|
|
const dangling = runGsdTools(['phase', 'add', 'Dashboard', '--id'], tmpDir);
|
|
assert.ok(!dangling.success, 'dangling --id should fail');
|
|
assert.match(dangling.error, /--id requires a value/);
|
|
});
|
|
|
|
test('skips 999.x backlog phases when calculating next phase number', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap v1.0
|
|
|
|
### Phase 1: Foundation
|
|
**Goal:** Setup
|
|
|
|
### Phase 2: API
|
|
**Goal:** Build API
|
|
|
|
### Phase 3: UI
|
|
**Goal:** Build UI
|
|
|
|
### Phase 999.1: Future Idea A
|
|
**Goal:** Backlog item
|
|
|
|
### Phase 999.2: Future Idea B
|
|
**Goal:** Backlog item
|
|
|
|
---
|
|
`
|
|
);
|
|
|
|
const result = runGsdTools('phase add Dashboard', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_number, 4, 'should be phase 4, not 1000');
|
|
assert.strictEqual(output.slug, 'dashboard');
|
|
|
|
assert.ok(
|
|
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '04-dashboard')),
|
|
'directory should be 04-dashboard, not 1000-dashboard'
|
|
);
|
|
});
|
|
|
|
test('CJS scanner [999, 1000] fixture: skips exactly 999 and returns 1001 (regression #3774)', () => {
|
|
// Locks the BLOCKER fix in phase.cjs: guards at :610, :624, :688, :698 must
|
|
// use === 999 (not >= 999). With >= 999, phase 1000 is excluded from the
|
|
// max-scan and the result collapses back toward 1 instead of 1001.
|
|
//
|
|
// GSD_WORKSTREAM=ws1 forces the CJS fallback in phase-command-router.cjs.
|
|
// When GSD_WORKSTREAM is set, planningDir resolves to
|
|
// .planning/workstreams/<ws>/ — so ROADMAP.md and phases/ live there.
|
|
const ws = 'ws1';
|
|
const planningBase = path.join(tmpDir, '.planning', 'workstreams', ws);
|
|
fs.mkdirSync(path.join(planningBase, 'phases'), { recursive: true });
|
|
|
|
fs.writeFileSync(
|
|
path.join(planningBase, 'ROADMAP.md'),
|
|
[
|
|
'# Roadmap',
|
|
'',
|
|
'## Current Milestone: v1.0',
|
|
'',
|
|
'### Phase 999: Backlog',
|
|
'',
|
|
'**Goal:** Backlog sentinel',
|
|
'**Plans:** 0 plans',
|
|
'',
|
|
'### Phase 1000: First Four-Digit Phase',
|
|
'',
|
|
'**Goal:** First canonical phase above backlog sentinel',
|
|
'**Requirements**: TBD',
|
|
'**Plans:** 1 plans',
|
|
'',
|
|
'Plans:',
|
|
'- [x] 1000-01 (initial work)',
|
|
'',
|
|
'---',
|
|
'*Last updated: 2026-05-21*',
|
|
'',
|
|
].join('\n')
|
|
);
|
|
|
|
// Create matching phase directories on disk (inside the workstream planning dir)
|
|
fs.mkdirSync(path.join(planningBase, 'phases', '999-backlog'), { recursive: true });
|
|
fs.mkdirSync(path.join(planningBase, 'phases', '1000-first-four-digit'), { recursive: true });
|
|
|
|
const result = runGsdTools('phase add After One Thousand', tmpDir, { GSD_WORKSTREAM: ws });
|
|
assert.ok(result.success, `CJS phase add failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
// Must be 1001: skips 999 (backlog sentinel), keeps 1000, adds 1.
|
|
// With the old >= 999 guard: phase 1000 is excluded → max stays 0 → result = 1.
|
|
assert.strictEqual(output.phase_number, 1001, 'CJS scanner must return 1001, not 1 (regression #3774)');
|
|
assert.ok(
|
|
fs.existsSync(path.join(planningBase, 'phases', '1001-after-one-thousand')),
|
|
'directory should be 1001-after-one-thousand'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// phase add — orphan directory collision prevention (#2026)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('phase add — orphan directory collision prevention (#2026)', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('orphan directory with higher number than ROADMAP pushes maxPhase up', () => {
|
|
// Orphan directory 05-orphan exists on disk but is NOT in ROADMAP.md
|
|
const orphanDir = path.join(tmpDir, '.planning', 'phases', '05-orphan');
|
|
fs.mkdirSync(orphanDir, { recursive: true });
|
|
fs.writeFileSync(path.join(orphanDir, 'SUMMARY.md'), 'existing work');
|
|
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
[
|
|
'# Roadmap',
|
|
'## Milestone v1',
|
|
'### Phase 1: First phase',
|
|
'**Plans:** 0 plans',
|
|
'---',
|
|
].join('\n')
|
|
);
|
|
|
|
const result = runGsdTools('phase add dashboard', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
// ROADMAP max is 1, but orphan 05-orphan means disk max is 5 → new phase = 6
|
|
assert.strictEqual(output.phase_number, 6, 'should be phase 6 (orphan 05 pushes max to 5)');
|
|
|
|
// The new directory must be 06-dashboard, not 02-dashboard
|
|
assert.ok(
|
|
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '06-dashboard')),
|
|
'new phase directory must be 06-dashboard, not collide with orphan 05-orphan'
|
|
);
|
|
|
|
// The orphan directory must be untouched
|
|
assert.ok(
|
|
fs.existsSync(path.join(orphanDir, 'SUMMARY.md')),
|
|
'orphan directory content must be preserved (not overwritten)'
|
|
);
|
|
});
|
|
|
|
test('orphan directories with 999.x prefix are skipped when calculating disk max', () => {
|
|
// 999.x backlog orphans must not inflate the next sequential phase number
|
|
const backlogOrphan = path.join(tmpDir, '.planning', 'phases', '999-backlog-stuff');
|
|
fs.mkdirSync(backlogOrphan, { recursive: true });
|
|
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
[
|
|
'# Roadmap',
|
|
'### Phase 1: Foundation',
|
|
'**Plans:** 0 plans',
|
|
'---',
|
|
].join('\n')
|
|
);
|
|
|
|
const result = runGsdTools('phase add new-feature', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
// ROADMAP max is 1, disk orphan is 999 (backlog) → should be ignored → new phase = 2
|
|
assert.strictEqual(output.phase_number, 2, 'backlog 999.x orphan must not inflate phase count');
|
|
assert.ok(
|
|
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-new-feature')),
|
|
'new phase directory should be 02-new-feature'
|
|
);
|
|
});
|
|
|
|
test('project_code prefix in orphan directory name is stripped before comparing', () => {
|
|
// Orphan directory has project_code prefix e.g. CK-05-orphan
|
|
const orphanDir = path.join(tmpDir, '.planning', 'phases', 'CK-05-old-feature');
|
|
fs.mkdirSync(orphanDir, { recursive: true });
|
|
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ project_code: 'CK' })
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
[
|
|
'# Roadmap',
|
|
'### Phase 1: Foundation',
|
|
'**Plans:** 0 plans',
|
|
'---',
|
|
].join('\n')
|
|
);
|
|
|
|
const result = runGsdTools('phase add new-feature', tmpDir, { HOME: tmpDir });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
// ROADMAP max is 1, disk has CK-05-old-feature → strip prefix → disk max is 5 → new phase = 6
|
|
assert.strictEqual(output.phase_number, 6, 'project_code prefix must be stripped before disk max calculation');
|
|
assert.ok(
|
|
fs.existsSync(path.join(tmpDir, '.planning', 'phases', 'CK-06-new-feature')),
|
|
'new phase directory must be CK-06-new-feature'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// phase add with project_code prefix
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
describe('phase add with project_code', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('prefixes phase directory with project_code', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ project_code: 'CK' })
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
'# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n'
|
|
);
|
|
|
|
const result = runGsdTools('phase add User Dashboard', tmpDir, { HOME: tmpDir });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_number, 2, 'should be phase 2');
|
|
assert.ok(
|
|
fs.existsSync(path.join(tmpDir, '.planning', 'phases', 'CK-02-user-dashboard')),
|
|
'directory should have CK- prefix'
|
|
);
|
|
});
|
|
|
|
test('no prefix when project_code is null', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ project_code: null })
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
'# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n'
|
|
);
|
|
|
|
const result = runGsdTools('phase add User Dashboard', tmpDir, { HOME: tmpDir });
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
assert.ok(
|
|
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-user-dashboard')),
|
|
'directory should have no prefix'
|
|
);
|
|
});
|
|
|
|
test('find-phase resolves prefixed directories', () => {
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', 'CK-01-foundation');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan');
|
|
|
|
const result = runGsdTools('find-phase 01', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.found, true, 'should find prefixed phase');
|
|
assert.strictEqual(output.phase_number, '01', 'should extract numeric phase number');
|
|
});
|
|
|
|
test('phases list sorts prefixed directories correctly', () => {
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', 'CK-02-api'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', 'CK-01-foundation'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', 'CK-03-ui'), { recursive: true });
|
|
|
|
const result = runGsdTools('phases list', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.deepStrictEqual(
|
|
output.directories,
|
|
['CK-01-foundation', 'CK-02-api', 'CK-03-ui'],
|
|
'prefixed phases should sort numerically'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// phase add-batch command (#2165)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('phase add-batch command (#2165)', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
[
|
|
'# Roadmap v1.0',
|
|
'',
|
|
'### Phase 1: Foundation',
|
|
'**Goal:** Setup',
|
|
'',
|
|
'---',
|
|
'',
|
|
].join('\n')
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('adds multiple phases with sequential numbers in a single call', () => {
|
|
// Use array form to avoid shell quoting issues with JSON args
|
|
const result = runGsdTools(['phase', 'add-batch', '--descriptions', '["Alpha","Beta","Gamma"]'], tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.count, 3, 'should report 3 phases added');
|
|
assert.strictEqual(output.phases[0].phase_number, 2);
|
|
assert.strictEqual(output.phases[1].phase_number, 3);
|
|
assert.strictEqual(output.phases[2].phase_number, 4);
|
|
|
|
assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-alpha')), '02-alpha dir must exist');
|
|
assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'phases', '03-beta')), '03-beta dir must exist');
|
|
assert.ok(fs.existsSync(path.join(tmpDir, '.planning', 'phases', '04-gamma')), '04-gamma dir must exist');
|
|
|
|
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
assert.ok(roadmap.includes('### Phase 2: Alpha'), 'roadmap should include Phase 2');
|
|
assert.ok(roadmap.includes('### Phase 3: Beta'), 'roadmap should include Phase 3');
|
|
assert.ok(roadmap.includes('### Phase 4: Gamma'), 'roadmap should include Phase 4');
|
|
});
|
|
|
|
test('no duplicate phase numbers when multiple add-batch calls are made sequentially', () => {
|
|
// Regression for #2165: parallel `phase add` invocations produced duplicates
|
|
// because each read disk state before any write landed. add-batch serializes
|
|
// the entire batch under a single lock so the next call sees the updated state.
|
|
const r1 = runGsdTools(['phase', 'add-batch', '--descriptions', '["Wave-One-A","Wave-One-B"]'], tmpDir);
|
|
assert.ok(r1.success, `First batch failed: ${r1.error}`);
|
|
|
|
const r2 = runGsdTools(['phase', 'add-batch', '--descriptions', '["Wave-Two-A","Wave-Two-B"]'], tmpDir);
|
|
assert.ok(r2.success, `Second batch failed: ${r2.error}`);
|
|
|
|
const out1 = JSON.parse(r1.output);
|
|
const out2 = JSON.parse(r2.output);
|
|
const allNums = [...out1.phases, ...out2.phases].map(p => p.phase_number);
|
|
const unique = new Set(allNums);
|
|
assert.strictEqual(unique.size, allNums.length, `Duplicate phase numbers detected: ${allNums}`);
|
|
|
|
// Directories must all exist and be unique
|
|
const dirs = fs.readdirSync(path.join(tmpDir, '.planning', 'phases'));
|
|
assert.strictEqual(dirs.length, 4, `Expected 4 phase dirs, got: ${dirs}`);
|
|
});
|
|
|
|
test('each phase directory contains a .gitkeep file', () => {
|
|
const result = runGsdTools(['phase', 'add-batch', '--descriptions', '["Setup","Build"]'], tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
assert.ok(
|
|
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-setup', '.gitkeep')),
|
|
'.gitkeep must exist in 02-setup'
|
|
);
|
|
assert.ok(
|
|
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '03-build', '.gitkeep')),
|
|
'.gitkeep must exist in 03-build'
|
|
);
|
|
});
|
|
|
|
test('returns error for empty descriptions array', () => {
|
|
const result = runGsdTools(['phase', 'add-batch', '--descriptions', '[]'], tmpDir);
|
|
assert.ok(!result.success, 'should fail on empty array');
|
|
});
|
|
|
|
test('returns error when --descriptions JSON is not an array', () => {
|
|
const result = runGsdTools(['phase', 'add-batch', '--descriptions', '{"one":"Alpha"}'], tmpDir);
|
|
assert.ok(!result.success, 'should fail on non-array JSON');
|
|
assert.match(result.error, /--descriptions must be a JSON array/);
|
|
});
|
|
|
|
test('returns error when --descriptions is missing its JSON value', () => {
|
|
const missing = runGsdTools(['phase', 'add-batch', '--descriptions'], tmpDir);
|
|
assert.ok(!missing.success, 'should fail on dangling --descriptions');
|
|
assert.match(missing.error, /--descriptions must be a JSON array/);
|
|
|
|
const flagValue = runGsdTools(['phase', 'add-batch', '--descriptions', '--raw'], tmpDir);
|
|
assert.ok(!flagValue.success, 'should fail when --descriptions value is another flag');
|
|
assert.match(flagValue.error, /--descriptions must be a JSON array/);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// phase insert command
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
describe('phase insert command', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('inserts decimal phase after target', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap
|
|
|
|
### Phase 1: Foundation
|
|
**Goal:** Setup
|
|
|
|
### Phase 2: API
|
|
**Goal:** Build API
|
|
`
|
|
);
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
|
|
|
|
const result = runGsdTools('phase insert 1 Fix Critical Bug', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_number, '01.1', 'should be 01.1');
|
|
assert.strictEqual(output.after_phase, '1');
|
|
|
|
// Verify directory
|
|
assert.ok(
|
|
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '01.1-fix-critical-bug')),
|
|
'decimal phase directory should be created'
|
|
);
|
|
|
|
// Verify ROADMAP
|
|
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
assert.ok(roadmap.includes('Phase 01.1: Fix Critical Bug (INSERTED)'), 'roadmap should include inserted phase');
|
|
});
|
|
|
|
test('increments decimal when siblings exist', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap
|
|
|
|
### Phase 1: Foundation
|
|
**Goal:** Setup
|
|
|
|
### Phase 2: API
|
|
**Goal:** Build API
|
|
`
|
|
);
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01.1-hotfix'), { recursive: true });
|
|
|
|
const result = runGsdTools('phase insert 1 Another Fix', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_number, '01.2', 'should be 01.2');
|
|
});
|
|
|
|
test('rejects missing phase', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap\n### Phase 1: Test\n**Goal:** Test\n`
|
|
);
|
|
|
|
const result = runGsdTools('phase insert 99 Fix Something', tmpDir);
|
|
assert.ok(!result.success, 'should fail for missing phase');
|
|
assert.ok(result.error.includes('not found'), 'error mentions not found');
|
|
});
|
|
|
|
test('handles padding mismatch between input and roadmap', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap
|
|
|
|
## Phase 09.05: Existing Decimal Phase
|
|
**Goal:** Test padding
|
|
|
|
## Phase 09.1: Next Phase
|
|
**Goal:** Test
|
|
`
|
|
);
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '09.05-existing'), { recursive: true });
|
|
|
|
// Pass unpadded "9.05" but roadmap has "09.05"
|
|
const result = runGsdTools('phase insert 9.05 Padding Test', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.after_phase, '9.05');
|
|
|
|
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
assert.ok(roadmap.includes('(INSERTED)'), 'roadmap should include inserted phase');
|
|
});
|
|
|
|
test('phase insert includes **Requirements**: TBD in new ROADMAP entry', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n### Phase 2: API\n**Goal:** Build API\n`
|
|
);
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
|
|
|
|
const result = runGsdTools('phase insert 1 Fix Critical Bug', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
assert.ok(roadmap.includes('**Requirements**: TBD'), 'inserted phase entry should include Requirements TBD');
|
|
});
|
|
|
|
test('reports actionable error for summary-only placeholder phase without detail section (#3098)', () => {
|
|
// #3098: a hybrid ROADMAP that has heading-style phases for some phases
|
|
// but only a bullet summary entry for phase 5 (the detail section is
|
|
// missing). Insert must fail with "missing a detail section" rather than
|
|
// silently inserting in bullet-style — because the surrounding ROADMAP
|
|
// uses headings, so the absent `### Phase 5:` is a genuine omission.
|
|
// (Compare with the #3815 case below: a purely bullet-style ROADMAP that
|
|
// has NO heading-style phases at all is valid and insert should succeed.)
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap\n\n### Phase 4: Foundation\n**Goal:** Setup\n\n- [ ] **Phase 5: Placeholder**\n`
|
|
);
|
|
|
|
const result = runGsdTools('phase insert 5 Hotfix', tmpDir);
|
|
assert.ok(!result.success, 'should fail when phase is summary-only placeholder in a heading-style ROADMAP');
|
|
assert.ok(result.error.includes('missing a detail section'));
|
|
});
|
|
|
|
test('phase insert rejects unsupported --dry-run flag explicitly (#3098)', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap\n\n### Phase 1: Foundation\n**Goal:** Setup\n`
|
|
);
|
|
|
|
const result = runGsdTools('phase insert 1 Hotfix --dry-run', tmpDir);
|
|
assert.ok(!result.success, 'phase insert should reject unsupported --dry-run');
|
|
assert.ok(result.error.includes('does not support --dry-run'));
|
|
});
|
|
|
|
test('handles #### heading depth from multi-milestone roadmaps', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap
|
|
|
|
### v1.1 Milestone
|
|
|
|
#### Phase 5: Feature Work
|
|
**Goal:** Build features
|
|
|
|
#### Phase 6: Polish
|
|
**Goal:** Polish
|
|
`
|
|
);
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '05-feature-work'), { recursive: true });
|
|
|
|
const result = runGsdTools('phase insert 5 Hotfix', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_number, '05.1');
|
|
|
|
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
assert.ok(roadmap.includes('Phase 05.1: Hotfix (INSERTED)'), 'roadmap should include inserted phase');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// phase remove command
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
describe('phase remove command', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('removes phase directory and renumbers subsequent', () => {
|
|
// Setup 3 phases
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap
|
|
|
|
### Phase 1: Foundation
|
|
**Goal:** Setup
|
|
**Depends on:** Nothing
|
|
|
|
### Phase 2: Auth
|
|
**Goal:** Authentication
|
|
**Depends on:** Phase 1
|
|
|
|
### Phase 3: Features
|
|
**Goal:** Core features
|
|
**Depends on:** Phase 2
|
|
`
|
|
);
|
|
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
|
|
const p2 = path.join(tmpDir, '.planning', 'phases', '02-auth');
|
|
fs.mkdirSync(p2, { recursive: true });
|
|
fs.writeFileSync(path.join(p2, '02-01-PLAN.md'), '# Plan');
|
|
const p3 = path.join(tmpDir, '.planning', 'phases', '03-features');
|
|
fs.mkdirSync(p3, { recursive: true });
|
|
fs.writeFileSync(path.join(p3, '03-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p3, '03-02-PLAN.md'), '# Plan 2');
|
|
|
|
// Remove phase 2
|
|
const result = runGsdTools('phase remove 2', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.removed, '2');
|
|
assert.strictEqual(output.directory_deleted, '02-auth');
|
|
|
|
// Phase 3 should be renumbered to 02
|
|
assert.ok(
|
|
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-features')),
|
|
'phase 3 should be renumbered to 02-features'
|
|
);
|
|
assert.ok(
|
|
!fs.existsSync(path.join(tmpDir, '.planning', 'phases', '03-features')),
|
|
'old 03-features should not exist'
|
|
);
|
|
|
|
// Files inside should be renamed
|
|
assert.ok(
|
|
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-features', '02-01-PLAN.md')),
|
|
'plan file should be renumbered to 02-01'
|
|
);
|
|
assert.ok(
|
|
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-features', '02-02-PLAN.md')),
|
|
'plan 2 should be renumbered to 02-02'
|
|
);
|
|
|
|
// ROADMAP should be updated
|
|
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
assert.ok(!roadmap.includes('Phase 2: Auth'), 'removed phase should not be in roadmap');
|
|
assert.ok(roadmap.includes('Phase 2: Features'), 'phase 3 should be renumbered to 2');
|
|
});
|
|
|
|
test('rejects removal of phase with summaries unless --force', () => {
|
|
const p1 = path.join(tmpDir, '.planning', 'phases', '01-test');
|
|
fs.mkdirSync(p1, { recursive: true });
|
|
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap\n### Phase 1: Test\n**Goal:** Test\n`
|
|
);
|
|
|
|
// Should fail without --force
|
|
const result = runGsdTools('phase remove 1', tmpDir);
|
|
assert.ok(!result.success, 'should fail without --force');
|
|
assert.ok(result.error.includes('executed plan'), 'error mentions executed plans');
|
|
|
|
// Should succeed with --force
|
|
const forceResult = runGsdTools('phase remove 1 --force', tmpDir);
|
|
assert.ok(forceResult.success, `Force remove failed: ${forceResult.error}`);
|
|
});
|
|
|
|
test('bug-3409: supports --force before phase id', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap\n### Phase 1: A\n**Goal:** A\n### Phase 2: B\n**Goal:** B\n`
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
`# State\n\n**Current Phase:** 1\n**Total Phases:** 2\n`
|
|
);
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-b'), { recursive: true });
|
|
|
|
const result = runGsdTools('phase remove --force 2', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.removed, '2');
|
|
assert.strictEqual(output.directory_deleted, '02-b');
|
|
assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', 'phases', '02-b')));
|
|
|
|
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
|
assert.ok(state.includes('**Total Phases:** 1'), 'total phases should be decremented after real removal');
|
|
});
|
|
|
|
test('removes decimal phase and renumbers siblings', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap\n### Phase 6: Main\n**Goal:** Main\n### Phase 6.1: Fix A\n**Goal:** Fix A\n### Phase 6.2: Fix B\n**Goal:** Fix B\n### Phase 6.3: Fix C\n**Goal:** Fix C\n`
|
|
);
|
|
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06-main'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06.1-fix-a'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06.2-fix-b'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06.3-fix-c'), { recursive: true });
|
|
|
|
const result = runGsdTools('phase remove 6.2', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
// 06.3 should become 06.2
|
|
assert.ok(
|
|
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '06.2-fix-c')),
|
|
'06.3 should be renumbered to 06.2'
|
|
);
|
|
assert.ok(
|
|
!fs.existsSync(path.join(tmpDir, '.planning', 'phases', '06.3-fix-c')),
|
|
'old 06.3 should not exist'
|
|
);
|
|
});
|
|
|
|
test('updates STATE.md phase count', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap\n### Phase 1: A\n**Goal:** A\n### Phase 2: B\n**Goal:** B\n`
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
`# State\n\n**Current Phase:** 1\n**Total Phases:** 2\n`
|
|
);
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-a'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-b'), { recursive: true });
|
|
|
|
runGsdTools('phase remove 2', tmpDir);
|
|
|
|
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
|
assert.ok(state.includes('**Total Phases:** 1'), 'total phases should be decremented');
|
|
});
|
|
|
|
test('bug-2434: integer phase remove does not rename 999.x backlog directory', () => {
|
|
// Setup: an active integer phase 4 and a backlog phase 999.1
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap
|
|
|
|
### Phase 1: Foundation
|
|
**Goal:** Setup
|
|
|
|
### Phase 2: Auth
|
|
**Goal:** Authentication
|
|
|
|
### Phase 3: Features
|
|
**Goal:** Core features
|
|
|
|
### Phase 4: Extras
|
|
**Goal:** Extra stuff
|
|
|
|
### Phase 999.1: Backlog item
|
|
**Goal:** Parked backlog task
|
|
`
|
|
);
|
|
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-auth'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-features'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '04-extras'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '999.1-backlog-item'), { recursive: true });
|
|
|
|
const result = runGsdTools('phase remove 4', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
// Backlog directory must remain at 999.1, not be decremented to 998.1
|
|
assert.ok(
|
|
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '999.1-backlog-item')),
|
|
'backlog directory 999.1-backlog-item must not be renamed'
|
|
);
|
|
assert.ok(
|
|
!fs.existsSync(path.join(tmpDir, '.planning', 'phases', '998.1-backlog-item')),
|
|
'backlog directory must not be incorrectly renamed to 998.1'
|
|
);
|
|
});
|
|
|
|
test('bug-16: integer phase remove renumbers canonical phases above 999 while preserving 999.x backlog', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap
|
|
|
|
### Phase 1199: Baseline
|
|
**Goal:** Before removal
|
|
Plans:
|
|
- [x] 1199-01-PLAN.md
|
|
|
|
### Phase 1200: Remove Me
|
|
**Goal:** Target phase
|
|
Plans:
|
|
- [ ] 1200-01-PLAN.md
|
|
|
|
### Phase 1201: Follow Up A
|
|
**Goal:** First phase after target
|
|
**Depends on:** Phase 1200
|
|
Plans:
|
|
- [ ] 1201-01-PLAN.md
|
|
|
|
### Phase 1202: Follow Up B
|
|
**Goal:** Second phase after target
|
|
**Depends on:** Phase 1201
|
|
Plans:
|
|
- [ ] 1202-01-PLAN.md
|
|
|
|
### Phase 999.1: Backlog Item
|
|
**Goal:** Parked backlog item
|
|
`
|
|
);
|
|
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '1199-baseline'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '1200-remove-me'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '1201-follow-up-a'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '1202-follow-up-b'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '999.1-backlog-item'), { recursive: true });
|
|
|
|
const result = runGsdTools('phase remove 1200', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
// On-disk phase directories should be decremented by one above removedInt.
|
|
assert.ok(
|
|
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '1200-follow-up-a')),
|
|
'1201-follow-up-a should be renamed to 1200-follow-up-a',
|
|
);
|
|
assert.ok(
|
|
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '1201-follow-up-b')),
|
|
'1202-follow-up-b should be renamed to 1201-follow-up-b',
|
|
);
|
|
assert.ok(
|
|
!fs.existsSync(path.join(tmpDir, '.planning', 'phases', '1201-follow-up-a')),
|
|
'old 1201-follow-up-a directory should not remain',
|
|
);
|
|
assert.ok(
|
|
!fs.existsSync(path.join(tmpDir, '.planning', 'phases', '1202-follow-up-b')),
|
|
'old 1202-follow-up-b directory should not remain',
|
|
);
|
|
|
|
// Backlog 999.x must remain untouched.
|
|
assert.ok(
|
|
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '999.1-backlog-item')),
|
|
'backlog directory 999.1-backlog-item must not be renamed',
|
|
);
|
|
|
|
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
assert.ok(!roadmap.includes('### Phase 1200: Remove Me'), 'removed phase 1200 section must be gone');
|
|
assert.ok(roadmap.includes('### Phase 1200: Follow Up A'), 'phase 1201 should be renumbered to 1200');
|
|
assert.ok(roadmap.includes('### Phase 1201: Follow Up B'), 'phase 1202 should be renumbered to 1201');
|
|
assert.ok(!roadmap.includes('### Phase 1202: Follow Up B'), 'old phase 1202 heading must not remain');
|
|
assert.ok(roadmap.includes('**Depends on:** Phase 1200'), 'depends-on reference above removed phase should be decremented');
|
|
assert.ok(roadmap.includes('### Phase 999.1: Backlog Item'), 'backlog phase 999.1 heading must not be renumbered');
|
|
});
|
|
|
|
test('bug-2435: integer phase remove does not corrupt YYYY-MM-DD dates in ROADMAP.md', () => {
|
|
// Setup: removing phase 4 from a roadmap containing 2026-04-14 date strings
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap
|
|
|
|
### Phase 1: Foundation
|
|
**Goal:** Setup
|
|
**Completed:** 2026-01-15
|
|
|
|
### Phase 2: Auth
|
|
**Goal:** Authentication
|
|
**Completed:** 2026-02-20
|
|
|
|
### Phase 3: Features
|
|
**Goal:** Core features
|
|
**Completed:** 2026-04-14
|
|
|
|
### Phase 4: Extras
|
|
**Goal:** Extra stuff
|
|
|
|
### Phase 5: Final
|
|
**Goal:** Final phase
|
|
`
|
|
);
|
|
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-auth'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-features'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '04-extras'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '05-final'), { recursive: true });
|
|
|
|
const result = runGsdTools('phase remove 4', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
|
|
// Dates must be preserved exactly
|
|
assert.ok(roadmap.includes('2026-01-15'), 'date 2026-01-15 must not be corrupted');
|
|
assert.ok(roadmap.includes('2026-02-20'), 'date 2026-02-20 must not be corrupted');
|
|
assert.ok(roadmap.includes('2026-04-14'), 'date 2026-04-14 must not be corrupted');
|
|
|
|
// Phase 5 should be renumbered to 4
|
|
assert.ok(roadmap.includes('Phase 4: Final'), 'Phase 5 should be renumbered to Phase 4');
|
|
});
|
|
|
|
test('bug-2435: integer phase remove does not corrupt date whose month matches removed phase number', () => {
|
|
// Setup: removing phase 4 from a roadmap containing 2026-05-14
|
|
// When renumbering phase 5→4, the regex must not replace "05-14" in the date "2026-05-14"
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap
|
|
|
|
### Phase 1: Foundation
|
|
**Goal:** Setup
|
|
**Completed:** 2026-01-15
|
|
|
|
### Phase 2: Auth
|
|
**Goal:** Authentication
|
|
**Completed:** 2026-02-20
|
|
|
|
### Phase 3: Features
|
|
**Goal:** Core features
|
|
**Completed:** 2026-03-10
|
|
|
|
### Phase 4: Extras
|
|
**Goal:** Extra stuff
|
|
|
|
### Phase 5: Final
|
|
**Goal:** Final phase
|
|
**Due:** 2026-05-14
|
|
`
|
|
);
|
|
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-auth'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-features'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '04-extras'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '05-final'), { recursive: true });
|
|
|
|
const result = runGsdTools('phase remove 4', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
|
|
// Date "2026-05-14" must not be corrupted to "2026-04-14" when phase 5 is renumbered to 4
|
|
assert.ok(roadmap.includes('2026-05-14'), 'date 2026-05-14 must not be corrupted when renumbering phase 5→4');
|
|
assert.ok(!roadmap.includes('2026-04-14'), 'date must not be incorrectly mutated to 2026-04-14');
|
|
|
|
// Phase 5 should be renumbered to 4
|
|
assert.ok(roadmap.includes('Phase 4: Final'), 'Phase 5 should be renumbered to Phase 4');
|
|
});
|
|
|
|
test('bug-3355: integer phase remove renumbers roadmap once without collapsing later phases', () => {
|
|
const lines = ['# Roadmap', '', '## Progress', '', '| Phase | Plans | Status | Notes |', '|---|---:|---|---|'];
|
|
for (let n = 26; n <= 35; n++) {
|
|
lines.push(`| ${n}. Phase ${n} | 0/1 | Planned | - |`);
|
|
}
|
|
lines.push('');
|
|
for (let n = 26; n <= 35; n++) {
|
|
lines.push(`### Phase ${n}: Phase ${n}`);
|
|
lines.push(`#### Phase ${n}.1: Phase ${n}.1 follow-up`);
|
|
lines.push(`**Goal:** Build phase ${n}`);
|
|
lines.push(n % 2 === 0 ? `**Depends on**: Phase ${n - 1}` : `**Depends on:** Phase ${n - 1}`);
|
|
lines.push(`Plans: ${String(n).padStart(2, '0')}-01-PLAN.md`);
|
|
lines.push('');
|
|
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', `${String(n).padStart(2, '0')}-phase-${n}`);
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
fs.writeFileSync(path.join(phaseDir, `${String(n).padStart(2, '0')}-01-PLAN.md`), '# Plan');
|
|
}
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), lines.join('\n'));
|
|
|
|
const result = runGsdTools('phase remove 27 --force', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
assert.equal((roadmap.match(/\|\s*27\.\s/g) || []).length, 1, 'progress row 27 appears once');
|
|
assert.equal((roadmap.match(/\|\s*28\.\s/g) || []).length, 1, 'progress row 28 appears once');
|
|
assert.equal((roadmap.match(/\|\s*34\.\s/g) || []).length, 1, 'progress row 34 appears once');
|
|
assert.equal((roadmap.match(/\|\s*35\.\s/g) || []).length, 0, 'old progress row 35 removed by renumber');
|
|
assert.equal((roadmap.match(/^### Phase 27:/gm) || []).length, 1, 'heading 27 appears once');
|
|
assert.equal((roadmap.match(/^### Phase 34:/gm) || []).length, 1, 'heading 34 appears once');
|
|
assert.equal((roadmap.match(/^### Phase 35:/gm) || []).length, 0, 'old heading 35 removed by renumber');
|
|
assert.equal((roadmap.match(/^#### Phase 27\.1:/gm) || []).length, 1, 'decimal heading 27.1 appears once');
|
|
assert.equal((roadmap.match(/^#### Phase 34\.1:/gm) || []).length, 1, 'decimal heading 34.1 appears once');
|
|
assert.equal((roadmap.match(/^#### Phase 35\.1:/gm) || []).length, 0, 'old decimal heading 35.1 removed by renumber');
|
|
assert.equal((roadmap.match(/\*\*Depends on\*\*:\s*Phase\s+28\b/g) || []).length, 1, 'bold depends-on with outside colon is decremented');
|
|
assert.equal((roadmap.match(/\*\*Depends on:\*\*\s*Phase\s+29\b/g) || []).length, 1, 'legacy bold depends-on with inside colon is decremented');
|
|
assert.equal((roadmap.match(/\*\*Depends on:\*\*\s*Phase\s+35\b/g) || []).length, 0, 'old depends-on 35 removed by renumber');
|
|
assert.equal((roadmap.match(/\b27-01-PLAN\.md\b/g) || []).length, 1, 'plan id 27-01 appears once');
|
|
assert.equal((roadmap.match(/\b34-01-PLAN\.md\b/g) || []).length, 1, 'plan id 34-01 appears once');
|
|
assert.equal((roadmap.match(/\b35-01-PLAN\.md\b/g) || []).length, 0, 'old plan id 35-01 removed by renumber');
|
|
|
|
for (let n = 27; n <= 34; n++) {
|
|
assert.ok(
|
|
fs.existsSync(path.join(tmpDir, '.planning', 'phases', `${String(n).padStart(2, '0')}-phase-${n + 1}`)),
|
|
`phase directory ${n} should preserve original phase slug ${n + 1}`,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// phase complete command
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
describe('phase complete command', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('marks phase complete and transitions to next', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap
|
|
|
|
- [ ] Phase 1: Foundation
|
|
- [ ] Phase 2: API
|
|
|
|
### Phase 1: Foundation
|
|
**Goal:** Setup
|
|
**Plans:** 1 plans
|
|
|
|
### Phase 2: API
|
|
**Goal:** Build API
|
|
`
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
`# State\n\n**Current Phase:** 01\n**Current Phase Name:** Foundation\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working on phase 1\n`
|
|
);
|
|
|
|
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
|
fs.mkdirSync(p1, { recursive: true });
|
|
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true });
|
|
|
|
const result = runGsdTools('phase complete 1', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.completed_phase, '1');
|
|
assert.strictEqual(output.plans_executed, '1/1');
|
|
assert.strictEqual(output.next_phase, '02');
|
|
assert.strictEqual(output.is_last_phase, false);
|
|
|
|
// Verify STATE.md updated
|
|
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
|
assert.ok(state.includes('**Current Phase:** 02'), 'should advance to phase 02');
|
|
assert.ok(state.includes('**Status:** Ready to plan'), 'status should be ready to plan');
|
|
assert.ok(state.includes('**Current Plan:** Not started'), 'plan should be reset');
|
|
|
|
// Verify ROADMAP checkbox
|
|
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
assert.ok(roadmap.includes('[x]'), 'phase should be checked off');
|
|
assert.ok(roadmap.includes('completed'), 'completion date should be added');
|
|
});
|
|
|
|
test('detects last phase in milestone', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap\n### Phase 1: Only Phase\n**Goal:** Everything\n`
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
`# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
|
|
);
|
|
|
|
const p1 = path.join(tmpDir, '.planning', 'phases', '01-only-phase');
|
|
fs.mkdirSync(p1, { recursive: true });
|
|
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
|
|
|
const result = runGsdTools('phase complete 1', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.is_last_phase, true, 'should detect last phase');
|
|
assert.strictEqual(output.next_phase, null, 'no next phase');
|
|
|
|
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
|
assert.ok(state.includes('Milestone complete'), 'status should be milestone complete');
|
|
});
|
|
|
|
test('updates REQUIREMENTS.md traceability when phase completes', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap
|
|
|
|
- [ ] Phase 1: Auth
|
|
|
|
### Phase 1: Auth
|
|
**Goal:** User authentication
|
|
**Requirements:** AUTH-01, AUTH-02
|
|
**Plans:** 1 plans
|
|
|
|
### Phase 2: API
|
|
**Goal:** Build API
|
|
**Requirements:** API-01
|
|
`
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),
|
|
`# Requirements
|
|
|
|
## v1 Requirements
|
|
|
|
### Authentication
|
|
|
|
- [ ] **AUTH-01**: User can sign up with email
|
|
- [ ] **AUTH-02**: User can log in
|
|
- [ ] **AUTH-03**: User can reset password
|
|
|
|
### API
|
|
|
|
- [ ] **API-01**: REST endpoints
|
|
|
|
## Traceability
|
|
|
|
| Requirement | Phase | Status |
|
|
|-------------|-------|--------|
|
|
| AUTH-01 | Phase 1 | Pending |
|
|
| AUTH-02 | Phase 1 | Pending |
|
|
| AUTH-03 | Phase 2 | Pending |
|
|
| API-01 | Phase 2 | Pending |
|
|
`
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
`# State\n\n**Current Phase:** 01\n**Current Phase Name:** Auth\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
|
|
);
|
|
|
|
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
|
|
fs.mkdirSync(p1, { recursive: true });
|
|
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true });
|
|
|
|
const result = runGsdTools('phase complete 1', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8');
|
|
|
|
// Checkboxes updated for phase 1 requirements
|
|
assert.ok(req.includes('- [x] **AUTH-01**'), 'AUTH-01 checkbox should be checked');
|
|
assert.ok(req.includes('- [x] **AUTH-02**'), 'AUTH-02 checkbox should be checked');
|
|
// Other requirements unchanged
|
|
assert.ok(req.includes('- [ ] **AUTH-03**'), 'AUTH-03 should remain unchecked');
|
|
assert.ok(req.includes('- [ ] **API-01**'), 'API-01 should remain unchecked');
|
|
|
|
// Traceability table updated
|
|
assert.ok(req.includes('| AUTH-01 | Phase 1 | Complete |'), 'AUTH-01 status should be Complete');
|
|
assert.ok(req.includes('| AUTH-02 | Phase 1 | Complete |'), 'AUTH-02 status should be Complete');
|
|
assert.ok(req.includes('| AUTH-03 | Phase 2 | Pending |'), 'AUTH-03 should remain Pending');
|
|
assert.ok(req.includes('| API-01 | Phase 2 | Pending |'), 'API-01 should remain Pending');
|
|
});
|
|
|
|
test('handles requirements with bracket format [REQ-01, REQ-02]', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap
|
|
|
|
- [ ] Phase 1: Auth
|
|
|
|
### Phase 1: Auth
|
|
**Goal:** User authentication
|
|
**Requirements:** [AUTH-01, AUTH-02]
|
|
**Plans:** 1 plans
|
|
|
|
### Phase 2: API
|
|
**Goal:** Build API
|
|
**Requirements:** [API-01]
|
|
`
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),
|
|
`# Requirements
|
|
|
|
## v1 Requirements
|
|
|
|
### Authentication
|
|
|
|
- [ ] **AUTH-01**: User can sign up with email
|
|
- [ ] **AUTH-02**: User can log in
|
|
- [ ] **AUTH-03**: User can reset password
|
|
|
|
### API
|
|
|
|
- [ ] **API-01**: REST endpoints
|
|
|
|
## Traceability
|
|
|
|
| Requirement | Phase | Status |
|
|
|-------------|-------|--------|
|
|
| AUTH-01 | Phase 1 | Pending |
|
|
| AUTH-02 | Phase 1 | Pending |
|
|
| AUTH-03 | Phase 2 | Pending |
|
|
| API-01 | Phase 2 | Pending |
|
|
`
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
`# State\n\n**Current Phase:** 01\n**Current Phase Name:** Auth\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
|
|
);
|
|
|
|
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
|
|
fs.mkdirSync(p1, { recursive: true });
|
|
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true });
|
|
|
|
const result = runGsdTools('phase complete 1', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8');
|
|
|
|
// Checkboxes updated for phase 1 requirements (brackets stripped)
|
|
assert.ok(req.includes('- [x] **AUTH-01**'), 'AUTH-01 checkbox should be checked');
|
|
assert.ok(req.includes('- [x] **AUTH-02**'), 'AUTH-02 checkbox should be checked');
|
|
// Other requirements unchanged
|
|
assert.ok(req.includes('- [ ] **AUTH-03**'), 'AUTH-03 should remain unchecked');
|
|
assert.ok(req.includes('- [ ] **API-01**'), 'API-01 should remain unchecked');
|
|
|
|
// Traceability table updated
|
|
assert.ok(req.includes('| AUTH-01 | Phase 1 | Complete |'), 'AUTH-01 status should be Complete');
|
|
assert.ok(req.includes('| AUTH-02 | Phase 1 | Complete |'), 'AUTH-02 status should be Complete');
|
|
assert.ok(req.includes('| AUTH-03 | Phase 2 | Pending |'), 'AUTH-03 should remain Pending');
|
|
assert.ok(req.includes('| API-01 | Phase 2 | Pending |'), 'API-01 should remain Pending');
|
|
});
|
|
|
|
test('handles phase with no requirements mapping', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap
|
|
|
|
- [ ] Phase 1: Setup
|
|
|
|
### Phase 1: Setup
|
|
**Goal:** Project setup (no requirements)
|
|
**Plans:** 1 plans
|
|
`
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),
|
|
`# Requirements
|
|
|
|
## v1 Requirements
|
|
|
|
- [ ] **REQ-01**: Some requirement
|
|
|
|
## Traceability
|
|
|
|
| Requirement | Phase | Status |
|
|
|-------------|-------|--------|
|
|
| REQ-01 | Phase 2 | Pending |
|
|
`
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
`# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
|
|
);
|
|
|
|
const p1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
|
fs.mkdirSync(p1, { recursive: true });
|
|
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
|
|
|
const result = runGsdTools('phase complete 1', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
// REQUIREMENTS.md should be unchanged
|
|
const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8');
|
|
assert.ok(req.includes('- [ ] **REQ-01**'), 'REQ-01 should remain unchecked');
|
|
assert.ok(req.includes('| REQ-01 | Phase 2 | Pending |'), 'REQ-01 should remain Pending');
|
|
});
|
|
|
|
test('handles missing REQUIREMENTS.md gracefully', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap
|
|
|
|
- [ ] Phase 1: Foundation
|
|
**Requirements:** REQ-01
|
|
|
|
### Phase 1: Foundation
|
|
**Goal:** Setup
|
|
`
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
`# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
|
|
);
|
|
|
|
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
|
fs.mkdirSync(p1, { recursive: true });
|
|
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
|
|
|
const result = runGsdTools('phase complete 1', tmpDir);
|
|
assert.ok(result.success, `Command should succeed even without REQUIREMENTS.md: ${result.error}`);
|
|
});
|
|
|
|
test('returns requirements_updated field in result', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap
|
|
|
|
- [ ] Phase 1: Auth
|
|
|
|
### Phase 1: Auth
|
|
**Goal:** User authentication
|
|
**Requirements:** AUTH-01
|
|
**Plans:** 1 plans
|
|
`
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),
|
|
`# Requirements
|
|
|
|
## v1 Requirements
|
|
|
|
- [ ] **AUTH-01**: User can sign up
|
|
|
|
## Traceability
|
|
|
|
| Requirement | Phase | Status |
|
|
|-------------|-------|--------|
|
|
| AUTH-01 | Phase 1 | Pending |
|
|
`
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
`# State\n\n**Current Phase:** 01\n**Current Phase Name:** Auth\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
|
|
);
|
|
|
|
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
|
|
fs.mkdirSync(p1, { recursive: true });
|
|
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
|
|
|
const result = runGsdTools('phase complete 1', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
const parsed = JSON.parse(result.output);
|
|
assert.strictEqual(parsed.requirements_updated, true, 'requirements_updated should be true');
|
|
});
|
|
|
|
test('handles In Progress status in traceability table', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap
|
|
|
|
- [ ] Phase 1: Auth
|
|
|
|
### Phase 1: Auth
|
|
**Goal:** User authentication
|
|
**Requirements:** AUTH-01, AUTH-02
|
|
**Plans:** 1 plans
|
|
`
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),
|
|
`# Requirements
|
|
|
|
## v1 Requirements
|
|
|
|
- [ ] **AUTH-01**: User can sign up
|
|
- [ ] **AUTH-02**: User can log in
|
|
|
|
## Traceability
|
|
|
|
| Requirement | Phase | Status |
|
|
|-------------|-------|--------|
|
|
| AUTH-01 | Phase 1 | In Progress |
|
|
| AUTH-02 | Phase 1 | Pending |
|
|
`
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
`# State\n\n**Current Phase:** 01\n**Current Phase Name:** Auth\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
|
|
);
|
|
|
|
const p1 = path.join(tmpDir, '.planning', 'phases', '01-auth');
|
|
fs.mkdirSync(p1, { recursive: true });
|
|
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
|
|
|
const result = runGsdTools('phase complete 1', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8');
|
|
assert.ok(req.includes('| AUTH-01 | Phase 1 | Complete |'), 'In Progress should become Complete');
|
|
assert.ok(req.includes('| AUTH-02 | Phase 1 | Complete |'), 'Pending should become Complete');
|
|
});
|
|
|
|
test('scoped regex does not cross phase boundaries', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap
|
|
|
|
- [ ] Phase 1: Setup
|
|
- [ ] Phase 2: Auth
|
|
|
|
### Phase 1: Setup
|
|
**Goal:** Project setup
|
|
**Plans:** 1 plans
|
|
|
|
### Phase 2: Auth
|
|
**Goal:** User authentication
|
|
**Requirements:** AUTH-01
|
|
**Plans:** 0 plans
|
|
`
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),
|
|
`# Requirements
|
|
|
|
## v1 Requirements
|
|
|
|
- [ ] **AUTH-01**: User can sign up
|
|
|
|
## Traceability
|
|
|
|
| Requirement | Phase | Status |
|
|
|-------------|-------|--------|
|
|
| AUTH-01 | Phase 2 | Pending |
|
|
`
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
`# State\n\n**Current Phase:** 01\n**Current Phase Name:** Setup\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
|
|
);
|
|
|
|
const p1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
|
fs.mkdirSync(p1, { recursive: true });
|
|
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-auth'), { recursive: true });
|
|
|
|
const result = runGsdTools('phase complete 1', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
// Phase 1 has no Requirements field, so Phase 2's AUTH-01 should NOT be updated
|
|
const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8');
|
|
assert.ok(req.includes('- [ ] **AUTH-01**'), 'AUTH-01 should remain unchecked (belongs to Phase 2)');
|
|
assert.ok(req.includes('| AUTH-01 | Phase 2 | Pending |'), 'AUTH-01 should remain Pending (belongs to Phase 2)');
|
|
});
|
|
|
|
test('handles multi-level decimal phase without regex crash', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap
|
|
|
|
- [x] Phase 3: Lorem
|
|
- [x] Phase 3.2: Ipsum
|
|
- [ ] Phase 3.2.1: Dolor Sit
|
|
- [ ] Phase 4: Amet
|
|
|
|
### Phase 3: Lorem
|
|
**Goal:** Setup
|
|
**Plans:** 1/1 plans complete
|
|
**Requirements:** LOR-01
|
|
|
|
### Phase 3.2: Ipsum
|
|
**Goal:** Build
|
|
**Plans:** 1/1 plans complete
|
|
**Requirements:** IPS-01
|
|
|
|
### Phase 03.2.1: Dolor Sit Polish (INSERTED)
|
|
**Goal:** Polish
|
|
**Plans:** 1/1 plans complete
|
|
|
|
### Phase 4: Amet
|
|
**Goal:** Deliver
|
|
**Requirements:** AMT-01: Filter items by category with AND logic (items matching ALL selected categories)
|
|
`
|
|
);
|
|
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'REQUIREMENTS.md'),
|
|
`# Requirements
|
|
|
|
- [ ] **LOR-01**: Lorem database schema
|
|
- [ ] **IPS-01**: Ipsum rendering engine
|
|
- [ ] **AMT-01**: Filter items by category
|
|
`
|
|
);
|
|
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
`# State
|
|
|
|
**Current Phase:** 03.2.1
|
|
**Current Phase Name:** Dolor Sit Polish
|
|
**Status:** Execution complete
|
|
**Current Plan:** 03.2.1-01
|
|
**Last Activity:** 2025-01-01
|
|
**Last Activity Description:** Working
|
|
`
|
|
);
|
|
|
|
const p32 = path.join(tmpDir, '.planning', 'phases', '03.2-ipsum');
|
|
const p321 = path.join(tmpDir, '.planning', 'phases', '03.2.1-dolor-sit');
|
|
const p4 = path.join(tmpDir, '.planning', 'phases', '04-amet');
|
|
fs.mkdirSync(p32, { recursive: true });
|
|
fs.mkdirSync(p321, { recursive: true });
|
|
fs.mkdirSync(p4, { recursive: true });
|
|
fs.writeFileSync(path.join(p321, '03.2.1-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p321, '03.2.1-01-SUMMARY.md'), '# Summary');
|
|
|
|
const result = runGsdTools('phase complete 03.2.1', tmpDir);
|
|
assert.ok(result.success, `Command should not crash on regex metacharacters: ${result.error}`);
|
|
|
|
const req = fs.readFileSync(path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), 'utf-8');
|
|
assert.ok(req.includes('- [ ] **AMT-01**'), 'AMT-01 should remain unchanged');
|
|
});
|
|
|
|
test('preserves Milestone column in 5-column progress table', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap
|
|
|
|
- [ ] Phase 1: Foundation
|
|
|
|
### Phase 1: Foundation
|
|
**Goal:** Setup
|
|
**Plans:** 1 plans
|
|
|
|
## Progress
|
|
|
|
| Phase | Milestone | Plans Complete | Status | Completed |
|
|
|-------|-----------|----------------|--------|-----------|
|
|
| 1. Foundation | v1.0 | 0/1 | Planned | |
|
|
`
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
`# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
|
|
);
|
|
|
|
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
|
fs.mkdirSync(p1, { recursive: true });
|
|
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
|
|
|
const result = runGsdTools('phase complete 1', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
const rowMatch = roadmap.match(/^\|[^\n]*1\. Foundation[^\n]*$/m);
|
|
assert.ok(rowMatch, 'table row should exist');
|
|
const cells = rowMatch[0].split('|').slice(1, -1).map(c => c.trim());
|
|
assert.strictEqual(cells.length, 5, 'should have 5 columns');
|
|
assert.strictEqual(cells[1], 'v1.0', 'Milestone column should be preserved');
|
|
assert.ok(cells[3].includes('Complete'), 'Status column should be Complete');
|
|
});
|
|
|
|
test('updates STATE.md with plain format fields (no bold)', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap\n\n### Phase 1: Only\n**Goal:** Test\n`
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
`# State\n\nPhase: 1 of 1 (Only)\nStatus: In progress\nPlan: 01-01\nLast Activity: 2025-01-01\nLast Activity Description: Working\n`
|
|
);
|
|
|
|
const p1 = path.join(tmpDir, '.planning', 'phases', '01-only');
|
|
fs.mkdirSync(p1, { recursive: true });
|
|
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
|
|
|
const result = runGsdTools('phase complete 1', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const state = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
|
assert.ok(state.includes('Milestone complete'), 'plain Status field should be updated');
|
|
assert.ok(state.includes('Not started'), 'plain Plan field should be updated');
|
|
// Verify compound format preserved
|
|
assert.ok(state.match(/Phase:.*of\s+1/), 'should preserve "of N" in compound Phase format');
|
|
});
|
|
|
|
test('updates Plans Complete column in 4-column progress table', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap
|
|
|
|
- [ ] Phase 1: Foundation
|
|
- [ ] Phase 2: API
|
|
|
|
### Phase 1: Foundation
|
|
**Goal:** Setup
|
|
**Plans:** 1 plans
|
|
|
|
### Phase 2: API
|
|
**Goal:** Build API
|
|
|
|
## Progress
|
|
|
|
| Phase | Plans Complete | Status | Completed |
|
|
|-------|----------------|--------|-----------|
|
|
| 1. Foundation | 0/1 | Not started | - |
|
|
| 2. API | 0/1 | Not started | - |
|
|
`
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
`# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
|
|
);
|
|
|
|
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
|
fs.mkdirSync(p1, { recursive: true });
|
|
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true });
|
|
|
|
const result = runGsdTools('phase complete 1', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
const rowMatch = roadmap.match(/^\|[^\n]*1\. Foundation[^\n]*$/m);
|
|
assert.ok(rowMatch, 'table row should exist');
|
|
const cells = rowMatch[0].split('|').slice(1, -1).map(c => c.trim());
|
|
assert.strictEqual(cells.length, 4, 'should have 4 columns');
|
|
assert.strictEqual(cells[1], '1/1', 'Plans Complete column should be updated to 1/1');
|
|
assert.ok(cells[2].includes('Complete'), 'Status column should be Complete');
|
|
});
|
|
|
|
test('updates Plans Complete column in 5-column progress table', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap
|
|
|
|
- [ ] Phase 1: Foundation
|
|
|
|
### Phase 1: Foundation
|
|
**Goal:** Setup
|
|
**Plans:** 1 plans
|
|
|
|
## Progress
|
|
|
|
| Phase | Milestone | Plans Complete | Status | Completed |
|
|
|-------|-----------|----------------|--------|-----------|
|
|
| 1. Foundation | v1.0 | 0/1 | Planned | |
|
|
`
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
`# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
|
|
);
|
|
|
|
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
|
fs.mkdirSync(p1, { recursive: true });
|
|
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
|
|
|
const result = runGsdTools('phase complete 1', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
const rowMatch = roadmap.match(/^\|[^\n]*1\. Foundation[^\n]*$/m);
|
|
assert.ok(rowMatch, 'table row should exist');
|
|
const cells = rowMatch[0].split('|').slice(1, -1).map(c => c.trim());
|
|
assert.strictEqual(cells.length, 5, 'should have 5 columns');
|
|
assert.strictEqual(cells[2], '1/1', 'Plans Complete column should be updated to 1/1');
|
|
assert.ok(cells[3].includes('Complete'), 'Status column should be Complete');
|
|
});
|
|
|
|
test('marks plan-level checkboxes on phase complete', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap
|
|
|
|
- [ ] Phase 1: Foundation
|
|
|
|
### Phase 1: Foundation
|
|
**Goal:** Setup
|
|
**Plans:** 2 plans
|
|
|
|
Plans:
|
|
- [ ] 01-01-PLAN.md \u2014 Schema migration
|
|
- [ ] 01-02-PLAN.md \u2014 Auth setup
|
|
`
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
`# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-02\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
|
|
);
|
|
|
|
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
|
fs.mkdirSync(p1, { recursive: true });
|
|
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
|
fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p1, '01-02-SUMMARY.md'), '# Summary');
|
|
|
|
const result = runGsdTools('phase complete 1', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
assert.ok(roadmap.includes('[x] 01-01-PLAN.md'), 'plan 01-01 checkbox should be checked');
|
|
assert.ok(roadmap.includes('[x] 01-02-PLAN.md'), 'plan 01-02 checkbox should be checked');
|
|
assert.ok(!roadmap.includes('[ ] 01-01-PLAN.md'), 'plan 01-01 should not remain unchecked');
|
|
assert.ok(!roadmap.includes('[ ] 01-02-PLAN.md'), 'plan 01-02 should not remain unchecked');
|
|
});
|
|
|
|
test('marks bold-wrapped plan-level checkboxes on phase complete', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap
|
|
|
|
- [ ] Phase 1: Foundation
|
|
|
|
### Phase 1: Foundation
|
|
**Goal:** Setup
|
|
**Plans:** 2 plans
|
|
|
|
Plans:
|
|
- [ ] **01-01**: Schema migration
|
|
- [ ] **01-02**: Auth setup
|
|
`
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
`# State\n\n**Current Phase:** 01\n**Status:** In progress\n**Current Plan:** 01-02\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
|
|
);
|
|
|
|
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
|
fs.mkdirSync(p1, { recursive: true });
|
|
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
|
fs.writeFileSync(path.join(p1, '01-02-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p1, '01-02-SUMMARY.md'), '# Summary');
|
|
|
|
const result = runGsdTools('phase complete 1', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
|
assert.ok(roadmap.includes('[x] **01-01**'), 'bold plan 01-01 checkbox should be checked');
|
|
assert.ok(roadmap.includes('[x] **01-02**'), 'bold plan 01-02 checkbox should be checked');
|
|
assert.ok(!roadmap.includes('[ ] **01-01**'), 'bold plan 01-01 should not remain unchecked');
|
|
assert.ok(!roadmap.includes('[ ] **01-02**'), 'bold plan 01-02 should not remain unchecked');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// comparePhaseNum and normalizePhaseName (imported directly)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
const { comparePhaseNum, normalizePhaseName } = require('../gsd-core/bin/lib/core.cjs');
|
|
|
|
describe('comparePhaseNum', () => {
|
|
test('sorts integer phases numerically', () => {
|
|
assert.ok(comparePhaseNum('2', '10') < 0);
|
|
assert.ok(comparePhaseNum('10', '2') > 0);
|
|
assert.strictEqual(comparePhaseNum('5', '5'), 0);
|
|
});
|
|
|
|
test('sorts decimal phases correctly', () => {
|
|
assert.ok(comparePhaseNum('12', '12.1') < 0);
|
|
assert.ok(comparePhaseNum('12.1', '12.2') < 0);
|
|
assert.ok(comparePhaseNum('12.2', '13') < 0);
|
|
});
|
|
|
|
test('sorts letter-suffix phases correctly', () => {
|
|
assert.ok(comparePhaseNum('12', '12A') < 0);
|
|
assert.ok(comparePhaseNum('12A', '12B') < 0);
|
|
assert.ok(comparePhaseNum('12B', '13') < 0);
|
|
});
|
|
|
|
test('sorts hybrid phases correctly', () => {
|
|
assert.ok(comparePhaseNum('12A', '12A.1') < 0);
|
|
assert.ok(comparePhaseNum('12A.1', '12A.2') < 0);
|
|
assert.ok(comparePhaseNum('12A.2', '12B') < 0);
|
|
});
|
|
|
|
test('handles full sort order', () => {
|
|
const phases = ['13', '12B', '12A.2', '12', '12.1', '12A', '12A.1', '12.2'];
|
|
phases.sort(comparePhaseNum);
|
|
assert.deepStrictEqual(phases, ['12', '12.1', '12.2', '12A', '12A.1', '12A.2', '12B', '13']);
|
|
});
|
|
|
|
test('handles directory names with slugs', () => {
|
|
const dirs = ['13-deploy', '12B-hotfix', '12A.1-bugfix', '12-foundation', '12.1-inserted', '12A-split'];
|
|
dirs.sort(comparePhaseNum);
|
|
assert.deepStrictEqual(dirs, [
|
|
'12-foundation', '12.1-inserted', '12A-split', '12A.1-bugfix', '12B-hotfix', '13-deploy'
|
|
]);
|
|
});
|
|
|
|
test('case insensitive letter matching', () => {
|
|
assert.ok(comparePhaseNum('12a', '12B') < 0);
|
|
assert.ok(comparePhaseNum('12A', '12b') < 0);
|
|
assert.strictEqual(comparePhaseNum('12a', '12A'), 0);
|
|
});
|
|
|
|
test('sorts multi-level decimal phases correctly', () => {
|
|
assert.ok(comparePhaseNum('3.2', '3.2.1') < 0);
|
|
assert.ok(comparePhaseNum('3.2.1', '3.2.2') < 0);
|
|
assert.ok(comparePhaseNum('3.2.1', '3.3') < 0);
|
|
assert.ok(comparePhaseNum('3.2.1', '4') < 0);
|
|
assert.strictEqual(comparePhaseNum('3.2.1', '3.2.1'), 0);
|
|
});
|
|
|
|
test('falls back to localeCompare for non-phase strings', () => {
|
|
const result = comparePhaseNum('abc', 'def');
|
|
assert.strictEqual(typeof result, 'number');
|
|
});
|
|
});
|
|
|
|
describe('normalizePhaseName', () => {
|
|
test('pads single-digit integers', () => {
|
|
assert.strictEqual(normalizePhaseName('3'), '03');
|
|
assert.strictEqual(normalizePhaseName('12'), '12');
|
|
});
|
|
|
|
test('handles decimal phases', () => {
|
|
assert.strictEqual(normalizePhaseName('3.1'), '03.1');
|
|
assert.strictEqual(normalizePhaseName('12.2'), '12.2');
|
|
});
|
|
|
|
test('handles letter-suffix phases', () => {
|
|
assert.strictEqual(normalizePhaseName('3A'), '03A');
|
|
assert.strictEqual(normalizePhaseName('12B'), '12B');
|
|
});
|
|
|
|
test('handles hybrid phases', () => {
|
|
assert.strictEqual(normalizePhaseName('3A.1'), '03A.1');
|
|
assert.strictEqual(normalizePhaseName('12A.2'), '12A.2');
|
|
});
|
|
|
|
test('preserves letter case', () => {
|
|
assert.strictEqual(normalizePhaseName('3a'), '03a');
|
|
assert.strictEqual(normalizePhaseName('12b.1'), '12b.1');
|
|
});
|
|
|
|
test('handles multi-level decimal phases', () => {
|
|
assert.strictEqual(normalizePhaseName('3.2.1'), '03.2.1');
|
|
assert.strictEqual(normalizePhaseName('12.3.4'), '12.3.4');
|
|
});
|
|
|
|
test('returns non-matching input unchanged', () => {
|
|
assert.strictEqual(normalizePhaseName('abc'), 'abc');
|
|
});
|
|
});
|
|
|
|
describe('letter-suffix phase sorting', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('lists letter-suffix phases in correct order', () => {
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '12-foundation'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '12.1-inserted'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '12A-split'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '12A.1-bugfix'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '12B-hotfix'), { recursive: true });
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '13-deploy'), { recursive: true });
|
|
|
|
const result = runGsdTools('phases list', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.deepStrictEqual(
|
|
output.directories,
|
|
['12-foundation', '12.1-inserted', '12A-split', '12A.1-bugfix', '12B-hotfix', '13-deploy'],
|
|
'letter-suffix phases should sort correctly'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// milestone-scoped next-phase in phase complete
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('phase complete milestone-scoped next-phase', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('finds next phase within milestone, ignoring prior milestone dirs', () => {
|
|
// ROADMAP lists phases 5-6 (current milestone v2.0)
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
[
|
|
'## Roadmap v2.0: Release',
|
|
'',
|
|
'- [ ] Phase 5: Auth',
|
|
'- [ ] Phase 6: Dashboard',
|
|
'',
|
|
'### Phase 5: Auth',
|
|
'**Goal:** Add authentication',
|
|
'**Plans:** 1 plans',
|
|
'',
|
|
'### Phase 6: Dashboard',
|
|
'**Goal:** Build dashboard',
|
|
].join('\n')
|
|
);
|
|
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
'# State\n\n**Current Phase:** 05\n**Current Phase Name:** Auth\n**Status:** In progress\n**Current Plan:** 05-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n'
|
|
);
|
|
|
|
// Disk has dirs 01-06 (01-04 completed from prior milestone)
|
|
for (let i = 1; i <= 4; i++) {
|
|
const padded = String(i).padStart(2, '0');
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', `${padded}-old-phase`);
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
fs.writeFileSync(path.join(phaseDir, `${padded}-01-PLAN.md`), '# Plan');
|
|
fs.writeFileSync(path.join(phaseDir, `${padded}-01-SUMMARY.md`), '# Summary');
|
|
}
|
|
|
|
// Phase 5 — completing this one
|
|
const p5 = path.join(tmpDir, '.planning', 'phases', '05-auth');
|
|
fs.mkdirSync(p5, { recursive: true });
|
|
fs.writeFileSync(path.join(p5, '05-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p5, '05-01-SUMMARY.md'), '# Summary');
|
|
|
|
// Phase 6 — next phase in milestone
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '06-dashboard'), { recursive: true });
|
|
|
|
const result = runGsdTools('phase complete 5', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.is_last_phase, false, 'should NOT be last phase — phase 6 is in milestone');
|
|
assert.strictEqual(output.next_phase, '06', 'next phase should be 06');
|
|
});
|
|
|
|
test('detects last phase when only milestone phases are considered', () => {
|
|
// ROADMAP lists only phase 5 (current milestone)
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
[
|
|
'## Roadmap v2.0: Release',
|
|
'',
|
|
'### Phase 5: Auth',
|
|
'**Goal:** Add authentication',
|
|
'**Plans:** 1 plans',
|
|
].join('\n')
|
|
);
|
|
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
'# State\n\n**Current Phase:** 05\n**Current Phase Name:** Auth\n**Status:** In progress\n**Current Plan:** 05-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n'
|
|
);
|
|
|
|
// Disk has dirs 01-06 but only 5 is in ROADMAP
|
|
for (let i = 1; i <= 6; i++) {
|
|
const padded = String(i).padStart(2, '0');
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', `${padded}-phase-${i}`);
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
fs.writeFileSync(path.join(phaseDir, `${padded}-01-PLAN.md`), '# Plan');
|
|
fs.writeFileSync(path.join(phaseDir, `${padded}-01-SUMMARY.md`), '# Summary');
|
|
}
|
|
|
|
const result = runGsdTools('phase complete 5', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
// Without the fix, dirs 06 on disk would make is_last_phase=false
|
|
// With the fix, only phase 5 is in milestone, so it IS the last phase
|
|
assert.strictEqual(output.is_last_phase, true, 'should be last phase — only phase 5 is in milestone');
|
|
assert.strictEqual(output.next_phase, null, 'no next phase in milestone');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// exact token matching (no prefix collisions)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('phase resolution uses exact token matching', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('1009 must NOT match 1009A-feature-consistency when 1009 dir is absent', () => {
|
|
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
|
fs.mkdirSync(path.join(phasesDir, '1009A-feature-consistency'));
|
|
fs.writeFileSync(path.join(phasesDir, '1009A-feature-consistency', 'PLAN.md'), '# Plan');
|
|
|
|
const result = runGsdTools('find-phase 1009', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.found, false, 'should NOT find phase 1009 when only 1009A exists');
|
|
});
|
|
|
|
test('1009 matches 1009-pipeline-accuracy-fix when both exist', () => {
|
|
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
|
fs.mkdirSync(path.join(phasesDir, '1009-pipeline-accuracy-fix'));
|
|
fs.mkdirSync(path.join(phasesDir, '1009A-feature-consistency'));
|
|
fs.writeFileSync(path.join(phasesDir, '1009-pipeline-accuracy-fix', 'PLAN.md'), '# Plan');
|
|
|
|
const result = runGsdTools('find-phase 1009', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.found, true, 'should find phase 1009');
|
|
assert.ok(
|
|
output.directory.includes('1009-pipeline-accuracy-fix'),
|
|
`should match 1009-pipeline-accuracy-fix, got: ${output.directory}`
|
|
);
|
|
});
|
|
|
|
test('999.6 must NOT match 999.60-episode-processing when 999.6 dir is absent', () => {
|
|
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
|
fs.mkdirSync(path.join(phasesDir, '999.60-episode-processing'));
|
|
fs.writeFileSync(path.join(phasesDir, '999.60-episode-processing', 'PLAN.md'), '# Plan');
|
|
|
|
const result = runGsdTools('find-phase 999.6', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.found, false, 'should NOT find phase 999.6 when only 999.60 exists');
|
|
});
|
|
|
|
test('999.6 matches 999.6-ground-truth-dataset when both exist', () => {
|
|
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
|
fs.mkdirSync(path.join(phasesDir, '999.6-ground-truth-dataset'));
|
|
fs.mkdirSync(path.join(phasesDir, '999.60-episode-processing'));
|
|
fs.writeFileSync(path.join(phasesDir, '999.6-ground-truth-dataset', 'PLAN.md'), '# Plan');
|
|
|
|
const result = runGsdTools('find-phase 999.6', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.found, true, 'should find phase 999.6');
|
|
assert.ok(
|
|
output.directory.includes('999.6-ground-truth-dataset'),
|
|
`should match 999.6-ground-truth-dataset, got: ${output.directory}`
|
|
);
|
|
});
|
|
|
|
test('normal non-colliding phases still resolve', () => {
|
|
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
|
fs.mkdirSync(path.join(phasesDir, '01-foundation'));
|
|
fs.mkdirSync(path.join(phasesDir, '02-implementation'));
|
|
fs.writeFileSync(path.join(phasesDir, '01-foundation', 'PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(phasesDir, '02-implementation', 'PLAN.md'), '# Plan');
|
|
|
|
const r1 = runGsdTools('find-phase 1', tmpDir);
|
|
assert.ok(r1.success, `Command failed for phase 1: ${r1.error}`);
|
|
const o1 = JSON.parse(r1.output);
|
|
assert.strictEqual(o1.found, true, 'should find phase 1');
|
|
assert.ok(o1.directory.includes('01-foundation'), `should match 01-foundation, got: ${o1.directory}`);
|
|
|
|
const r2 = runGsdTools('find-phase 2', tmpDir);
|
|
assert.ok(r2.success, `Command failed for phase 2: ${r2.error}`);
|
|
const o2 = JSON.parse(r2.output);
|
|
assert.strictEqual(o2.found, true, 'should find phase 2');
|
|
assert.ok(o2.directory.includes('02-implementation'), `should match 02-implementation, got: ${o2.directory}`);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// phase complete — Performance Metrics gate (Step 2 — Gate 4)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('phase complete updates Performance Metrics', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('after cmdPhaseComplete: Performance Metrics has updated total plans count', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
`# Project State\n\n**Current Phase:** 2\n**Status:** Executing Phase 2\n**Total Plans in Phase:** 3\n**Current Plan:** 3\n**Completed Phases:** 0\n**Total Phases:** 3\n**Progress:** 0%\n\n## Performance Metrics\n\n**Velocity:**\n- Total plans completed: 0\n- Average duration: N/A\n- Total execution time: 0 hours\n\n**By Phase:**\n\n| Phase | Plans | Total | Avg/Plan |\n|-------|-------|-------|----------|\n\n## Accumulated Context\n`
|
|
);
|
|
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '02-core');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
fs.writeFileSync(path.join(phaseDir, '02-01-PLAN.md'), '# Plan 1\n');
|
|
fs.writeFileSync(path.join(phaseDir, '02-02-PLAN.md'), '# Plan 2\n');
|
|
fs.writeFileSync(path.join(phaseDir, '02-03-PLAN.md'), '# Plan 3\n');
|
|
fs.writeFileSync(path.join(phaseDir, '02-01-SUMMARY.md'), '# Summary\n');
|
|
fs.writeFileSync(path.join(phaseDir, '02-02-SUMMARY.md'), '# Summary\n');
|
|
fs.writeFileSync(path.join(phaseDir, '02-03-SUMMARY.md'), '# Summary\n');
|
|
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap\n\n## Phase 2: Core\n\n- [ ] Phase 2: Core Systems\n`
|
|
);
|
|
|
|
const result = runGsdTools('phase complete 2', tmpDir);
|
|
assert.ok(result.success, `phase complete failed: ${result.error}`);
|
|
|
|
const stateAfter = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
|
assert.ok(stateAfter.match(/Total plans completed:\s*3/), 'Total plans completed should be 3');
|
|
});
|
|
|
|
test('after cmdPhaseComplete: By Phase table has row for completed phase', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
`# Project State\n\n**Current Phase:** 1\n**Status:** Executing Phase 1\n**Total Plans in Phase:** 2\n**Current Plan:** 2\n**Completed Phases:** 0\n**Total Phases:** 2\n**Progress:** 0%\n\n## Performance Metrics\n\n**Velocity:**\n- Total plans completed: 0\n- Average duration: N/A\n- Total execution time: 0 hours\n\n**By Phase:**\n\n| Phase | Plans | Total | Avg/Plan |\n|-------|-------|-------|----------|\n\n## Accumulated Context\n`
|
|
);
|
|
|
|
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan\n');
|
|
fs.writeFileSync(path.join(phaseDir, '01-02-PLAN.md'), '# Plan\n');
|
|
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary\n');
|
|
fs.writeFileSync(path.join(phaseDir, '01-02-SUMMARY.md'), '# Summary\n');
|
|
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
`# Roadmap\n\n## Phase 1: Setup\n\n- [ ] Phase 1: Setup\n`
|
|
);
|
|
|
|
const result = runGsdTools('phase complete 1', tmpDir);
|
|
assert.ok(result.success, `phase complete failed: ${result.error}`);
|
|
|
|
const stateAfter = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
|
assert.ok(stateAfter.match(/\|\s*1\s*\|\s*2\s*\|/), 'By Phase table should have row for phase 1 with 2 plans');
|
|
// Row must appear BEFORE the next section, not after it (regression: empty table body regex)
|
|
const rowIdx = stateAfter.indexOf('| 1 |');
|
|
const accIdx = stateAfter.indexOf('## Accumulated Context');
|
|
if (accIdx !== -1) {
|
|
assert.ok(rowIdx < accIdx, 'By Phase row must appear before ## Accumulated Context section');
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// phase complete — backlog phase (999.x) exclusion (#2129)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('phase complete excludes 999.x backlog from next-phase (#2129)', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('next phase skips 999.x backlog dirs and falls back to roadmap', () => {
|
|
// ROADMAP defines phases 1, 2, 3 and a backlog 999.1
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
[
|
|
'# Roadmap',
|
|
'',
|
|
'- [ ] Phase 1: Setup',
|
|
'- [ ] Phase 2: Core',
|
|
'- [ ] Phase 3: Polish',
|
|
'- [ ] Phase 999.1: Backlog idea',
|
|
'',
|
|
'### Phase 1: Setup',
|
|
'**Goal:** Initial setup',
|
|
'',
|
|
'### Phase 2: Core',
|
|
'**Goal:** Build core',
|
|
'',
|
|
'### Phase 3: Polish',
|
|
'**Goal:** Polish everything',
|
|
'',
|
|
'### Phase 999.1: Backlog idea',
|
|
'**Goal:** Parked idea',
|
|
].join('\n')
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
[
|
|
'# State',
|
|
'',
|
|
'**Current Phase:** 02',
|
|
'**Status:** In progress',
|
|
'**Current Plan:** 02-01',
|
|
'**Last Activity:** 2025-01-01',
|
|
'**Last Activity Description:** Working',
|
|
].join('\n')
|
|
);
|
|
|
|
// Phase 1 and 2 exist on disk, phase 3 does NOT exist yet, 999.1 DOES exist
|
|
const p1 = path.join(tmpDir, '.planning', 'phases', '01-setup');
|
|
fs.mkdirSync(p1, { recursive: true });
|
|
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
|
|
|
const p2 = path.join(tmpDir, '.planning', 'phases', '02-core');
|
|
fs.mkdirSync(p2, { recursive: true });
|
|
fs.writeFileSync(path.join(p2, '02-01-PLAN.md'), '# Plan');
|
|
fs.writeFileSync(path.join(p2, '02-01-SUMMARY.md'), '# Summary');
|
|
|
|
// Backlog stub on disk — this is what triggers the bug
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '999.1-backlog-idea'), { recursive: true });
|
|
|
|
const result = runGsdTools('phase complete 2', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
// Should find phase 3 from roadmap, NOT 999.1 from filesystem
|
|
assert.strictEqual(output.next_phase, '3', 'next_phase should be 3, not 999.1');
|
|
assert.strictEqual(output.is_last_phase, false, 'should not be last phase');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// regression: bug #1962 — normalizePhaseName preserves letter suffix case
|
|
// (consolidated from tests/bug-1962-phase-suffix-case.test.cjs)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('bug #1962: normalizePhaseName preserves letter suffix case', () => {
|
|
test('lowercase suffix preserved: 16c → 16c', () => {
|
|
assert.equal(normalizePhaseName('16c'), '16c');
|
|
});
|
|
|
|
test('uppercase suffix preserved: 16C → 16C', () => {
|
|
assert.equal(normalizePhaseName('16C'), '16C');
|
|
});
|
|
|
|
test('single digit padded with lowercase suffix: 1a → 01a', () => {
|
|
assert.equal(normalizePhaseName('1a'), '01a');
|
|
});
|
|
|
|
test('single digit padded with uppercase suffix: 1A → 01A', () => {
|
|
assert.equal(normalizePhaseName('1A'), '01A');
|
|
});
|
|
|
|
test('no suffix unchanged: 16 → 16', () => {
|
|
assert.equal(normalizePhaseName('16'), '16');
|
|
});
|
|
|
|
test('decimal suffix preserved: 16.1 → 16.1', () => {
|
|
assert.equal(normalizePhaseName('16.1'), '16.1');
|
|
});
|
|
|
|
test('letter + decimal preserved: 16c.2 → 16c.2', () => {
|
|
assert.equal(normalizePhaseName('16c.2'), '16c.2');
|
|
});
|
|
|
|
test('project code prefix stripped, suffix case preserved: CK-01a → 01a', () => {
|
|
assert.equal(normalizePhaseName('CK-01a'), '01a');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// regression: bug #1998 — phase complete updates overview checkbox
|
|
// (consolidated from tests/bug-1998-phase-complete-checkbox.test.cjs)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('bug #1998: phase complete updates overview checkbox', () => {
|
|
let tmpDir;
|
|
let planningDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1998-'));
|
|
planningDir = path.join(tmpDir, '.planning');
|
|
fs.mkdirSync(planningDir, { recursive: true });
|
|
|
|
// Minimal config
|
|
fs.writeFileSync(
|
|
path.join(planningDir, 'config.json'),
|
|
JSON.stringify({ project_code: 'TEST' })
|
|
);
|
|
|
|
// Minimal STATE.md
|
|
fs.writeFileSync(
|
|
path.join(planningDir, 'STATE.md'),
|
|
'---\ncurrent_phase: 1\nstatus: executing\n---\n# State\n'
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('checkbox updated when no archived milestones exist', () => {
|
|
const phasesDir = path.join(planningDir, 'phases', '01-foundation');
|
|
fs.mkdirSync(phasesDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(phasesDir, '01-1-SUMMARY.md'),
|
|
'---\nstatus: complete\n---\n# Summary\nDone.'
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(phasesDir, '01-1-PLAN.md'),
|
|
'---\nphase: 1\nplan: 1\n---\n# Plan 1\n'
|
|
);
|
|
|
|
const roadmapPath = path.join(planningDir, 'ROADMAP.md');
|
|
fs.writeFileSync(roadmapPath, [
|
|
'# Roadmap',
|
|
'',
|
|
'## Phases',
|
|
'',
|
|
'- [ ] **Phase 1: Foundation** - core setup',
|
|
'- [ ] **Phase 2: Features** - add features',
|
|
'',
|
|
'## Progress',
|
|
'',
|
|
'| Phase | Plans | Status | Completed |',
|
|
'|-------|-------|--------|-----------|',
|
|
'| 1. Foundation | 0/1 | Pending | - |',
|
|
'| 2. Features | 0/1 | Pending | - |',
|
|
].join('\n'));
|
|
|
|
try {
|
|
execFileSync('node', [GSD_TOOLS_BIN, 'phase', 'complete', '1'], { cwd: tmpDir, timeout: 10000 });
|
|
} catch {
|
|
// Command may exit non-zero if STATE.md update fails, but ROADMAP.md update happens first
|
|
}
|
|
|
|
const result = fs.readFileSync(roadmapPath, 'utf-8');
|
|
assert.match(result, /- \[x\] \*\*Phase 1: Foundation\*\*/, 'overview checkbox should be checked');
|
|
assert.match(result, /- \[ \] \*\*Phase 2: Features\*\*/, 'phase 2 checkbox should remain unchecked');
|
|
});
|
|
|
|
test('checkbox updated when archived milestones exist in <details>', () => {
|
|
const phasesDir = path.join(planningDir, 'phases', '01-setup');
|
|
fs.mkdirSync(phasesDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(phasesDir, '01-1-SUMMARY.md'),
|
|
'---\nstatus: complete\n---\n# Summary\nDone.'
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(phasesDir, '01-1-PLAN.md'),
|
|
'---\nphase: 1\nplan: 1\n---\n# Plan 1\n'
|
|
);
|
|
|
|
const roadmapPath = path.join(planningDir, 'ROADMAP.md');
|
|
fs.writeFileSync(roadmapPath, [
|
|
'# Roadmap v2.0',
|
|
'',
|
|
'## Phases',
|
|
'',
|
|
'- [ ] **Phase 1: Setup** - initial setup',
|
|
'- [ ] **Phase 2: Build** - build features',
|
|
'',
|
|
'## Progress',
|
|
'',
|
|
'| Phase | Plans | Status | Completed |',
|
|
'|-------|-------|--------|-----------|',
|
|
'| 1. Setup | 0/1 | Pending | - |',
|
|
'| 2. Build | 0/1 | Pending | - |',
|
|
'',
|
|
'<details>',
|
|
'<summary>v1.0 (Archived)</summary>',
|
|
'',
|
|
'## v1.0 Phases',
|
|
'- [x] **Phase 1: Init** - initialization',
|
|
'- [x] **Phase 2: Deploy** - deployment',
|
|
'',
|
|
'</details>',
|
|
].join('\n'));
|
|
|
|
try {
|
|
execFileSync('node', [GSD_TOOLS_BIN, 'phase', 'complete', '1'], { cwd: tmpDir, timeout: 10000 });
|
|
} catch {
|
|
// May exit non-zero
|
|
}
|
|
|
|
const result = fs.readFileSync(roadmapPath, 'utf-8');
|
|
assert.match(result, /- \[x\] \*\*Phase 1: Setup\*\*/, 'current milestone checkbox should be checked');
|
|
assert.match(result, /- \[ \] \*\*Phase 2: Build\*\*/, 'phase 2 checkbox should remain unchecked');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// regression: bug #2005 — phase complete inside <details> updates plan count
|
|
// (consolidated from tests/bug-2005-phase-complete-details.test.cjs)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('bug #2005: phase complete updates plan count when milestone is inside <details>', () => {
|
|
let tmpDir;
|
|
let planningDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2005-'));
|
|
planningDir = path.join(tmpDir, '.planning');
|
|
fs.mkdirSync(planningDir, { recursive: true });
|
|
|
|
fs.writeFileSync(
|
|
path.join(planningDir, 'config.json'),
|
|
JSON.stringify({ project_code: 'TEST' })
|
|
);
|
|
|
|
fs.writeFileSync(
|
|
path.join(planningDir, 'STATE.md'),
|
|
'---\ncurrent_phase: 1\nstatus: executing\nmilestone: v2.0\n---\n# State\n'
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('plan count is updated when current milestone is wrapped in <details>', () => {
|
|
const phasesDir = path.join(planningDir, 'phases', '01-setup');
|
|
fs.mkdirSync(phasesDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(phasesDir, '01-1-SUMMARY.md'),
|
|
'---\nstatus: complete\n---\n# Summary\nDone.\n'
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(phasesDir, '01-1-PLAN.md'),
|
|
'---\nphase: 1\nplan: 1\n---\n# Plan 1\n'
|
|
);
|
|
|
|
const roadmapPath = path.join(planningDir, 'ROADMAP.md');
|
|
fs.writeFileSync(roadmapPath, [
|
|
'# Roadmap',
|
|
'',
|
|
'<details>',
|
|
'<summary>v1.0 (shipped)</summary>',
|
|
'',
|
|
'## v1.0 Phases',
|
|
'- [x] **Phase 0: Bootstrap** - shipped',
|
|
'',
|
|
'</details>',
|
|
'',
|
|
'<details open>',
|
|
'<summary>v2.0 (in progress)</summary>',
|
|
'',
|
|
'## v2.0 Phases',
|
|
'',
|
|
'- [ ] **Phase 1: Setup** - initial setup',
|
|
'- [ ] **Phase 2: Build** - build features',
|
|
'',
|
|
'## Progress',
|
|
'',
|
|
'| Phase | Plans | Status | Completed |',
|
|
'|-------|-------|--------|-----------|',
|
|
'| 1. Setup | 0/1 | Pending | - |',
|
|
'| 2. Build | 0/1 | Pending | - |',
|
|
'',
|
|
'### Phase 1: Setup',
|
|
'',
|
|
'**Plans:** 0/1 plans complete',
|
|
'',
|
|
'### Phase 2: Build',
|
|
'',
|
|
'**Plans:** 0/1 plans complete',
|
|
'',
|
|
'</details>',
|
|
].join('\n'));
|
|
|
|
try {
|
|
execFileSync('node', [GSD_TOOLS_BIN, 'phase', 'complete', '1'], { cwd: tmpDir, timeout: 10000 });
|
|
} catch {
|
|
// May exit non-zero if STATE.md update fails, but ROADMAP.md update is the target
|
|
}
|
|
|
|
const result = fs.readFileSync(roadmapPath, 'utf-8');
|
|
|
|
assert.match(
|
|
result,
|
|
/\*\*Plans:\*\*\s*1\/1 plans complete/,
|
|
'plan count in Phase 1 section must be updated to 1/1 plans complete'
|
|
);
|
|
assert.match(
|
|
result,
|
|
/- \[x\] \*\*Phase 1: Setup\*\*/,
|
|
'Phase 1 checkbox must be checked after completion'
|
|
);
|
|
assert.match(
|
|
result,
|
|
/- \[ \] \*\*Phase 2: Build\*\*/,
|
|
'Phase 2 checkbox must remain unchecked'
|
|
);
|
|
});
|
|
|
|
test('phase complete with all milestones in <details> does not corrupt phase 2 plan count', () => {
|
|
const phasesDir = path.join(planningDir, 'phases', '01-setup');
|
|
fs.mkdirSync(phasesDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(phasesDir, '01-1-SUMMARY.md'),
|
|
'---\nstatus: complete\n---\n# Summary\nDone.\n'
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(phasesDir, '01-1-PLAN.md'),
|
|
'---\nphase: 1\nplan: 1\n---\n# Plan 1\n'
|
|
);
|
|
|
|
const roadmapPath = path.join(planningDir, 'ROADMAP.md');
|
|
fs.writeFileSync(roadmapPath, [
|
|
'# Roadmap',
|
|
'',
|
|
'<details open>',
|
|
'<summary>v2.0 (in progress)</summary>',
|
|
'',
|
|
'## v2.0 Phases',
|
|
'',
|
|
'- [ ] **Phase 1: Setup** - initial setup',
|
|
'- [ ] **Phase 2: Build** - build features',
|
|
'',
|
|
'### Phase 1: Setup',
|
|
'',
|
|
'**Plans:** 0/1 plans complete',
|
|
'',
|
|
'### Phase 2: Build',
|
|
'',
|
|
'**Plans:** 0/2 plans complete',
|
|
'',
|
|
'</details>',
|
|
].join('\n'));
|
|
|
|
try {
|
|
execFileSync('node', [GSD_TOOLS_BIN, 'phase', 'complete', '1'], { cwd: tmpDir, timeout: 10000 });
|
|
} catch {}
|
|
|
|
const result = fs.readFileSync(roadmapPath, 'utf-8');
|
|
|
|
assert.match(
|
|
result,
|
|
/Phase 2: Build[\s\S]*?\*\*Plans:\*\*\s*0\/2 plans complete/,
|
|
'Phase 2 plan count must remain 0/2 (untouched)'
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// regression: bug #2526 — phase complete warns about unregistered REQ-IDs
|
|
// (consolidated from tests/bug-2526-phase-complete-req-discovery.test.cjs)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
describe('bug #2526: phase complete warns about unregistered REQ-IDs', () => {
|
|
let tmpDir;
|
|
let planningDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2526-'));
|
|
planningDir = path.join(tmpDir, '.planning');
|
|
fs.mkdirSync(planningDir, { recursive: true });
|
|
|
|
fs.writeFileSync(
|
|
path.join(planningDir, 'config.json'),
|
|
JSON.stringify({ project_code: '' })
|
|
);
|
|
|
|
fs.writeFileSync(
|
|
path.join(planningDir, 'STATE.md'),
|
|
'---\ncurrent_phase: 1\nstatus: executing\n---\n# State\n'
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('emits warning for REQ-IDs in body but missing from Traceability table', () => {
|
|
const phasesDir = path.join(planningDir, 'phases', '01-foundation');
|
|
fs.mkdirSync(phasesDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(phasesDir, '01-1-PLAN.md'),
|
|
'---\nphase: 1\nplan: 1\n---\n# Plan 1\n'
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(phasesDir, '01-1-SUMMARY.md'),
|
|
'---\nstatus: complete\n---\n# Summary\nDone.'
|
|
);
|
|
|
|
const roadmapPath = path.join(planningDir, 'ROADMAP.md');
|
|
fs.writeFileSync(roadmapPath, [
|
|
'# Roadmap',
|
|
'',
|
|
'### Phase 1: Foundation',
|
|
'',
|
|
'**Goal:** Build core',
|
|
'**Requirements:** REQ-001',
|
|
'**Plans:** 1 plans',
|
|
'',
|
|
'Plans:',
|
|
'- [x] 01-1-PLAN.md',
|
|
'',
|
|
'| Phase | Plans | Status | Completed |',
|
|
'|-------|-------|--------|-----------|',
|
|
'| 1. Foundation | 0/1 | Pending | - |',
|
|
].join('\n'));
|
|
|
|
const reqPath = path.join(planningDir, 'REQUIREMENTS.md');
|
|
fs.writeFileSync(reqPath, [
|
|
'# Requirements',
|
|
'',
|
|
'## Functional Requirements',
|
|
'',
|
|
'- [x] **REQ-001**: Core data model',
|
|
'- [ ] **REQ-002**: User authentication',
|
|
'- [ ] **REQ-003**: API endpoints',
|
|
'',
|
|
'## Traceability',
|
|
'',
|
|
'| REQ-ID | Phase | Status |',
|
|
'|--------|-------|--------|',
|
|
'| REQ-001 | 1 | Pending |',
|
|
].join('\n'));
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
try {
|
|
const result = execFileSync('node', [GSD_TOOLS_BIN, 'phase', 'complete', '1'], {
|
|
cwd: tmpDir,
|
|
timeout: 10000,
|
|
encoding: 'utf-8',
|
|
});
|
|
stdout = result;
|
|
} catch (err) {
|
|
stdout = err.stdout || '';
|
|
stderr = err.stderr || '';
|
|
throw err;
|
|
}
|
|
|
|
const combined = stdout + stderr;
|
|
assert.match(combined, /REQ-002/, 'output should mention REQ-002 as missing from Traceability table');
|
|
assert.match(combined, /REQ-003/, 'output should mention REQ-003 as missing from Traceability table');
|
|
});
|
|
|
|
test('no warning when all body REQ-IDs are present in Traceability table', () => {
|
|
const phasesDir = path.join(planningDir, 'phases', '01-foundation');
|
|
fs.mkdirSync(phasesDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(phasesDir, '01-1-PLAN.md'),
|
|
'---\nphase: 1\nplan: 1\n---\n# Plan 1\n'
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(phasesDir, '01-1-SUMMARY.md'),
|
|
'---\nstatus: complete\n---\n# Summary\nDone.'
|
|
);
|
|
|
|
const roadmapPath = path.join(planningDir, 'ROADMAP.md');
|
|
fs.writeFileSync(roadmapPath, [
|
|
'# Roadmap',
|
|
'',
|
|
'### Phase 1: Foundation',
|
|
'',
|
|
'**Goal:** Build core',
|
|
'**Requirements:** REQ-001, REQ-002',
|
|
'**Plans:** 1 plans',
|
|
'',
|
|
'Plans:',
|
|
'- [x] 01-1-PLAN.md',
|
|
'',
|
|
'| Phase | Plans | Status | Completed |',
|
|
'|-------|-------|--------|-----------|',
|
|
'| 1. Foundation | 0/1 | Pending | - |',
|
|
].join('\n'));
|
|
|
|
const reqPath = path.join(planningDir, 'REQUIREMENTS.md');
|
|
fs.writeFileSync(reqPath, [
|
|
'# Requirements',
|
|
'',
|
|
'## Functional Requirements',
|
|
'',
|
|
'- [x] **REQ-001**: Core data model',
|
|
'- [x] **REQ-002**: User authentication',
|
|
'',
|
|
'## Traceability',
|
|
'',
|
|
'| REQ-ID | Phase | Status |',
|
|
'|--------|-------|--------|',
|
|
'| REQ-001 | 1 | Pending |',
|
|
'| REQ-002 | 1 | Pending |',
|
|
].join('\n'));
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
try {
|
|
const result = execFileSync('node', [GSD_TOOLS_BIN, 'phase', 'complete', '1'], {
|
|
cwd: tmpDir,
|
|
timeout: 10000,
|
|
encoding: 'utf-8',
|
|
});
|
|
stdout = result;
|
|
} catch (err) {
|
|
stdout = err.stdout || '';
|
|
stderr = err.stderr || '';
|
|
throw err;
|
|
}
|
|
|
|
const combined = stdout + stderr;
|
|
assert.doesNotMatch(
|
|
combined,
|
|
/unregistered|missing.*traceability|not in.*traceability/i,
|
|
'no warning should appear when all REQ-IDs are in the table'
|
|
);
|
|
});
|
|
|
|
test('warning includes all missing REQ-IDs, not just the first', () => {
|
|
const phasesDir = path.join(planningDir, 'phases', '01-foundation');
|
|
fs.mkdirSync(phasesDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(phasesDir, '01-1-PLAN.md'),
|
|
'---\nphase: 1\nplan: 1\n---\n# Plan 1\n'
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(phasesDir, '01-1-SUMMARY.md'),
|
|
'---\nstatus: complete\n---\n# Summary\nDone.'
|
|
);
|
|
|
|
const roadmapPath = path.join(planningDir, 'ROADMAP.md');
|
|
fs.writeFileSync(roadmapPath, [
|
|
'# Roadmap',
|
|
'',
|
|
'### Phase 1: Foundation',
|
|
'',
|
|
'**Goal:** Build core',
|
|
'**Requirements:** REQ-001',
|
|
'**Plans:** 1 plans',
|
|
'',
|
|
'Plans:',
|
|
'- [x] 01-1-PLAN.md',
|
|
'',
|
|
'| Phase | Plans | Status | Completed |',
|
|
'|-------|-------|--------|-----------|',
|
|
'| 1. Foundation | 0/1 | Pending | - |',
|
|
].join('\n'));
|
|
|
|
const reqPath = path.join(planningDir, 'REQUIREMENTS.md');
|
|
fs.writeFileSync(reqPath, [
|
|
'# Requirements',
|
|
'',
|
|
'- [x] **REQ-001**: Core data model',
|
|
'- [ ] **REQ-002**: User auth',
|
|
'- [ ] **REQ-003**: API',
|
|
'- [ ] **REQ-004**: Reports',
|
|
'',
|
|
'## Traceability',
|
|
'',
|
|
'| REQ-ID | Phase | Status |',
|
|
'|--------|-------|--------|',
|
|
'| REQ-001 | 1 | Pending |',
|
|
].join('\n'));
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
try {
|
|
const result = execFileSync('node', [GSD_TOOLS_BIN, 'phase', 'complete', '1'], {
|
|
cwd: tmpDir,
|
|
timeout: 10000,
|
|
encoding: 'utf-8',
|
|
});
|
|
stdout = result;
|
|
} catch (err) {
|
|
stdout = err.stdout || '';
|
|
stderr = err.stderr || '';
|
|
throw err;
|
|
}
|
|
|
|
const combined = stdout + stderr;
|
|
assert.match(combined, /REQ-002/, 'should warn about REQ-002');
|
|
assert.match(combined, /REQ-003/, 'should warn about REQ-003');
|
|
assert.match(combined, /REQ-004/, 'should warn about REQ-004');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// regression: bug #3287 — phase-dir prefix parity across creation paths
|
|
// (consolidated from tests/bug-3287-phase-dir-prefix-parity.test.cjs)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
// ─── shared fixture for bug-3287 ─────────────────────────────────────────────
|
|
function makeXRProject(tmpDir) {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({ project_code: 'XR' }),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
[
|
|
'# Roadmap v1.0',
|
|
'',
|
|
'### Phase 1: Foundation',
|
|
'**Goal:** Setup project',
|
|
'**Plans:** 0 plans',
|
|
'',
|
|
'---',
|
|
'',
|
|
].join('\n'),
|
|
);
|
|
}
|
|
|
|
describe('bug-3287 — phase.add emits project_code prefix (sanity)', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => { tmpDir = createTempProject(); });
|
|
afterEach(() => { cleanup(tmpDir); });
|
|
|
|
test('phase.add creates XR-02-<slug> when project_code is XR', () => {
|
|
makeXRProject(tmpDir);
|
|
|
|
const result = runGsdTools('phase add auth service', tmpDir, { HOME: tmpDir });
|
|
assert.ok(result.success, `phase.add failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_number, 2, 'phase number should be 2');
|
|
|
|
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
|
const dirs = fs.readdirSync(phasesDir);
|
|
const prefixedDirs = dirs.filter(d => d.startsWith('XR-'));
|
|
assert.ok(
|
|
prefixedDirs.length > 0,
|
|
`Expected at least one XR- prefixed dir, got: ${JSON.stringify(dirs)}`,
|
|
);
|
|
assert.ok(
|
|
dirs.some(d => d === 'XR-02-auth-service'),
|
|
`Expected XR-02-auth-service, got: ${JSON.stringify(dirs)}`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('bug-3287 — init phase-op exposes expected_phase_dir with project_code prefix', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => { tmpDir = createTempProject(); });
|
|
afterEach(() => { cleanup(tmpDir); });
|
|
|
|
test('returns expected_phase_dir with XR- prefix when phase directory does not exist', () => {
|
|
makeXRProject(tmpDir);
|
|
|
|
const result = runGsdTools('init phase-op 1', tmpDir, { HOME: tmpDir });
|
|
assert.ok(result.success, `init phase-op failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_found, true, 'phase should be found in roadmap');
|
|
assert.strictEqual(output.phase_dir, null, 'phase_dir should be null (no dir yet)');
|
|
|
|
assert.ok(
|
|
typeof output.expected_phase_dir === 'string',
|
|
`expected_phase_dir should be a string, got: ${JSON.stringify(output.expected_phase_dir)}`,
|
|
);
|
|
assert.ok(
|
|
output.expected_phase_dir.includes('XR-'),
|
|
`expected_phase_dir should contain XR- prefix, got: "${output.expected_phase_dir}"`,
|
|
);
|
|
assert.ok(
|
|
output.expected_phase_dir.includes('foundation'),
|
|
`expected_phase_dir should contain the phase slug, got: "${output.expected_phase_dir}"`,
|
|
);
|
|
});
|
|
|
|
test('expected_phase_dir is null when no project_code is set', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({}),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
'# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n',
|
|
);
|
|
|
|
const result = runGsdTools('init phase-op 1', tmpDir, { HOME: tmpDir });
|
|
assert.ok(result.success, `init phase-op failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_dir, null);
|
|
assert.ok(
|
|
typeof output.expected_phase_dir === 'string',
|
|
`expected_phase_dir should be a string even without project_code, got: ${JSON.stringify(output.expected_phase_dir)}`,
|
|
);
|
|
assert.ok(
|
|
!output.expected_phase_dir.match(/^[A-Z][A-Z0-9]*-/),
|
|
`expected_phase_dir should have NO prefix without project_code, got: "${output.expected_phase_dir}"`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('bug-3287 — init plan-phase exposes expected_phase_dir with project_code prefix', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => { tmpDir = createTempProject(); });
|
|
afterEach(() => { cleanup(tmpDir); });
|
|
|
|
test('returns expected_phase_dir with XR- prefix when phase directory does not exist', () => {
|
|
makeXRProject(tmpDir);
|
|
|
|
const result = runGsdTools('init plan-phase 1', tmpDir, { HOME: tmpDir });
|
|
assert.ok(result.success, `init plan-phase failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_found, true, 'phase should be found in roadmap');
|
|
assert.strictEqual(output.phase_dir, null, 'phase_dir should be null (no dir yet)');
|
|
|
|
assert.ok(
|
|
typeof output.expected_phase_dir === 'string',
|
|
`expected_phase_dir should be a string, got: ${JSON.stringify(output.expected_phase_dir)}`,
|
|
);
|
|
assert.ok(
|
|
output.expected_phase_dir.includes('XR-'),
|
|
`expected_phase_dir should contain XR- prefix, got: "${output.expected_phase_dir}"`,
|
|
);
|
|
assert.ok(
|
|
output.expected_phase_dir.includes('foundation'),
|
|
`expected_phase_dir should contain the phase slug, got: "${output.expected_phase_dir}"`,
|
|
);
|
|
});
|
|
|
|
test('expected_phase_dir omits prefix when project_code is not set', () => {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'config.json'),
|
|
JSON.stringify({}),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
|
'# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n',
|
|
);
|
|
|
|
const result = runGsdTools('init plan-phase 1', tmpDir, { HOME: tmpDir });
|
|
assert.ok(result.success, `init plan-phase failed: ${result.error}`);
|
|
|
|
const output = JSON.parse(result.output);
|
|
assert.strictEqual(output.phase_dir, null);
|
|
assert.ok(
|
|
typeof output.expected_phase_dir === 'string',
|
|
`expected_phase_dir should be a string, got: ${JSON.stringify(output.expected_phase_dir)}`,
|
|
);
|
|
assert.ok(
|
|
!output.expected_phase_dir.match(/^[A-Z][A-Z0-9]*-/),
|
|
`expected_phase_dir should have NO prefix without project_code, got: "${output.expected_phase_dir}"`,
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// regression: bug #3298 — phase-dir prefix drift in workflows
|
|
// (consolidated from tests/bug-3298-phase-dir-prefix-drift-in-workflows.test.cjs)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
{
|
|
const PMG_WF = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-milestone-gaps.md');
|
|
const IMPORT_WF = path.join(__dirname, '..', 'gsd-core', 'workflows', 'import.md');
|
|
const BACKLOG_WF = path.join(__dirname, '..', 'gsd-core', 'workflows', 'add-backlog.md');
|
|
|
|
function readWorkflow(filePath) {
|
|
try {
|
|
return fs.readFileSync(filePath, 'utf8');
|
|
} catch (err) {
|
|
throw new Error(`Cannot read workflow file ${filePath}: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
function containsBareTemplateMkdir(content) {
|
|
return /mkdir[^`\n]*\.planning\/phases\/\{[A-Z0-9]+\}-\{/.test(content);
|
|
}
|
|
|
|
function containsBareShellVarMkdir(content) {
|
|
return /mkdir[^`\n]*\.planning\/phases\/"\$\{(?:NEXT|NN|PHASE)[^}]*\}-/.test(content)
|
|
|| /mkdir[^`\n]*\.planning\/phases\/\$\{(?:NEXT|NN|PHASE)[^}]*\}-/.test(content);
|
|
}
|
|
|
|
describe('bug-3298 — plan-milestone-gaps.md must not construct bare {NN}-{name} phase dirs', () => {
|
|
test('workflow file exists', () => {
|
|
assert.ok(fs.existsSync(PMG_WF), `plan-milestone-gaps.md must exist at ${PMG_WF}`);
|
|
});
|
|
|
|
test('step 8 must not use bare {NN}-{name} mkdir pattern', () => {
|
|
const content = readWorkflow(PMG_WF);
|
|
assert.ok(
|
|
!containsBareTemplateMkdir(content),
|
|
'plan-milestone-gaps.md must not contain bare mkdir .planning/phases/{NN}-{name} pattern — use phase.add or expected_phase_dir',
|
|
);
|
|
});
|
|
|
|
test('step 8 must use expected_phase_dir or phase.add for directory creation', () => {
|
|
const content = readWorkflow(PMG_WF);
|
|
const usesExpectedPhaseDir = content.includes('expected_phase_dir');
|
|
const usesPhaseAdd = content.includes('phase.add');
|
|
assert.ok(
|
|
usesExpectedPhaseDir || usesPhaseAdd,
|
|
'plan-milestone-gaps.md must use expected_phase_dir (from init.phase-op) or phase.add to create phase directories with project_code prefix',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('bug-3298 — import.md must not construct bare {NN}-{slug} phase dirs', () => {
|
|
test('workflow file exists', () => {
|
|
assert.ok(fs.existsSync(IMPORT_WF), `import.md must exist at ${IMPORT_WF}`);
|
|
});
|
|
|
|
test('plan_convert step must not use bare {NN}-{slug} mkdir pattern', () => {
|
|
const content = readWorkflow(IMPORT_WF);
|
|
assert.ok(
|
|
!containsBareTemplateMkdir(content),
|
|
'import.md must not contain bare mkdir .planning/phases/{NN}-{slug} pattern — use expected_phase_dir from init.phase-op',
|
|
);
|
|
});
|
|
|
|
test('plan_convert step must use expected_phase_dir for directory creation', () => {
|
|
const content = readWorkflow(IMPORT_WF);
|
|
assert.ok(
|
|
content.includes('expected_phase_dir'),
|
|
'import.md must use expected_phase_dir (from init.phase-op) to create phase directory with project_code prefix',
|
|
);
|
|
});
|
|
|
|
test('plan_convert step must call init.phase-op to resolve the prefixed dir', () => {
|
|
const content = readWorkflow(IMPORT_WF);
|
|
assert.ok(
|
|
content.includes('init.phase-op') || content.includes('init phase-op'),
|
|
'import.md must call gsd-sdk query init.phase-op to get expected_phase_dir with project_code prefix',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('bug-3298 — add-backlog.md must apply project_code prefix when creating 999.x dirs', () => {
|
|
test('workflow file exists', () => {
|
|
assert.ok(fs.existsSync(BACKLOG_WF), `add-backlog.md must exist at ${BACKLOG_WF}`);
|
|
});
|
|
|
|
test('step 4 must not use bare ${NEXT}-${SLUG} mkdir without project_code prefix', () => {
|
|
const content = readWorkflow(BACKLOG_WF);
|
|
assert.ok(
|
|
!containsBareShellVarMkdir(content),
|
|
'add-backlog.md must not create .planning/phases/${NEXT}-${SLUG} without a project_code prefix variable — apply ${PREFIX} (or equivalent) before ${NEXT}',
|
|
);
|
|
});
|
|
|
|
test('step 4 must reference project_code or a prefix variable before the phase number', () => {
|
|
const content = readWorkflow(BACKLOG_WF);
|
|
const hasProjectCodeRef = content.includes('project_code') || content.includes('PROJECT_CODE');
|
|
const hasPrefixVar = content.includes('${PREFIX}') || content.includes('${PHASE_PREFIX}') || content.includes('${CODE}');
|
|
assert.ok(
|
|
hasProjectCodeRef || hasPrefixVar,
|
|
'add-backlog.md must read project_code (or use a PREFIX variable) to apply the project_code prefix to the 999.x phase directory name',
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// regression: bug #3517 — phase.complete leaves STATE.md with stale fields
|
|
// (consolidated from tests/bug-3517-phase-complete-state-md-staleness.test.cjs)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
{
|
|
function runSdkQuery(args, cwd) {
|
|
const result = runGsdTools(args, cwd);
|
|
if (!result.success) return { success: false, error: result.error };
|
|
try {
|
|
const parsed = JSON.parse(result.output || '{}');
|
|
return { success: true, data: parsed };
|
|
} catch (err) {
|
|
return { success: false, error: err.message };
|
|
}
|
|
}
|
|
|
|
function setupPhase3517Project(tmpDir) {
|
|
const planningDir = path.join(tmpDir, '.planning');
|
|
const phasesDir = path.join(planningDir, 'phases');
|
|
fs.mkdirSync(planningDir, { recursive: true });
|
|
fs.mkdirSync(phasesDir, { recursive: true });
|
|
|
|
fs.writeFileSync(
|
|
path.join(planningDir, 'config.json'),
|
|
JSON.stringify({ project_code: 'TEST' }),
|
|
);
|
|
|
|
const roadmap = [
|
|
'# Roadmap',
|
|
'',
|
|
'## Current Milestone: v3.0',
|
|
'',
|
|
'| Phase | Plans | Status | Completed |',
|
|
'|-------|-------|--------|-----------|',
|
|
'| 4. | 3/3 | Complete | 2026-04-01 |',
|
|
'| 5. | 7/7 | In Progress | |',
|
|
'| 6. | 0/5 | Not Started | |',
|
|
'',
|
|
'- [x] Phase 4: Foundation (completed 2026-04-01)',
|
|
'- [ ] Phase 5: Core API',
|
|
'- [ ] Phase 6: Integration',
|
|
'',
|
|
'### Phase 4: Foundation',
|
|
'',
|
|
'**Goal:** Foundation work',
|
|
'**Plans:** 3/3 plans complete',
|
|
'',
|
|
'### Phase 5: Core API',
|
|
'',
|
|
'**Goal:** Build core API layer',
|
|
'**Plans:** 7 plans',
|
|
'',
|
|
'Plans:',
|
|
'- [ ] 05-01 plan',
|
|
'- [ ] 05-02 plan',
|
|
'- [ ] 05-03 plan',
|
|
'- [ ] 05-04 plan',
|
|
'- [ ] 05-05 plan',
|
|
'- [ ] 05-06 plan',
|
|
'- [ ] 05-07 plan',
|
|
'',
|
|
'### Phase 6: Integration',
|
|
'',
|
|
'**Goal:** Integration work',
|
|
'**Plans:** 5 plans',
|
|
'',
|
|
'---',
|
|
'*Last updated: 2026-05-14*',
|
|
].join('\n');
|
|
|
|
fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), roadmap);
|
|
|
|
const state = [
|
|
'---',
|
|
'gsd_state_version: 1.0',
|
|
'milestone: v3.0',
|
|
'milestone_name: Core Platform',
|
|
'status: executing',
|
|
'stopped_at: Completed 05-03-PLAN.md',
|
|
'last_updated: 2026-05-10T08:00:00.000Z',
|
|
'progress:',
|
|
' total_phases: 3',
|
|
' completed_phases: 1',
|
|
' total_plans: 15',
|
|
' completed_plans: 6',
|
|
' percent: 33',
|
|
'---',
|
|
'',
|
|
'# Project State',
|
|
'',
|
|
'## Current Position',
|
|
'',
|
|
'**Current focus:** Phase 5 — Core API',
|
|
'Phase: 5 of 3 (Core API) — EXECUTING',
|
|
'Plan: 7 of 7',
|
|
'Status: Executing Phase 5',
|
|
'Last activity: 2026-05-10',
|
|
'',
|
|
'## Progress',
|
|
'',
|
|
'Progress: [████████████░░░░] 40% (1/3 phases complete before Phase 5 closeout)',
|
|
'',
|
|
'## Performance Metrics',
|
|
'',
|
|
'**Velocity:**',
|
|
'',
|
|
'- Total plans completed: 6',
|
|
'- Average duration: 2h',
|
|
'- Total execution time: 14 hours',
|
|
'- Window: 2026-04-01 to 2026-05-10',
|
|
'',
|
|
'**By Phase:**',
|
|
'',
|
|
'| Phase | Plans | Total | Avg/Plan |',
|
|
'|-------|-------|-------|----------|',
|
|
'| 4 | 3 | - | - |',
|
|
'',
|
|
'## Session Continuity',
|
|
'',
|
|
'Last session: 2026-05-10T08:00:00.000Z',
|
|
'Stopped at: Completed 05-07-PLAN.md',
|
|
].join('\n');
|
|
|
|
fs.writeFileSync(path.join(planningDir, 'STATE.md'), state);
|
|
|
|
const phase5Dir = path.join(phasesDir, '05-core-api');
|
|
fs.mkdirSync(phase5Dir, { recursive: true });
|
|
for (let i = 1; i <= 7; i++) {
|
|
const padded = String(i).padStart(2, '0');
|
|
fs.writeFileSync(path.join(phase5Dir, `05-${padded}-PLAN.md`), `plan ${i}`, 'utf8');
|
|
fs.writeFileSync(path.join(phase5Dir, `05-${padded}-SUMMARY.md`), `summary ${i}`, 'utf8');
|
|
}
|
|
|
|
const phase4Dir = path.join(phasesDir, '04-foundation');
|
|
fs.mkdirSync(phase4Dir, { recursive: true });
|
|
for (let i = 1; i <= 3; i++) {
|
|
const padded = String(i).padStart(2, '0');
|
|
fs.writeFileSync(path.join(phase4Dir, `04-${padded}-PLAN.md`), `plan ${i}`, 'utf8');
|
|
fs.writeFileSync(path.join(phase4Dir, `04-${padded}-SUMMARY.md`), `summary ${i}`, 'utf8');
|
|
}
|
|
|
|
const phase6Dir = path.join(phasesDir, '06-integration');
|
|
fs.mkdirSync(phase6Dir, { recursive: true });
|
|
|
|
return { planningDir, phase5Dir };
|
|
}
|
|
|
|
describe('bug #3517: phase.complete leaves STATE.md with stale fields', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3517-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('completed_phases is derived from ROADMAP, not blindly incremented (idempotency)', () => {
|
|
setupPhase3517Project(tmpDir);
|
|
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
|
|
const r1 = runSdkQuery(['phase.complete', '5'], tmpDir);
|
|
assert.ok(r1.success, `first call failed: ${r1.error}`);
|
|
|
|
const stateAfter1 = fs.readFileSync(statePath, 'utf8');
|
|
const match1 = stateAfter1.match(/completed_phases:\s*(\d+)/);
|
|
assert.ok(match1, 'completed_phases not found in frontmatter after first call');
|
|
assert.equal(
|
|
Number(match1[1]),
|
|
2,
|
|
`After first call: completed_phases should be 2 (derived from ROADMAP: phases 4 and 5 complete), got ${match1[1]}`,
|
|
);
|
|
|
|
const r2 = runSdkQuery(['phase.complete', '5'], tmpDir);
|
|
assert.ok(r2.success, `second call failed: ${r2.error}`);
|
|
|
|
const stateAfter2 = fs.readFileSync(statePath, 'utf8');
|
|
const match2 = stateAfter2.match(/completed_phases:\s*(\d+)/);
|
|
assert.ok(match2, 'completed_phases not found in frontmatter after second call');
|
|
assert.equal(
|
|
Number(match2[1]),
|
|
2,
|
|
`After second call (same phase): completed_phases must remain 2 (idempotent), got ${match2[1]}`,
|
|
);
|
|
});
|
|
|
|
test('frontmatter stopped_at is updated after phase.complete', () => {
|
|
setupPhase3517Project(tmpDir);
|
|
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
|
|
const r = runSdkQuery(['phase.complete', '5'], tmpDir);
|
|
assert.ok(r.success, `call failed: ${r.error}`);
|
|
|
|
const state = fs.readFileSync(statePath, 'utf8');
|
|
const stoppedMatch = state.match(/stopped_at:\s*(.+)/);
|
|
assert.ok(stoppedMatch, 'stopped_at not found in frontmatter');
|
|
assert.ok(
|
|
!stoppedMatch[1].includes('05-03-PLAN.md'),
|
|
`stopped_at should not still say "Completed 05-03-PLAN.md" — got: ${stoppedMatch[1]}`,
|
|
);
|
|
assert.ok(
|
|
stoppedMatch[1].toLowerCase().includes('phase 5') ||
|
|
stoppedMatch[1].toLowerCase().includes('complete'),
|
|
`stopped_at should reference phase 5 completion, got: ${stoppedMatch[1]}`,
|
|
);
|
|
});
|
|
|
|
test('frontmatter last_updated is refreshed to today after phase.complete', () => {
|
|
setupPhase3517Project(tmpDir);
|
|
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
|
|
const r = runSdkQuery(['phase.complete', '5'], tmpDir);
|
|
assert.ok(r.success, `call failed: ${r.error}`);
|
|
|
|
const state = fs.readFileSync(statePath, 'utf8');
|
|
const lastUpdatedMatch = state.match(/last_updated:\s*(.+)/);
|
|
assert.ok(lastUpdatedMatch, 'last_updated not found in frontmatter');
|
|
assert.notEqual(
|
|
lastUpdatedMatch[1].trim(),
|
|
'2026-05-10T08:00:00.000Z',
|
|
`last_updated must be refreshed, but it is still the stale value: ${lastUpdatedMatch[1]}`,
|
|
);
|
|
const updatedAt = new Date(lastUpdatedMatch[1].trim().replace(/^"(.*)"$/, '$1'));
|
|
const now = new Date();
|
|
const diffMs = Math.abs(now - updatedAt);
|
|
assert.ok(
|
|
diffMs < 60_000,
|
|
`last_updated should be approximately now (within 60s), got: ${lastUpdatedMatch[1]} (diff: ${diffMs}ms)`,
|
|
);
|
|
});
|
|
|
|
test('frontmatter total_plans is updated from ROADMAP plan counts after phase.complete', () => {
|
|
setupPhase3517Project(tmpDir);
|
|
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
|
|
const r = runSdkQuery(['phase.complete', '5'], tmpDir);
|
|
assert.ok(r.success, `call failed: ${r.error}`);
|
|
|
|
const state = fs.readFileSync(statePath, 'utf8');
|
|
const match = state.match(/total_plans:\s*(\d+)/);
|
|
assert.ok(match, 'total_plans not found in frontmatter');
|
|
const totalPlans = Number(match[1]);
|
|
assert.ok(Number.isFinite(totalPlans) && totalPlans > 0, `total_plans must be a positive number, got: ${match[1]}`);
|
|
});
|
|
|
|
test('frontmatter completed_plans is updated from SUMMARY file count after phase.complete', () => {
|
|
setupPhase3517Project(tmpDir);
|
|
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
|
|
const r = runSdkQuery(['phase.complete', '5'], tmpDir);
|
|
assert.ok(r.success, `call failed: ${r.error}`);
|
|
|
|
const state = fs.readFileSync(statePath, 'utf8');
|
|
const match = state.match(/completed_plans:\s*(\d+)/);
|
|
assert.ok(match, 'completed_plans not found in frontmatter');
|
|
const completedPlans = Number(match[1]);
|
|
assert.equal(
|
|
completedPlans,
|
|
10,
|
|
`completed_plans should be 10 (3 phase-4 summaries + 7 phase-5 summaries), got: ${completedPlans}`,
|
|
);
|
|
});
|
|
|
|
test('frontmatter percent is recomputed from fresh derived counts', () => {
|
|
setupPhase3517Project(tmpDir);
|
|
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
|
|
const r = runSdkQuery(['phase.complete', '5'], tmpDir);
|
|
assert.ok(r.success, `call failed: ${r.error}`);
|
|
|
|
const state = fs.readFileSync(statePath, 'utf8');
|
|
const match = state.match(/percent:\s*(\d+)/);
|
|
assert.ok(match, 'percent not found in frontmatter');
|
|
assert.equal(Number(match[1]), 67, `percent should be 67 (2/3 phases), got: ${match[1]}`);
|
|
});
|
|
|
|
test('state frontmatter and numeric phase line reflect next phase after phase.complete', () => {
|
|
setupPhase3517Project(tmpDir);
|
|
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
|
|
const r = runSdkQuery(['phase.complete', '5'], tmpDir);
|
|
assert.ok(r.success, `call failed: ${r.error}`);
|
|
|
|
const state = fs.readFileSync(statePath, 'utf8');
|
|
assert.match(state, /completed_phases:\s*2/, 'completed_phases must be updated in frontmatter');
|
|
assert.match(state, /Phase:\s*0?6\b/, 'numeric Phase line should advance to phase 6');
|
|
});
|
|
|
|
test('body By Phase table row for completed phase shows correct plan count', () => {
|
|
setupPhase3517Project(tmpDir);
|
|
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
|
|
const r = runSdkQuery(['phase.complete', '5'], tmpDir);
|
|
assert.ok(r.success, `call failed: ${r.error}`);
|
|
|
|
const state = fs.readFileSync(statePath, 'utf8');
|
|
assert.match(
|
|
state,
|
|
/\|\s*5\s*\|\s*7\s*\|/,
|
|
`By Phase table should have a row for phase 5 with 7 summaries.\nState:\n${state}`,
|
|
);
|
|
});
|
|
|
|
test('full consistency check: all STATE.md fields are coherent after phase.complete', () => {
|
|
setupPhase3517Project(tmpDir);
|
|
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
|
|
const r = runSdkQuery(['phase.complete', '5'], tmpDir);
|
|
assert.ok(r.success, `call failed: ${r.error}`);
|
|
assert.equal(r.data?.state_updated, true, 'state_updated must be true');
|
|
|
|
const state = fs.readFileSync(statePath, 'utf8');
|
|
|
|
assert.match(state, /completed_phases:\s*2/, 'completed_phases must be 2 (4 and 5 complete)');
|
|
assert.match(state, /percent:\s*67/, 'percent must be 67%');
|
|
const hasPhase6 = /Phase:\s*0?6/.test(state) || /current_phase:\s*0?6/.test(state);
|
|
assert.ok(hasPhase6, `STATE.md must reference Phase 6 as current after completing Phase 5.\nState:\n${state}`);
|
|
});
|
|
});
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// regression: bug #3601 — phase remove preserves peer-depth decimal sections
|
|
// (consolidated from tests/bug-3601-phase-remove-preserves-decimal-sections.test.cjs)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
{
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
function writeRoadmapForRemove(tmpDir, body) {
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), body);
|
|
}
|
|
function writeStateForRemove(tmpDir, version) {
|
|
fs.writeFileSync(
|
|
path.join(tmpDir, '.planning', 'STATE.md'),
|
|
`---\nmilestone: ${version}\n---\n`,
|
|
);
|
|
}
|
|
function ensurePhaseDir(tmpDir, name) {
|
|
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', name), { recursive: true });
|
|
}
|
|
function getPhase(tmpDir, phaseNum) {
|
|
const r = runGsdTools(['roadmap', 'get-phase', phaseNum, '--json'], tmpDir);
|
|
if (!r.success) return { found: false, error: r.error };
|
|
return JSON.parse(r.output);
|
|
}
|
|
|
|
describe('bug #3601: phase remove preserves peer-depth decimal sections', () => {
|
|
let tmpDir;
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject('bug-3601-');
|
|
});
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('removing Phase 2 preserves peer-depth Phase 2.1 and renumbers Phase 3 → 2', () => {
|
|
writeStateForRemove(tmpDir, 'v1.0.0');
|
|
writeRoadmapForRemove(
|
|
tmpDir,
|
|
[
|
|
'# Roadmap',
|
|
'',
|
|
'## Current Milestone: v1.0.0 - Test',
|
|
'',
|
|
'### Phase 2: Parent',
|
|
'**Goal:** RemoveMeGoal',
|
|
'',
|
|
'### Phase 2.1: Follow-up',
|
|
'**Goal:** PreserveDecimalGoal',
|
|
'',
|
|
'### Phase 3: Trailing',
|
|
'**Goal:** PreserveTrailingGoal',
|
|
'',
|
|
].join('\n'),
|
|
);
|
|
ensurePhaseDir(tmpDir, '02-parent');
|
|
ensurePhaseDir(tmpDir, '02.1-follow-up');
|
|
ensurePhaseDir(tmpDir, '03-trailing');
|
|
|
|
const r = runGsdTools(['phase', 'remove', '2'], tmpDir);
|
|
assert.ok(r.success, `phase remove failed: ${r.error || r.output}`);
|
|
|
|
const decimal = getPhase(tmpDir, '2.1');
|
|
assert.strictEqual(decimal.found, true, 'Phase 2.1 deleted alongside Phase 2');
|
|
assert.strictEqual(decimal.phase_name, 'Follow-up');
|
|
assert.strictEqual(decimal.goal, 'PreserveDecimalGoal');
|
|
|
|
const renumbered = getPhase(tmpDir, '2');
|
|
assert.strictEqual(renumbered.found, true);
|
|
assert.strictEqual(renumbered.phase_name, 'Trailing');
|
|
assert.strictEqual(
|
|
renumbered.goal,
|
|
'PreserveTrailingGoal',
|
|
'Phase 3 → Phase 2 renumber did not carry the right section content',
|
|
);
|
|
|
|
const parentLookup = getPhase(tmpDir, '3');
|
|
assert.notStrictEqual(
|
|
parentLookup.goal,
|
|
'RemoveMeGoal',
|
|
'removed parent goal reappeared under a phase header',
|
|
);
|
|
});
|
|
|
|
test('removing Phase 5 preserves Phase 5.1 and Phase 5.2 (multiple peer decimals)', () => {
|
|
writeStateForRemove(tmpDir, 'v1.0.0');
|
|
writeRoadmapForRemove(
|
|
tmpDir,
|
|
[
|
|
'# Roadmap',
|
|
'',
|
|
'## Current Milestone: v1.0.0 - Test',
|
|
'',
|
|
'### Phase 5: Parent',
|
|
'**Goal:** RemoveParent',
|
|
'',
|
|
'### Phase 5.1: First child',
|
|
'**Goal:** ChildAGoal',
|
|
'',
|
|
'### Phase 5.2: Second child',
|
|
'**Goal:** ChildBGoal',
|
|
'',
|
|
'### Phase 6: Tail',
|
|
'**Goal:** TailGoal',
|
|
'',
|
|
].join('\n'),
|
|
);
|
|
ensurePhaseDir(tmpDir, '05-parent');
|
|
ensurePhaseDir(tmpDir, '05.1-first-child');
|
|
ensurePhaseDir(tmpDir, '05.2-second-child');
|
|
ensurePhaseDir(tmpDir, '06-tail');
|
|
|
|
const r = runGsdTools(['phase', 'remove', '5'], tmpDir);
|
|
assert.ok(r.success);
|
|
|
|
const decimalA = getPhase(tmpDir, '5.1');
|
|
assert.strictEqual(decimalA.found, true, 'Phase 5.1 deleted');
|
|
assert.strictEqual(decimalA.goal, 'ChildAGoal');
|
|
|
|
const decimalB = getPhase(tmpDir, '5.2');
|
|
assert.strictEqual(decimalB.found, true, 'Phase 5.2 deleted');
|
|
assert.strictEqual(decimalB.goal, 'ChildBGoal');
|
|
|
|
const tail = getPhase(tmpDir, '5');
|
|
assert.strictEqual(tail.found, true);
|
|
assert.strictEqual(tail.goal, 'TailGoal', 'Phase 6 → Phase 5 renumber misfired');
|
|
});
|
|
});
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// regression: bug #3602 — phase remove renumbers slugged plan references
|
|
// (consolidated from tests/bug-3602-phase-remove-renumbers-slugged-plan-refs.test.cjs)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
function ensurePlanFile(tmpDir, phaseDirName, planName) {
|
|
const p = path.join(tmpDir, '.planning', 'phases', phaseDirName, planName);
|
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
fs.writeFileSync(p, '# Plan\n');
|
|
}
|
|
|
|
describe('bug #3602: phase remove renumbers slugged plan references in ROADMAP', () => {
|
|
let tmpDir;
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject('bug-3602-');
|
|
});
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('slugged PLAN reference (07-01-cherry-pick-foundation-PLAN.md) is renumbered to 06-01-…', () => {
|
|
writeStateForRemove(tmpDir, 'v1.0.0');
|
|
writeRoadmapForRemove(
|
|
tmpDir,
|
|
[
|
|
'# Roadmap',
|
|
'',
|
|
'## Current Milestone: v1.0.0',
|
|
'',
|
|
'### Phase 6: Old Work',
|
|
'**Goal:** RemoveThisGoal',
|
|
'',
|
|
'### Phase 7: New Work',
|
|
'**Goal:** Plans: 07-01-cherry-pick-foundation-PLAN.md and 07-02-finish-it-SUMMARY.md',
|
|
'',
|
|
].join('\n'),
|
|
);
|
|
ensurePhaseDir(tmpDir, '06-old');
|
|
ensurePhaseDir(tmpDir, '07-new');
|
|
ensurePlanFile(tmpDir, '07-new', '07-01-cherry-pick-foundation-PLAN.md');
|
|
ensurePlanFile(tmpDir, '07-new', '07-02-finish-it-SUMMARY.md');
|
|
|
|
const r = runGsdTools(['phase', 'remove', '6'], tmpDir);
|
|
assert.ok(r.success, `phase remove failed: ${r.error || r.output}`);
|
|
|
|
const phase6 = getPhase(tmpDir, '6');
|
|
assert.strictEqual(phase6.found, true);
|
|
assert.strictEqual(phase6.phase_name, 'New Work');
|
|
assert.ok(
|
|
phase6.goal.includes('06-01-cherry-pick-foundation-PLAN.md'),
|
|
`Plans reference for slugged PLAN was not renumbered. Goal: ${phase6.goal}`,
|
|
);
|
|
assert.ok(
|
|
phase6.goal.includes('06-02-finish-it-SUMMARY.md'),
|
|
`Plans reference for slugged SUMMARY was not renumbered. Goal: ${phase6.goal}`,
|
|
);
|
|
assert.ok(
|
|
!phase6.goal.includes('07-01'),
|
|
`stale 07-01 prefix remains in ROADMAP. Goal: ${phase6.goal}`,
|
|
);
|
|
assert.ok(
|
|
!phase6.goal.includes('07-02'),
|
|
`stale 07-02 prefix remains in ROADMAP. Goal: ${phase6.goal}`,
|
|
);
|
|
});
|
|
|
|
test('compact PLAN/SUMMARY reference (07-01-PLAN.md) still renumbers (#3601 contract preserved)', () => {
|
|
writeStateForRemove(tmpDir, 'v1.0.0');
|
|
writeRoadmapForRemove(
|
|
tmpDir,
|
|
[
|
|
'# Roadmap',
|
|
'',
|
|
'## Current Milestone: v1.0.0',
|
|
'',
|
|
'### Phase 6: Old',
|
|
'**Goal:** RemoveGoal',
|
|
'',
|
|
'### Phase 7: New',
|
|
'**Goal:** Plans: 07-01-PLAN.md and 07-02-SUMMARY.md',
|
|
'',
|
|
].join('\n'),
|
|
);
|
|
ensurePhaseDir(tmpDir, '06-old');
|
|
ensurePhaseDir(tmpDir, '07-new');
|
|
ensurePlanFile(tmpDir, '07-new', '07-01-PLAN.md');
|
|
ensurePlanFile(tmpDir, '07-new', '07-02-SUMMARY.md');
|
|
|
|
const r = runGsdTools(['phase', 'remove', '6'], tmpDir);
|
|
assert.ok(r.success);
|
|
|
|
const phase6 = getPhase(tmpDir, '6');
|
|
assert.strictEqual(phase6.found, true);
|
|
assert.ok(phase6.goal.includes('06-01-PLAN.md'));
|
|
assert.ok(phase6.goal.includes('06-02-SUMMARY.md'));
|
|
assert.ok(!phase6.goal.includes('07-01-PLAN.md'));
|
|
assert.ok(!phase6.goal.includes('07-02-SUMMARY.md'));
|
|
});
|
|
|
|
test('does NOT renumber values that look like phase-plan tokens but are not (e.g. 2026-01-01 dates)', () => {
|
|
writeStateForRemove(tmpDir, 'v1.0.0');
|
|
writeRoadmapForRemove(
|
|
tmpDir,
|
|
[
|
|
'# Roadmap',
|
|
'',
|
|
'## Current Milestone: v1.0.0',
|
|
'',
|
|
'### Phase 6: Old',
|
|
'**Goal:** RemoveGoal',
|
|
'',
|
|
'### Phase 7: Date safety',
|
|
'**Goal:** Created 2026-01-01 and tagged v1-2-3 — must not renumber',
|
|
'',
|
|
].join('\n'),
|
|
);
|
|
ensurePhaseDir(tmpDir, '06-old');
|
|
ensurePhaseDir(tmpDir, '07-new');
|
|
|
|
const r = runGsdTools(['phase', 'remove', '6'], tmpDir);
|
|
assert.ok(r.success);
|
|
|
|
const phase6 = getPhase(tmpDir, '6');
|
|
assert.strictEqual(phase6.found, true);
|
|
assert.ok(
|
|
phase6.goal.includes('2026-01-01'),
|
|
`ISO date 2026-01-01 was wrongly modified. Goal: ${phase6.goal}`,
|
|
);
|
|
assert.ok(
|
|
phase6.goal.includes('v1-2-3'),
|
|
`version-tag v1-2-3 was wrongly modified. Goal: ${phase6.goal}`,
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// feature: phase complete auto-prune (#2087)
|
|
// (consolidated from tests/phase-complete-auto-prune.test.cjs)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
{
|
|
function writeConfigForAutoPrune(tmpDir, config) {
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify(config, null, 2));
|
|
}
|
|
|
|
function writeStateMdForAutoPrune(tmpDir, content) {
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), content);
|
|
}
|
|
|
|
function readStateMdForAutoPrune(tmpDir) {
|
|
return fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
|
}
|
|
|
|
function writeRoadmapForAutoPrune(tmpDir, content) {
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), content);
|
|
}
|
|
|
|
function setupPhaseForAutoPrune(tmpDir, phaseNum, planCount) {
|
|
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
|
const phaseDir = path.join(phasesDir, `${String(phaseNum).padStart(2, '0')}-test-phase`);
|
|
fs.mkdirSync(phaseDir, { recursive: true });
|
|
|
|
for (let i = 1; i <= planCount; i++) {
|
|
const planId = `${String(phaseNum).padStart(2, '0')}-${String(i).padStart(2, '0')}`;
|
|
fs.writeFileSync(path.join(phaseDir, `${planId}-PLAN.md`), `# Plan ${planId}\n`);
|
|
fs.writeFileSync(path.join(phaseDir, `${planId}-SUMMARY.md`), `# Summary ${planId}\n`);
|
|
}
|
|
}
|
|
|
|
describe('phase complete auto-prune (#2087)', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('prunes STATE.md automatically when auto_prune_state is true', () => {
|
|
writeConfigForAutoPrune(tmpDir, {
|
|
workflow: { auto_prune_state: true },
|
|
});
|
|
|
|
writeStateMdForAutoPrune(tmpDir, [
|
|
'# Session State',
|
|
'',
|
|
'**Current Phase:** 6',
|
|
'**Status:** Executing',
|
|
'',
|
|
'## Decisions',
|
|
'',
|
|
'- [Phase 1]: Old decision from phase 1',
|
|
'- [Phase 2]: Old decision from phase 2',
|
|
'- [Phase 5]: Recent decision',
|
|
'- [Phase 6]: Current decision',
|
|
'',
|
|
].join('\n'));
|
|
|
|
writeRoadmapForAutoPrune(tmpDir, [
|
|
'# Roadmap',
|
|
'',
|
|
'## Phase 6: Test Phase',
|
|
'',
|
|
'**Plans:** 0/2',
|
|
'',
|
|
].join('\n'));
|
|
|
|
setupPhaseForAutoPrune(tmpDir, 6, 2);
|
|
|
|
const result = runGsdTools('phase complete 6', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const newState = readStateMdForAutoPrune(tmpDir);
|
|
assert.doesNotMatch(newState, /\[Phase 1\]: Old decision/);
|
|
assert.doesNotMatch(newState, /\[Phase 2\]: Old decision/);
|
|
assert.match(newState, /\[Phase 5\]: Recent decision/);
|
|
assert.match(newState, /\[Phase 6\]: Current decision/);
|
|
});
|
|
|
|
test('does NOT prune when auto_prune_state is false (default)', () => {
|
|
writeConfigForAutoPrune(tmpDir, {
|
|
workflow: { auto_prune_state: false },
|
|
});
|
|
|
|
writeStateMdForAutoPrune(tmpDir, [
|
|
'# Session State',
|
|
'',
|
|
'**Current Phase:** 6',
|
|
'**Status:** Executing',
|
|
'',
|
|
'## Decisions',
|
|
'',
|
|
'- [Phase 1]: Old decision from phase 1',
|
|
'- [Phase 5]: Recent decision',
|
|
'- [Phase 6]: Current decision',
|
|
'',
|
|
].join('\n'));
|
|
|
|
writeRoadmapForAutoPrune(tmpDir, [
|
|
'# Roadmap',
|
|
'',
|
|
'## Phase 6: Test Phase',
|
|
'',
|
|
'**Plans:** 0/2',
|
|
'',
|
|
].join('\n'));
|
|
|
|
setupPhaseForAutoPrune(tmpDir, 6, 2);
|
|
|
|
const result = runGsdTools('phase complete 6', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const newState = readStateMdForAutoPrune(tmpDir);
|
|
assert.match(newState, /\[Phase 1\]: Old decision/);
|
|
});
|
|
|
|
test('does NOT prune when auto_prune_state is absent from config', () => {
|
|
writeConfigForAutoPrune(tmpDir, {
|
|
workflow: {},
|
|
});
|
|
|
|
writeStateMdForAutoPrune(tmpDir, [
|
|
'# Session State',
|
|
'',
|
|
'**Current Phase:** 6',
|
|
'**Status:** Executing',
|
|
'',
|
|
'## Decisions',
|
|
'',
|
|
'- [Phase 1]: Old decision from phase 1',
|
|
'- [Phase 6]: Current decision',
|
|
'',
|
|
].join('\n'));
|
|
|
|
writeRoadmapForAutoPrune(tmpDir, [
|
|
'# Roadmap',
|
|
'',
|
|
'## Phase 6: Test Phase',
|
|
'',
|
|
'**Plans:** 0/2',
|
|
'',
|
|
].join('\n'));
|
|
|
|
setupPhaseForAutoPrune(tmpDir, 6, 2);
|
|
|
|
const result = runGsdTools('phase complete 6', tmpDir);
|
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
|
|
|
const newState = readStateMdForAutoPrune(tmpDir);
|
|
assert.match(newState, /\[Phase 1\]: Old decision/);
|
|
});
|
|
});
|
|
}
|