CI caught what the bench run didn't: "row 12" (every NONE-risk action
must actually apply) called applyRepairs('/fake/cwd', ...) — a literal
path that doesn't exist on disk. This was fine when applyRepairs's
handlers were stubs (pre-migration skeleton), but real handlers now
read/write actual files: createConfig writes config.json,
addNyquistKey/addAiIntegrationPhaseKey read it before patching. Against
a genuinely non-existent path these now correctly fail (ENOENT), and
an earlier fix in this same PR (applied only receives a code on real
success) correctly surfaces that as a failure instead of masking it —
so 3 of 4 codes stopped landing in `applied`, deterministically, on
any environment that actually enforces ENOENT against /fake/cwd.
Uses a real temp project (createTempProject + a valid config.json)
instead. Row 11 (DESTRUCTIVE refusal) and the ADVISE-skip test are
unaffected — both paths return before any handler touches the
filesystem, confirmed by reading applyRepairs's dispatch order.
REQ-HEALTH-05 said --repair auto-fixes recoverable issues without
qualification — now inaccurate since DESTRUCTIVE-risk remedies are
reported but never auto-applied. Adds REQ-HEALTH-06 for --backfill,
previously unmentioned in this requirements register.
docs/COMMANDS.md's /gsd-health section never documented --backfill at
all, and predates this phase's breaking changes: --repair no longer
auto-applies resetConfig/regenerateState (both destructive), and W021/
W017 split into W026/W027 for their previously-conflated second
subjects. Required by lint:docs, which needs a docs/ touch alongside
any Changed-type changeset fragment.
Both tests were written against the pre-fix behavior and never updated
once the real fixes landed:
- state-consistency.test.cjs's "KNOWN GAP" test hardcoded the
expectation that W002 incorrectly fires for a STATE.md phase
reference whose only home is an archived milestone — that gap is
now closed (0 diagnostics, confirmed against real buildPlanningSnapshot
output), so the test is renamed and its expectation flipped.
- worktree-health.test.cjs's two W020 tests asserted the OLD single
combined "timed out or failed" message/remedy — verified against
the real pre-migration src/verify.cts:2204-2219 that git_timed_out
and git_list_failed always had distinct messages; the fix that
restored this distinction is correct, these tests just never
caught up to it.
gsd-test found two independent gaps around the newly-generated
health.md tables:
- emitted-attribution.test.cjs requires an acknowledgment for
health.md's 2271-byte growth (16-code hand-maintained table -> 34-row
generated table, this phase's explicit acceptance criterion). Adds
tests/emitted-drift-acks/3309-health-docs-generated.json. Removes
2573-state-head-freshness.json's now-inert health.md entry (that
fragment's growth already landed on origin/next, the diff base this
branch is compared against, so it has nothing left to acknowledge —
and the guard forbids two fragments naming the same path).
- runtime-converters.test.cjs's health.md content-consistency checks
asserted stale text from the old hand-written table: a regex that
false-positived on the new table's own unrelated W020 row (worktree
scan degradation, a different diagnostic than the W025 isolation
warning it was meant to detect), and anchors expecting the old
table's exact last row / footnote wording. Narrowed the regex to
require the literal use_worktrees config key, and updated the
anchors to the new table's real shape (I001/I010 as the last rows,
the new generated-table footnote).
gsd-test found buildWorktreeHealthField collapsed every
inspectWorktreeHealth failure reason (git_timed_out, git_list_failed,
not_a_git_repo) into one UNREADABLE scope, discarding which one. The
migrated checkW020 then warned unconditionally on any UNREADABLE
scope — but the original (verify.cts:2202-2217) only warned on
git_timed_out/git_list_failed, staying silent on not_a_git_repo (a
.planning/-only fixture with no git repo at all is not a degraded
scan, just the absence of one). This spuriously degraded every test
fixture that isn't a real git repo.
planning-snapshot.cts's worktreeHealth field now carries `reason`
through instead of discarding it; checkW020 branches on it exactly
like the pre-migration code did.
gsd-test found the migrated W023 dropped a piece of information the
original message included: each colliding phase directory's overall
status (e.g. "Complete"), not just its raw plan/summary/verification
counts. Adds derivePhaseStatusLabel, reconstructing the status label
from already-exposed PhaseSnapshot fields (planCount/summaryCount/
complete/verificationStatus) — no new ambient I/O, no new snapshot
field.
Also fixes a non-conforming test fixture found while verifying:
tests/health-validation.test.cjs's "05-real" fixture used a bare
VERIFICATION.md, which readVerificationStatus never matches (the real
convention, and every other fixture in this repo, use the
*-VERIFICATION.md suffix) — the status was always reading as "missing"
regardless of message formatting. Renamed to 05-real-VERIFICATION.md.
gsd-test found three real regressions in the migrated STATE.md checks:
- W002 didn't exempt phase refs whose only directory lives in an
archived milestone (#3652) — now consults planning-snapshot.cts's
archivedPhaseTokens field (added alongside this fix, shared with
W006's identical need).
- W011 (STATE/ROADMAP cross-validation) never fired: currentPhaseLabel
only read the current template's bare "Phase:" field, silently
missing legacy STATE.md fixtures that use the older bold
"**Current Phase:**" field (mirrors state.cts's own resolveStatePhase
fallback ladder, which the migration didn't carry over).
- W026 (STATE milestone-complete vs. unstarted ROADMAP phases) had two
independent defects: roadmapDeclaredPhases's milestone attribution
can't see <details>/<summary>-shaped ROADMAP sections, and current-
milestone resolution could go null — both silently emptied the
"unstarted" set every time. Fixed by scoping ROADMAP.md to the
current milestone via the same <details>-tolerant extractCurrentMilestone
every other milestone-aware consumer uses, in a new dedicated
planning-snapshot.cts field (currentMilestoneRoadmapPhaseIds) rather
than reusing roadmapDeclaredPhases, which exists for a narrower,
<details>-blind derivation (W021's own original logic) and would
have regressed it if repurposed.
Also fixes an unrelated drift-guard violation this same rule file
introduced: its own phase-token regex was independently re-derived
instead of built from the canonical PHASE_NUMBER_TOKEN_SOURCE.
gsd-test found two real regressions in the migrated W006/W007:
1. A phase whose directory lives under an archived milestone
(.planning/milestones/v*-phases/<phase>/) instead of the active
phases/ dir read as "in ROADMAP but no directory on disk" — the
original forEachArchivedPhaseToken(planBase, ...) fed archived
tokens into the same existence check (verify.cts:2038); the
migrated rule's allPhaseDirNames never included them.
2. Comparing a ROADMAP-declared phase id against a disk directory name
dropped phaseVariants() normalization the original ran as a second,
independent check (verify.cts:2071-2073/2092-2093) — a ROADMAP
"01A" and a disk "1A-..." read as mismatched instead of the same
phase, since matchPhaseDirs's own token comparison never unifies
that padding/letter-suffix difference.
Adds planning-snapshot.cts's archivedPhaseTokens field (mirrors
forEachArchivedPhaseToken/listMilestoneArchiveDirs exactly, no new
regex derivation) and a phaseVariants()-based fallback in
dirsForPhase when matchPhaseDirs finds nothing.
Three user-visible changes disclosed per CONTRIBUTING.md's changeset
convention: --repair no longer auto-applies DESTRUCTIVE remedies
(Changed), W021/W017 split into W026/W027 for their previously-
conflated second subjects (Changed), and --backfill alone now actually
works (Fixed, a latent-bug fix). pr:0 placeholder, backfilled once the
PR number is known.
Closes the issue's explicit acceptance criterion: "health.md's tables
are generated rather than hand-maintained, closing the 16-vs-30+
documentation gap structurally." The published roster listed 16 codes
against 30+ actually emitted; W010-W017 and W020-W023 had never been
documented.
Adds description/repairable as static fields on Rule (health-diagnostic-types.cts)
— generation needs a fixed, human-readable summary per code, distinct
from the dynamic per-instance Diagnostic.message a rule's check()
produces. repairable is true only when --repair will actually apply
the remedy: false for ADVISE-only rules AND for DESTRUCTIVE-risk rules
(regenerateState/resetConfig), which are described but never
auto-applied — matches verify.cts's diagnosticToIssueEntry semantics
exactly, after fixing E004/E005's static field to agree with it (both
were wrongly true, an inconsistency caught during this same commit's
own review, not left for later).
New scripts/gen-health-docs.cjs (--write/--check, wired into
lint:generated-sync) regenerates the two tagged table regions in
gsd-core/workflows/health.md from RULES (31 rules) plus the 3
pre-checks that stay outside the rule table by design (E001, E010,
I010) plus a small static Effect/Risk lookup for the 6 real repair
actions — including addAiIntegrationPhaseKey, live in code since an
earlier phase but never documented until now. 34 error-code rows, 6
repair-action rows. The table's old "grep verify.cts for the next free
number" footnote is rewritten to point at the rule table and its lint
guard instead.
Still said RULES ships empty and repair handlers are stubs — true when
the skeleton batch first wrote this glossary entry, false since the
migration landed (RULES holds 31 wired rules, applyRepairs has real
per-action handlers). Found by the Standards-axis orthogonal review.
W024's committed rule (state-consistency.cts) is a documented permanent
no-op — its real check runs in cmdValidateHealth itself, outside the
rule table, since readStateHeadFreshness needs a git-log shell-out no
Rule.check may perform. The guard's §8.5 fixture-proof check previously
"passed" for W024 only because some test file's title happened to
contain the string "W024" — not because any fixture actually proves it
fires, which it structurally never can. Found by the Spec-axis
orthogonal review.
Adds an explicit PERMANENTLY_INERT_CODES map (currently just W024,
with its reason recorded) that checkFixtureProofInvariant reports
separately from real coverage. The guard's PASS output now says
"30 covered by a real fixture, 1 exempted" instead of implying uniform
proof — a code with no coverage and no exemption entry still fails.
adviseRemedy() was defined identically in two of the eight rule-group
files (config-validation.cts, agent-install.cts) while the other six
repeated the same {action: ADVISE, risk: NONE, args: {command}} object
literal inline ~20+ times. Found by the Standards-axis orthogonal
review (Duplicated Code smell).
Moves the one-line helper into health-diagnostic-types.cts, the leaf
module every rule-group file already imports for its enums/types, and
uses it consistently across all 8 files. Pure mechanical refactor — no
ADVISE remedy's command text, code, or action changed.
applyRepairs pushed a diagnostic's code onto applied unconditionally
after the try/catch around runRepairAction, even when the handler
threw (caught, recorded in details with success:false) or otherwise
failed — making applied mean "attempted" rather than "succeeded," with
no test exercising the failure path. Found by the Spec-axis orthogonal
review.
applied now only receives a code when the repair actually succeeded;
a failed attempt is still fully recorded in details (success:false,
the error message) but no longer misreported as applied. Adds a
regression test forcing addNyquistKey to throw (ENOENT on a config.json
that doesn't exist) and asserts it lands in details, not applied.
The migrated checkW027 (stale worktree) dropped the pre-migration
exclusion of the CLI's own current worktree, since a Rule.check(snapshot)
has no cwd access (§8.1 rule 1 forbids ambient I/O) — flagged as a
disclosed regression during this phase's own design work, then
confirmed as a real, fixable gap by the Spec-axis orthogonal review
rather than an inherent limitation.
Fixes it properly instead of accepting the regression: buildPlanningSnapshot(cwd)
already receives cwd as its own input, so exposing it as snapshot.cwd
is not new ambient I/O, just surfacing an existing parameter — fully
consistent with §8.1 rule 2's "parsed value" allowance. checkW027 now
excludes the entry matching snapshot.cwd before flagging, matching the
original verify.cts:2233-2242 behavior exactly.
gen-inventory-manifest.cjs's cli_modules family did a flat readdirSync
of gsd-core/bin/lib/, invisible to anything shipped in a subdirectory.
Found while registering this phase's 8 health-diagnostic-rules/*.cjs
files in docs/INVENTORY.md (Standards-axis review) — the automated
manifest cross-check couldn't see them even though the manual
INVENTORY.md rows were correct.
Adds collectOneLevelSubdirs (mirrors the existing collectNested's
defensive statOrNull style) and merges flat + one-level-subdirectory
results into cli_modules's single sorted array, using the same
<subdir>/<file>.cjs key format INVENTORY.md's rows already use.
Regenerating the manifest surfaced that three OTHER existing
subdirectories (installer-migrations/, host-integration-adapters/,
observability/ — pre-existing, unrelated to this phase) were equally
invisible and had zero docs/INVENTORY.md rows at all. Added all 15
missing rows rather than leave a gap the fix itself just exposed.
Also fixes 3 pre-existing lint-legacy-dir-name violations in the
installer-migrations rows (legitimate references to the historical
get-shit-done -> gsd-core rename these migrations clean up — marked
with the guard's own gsd-allow-legacy-name exemption) and a stale
health-diagnostic.cjs row that still said "RULES ships empty."
root-existence.cts (E002, E003) and phase-structure.cts (W009) hardcode
a canonical hyphen-form slash command in their ADVISE remedy, same as
config-validation.cts's already-disclosed W016 — forced by §8.1 rule 1
(a Rule's check(snapshot) cannot call the runtime-resolved slash()
formatter, which needs cwd). Only config-validation.cts's own header
disclosed this tradeoff; the other two sites had it happen without
recording it in their own file. Adds the same disclosure to both,
matching the established convention. No behavior change.
Replaces cmdValidateHealth's hand-rolled addIssue/switch accumulation
(961 lines) with buildPlanningSnapshot -> evaluateRules -> map to the
legacy {code, message, fix, repairable} shape, bucketed by severity.
Two pre-checks (home-dir E010/I010, .planning/-root-missing E001) stay
outside the rule table entirely, per ADR-3180 §8.2 rule 4 ("no
precedence system") — building "some rules suppress others" into the
table would itself be the forbidden precedence system.
W024 (STATE.md commit-age freshness) also stays outside the table:
its committed rule is a documented permanent no-op (readStateHeadFreshness's
git-log shell-out is ambient I/O a Rule.check may never perform, and no
PlanningSnapshot field carries a commits-behind count). Migrating onto
the rule table as designed would have silently regressed 7 passing
tests in tests/health-validation.test.cjs — found while wiring this
function, kept as a real check in the wrapper instead (same I/O
license applyRepairs already relies on), fixed inline per this repo's
no-defer policy rather than accepted as a silent loss.
Ports the real repair-handler bodies (createConfig/resetConfig,
regenerateState, addNyquistKey/addAiIntegrationPhaseKey,
backfillMilestones) into health-diagnostic.cts's applyRepairs,
replacing the skeleton's stub. DESTRUCTIVE-risk remedies
(resetConfig/regenerateState) are refused by --repair — a disclosed
breaking change; repairable now means "an automatic repair will
actually run," not merely "a remedy exists to describe," so E004/E005
now report repairable:false. --backfill alone now actually triggers
backfillMilestones, fixing a latent bug where its gate was unreachable
without --repair also being set (verify.cts:2504, confirmed dead code
pre-migration).
Test updates distinguish the two explicitly-authorized behavior
changes (DESTRUCTIVE refusal, backfill-alone fix, W021->W026 split)
from preservation — every changed assertion is commented with why, and
new regression tests were added for both changes plus W021/W026
mutual independence. Drift-guard bookkeeping (bypass-baseline shrunk
to the one disclosed W024 exception, milestone-window and
phase-enumeration exemptions, test-file-count allowlist) updated for
the relocated/new functions this migration introduces.
Enforces ADR-3180 §8.2's 1:1 rule-code invariant (every code unique,
every severity a property of the Rule) and §8.5's fixture-proof
invariant (every code has a describe()/test() block naming it,
verified statically against tests/health-diagnostic-rules/*.test.cjs
and tests/health-diagnostic.test.cjs) for the new RULES table.
Adapted from the design doc's original plan of separate
tests/fixtures/health-diagnostic/<code>.* files: implementation used
inline temp-dir fixtures instead (mirrors tests/planning-snapshot.test.cjs),
so coverage is checked statically against test-file structure, mirroring
lint-fix-has-regression-test.cjs's house style. Wired into lint:ci
adjacent to lint-planning-snapshot-bypass-drift.cjs, its closest sibling.
Passes clean against the real tree: 31 codes, all unique, all covered.
Wiring all 8 rule-group files into health-diagnostic.cts's RULES array
created a genuine CJS circular dependency: each group file required
health-diagnostic.cjs back for the shared enums, and health-diagnostic.cjs
now required the group files forward, so the enums were undefined
mid-load (destructuring health-diagnostic.cjs's still-unassigned
exports).
Fixes it by splitting the enums/types (SEVERITY, REMEDY_ACTION,
REMEDY_RISK, Remedy, Diagnostic, Rule) into a dependency-free leaf
module, health-diagnostic-types.cts, that both sides import instead of
each other. health-diagnostic.cts re-exports the enums for existing
consumers. RULES is now the real concatenation of all 8 groups (31
codes — E001 intentionally stays a pre-check outside the table).
W020 (3 conditions), W017, W027 — git worktree list degradation,
orphan and stale worktree checks, migrated onto the frozen rule table
per ADR-3180 §8.2. W027 has a documented fidelity reduction: rules
have no cwd access, so it can no longer exclude the active worktree.
W024 (deliberately inert, no snapshot field yet for stale state_head),
W002, W011, W021, W026 — STATE.md cross-checks against ROADMAP/config,
migrated onto the frozen rule table per ADR-3180 §8.2.
E002, E003, E004, W001 — PROJECT.md/ROADMAP.md/STATE.md existence and
PROJECT.md section-completeness checks, migrated onto the frozen rule
table per ADR-3180 §8.2.
Mirrors the existing health-diagnostic.cjs / planning-snapshot.cjs
pattern: gitignore the compiled output and exclude it from eslint so
the generated JS isn't linted as hand-written source. Also adds the
CONTEXT.md glossary entry and INVENTORY.md rows for the new
src/health-diagnostic-rules/ directory.
W007 (orphan disk dir with no ROADMAP entry) cannot be sourced from
phaseDirs, which is windowed to ROADMAP-declared phases only — an
orphan dir can never appear in an already-ROADMAP-filtered set. Adds
an unwindowed allPhaseDirNames field so the rule can actually fire.
Phase 11 of epic #3180 (ADR-3180 §8.1 rule 2). PlanningSnapshot grows from
7 fields to 15: projectSections, statePhaseTokens, stateStatus,
roadmapDeclaredPhases, roadmapPhaseCheckboxes, researchValidationStatus,
milestoneArchiveStatus, planningRootFiles.
Every field is a reused owner (buildRoadmapPhaseVariants/
buildNotStartedPhaseVariants from src/validate.cts, stateFieldValue) or a
small relocation of already-working verify.cts logic (PHASE_NUMBER_TOKEN_SOURCE
scanning, the checkMilestonePrefixMismatches sectionRx walk, W009/W018's
file-existence checks) — never a new algorithm, and never raw document text:
§8.1 rule 2 forbids exposing raw text, not exposing a parsed list or boolean
derived from it once by the snapshot builder.
roadmapPhaseCheckboxes deliberately reads the same ROADMAP checkbox
isPhaseComplete (§7.4, disk-strict) refuses to consult — that owner decides
completion and must not read it; this field only exposes what the checkbox
says, for a diagnostic (W011) whose whole purpose is flagging disagreement.
Not a re-derivation of §7.4, recorded explicitly to prevent that reading.
Adds PROJECT_UNREADABLE to UNUSABLE_REASON (ninth #1879 site), closing a
gap the implementing agent correctly flagged rather than silently leaving
absent-vs-corrupt collapsed for PROJECT.md, matching the STATE_UNREADABLE/
CONFIG_UNREADABLE precedent from this same effort's prior commits.
currentPhaseLabel/statePhaseTokens/stateStatus share one STATE.md read
(buildStateFields) rather than three independent reads.
Additive only — all prior fields and worstScope/buildPhaseSnapshot
unchanged.
Phase 11 of epic #3180 (ADR-3180 §8.2/§8.3/§8.5). New src/health-diagnostic.cts:
SEVERITY/REMEDY_ACTION (7 members: 6 real repair actions + ADVISE)/REMEDY_RISK
(NONE/DESTRUCTIVE) frozen enums, Diagnostic/Remedy/Rule types, an empty RULES
table (rules land in the next commits), evaluateRules (with a duplicate-code
defense-in-depth check ahead of the lint guard), and applyRepairs (the
DESTRUCTIVE-risk-refusal dispatcher — §8.3 rule 3 — with stub handlers; real
repair bodies port in the migration step).
Six-gate .cts ripple: .gitignore, eslint.config.mjs, docs/INVENTORY.md +
manifest, CONTEXT.md glossary entry.
Phase 11 of epic #3180 (ADR-3180 §8.2/§8.3/§8.5) foundation. Extends the
already-merged Phase-10 PlanningSnapshot additively with three fields the
upcoming health-diagnostic rule table needs and Phase 10 never required:
- config: {value, scope, exists} — parsed .planning/config.json. `exists`
distinguishes absent (no diagnostic, non-answer) from present-but-invalid
(CONFIG_UNREADABLE diagnostic, corruption) — both collapse to scope
UNREADABLE, so a rule needs the extra bit to tell "not configured yet"
apart from "config.json is broken."
- agentInstall / worktreeHealth — not .planning/-sourced, wrap the existing
checkAgentsInstalled/inspectWorktreeHealth owners with the same arguments
cmdValidateHealth already passes them, so a later migration step reads
these fields instead of calling the owners itself.
Adds CONFIG_UNREADABLE to src/unusable-input.cts's UNUSABLE_REASON (eighth
#1879 site), mirroring STATE_UNREADABLE's exact shape from Phase 10.
Additive only — the four Phase-10 fields and worstScope/buildPhaseSnapshot
are unchanged; existing tests for them are untouched.
PR #3402's real Windows CI (windows-latest node22/24) caught what gsd-test's
Linux-only lanes structurally cannot: tests/planning-snapshot-bypass-drift.test.cjs
compared the guard's own POSIX-normalized output (findSnapshotBypassDrift's
`file` field, dedupeViolationsForBaseline/sortEntries entries, a written
baseline read back from disk) against REGISTERED_FILE, which is built via
path.join('src', 'verify.cts') and is therefore backslash-separated on
Windows. The guard always normalizes its OUTPUT to POSIX via toPosixRel
regardless of the input separator form, so the comparison only ever
coincidentally passed on POSIX.
Adds REGISTERED_FILE_POSIX for every assertion against a guard-PRODUCED
value (including baseline fixtures fed into diffAgainstBaseline, which are
matched by exact string key against the guard's normalized output).
REGISTERED_FILE itself is unchanged and still used, correctly, everywhere it
is the relPath INPUT to findSnapshotBypassDrift or a DIAGNOSTIC_RULE_FUNCTIONS
Map-key lookup — both need the platform-native form to match the guard's own
Map key, which is also path.join-constructed.
No behavior change on POSIX (both constants are byte-identical there).
* test(#3170): extractOneLinerFromBody must anchor to a summary-shaped heading
Regression for #3170: the function matched the first heading's first bold
run, so an incidental first heading (rule list, deviation notes) contributed
its bold text as the milestone accomplishment. Rows 1/2 fail RED on next; rows
3/4 guard Overview recognition and the #2660 Summary-heading form.
* fix(#3170): anchor milestone one-liner extraction to a summary-shaped heading
extractOneLinerFromBody matched the first heading's first bold run regardless
of section, so an incidental first heading (rule list, deviation notes)
contributed its bold text as the milestone accomplishment written into
MILESTONES.md. Iterate headings and extract from the first Summary/Overview/
Accomplishments one with a bold run, falling back to null when none exists.
The #2660 Summary-heading forms and the frontmatter one-liner precedence are
preserved.
* docs(#3170): add changeset
* test(#3170): align extractOneLinerFromBody unit fixtures with summary-heading contract
The core-utils unit fixtures used generic # Title headings encoding the old
'any first heading' contract; the #3170 fix anchors to a Summary/Overview/
Accomplishments heading (the function is summary-specific). Update the
heading text to Summary-shaped; the extraction assertions (bold, frontmatter
strip, colon-label, CRLF, unicode) are unchanged.
* docs(#3170): backfill changeset PR number (3401)
---------
Co-authored-by: sim <sim@local>
Found while running gsd-test for #3308: tests/commit-files-pathspec.test.cjs's
repo-wide `--files` scan runs `git ls-files -z -- *.md` directly against the
checked-out repo root (not a createTempGitProject() fixture, unlike every
other gitOrThrow call in this file). Inside a container-provisioned test
runner the checkout's on-disk owner can legitimately differ from the running
UID, tripping git's CVE-2022-24765 dubious-ownership guard and failing the
scan closed (exitCode 128) rather than reporting a real file-list result —
reproduced on gsd-test's linux-node22 and linux-node24 lanes.
Adds `-c safe.directory=*` to that ONE invocation only, so the bypass is
scoped to this call rather than a global `git config` write that would leak
into every other git call in the process.
No source behavior changed; test-infrastructure resilience only.
Updates docs/adr/3180-planning-semantic-model-single-owner.md to
reflect Phase 10 shipping: §8.1 status Required -> Enforced (Phase 10,
#3308), guard-roster row contract only -> enforced, phase-index table
issue/status backfilled, and a new Amendment 9 recording the guard's
real baseline (15 distinct raw-read sites, 21 total acknowledged
occurrences in cmdValidateHealth) against the issue's own vaguer
estimate, per Amendment 4a's standing "N found by the guard, never
per the epic" rule. Also records the intended reading of an absent
STATE.md as UNREADABLE-without-diagnostic, symmetric with every other
§7 owner's absence-vs-corruption distinction.
Phase 10 of epic #3180. src/planning-snapshot.cts is a new parsed
projection of .planning/, composed exclusively from the already-
consolidated §7 owners (getMilestoneInfo, listMilestonePhaseDirs,
isPhaseComplete, scanPhasePlans, stateFieldValue, planningPaths) plus
the frozen SCOPE enum. No new semantic derivation is introduced beyond
worstScope, a pure combinator folding several independently-scoped
owner answers into one composite signal.
Adds STATE_UNREADABLE to src/unusable-input.cts's UNUSABLE_REASON
(seventh #1879 site) for STATE.md exists-but-unreadable, distinct
from absent.
Adds scripts/lint-planning-snapshot-bypass-drift.cjs, a ratcheted
drift guard (ADR-3180 Decision 4(e)) scoped to DIAGNOSTIC_RULE_FUNCTIONS
(currently cmdValidateHealth in src/verify.cts only) preventing new
raw .planning/ reads from bypassing the snapshot, while acknowledging
cmdValidateHealth's existing 15 raw-read sites as debt owned by
Phase 11 (#3309).
Six-gate .cts ripple: .gitignore, eslint.config.mjs,
docs/INVENTORY.md + manifest regen, CONTEXT.md glossary entry.
Breaking changes: none. This phase adds the subject only; Phase 11
migrates cmdValidateHealth onto it.
ADR-3180 epic #3180 Phase 10 (§8.1): tests for the not-yet-existing
src/planning-snapshot.cts (buildPlanningSnapshot, worstScope), the
not-yet-existing scripts/lint-planning-snapshot-bypass-drift.cjs guard,
and the new STATE_UNREADABLE reason on tests/unusable-input.test.cjs's
already-shipped UNUSABLE_REASON enum. RED by construction: the modules
under test do not exist yet.
* test(#3163): phase add must insert in the active milestone, not the trailing archive
Regression for #3163: cmdPhaseAdd/cmdPhaseAddBatch pick the insertion point
via rawContent.lastIndexOf('\n---'), the file's last horizontal rule — which
on a roadmap with shipped/history material after the active phase list sits
deep in archive. Rows 1/2/4 fail RED on next (entry lands after the archive
heading); row 3 guards the no-milestone legacy fallback.
* fix(#3163): scope phase.add insertion to the current milestone window
cmdPhaseAdd and cmdPhaseAddBatch picked the insertion point via
rawContent.lastIndexOf('\n---') — the file's last horizontal rule, which on
a roadmap with shipped/history material after the active phase list sits deep
in archive. Extract phaseEntryInsertOffset(rawContent, cwd): scope the search
to currentMilestoneRawRanges' primary window so the entry lands at the end of
the active phase list. Fall back to the legacy whole-file heuristic when no
current milestone resolves, preserving simple no-milestone roadmaps. Applies
to both cmdPhaseAdd and cmdPhaseAddBatch (identical expression); the decimal
insert path was already header-anchored and is untouched.
* docs(#3163): add changeset
* docs(#3163): backfill changeset PR number (3400)
---------
Co-authored-by: sim <sim@local>
The has_work=false branch previously exit-0'd from a step that ran,
so the job's conclusion was `success` -- identical to a PR that
actually ran mutation testing and passed. Moved the trivial-pass
condition to the job's own `if:`, so the job is SKIPPED (not run)
when has_work=false, matching the `coverage-gate` precedent in
test.yml and confirmed (via job-level `if:` research plus this
repo's own live PR #3392) that a skipped required check does not
block merge while still rendering visibly distinct from a pass.
First design attempt (splitting into two step-level `if:`-gated
steps) was verified WRONG before landing: a job whose every step is
individually skipped via step-level `if:` still reports `success`,
not `skipped` -- confirmed against documented GitHub Actions
behavior, not assumed.
Empirically verified via `workflow_dispatch`:
pre-fix (run 31652963610, on next): mutation-gate conclusion = success
post-fix (see PR): mutation-gate conclusion = skipped
Co-authored-by: sim <sim@local>
--include 'gsd-core/bin/lib/*.cjs' (single-star) does not match the 15
nested .cjs files under installer-migrations/, host-integration-adapters/,
and observability/ -- confirmed live via
`find gsd-core/bin/lib -mindepth 2 -name '*.cjs'`. c8's --all zero-fill
is scoped by --include (confirmed via c8 docs), so widening the glob
alone fixes both --include and --all together; no separate --all change
needed.
Thresholds intentionally left unchanged in this commit -- the real
lines/branches percentage including the now-visible nested files can
only be measured via a real CI run (this repo hard-blocks local
node --test), so this push observes CI's actual number before deciding
whether scripts/check-coverage-gate.cjs's OVERALL_LINES/OVERALL_BRANCHES
(the constants CI's coverage-gate job actually enforces) need
re-baselining.
Co-authored-by: sim <sim@local>
* fix(#2570): parse leading date from last_activity so stale_activity fires with a description suffix
templates/state.md prescribes `Last activity: [YYYY-MM-DD] — [What happened]`,
and gsd-core's own STATE.md mirrors that suffix into frontmatter. Date.parse on
the whole string returned NaN, and because staleActivity treats null as "not
stale" (fails open), the only idle/staleness detector never fired on any project
whose last_activity kept its description.
parseActivityTimestamp now reads the leading ISO date/time token when a
whole-string parse fails, validating the calendar date (ADR-227: reject an
impossible date rather than let Date.parse roll it forward) and preferring the
whole-string parse when it succeeds so a trailing zone name is not dropped.
Composes with #3099 (LAST_ACTIVITY_UNPARSEABLE diagnostic), which merged to next
after this branch: both key off parseActivityTimestamp === null, so a value whose
leading date now parses takes the stale path and does NOT emit the diagnostic. A
regression test in tests/smart-entry.unit.test.cjs asserts exactly that (stale
true, emission count 0), guarding against two staleness signals on one field.
Rebased onto next (flattened): resolved the add/add test conflict by keeping both
the #2570 and #3099 describe blocks. Tests: unit + property, 80 pass.
* fix(#2570): fail open when a named zone can't be reconstructed from the token (#2571 B1)
The 2026-08-08 flatten dropped the zone handling earlier rounds built, so the
fallback path -- reached only when a description suffix makes the whole-string
parse fail, the #2570 case -- reconstructed `${date}${time}` WITHOUT any named
zone. ISO_LEADING_RE's offset group captures only Z / +-HH:MM, so " GMT"/" EST"
land in the un-captured suffix; Date.parse then read the reconstruction as LOCAL
time, shifting the instant by the host's UTC offset -- a wrong, host-dependent
value the diff's own comment warned against but guarded only on the other branch.
Fix (the simpler of the two offered in review): when the remainder after the
matched token begins with a letter (a named zone we cannot preserve), return
null -- fail open to not-stale, matching the base's honest behaviour and
ADR-227's "never propagate a wrong instant". The #2570 template suffix
(" -- description") starts with a separator, so it still reconstructs and reads
stale as intended.
Tests (both fail-first, verified RED on the pre-fix head):
- smart-entry.unit: a named-zone + description suffix (54 days old) enters the
fallback and must read not-stale, not a still-old local instant. Host-
independent by construction.
- smart-entry.property (f): named-zone + suffix over 1-week..1-year ages and 8
zones stays total and fails open.
Discloses the removal M2 flagged: TRAILING_ZONE_RE / UTC_ZONE_NAMES /
timeCarriesOffset were dropped by the flatten; this restores the SAFETY (no
wrong instant) via the simpler null contract rather than the allowlist.
* fix(#2570): narrow the stale_activity fallback guard to a zone-designator shape
The round-9 fail-open guard `/^\s*[A-Za-z]/` treated any letter-led remainder as
an unpreservable named zone, so a leading real date followed by a bare
space/tab/colon and an ordinary description (a hand-edited STATE.md that omits the
template em dash) returned null and re-opened #2570 for exactly those shapes.
Narrow the guard to ZONE_DESIGNATOR_RE -- a standalone short all-caps run -- and
consult it ONLY when the leading token captured a time-of-day: a zone qualifies a
clock time, so a bare date carries no zone hazard and always reconstructs to its
UTC midnight. A plain description (including one that opens with a tech acronym
like "CI green") reconstructs; a real named zone on a timed value (GMT/EST/...)
still fails open (ADR-227: never propagate a wrong, host-dependent instant).
Widen the property generator to the non-em-dash separators (space/tab/colon), the
arm that structurally could not reach the fallback before, and add unit cases for
whitespace/tab/colon-separated and bare-date+acronym descriptions. All fail-first
on the prior guard; green across UTC/LA/Tokyo/Kiritimati.
---------
Co-authored-by: Tom Boucher <trekkie@nomorestars.com>