fix(#4455): autonomous.md and complete-milestone.md resolve STATE/ROADMAP/MILESTONES/PROJECT/REQUIREMENTS through the workstream-scoped init fields (#4542)
* fix(#4455): thread workstream-scoped paths through autonomous and complete-milestone workflows autonomous.md and complete-milestone.md read/wrote hardcoded literal `.planning/STATE.md` / `.planning/ROADMAP.md` / `.planning/milestones/...` paths in their shell fences, bypassing workstream scoping entirely. With GSD_WORKSTREAM=alpha set, planningDir(cwd) correctly resolves into workstreams/alpha/, but a literal `cat .planning/STATE.md` still read the ROOT file (or silently returned empty if root state was absent) -- reproduced deterministically in the issue's own repro. Root cause: each workflow step's bash fence is a separate shell invocation, and cmdInitManager/cmdInitCompleteMilestone's JSON payloads never carried resolved state_path/roadmap_path/archive_dir fields for the workflows to extract -- unlike cmdInitPlanPhase, which already does this correctly and is the pattern this fix mirrors. - src/init.cts: cmdInitManager and cmdInitCompleteMilestone now emit state_path/roadmap_path (workstream-scoped via planningDir(cwd), existence-checked, toPosixPath'd, null when absent -- identical to cmdInitPlanPhase's existing contract) and archive_dir (the milestone archive directory, composed the same way milestone.cts's already-correct archive helper does per #1911). - autonomous.md: discover_phases and iterate now extract state_path via the already-fetched INIT_MANAGER payload instead of hardcoding `.planning/STATE.md`; iterate's second, previously-separate hardcoded read is folded into the same fence (no double-fetch); lifecycle step 5b checks the resolved archive_dir instead of a hardcoded milestones path. - complete-milestone.md's reorganize_roadmap_and_delete_originals step (which previously called no init command at all) now fetches init.complete-milestone and uses the resolved roadmap_path/state_path/ archive_dir for the backlog read, the write-guard sentinel's armed content, the Write-tool target for the reorganized ROADMAP.md (the sentinel fence now echoes the resolved path so the executing agent can see it), and the safety-commit --files list. `.planning/MILESTONES.md` and `.planning/PROJECT.md` stay literal root paths -- documented shared files, per the issue's explicit "not a blanket replacement" scope. Regression tests extract and execute the real bash fences (with a stubbed gsd_run) rather than string-matching the markdown, covering flat mode (unaffected), an active workstream (the issue's own repro shape, now correctly resolving), the no-double-fetch requirement, and a dedicated guard locking MILESTONES.md/PROJECT.md as shared. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs(#4455): add changeset for workstream-scoped autonomous/complete-milestone fix Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#4455): close write-guard gap on workstream-scoped curated paths Isolated security review of the #4455 fix (workstream-scoped STATE/ ROADMAP/milestone-archive path resolution in autonomous.md and complete-milestone.md) flagged that hooks/gsd-write-guard.js's CURATED_PATTERNS only matched root-level .planning/ paths, never .planning/[<project>/]workstreams/<ws>/... — meaning the catastrophic- shrink guard silently never engaged for a workstream-scoped write. This is directly relevant here: the #4455 change makes a workstream- scoped ROADMAP.md Write reachable via complete-milestone.md's own explicit sentinel-hatch instructions, which assume guard protection that did not actually exist for that path shape. Extended CURATED_PATTERNS with the three workstream-scoped equivalents; consumeSentinelFor's own path-derivation logic needed no change since it derives from the actual write target. Verified empirically (a 293->16 line workstream ROADMAP.md shrink now correctly returns exit 2 / decision:"block") and with 5 new regression tests. Also addressed a code-review nit on the core #4455 fix: cmdInitCompleteMilestone called planningDir(cwd) three separate times instead of caching it once. Accepted as-is (not fixed): complete-milestone.md's reorganize_roadmap_and_delete_originals step re-fetches `gsd_run query init.complete-milestone` three times across its fences rather than merging the first two (no state-changing Write between them, unlike autonomous.md's iterate step which does merge). This is an efficiency nit, not a correctness bug — merging risks disrupting the step's prose flow and its existing binding test for a non-functional gain. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs(#4455): add changeset for the write-guard workstream-scope fix Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#4455): fix gsd-test-surfaced regressions from workstream-path fix Running gsd-test against the full #4455 diff (including the write-guard security fix and the cmdInitCompleteMilestone caching nit) surfaced four real, non-flaky failures, all direct consequences of editing gsd-core/workflows/autonomous.md and complete-milestone.md: 1. tests/autonomous-converge.test.cjs pinned the OLD hardcoded `STATE_CONTENT=$(cat .planning/STATE.md ...)` read in both discover_phases and iterate. That is exactly the literal-path behavior #4455 fixes, so the test needed updating to assert the new init.manager-resolved `STATE_PATH` read instead (with an explicit doesNotMatch guard against regressing to the old literal). 2. tests/workstream-scoped-paths.test.cjs's own "no-double-fetch" test counted gsd_run invocations via a shell variable incremented inside the stub function — but `INIT_MANAGER=$(gsd_run ...)` runs gsd_run inside the command-substitution SUBSHELL, so that increment never survives back to the parent shell and the counter always read 0. Switched to a file-based call log (one byte appended per call), which survives the subshell boundary. 3. tests/compact-content-partition-guard.test.cjs's disjointness check flagged the reorganize_roadmap_and_delete_originals step's new `INIT_CM=$(gsd_run query init.complete-milestone)` fetch (added 3x, per the accepted-as-is disposition in the prior commit) as byte-identical to a pre-existing, unrelated fetch already present in complete-milestone/detail/elaboration.md's handle_branches section (§2). Same idiom, same conventional variable name, coincidentally colliding across the spine/detail split boundary. Renamed the new step's local variable to INIT_REORG — a distinct, purpose-specific name is arguably better practice anyway for two logically unrelated fetches, and it removes the literal collision honestly rather than restructuring the split. 4. tests/benchmark-compact-content.test.cjs reported real byte-count drift in the committed baseline (autonomous.md and complete-milestone.md both grew from the #4455 content). Refreshed via `node scripts/benchmark-compact-content.cjs --write`. Verified: node scripts/benchmark-compact-content.cjs --check now reports the baseline up to date; a standalone invocation of checkDisjointness() against the real repo state now reports zero violations across all 6 registered splits; manual bash-fence execution of both the autonomous.md iterate fence (call count = 1) and the complete-milestone.md backlog fence (with INIT_REORG) confirms correct behavior. Emitted-Drift-Ack-Growth: autonomous.md — #4455 workstream-scoped STATE.md path resolution replaces hardcoded literal reads Emitted-Drift-Ack-Growth: complete-milestone.md — #4455 workstream-scoped STATE/ROADMAP/archive path resolution replaces hardcoded literal reads Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#4455): MILESTONES.md/PROJECT.md/REQUIREMENTS.md are workstream-scoped too, and so is project-only mode Fresh isolated code-review and security-review passes against the full diff (run after the previous gsd-test-surfaced fixups landed) each found one real, confirmed defect: Code review: the safety-commit `--files` list and the REQUIREMENTS.md `git rm` step both hardcoded `.planning/MILESTONES.md`, `.planning/PROJECT.md`, and `.planning/REQUIREMENTS.md` as literal root paths — but src/milestone.cts's cmdMilestoneComplete writes MILESTONES.md via `planningPaths(cwd).planning` (the workstream base) and PROJECT.md/REQUIREMENTS.md resolve the same way through `planningPaths().project`/`.requirements` (src/planning-workspace.cts). Only `todos` is the documented root-scoped exception (#4256); an earlier version of this fix wrongly generalized that exception to MILESTONES.md/PROJECT.md too, and the now-corrected test previously enshrined that wrong behavior as intended. Under an active workstream, the safety commit would have silently missed the actual files `milestone complete` just wrote, and the git-rm step would have targeted the wrong (root) REQUIREMENTS.md entirely. Fixed by exposing `milestones_path`/`project_path`/`requirements_path` from init.complete-milestone (src/init.cts) and resolving all three through them, the same pattern already used for state_path/roadmap_path/ archive_dir. The four remaining literal MILESTONES.md/PROJECT.md mentions elsewhere in complete-milestone.md (lines ~12-13, ~441, ~607, ~662) are display-only prose in status/summary message templates, not actual file operations — left as-is; they are a cosmetic path-display inaccuracy under an active workstream, not a data-integrity bug like the two fixed here. Security review: confirmed the write-guard fix from the prior commit is correct and complete for workstream scoping, and independently surfaced the same project-only gap the code-review pass above also caught structurally: `CURATED_PATTERNS` had no pattern for `.planning/<project>/...` (GSD_PROJECT set, GSD_WORKSTREAM unset) — planningDir(cwd) supports that shape independently of workstream nesting, so it is reachable, not hypothetical. Fixed by adding three more patterns, verified empirically (a project-scoped 292->16 line ROADMAP.md shrink now correctly returns exit 2 / decision:"block") and with 6 new regression tests. Verified: manual bash-fence execution of the corrected commit-files and requirements-rm fences (both flat mode and GSD_WORKSTREAM=alpha) resolves to the right paths in both cases; a standalone invocation of checkDisjointness() against the real repo state still reports zero violations; the benchmark baseline was refreshed again for the further size change (already covered by the existing Emitted-Drift-Ack-Growth trailer on complete-milestone.md two commits back — that trailer is read over the whole merge-base..HEAD range, not per-commit, so it still applies here). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs(#4455): backfill changeset PR numbers and correct final scope pr: 0 -> pr: 4542 for both fragments, and updated both bodies to reflect the final fix scope (MILESTONES/PROJECT/REQUIREMENTS are workstream-scoped too, not shared-root exceptions; the write-guard fix also covers project-only scoping, not just workstream nesting). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#4455): lifecycle-5b archive-path assertions use the fence's own separator, not path.join PR CI's windows-latest shard 3/3 failed: "expected ls to find the root archive file, got: ...\milestones-root/v1.0-ROADMAP.md". The autonomous.md lifecycle step 5b fence composes the checked path with a literal bash `/` (`"${ARCHIVE_DIR}/v${milestone_version}-ROADMAP.md"`), which on Windows yields a MIXED-separator path — Windows backslashes from archiveDir plus one trailing `/`. My test's assertion used path.join(archiveDir, 'v1.0-ROADMAP.md') instead, which on a Windows Node process produces an all-backslash path that never matches the fence's mixed-separator output. Both assertions in that describe block now mirror the fence's own literal `/` concatenation (`${archiveDir}/v1.0-ROADMAP.md`) instead of path.join — matching the style the other two describe blocks in this same file (safety-commit --files list) already used correctly for the identical archive-dir pattern, so this brings the one outlier into line rather than introducing a new idiom. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#4455): write-guard sentinel comparison now realpath-resolves the token, not just the target PR CI's macos-latest full-test shard 2/3 failed a #4455 test: "the sentinel hatch ... unblocks a workstream ROADMAP.md write" got status 2 (still blocked) instead of 0. Root cause, unrelated to the Windows fix in the previous commit: hooks/gsd-write-guard.js's main flow realpath-resolves the Write TARGET before the curated-pattern match (round 9 Minor 1's symlink-before-match fix, `filePath = fs.realpathSync(filePath)`), but consumeSentinelFor resolved the sentinel TOKEN's absolute path via plain path.resolve() with no realpath step. On macOS, os.tmpdir() resolves through a /var -> /private/var symlink, so a test's cwd (lexically under /var/folders/...) and its realpath'd target (/private/var/folders/...) diverge — an armed, correct sentinel then never matches the realpath'd target string, and the guard stays incorrectly blocked. This is not macOS-specific in principle: ANY cwd sitting under a symlink (a symlinked project checkout, a symlinked worktree) hits the same asymmetry — gsd-test's Linux bench runs never caught it because /tmp there is not a symlink. Fixed by applying the same fs.realpathSync (with the same keep-lexical-on-failure fallback the caller already uses) to the token's resolved path before comparing. The named file is already known to exist at this point (the caller only reaches consumeSentinelFor after successfully reading the target), so realpath is expected to succeed in the legitimate case; a garbage/mismatched token still fails safe (verified — falls back to the lexical path, still mismatches, stays blocked). Verified: reproduced the exact bug locally (macOS) via os.tmpdir() before the fix, confirmed it resolves after; the negative case (sentinel armed for a DIFFERENT file) still correctly blocks; the pre-existing relative-token sentinel tests (predating #4455) still pass; a garbage/non-existent token still fails safe. Added a deterministic, cross-platform regression test using an explicit symlink (skipped on Windows, matching the existing round-9 symlink test's own skip condition) so this class of bug is caught by gsd-test's Linux bench too, not only by a real macOS CI run. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
5
.changeset/eager-tunas-hum.md
Normal file
5
.changeset/eager-tunas-hum.md
Normal file
@@ -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/[<project>/]workstreams/<ws>/...`) or project-only-scoped (`.planning/<project>/...`) 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.
|
||||
5
.changeset/witty-orcas-jump.md
Normal file
5
.changeset/witty-orcas-jump.md
Normal file
@@ -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).
|
||||
@@ -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."
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
</step>
|
||||
|
||||
@@ -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/[<project>/]workstreams/<ws>/...` 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/<project>/...` 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
|
||||
}
|
||||
|
||||
39
src/init.cts
39
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<string, unknown> = {
|
||||
// #2994: hoisted from complete-milestone.md's git_tag step
|
||||
// <config-check> 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', {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/[<project>/]workstreams/<ws>/...` 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/<project>/...` 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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
385
tests/workstream-scoped-paths.test.cjs
Normal file
385
tests/workstream-scoped-paths.test.cjs
Normal file
@@ -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 <discover_phases fence>');
|
||||
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 <discover_phases fence>');
|
||||
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 <iterate fence>');
|
||||
// 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 <iterate fence>');
|
||||
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 <iterate fence>');
|
||||
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 <lifecycle 5b fence>');
|
||||
// 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 <lifecycle 5b fence>');
|
||||
// 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 = '<step name="reorganize_roadmap_and_delete_originals">';
|
||||
const STEP_END = '<step name="write_retrospective">';
|
||||
|
||||
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:<argv>` 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 <backlog fence>');
|
||||
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 <backlog fence>');
|
||||
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 <sentinel fence>');
|
||||
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 <sentinel fence>');
|
||||
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 <commit --files fence>');
|
||||
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 <requirements rm fence>');
|
||||
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 <requirements rm fence>');
|
||||
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}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user