The Gemini, Claude, and Codex reviewer blocks invoked the CLIs with no explicit
timeout, so each inherited the host default (~2 min on Claude Code). A source-
grounded review of a large plan set takes ~570s (Codex xhigh) / ~525s (headless
Claude) — both exceed that window, so the lane is killed mid-review, its output
is empty, and the cross-AI review silently proceeds with fewer lanes. CodeRabbit
and OpenCode already documented a timeout; the four main lanes did not.
Add a shared timeout-guidance note directing a high Bash timeout (>= 900000;
1200000 for Codex xhigh / headless Claude), referencing BASH_MAX_TIMEOUT_MS for
the Claude Code host cap, and framing a slow-lane empty output as a timeout kill
(not the 0xc0000142 crash it gets misdiagnosed as) so operators re-run with more
time instead of diagnosing a CLI failure.
Closes#2194
Recaptures the 18 golden-install-parity fixtures + workflow-size baseline (only
the review.md entry changed in each; review is LARGE-tier, 47168 < 61440).
Source-text contract guard: review.md IS the product the runtime loads. Assert
its reviewer-invocation section carries Bash timeout guidance (>= 900000ms) for
the Gemini/Claude/Codex lanes and frames a slow-lane empty output as a timeout
rather than a crash.
CI lint (local/no-crlf-fragile-split + no-regex-spaces) flagged the regex
assertions: a bare \n on readFileSync content is CRLF-fragile on Windows
git-autocrlf, and literal double-spaces are hard to count. Switch to .includes()
strings — same validation intent, lint-clean on all platforms.
cmdStateUpdateProgress ran its Progress: patterns against the raw STATE.md
content (frontmatter included). With /i+/m the first match was the YAML
frontmatter `progress:` key, not the body line — so the frontmatter block was
mangled (\s* crossed the newline) while the body line stayed stale, and the
next STATE.md write re-derived percent from that stale line, silently reverting
the update. \2: the .* also discarded any agent-authored descriptive suffix.
- Match against the frontmatter-stripped body only (reusing stripFrontmatter);
reconstruct content as fmPrefix + modified body so the frontmatter is untouched.
- Swap only the machine segment ([bar] NN% or bare NN%), preserving any suffix.
- updated stays false when the body has no Progress: line (no false success from
a frontmatter progress: key).
Closes#2177
Adds the frontmatter-bearing STATE.md fixture the suite lacked. Asserts the
body Progress: line (not the YAML progress: key) is the update target, the
descriptive suffix after [bar] NN% is preserved, the frontmatter block is not
mangled, and a body with no Progress: line reports updated:false even when the
frontmatter has a progress: key (no false success).
normalizeNodePath had hardcoded branches for macOS Intel (/usr/local/Cellar) and
Apple Silicon (/opt/homebrew/Cellar) but none for Linuxbrew
(/home/linuxbrew/.linuxbrew/Cellar). After `brew upgrade node` on Linux, the
version-pinned Cellar path baked into managed hook commands 404'd, and re-running
the installer did not repair it — normalizeNodePath returned the pinned path
unchanged, so the 'already normalized' skip-rewrite check no-op'd.
Generalize to one branch: match <prefix>/Cellar/node(<@ver>)?/<ver>/bin/node and
rewrite to <prefix>/bin/node, deriving <prefix> from the path itself. This covers
macOS Intel, Apple Silicon, Linuxbrew, and any custom HOMEBREW_PREFIX — the same
failure class as #977 (fnm) and #1619 (mise), now closed for Homebrew on every
platform.
Closes#2185
Adds the Linuxbrew Cellar layout (/home/linuxbrew/.linuxbrew/Cellar/...), a
post-version-bump path, a node@version formula, and a custom HOMEBREW_PREFIX —
all mapping to the stable <prefix>/bin/node symlink. Mirrored in both
consolidated describe blocks.
readGsdEffectiveModelOverrides resolved ~/.gsd/defaults.json via os.homedir()
with no seam, so a test asserting project-only overrides could not isolate the
global file. Add an optional { homedir } option (defaults to os.homedir()) to
readGsdGlobalModelOverrides and readGsdEffectiveModelOverrides — the same
dependency-injection shape the sibling warnIfStaleBake already uses. Backward-
compatible: existing callers pass no option and behave identically.
Closes#2152
The subtest asserted project-only model_overrides but called
readGsdEffectiveModelOverrides without isolating HOME, so the real
~/.gsd/defaults.json global overrides bled into the deepEqual on any dev box or
non-hermetic runner that has one. Pass a sandboxed homedir (mirroring the sibling
warnIfStaleBake subtests that already inject homedir).
Review found the hint value (yes|no) matched prefixes ('nope'/'not' read as
'no') and the hint regex was unanchored, so a mid-line prose mention like 'see
**UI hint**: no above' was treated as the authoritative metadata line. Line-
anchor (^ + m flag) so only a real hint line counts; word-boundary on the value
so nope/not do not mean no. Adds coverage for hint:yes over a pure-backend body
and the nope fall-through.
checkUiPresence ran UI_TOKENS (which includes the bare token 'UI') over the raw
phase-section text, so GSD's own '**UI hint**: no' metadata line matched the 'UI'
token and reported hasUI=true — blocking non-frontend phases that explicitly
declare themselves non-frontend via the documented convention (the plan-phase
UI-SPEC gate fired under the default workflow.ui_safety_gate=true).
- An explicit '**UI hint**: yes|no' line is now authoritative (mirrors how
progress.md / new-project.md already parse it via 'UI hint.*yes'). hint:no ->
hasUI=false; hint:yes -> hasUI=true.
- Any '**UI hint**:' line is stripped before token-sniffing, so a hint without a
recognised yes/no cannot false-positive on the bare 'UI' token.
- With no hint line, behaviour is unchanged (token-sniffing on the rest).
Closes#2150
Adds the divergent input the green suite lacked: the documented `**UI hint**:
no` metadata line. Asserts hint:no is authoritative non-frontend (no false
positive), hint:yes is authoritative frontend, a malformed hint is stripped so
its bare UI token does not fire, and hint:no overrides genuine UI language.
hasRow keyed on a bare '| ID |' which could match the ID as the first cell of a
non-traceability table elsewhere in REQUIREMENTS.md, suppressing a real
table_unmatched signal. Require a second cell ('| ID | <phase> |') so only a
traceability-row shape counts as a row.
cmdRequirementsMarkComplete OR-ed its two write surfaces (the - [ ] checkbox and
the | ID | ... | Pending | traceability row) into one 'found' flag. When the
checkbox matched and no table row did, it reported the same 'updated: true,
marked_complete: [ID]' payload as a full reconcile — while the traceability row
stayed Pending. The already_complete branch used OR (checkbox done OR row done),
so re-running on the half-written file classified the drift as already_complete
and moved on. audit-milestone (which reads the table) still saw Pending.
- Track the two surfaces separately (checkboxHit / tableHit).
- Add a 'table_unmatched' bucket: an ID whose checkbox reconciled (this run or
before) but whose traceability table has no row for it — only when a table
actually exists (a table-less REQUIREMENTS.md is legitimate).
- Fix the already_complete predicate: fully reconciled requires the row Complete,
OR the checkbox done with NO table — a [x] checkbox with an absent/Pending row
is partial, not done.
- A table-less REQUIREMENTS.md stays a clean success (the OR's legitimate use).
The invariant: a not-fully-applied requirement no longer returns a payload
indistinguishable from a fully-applied one.
Closes#2140
Adds the fixture the green suite lacked: an ID with a checkbox and a traceability
table that does NOT mention it. Asserts (1) a checkbox-only reconcile surfaces
table_unmatched instead of a silent full-success payload, (2) re-run on the
half-written file does NOT mask the drift as already_complete, and (3) a
REQUIREMENTS.md with no traceability table stays a clean success.
Review (MEDIUM): `git push ... 2>&1` did not check exit code, so a silent push
failure (auth-token expiry, network blip, non-fast-forward) would proceed to the
report step and declare success — silently reproducing the exact #2138 defect.
Add a fallback warning naming the rerun command so a failed push is visible
(best-effort: the PR already exists, so we still report it rather than abort).
Recaptures goldens + size baseline.
track_shipping committed the STATE ship-note ('Phase N shipped — PR #N') AFTER
create_pr and never pushed it, so the commit stayed local-only. When the GitHub
PR merged (especially fast/auto-merge) the ship-note was not in the source branch
and never reached the default branch — STATE's ship-status was silently lost,
recoverable only by STATE self-heal on the next /gsd-start.
Push the ship-note commit onto the PR branch with a [ci skip] trailer. GitHub
honors [ci skip]/[skip ci], so this lands the note on merge without triggering a
redundant pipeline, and preserves the PR number in STATE.
Recaptures the 18 golden-install-parity fixtures + the workflow-size baseline
(only the ship.md entry changed in each).
Closes#2138
ship.md IS the product the runtime loads. Assert its track_shipping step pushes
the committed ship-note onto the PR branch and carries a [ci skip] trailer, so a
regression to the local-only commit (the #2138 bug) is caught.
The UTC-slice anti-pattern (deriving a calendar day a human reads by slicing a
UTC instant) remained in several operator-facing sites beyond the original
seamed last_activity set. Eliminate it everywhere a human reads the value as a
calendar day — do not leave known bad code in place:
- commands.cts cmdTodoComplete + cmdScaffold: completion/scaffold dates.
- gsd2-import.cts: migrated STATE.md 'Last activity' / 'Last session'.
- template.cts: plan frontmatter 'completed:' date.
- verify.cts: health --repair session-log date + '(Backfilled: <date>)' header.
- workstream.cts: workstream-create 'Last Activity' / 'created' + archive dirname.
- init.cts: JSON-bundle 'date' (-> localToday) + 'timestamp' (-> nowIso) at all
three sites; drop the now-dead 'const now = new Date()' in cmdInitTodos /
cmdInitMapCodebase (cmdInitQuick keeps it for the local branch-id derivation).
- state.cts: prune-archive '## Pruned <date>' header.
- state-transition.cts: the 7 seamed last_activity writes (prior commit).
No realClock.today() / .clock.today() / raw new Date().toISOString().split('T')
operator-facing sites remain in src/. Rebuilds the tracked
bin/lib/state-transition.cjs artifact to match.
clock.today() derived the calendar day by slicing a UTC ISO instant
(nowIso().split('T')[0]), so in any negative-UTC-offset zone during UTC's early
hours last_activity named a day the operator had not reached yet — ahead of
last_updated's local date, written by the same call.
- Add Clock.localToday(): host-local YYYY-MM-DD via getMonth/getDate/
getFullYear, honoring the same GSD_NOW_MS pin as today()/nowIso().
- Route operator-facing date-only fields through localToday(): last_activity
(state-transition ×7, state.cts), roadmap 'completed <date>' (phase.cts,
roadmap.cts), milestone completion date (milestone.cts), todo/scaffold
completion (commands.cts — now threaded through the realClock seam).
- Leave today() (UTC) as the source for internal/cosmetic stamps.
Closes#2136
Adds a dedicated regression (tests/fix-2136-clock-local-today.test.cjs):
- realClock.localToday() returns the LOCAL calendar day under a pinned instant
+ TZ (America/Chicago → 2020-06-14, the issue's repro instant).
- realClock.today() is unchanged (UTC) — internal/cosmetic stamps stay UTC.
- localToday === today on a UTC host (no spurious divergence).
- makeFakeClock mirrors localToday (drop-in Clock substitute).
- field-level: a 'sync' transition with a split clock (today≠localToday)
writes the LOCAL day into Last Activity, proving the wiring uses localToday.
Mirrors localToday() in the fake clock helper and the inline fixedClock stubs
in state-transition.test.cjs / state-rebuild.test.cjs (the Clock interface now
requires it).
getMilestoneInfo's `##` heading regex was unanchored (no `^`/`m`), so it
matched a `##` quoted mid-line inside a Milestones bullet and captured a
delimiter-led fragment into milestone_name — clobbering the curated name on
every phase transition.
roadmap-parser.cts (load-bearing):
- Consult the 🚧 name-bearing marker FIRST (reorder; it already existed but was
shadowed by a spuriously-successful heading match).
- Anchor the `##` regex to line start (`^` + `m` flag) so a heading quoted
in backticks/prose can no longer match.
- stripLeadingDelimiter removes a leading em/en-dash/colon/hyphen run that
.trim() cannot (the `## vX.Y — Name` convention).
state.cts (defense in depth):
- Widen the #948 preserve guard from 'derived equals placeholder' to
'derived does not look like a name' (non-empty, not placeholder, not
punctuation-led), so a future bad derive preserves the curated name instead
of silently overwriting it.
Closes#2135
Adds the issue's verification matrix (cases A-E): a 🚧 bullet quoting a
nameless ## heading in backticks (the corruption), a nameless heading plus a
🚧 sub-heading carrying the name, canonical colon/em-dash shapes (no
regression), a 🚧 bullet only, and an anchored-regex guard proving a ##
heading quoted mid-line in backticks never matches.
The fast.md log_to_state fix changes an installed workflow file, so the per-
runtime golden-install-parity fixtures (18 runtimes) and the workflow-size
baseline are recaptured. Diff is exactly one entry per fixture (the fast.md
hash) and one baseline byte count — no spurious drift.
The first draft banned the literal /NF-1/ anywhere in the block, but the
explanatory comment legitimately references 'NF-1' to document the off-by-one
root cause. Match the COL_COUNT awk assignment specifically so the structural
guard targets the executable formula, not the prose.