* fix(#4456): forward --ws to every downstream new-milestone.md call new-milestone.md's Step 1 parses --ws <name> into GSD_WS, but each workflow step's bash fence is a separate shell invocation — GSD_WS set in Step 1 never survived to Steps 5, 6, or 7. Four call sites never forwarded it: init.new-milestone (both calls), state.milestone-switch, and both phases.clear branches. Under GSD_WORKSTREAM env or a stored session pointer differing from the explicitly requested --ws, every downstream operation silently operated on the wrong workstream (or root) instead of the one the caller asked for. Confirmed --ws is a universally-parsed CLI flag (gsd-core/bin/gsd-tools.cjs: 4867, resolveActiveWorkstream) — stripped from argv and written into process.env.GSD_WORKSTREAM for the rest of that process, so appending it to ANY gsd_run query call works uniformly. Fixed by persisting GSD_WS to .planning/.gsd-ws-arg right after Step 1 parses it (mirroring the established .gsd-outgoing-milestone round-trip idiom this same file already uses for the identical cross-fence problem), reading it back in each later step, and appending it unquoted (matching the ${GSD_WS} splicing convention documented in workstream-flag.md). Cleaned up after its last use in Step 7. Bundled, in-scope fixes found while implementing the above (per this repo's no-defer policy): - Step 6's phase-archive `git add .planning/milestones/ .planning/phases/` hardcoded literal ROOT paths — both directories are workstream-scoped (matching cmdMilestoneComplete's established #1911 precedent), so under a workstream this staged nothing real. Added phases_dir/archive_dir fields to cmdInitNewMilestone and resolved through them instead. - Step 6's milestone-start commit hardcoded .planning/STATE.md — also workstream-scoped, so it would commit the wrong (or a stale) file under a workstream. Resolved through init.new-milestone's existing state_path field instead; PROJECT.md correctly stays a literal-shaped-but-resolved root path (shared, per the #4455 follow-up already merged). - cmdInitNewMilestone's config_path field: config.json is ALSO a shared file (marked `# Shared` in workstream-flag.md's directory diagram, same as PROJECT.md) but was resolved via the workstream-aware planningDir — fixed alongside cmdInitNewProject's identical instance of the same bug (found via grep, matching the precedent from the #4455 follow-up of fixing every occurrence of an identically-evidenced bug uniformly). Verified: direct CLI invocation confirms phases_dir/archive_dir/state_path resolve into the workstream while project_path/config_path stay root under GSD_WORKSTREAM=alpha. Manual bash-fence execution of every modified fence (Step 1 parse+persist, Step 5 forwarding, both phases.clear branches, the git add fence, Step 7's forward+cleanup, the commit fence) confirms correct behavior in both flat and --ws modes, including two flags composing together (--archive-version + --ws; --reset-phase-numbers + --ws). Rewrote the pre-existing "step 6: commit stages PROJECT.md" test, which asserted the literal (buggy) --files string verbatim — it now asserts the resolved paths via a JSON-returning stub, and gained isolated per-test tmp dirs (the prior version ran with no explicit cwd, at real risk of writing a stray .gsd-ws-arg into this repo's own .planning/ once Step 1's fence started performing a real write). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#4456): Steps 9/10 also commit workstream-scoped files via literal root paths A fresh isolated code-review pass on the first version of this fix found the identical bug in two more places, missed in the initial sweep: - Step 9's requirements commit (`gsd_run query commit ... --files .planning/REQUIREMENTS.md`) and Step 10's roadmap commit (`--files .planning/ROADMAP.md .planning/STATE.md .planning/REQUIREMENTS.md`) both hardcoded literal ROOT paths for files that are workstream-scoped. - Worse: `.planning/.gsd-ws-arg` was being deleted at the end of Step 7, but Steps 9 and 10 run AFTER Step 7 and still needed to re-read it — the round-trip mechanism this fix builds was already gone before its two remaining consumers ran. Fixed by moving the `.gsd-ws-arg` cleanup to Step 10 (its true last consumer, after the roadmap commit) and adding the same fetch-then-_gsd_field-extract pattern already used in Step 6 to Steps 9 and 10, resolving `requirements_path`/`roadmap_path`/`state_path` through `init.new-milestone $GSD_WS_ARG` instead of literal paths. Also fixed (MEDIUM, same review pass): Step 1's `.gsd-ws-arg` write had no `2>/dev/null || true`, unlike every other round-trip write in this same file — brought into line with the established idiom. Verified: reproduced the pre-fix bug directly (Step 9/10 fences echoing the literal root paths regardless of --ws), confirmed both fences now resolve the workstream-scoped paths correctly, and confirmed the round-trip file survives Step 7 and is only removed after Step 10. Updated the Step 7 test that previously asserted premature cleanup (inverted to assert the file survives); added new coverage for Steps 9 and 10 in both flat and --ws modes. Two remaining LOW/pre-existing findings from the same review pass, deliberately left as-is: `phase_archive_path` (src/init.cts, untouched by this diff) resolves via the same root-only `getLatestCompletedMilestone` this fix's earlier commit already declined to touch, for the same genuine-product-intent-ambiguity reason (workstream-scoped vs project-pooled "latest completed milestone" is not resolvable from the code alone). `.planning/research/` staying root-scoped in the #222 self-heal prose is consistent with the existing (unchanged) `research_dir` field, not a new inconsistency. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#4456): revert wrong config_path change; fix isolated-cwd test env gsd-test caught two real regressions from this fix's earlier commits: 1. config_path is NOT shared like PROJECT.md. The prior commit's grep-and-replace ("fix six more functions with the identical bug") also touched cmdInitExecutePhase's config_path (a fourth call site beyond the two I'd manually checked) — but tests/init.test.cjs's pre-existing, ADR-0006-governed "init handlers honor GSD_WORKSTREAM" coverage explicitly asserts config_path IS workstream-scoped for execute-phase/plan-phase/phase-op/milestone-op. workstream-flag.md's "# Shared" marking for config.json is stale (the same class of staleness already found for milestones/ during the #4455 follow-up); ADR-0006 plus its real, passing tests is the authoritative source. Reverted config_path to the plain workstream-aware planningDir(cwd) in all four functions it was wrongly changed in. 2. Isolating cwd to a tmpDir (needed once Step 1's fence started performing a real .gsd-ws-arg write) broke the runtime-launcher preamble's own gsd-tools.cjs discovery — no git repo at an isolated tmpDir, no global gsd_run on the CI bench's PATH. Fixed by passing RUNTIME_DIR explicitly in every isolated-cwd test's env, matching the preamble's own documented override precedence. Verified: direct CLI invocation confirms execute-phase's config_path is workstream-scoped again under GSD_WORKSTREAM=wsx; the RUNTIME_DIR fix confirmed against a stripped PATH (no global gsd_run), matching the bench condition that surfaced the original failure. Emitted-Drift-Ack-Growth: new-milestone.md — #4456 forwards --ws to every downstream gsd_run call across 7 fences (Steps 1/5/6x3/7/9/10), adding a persisted round-trip file plus resolved-path fetches that replace several hardcoded literal paths Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs(#4456): backfill changeset PR number and correct final scope pr: 0 -> pr: 4545, and removed the changeset's claim that config.json is a shared file -- that was the change this same PR later reverted after gsd-test caught it contradicting ADR-0006's established, workstream-scoped config_path contract. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#4456): baseline the 10 new SC2086 findings from --ws forwarding The lint-tests CI job failed with a hard exit 1. Diagnosis (not assumed): the log's two `fatal: ambiguous argument 'origin/next...HEAD'` git errors (lines 244/248) are a red herring — both belong to lint-removed-but-needed.cjs, which prints its own "could not resolve origin/next, skipping" message and exits gracefully, exactly like the already-handled two-dot-form error from lint-fix-has-regression-tests earlier in the same log. Neither contributes to the actual failure. The real cause is lint-workflow-shellcheck: this fix's new fences append $GSD_WS_ARG unquoted to gsd_run calls (deliberately, so it splits into 0 or 2 argv tokens — the same idiom gsd-core/workflows/verify-work.md already uses for ${GSD_WS} and already has baselined). ShellCheck correctly flags each as SC2086, and lint-workflow-shellcheck.cjs's baseline is a deliberate ratchet (#4109) requiring new findings to be explicitly accepted, not auto-passed. new-milestone.md previously had zero baselined SC2086 findings, so all 10 new (correct, intentional) occurrences were reported as new and failed the gate. Added 10 {file, code, message} entries to scripts/lint-workflow-shellcheck-baseline.json for gsd-core/workflows/new-milestone.md's SC2086 findings, matching the established, already-accepted precedent for the identical pattern in verify-work.md. No source or workflow file changed. 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/wise-hawks-howl.md
Normal file
5
.changeset/wise-hawks-howl.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 4545
|
||||
---
|
||||
**`/gsd-new-milestone --ws <name>` now correctly scopes every downstream operation to the requested workstream** — the parsed `--ws` flag was silently dropped by every step after the one that parsed it (each workflow step runs in its own shell), so `init.new-milestone`, `state.milestone-switch`, `phases.clear`, the phase-archive `git add`, and the requirements/roadmap/milestone-start commits all operated on the wrong (ambient or root) scope instead of the explicitly requested workstream.
|
||||
@@ -34,6 +34,10 @@ _GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-pars
|
||||
GSD_WS=""
|
||||
echo "$ARGUMENTS" | grep -qE -- '--ws[[:space:]]+[A-Za-z0-9._-]+' && GSD_WS=$(echo "$ARGUMENTS" | grep -oE -- '--ws[[:space:]]+[A-Za-z0-9._-]+')
|
||||
MILESTONE_ARG=$(echo "$ARGUMENTS" | sed -E 's/--ws[[:space:]]+[A-Za-z0-9._-]+//g' | xargs)
|
||||
# #4456: persist GSD_WS to a file so later steps' bash fences (each a
|
||||
# separate shell) can forward it — the same cross-fence problem Step 5/6
|
||||
# already solve for OUTGOING_MILESTONE via .gsd-outgoing-milestone.
|
||||
printf '%s' "$GSD_WS" > .planning/.gsd-ws-arg 2>/dev/null || true
|
||||
RESPONSE_LANGUAGE=$(gsd_run query config-get response_language --raw --default "" 2>/dev/null || echo "")
|
||||
# #2994: EARLY, section-manifest-only init.new-milestone call — needed here
|
||||
# (before Step 4) to gate the project-md-milestone-write section. This is
|
||||
@@ -44,7 +48,11 @@ RESPONSE_LANGUAGE=$(gsd_run query config-get response_language --raw --default "
|
||||
# fields too early and corrupt the roadmapper's phase-numbering context.
|
||||
# init.new-milestone is a pure read (no mutation), so calling it twice is
|
||||
# safe; only `section_manifest` is consumed from this early call.
|
||||
INIT_EARLY=$(gsd_run query init.new-milestone)
|
||||
# #4456: $GSD_WS forwarded (same fence as the parse above, no round-trip
|
||||
# needed here) so the section manifest — and the shared PROJECT.md write
|
||||
# guard it gates — reflects the EXPLICITLY requested workstream, not
|
||||
# whatever ambient GSD_WORKSTREAM/session pointer happens to be active.
|
||||
INIT_EARLY=$(gsd_run query init.new-milestone $GSD_WS)
|
||||
if [[ "$INIT_EARLY" == @file:* ]]; then INIT_EARLY=$(cat "${INIT_EARLY#@file:}"); fi
|
||||
```
|
||||
|
||||
@@ -195,10 +203,11 @@ blockers, todos) is preserved across the switch — symmetric with
|
||||
`milestone.complete`.
|
||||
|
||||
```bash
|
||||
OUTGOING_MILESTONE=$(gsd_run query state.get milestone --raw 2>/dev/null || true)
|
||||
GSD_WS_ARG=$(cat .planning/.gsd-ws-arg 2>/dev/null || true)
|
||||
OUTGOING_MILESTONE=$(gsd_run query state.get milestone --raw $GSD_WS_ARG 2>/dev/null || true)
|
||||
printf '%s' "$OUTGOING_MILESTONE" > .planning/.gsd-outgoing-milestone 2>/dev/null || true
|
||||
echo "Outgoing milestone (phase history archives under THIS version in step 6): ${OUTGOING_MILESTONE:-<unknown>}"
|
||||
gsd_run query state.milestone-switch --milestone "v[X.Y]" --name "[Name]"
|
||||
gsd_run query state.milestone-switch --milestone "v[X.Y]" --name "[Name]" $GSD_WS_ARG
|
||||
```
|
||||
|
||||
**Capture the outgoing version now.** The lines above read the *current* (previous) milestone
|
||||
@@ -239,11 +248,12 @@ the captured value into the command, so untrusted STATE.md content cannot be re-
|
||||
shell:
|
||||
|
||||
```bash
|
||||
GSD_WS_ARG=$(cat .planning/.gsd-ws-arg 2>/dev/null || true)
|
||||
OUTGOING_MILESTONE=$(cat .planning/.gsd-outgoing-milestone 2>/dev/null || true)
|
||||
if [ -n "$OUTGOING_MILESTONE" ]; then
|
||||
gsd_run query phases.clear --confirm --archive-version "$OUTGOING_MILESTONE"
|
||||
gsd_run query phases.clear --confirm --archive-version "$OUTGOING_MILESTONE" $GSD_WS_ARG
|
||||
else
|
||||
gsd_run query phases.clear --confirm
|
||||
gsd_run query phases.clear --confirm $GSD_WS_ARG
|
||||
fi
|
||||
rm -f .planning/.gsd-outgoing-milestone 2>/dev/null || true
|
||||
```
|
||||
@@ -258,31 +268,48 @@ Stage the phase archive move + source removal so they land in the same commit as
|
||||
|
||||
```bash
|
||||
COMMIT_DOCS=$(gsd_run query config-get commit_docs --raw 2>/dev/null || echo "true")
|
||||
GSD_WS_ARG=$(cat .planning/.gsd-ws-arg 2>/dev/null || true)
|
||||
INIT_STAGE=$(gsd_run query init.new-milestone $GSD_WS_ARG)
|
||||
if [[ "$INIT_STAGE" == @file:* ]]; then INIT_STAGE=$(cat "${INIT_STAGE#@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_STAGE" archive_dir)
|
||||
PHASES_DIR=$(_gsd_field "$INIT_STAGE" phases_dir)
|
||||
if [ "$COMMIT_DOCS" != "false" ]; then
|
||||
git add .planning/milestones/ .planning/phases/ 2>/dev/null || true
|
||||
git add "$ARCHIVE_DIR/" "$PHASES_DIR/" 2>/dev/null || true
|
||||
fi
|
||||
```
|
||||
|
||||
When `commit_docs` is false, the archive move and phase removals are deliberately left unstaged here — not a bug — since Step 6's commit is skipped too.
|
||||
|
||||
Stage PROJECT.md in both modes. Step 4's Part A guard — not this commit — is what protects the shared `## Current Milestone` heading (#2308): when a workstream is active Part A never writes it, so the only change PROJECT.md can carry here is Part B's idempotent `## Evolution` backfill, which must be committed rather than stranded as a dangling edit. Do NOT reintroduce a `[ -n "$GSD_WS" ]` branch around this commit: `GSD_WS` is set in Step 1's shell and each step's bash block runs in its own shell (the same reason Step 5 round-trips `OUTGOING_MILESTONE` through a file), so such a guard reads an unset variable, always takes the flat-mode branch, and only appears to work.
|
||||
Stage PROJECT.md in both modes. Step 4's Part A guard — not this commit — is what protects the shared `## Current Milestone` heading (#2308): when a workstream is active Part A never writes it, so the only change PROJECT.md can carry here is Part B's idempotent `## Evolution` backfill, which must be committed rather than stranded as a dangling edit. Do NOT reintroduce a `[ -n "$GSD_WS" ]` branch around this commit: `GSD_WS` is set in Step 1's shell and each step's bash block runs in its own shell (the same reason Step 5 round-trips `OUTGOING_MILESTONE` through a file), so such a guard reads an unset variable, always takes the flat-mode branch, and only appears to work. STATE.md, unlike PROJECT.md, IS workstream-scoped (Step 5's switch just wrote the workstream's own copy) — resolved below via `init.new-milestone` rather than a literal `.planning/STATE.md`, which would commit the wrong (or a stale, unrelated) file under an active workstream.
|
||||
|
||||
```bash
|
||||
gsd_run query commit "docs: start milestone v[X.Y] [Name]" --files .planning/PROJECT.md .planning/STATE.md
|
||||
GSD_WS_ARG=$(cat .planning/.gsd-ws-arg 2>/dev/null || true)
|
||||
INIT_COMMIT=$(gsd_run query init.new-milestone $GSD_WS_ARG)
|
||||
if [[ "$INIT_COMMIT" == @file:* ]]; then INIT_COMMIT=$(cat "${INIT_COMMIT#@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_COMMIT" state_path)
|
||||
PROJECT_PATH=$(_gsd_field "$INIT_COMMIT" project_path)
|
||||
gsd_run query commit "docs: start milestone v[X.Y] [Name]" --files "$PROJECT_PATH" "$STATE_PATH"
|
||||
```
|
||||
|
||||
## 7. Load Context and Resolve Models
|
||||
|
||||
```bash
|
||||
RESET_PHASE_NUMBERS_PARAM=""; if [[ "$ARGUMENTS" =~ (^|[[:space:]])--reset-phase-numbers([[:space:]]|$) ]]; then RESET_PHASE_NUMBERS_PARAM="--reset-phase-numbers"; fi
|
||||
INIT=$(gsd_run query init.new-milestone $RESET_PHASE_NUMBERS_PARAM)
|
||||
GSD_WS_ARG=$(cat .planning/.gsd-ws-arg 2>/dev/null || true)
|
||||
INIT=$(gsd_run query init.new-milestone $RESET_PHASE_NUMBERS_PARAM $GSD_WS_ARG)
|
||||
if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi
|
||||
AGENT_SKILLS_RESEARCHER=$(gsd_run query agent-skills gsd-project-researcher)
|
||||
AGENT_SKILLS_SYNTHESIZER=$(gsd_run query agent-skills gsd-research-synthesizer)
|
||||
AGENT_SKILLS_ROADMAPPER=$(gsd_run query agent-skills gsd-roadmapper)
|
||||
```
|
||||
<!-- #4456: .planning/.gsd-ws-arg is NOT cleaned up here — Steps 9 and 10
|
||||
below still need to re-read it (each is its own shell) to resolve
|
||||
REQUIREMENTS.md/ROADMAP.md/STATE.md correctly under a workstream. It is
|
||||
removed in Step 10, its true last consumer. -->
|
||||
|
||||
Extract from init JSON: `researcher_model`, `synthesizer_model`, `roadmapper_model`, `commit_docs`, `research_enabled`, `current_milestone`, `project_exists`, `roadmap_exists`, `latest_completed_milestone`, `phase_dir_count`, `phase_archive_path`, `agents_installed`, `missing_agents`, `project_path`, `roadmap_path`, `requirements_path`, `config_path`, `research_dir`, `milestones_path`.
|
||||
Extract from init JSON: `researcher_model`, `synthesizer_model`, `roadmapper_model`, `commit_docs`, `research_enabled`, `current_milestone`, `project_exists`, `roadmap_exists`, `latest_completed_milestone`, `phase_dir_count`, `phase_archive_path`, `agents_installed`, `missing_agents`, `project_path`, `roadmap_path`, `requirements_path`, `config_path`, `research_dir`, `milestones_path`, `phases_dir`, `archive_dir`.
|
||||
|
||||
**If `agents_installed` is false:** Display a warning before proceeding:
|
||||
```
|
||||
@@ -495,7 +522,12 @@ If "adjust": Return to scoping.
|
||||
|
||||
**Commit requirements:**
|
||||
```bash
|
||||
gsd_run query commit "docs: define milestone v[X.Y] requirements" --files .planning/REQUIREMENTS.md
|
||||
GSD_WS_ARG=$(cat .planning/.gsd-ws-arg 2>/dev/null || true)
|
||||
INIT_REQ=$(gsd_run query init.new-milestone $GSD_WS_ARG)
|
||||
if [[ "$INIT_REQ" == @file:* ]]; then INIT_REQ=$(cat "${INIT_REQ#@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_REQ" requirements_path)
|
||||
gsd_run query commit "docs: define milestone v[X.Y] requirements" --files "$REQUIREMENTS_PATH"
|
||||
```
|
||||
|
||||
## 10. Create Roadmap
|
||||
@@ -579,7 +611,17 @@ Success criteria:
|
||||
|
||||
**Commit roadmap** (after approval):
|
||||
```bash
|
||||
gsd_run query commit "docs: create milestone v[X.Y] roadmap ([N] phases)" --files .planning/ROADMAP.md .planning/STATE.md .planning/REQUIREMENTS.md
|
||||
GSD_WS_ARG=$(cat .planning/.gsd-ws-arg 2>/dev/null || true)
|
||||
INIT_ROADMAP=$(gsd_run query init.new-milestone $GSD_WS_ARG)
|
||||
if [[ "$INIT_ROADMAP" == @file:* ]]; then INIT_ROADMAP=$(cat "${INIT_ROADMAP#@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_ROADMAP" roadmap_path)
|
||||
STATE_PATH=$(_gsd_field "$INIT_ROADMAP" state_path)
|
||||
REQUIREMENTS_PATH=$(_gsd_field "$INIT_ROADMAP" requirements_path)
|
||||
gsd_run query commit "docs: create milestone v[X.Y] roadmap ([N] phases)" --files "$ROADMAP_PATH" "$STATE_PATH" "$REQUIREMENTS_PATH"
|
||||
# #4456: true last consumer of the persisted --ws in this workflow — the
|
||||
# round-trip file is no longer needed after this commit.
|
||||
rm -f .planning/.gsd-ws-arg 2>/dev/null || true
|
||||
```
|
||||
|
||||
## 10.5. Link Pending Todos to Roadmap Phases
|
||||
|
||||
@@ -494,6 +494,56 @@
|
||||
"code": "2012",
|
||||
"message": "Use find instead of ls to better handle non-alphanumeric filenames."
|
||||
},
|
||||
{
|
||||
"file": "gsd-core/workflows/new-milestone.md",
|
||||
"code": "2086",
|
||||
"message": "Double quote to prevent globbing and word splitting."
|
||||
},
|
||||
{
|
||||
"file": "gsd-core/workflows/new-milestone.md",
|
||||
"code": "2086",
|
||||
"message": "Double quote to prevent globbing and word splitting."
|
||||
},
|
||||
{
|
||||
"file": "gsd-core/workflows/new-milestone.md",
|
||||
"code": "2086",
|
||||
"message": "Double quote to prevent globbing and word splitting."
|
||||
},
|
||||
{
|
||||
"file": "gsd-core/workflows/new-milestone.md",
|
||||
"code": "2086",
|
||||
"message": "Double quote to prevent globbing and word splitting."
|
||||
},
|
||||
{
|
||||
"file": "gsd-core/workflows/new-milestone.md",
|
||||
"code": "2086",
|
||||
"message": "Double quote to prevent globbing and word splitting."
|
||||
},
|
||||
{
|
||||
"file": "gsd-core/workflows/new-milestone.md",
|
||||
"code": "2086",
|
||||
"message": "Double quote to prevent globbing and word splitting."
|
||||
},
|
||||
{
|
||||
"file": "gsd-core/workflows/new-milestone.md",
|
||||
"code": "2086",
|
||||
"message": "Double quote to prevent globbing and word splitting."
|
||||
},
|
||||
{
|
||||
"file": "gsd-core/workflows/new-milestone.md",
|
||||
"code": "2086",
|
||||
"message": "Double quote to prevent globbing and word splitting."
|
||||
},
|
||||
{
|
||||
"file": "gsd-core/workflows/new-milestone.md",
|
||||
"code": "2086",
|
||||
"message": "Double quote to prevent globbing and word splitting."
|
||||
},
|
||||
{
|
||||
"file": "gsd-core/workflows/new-milestone.md",
|
||||
"code": "2086",
|
||||
"message": "Double quote to prevent globbing and word splitting."
|
||||
},
|
||||
{
|
||||
"file": "gsd-core/workflows/new-project.md",
|
||||
"code": "2012",
|
||||
|
||||
52
src/init.cts
52
src/init.cts
@@ -1003,6 +1003,17 @@ function cmdInitExecutePhase(
|
||||
// #3188: null when the file is absent (parity with patterns_path/context_path).
|
||||
state_path: fs.existsSync(statePath) ? toPosixPath(statePath) : null,
|
||||
roadmap_path: fs.existsSync(roadmapPath) ? toPosixPath(roadmapPath) : null,
|
||||
// #4456 correction: an isolated review pass initially "fixed" this to
|
||||
// planningDir(cwd, null) on the assumption that config.json is shared
|
||||
// like PROJECT.md (workstream-flag.md's directory diagram marks it
|
||||
// `# Shared`) — but ADR-0006's own tests (tests/init.test.cjs, "init
|
||||
// handlers honor GSD_WORKSTREAM") assert config_path IS workstream-scoped
|
||||
// for execute-phase/new-project/new-milestone/progress, and gsd-test
|
||||
// caught the regression immediately. The diagram is stale for
|
||||
// config.json specifically (same class of staleness already found for
|
||||
// `milestones/` during the #4455 follow-up) — reverted to the
|
||||
// workstream-aware planningDir(cwd), matching the established,
|
||||
// ADR-governed, tested contract.
|
||||
config_path: toPosixPath(path.join(planningDir(cwd), 'config.json')),
|
||||
// #2376: execute-phase.md's verify_phase_goal step reads this instead of
|
||||
// hardcoding '.planning/REQUIREMENTS.md' into the gsd-verifier spawn prompt.
|
||||
@@ -1402,6 +1413,17 @@ function cmdInitNewProject(cwd: string, raw: boolean, options: Record<string, un
|
||||
// read these instead of hardcoding '.planning/...' literals.
|
||||
requirements_path: toPosixPath(path.join(planningDir(cwd), 'REQUIREMENTS.md')),
|
||||
roadmap_path: toPosixPath(path.join(planningDir(cwd), 'ROADMAP.md')),
|
||||
// #4456 correction: an isolated review pass initially "fixed" this to
|
||||
// planningDir(cwd, null) on the assumption that config.json is shared
|
||||
// like PROJECT.md (workstream-flag.md's directory diagram marks it
|
||||
// `# Shared`) — but ADR-0006's own tests (tests/init.test.cjs, "init
|
||||
// handlers honor GSD_WORKSTREAM") assert config_path IS workstream-scoped
|
||||
// for execute-phase/new-project/new-milestone/progress, and gsd-test
|
||||
// caught the regression immediately. The diagram is stale for
|
||||
// config.json specifically (same class of staleness already found for
|
||||
// `milestones/` during the #4455 follow-up) — reverted to the
|
||||
// workstream-aware planningDir(cwd), matching the established,
|
||||
// ADR-governed, tested contract.
|
||||
config_path: toPosixPath(path.join(planningDir(cwd), 'config.json')),
|
||||
research_dir: toPosixPath(path.join(planningRoot(cwd), 'research')),
|
||||
};
|
||||
@@ -1466,9 +1488,28 @@ function cmdInitNewMilestone(cwd: string, raw: boolean, options: Record<string,
|
||||
// #2376: new-milestone.md's research-synthesizer/roadmapper spawn prompts
|
||||
// read these instead of hardcoding '.planning/...' literals.
|
||||
requirements_path: toPosixPath(path.join(planningDir(cwd), 'REQUIREMENTS.md')),
|
||||
// #4456 correction: an isolated review pass initially "fixed" this to
|
||||
// planningDir(cwd, null) on the assumption that config.json is shared
|
||||
// like PROJECT.md (workstream-flag.md's directory diagram marks it
|
||||
// `# Shared`) — but ADR-0006's own tests (tests/init.test.cjs, "init
|
||||
// handlers honor GSD_WORKSTREAM") assert config_path IS workstream-scoped
|
||||
// for execute-phase/new-project/new-milestone/progress, and gsd-test
|
||||
// caught the regression immediately. The diagram is stale for
|
||||
// config.json specifically (same class of staleness already found for
|
||||
// `milestones/` during the #4455 follow-up) — reverted to the
|
||||
// workstream-aware planningDir(cwd), matching the established,
|
||||
// ADR-governed, tested contract.
|
||||
config_path: toPosixPath(path.join(planningDir(cwd), 'config.json')),
|
||||
research_dir: toPosixPath(path.join(planningRoot(cwd), 'research')),
|
||||
milestones_path: toPosixPath(path.join(planningDir(cwd), 'MILESTONES.md')),
|
||||
// #4456: new-milestone.md's Step 6 stages the phase-archive move
|
||||
// (`git add .planning/milestones/ .planning/phases/`) — both
|
||||
// workstream-scoped (phases_dir mirrors the phasesDir local above;
|
||||
// archive_dir mirrors cmdInitCompleteMilestone's own field of the same
|
||||
// name), so a literal root `git add` misses the actual files
|
||||
// phases.clear just moved under an active workstream.
|
||||
phases_dir: toPosixPath(phasesDir),
|
||||
archive_dir: toPosixPath(path.join(planningDir(cwd), 'milestones')),
|
||||
};
|
||||
|
||||
// `state:flat-mode` (#2994): whether NO workstream is active — the inverse
|
||||
@@ -3665,6 +3706,17 @@ function cmdInitProgress(cwd: string, raw: boolean, options: Record<string, unkn
|
||||
roadmap_path: toPosixPath(path.join(planningDir(cwd), 'ROADMAP.md')),
|
||||
// #4455 follow-up: PROJECT.md is shared across workstreams.
|
||||
project_path: toPosixPath(path.join(planningDir(cwd, null), 'PROJECT.md')),
|
||||
// #4456 correction: an isolated review pass initially "fixed" this to
|
||||
// planningDir(cwd, null) on the assumption that config.json is shared
|
||||
// like PROJECT.md (workstream-flag.md's directory diagram marks it
|
||||
// `# Shared`) — but ADR-0006's own tests (tests/init.test.cjs, "init
|
||||
// handlers honor GSD_WORKSTREAM") assert config_path IS workstream-scoped
|
||||
// for execute-phase/new-project/new-milestone/progress, and gsd-test
|
||||
// caught the regression immediately. The diagram is stale for
|
||||
// config.json specifically (same class of staleness already found for
|
||||
// `milestones/` during the #4455 follow-up) — reverted to the
|
||||
// workstream-aware planningDir(cwd), matching the established,
|
||||
// ADR-governed, tested contract.
|
||||
config_path: toPosixPath(path.join(planningDir(cwd), 'config.json')),
|
||||
};
|
||||
|
||||
|
||||
@@ -602,6 +602,16 @@ test('execute-phase.md: awk extracts resolves_phase from YAML frontmatter', () =
|
||||
// enough if GSD_WS was never re-derived and is always empty at runtime.
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
describe('new-milestone.md: workstream-aware PROJECT.md guard (#2308)', () => {
|
||||
// #4456: executed fences below use the runtime-launcher preamble, which
|
||||
// resolves gsd-tools.cjs via `${RUNTIME_DIR:-$(git rev-parse --show-toplevel
|
||||
// || pwd)}`. Any fence run with an ISOLATED `cwd` (a tmpDir outside the
|
||||
// repo, needed once Step 1's fence started performing a real
|
||||
// `.gsd-ws-arg` write) has no git repo to discover, and CI benches have no
|
||||
// global `gsd_run` on PATH to fall back to — so RUNTIME_DIR must be set
|
||||
// explicitly wherever an isolated `cwd` is used, or the preamble fails
|
||||
// with "gsd-tools.cjs not found" (a real bench failure this fix hit).
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
const runtimeDirEnv = { ...process.env, RUNTIME_DIR: REPO_ROOT };
|
||||
const workflowPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'new-milestone.md');
|
||||
// readFileNormalized() strips \r\n -> \n before either extractor below slices
|
||||
// a fence out of `content` — both fences are handed to execFileSync('bash', ...)
|
||||
@@ -668,11 +678,20 @@ describe('new-milestone.md: workstream-aware PROJECT.md guard (#2308)', () => {
|
||||
|
||||
describe('step 1: --ws parsing is real, executable shell (not prose)', () => {
|
||||
const step1Fence = extractFenceBetween(content, '## 1. Load Context', '## 2. Gather Milestone Goals');
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
function runStep1(argumentsValue) {
|
||||
const script = `ARGUMENTS=${JSON.stringify(argumentsValue)}\n${step1Fence}\n` +
|
||||
'printf \'GSD_WS=[%s]\\nMILESTONE_ARG=[%s]\\n\' "$GSD_WS" "$MILESTONE_ARG"';
|
||||
const r = runHookSeam('-c', [script], { interpreter: 'bash' });
|
||||
const r = runHookSeam('-c', [script], { interpreter: 'bash', cwd: tmpDir, env: runtimeDirEnv });
|
||||
throwIfFailed(r, 'bash <step1 fence>');
|
||||
const out = r.stdout;
|
||||
return {
|
||||
@@ -692,48 +711,314 @@ describe('new-milestone.md: workstream-aware PROJECT.md guard (#2308)', () => {
|
||||
assert.strictEqual(gsdWs, '');
|
||||
assert.strictEqual(milestoneArg, 'v2.0 Search');
|
||||
});
|
||||
|
||||
// #4456: GSD_WS is persisted to .planning/.gsd-ws-arg so later steps'
|
||||
// separate shells can forward it — without this file the fix is inert.
|
||||
test('#4456: persists GSD_WS to .planning/.gsd-ws-arg for later steps to read back', () => {
|
||||
runStep1('--ws search v2.0 Search');
|
||||
const persisted = fs.readFileSync(path.join(tmpDir, '.planning', '.gsd-ws-arg'), 'utf8');
|
||||
assert.strictEqual(persisted, '--ws search');
|
||||
});
|
||||
|
||||
test('#4456: persists an empty file in flat mode (regression guard)', () => {
|
||||
runStep1('v2.0 Search');
|
||||
const persisted = fs.readFileSync(path.join(tmpDir, '.planning', '.gsd-ws-arg'), 'utf8');
|
||||
assert.strictEqual(persisted, '');
|
||||
});
|
||||
});
|
||||
|
||||
describe('step 6: commit stages PROJECT.md in both modes, with no cross-step guard', () => {
|
||||
const step6CommitFence = extractFenceContaining(
|
||||
content,
|
||||
'## 6. Cleanup and Commit',
|
||||
'## 7. Load Context and Resolve Models',
|
||||
'docs: start milestone v[X.Y] [Name]'
|
||||
);
|
||||
describe('#4456: new-milestone.md forwards --ws to every downstream gsd_run call', () => {
|
||||
let tmpDir;
|
||||
|
||||
function runStep6Commit(argumentsValue) {
|
||||
const gsdRunStub = 'gsd_run() { printf "%s\\n" "gsd_run_call:$*"; }\n';
|
||||
const script = `ARGUMENTS=${JSON.stringify(argumentsValue)}\n${gsdRunStub}${step6CommitFence}`;
|
||||
const r = runHookSeam('-c', [script], { interpreter: 'bash' });
|
||||
throwIfFailed(r, 'bash <step6 commit fence>');
|
||||
return r.stdout;
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
/**
|
||||
* Stub gsd_run: `query init.new-milestone [--ws <name>]` returns a canned
|
||||
* JSON payload keyed by whether --ws was present in ITS OWN argv (so a
|
||||
* fence that forgets to forward $GSD_WS_ARG gets caught — the stub
|
||||
* doesn't just trust a variable, it inspects the actual call). Every
|
||||
* other call is recorded verbatim (gsd_run_call:<argv>) for assertion.
|
||||
*/
|
||||
function stubGsdRun(rootPaths, wsPaths) {
|
||||
const rootJson = JSON.stringify(rootPaths).replace(/'/g, "'\\''");
|
||||
const wsJson = JSON.stringify(wsPaths).replace(/'/g, "'\\''");
|
||||
return [
|
||||
'gsd_run() {',
|
||||
' if [ "$1" = "query" ] && [ "$2" = "init.new-milestone" ]; then',
|
||||
' case " $* " in',
|
||||
` *" --ws "*) printf '%s' '${wsJson}' ;;`,
|
||||
` *) printf '%s' '${rootJson}' ;;`,
|
||||
' esac',
|
||||
' else',
|
||||
' printf "gsd_run_call:%s\\n" "$*"',
|
||||
' fi',
|
||||
'}',
|
||||
].join('\n') + '\n';
|
||||
}
|
||||
|
||||
// Step 4 Part A's guard — not this commit — is what protects the shared
|
||||
// heading. Part B's Evolution backfill DOES write PROJECT.md in workstream
|
||||
// mode, so a ws-mode branch that dropped PROJECT.md from --files would
|
||||
// strand that edit uncommitted.
|
||||
for (const [mode, args] of [['ws', '--ws search v2.0 Search'], ['flat', 'v2.0 Search']]) {
|
||||
test(`${mode} mode: --files stages PROJECT.md so Part B's Evolution backfill is committed`, () => {
|
||||
const out = runStep6Commit(args);
|
||||
describe('step 5: state.get / state.milestone-switch', () => {
|
||||
const step5Fence = extractFenceBetween(content, '## 5. Update STATE.md', '## 6. Cleanup and Commit');
|
||||
|
||||
function runStep5(gsdWsArg) {
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', '.gsd-ws-arg'), gsdWsArg);
|
||||
const gsdRunStub = 'gsd_run() { printf "gsd_run_call:%s\\n" "$*"; }\n';
|
||||
const r = runHookSeam('-c', [gsdRunStub + step5Fence], { interpreter: 'bash', cwd: tmpDir, env: runtimeDirEnv });
|
||||
throwIfFailed(r, 'bash <step5 fence>');
|
||||
return r.stdout;
|
||||
}
|
||||
|
||||
test('ws mode: forwards --ws to both state.get and state.milestone-switch', () => {
|
||||
const out = runStep5('--ws search');
|
||||
assert.match(out, /gsd_run_call:query state\.get milestone --raw --ws search/,
|
||||
`expected state.get to forward --ws search, got: ${out}`);
|
||||
assert.match(out, /gsd_run_call:query state\.milestone-switch --milestone v\[X\.Y\] --name \[Name\] --ws search/,
|
||||
`expected state.milestone-switch to forward --ws search, got: ${out}`);
|
||||
});
|
||||
|
||||
test('flat mode: no stray --ws tokens on either call', () => {
|
||||
const out = runStep5('');
|
||||
assert.match(out, /gsd_run_call:query state\.get milestone --raw\s*$/m,
|
||||
`expected no trailing tokens after --raw in flat mode, got: ${out}`);
|
||||
assert.match(out, /gsd_run_call:query state\.milestone-switch --milestone v\[X\.Y\] --name \[Name\]\s*$/m,
|
||||
`expected no trailing tokens on state.milestone-switch in flat mode, got: ${out}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('step 6: phases.clear forwards --ws', () => {
|
||||
const phasesClearFence = extractFenceContaining(
|
||||
content, '## 6. Cleanup and Commit', '## 7. Load Context and Resolve Models', 'phases.clear',
|
||||
);
|
||||
|
||||
function runPhasesClearFence(gsdWsArg) {
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', '.gsd-ws-arg'), gsdWsArg);
|
||||
const gsdRunStub = 'gsd_run() { printf "gsd_run_call:%s\\n" "$*"; }\n';
|
||||
const r = runHookSeam('-c', [gsdRunStub + phasesClearFence], { interpreter: 'bash', cwd: tmpDir, env: runtimeDirEnv });
|
||||
throwIfFailed(r, 'bash <phases.clear fence>');
|
||||
return r.stdout;
|
||||
}
|
||||
|
||||
test('ws mode, no outgoing milestone: forwards --ws to the plain phases.clear branch', () => {
|
||||
const out = runPhasesClearFence('--ws search');
|
||||
assert.match(out, /gsd_run_call:query phases\.clear --confirm --ws search\s*$/m,
|
||||
`expected phases.clear to forward --ws search, got: ${out}`);
|
||||
});
|
||||
|
||||
test('ws mode, with outgoing milestone: forwards --ws alongside --archive-version', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', '.gsd-outgoing-milestone'), 'v1.0');
|
||||
const out = runPhasesClearFence('--ws search');
|
||||
assert.match(out, /gsd_run_call:query phases\.clear --confirm --archive-version v1\.0 --ws search\s*$/m,
|
||||
`expected phases.clear to forward both --archive-version and --ws, got: ${out}`);
|
||||
});
|
||||
|
||||
test('flat mode: no stray --ws token', () => {
|
||||
const out = runPhasesClearFence('');
|
||||
assert.match(out, /gsd_run_call:query phases\.clear --confirm\s*$/m,
|
||||
`expected no trailing tokens in flat mode, got: ${out}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('step 6: git add stages the resolved (workstream-scoped) archive/phases dirs', () => {
|
||||
const gitAddFence = extractFenceContaining(
|
||||
content, '## 6. Cleanup and Commit', '## 7. Load Context and Resolve Models', 'git add',
|
||||
);
|
||||
|
||||
function runGitAddFence(gsdWsArg, rootPaths, wsPaths) {
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', '.gsd-ws-arg'), gsdWsArg);
|
||||
const script = stubGsdRun(rootPaths, wsPaths) + gitAddFence +
|
||||
'\necho "GIT_ADD_CALL: git add \\"$ARCHIVE_DIR/\\" \\"$PHASES_DIR/\\""';
|
||||
const r = runHookSeam('-c', [script], { interpreter: 'bash', cwd: tmpDir, env: runtimeDirEnv });
|
||||
throwIfFailed(r, 'bash <git add fence>');
|
||||
return r.stdout;
|
||||
}
|
||||
|
||||
test('ws mode: stages the workstream-scoped archive_dir/phases_dir, not root', () => {
|
||||
const rootPaths = { archive_dir: '/root/milestones', phases_dir: '/root/phases' };
|
||||
const wsPaths = { archive_dir: '/ws/milestones', phases_dir: '/ws/phases' };
|
||||
const out = runGitAddFence('--ws search', rootPaths, wsPaths);
|
||||
assert.ok(out.includes('GIT_ADD_CALL: git add "/ws/milestones/" "/ws/phases/"'),
|
||||
`expected the workstream-scoped dirs to be staged, got: ${out}`);
|
||||
});
|
||||
|
||||
test('flat mode: stages the root archive_dir/phases_dir', () => {
|
||||
const rootPaths = { archive_dir: '/root/milestones', phases_dir: '/root/phases' };
|
||||
const wsPaths = { archive_dir: '/ws/milestones', phases_dir: '/ws/phases' };
|
||||
const out = runGitAddFence('', rootPaths, wsPaths);
|
||||
assert.ok(out.includes('GIT_ADD_CALL: git add "/root/milestones/" "/root/phases/"'),
|
||||
`expected the root dirs to be staged, got: ${out}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('step 7: init.new-milestone forwards --ws (round-trip file survives — steps 9/10 still need it)', () => {
|
||||
const step7Fence = extractFenceContaining(
|
||||
content, '## 7. Load Context and Resolve Models', 'Extract from init JSON', 'init.new-milestone',
|
||||
);
|
||||
|
||||
test('ws mode: forwards --ws alongside --reset-phase-numbers', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', '.gsd-ws-arg'), '--ws search');
|
||||
const gsdRunStub = 'gsd_run() { if [ "$1" = "query" ] && [ "$2" = "init.new-milestone" ]; then printf "gsd_run_call:%s\\n" "$*" >&2; echo "{}"; else echo "{}"; fi; }\n';
|
||||
const script = `ARGUMENTS="--reset-phase-numbers"\n${gsdRunStub}${step7Fence}`;
|
||||
const r = runHookSeam('-c', [script], { interpreter: 'bash', cwd: tmpDir, env: runtimeDirEnv });
|
||||
throwIfFailed(r, 'bash <step7 fence>');
|
||||
assert.match(r.stderr, /gsd_run_call:query init\.new-milestone --reset-phase-numbers --ws search\s*$/m,
|
||||
`expected --ws to be forwarded alongside --reset-phase-numbers, got: ${r.stderr}`);
|
||||
});
|
||||
|
||||
// #4456 code-review finding: Steps 9 and 10 (requirements/roadmap
|
||||
// commits) run AFTER step 7 and still need to re-read .gsd-ws-arg —
|
||||
// deleting it here (the original implementation) left them with no
|
||||
// way to resolve REQUIREMENTS.md/ROADMAP.md/STATE.md under a
|
||||
// workstream. See the "step 7 no longer deletes .gsd-ws-arg" test
|
||||
// below and the step 10 describe block for the corrected cleanup.
|
||||
test('does NOT remove .planning/.gsd-ws-arg — steps 9/10 still need it', () => {
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', '.gsd-ws-arg'), '--ws search');
|
||||
const gsdRunStub = 'gsd_run() { echo "{}"; }\n';
|
||||
const r = runHookSeam('-c', [gsdRunStub + step7Fence], { interpreter: 'bash', cwd: tmpDir, env: runtimeDirEnv });
|
||||
throwIfFailed(r, 'bash <step7 fence>');
|
||||
assert.ok(fs.existsSync(path.join(tmpDir, '.planning', '.gsd-ws-arg')),
|
||||
'.gsd-ws-arg must survive step 7 — steps 9 and 10 run after it and still need to read the file');
|
||||
});
|
||||
});
|
||||
|
||||
describe('step 6: commit resolves PROJECT.md/STATE.md through init.new-milestone, not literal paths', () => {
|
||||
const step6CommitFence = extractFenceContaining(
|
||||
content,
|
||||
'## 6. Cleanup and Commit',
|
||||
'## 7. Load Context and Resolve Models',
|
||||
'docs: start milestone v[X.Y] [Name]'
|
||||
);
|
||||
|
||||
function runStep6Commit(gsdWsArg, rootPaths, wsPaths) {
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', '.gsd-ws-arg'), gsdWsArg);
|
||||
const script = stubGsdRun(rootPaths, wsPaths) + step6CommitFence;
|
||||
const r = runHookSeam('-c', [script], { interpreter: 'bash', cwd: tmpDir, env: runtimeDirEnv });
|
||||
throwIfFailed(r, 'bash <step6 commit fence>');
|
||||
return r.stdout;
|
||||
}
|
||||
|
||||
// PROJECT.md is shared (stays root in both modes, #4455 follow-up);
|
||||
// STATE.md is workstream-scoped and must resolve accordingly.
|
||||
const rootPaths = { project_path: '/root/PROJECT.md', state_path: '/root/STATE.md' };
|
||||
const wsPaths = { project_path: '/root/PROJECT.md', state_path: '/ws/STATE.md' };
|
||||
|
||||
test('ws mode: --files uses the resolved workstream STATE.md, shared root PROJECT.md', () => {
|
||||
const out = runStep6Commit('--ws search', rootPaths, wsPaths);
|
||||
assert.ok(
|
||||
out.includes('--files .planning/PROJECT.md .planning/STATE.md'),
|
||||
`expected PROJECT.md + STATE.md --files in ${mode} mode, got: ${out}`
|
||||
out.includes('gsd_run_call:query commit docs: start milestone v[X.Y] [Name] --files /root/PROJECT.md /ws/STATE.md'),
|
||||
`expected the resolved ws-mode paths in --files, got: ${out}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test('does not guard the commit on GSD_WS — a cross-step variable is always empty here', () => {
|
||||
// Regression guard for the inert-guard trap: GSD_WS is assigned in Step
|
||||
// 1's shell, and each step's bash block runs in its own shell (the same
|
||||
// reason Step 5 round-trips OUTGOING_MILESTONE through a file). A
|
||||
// `[ -n "$GSD_WS" ]` branch here reads an unset variable, always takes
|
||||
// the flat branch, and only appears to work.
|
||||
assert.ok(
|
||||
!/\[\s*-n\s*"\$GSD_WS"\s*\]/.test(step6CommitFence),
|
||||
`step 6 must not branch on a cross-step GSD_WS; got fence:\n${step6CommitFence}`
|
||||
test('flat mode: --files uses the resolved root paths for both', () => {
|
||||
const out = runStep6Commit('', rootPaths, wsPaths);
|
||||
assert.ok(
|
||||
out.includes('gsd_run_call:query commit docs: start milestone v[X.Y] [Name] --files /root/PROJECT.md /root/STATE.md'),
|
||||
`expected the resolved flat-mode paths in --files, got: ${out}`
|
||||
);
|
||||
});
|
||||
|
||||
test('does not guard the commit on a bare cross-step GSD_WS variable (regression guard)', () => {
|
||||
// Regression guard for the inert-guard trap: GSD_WS is assigned in Step
|
||||
// 1's shell, and each step's bash block runs in its own shell. A
|
||||
// `[ -n "$GSD_WS" ]` branch here would read an unset variable, always
|
||||
// take the flat branch, and only appear to work — the fix instead
|
||||
// reads $GSD_WS_ARG back from the persisted file.
|
||||
assert.ok(
|
||||
!/\[\s*-n\s*"\$GSD_WS"\s*\]/.test(step6CommitFence),
|
||||
`step 6 must not branch on a bare cross-step GSD_WS; got fence:\n${step6CommitFence}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// #4456 code-review finding: Steps 9 and 10 ALSO commit workstream-scoped
|
||||
// files (REQUIREMENTS.md, ROADMAP.md, STATE.md) via literal root paths —
|
||||
// the same bug class as Step 6, missed in the first pass. Because these
|
||||
// steps run AFTER Step 7 (where .gsd-ws-arg was previously being deleted),
|
||||
// fixing them required moving the round-trip file's cleanup to Step 10 —
|
||||
// its true last consumer — instead of Step 7.
|
||||
describe('step 9: requirements commit resolves REQUIREMENTS.md through init.new-milestone', () => {
|
||||
const step9Fence = extractFenceContaining(
|
||||
content, '## 9. Define Requirements', '## 10. Create Roadmap', 'docs: define milestone',
|
||||
);
|
||||
|
||||
function runStep9(gsdWsArg, rootPaths, wsPaths) {
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', '.gsd-ws-arg'), gsdWsArg);
|
||||
const script = stubGsdRun(rootPaths, wsPaths) + step9Fence;
|
||||
const r = runHookSeam('-c', [script], { interpreter: 'bash', cwd: tmpDir, env: runtimeDirEnv });
|
||||
throwIfFailed(r, 'bash <step9 fence>');
|
||||
return r.stdout;
|
||||
}
|
||||
|
||||
const rootPaths = { requirements_path: '/root/REQUIREMENTS.md' };
|
||||
const wsPaths = { requirements_path: '/ws/REQUIREMENTS.md' };
|
||||
|
||||
test('ws mode: --files uses the resolved workstream REQUIREMENTS.md', () => {
|
||||
const out = runStep9('--ws search', rootPaths, wsPaths);
|
||||
assert.ok(
|
||||
out.includes('gsd_run_call:query commit docs: define milestone v[X.Y] requirements --files /ws/REQUIREMENTS.md'),
|
||||
`expected the resolved ws-mode path, got: ${out}`
|
||||
);
|
||||
});
|
||||
|
||||
test('flat mode: --files uses the resolved root REQUIREMENTS.md', () => {
|
||||
const out = runStep9('', rootPaths, wsPaths);
|
||||
assert.ok(
|
||||
out.includes('gsd_run_call:query commit docs: define milestone v[X.Y] requirements --files /root/REQUIREMENTS.md'),
|
||||
`expected the resolved flat-mode path, got: ${out}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('step 10: roadmap commit resolves ROADMAP/STATE/REQUIREMENTS through init.new-milestone, then cleans up .gsd-ws-arg', () => {
|
||||
const step10Fence = extractFenceContaining(
|
||||
content, '## 10. Create Roadmap', '## 10.5.', 'docs: create milestone v[X.Y] roadmap',
|
||||
);
|
||||
|
||||
function runStep10(gsdWsArg, rootPaths, wsPaths) {
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', '.gsd-ws-arg'), gsdWsArg);
|
||||
const script = stubGsdRun(rootPaths, wsPaths) + step10Fence;
|
||||
const r = runHookSeam('-c', [script], { interpreter: 'bash', cwd: tmpDir, env: runtimeDirEnv });
|
||||
throwIfFailed(r, 'bash <step10 fence>');
|
||||
return r.stdout;
|
||||
}
|
||||
|
||||
const rootPaths = { roadmap_path: '/root/ROADMAP.md', state_path: '/root/STATE.md', requirements_path: '/root/REQUIREMENTS.md' };
|
||||
const wsPaths = { roadmap_path: '/ws/ROADMAP.md', state_path: '/ws/STATE.md', requirements_path: '/ws/REQUIREMENTS.md' };
|
||||
|
||||
test('ws mode: --files uses all three resolved workstream paths', () => {
|
||||
const out = runStep10('--ws search', rootPaths, wsPaths);
|
||||
assert.ok(
|
||||
out.includes('gsd_run_call:query commit docs: create milestone v[X.Y] roadmap ([N] phases) --files /ws/ROADMAP.md /ws/STATE.md /ws/REQUIREMENTS.md'),
|
||||
`expected the resolved ws-mode paths, got: ${out}`
|
||||
);
|
||||
});
|
||||
|
||||
test('flat mode: --files uses all three resolved root paths', () => {
|
||||
const out = runStep10('', rootPaths, wsPaths);
|
||||
assert.ok(
|
||||
out.includes('gsd_run_call:query commit docs: create milestone v[X.Y] roadmap ([N] phases) --files /root/ROADMAP.md /root/STATE.md /root/REQUIREMENTS.md'),
|
||||
`expected the resolved flat-mode paths, got: ${out}`
|
||||
);
|
||||
});
|
||||
|
||||
test('removes .planning/.gsd-ws-arg after this commit (the true last consumer, not step 7)', () => {
|
||||
runStep10('--ws search', rootPaths, wsPaths);
|
||||
assert.ok(!fs.existsSync(path.join(tmpDir, '.planning', '.gsd-ws-arg')),
|
||||
'.gsd-ws-arg should be cleaned up here, since steps 9 and 10 still need it after step 7');
|
||||
});
|
||||
});
|
||||
|
||||
test('step 7 no longer deletes .gsd-ws-arg (steps 9/10 still need it)', () => {
|
||||
const step7Fence = extractFenceContaining(
|
||||
content, '## 7. Load Context and Resolve Models', 'Extract from init JSON', 'init.new-milestone',
|
||||
);
|
||||
assert.ok(!step7Fence.includes('rm -f .planning/.gsd-ws-arg'),
|
||||
'step 7 must not delete .gsd-ws-arg — steps 9 and 10 run after it and still need to read the file');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user