diff --git a/.changeset/eager-tunas-hum.md b/.changeset/eager-tunas-hum.md new file mode 100644 index 000000000..b8367aee6 --- /dev/null +++ b/.changeset/eager-tunas-hum.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4542 +--- +**The catastrophic-shrink write-guard now protects workstream- and project-scoped planning files** — `hooks/gsd-write-guard.js`'s curated-file patterns only matched root-level `.planning/STATE.md`/`ROADMAP.md`/milestone archives, so a large-shrink Write to a workstream-scoped (`.planning/[/]workstreams//...`) or project-only-scoped (`.planning//...`) copy of the same files was never blocked. Found while fixing #4455's workstream-scoped path resolution, which makes such writes reachable via `/gsd-complete-milestone`'s own instructions. diff --git a/.changeset/witty-orcas-jump.md b/.changeset/witty-orcas-jump.md new file mode 100644 index 000000000..9add0595f --- /dev/null +++ b/.changeset/witty-orcas-jump.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4542 +--- +**`/gsd-autonomous` and `/gsd-complete-milestone` now correctly scope STATE/ROADMAP/MILESTONES/PROJECT/REQUIREMENTS reads and writes to the active workstream** — with `GSD_WORKSTREAM` set, these two workflows previously still read and wrote the root `.planning/` copies of these files instead of the selected workstream's own files, silently ignoring or corrupting the wrong scope's planning state (and, for `/gsd-complete-milestone`'s safety commit, silently missing the actual files just archived). `todos` remains the one deliberately shared, root-scoped exception (#4256). diff --git a/gsd-core/workflows/autonomous.md b/gsd-core/workflows/autonomous.md index 4639a0004..3c6b788e3 100644 --- a/gsd-core/workflows/autonomous.md +++ b/gsd-core/workflows/autonomous.md @@ -135,7 +135,9 @@ Run phase discovery: ```bash INIT_MANAGER=$(gsd_run query init.manager) if [[ "$INIT_MANAGER" == @file:* ]]; then INIT_MANAGER=$(cat "${INIT_MANAGER#@file:}"); fi -STATE_CONTENT=$(cat .planning/STATE.md 2>/dev/null || true) +_gsd_field() { node -e "const o=JSON.parse(process.argv[1]); const v=o[process.argv[2]]; process.stdout.write(v==null?'':String(v))" "$1" "$2"; } +STATE_PATH=$(_gsd_field "$INIT_MANAGER" state_path) +STATE_CONTENT=$(cat "$STATE_PATH" 2>/dev/null || true) ``` Parse the JSON `phases` array. @@ -600,22 +602,19 @@ Read and execute: `$HOME/.claude/gsd-core/references/autonomous-smart-discuss.md Proceed to lifecycle step (partial completion skips audit/complete/cleanup). Exit cleanly. -**Otherwise:** After each phase, re-read manager projection: +**Otherwise:** After each phase, re-read manager projection, then read STATE.md fresh (same fence — a single `gsd_run query init.manager` fetch backs both the JSON re-filter below and the raw re-read, no double-fetch): ```bash INIT_MANAGER=$(gsd_run query init.manager) if [[ "$INIT_MANAGER" == @file:* ]]; then INIT_MANAGER=$(cat "${INIT_MANAGER#@file:}"); fi -STATE_CONTENT=$(cat .planning/STATE.md 2>/dev/null || true) +_gsd_field() { node -e "const o=JSON.parse(process.argv[1]); const v=o[process.argv[2]]; process.stdout.write(v==null?'':String(v))" "$1" "$2"; } +STATE_PATH=$(_gsd_field "$INIT_MANAGER" state_path) +STATE_CONTENT=$(cat "$STATE_PATH" 2>/dev/null || true) +cat "$STATE_PATH" ``` Re-filter incomplete phases using discover_phases logic: keep phases where `phase_complete !== true` or `verification_status !== "passed"`, drop deferred phases from the autonomous queue, re-apply `--from` / `--to`, then sort by number ascending. -Read STATE.md fresh: - -```bash -cat .planning/STATE.md -``` - Check for blockers in the Blockers/Concerns section. If blockers are found, go to handle_blocker with the blocker description. If incomplete phases remain: proceed to next phase, loop back to execute_phase. @@ -727,7 +726,11 @@ Skill(skill="gsd-complete-milestone", args="${milestone_version}") After complete-milestone returns, verify it produced output: ```bash -ls .planning/milestones/v${milestone_version}-ROADMAP.md 2>/dev/null || true +INIT_MANAGER=$(gsd_run query init.manager) +if [[ "$INIT_MANAGER" == @file:* ]]; then INIT_MANAGER=$(cat "${INIT_MANAGER#@file:}"); fi +_gsd_field() { node -e "const o=JSON.parse(process.argv[1]); const v=o[process.argv[2]]; process.stdout.write(v==null?'':String(v))" "$1" "$2"; } +ARCHIVE_DIR=$(_gsd_field "$INIT_MANAGER" archive_dir) +ls "${ARCHIVE_DIR}/v${milestone_version}-ROADMAP.md" 2>/dev/null || true ``` If the archive file does not exist, go to handle_blocker: "Complete milestone did not produce expected archive files." diff --git a/gsd-core/workflows/complete-milestone.md b/gsd-core/workflows/complete-milestone.md index daf9735e3..a68b769e7 100644 --- a/gsd-core/workflows/complete-milestone.md +++ b/gsd-core/workflows/complete-milestone.md @@ -452,8 +452,12 @@ After `milestone complete` has archived, reorganize ROADMAP.md with milestone gr Extract the Backlog section from the current ROADMAP.md before making any changes: ```bash +INIT_REORG=$(gsd_run query init.complete-milestone) +if [[ "$INIT_REORG" == @file:* ]]; then INIT_REORG=$(cat "${INIT_REORG#@file:}"); fi +_gsd_field() { node -e "const o=JSON.parse(process.argv[1]); const v=o[process.argv[2]]; process.stdout.write(v==null?'':String(v))" "$1" "$2"; } +ROADMAP_PATH=$(_gsd_field "$INIT_REORG" roadmap_path) # Extract lines under ## Backlog through end of file (or next ## section) -BACKLOG_SECTION=$(awk '/^## Backlog/{found=1} found{print}' .planning/ROADMAP.md) +BACKLOG_SECTION=$(awk '/^## Backlog/{found=1} found{print}' "$ROADMAP_PATH") ``` If `$BACKLOG_SECTION` is empty, there is no Backlog section — skip silently. @@ -465,10 +469,15 @@ This rewrite is an *intentional* catastrophic shrink: phase detail was just arch 1. Arm the sentinel (single-use; the guard checks it is fresh — within 15 minutes — and names exactly this file, then consumes it): ```bash -printf '.planning/ROADMAP.md\n' > .planning/.gsd-allow-shrink +INIT_REORG=$(gsd_run query init.complete-milestone) +if [[ "$INIT_REORG" == @file:* ]]; then INIT_REORG=$(cat "${INIT_REORG#@file:}"); fi +_gsd_field() { node -e "const o=JSON.parse(process.argv[1]); const v=o[process.argv[2]]; process.stdout.write(v==null?'':String(v))" "$1" "$2"; } +ROADMAP_PATH=$(_gsd_field "$INIT_REORG" roadmap_path) +printf '%s\n' "$ROADMAP_PATH" > .planning/.gsd-allow-shrink +echo "Write target: $ROADMAP_PATH" ``` -2. Compose the full new ROADMAP.md content (template below) and overwrite `.planning/ROADMAP.md` with the **Write tool** — the normal path. The guard allows this one shrink and deletes the sentinel. If the Write is blocked anyway, the sentinel was stale or consumed — re-run the `printf` and retry the Write. +2. Compose the full new ROADMAP.md content (template below) and overwrite the file at **`$ROADMAP_PATH`** (the "Write target" path printed above — under an active workstream this is the workstream-scoped roadmap, NOT the literal `.planning/ROADMAP.md`) with the **Write tool** — the normal path. The guard allows this one shrink and deletes the sentinel. If the Write is blocked anyway, the sentinel was stale or consumed — re-run the `printf` and retry the Write. Template for the composed content: @@ -498,15 +507,29 @@ Append the extracted Backlog content verbatim to the end of the newly written RO **Safety commit — commit archive files BEFORE deleting any originals:** ```bash -gsd_run query commit "chore: archive v[X.Y] milestone files" --files .planning/milestones/v[X.Y]-ROADMAP.md .planning/milestones/v[X.Y]-REQUIREMENTS.md .planning/milestones/v[X.Y]-MILESTONE-AUDIT.md .planning/MILESTONES.md .planning/PROJECT.md .planning/STATE.md .planning/ROADMAP.md +INIT_REORG=$(gsd_run query init.complete-milestone) +if [[ "$INIT_REORG" == @file:* ]]; then INIT_REORG=$(cat "${INIT_REORG#@file:}"); fi +_gsd_field() { node -e "const o=JSON.parse(process.argv[1]); const v=o[process.argv[2]]; process.stdout.write(v==null?'':String(v))" "$1" "$2"; } +STATE_PATH=$(_gsd_field "$INIT_REORG" state_path) +ROADMAP_PATH=$(_gsd_field "$INIT_REORG" roadmap_path) +ARCHIVE_DIR=$(_gsd_field "$INIT_REORG" archive_dir) +MILESTONES_PATH=$(_gsd_field "$INIT_REORG" milestones_path) +PROJECT_PATH=$(_gsd_field "$INIT_REORG" project_path) +gsd_run query commit "chore: archive v[X.Y] milestone files" --files "${ARCHIVE_DIR}/v[X.Y]-ROADMAP.md" "${ARCHIVE_DIR}/v[X.Y]-REQUIREMENTS.md" "${ARCHIVE_DIR}/v[X.Y]-MILESTONE-AUDIT.md" "$MILESTONES_PATH" "$PROJECT_PATH" "$STATE_PATH" "$ROADMAP_PATH" ``` This creates a durable checkpoint in git history. If anything fails after this point, the working tree can be reconstructed from git. +MILESTONES.md and PROJECT.md are workstream-scoped the same way STATE.md/ROADMAP.md are (`planningPaths(cwd).planning`/`.project`) — under an active workstream this commits the actual files `milestone complete` wrote, not the root copies. + **Remove REQUIREMENTS.md via git rm** (preserves history, stages deletion atomically): ```bash -git rm .planning/REQUIREMENTS.md +INIT_REORG=$(gsd_run query init.complete-milestone) +if [[ "$INIT_REORG" == @file:* ]]; then INIT_REORG=$(cat "${INIT_REORG#@file:}"); fi +_gsd_field() { node -e "const o=JSON.parse(process.argv[1]); const v=o[process.argv[2]]; process.stdout.write(v==null?'':String(v))" "$1" "$2"; } +REQUIREMENTS_PATH=$(_gsd_field "$INIT_REORG" requirements_path) +git rm "$REQUIREMENTS_PATH" ``` diff --git a/hooks/gsd-write-guard.js b/hooks/gsd-write-guard.js index b85bc734e..afd92fea3 100644 --- a/hooks/gsd-write-guard.js +++ b/hooks/gsd-write-guard.js @@ -105,10 +105,37 @@ const FLOOR_LINES = 40; // default to, a differently-cased path is the SAME real file — a Write to // '.planning/roadmap.md' clobbers ROADMAP.md while a case-sensitive match // waves it through. +// #4455: workstream-scoped (and optionally project-scoped) variants — +// planningDir(cwd) (src/planning-workspace.cts) resolves to +// `.planning/[/]workstreams//...` whenever GSD_WORKSTREAM is +// set. Before this, none of these three root-only patterns matched a +// workstream-scoped target at all, so the ENTIRE guard (not just the +// sentinel step — the shrink-ratio check too) silently never engaged for a +// workstream-scoped ROADMAP.md/STATE.md/milestone-archive Write: exactly +// the catastrophic-shrink scenario this file exists to stop, unguarded +// under an active workstream. consumeSentinelFor's own `.planning` +// derivation below is unaffected by this addition — it locates the single +// outer `.planning` segment regardless of what's nested inside it, which is +// also where the workflow's sentinel `printf` already writes, so no change +// was needed there. +// +// Same gap exists one level up: planningDir(cwd) ALSO resolves to +// `.planning//...` when GSD_PROJECT is set with NO GSD_WORKSTREAM +// (project-only mode — the two env vars are independent; see planningDir's +// own body). None of the patterns above cover that shape either. Found +// during #4455's own review pass (same root cause, one more path variant) +// — fixed in the same change rather than deferred, since it is the +// identical defect class this PR already exists to close. const CURATED_PATTERNS = [ /(?:^|\/)\.planning\/ROADMAP\.md$/i, /(?:^|\/)\.planning\/STATE\.md$/i, /(?:^|\/)\.planning\/milestones\/[^/]+-ROADMAP\.md$/i, + /(?:^|\/)\.planning\/(?:[^/]+\/)?workstreams\/[^/]+\/ROADMAP\.md$/i, + /(?:^|\/)\.planning\/(?:[^/]+\/)?workstreams\/[^/]+\/STATE\.md$/i, + /(?:^|\/)\.planning\/(?:[^/]+\/)?workstreams\/[^/]+\/milestones\/[^/]+-ROADMAP\.md$/i, + /(?:^|\/)\.planning\/[^/]+\/ROADMAP\.md$/i, + /(?:^|\/)\.planning\/[^/]+\/STATE\.md$/i, + /(?:^|\/)\.planning\/[^/]+\/milestones\/[^/]+-ROADMAP\.md$/i, ]; // Count logical lines, ignoring a single trailing newline so that @@ -156,7 +183,25 @@ function consumeSentinelFor(filePath, normalized) { // Path-bound: the token names exactly one file, resolved against the // .planning/ dir's parent (repo root) — same case-insensitive stance as // the curated match itself. - const namedNorm = path.resolve(path.join(planningDir, '..'), token).replace(/\\/g, '/').toLowerCase(); + let namedPath = path.resolve(path.join(planningDir, '..'), token); + // Symmetry with the caller's own resolution (#4455 CI finding, macOS + // full-test shard): `filePath`/`normalized` were already realpath-resolved + // before this function was called (round 9 Minor 1's symlink-before-match + // fix), but `token` — typically an already-absolute path composed by the + // workflow's own init.* fields — was compared WITHOUT that same + // resolution. Wherever cwd sits under a symlink (macOS's /var -> + // /private/var is the common case, since that's exactly what os.tmpdir() + // resolves through, but any symlinked project/worktree checkout hits the + // same asymmetry), the token names the lexical path while `normalized` + // names the realpath — a validly-armed sentinel then never matches, and a + // legitimate milestone-reset Write stays incorrectly blocked. The named + // file is already known to exist (the caller only reaches this function + // after successfully reading it), so realpath is expected to succeed; + // keep the lexical path on failure, matching the caller's own fallback. + try { + namedPath = fs.realpathSync(namedPath); + } catch { /* keep the lexical path */ } + const namedNorm = namedPath.replace(/\\/g, '/').toLowerCase(); if (namedNorm !== normalized.toLowerCase()) { return false; // armed for a different file — leave it for that write } diff --git a/src/init.cts b/src/init.cts index 81b167380..f6b9ee26f 100644 --- a/src/init.cts +++ b/src/init.cts @@ -3043,6 +3043,16 @@ function cmdInitManager(cwd: string, raw: boolean): void { roadmap_exists: true, state_exists: true, manager_flags: managerFlags, + // #4455: workstream-scoped STATE/ROADMAP/milestone-archive paths — same + // pattern cmdInitPlanPhase already uses (existence-checked, toPosixPath'd, + // null when absent) plus the archive dir composition milestone.cts's + // `cmdMilestoneComplete` uses (#1911: planningPaths(cwd).planning + + // 'milestones', workstream-aware). autonomous.md's discover_phases/ + // iterate/lifecycle steps consume these instead of hardcoding + // `.planning/STATE.md` / `.planning/milestones/...`. + state_path: fs.existsSync(paths.state) ? toPosixPath(paths.state) : null, + roadmap_path: fs.existsSync(paths.roadmap) ? toPosixPath(paths.roadmap) : null, + archive_dir: toPosixPath(path.join(paths.planning, 'milestones')), }; output(withProjectRoot(cwd, result), raw); @@ -3068,10 +3078,39 @@ function cmdInitCompleteMilestone( ): void { const gitCreateTag = detectGitCreateTag(cwd); + // #4455: workstream-scoped STATE/ROADMAP/milestone-archive paths for + // complete-milestone.md's reorganize_roadmap_and_delete_originals step — + // same pattern cmdInitPlanPhase already uses, mirrored here since this is + // that workflow's own dedicated init entry point. + const planningBase = planningDir(cwd); + const statePath = path.join(planningBase, 'STATE.md'); + const roadmapPath = path.join(planningBase, 'ROADMAP.md'); + const archiveDir = path.join(planningBase, 'milestones'); + // #4455 follow-up (code-review finding): MILESTONES.md and PROJECT.md are + // workstream-scoped too — cmdMilestoneComplete (src/milestone.cts) writes + // MILESTONES.md via planningPaths(cwd).planning (the workstream base, not + // root), and planningPaths().project resolves PROJECT.md the same way. + // Neither is the deliberately-root-scoped exception `todos` is (#4256) — + // an earlier version of this fix wrongly treated both as shared root + // files, which would have made the safety commit below silently miss the + // actual files milestone.complete just wrote under an active workstream. + const milestonesPath = path.join(planningBase, 'MILESTONES.md'); + const projectPath = path.join(planningBase, 'PROJECT.md'); + // REQUIREMENTS.md is workstream-scoped the same way (planningPaths(cwd).requirements, + // src/planning-workspace.cts) — the git-rm-after-archive step needs the + // resolved path too, not the literal root file. + const requirementsPath = path.join(planningBase, 'REQUIREMENTS.md'); + const result: Record = { // #2994: hoisted from complete-milestone.md's git_tag step // resolver (git.create_tag, fail-open default true). git_create_tag: gitCreateTag, + state_path: fs.existsSync(statePath) ? toPosixPath(statePath) : null, + roadmap_path: fs.existsSync(roadmapPath) ? toPosixPath(roadmapPath) : null, + archive_dir: toPosixPath(archiveDir), + milestones_path: fs.existsSync(milestonesPath) ? toPosixPath(milestonesPath) : null, + project_path: fs.existsSync(projectPath) ? toPosixPath(projectPath) : null, + requirements_path: fs.existsSync(requirementsPath) ? toPosixPath(requirementsPath) : null, }; result['section_manifest'] = buildSectionManifestField(cwd, null, options, 'complete-milestone', { diff --git a/tests/autonomous-converge.test.cjs b/tests/autonomous-converge.test.cjs index 889729bf5..c06d66e4b 100644 --- a/tests/autonomous-converge.test.cjs +++ b/tests/autonomous-converge.test.cjs @@ -270,7 +270,19 @@ describe('autonomous verification deferral contract', () => { ); assert.match(discoverStep, /phase_complete !== true/); assert.match(discoverStep, /verification_status !== "passed"/); - assert.match(discoverStep, /STATE_CONTENT=\$\(cat \.planning\/STATE\.md 2>\/dev\/null \|\| true\)/); + // #4455: STATE.md is read through the workstream-resolved path from + // init.manager (state_path), not a hardcoded .planning/STATE.md literal — + // a GSD_WORKSTREAM run must read its own workstream's STATE.md. + assert.ok( + discoverStep.includes('STATE_PATH=$(_gsd_field "$INIT_MANAGER" state_path)'), + 'autonomous discovery must resolve STATE.md through init.manager, not a hardcoded path', + ); + assert.match(discoverStep, /STATE_CONTENT=\$\(cat "\$STATE_PATH" 2>\/dev\/null \|\| true\)/); + assert.doesNotMatch( + discoverStep, + /STATE_CONTENT=\$\(cat \.planning\/STATE\.md 2>\/dev\/null \|\| true\)/, + 'autonomous discovery must not regress to a hardcoded root .planning/STATE.md read (#4455)', + ); assert.match(discoverStep, /drop any phase whose number appears in the deferred-phase map/); assert.doesNotMatch(discoverStep, /ROADMAP=\$\(gsd_run query roadmap\.analyze\)/); assert.doesNotMatch(discoverStep, /disk_status !== "complete"/); @@ -282,7 +294,16 @@ describe('autonomous verification deferral contract', () => { ); assert.match(iterateStep, /phase_complete !== true/); assert.match(iterateStep, /verification_status !== "passed"/); - assert.match(iterateStep, /STATE_CONTENT=\$\(cat \.planning\/STATE\.md 2>\/dev\/null \|\| true\)/); + assert.ok( + iterateStep.includes('STATE_PATH=$(_gsd_field "$INIT_MANAGER" state_path)'), + 'autonomous iteration must resolve STATE.md through init.manager, not a hardcoded path', + ); + assert.match(iterateStep, /STATE_CONTENT=\$\(cat "\$STATE_PATH" 2>\/dev\/null \|\| true\)/); + assert.doesNotMatch( + iterateStep, + /STATE_CONTENT=\$\(cat \.planning\/STATE\.md 2>\/dev\/null \|\| true\)/, + 'autonomous iteration must not regress to a hardcoded root .planning/STATE.md read (#4455)', + ); assert.match(iterateStep, /drop deferred phases from the autonomous queue/); }); }); diff --git a/tests/fixtures/compact-content-benchmark-baseline.json b/tests/fixtures/compact-content-benchmark-baseline.json index ef7d6483b..b75f9be26 100644 --- a/tests/fixtures/compact-content-benchmark-baseline.json +++ b/tests/fixtures/compact-content-benchmark-baseline.json @@ -8,9 +8,9 @@ "label": "PROXY-TOKENIZER DELTA — gpt-tokenizer is a stand-in; Anthropic publishes no tokenizer for Claude 3+. The on/off COMPARISON is exact under this pinned tokenizer; absolute counts are not Claude's real token counts.", "splits": { "complete-milestone": { - "offTokens": 12600, - "onTokens": 7760, - "reductionPct": 38.41 + "offTokens": 13199, + "onTokens": 8359, + "reductionPct": 36.67 }, "docs-update": { "offTokens": 14231, @@ -39,8 +39,8 @@ } }, "aggregate": { - "offTokens": 106308, - "onTokens": 89660, - "reductionPct": 15.66 + "offTokens": 106907, + "onTokens": 90259, + "reductionPct": 15.57 } } diff --git a/tests/gsd-write-guard.test.cjs b/tests/gsd-write-guard.test.cjs index b0dc0a009..c555d26e8 100644 --- a/tests/gsd-write-guard.test.cjs +++ b/tests/gsd-write-guard.test.cjs @@ -25,6 +25,7 @@ const { describe, test, before, after } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); +const os = require('node:os'); const path = require('node:path'); const { createTempDir, cleanup } = require('./helpers.cjs'); const { runHook: runHookSeam } = require('./helpers/process-seam.cjs'); @@ -540,6 +541,187 @@ describe('guard <-> complete-milestone workflow binding (the escape hatch is WIR }); }); +describe('workstream-scoped curated paths (#4455)', () => { + // Security-review finding on #4455: planningDir(cwd) (src/planning-workspace.cts) + // resolves to `.planning/[/]workstreams//...` whenever GSD_WORKSTREAM + // is set — but CURATED_PATTERNS only matched the root form, so the guard's ENTIRE + // catastrophic-shrink protection (not just the sentinel step — the ratio check too) + // silently never engaged for a workstream-scoped ROADMAP.md/STATE.md/milestone + // archive Write. complete-milestone.md's reorganize step explicitly targets a + // resolved (potentially workstream-scoped) $ROADMAP_PATH via the Write tool and + // claims "the guard allows this one shrink" — that claim was FALSE under an active + // workstream, since the guard never recognized the target as curated at all. + let wsProjectDir; + let wsRoadmapPath; + let wsStatePath; + let wsMilestoneArchivePath; + let wsPlanningDir; + + before(() => { + wsProjectDir = createTempDir('gsd-write-guard-ws-'); + wsPlanningDir = path.join(wsProjectDir, '.planning'); + const wsDir = path.join(wsPlanningDir, 'workstreams', 'alpha'); + fs.mkdirSync(path.join(wsDir, 'milestones'), { recursive: true }); + wsRoadmapPath = path.join(wsDir, 'ROADMAP.md'); + wsStatePath = path.join(wsDir, 'STATE.md'); + wsMilestoneArchivePath = path.join(wsDir, 'milestones', 'v1.0-ROADMAP.md'); + }); + + after(() => { + cleanup(wsProjectDir); + }); + + test('a workstream-scoped ROADMAP.md catastrophic shrink is BLOCKED (was silently unguarded before #4455)', () => { + fs.writeFileSync(wsRoadmapPath, lines(292)); + const r = runHook(writePayload(wsRoadmapPath, lines(16), { cwd: wsProjectDir })); + assert.equal(r.status, 2, `expected exit 2 (blocked), got ${r.status}; stdout: ${r.stdout}`); + assert.equal(JSON.parse(r.stdout).decision, 'block'); + }); + + test('a workstream-scoped STATE.md catastrophic shrink is BLOCKED', () => { + fs.writeFileSync(wsStatePath, lines(292)); + const r = runHook(writePayload(wsStatePath, lines(16), { cwd: wsProjectDir })); + assert.equal(r.status, 2, `expected exit 2 (blocked), got ${r.status}; stdout: ${r.stdout}`); + assert.equal(JSON.parse(r.stdout).decision, 'block'); + }); + + test('a workstream-scoped milestone archive ROADMAP catastrophic shrink is BLOCKED', () => { + fs.writeFileSync(wsMilestoneArchivePath, lines(292)); + const r = runHook(writePayload(wsMilestoneArchivePath, lines(16), { cwd: wsProjectDir })); + assert.equal(r.status, 2, `expected exit 2 (blocked), got ${r.status}; stdout: ${r.stdout}`); + assert.equal(JSON.parse(r.stdout).decision, 'block'); + }); + + test('the sentinel hatch (armed at the ROOT .planning/.gsd-allow-shrink, naming the workstream path) unblocks a workstream ROADMAP.md write', () => { + // complete-milestone.md's sentinel fence always writes to the ROOT + // .planning/.gsd-allow-shrink (unchanged by #4455 — consumeSentinelFor + // derives that same root location from the write TARGET's path + // regardless of how deep a workstream target is nested), naming the + // resolved (workstream-scoped) $ROADMAP_PATH as its content. + fs.writeFileSync(wsRoadmapPath, lines(292)); + fs.writeFileSync(path.join(wsPlanningDir, '.gsd-allow-shrink'), `${wsRoadmapPath}\n`); + const r = runHook(writePayload(wsRoadmapPath, lines(16), { cwd: wsProjectDir })); + assert.equal(r.status, 0, + `expected the sentinel to unblock the workstream-scoped write, got status ${r.status}; stdout: ${r.stdout}`); + }); + + test('a non-curated file inside a workstream dir stays exempt (no widening beyond ROADMAP/STATE/milestone-archive)', () => { + const notesPath = path.join(wsPlanningDir, 'workstreams', 'alpha', 'NOTES.md'); + fs.writeFileSync(notesPath, lines(292)); + const r = runHook(writePayload(notesPath, lines(16), { cwd: wsProjectDir })); + assert.equal(r.status, 0, `non-curated workstream file must pass; stdout: ${r.stdout}`); + }); + + test('the sentinel hatch unblocks even when cwd sits under a symlink (macOS full-test CI finding)', + { skip: process.platform === 'win32' ? 'symlink creation needs privilege on Windows' : false }, () => { + // PR CI's macos-latest full-test shard caught this for free — os.tmpdir() + // on macOS resolves through a /var -> /private/var symlink, so the + // ABOVE test (identical cwd/target shape) failed there while passing + // everywhere gsd-test's Linux bench runs, where /tmp is not a symlink. + // This test reproduces the same asymmetry deterministically on any + // platform via an EXPLICIT symlink, so a regression here is caught by + // gsd-test too, not only by a real macOS CI run. + // + // Root cause: the caller (guard's main flow) realpath-resolves the + // WRITE TARGET before the curated match (round 9 Minor 1), but + // consumeSentinelFor compared the sentinel TOKEN's resolved path + // without the same realpath step — an armed, correct sentinel then + // never matched whenever cwd traversed a symlink. + const realBase = createTempDir('gsd-write-guard-symlink-real-'); + const linkedProjectDir = path.join(os.tmpdir(), `gsd-write-guard-symlink-link-${process.pid}-${Date.now()}`); + fs.symlinkSync(realBase, linkedProjectDir, 'dir'); + try { + const linkedPlanningDir = path.join(linkedProjectDir, '.planning'); + const linkedWsDir = path.join(linkedPlanningDir, 'workstreams', 'alpha'); + fs.mkdirSync(linkedWsDir, { recursive: true }); + const linkedRoadmapPath = path.join(linkedWsDir, 'ROADMAP.md'); + fs.writeFileSync(linkedRoadmapPath, lines(292)); + // Armed with the LEXICAL (through-the-symlink) path, matching exactly + // what the workflow's own $ROADMAP_PATH (from init.complete-milestone, + // never realpath-resolved) would contain. + fs.writeFileSync(path.join(linkedPlanningDir, '.gsd-allow-shrink'), `${linkedRoadmapPath}\n`); + const r = runHook(writePayload(linkedRoadmapPath, lines(16), { cwd: linkedProjectDir })); + assert.equal(r.status, 0, + `expected the sentinel to unblock through the symlinked cwd, got status ${r.status}; stdout: ${r.stdout}`); + } finally { + cleanup(linkedProjectDir); + cleanup(realBase); + } + }); +}); + +describe('project-only-scoped curated paths (#4455 follow-up)', () => { + // planningDir(cwd) ALSO resolves to `.planning//...` when + // GSD_PROJECT is set with NO GSD_WORKSTREAM — an independent dimension + // from the workstream nesting covered above. Found during this same PR's + // own review pass (identical root cause, one more path-shape variant) and + // fixed in the same change per this repo's no-deferral policy rather than + // left open as a "pre-existing, out of scope" gap. + let projProjectDir; + let projPlanningDir; + let projRoadmapPath; + let projStatePath; + let projMilestoneArchivePath; + + before(() => { + projProjectDir = createTempDir('gsd-write-guard-proj-'); + projPlanningDir = path.join(projProjectDir, '.planning'); + const projDir = path.join(projPlanningDir, 'myproject'); + fs.mkdirSync(path.join(projDir, 'milestones'), { recursive: true }); + projRoadmapPath = path.join(projDir, 'ROADMAP.md'); + projStatePath = path.join(projDir, 'STATE.md'); + projMilestoneArchivePath = path.join(projDir, 'milestones', 'v1.0-ROADMAP.md'); + }); + + after(() => { + cleanup(projProjectDir); + }); + + test('a project-scoped ROADMAP.md catastrophic shrink is BLOCKED (was silently unguarded before this fix)', () => { + fs.writeFileSync(projRoadmapPath, lines(292)); + const r = runHook(writePayload(projRoadmapPath, lines(16), { cwd: projProjectDir })); + assert.equal(r.status, 2, `expected exit 2 (blocked), got ${r.status}; stdout: ${r.stdout}`); + assert.equal(JSON.parse(r.stdout).decision, 'block'); + }); + + test('a project-scoped STATE.md catastrophic shrink is BLOCKED', () => { + fs.writeFileSync(projStatePath, lines(292)); + const r = runHook(writePayload(projStatePath, lines(16), { cwd: projProjectDir })); + assert.equal(r.status, 2, `expected exit 2 (blocked), got ${r.status}; stdout: ${r.stdout}`); + assert.equal(JSON.parse(r.stdout).decision, 'block'); + }); + + test('a project-scoped milestone archive ROADMAP catastrophic shrink is BLOCKED', () => { + fs.writeFileSync(projMilestoneArchivePath, lines(292)); + const r = runHook(writePayload(projMilestoneArchivePath, lines(16), { cwd: projProjectDir })); + assert.equal(r.status, 2, `expected exit 2 (blocked), got ${r.status}; stdout: ${r.stdout}`); + assert.equal(JSON.parse(r.stdout).decision, 'block'); + }); + + test('a non-curated file inside a project dir stays exempt', () => { + const notesPath = path.join(projPlanningDir, 'myproject', 'NOTES.md'); + fs.writeFileSync(notesPath, lines(292)); + const r = runHook(writePayload(notesPath, lines(16), { cwd: projProjectDir })); + assert.equal(r.status, 0, `non-curated project-scoped file must pass; stdout: ${r.stdout}`); + }); + + test('a project-scoped path is not accidentally matched by the workstream patterns (regex specificity check)', () => { + // Guards against a regression where the workstream patterns' optional + // `(?:[^/]+\/)?` project prefix is loosened enough to also swallow this + // shape by accident — asserting BOTH describe blocks' patterns exist + // independently, not that this one only passes via the other's regex. + const wsShapeAtProjectPath = path.join(projPlanningDir, 'myproject', 'workstreams'); + fs.mkdirSync(wsShapeAtProjectPath, { recursive: true }); + // Sanity: the project-scoped ROADMAP.md itself (not inside `workstreams/`) + // must still be curated — already proven above; this test only confirms + // the directory literally named "workstreams" existing alongside it + // doesn't change that outcome. + fs.writeFileSync(projRoadmapPath, lines(292)); + const r = runHook(writePayload(projRoadmapPath, lines(16), { cwd: projProjectDir })); + assert.equal(r.status, 2, `project-scoped ROADMAP.md must stay blocked; stdout: ${r.stdout}`); + }); +}); + describe('the shipped claim matches the shipped guarantee (round 10 Major 2)', () => { // The guard's reach is bounded: the sentinel is a plain file, so an agent // that would reason past an advisory can arm one with a single Bash call. diff --git a/tests/init-manager.test.cjs b/tests/init-manager.test.cjs index ff0e0f14a..2cd31f163 100644 --- a/tests/init-manager.test.cjs +++ b/tests/init-manager.test.cjs @@ -6,7 +6,8 @@ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); -const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); +const { runGsdTools, createTempProject, cleanup, absPlanningPath } = require('./helpers.cjs'); +const { seedWorkstream } = require('./fixtures/index.cjs'); // Helper: write a minimal ROADMAP.md with phases function writeRoadmap(tmpDir, phases) { @@ -918,6 +919,118 @@ describe('init manager — cross-milestone dependency satisfaction (#2267)', () }); }); +// ───────────────────────────────────────────────────────────────────────────── +// #4455 — init manager emits workstream-scoped state_path/roadmap_path/ +// archive_dir (same pattern cmdInitPlanPhase already uses, plus the +// milestone.cts archive-dir composition, #1911). autonomous.md's +// discover_phases/iterate/lifecycle steps consume these instead of +// hardcoding `.planning/STATE.md` / `.planning/milestones/...`. +// ───────────────────────────────────────────────────────────────────────────── + +describe('init manager — state_path/roadmap_path/archive_dir (#4455)', () => { + let tmpDir; + + beforeEach(() => { + // macOS: realpath before absPlanningPath comparisons (symlinked /var/tmp). + tmpDir = require('fs').realpathSync(createTempProject()); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('flat mode (no GSD_WORKSTREAM): paths resolve to root .planning (regression guard)', () => { + writeState(tmpDir); + writeRoadmap(tmpDir, [{ number: '1', name: 'Setup' }]); + + const result = runGsdTools('init manager', tmpDir, { GSD_WORKSTREAM: '' }); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + + assert.strictEqual(output.state_path, absPlanningPath(tmpDir, 'STATE.md')); + assert.strictEqual(output.roadmap_path, absPlanningPath(tmpDir, 'ROADMAP.md')); + assert.strictEqual(output.archive_dir, absPlanningPath(tmpDir, 'milestones')); + }); + + test('GSD_WORKSTREAM=alpha: paths resolve into the workstream, not root (#4455 regression)', () => { + seedWorkstream(tmpDir, { + name: 'alpha', + state: '---\nstatus: active\n---\n# State\n', + roadmap: '# Roadmap\n\n## Progress\n\n- [ ] **Phase 1: Setup**\n\n### Phase 1: Setup\n\n**Goal:** Bootstrap\n', + }); + + const result = runGsdTools('init manager', tmpDir, { GSD_WORKSTREAM: 'alpha' }); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + + assert.strictEqual(output.state_path, absPlanningPath(tmpDir, 'workstreams', 'alpha', 'STATE.md')); + assert.strictEqual(output.roadmap_path, absPlanningPath(tmpDir, 'workstreams', 'alpha', 'ROADMAP.md')); + assert.strictEqual(output.archive_dir, absPlanningPath(tmpDir, 'workstreams', 'alpha', 'milestones')); + // Goodhart both-directions: must NOT be the flat root form. + assert.notStrictEqual(output.state_path, absPlanningPath(tmpDir, 'STATE.md')); + assert.notStrictEqual(output.roadmap_path, absPlanningPath(tmpDir, 'ROADMAP.md')); + assert.notStrictEqual(output.archive_dir, absPlanningPath(tmpDir, 'milestones')); + }); + + test('state_path/roadmap_path are null when the files do not exist yet', () => { + // init manager itself requires ROADMAP.md/STATE.md to exist to proceed + // past its own readiness guard, so exercise the null branch through + // init complete-milestone instead — same fs.existsSync(...) ? ... : null + // pattern, no upstream guard blocking an empty-fixture run. + const result = runGsdTools('init complete-milestone', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + + assert.strictEqual(output.state_path, null); + assert.strictEqual(output.roadmap_path, null); + assert.strictEqual(output.archive_dir, absPlanningPath(tmpDir, 'milestones')); + }); +}); + +describe('init complete-milestone — state_path/roadmap_path/archive_dir (#4455)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = require('fs').realpathSync(createTempProject()); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('flat mode (no GSD_WORKSTREAM): paths resolve to root .planning (regression guard)', () => { + writeState(tmpDir); + writeRoadmap(tmpDir, [{ number: '1', name: 'Setup' }]); + + const result = runGsdTools('init complete-milestone', tmpDir, { GSD_WORKSTREAM: '' }); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + + assert.strictEqual(output.state_path, absPlanningPath(tmpDir, 'STATE.md')); + assert.strictEqual(output.roadmap_path, absPlanningPath(tmpDir, 'ROADMAP.md')); + assert.strictEqual(output.archive_dir, absPlanningPath(tmpDir, 'milestones')); + }); + + test('GSD_WORKSTREAM=alpha: paths resolve into the workstream, not root (#4455 regression)', () => { + seedWorkstream(tmpDir, { + name: 'alpha', + state: '---\nstatus: active\n---\n# State\n', + roadmap: '# Roadmap\n\n## Progress\n\n- [ ] **Phase 1: Setup**\n\n### Phase 1: Setup\n\n**Goal:** Bootstrap\n', + }); + + const result = runGsdTools('init complete-milestone', tmpDir, { GSD_WORKSTREAM: 'alpha' }); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + + assert.strictEqual(output.state_path, absPlanningPath(tmpDir, 'workstreams', 'alpha', 'STATE.md')); + assert.strictEqual(output.roadmap_path, absPlanningPath(tmpDir, 'workstreams', 'alpha', 'ROADMAP.md')); + assert.strictEqual(output.archive_dir, absPlanningPath(tmpDir, 'workstreams', 'alpha', 'milestones')); + assert.notStrictEqual(output.state_path, absPlanningPath(tmpDir, 'STATE.md')); + assert.notStrictEqual(output.roadmap_path, absPlanningPath(tmpDir, 'ROADMAP.md')); + assert.notStrictEqual(output.archive_dir, absPlanningPath(tmpDir, 'milestones')); + }); +}); + // ──────────────────────────────────────────────────────────────────────── // Folded from tests/bug-3584-runtime-slash-emitters.test.cjs — consolidation epic #1969 (B2 #1971) diff --git a/tests/workstream-scoped-paths.test.cjs b/tests/workstream-scoped-paths.test.cjs new file mode 100644 index 000000000..419a75daa --- /dev/null +++ b/tests/workstream-scoped-paths.test.cjs @@ -0,0 +1,385 @@ +// allow-test-rule: source-text-is-the-product see #4455 +// Workflow .md files — their text IS what the runtime loads. Testing text +// content (as extracted, executed bash fences) tests the deployed contract. +// Per CONTRIBUTING.md exception matrix. + +/** + * GSD Tools Tests - autonomous.md and complete-milestone.md workstream-scoped + * STATE/ROADMAP/archive paths (#4455) + * + * Both workflows used to read/write hardcoded literal `.planning/STATE.md` / + * `.planning/ROADMAP.md` / `.planning/milestones/...` paths in their shell + * fences — bypassing workstream scoping entirely. When GSD_WORKSTREAM is set, + * `init.manager`/`init.complete-milestone` resolve `state_path`/`roadmap_path`/ + * `archive_dir` into the workstream, but the literal reads/writes still hit + * root `.planning/` (or silently returned empty). + * + * These tests extract the real bash fences from the workflow files and + * execute them (with a stubbed `gsd_run`) rather than string-matching the + * markdown — the same idiom tests/new-milestone-clear-phases.test.cjs uses. + * + * One file (not two) per scripts/lint-test-file-count.cjs's per-module cap — + * tests/workstream.test.cjs already owns the "workstream" module name, so + * this is the second and last slot; the two workflows are kept apart via + * top-level describe blocks instead of separate files. + */ + +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('os'); +const { runHook: runHookSeam } = require('./helpers/process-seam.cjs'); +const { throwIfFailed } = require('./helpers/git-fixture.cjs'); +const { scanFencedBlocks } = require('../gsd-core/bin/lib/markdown-sectionizer.cjs'); +const { cleanup } = require('./helpers.cjs'); + +/** Return the raw text of every ```bash fenced block in `text`. */ +function extractBashBlocks(text) { + const lines = text.split(/\r?\n/); + const blocks = []; + for (const block of scanFencedBlocks(lines)) { + if (block.closeLineIdx === -1) continue; + if ((block.infoString || '').trim() !== 'bash') continue; + blocks.push(lines.slice(block.openLineIdx + 1, block.closeLineIdx).join('\n')); + } + return blocks; +} + +// Locate the first ```bash fence strictly between two boundary strings. +function extractFenceBetween(markdown, startMarker, endMarker) { + const startIdx = markdown.indexOf(startMarker); + const endIdx = markdown.indexOf(endMarker); + assert.ok(startIdx !== -1, `marker not found: ${startMarker}`); + assert.ok(endIdx !== -1, `marker not found: ${endMarker}`); + assert.ok(startIdx < endIdx, `${startMarker} must precede ${endMarker}`); + const section = markdown.slice(startIdx, endIdx); + const bashBlocks = extractBashBlocks(section); + assert.ok(bashBlocks.length > 0, `no bash fence found between "${startMarker}" and "${endMarker}"`); + return bashBlocks[0]; +} + +// Locate the ```bash fence containing `marker`, between two boundary strings. +function extractFenceContaining(markdown, startMarker, endMarker, marker) { + const startIdx = markdown.indexOf(startMarker); + const endIdx = markdown.indexOf(endMarker); + assert.ok(startIdx !== -1 && endIdx !== -1 && startIdx < endIdx, 'boundary markers not found in order'); + const section = markdown.slice(startIdx, endIdx); + for (const block of extractBashBlocks(section)) { + if (block.includes(marker)) return block; + } + assert.fail(`no bash fence containing "${marker}" found between "${startMarker}" and "${endMarker}"`); + return null; +} + +describe('autonomous.md workstream-scoped paths (#4455)', () => { + const WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'autonomous.md'); + const content = fs.readFileSync(WORKFLOW_PATH, 'utf8'); + + const discoverPhasesFence = extractFenceBetween(content, '## 2. Discover Phases', '## 3. Execute Phase'); + const iterateFence = extractFenceBetween(content, '## 4. Iterate', '## 5. Lifecycle'); + const lifecycle5bFence = extractFenceContaining(content, '## 5. Lifecycle', '## 6. Handle Blocker', 'ARCHIVE_DIR'); + + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-autonomous-ws-')); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + /** + * Write a canned init.manager-shaped JSON payload and a gsd_run stub that + * returns it verbatim. Each call also appends one byte to a call-log file + * — NOT a shell variable increment, because `INIT_MANAGER=$(gsd_run ...)` + * runs gsd_run inside the command-substitution SUBSHELL, so a variable + * mutated there never survives back into the caller's shell. + */ + function stubGsdRun(jsonPayload) { + const jsonPath = path.join(tmpDir, 'init-manager.json'); + const callLogPath = path.join(tmpDir, 'gsd-run-calls.log'); + fs.writeFileSync(jsonPath, JSON.stringify(jsonPayload)); + fs.writeFileSync(callLogPath, ''); + return `gsd_run() { printf 'x' >> "${callLogPath}"; cat "${jsonPath}"; }\n`; + } + + /** Number of gsd_run invocations recorded by the most recent stubGsdRun-backed script. */ + function gsdRunCallCount() { + return fs.readFileSync(path.join(tmpDir, 'gsd-run-calls.log'), 'utf8').length; + } + + describe('discover_phases step: reads STATE.md from init.manager state_path, not a hardcoded literal', () => { + test('flat mode: no GSD_WORKSTREAM — resolves the root STATE.md path (regression guard)', () => { + const statePath = path.join(tmpDir, 'STATE-root.md'); + fs.writeFileSync(statePath, '# Root State\n'); + const script = `${stubGsdRun({ state_path: statePath })}${discoverPhasesFence}\nprintf 'STATE_CONTENT=[%s]\\n' "$STATE_CONTENT"`; + const r = runHookSeam('-c', [script], { interpreter: 'bash', cwd: tmpDir }); + throwIfFailed(r, 'bash '); + assert.ok(r.stdout.includes('STATE_CONTENT=[# Root State'), + `expected root STATE.md content, got: ${r.stdout}`); + }); + + test('GSD_WORKSTREAM=alpha: reads the workstream-scoped STATE.md, not the root one (#4455 regression)', () => { + const rootStatePath = path.join(tmpDir, 'STATE-root.md'); + fs.writeFileSync(rootStatePath, '# Root State — must not be read\n'); + const wsStatePath = path.join(tmpDir, 'STATE-alpha.md'); + fs.writeFileSync(wsStatePath, '# Workstream Alpha State\n'); + + const script = `${stubGsdRun({ state_path: wsStatePath })}${discoverPhasesFence}\nprintf 'STATE_CONTENT=[%s]\\n' "$STATE_CONTENT"`; + const r = runHookSeam('-c', [script], { interpreter: 'bash', cwd: tmpDir }); + throwIfFailed(r, 'bash '); + assert.ok(r.stdout.includes('STATE_CONTENT=[# Workstream Alpha State'), + `expected workstream STATE.md content, got: ${r.stdout}`); + assert.ok(!r.stdout.includes('Root State'), + `must not have read the root STATE.md, got: ${r.stdout}`); + }); + }); + + describe('iterate step: single init.manager fetch backs both the re-filter and the fresh re-read', () => { + test('flat mode: resolves the root STATE.md path (regression guard)', () => { + const statePath = path.join(tmpDir, 'STATE-root.md'); + fs.writeFileSync(statePath, '# Root State\n'); + const script = `${stubGsdRun({ state_path: statePath })}${iterateFence}`; + const r = runHookSeam('-c', [script], { interpreter: 'bash', cwd: tmpDir }); + throwIfFailed(r, 'bash '); + // The fence's own `cat "$STATE_PATH"` line prints the raw re-read to stdout. + assert.ok(r.stdout.includes('# Root State'), `expected root STATE.md content, got: ${r.stdout}`); + }); + + test('GSD_WORKSTREAM=alpha: re-reads the workstream STATE.md, not root (#4455 regression)', () => { + const rootStatePath = path.join(tmpDir, 'STATE-root.md'); + fs.writeFileSync(rootStatePath, '# Root State — must not be read\n'); + const wsStatePath = path.join(tmpDir, 'STATE-alpha.md'); + fs.writeFileSync(wsStatePath, '# Workstream Alpha State\n'); + + const script = `${stubGsdRun({ state_path: wsStatePath })}${iterateFence}`; + const r = runHookSeam('-c', [script], { interpreter: 'bash', cwd: tmpDir }); + throwIfFailed(r, 'bash '); + assert.ok(r.stdout.includes('# Workstream Alpha State'), + `expected workstream STATE.md content, got: ${r.stdout}`); + assert.ok(!r.stdout.includes('Root State'), + `must not have read the root STATE.md, got: ${r.stdout}`); + }); + + test('does not double-fetch init.manager within the iterate fence (no-double-fetch requirement)', () => { + const statePath = path.join(tmpDir, 'STATE-root.md'); + fs.writeFileSync(statePath, '# Root State\n'); + const script = `${stubGsdRun({ state_path: statePath })}${iterateFence}`; + const r = runHookSeam('-c', [script], { interpreter: 'bash', cwd: tmpDir }); + throwIfFailed(r, 'bash '); + assert.strictEqual(gsdRunCallCount(), 1, + `iterate fence must call gsd_run exactly once (no double-fetch), got ${gsdRunCallCount()}; stdout: ${r.stdout}`); + }); + }); + + describe('lifecycle step 5b: archive existence check uses init.manager archive_dir, not a hardcoded literal', () => { + test('flat mode: checks the root milestones/ archive dir (regression guard)', () => { + const archiveDir = path.join(tmpDir, 'milestones-root'); + fs.mkdirSync(archiveDir, { recursive: true }); + fs.writeFileSync(path.join(archiveDir, 'v1.0-ROADMAP.md'), '# Archived Roadmap\n'); + + const script = `milestone_version="1.0"\n${stubGsdRun({ archive_dir: archiveDir })}${lifecycle5bFence}`; + const r = runHookSeam('-c', [script], { interpreter: 'bash', cwd: tmpDir }); + throwIfFailed(r, 'bash '); + // The fence concatenates with a literal bash `/` ("${ARCHIVE_DIR}/v...-ROADMAP.md"), + // never path.join — on Windows that yields a MIXED-separator path (backslashes + // from archiveDir + one trailing `/`), which path.join's all-backslash output + // does not match. Mirror the fence's own concatenation instead (#4455 CI finding). + assert.ok(r.stdout.includes(`${archiveDir}/v1.0-ROADMAP.md`), + `expected ls to find the root archive file, got: ${r.stdout}`); + }); + + test('GSD_WORKSTREAM=alpha: checks the workstream-scoped archive dir, not root (#4455 regression)', () => { + const rootArchiveDir = path.join(tmpDir, 'milestones-root'); + fs.mkdirSync(rootArchiveDir, { recursive: true }); + fs.writeFileSync(path.join(rootArchiveDir, 'v1.0-ROADMAP.md'), '# Root Archived Roadmap — must not be found\n'); + + const wsArchiveDir = path.join(tmpDir, 'milestones-alpha'); + fs.mkdirSync(wsArchiveDir, { recursive: true }); + fs.writeFileSync(path.join(wsArchiveDir, 'v1.0-ROADMAP.md'), '# Workstream Archived Roadmap\n'); + + const script = `milestone_version="1.0"\n${stubGsdRun({ archive_dir: wsArchiveDir })}${lifecycle5bFence}`; + const r = runHookSeam('-c', [script], { interpreter: 'bash', cwd: tmpDir }); + throwIfFailed(r, 'bash '); + // See the flat-mode test above for why this is a literal `/` join, not path.join. + assert.ok(r.stdout.includes(`${wsArchiveDir}/v1.0-ROADMAP.md`), + `expected ls to find the workstream archive file, got: ${r.stdout}`); + assert.ok(!r.stdout.includes(rootArchiveDir), + `must not have checked the root archive dir, got: ${r.stdout}`); + }); + }); +}); + +describe('complete-milestone.md workstream-scoped paths (#4455)', () => { + const WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'complete-milestone.md'); + const content = fs.readFileSync(WORKFLOW_PATH, 'utf8'); + + const STEP_START = ''; + const STEP_END = ''; + + const backlogFence = extractFenceContaining(content, STEP_START, STEP_END, 'BACKLOG_SECTION'); + const sentinelFence = extractFenceContaining(content, STEP_START, STEP_END, '.gsd-allow-shrink'); + const commitFilesFence = extractFenceContaining(content, STEP_START, STEP_END, 'gsd_run query commit'); + const requirementsRmFence = extractFenceContaining(content, STEP_START, STEP_END, 'git rm'); + + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-complete-milestone-ws-')); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + /** + * Write a canned init.complete-milestone-shaped JSON payload and a gsd_run + * stub that returns it for the `query init.complete-milestone` fetch, and + * echoes `gsd_run_call:` for every OTHER call (e.g. `query commit + * ...`) — the same recording-stub idiom tests/new-milestone-clear-phases. + * test.cjs uses, so the commit `--files` list can be asserted on directly. + */ + function stubGsdRun(jsonPayload) { + const jsonPath = path.join(tmpDir, 'init-cm.json'); + fs.writeFileSync(jsonPath, JSON.stringify(jsonPayload)); + return `gsd_run() { if [ "$1" = "query" ] && [ "$2" = "init.complete-milestone" ]; then cat "${jsonPath}"; else printf 'gsd_run_call:%s\\n' "$*"; fi; }\n`; + } + + describe('backlog extraction: reads ROADMAP.md from init.complete-milestone roadmap_path', () => { + test('flat mode: no GSD_WORKSTREAM — resolves the root ROADMAP.md path (regression guard)', () => { + const roadmapPath = path.join(tmpDir, 'ROADMAP-root.md'); + fs.writeFileSync(roadmapPath, '# Roadmap\n\n## Backlog\n\n- 999.1 Root backlog item\n'); + const script = `${stubGsdRun({ roadmap_path: roadmapPath })}${backlogFence}\nprintf 'BACKLOG=[%s]\\n' "$BACKLOG_SECTION"`; + const r = runHookSeam('-c', [script], { interpreter: 'bash', cwd: tmpDir }); + throwIfFailed(r, 'bash '); + assert.ok(r.stdout.includes('Root backlog item'), + `expected root ROADMAP.md backlog content, got: ${r.stdout}`); + }); + + test('GSD_WORKSTREAM=alpha: reads the workstream-scoped ROADMAP.md, not root (#4455 regression)', () => { + const rootRoadmapPath = path.join(tmpDir, 'ROADMAP-root.md'); + fs.writeFileSync(rootRoadmapPath, '# Roadmap\n\n## Backlog\n\n- 999.1 Root backlog item — must not be read\n'); + const wsRoadmapPath = path.join(tmpDir, 'ROADMAP-alpha.md'); + fs.writeFileSync(wsRoadmapPath, '# Roadmap\n\n## Backlog\n\n- 999.1 Workstream Alpha backlog item\n'); + + const script = `${stubGsdRun({ roadmap_path: wsRoadmapPath })}${backlogFence}\nprintf 'BACKLOG=[%s]\\n' "$BACKLOG_SECTION"`; + const r = runHookSeam('-c', [script], { interpreter: 'bash', cwd: tmpDir }); + throwIfFailed(r, 'bash '); + assert.ok(r.stdout.includes('Workstream Alpha backlog item'), + `expected workstream ROADMAP.md backlog content, got: ${r.stdout}`); + assert.ok(!r.stdout.includes('Root backlog item'), + `must not have read the root ROADMAP.md, got: ${r.stdout}`); + }); + }); + + describe('write-guard sentinel: arms with init.complete-milestone roadmap_path', () => { + test('flat mode: sentinel names the root ROADMAP.md path (regression guard)', () => { + const roadmapPath = path.join(tmpDir, 'ROADMAP-root.md'); + fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); + const script = `${stubGsdRun({ roadmap_path: roadmapPath })}${sentinelFence}`; + const r = runHookSeam('-c', [script], { interpreter: 'bash', cwd: tmpDir }); + throwIfFailed(r, 'bash '); + const sentinelContent = fs.readFileSync(path.join(tmpDir, '.planning', '.gsd-allow-shrink'), 'utf8').trim(); + assert.strictEqual(sentinelContent, roadmapPath); + }); + + test('GSD_WORKSTREAM=alpha: sentinel names the workstream ROADMAP.md, not root (#4455 regression)', () => { + const wsRoadmapPath = path.join(tmpDir, 'ROADMAP-alpha.md'); + fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); + const script = `${stubGsdRun({ roadmap_path: wsRoadmapPath })}${sentinelFence}`; + const r = runHookSeam('-c', [script], { interpreter: 'bash', cwd: tmpDir }); + throwIfFailed(r, 'bash '); + const sentinelContent = fs.readFileSync(path.join(tmpDir, '.planning', '.gsd-allow-shrink'), 'utf8').trim(); + assert.strictEqual(sentinelContent, wsRoadmapPath); + assert.notStrictEqual(sentinelContent, path.join(tmpDir, '.planning', 'ROADMAP.md')); + }); + }); + + describe('safety commit --files list: STATE/ROADMAP/archive/MILESTONES/PROJECT paths all scoped together', () => { + function runCommitFence(cmJson) { + const script = `${stubGsdRun(cmJson)}${commitFilesFence}`; + const r = runHookSeam('-c', [script], { interpreter: 'bash', cwd: tmpDir }); + throwIfFailed(r, 'bash '); + return r.stdout; + } + + test('flat mode: --files lists root STATE/ROADMAP/archive/MILESTONES/PROJECT paths (regression guard)', () => { + const statePath = path.join(tmpDir, 'STATE-root.md'); + const roadmapPath = path.join(tmpDir, 'ROADMAP-root.md'); + const archiveDir = path.join(tmpDir, 'milestones-root'); + const milestonesPath = path.join(tmpDir, 'MILESTONES-root.md'); + const projectPath = path.join(tmpDir, 'PROJECT-root.md'); + const out = runCommitFence({ + state_path: statePath, roadmap_path: roadmapPath, archive_dir: archiveDir, + milestones_path: milestonesPath, project_path: projectPath, + }); + + assert.ok(out.includes('gsd_run_call:query commit'), `expected the commit call to be recorded, got: ${out}`); + assert.ok(out.includes(statePath), `expected root STATE.md in --files, got: ${out}`); + assert.ok(out.includes(roadmapPath), `expected root ROADMAP.md in --files, got: ${out}`); + assert.ok(out.includes(`${archiveDir}/v[X.Y]-ROADMAP.md`), `expected root archive ROADMAP in --files, got: ${out}`); + assert.ok(out.includes(milestonesPath), `expected root MILESTONES.md in --files, got: ${out}`); + assert.ok(out.includes(projectPath), `expected root PROJECT.md in --files, got: ${out}`); + }); + + test('GSD_WORKSTREAM=alpha: --files lists workstream-scoped STATE/ROADMAP/archive/MILESTONES/PROJECT paths, not root (#4455 regression)', () => { + const wsStatePath = path.join(tmpDir, 'STATE-alpha.md'); + const wsRoadmapPath = path.join(tmpDir, 'ROADMAP-alpha.md'); + const wsArchiveDir = path.join(tmpDir, 'milestones-alpha'); + const wsMilestonesPath = path.join(tmpDir, 'MILESTONES-alpha.md'); + const wsProjectPath = path.join(tmpDir, 'PROJECT-alpha.md'); + const out = runCommitFence({ + state_path: wsStatePath, roadmap_path: wsRoadmapPath, archive_dir: wsArchiveDir, + milestones_path: wsMilestonesPath, project_path: wsProjectPath, + }); + + assert.ok(out.includes(wsStatePath), `expected workstream STATE.md in --files, got: ${out}`); + assert.ok(out.includes(wsRoadmapPath), `expected workstream ROADMAP.md in --files, got: ${out}`); + assert.ok(out.includes(`${wsArchiveDir}/v[X.Y]-ROADMAP.md`), `expected workstream archive ROADMAP in --files, got: ${out}`); + // MILESTONES.md and PROJECT.md are workstream-scoped too — cmdMilestoneComplete + // (src/milestone.cts) writes MILESTONES.md via planningPaths(cwd).planning (the + // workstream base), and PROJECT.md resolves the same way (planningPaths().project). + // An earlier version of this fix wrongly pinned both as shared root files, which + // would have made this safety commit silently miss the actual files + // `milestone complete` just wrote under an active workstream (#4455 follow-up, + // caught by isolated code review). + assert.ok(out.includes(wsMilestonesPath), `expected workstream MILESTONES.md in --files, got: ${out}`); + assert.ok(out.includes(wsProjectPath), `expected workstream PROJECT.md in --files, got: ${out}`); + assert.ok(!out.includes(path.join(tmpDir, '.planning', 'STATE.md')), + `must not fall back to the flat root STATE.md path, got: ${out}`); + assert.ok(!out.includes(path.join(tmpDir, '.planning', 'ROADMAP.md')), + `must not fall back to the flat root ROADMAP.md path, got: ${out}`); + assert.ok(!out.includes(path.join(tmpDir, '.planning', 'MILESTONES.md')), + `must not fall back to the flat root MILESTONES.md path, got: ${out}`); + assert.ok(!out.includes(path.join(tmpDir, '.planning', 'PROJECT.md')), + `must not fall back to the flat root PROJECT.md path, got: ${out}`); + }); + }); + + describe('REQUIREMENTS.md removal: git rm uses init.complete-milestone requirements_path, not a hardcoded literal', () => { + test('flat mode: removes the root REQUIREMENTS.md path (regression guard)', () => { + const requirementsPath = path.join(tmpDir, 'REQUIREMENTS-root.md'); + fs.writeFileSync(requirementsPath, '# Requirements\n'); + fs.mkdirSync(path.join(tmpDir, '.git'), { recursive: true }); // git rm needs a repo; the stub below intercepts it + const script = `git() { printf 'git_call:%s\\n' "$*"; }\n${stubGsdRun({ requirements_path: requirementsPath })}${requirementsRmFence}`; + const r = runHookSeam('-c', [script], { interpreter: 'bash', cwd: tmpDir }); + throwIfFailed(r, 'bash '); + assert.ok(r.stdout.includes(`git_call:rm ${requirementsPath}`), + `expected git rm to target the root REQUIREMENTS.md, got: ${r.stdout}`); + }); + + test('GSD_WORKSTREAM=alpha: removes the workstream-scoped REQUIREMENTS.md, not root (#4455 follow-up regression)', () => { + const wsRequirementsPath = path.join(tmpDir, 'REQUIREMENTS-alpha.md'); + fs.writeFileSync(wsRequirementsPath, '# Requirements\n'); + const script = `git() { printf 'git_call:%s\\n' "$*"; }\n${stubGsdRun({ requirements_path: wsRequirementsPath })}${requirementsRmFence}`; + const r = runHookSeam('-c', [script], { interpreter: 'bash', cwd: tmpDir }); + throwIfFailed(r, 'bash '); + assert.ok(r.stdout.includes(`git_call:rm ${wsRequirementsPath}`), + `expected git rm to target the workstream REQUIREMENTS.md, got: ${r.stdout}`); + assert.ok(!r.stdout.includes('git_call:rm .planning/REQUIREMENTS.md'), + `must not have targeted the literal root REQUIREMENTS.md path, got: ${r.stdout}`); + }); + }); +});