Commit Graph

5 Commits

Author SHA1 Message Date
Tom Boucher
a27cb6b2fa enhance(#4139): Phase 6 — the lazily-read remainder and the artifact templates (#4540)
* enhance(#4406): the lazily-read remainder and the artifact templates

ADR-4139 Decision 3, Phase 6 of the #4139 Compact Content epic. Covers stream 1b
(gsd-core/workflows/<name>/{modes,steps,templates}/*.md) and stream 4
(gsd-core/templates/**) with a variant-swap mechanism, confirmed with the user:
two independent, complete files per covered path (canonical + .compact.md
sibling), with the gate picking which one gets Read at the call site. This is
a different shape from Phase 5's spine+detail partition, and is safe here
specifically because these files are already reached only by a runtime Read —
a missed Read already means zero overlay content today, with or without
workflow.compact_content, so selecting between two independently-complete
files introduces no new failure mode (documented in
gsd-core/references/compact-content-gate.md's new "Streams 1b and 4" section).

Disposition, after inspecting every candidate rather than trusting a byte-size
threshold (same rigor Phase 5 applied to review.md):

- Stream 1b: 1 of 78 files compacted (help/modes/full.md, a user-facing
  reference doc emitted verbatim, not orchestrator instruction). The other 9
  size-threshold candidates are dominated by fail-closed guards, exact CLI
  invocations, or output-format contracts (AskUserQuestion blocks) — recorded
  not-worth-compacting, same reasoning as Phase 5's review.md.
- Stream 4: a ground-truth reachability audit replaced the initial size-only
  candidate list. Two files (summary.md, user-setup.md) got compact variants;
  a third (spec.md) was drafted, then dropped after discovering its only two
  call sites are eager @-includes, not a runtime Read — stream-1 material
  hiding under gsd-core/templates/, not stream-4's actual mechanism. summary.md
  itself has 3 eager call sites and only 1 genuine runtime-Read call site
  (execute-plan.md); only that one was wired, so the compact variant's savings
  apply to the sequential single-plan execution path only.
- Discovered while auditing reachability: 12 gsd-core/templates/** files with
  zero references anywhere in workflow/agent/command prose, compiled source,
  or tests — dead scaffolding predating this phase. Deleted in this same PR
  per this repo's no-defer policy, after re-verifying against a computed
  path.join(...) pattern (not just a plain-string search) that nearly caused
  two genuinely load-bearing templates (user-profile.md, dev-preferences.md)
  to be misclassified as dead.

New checker (tests/helpers/compact-content-variant.cjs): registration,
reachability, protected-content-preserved, size-smaller — replacing Phase
3/5's disjointness/completeness checks, which assume a partition rather than
two deliberately-overlapping documents. The reachability check's own
"unprefixed match" guard had a real bug (rejected the repo's own
`~/.claude/gsd-core/...` convention), caught by running it against the
already-wired help/modes/full.compact.md pair rather than only synthetic
fixtures — fixed to anchor on the nearest `gsd-core` path segment instead.

Template consumer parity (tests/compact-content-template-variant-parity.test.cjs):
proves each compact variant's `## File Template` fenced block — the actual
output-format contract a generated SUMMARY.md/USER-SETUP.md is parsed
against — is byte-identical to the canonical file, then runs the one real
deterministic consumer (gsd-core/bin/lib/coverage.cjs's classifyContent,
backing `gsd-tools uat classify-coverage`) against content built from that
shared contract.

Added a sibling benchmark script (scripts/benchmark-compact-content-variants.cjs)
rather than extending the existing spine/detail one — different data shape,
and the existing script's own contract deliberately isolates it from a
test-only helper's shape changing.

Emitted-drift acknowledgement: not needed. Every changed/added path in this
diff is hand-authored and present in the diff itself, so diffEmitted's
attribution loop resolves `via` to the path's own source before reaching the
ack-lookup branch (same reasoning Phase 5 verified for its own diff).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* enhance(#4406): address code-review findings on the variant-swap gate

- docs/CONFIGURATION.md and gsd-core/references/planning-config.md's
  workflow.compact_content entries described only the spine+detail mechanism
  (Phase 5) and were missing this phase's variant-swap mechanism and its
  benchmark:compact-content-variants script entirely — required since this
  PR's changeset is type Added (CLAUDE.md's "Missing Docs for Changesets"
  rule). Both now describe both mechanisms and which call sites are wired.
- Added the missing RED^-1/no-op fixture for checkProtectedContentPreserved:
  a canonical file with zero <!-- gsd:protected --> blocks must be a
  no-op, not a violation — the only branch of that function the existing
  fixtures didn't exercise.
- Collapsed findCompactFiles/findMarkdownFiles in
  tests/helpers/compact-content-variant.cjs into one findFilesWithSuffix
  helper — the two were identical recursive walks differing only in the
  extension predicate (minor Duplicated-Code finding).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4406): restore copilot-instructions.md, a false-positive dead-template classification

gsd-test caught this, not static analysis: 10 real failures in
tests/copilot-install.test.cjs, tests/installer-migration-install.integration.test.cjs,
and tests/repo-layout.test.cjs — all downstream of bin/install.js's Copilot install
path, which does
fs.readFileSync(path.join(targetDir, 'gsd-core', 'templates', 'copilot-instructions.md'))
after copying gsd-core/templates/** into the target project, then merges it into both
.github/copilot-instructions.md and (local installs) AGENTS.md. The reachability audit
that flagged this file as dead checked src/*.cts and gsd-core/bin/*.cjs but never the
repo-root bin/install.js — a separately maintained installer bundle outside the
src/-to-gsd-core/bin/lib/ compiled-output convention. The fs.existsSync guard around
that read degrades to a silent skip rather than a crash when the template is missing,
which is why this surfaced only once the real E2E install test ran, not from any
static check.

Re-verified the remaining 11 deleted filenames against bin/install.js specifically
(plain substring and quoted-filename search) before trusting that list — all 11 have
zero hits there, confirmed dead by the same standard this one file failed.

Regenerated the installer emitted-tree goldens (tests/fixtures/install-tree/*.json) to
reflect the restored file, and corrected the "Removed" changeset (jolly-lynx-sprint.md)
and the phase design doc from 12 to 11 deleted files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Emitted-Drift-Ack-Growth: execute-plan.md — call-site wiring for the summary.md and user-setup.md .compact.md variants
Emitted-Drift-Ack-Growth: help.md — call-site wiring for full.compact.md, same variant-resolution rule

* docs(#4406): backfill changeset PR numbers

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4406): resolve removed-but-needed lint findings on the dead-template deletion

CI's own full-test matrix (not gsd-test's matrix, which does not run this
check) caught 4 more false-positive dead-template classifications via
tests/removed-but-needed-lint.test.cjs / scripts/lint-removed-but-needed.cjs
— a literal, word-boundary basename check across .github/workflows/,
gsd-core/, and docs/ (excluding docs/adr/** and docs/research/**) for every
file a PR deletes. It has no semantic awareness, so a deleted template's
basename colliding with something else entirely still fires:

- claude-md.md: gsd-core/templates/README.md had a stale table row claiming
  /gsd-profile reads this template to generate CLAUDE.md. Verified false (no
  code reads it anywhere, same search that already covered bin/install.js) —
  fixed the row to *(inline)*, matching every other command-generated
  artifact in that table. File stays deleted.
- codebase/testing.md: collided with docs/guides/testing.md, an illustrative
  example row in docs-update.md's sample output table (an unrelated real
  generated-docs path). Swapped the example topic to "contributing" — the
  row is illustrative, any topic works. File stays deleted.
- codebase/architecture.md, codebase/stack.md: collided with docs/reference/
  planning-artifacts.md's directory listing of a user's own generated
  .planning/codebase/architecture.md and stack.md output — the same
  semantic mismatch already investigated and dismissed as unrelated earlier
  in this phase's audit, now caught by a gate instead of judgment. That
  listing repeats across 5 locale copies of the doc.
- continue-here.md: collided with the real .continue-here.md pause-work
  artifact, referenced across 15+ locale and workflow files.

For the last two, the lint's own error message offers "restore the file or
update every consumer in the same commit." Rewording 15+ files across
languages I cannot verify translation quality for, to shave 2 already-tiny
templates that were merely presumed dead, is disproportionate to this PR's
actual scope — restored codebase/architecture.md, codebase/stack.md, and
continue-here.md instead, and corrected docs/ARCHITECTURE.md's Templates
section accordingly.

Final confirmed-dead set: claude-md.md, codebase/concerns.md,
codebase/conventions.md, codebase/integrations.md, codebase/structure.md,
codebase/testing.md, debug-subagent-prompt.md, discovery.md — 8 files, down
from the original 12. Verified locally: GSD_REMOVED_BUT_NEEDED_BASE=next
node scripts/lint-removed-but-needed.cjs now passes clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Emitted-Drift-Ack-Growth: docs-update.md — swapped an illustrative example-table topic (testing -> contributing) to avoid a removed-but-needed basename collision with the deleted codebase/testing.md template; net +10 bytes

* fix(#4406): split codex-config.test.cjs to fix a genuine Windows CI timeout

Root cause of the `full test (windows-latest, 24, shard 2/3)` failure the
user asked to be actually fixed, not just re-run past: PR #4497 (landed
2026-09-07, one day before this PR's CI run) isolated
tests/codex-config.test.cjs into its own dedicated chunk because its
measured weight (17.87, ~45% of the post-cut Windows budget) made it unsafe
to share a chunk with any other file. That isolation was necessary but not
sufficient — even alone, with zero companion-file contention, the file's
real Windows execution time sits right at the 600s per-chunk ceiling. Two
independent CI runs on two unrelated PRs (this one and #4154) were both
killed within ~1.4s of the identical 600000ms mark — not random contention,
a deterministic near-miss the isolation fix couldn't address because it
never reduced the file's own cost, only removed the risk of a companion
file's cost stacking on top of it (which the PR #4497 comment explicitly
anticipated: "if a future profiling pass genuinely speeds up
codex-config.test.cjs itself, this isolation can be revisited").

The file itself explains why it's this heavy: 11,262 lines / 433 tests / 79
describe blocks, accumulated over dozens of bug-fix PRs (#2695, #2760,
#3245, #3285, #3346, #3426, #3427, #3562, #3566, #3582, #3808, and more),
several of which are explicitly documented as "folded" in from separate
files that were never actually split back out ("Verified non-duplicate
against both the pre-existing target and the other three folded sources").

Split into 4 files by top-level AST statement boundaries (never a naive
column-0 regex — an early attempt at that overcounted 79 apparent
"describe(" matches when only 21 are genuinely top-level; the rest are
nested inside a handful of large folded-in blocks, which a regex can't tell
apart from real top-level statements). Verified lossless twice: the split
script asserts byte-for-byte reconstruction of every source character, and
independently, total test()/describe() call counts match exactly between
the original file and the sum across all 4 new files (433/79 both sides).
Each new file carries the complete original shared header (imports/helpers)
for safety; per-file unused-import warnings from that duplication are
resolved via ESLint-precise alias renames (`{ foo: _foo }`, the standard
form for an intentionally-unused destructured binding — never a bare `{
_foo }`, which would destructure a different, nonexistent property).

No change needed to scripts/run-tests.cjs's ISOLATED_HEAVY_FILES or its
pinned test in tests/run-tests-harness.test.cjs: the file that keeps the
original name (tests/codex-config.test.cjs) is now only ~28% of the
original's size and safely isolated in its own chunk as before; the other
three new files re-enter normal weight-balanced packing, none individually
close to disproportionate. Confirmed no other file hardcodes the hardcoded
filename anywhere that would silently stop these tests from running (the
CI test-selection scripts determine scope algorithmically, not by literal
filename).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 14:31:04 -04:00
Tom Boucher
511c900052 fix(#4458): reuse detectSubRepos for new-project.md's sub-repo detection (#4548)
* fix(#4458): reuse detectSubRepos for new-project.md's sub-repo detection

new-project.md's Step 5.1 (Sub-Repo Detection) ran its own bash predicate:

  find . -maxdepth 1 -type d -not -name ".*" -not -name "node_modules" \
    -exec test -d "{}/.git" \; -print

`test -d` requires .git to be a DIRECTORY. A linked git worktree's .git is
a FILE (a `gitdir: <path>` pointer), so this predicate silently excluded
valid linked-worktree children while still finding ordinary clones.

src/core-utils.cts's detectSubRepos(cwd) already handles this correctly
(fs.existsSync, type-agnostic) but had zero callers anywhere in the
codebase -- orphaned logic the workflow never actually used, despite
duplicating a narrower version of the same check inline.

Wired detectSubRepos into cmdInitNewProject's JSON output as a new
sub_repos_detected field (matching the file's existing pattern of
similar directory-scan-derived fields like has_existing_code/
is_brownfield/has_codebase_map) and replaced the workflow's raw find
fence with a gsd_run query init.new-project call reading that field --
removing the duplicate, narrower detection logic entirely rather than
patching it in place, per the issue's own "reuse a central policy"
framing.

Added the missing .git-as-FILE test case to the existing
tests/core-utils.test.cjs detectSubRepos coverage (proving the helper
was already correct -- the defect was entirely in the unwired workflow
predicate) plus CLI-level end-to-end coverage in
tests/init-manager.test.cjs using a REAL `git worktree add` fixture,
matching the issue's own reproduction steps, alongside an ordinary
child-clone case and a non-repository-directory negative case.

Refreshed tests/fixtures/compact-content-benchmark-baseline.json (gsd-test
caught the drift from new-project.md's byte-count change; the benchmark
script itself always exits 0 -- report, not gate -- but the wrapper test
enforces the committed baseline stays in sync).

Emitted-Drift-Ack-Growth: new-project.md — #4458 replaces the raw find predicate in Step 5.1 with a gsd_run query call reading the new sub_repos_detected field, net +140 bytes
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#4458): backfill changeset PR number

pr: 0 -> pr: 4548

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4458): bound the new git worktree add spawn with a named timeout

CI's lint-tests caught two ESLint findings my local gsd-test run
couldn't see (gsd-test's matrix doesn't run npm run lint:ci -- same gap
already observed on #4456's PR):

- local/no-unbounded-spawn: the new execFileSync('git', ['worktree',
  'add', ...]) call had no timeout, an indefinite-hang risk.
- local/no-adhoc-timeout-literal: my first fix (a bare `timeout: 15_000`
  literal) was itself flagged -- two independent hardcoded copies of a
  guessed timeout can silently drift or collide (this repo hit exactly
  that on 2026-09-06, PR #4428).

Fixed by importing GIT_FIXTURE_TIMEOUT_MS from tests/helpers/timeouts.cjs
-- `git worktree add` checks out files into a new working tree, the same
"construction" weight class as init/config/add/commit that constant
already covers, not plain plumbing (GIT_TIMEOUT_MS's class).

Verified via `npm run lint` directly (clean) before re-running gsd-test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4458): reduce redundant git subprocess overhead in new tests

CI's full-test Windows shard 2/3 failed: chunk 1/9 (306 files) exceeded
its internal 600s budget and was force-killed, with an unrelated file
(codex-config.test.cjs) in flight at the moment of the kill -- meaning
the chunk's AGGREGATE runtime, not any single hang, blew the budget.

This PR's own three new tests each independently called
createTempGitProject() (git init + a commit), and one of them also runs
git worktree add -- real subprocess spawns, each Defender-scanned on
Windows CI (tests/helpers/timeouts.cjs's own documented rationale for
why Windows spawn classes get generous budgets). That's a genuine,
quantifiable overhead addition to the exact chunk that timed out, not
something to wave off as unrelated flake without checking.

Two of the three tests never actually needed a real git repo --
detectSubRepos only inspects a CHILD directory's own .git, never the
root's git state, and the existing SUBCOMMANDS loop earlier in this same
file already proves `init new-project` succeeds against a plain,
non-git createTempProject() fixture. Switched those two tests to the
lighter fixture, leaving only the one test that genuinely needs a real
repo (git worktree add requires one) on createTempGitProject().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 12:39:34 -04:00
Tom Boucher
c6df4e1e46 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>
2026-09-08 04:56:11 -04:00
Tom Boucher
e03921c7d8 enhance(#4405): split the rest of the eager-window workflows worth splitting (#4536) 2026-09-08 00:17:22 -04:00
Tom Boucher
93e141a006 enhance(#4139): Phase 4 — measure the window instead of asserting it (#4502)
* enhance(#4404): add offline token benchmark for compact-content splits

ADR-4139 Decision 2 requires the finite-attention justification for
workflow.compact_content to be measured, not asserted. `npm run
benchmark:compact-content` computes, per registered spine/detail split
discovered under gsd-core/workflows/, the token count with the split
active (spine alone) vs inactive (spine + all detail parts read back
in), using gpt-tokenizer (pinned exact devDependency — Anthropic
publishes no tokenizer for Claude 3+, so every output surface labels
this a PROXY-TOKENIZER comparison: the on/off delta is exact under one
tokenizer applied identically to both sides, the absolute counts are
not Claude's real ones).

Reporting-only by design and verified so: --check diffs the live
recompute against a committed baseline (tests/fixtures/compact-content-benchmark-baseline.json)
and prints drift, but never exits non-zero for a drifted or missing
baseline — the only thing allowed to fail this script is a genuine I/O
error reading a source .md file it's measuring. Not wired into lint:ci
or pretest.

Discovery is deliberately reimplemented rather than importing
tests/helpers/compact-content-split.cjs (Phase 3, #4403), keeping a
scripts/ reporting tool from depending on a test-only module.

tests/fixtures/deny-network.cjs preloads via NODE_OPTIONS=--require to
prove the benchmark makes no network call, monkeypatching http/https/
net/dns/fetch to throw rather than relying on sandboxing.

docs/CONFIGURATION.md documents the new benchmark against the
workflow.compact_content key to satisfy this repo's docs-required gate
for an Added-type changeset.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(#4404): address orthogonal review findings on the token benchmark

Standards axis found two hard violations against documented rules:
- CLAUDE.md's Generative Fix Divergence rule requires a parity assertion
  for shared discovery logic maintained in two places. Added a test
  comparing benchmark-compact-content.cjs's own discoverRegisteredSplits
  against tests/helpers/compact-content-split.cjs's version on the real
  repo tree, so the two can never silently drift apart.
- The changeset body closed its bold span with a period and continued
  as a second sentence, instead of the canonical
  `**<phrase>** — <explanation>.` shape CONTRIBUTING.md documents.

Spec axis found the "network disabled + identical output across two
runs" Done-when criterion was verified as two separate properties
(determinism tested without network denial, offline survival tested as
a single run) rather than as one combined property. Added a test that
runs the benchmark twice under the deny-network preload and asserts
byte-identical stdout.

Security axis found tests/fixtures/deny-network.cjs didn't patch
dns.promises (a separate binding from the callback dns API), tls.connect,
or http2.connect — inert today since nothing in the benchmark calls
them, but a silent gap in what the preload's own header claims to
guarantee. Patched all three.

CLAUDE.md's Property-Based Testing rule also requires a fast-check test
for budget-limit arithmetic; added one for computeAggregate's off/on
summation (true sum over N splits, never NaN/Infinity, never exceeds
100% when off >= on for every split).

Standards axis's remaining two findings (a Data Clumps observation on
the {offTokens, onTokens, reductionPct} triple, and mild duplication in
formatDriftReport's three line-formatters) are left as judgement calls:
introducing a named type for a 3-field local tuple, or a formatter
abstraction for three short lines, would be exactly the premature
abstraction CLAUDE.md's engineering guidance warns against for a script
this size.

All changes verified directly (parity logic, the fast-check property,
and the three newly-denied network surfaces actually throwing under the
preload) via node -e before committing; full npm run lint:ci passes
with the eslint cache cleared.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* docs(#4404): backfill changeset pr number to 4502

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 19:19:03 -04:00