* test(#1882): stage one file per commit in the base-ref ancestry fixture CI failed on ubuntu-24 inside this test's setup loop, before any code under test ran: at commit 32 of 60 the index referenced a blob whose object write had not landed -- "invalid object ... for 'base-31.txt' / Error building trees". The loop staged with `git add .`, which re-stages every file already in the tree. Across 60 iterations that rehashes O(n squared) blobs -- roughly 1,800 stagings and 60 full index rewrites to add 60 one-line files -- and that churn is what the object store failed under. Each commit only ever adds a single new file, so staging that one path is equivalent and removes the redundant work entirely. Verified the loop still builds the intended history: 61 commits, git fsck clean. The fixture already carries a note from an earlier fix in this epic recording that it passed on ubuntu-22 and windows-24 and failed on ubuntu-24 for the same commit. That was a different stage -- fetch versus diff -- but the same lane and the same brittleness, so this is the second time this fixture's cost has surfaced as a red build rather than as a test failure. Not caused by this PR's change, which touches two configuration lists and cannot reach a scratch git repository in tmpdir. Fixed here rather than deferred, because the run surfaced it. Refs #1879 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#1881): prove an unreadable ROADMAP is indistinguishable from an absent one Failing-first. Encodes the issue's runtime repro: an unreadable ROADMAP.md makes getRoadmapPhaseInternal return the same null it returns for "phase not found", and getMilestoneInfo return the same {v1.0, milestone} it returns for a project with no roadmap at all -- so a permission or I/O fault reads as a brand-new project. Half these cases exist to hold the opposite line. getMilestoneInfo has no existsSync guard, so platformReadSync's null-for-ENOENT is converted to a synthetic Error carrying no errno, and that lands in the SAME catch as a real EACCES. Reporting unconditionally there would flag every project without a ROADMAP.md -- every brand-new project -- as corrupt. The absent case, the errno-less error, a non-string errno, unparseable content and a genuinely missing phase are all pinned silent. One case guards a decision rather than behaviour: an unreadable STATE.md alone must stay silent, because the inner catch that swallows it is deliberate and documented under the #2245 audit as an optional enhancement falling back to ROADMAP-only heuristics. Two more pin the invariant ADR-1411 names explicitly -- neither function may throw, because src/state.cts removed its own defensive try/catch on the strength of that guarantee. Assertions are on the frozen reason enum and the emission counter, never on diagnostic prose. Faults are injected by overriding the platformReadSync seam and restoring in t.after(), never chmod 0o000, which root bypasses. Adds the ROADMAP_UNREADABLE reason to the shared vocabulary as scaffolding; no call site emits it yet, which is what makes these tests red. Refs #1879 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#1881): report an unreadable ROADMAP instead of reading it as absent getRoadmapPhaseInternal returned null for a read failure exactly as it does for "phase not found", and getMilestoneInfo returned {v1.0, milestone} exactly as it does for a project with no roadmap -- so a permission or I/O fault presented as a brand-new project and workflows synthesised a blank phase or skipped requirement extraction with no signal. Both return values are preserved exactly, per ADR-1411's amendment: continuity is correct, the silence was the defect. Each catch now reports through the shared unusable-input seam that shipped with #1882 rather than a second copy of the same mechanism. The discriminator is the errno, and it is load-bearing in the silent direction. getMilestoneInfo has no existsSync guard, so platformReadSync's null-for-ENOENT is converted into a synthetic Error with no code that lands in the same catch as a real EACCES. Reporting unconditionally there would flag every project without a ROADMAP.md -- every brand-new project -- as corrupt. A genuine read fault always carries an errno; absence never does. The parse is regex over text and cannot throw, so nothing else reaches these catches. Neither function gains a throw. ADR-1411 names this explicitly: src/state.cts removed its defensive try/catch around getMilestoneInfo under the #2245 audit because it never throws, and two tests pin that. The inner STATE.md catch stays untouched and silent -- its fallback to ROADMAP-only heuristics is a deliberate, documented optional-enhancement path, not a fault. Where the fix belongs was the design question. platformReadSync does not leak: it keeps absent and unusable as two channels, exactly as an abstraction should. Both callers re-collapsed that distinction, so the fix is caller-side and the projection seam -- with roughly ninety other dependents -- is untouched. Closes #1881 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#1881): admit the roadmap reason to the locked vocabulary The seam documents adding a reason as three coordinated changes -- the enum entry, the emitting call site, and the test that locks Object.keys(...).sort(). This PR made the first two and the lock caught the third, which is the whole point of pinning the key set rather than asserting each value exists. The roadmap suite no longer re-locks the full set. Two complete locks would mean two files to update every time a later phase adds a reason, and #1883 and #1884 are both going to. The canonical lock stays in the seam's own suite; the roadmap suite asserts only the value it introduces. Refs #1879 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#1881): resolve the roadmap path inside the try, not outside it Naming the file in the diagnostic required the resolved path in the catch, and the obvious way to get it was to hoist `path.join(planningDir(cwd), 'ROADMAP.md')` above the try. planningDir throws a plain Error for an invalid GSD_WORKSTREAM or GSD_PROJECT segment -- one containing a slash, backslash or `..` -- so hoisting it let that throw escape uncaught. That broke the exact invariant ADR-1411 names as this file's hazard: src/state.cts removed its defensive try/catch around getMilestoneInfo under the #2245 audit because that function never throws. Of its callers only archivePhaseDirectories wraps it; cmdInitExecutePhase, cmdInitNewMilestone, cmdInitMilestoneOp, cmdInitManager, cmdInitProgress, cmdProgressRender and cmdStats all call it bare, so a workstream name with a slash in it crashed the CLI outright instead of degrading. The previous commit asserted "neither function gains a throw -- two tests pin that". That was false. Both tests inject faults through platformReadSync only and never through planningDir, so neither could have exercised the path that broke. The guarantee was claimed, not demonstrated. The path is now declared before the try and resolved inside it, so the catch can still name the file when there is one, and a path that never resolved reports nothing and returns the sentinel unchanged. The two test names are narrowed to what they actually prove -- that a failing READ does not throw -- and a new case injects the planningDir failure directly, which is what would have caught this. getRoadmapPhaseInternal carried the same hazard, resolving the path outside its try since before this branch. It is fixed the same way rather than left: ADR-227 is explicit that throwing breaks pipeline continuity, this read path already degrades to null for every other failure, and a PR whose purpose is hardening this invariant is the wrong place to leave the sibling crashing. Behaviour otherwise unchanged and re-verified: healthy lookups, EACCES reporting on both functions, absent-roadmap silence, and the errno discriminator all unaffected. Refs #1879 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#1881): backfill changeset pr number to 2729 --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Changeset Fragments
This directory holds per-PR CHANGELOG fragments. Every PR with user-facing changes drops one (or more) <random-name>.md files here describing its CHANGELOG entry. Fragments are consolidated into the top-level CHANGELOG.md at release time.
Why
Two PRs that both edit the ### Fixed block of CHANGELOG.md always conflict on merge — git can't pick a serialization order without human input. Two PRs that each add a fresh .changeset/<unique-name>.md never conflict because they don't share lines.
See #2975 for the full rationale.
Adding a fragment
node scripts/changeset/new.cjs \
--type Fixed \
--pr 1234 \
--body "fix the thing — explain the user-visible change in one sentence"
This writes .changeset/<adjective>-<noun>-<noun>.md with frontmatter and a body. Three random words → concurrent PRs don't collide.
Format
---
type: Fixed
pr: 1234
---
**`/gsd-foo` no longer drops trailing slashes** — explain the user-visible change.
Allowed type: values follow Keep a Changelog: Added, Changed, Deprecated, Removed, Fixed, Security.
Opting out
PRs that legitimately have no user-facing impact can add the no-changelog label. CI honors it. When unsure, add the fragment.
At release time
Promotion is automatic. The release workflow's finalize job runs:
node scripts/changeset/cli.cjs render --version vX.Y.Z --date YYYY-MM-DD --allow-empty
This reads every fragment, groups bullets by type:, replaces ## [Unreleased] with a new ## [vX.Y.Z] - YYYY-MM-DD block, opens a fresh ## [Unreleased] above, and deletes consumed fragments. The --allow-empty flag ensures a no-change release still gets a dated heading (with a _No notable changes._ placeholder). A subsequent verify step confirms the promotion landed correctly. Maintainers do not run this by hand.
Archived fragments
.changeset/archived/ holds fragments for already-shipped releases (≤ 1.3.1), retained for provenance. Their content was hand-curated into the dated ## [1.x.y] sections of CHANGELOG.md during the #690 backfill — they were never consumed by render. All changeset tooling enumerates .changeset/ non-recursively, so archived fragments are never picked up or rendered. Do not move them back to the top level.