* feat(#3586): warn when .planning/ is gitignored but still tracked
git ignore rules have no effect on files git already tracks, so a project
that committed .planning/ before ignoring it keeps staging those files --
while commit_docs correctly resolves to false, which is exactly what makes
the contradiction invisible.
The probe lives in the SNAPSHOT BUILDER, not the rule: Rule.check may perform
no ambient I/O (ADR-3180 8.1 rule 1, enforced by lint-planning-snapshot-bypass).
buildPlanningTrackedField follows buildWorktreeHealthField's precedent --
injected execGit, bounded, degrading to UNREADABLE with a typed reason rather
than throwing. W024 went inline instead only because no snapshot field carried
its fact; that precondition does not apply here.
W029 fires only on COMPLETE scope with ignored and tracked both true, so a
degraded probe yields neither a finding nor a false all-clear, and the default
project (tracked, not ignored) stays silent. The remedy is ADVISE-only --
--repair never untracks anything.
* docs(#3586): document W029 and correct the health rule count
CONFIGURATION.md documented the gitignore auto-detect without the caveat that
ignore rules do not affect already-tracked files -- the very gap W029 exists to
surface. Adds the caveat, the warning, its remedy, and why --repair will not
act on it.
CONTEXT.md's rule count was stale at 31 before this change (actual 32 through
W028); corrected to 33 and pointed at the two other places the count is locked,
so the next editor updates all three together.
* fix(#3586): treat ls-files overflow as tracked, add CLI-level W029 tests
Review findings.
Security (minor, confirmed): execGit sets no maxBuffer, so Node's 1MB default
applies to git ls-files. A .planning/ tree large enough to overflow it failed
into git_list_failed and silenced W029 -- a false negative in exactly the
large-history case most likely to have the real bug. Overflow is now treated
as PROOF of tracking (the output was non-empty by definition) and resolves to
tracked:true, scope COMPLETE, reason ok_truncated.
Spec (major): test-matrix rows C1 and C2 were never implemented -- there was no
CLI-level integration test at all, only rule-level ones. Both now drive the real
validate-health dispatch and confirm W029 is reachable end-to-end.
Known limit documented, not papered over: a deliberate git add -f under an
otherwise-ignored .planning/ raises the same signal as the accidental case.
There is no reliable way to tell them apart, the finding is advisory-only, and
a heuristic that cannot actually distinguish them would be worse than the
honest caveat.
* test(#3586): update frozen health-doc counts and acknowledge health.md growth
The remote matrix caught three gates that lint:ci does not cover.
gen-health-docs.test.cjs froze a 35-row / 32-rule assertion; W029 makes it
36/33. Updated both the assertion and the test NAME, which embeds the counts --
a stale name is a lie even when the assertion passes. The second reported
failure was the same assertion surfacing at describe-rollup granularity, not a
distinct bug.
emitted-attribution's growth arm needed an ack for the generated health.md.
health.md was already named in 3309-health-docs-generated.json, and two ack
sources naming one path is a hard error -- so a new fragment was not an option.
That fragment's own history shows the pattern: #3309 created it, #2873 amended
it in place for W028. Amended again for W029, with a note recording why this
one file is amended rather than joined by a sibling.
* docs(#3586): add the private-planning how-to and fix a wrong link
docs/CONFIGURATION.md pointed 'Configure private planning' at
how-to/configure-model-profiles.md -- an unrelated page -- and no
private-planning how-to existed at all. Found while editing that section.
The how-to test genuinely fires here: going private is four steps and crosses
planning.search_gitignored, a setting owned by another concern, so a reference
table structurally cannot carry it. The new page walks the whole sequence and
leads with the step people miss -- .gitignore does not untrack what git already
tracks -- which is the exact state W029 now detects.
Also corrects 'artefacts' to 'artifacts' (repo house style is American).
* chore(#3586): backfill changeset pr number to 3598
---------
Co-authored-by: sim <sim@local>
4a - the detection floor. A shadowed install now reports which triggers are
shadowed and which scope wins, at install time and through a new W028
/gsd-health diagnostic. Exit codes are untouched: a shadowed install is a
warning, not a failure. Only triggers whose stem exists at BOTH scopes are
reported, so a global full profile beside a local core profile no longer
names local artifacts the user does not have.
4b - spec-root reachability, claude runtime and global scope only. The
winning global skill stops carrying a static workflow @-include and instead
resolves its spec at runtime: prefer the project-local copy, fall back to
the global one, stop if neither exists. Every other @-include stays static,
and the local emission is byte-identical. It runs after the staged-skills
rewrite pass, whose claude branch would otherwise mangle the literal tilde
path into an undocumented $HOME form.
Also fixed inline: readInstallManifest classified a top-level JSON array as
an installed v1 manifest, because typeof [] is object.
Refs #2873
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.
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.
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.
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.
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.
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.