refactor(#3309): extend planning-snapshot.cts with config/agentInstall/worktreeHealth

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.
This commit is contained in:
sim
2026-08-13 00:41:39 -04:00
parent 3c4df10a50
commit 6aa378b261
4 changed files with 370 additions and 6 deletions

View File

@@ -19,6 +19,7 @@
* gsd-core/bin/lib/planning-snapshot.cjs (gitignored).
*/
import fs from 'node:fs';
import path from 'node:path';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import roadmapParserMod = require('./roadmap-parser.cjs');
@@ -33,8 +34,8 @@ const { isPhaseComplete } = verificationMod;
import scanPhasePlans = require('./plan-scan.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspace = require('./planning-workspace.cjs');
const { planningPaths } = planningWorkspace;
import { platformReadSync } from './shell-command-projection.cjs';
const { planningPaths, planningRoot } = planningWorkspace;
import { platformReadSync, execGit } from './shell-command-projection.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import frontmatterMod = require('./frontmatter.cjs');
const { extractFrontmatter, stripFrontmatter } = frontmatterMod;
@@ -46,6 +47,13 @@ const { UNUSABLE_REASON, warnUnusableInput } = unusableInputMod;
import planningScopeMod = require('./planning-scope.cjs');
const { SCOPE } = planningScopeMod;
type Scope = planningScopeMod.Scope;
import { resolveRuntime } from './runtime-slash.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports -- agent-install-check.cjs is an export= CommonJS module
import agentInstallCheckMod = require('./agent-install-check.cjs');
const { checkAgentsInstalled } = agentInstallCheckMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports -- worktree-safety.cjs is an export= CommonJS module
import worktreeSafetyMod = require('./worktree-safety.cjs');
const { inspectWorktreeHealth } = worktreeSafetyMod;
// ─── worstScope — the one new piece of coordination logic ───────────────────
@@ -89,6 +97,16 @@ interface PlanningSnapshot {
phaseDirs: ReturnType<typeof listMilestonePhaseDirs>;
phases: { value: PhaseSnapshot[]; scope: Scope };
currentPhaseLabel: { value: string | null; scope: Scope };
// ─── Phase 11 (#3309, ADR-3180 §8.2/§8.3/§8.5) additions ───────────────────
// Additive-only — see the design doc's "The subject-surface gap" section.
// `config` genuinely lives under `.planning/`; `agentInstall` and
// `worktreeHealth` do not (named as such so a future reader does not
// mistake them for §7 derivations) but are exposed here anyway so every
// rule's `check(snapshot)` signature stays the single object §8.1 rule 1
// names, "the snapshot".
config: { value: Record<string, unknown> | null; scope: Scope; exists: boolean };
agentInstall: { value: ReturnType<typeof checkAgentsInstalled>; scope: Scope };
worktreeHealth: { value: ReturnType<typeof inspectWorktreeHealth>['findings']; scope: Scope };
}
/**
@@ -153,9 +171,103 @@ function buildCurrentPhaseLabel(statePath: string): { value: string | null; scop
}
/**
* Build the full `.planning/` projection for `cwd`. Composes exactly the six
* §7 owners named in the design doc's "Owners consumed" table — no
* re-derivation, no new semantic answer. See the design doc for the
* Resolve `config` — the parsed `.planning/config.json`, preserving the same
* three-way distinction `cmdValidateHealth` (`src/verify.cts` W003/E005)
* already makes without going through `loadConfig` (which collapses that
* distinction): absent is a real non-answer — `{value: null, scope:
* UNREADABLE, exists: false}`, no `warnUnusableInput` call, mirrors
* `buildCurrentPhaseLabel`'s treatment of an absent STATE.md; present but
* unparseable JSON IS corruption — `{value: null, scope: UNREADABLE, exists:
* true}`, `warnUnusableInput(CONFIG_UNREADABLE)` fires exactly once, so a
* later health-diagnostic rule can tell "config.json not found" (W003,
* repairable via `createConfig`) apart from "config.json: JSON parse error"
* (E005, repairable via `resetConfig`) — the `exists` flag is exactly that
* discriminator. `config.json` is root-scoped (`planningRoot`), NOT
* workstream-scoped (`planningPaths(cwd).config` would resolve under
* `.planning/workstreams/<ws>/` instead) — see verify.cts's own
* rootBase-vs-wsBase split at cmdValidateHealth's top.
*/
function buildConfigField(cwd: string): { value: Record<string, unknown> | null; scope: Scope; exists: boolean } {
const configPath = path.join(planningRoot(cwd), 'config.json');
if (!fs.existsSync(configPath)) {
return { value: null, scope: SCOPE.UNREADABLE, exists: false };
}
try {
const raw = fs.readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(raw) as Record<string, unknown>;
return { value: parsed, scope: SCOPE.COMPLETE, exists: true };
} catch {
warnUnusableInput({ reason: UNUSABLE_REASON.CONFIG_UNREADABLE, source: configPath });
return { value: null, scope: SCOPE.UNREADABLE, exists: true };
}
}
/**
* Resolve `agentInstall` — wraps `checkAgentsInstalled(runtime, cwd)` with
* the same `runtime` `cmdValidateHealth` resolves (`resolveRuntime(cwd)`,
* its `_slashRuntime`). Not `.planning/`-sourced (see design doc). `scope`
* is `COMPLETE` whenever the scan itself ran, even when it reports missing
* or incomplete agents — that is a real answer, not a non-answer.
* `UNREADABLE` only if the scan itself throws, mirroring cmdValidateHealth's
* own try/catch around this same call (there, the exception is swallowed as
* "non-blocking"; here it is surfaced via `scope` instead of silently
* dropped, since a snapshot field has nowhere else to carry that fact).
*/
function buildAgentInstallField(cwd: string): { value: ReturnType<typeof checkAgentsInstalled>; scope: Scope } {
const runtime = resolveRuntime(cwd);
try {
return { value: checkAgentsInstalled(runtime, cwd), scope: SCOPE.COMPLETE };
} catch {
return {
value: {
agents_installed: false,
missing_agents: [],
installed_agents: [],
incomplete_agents: [],
agents_dir: '',
agent_runtime: runtime,
},
scope: SCOPE.UNREADABLE,
};
}
}
/**
* Resolve `worktreeHealth` — wraps `inspectWorktreeHealth(cwd, { staleAfterMs
* }, deps)` with the exact same arguments `cmdValidateHealth` passes
* (`src/verify.cts` W017/W020/W027 call sites): a 1-hour staleness window,
* and the raw `execGit`/`fs.existsSync`/`fs.statSync` seam (not
* `worktree-safety.cts`'s own `execGitDefault` wrapper). Not
* `.planning/`-sourced (see design doc). `scope` is `COMPLETE` only when the
* underlying `git worktree list` scan itself succeeded (`ok: true`) — a
* timed-out or failed scan (`ok: false`, mirroring W020's degraded-check
* report) or a thrown exception (mirrors cmdValidateHealth's own
* "git worktree not available or not a git repo — skip silently" catch)
* both degrade to `UNREADABLE` with an empty findings array, since neither
* case has real per-worktree data to report.
*/
function buildWorktreeHealthField(cwd: string): { value: ReturnType<typeof inspectWorktreeHealth>['findings']; scope: Scope } {
try {
const result = inspectWorktreeHealth(
cwd,
{ staleAfterMs: 60 * 60 * 1000 },
{ execGit, existsSync: fs.existsSync, statSync: fs.statSync },
);
if (!result.ok) {
return { value: [], scope: SCOPE.UNREADABLE };
}
return { value: result.findings, scope: SCOPE.COMPLETE };
} catch {
return { value: [], scope: SCOPE.UNREADABLE };
}
}
/**
* Build the full `.planning/` projection for `cwd`. Composes the six §7
* owners named in the design doc's "Owners consumed" table, plus (Phase 11,
* #3309) the three additive subject-surface fields `config`/`agentInstall`/
* `worktreeHealth` — no re-derivation, no new semantic answer beyond what
* their respective owners already compute. See the design doc for the
* behavior table and rejected alternatives.
*/
function buildPlanningSnapshot(cwd: string): PlanningSnapshot {
@@ -173,6 +285,9 @@ function buildPlanningSnapshot(cwd: string): PlanningSnapshot {
scope: worstScope(phaseDirs.scope, ...phasesValue.map((p) => p.scope)),
},
currentPhaseLabel: buildCurrentPhaseLabel(paths.state),
config: buildConfigField(cwd),
agentInstall: buildAgentInstallField(cwd),
worktreeHealth: buildWorktreeHealthField(cwd),
};
}

View File

@@ -64,6 +64,13 @@ const UNUSABLE_REASON = Object.freeze({
* planning-snapshot's current-phase field)
*/
STATE_UNREADABLE: 'state_unreadable',
/**
* A config.json exists but could not be read/parsed (EACCES/EIO/malformed JSON/…).
* Distinct from a project that has not run any config-writing command yet: absence
* returns the same non-answer, silently — only an exists-but-unreadable config.json
* is corruption. (#3309, eighth #1879 site — planning-snapshot's config field)
*/
CONFIG_UNREADABLE: 'config_unreadable',
} as const);
type UnusableReason = (typeof UNUSABLE_REASON)[keyof typeof UNUSABLE_REASON];
@@ -78,6 +85,8 @@ const REASON_PROSE: Readonly<Record<UnusableReason, string>> = Object.freeze({
'last_activity in STATE.md is present but unparseable as a date; stale_activity fell back to false (idle-stranded suppressed)',
[UNUSABLE_REASON.STATE_UNREADABLE]:
'STATE.md exists but could not be read; the current-phase label fell back to unavailable',
[UNUSABLE_REASON.CONFIG_UNREADABLE]:
'config.json exists but could not be read or parsed; the config field fell back to unavailable',
});
// ─── Dedup state ──────────────────────────────────────────────────────────────

View File

@@ -26,6 +26,7 @@ const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const childProcess = require('node:child_process');
const fc = require('fast-check');
const { createTempDir, cleanup } = require('./helpers.cjs');
@@ -43,6 +44,12 @@ const { worstScope } = planningSnapshotLib;
const { SCOPE } = require('../gsd-core/bin/lib/planning-scope.cjs');
const { _unusableInputEmissionCountForTests } = require('../gsd-core/bin/lib/unusable-input.cjs');
// Phase 11 (#3309) additions — agent-install fixture helper mirrors
// tests/agent-install-check.test.cjs's own EXPECTED_AGENTS/createCompleteAgents
// (design doc's "subject-surface gap" §, reused per its provenance rule).
const { MODEL_PROFILES } = require('../gsd-core/bin/lib/model-profiles.cjs');
const EXPECTED_AGENTS = Object.keys(MODEL_PROFILES);
// ─── Fixture helpers (mirrors tests/completion-ratio-scope-withholding.test.cjs) ─
function planningDirOf(cwd) {
@@ -495,3 +502,189 @@ describe('worstScope — pure unit coverage', () => {
);
});
});
// ═════════════════════════════════════════════════════════════════════════
// Phase 11 (#3309) additions — config / agentInstall / worktreeHealth
//
// Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md
// ("The subject-surface gap: config.json, agent-install, git-worktree-list")
// Test matrix: .gsd/phase/refactor-3309-health-diagnostic-rule-table/50-test-matrix.md
// section 1, rows 1-8
//
// These three new fields wrap the SAME owner calls `cmdValidateHealth`
// (src/verify.cts) already makes (`checkAgentsInstalled`, `inspectWorktreeHealth`,
// a raw config.json read), so a later phase step can migrate the caller onto
// this snapshot without a shape mismatch.
// ═════════════════════════════════════════════════════════════════════════
function writeConfig(cwd, obj) {
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
fs.writeFileSync(path.join(planningDirOf(cwd), 'config.json'), JSON.stringify(obj));
}
function writeRawConfig(cwd, rawText) {
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
fs.writeFileSync(path.join(planningDirOf(cwd), 'config.json'), rawText);
}
// Env isolation for GSD_AGENTS_DIR — mirrors tests/agent-install-check.test.cjs's
// beforeEach/afterEach save-restore idiom, inlined per-test via t.after since this
// describe block does not otherwise need beforeEach/afterEach hooks.
function withAgentsDirOverride(t, agentsDir) {
const saved = process.env['GSD_AGENTS_DIR'];
process.env['GSD_AGENTS_DIR'] = agentsDir;
t.after(() => {
if (saved === undefined) delete process.env['GSD_AGENTS_DIR'];
else process.env['GSD_AGENTS_DIR'] = saved;
});
}
function createCompleteAgentsDir(agentsDir) {
fs.mkdirSync(agentsDir, { recursive: true });
for (const agent of EXPECTED_AGENTS) {
fs.writeFileSync(path.join(agentsDir, `${agent}.toml`), `name = "${agent}"\n`);
}
}
// Simulates a successful `git worktree list --porcelain` at the spawnSync seam —
// mirrors tests/worktree-safety.test.cjs's "execGitDefault (real spawn seam)"
// section, the repo's convention for driving the real execGit rather than a
// hand-set deps.execGit stub (this module accepts no deps parameter to inject).
function mockGitWorktreeListOk(t, porcelain) {
t.mock.method(childProcess, 'spawnSync', () => ({
status: 0,
stdout: porcelain,
stderr: '',
signal: null,
error: null,
}));
}
// Simulates a timed-out `git worktree list --porcelain` (ETIMEDOUT), the same
// shape shell-command-projection.cjs's execGit / isSpawnTimeout recognize.
function mockGitWorktreeListTimeout(t) {
t.mock.method(childProcess, 'spawnSync', () => ({
status: null,
stdout: '',
stderr: '',
signal: null,
error: Object.assign(new Error('spawnSync git ETIMEDOUT'), { code: 'ETIMEDOUT' }),
}));
}
describe('config field (Phase 11, #3309, matrix rows 1-3)', () => {
test('row 1: well-formed config.json parses to {value, scope: COMPLETE}', (t) => {
const cwd = createTempDir('gsd-3309-cfg1-');
t.after(() => cleanup(cwd));
writeConfig(cwd, { model_profile: 'balanced' });
const snap = buildPlanningSnapshot(cwd);
assert.deepStrictEqual(snap.config, { value: { model_profile: 'balanced' }, scope: SCOPE.COMPLETE, exists: true });
});
test('row 2: absent config.json is a real non-answer — {value: null, scope: UNREADABLE, exists: false}', (t) => {
const cwd = createTempDir('gsd-3309-cfg2-');
t.after(() => cleanup(cwd));
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
// No config.json written at all.
const [snap, emitted] = emissionsDuring(() => buildPlanningSnapshot(cwd));
assert.deepStrictEqual(snap.config, { value: null, scope: SCOPE.UNREADABLE, exists: false });
assert.strictEqual(emitted, 0, 'absence is not corruption — no diagnostic');
});
test('row 3: present-but-unparseable config.json degrades without throwing — {value: null, scope: UNREADABLE, exists: true}, emits CONFIG_UNREADABLE exactly once', (t) => {
const cwd = createTempDir('gsd-3309-cfg3-');
t.after(() => cleanup(cwd));
writeRawConfig(cwd, '{ not valid json');
let snap;
let emitted;
assert.doesNotThrow(() => {
[snap, emitted] = emissionsDuring(() => buildPlanningSnapshot(cwd));
});
assert.deepStrictEqual(snap.config, { value: null, scope: SCOPE.UNREADABLE, exists: true });
assert.strictEqual(emitted, 1, 'present-but-unparseable config.json is corruption — exactly one CONFIG_UNREADABLE diagnostic');
});
});
describe('agentInstall field (Phase 11, #3309, matrix rows 4-5)', () => {
test('row 4: all agents present reports zero missing/incomplete, scope COMPLETE', (t) => {
const cwd = createTempDir('gsd-3309-agt4-');
t.after(() => cleanup(cwd));
const agentsDir = path.join(cwd, 'agents-complete');
createCompleteAgentsDir(agentsDir);
withAgentsDirOverride(t, agentsDir);
const snap = buildPlanningSnapshot(cwd);
assert.strictEqual(snap.agentInstall.scope, SCOPE.COMPLETE);
assert.strictEqual(snap.agentInstall.value.agents_installed, true);
assert.deepStrictEqual(snap.agentInstall.value.missing_agents, []);
assert.deepStrictEqual(snap.agentInstall.value.incomplete_agents, []);
});
test('row 5: missing agents dir reports the full missing set, scope COMPLETE (the scan itself succeeded)', (t) => {
const cwd = createTempDir('gsd-3309-agt5-');
t.after(() => cleanup(cwd));
const agentsDir = path.join(cwd, 'agents-absent');
withAgentsDirOverride(t, agentsDir);
// agentsDir deliberately never created.
const snap = buildPlanningSnapshot(cwd);
assert.strictEqual(snap.agentInstall.scope, SCOPE.COMPLETE);
assert.strictEqual(snap.agentInstall.value.agents_installed, false);
assert.deepStrictEqual(snap.agentInstall.value.missing_agents.slice().sort(), EXPECTED_AGENTS.slice().sort());
});
});
describe('worktreeHealth field (Phase 11, #3309, matrix rows 6-7)', () => {
test('row 6: git worktree list succeeds — value is the parsed findings array, scope COMPLETE', (t) => {
const cwd = createTempDir('gsd-3309-wt6-');
t.after(() => cleanup(cwd));
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
mockGitWorktreeListOk(t, 'worktree /repo\nHEAD 0000000000000000000000000000000000000000\nbranch refs/heads/main\n\n');
const snap = buildPlanningSnapshot(cwd);
assert.strictEqual(snap.worktreeHealth.scope, SCOPE.COMPLETE);
assert.ok(Array.isArray(snap.worktreeHealth.value));
});
test('row 7: git worktree list times out — scope reflects degradation (mirrors W020)', (t) => {
const cwd = createTempDir('gsd-3309-wt7-');
t.after(() => cleanup(cwd));
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
mockGitWorktreeListTimeout(t);
const snap = buildPlanningSnapshot(cwd);
assert.notStrictEqual(snap.worktreeHealth.scope, SCOPE.COMPLETE);
assert.strictEqual(snap.worktreeHealth.scope, SCOPE.UNREADABLE);
assert.deepStrictEqual(snap.worktreeHealth.value, []);
});
});
describe('Phase-10 fields unchanged by the Phase-11 extension (matrix row 8)', () => {
test('the four original fields keep their exact pre-extension values on the same fixture', (t) => {
const cwd = createTempDir('gsd-3309-reg8-');
t.after(() => cleanup(cwd));
buildHealthyTwoPhaseFixture(cwd);
const snap = buildPlanningSnapshot(cwd);
assert.strictEqual(snap.milestone.scope, SCOPE.COMPLETE);
assert.strictEqual(snap.phaseDirs.scope, SCOPE.COMPLETE);
assert.strictEqual(snap.phases.scope, SCOPE.COMPLETE);
assert.strictEqual(snap.phases.value.length, 2);
for (const p of snap.phases.value) {
assert.strictEqual(p.complete, true);
assert.strictEqual(p.scope, SCOPE.COMPLETE);
assert.strictEqual(p.verificationStatus, 'passed');
assert.strictEqual(p.planCount, 1);
assert.strictEqual(p.summaryCount, 1);
}
// The extension is additive — the new fields must be present alongside
// the untouched originals, not in place of them.
assert.ok('config' in snap);
assert.ok('agentInstall' in snap);
assert.ok('worktreeHealth' in snap);
});
});

View File

@@ -72,7 +72,7 @@ describe('UNUSABLE_REASON', () => {
// (enum + call site + this assertion) instead of a silent widening.
assert.deepStrictEqual(
Object.keys(UNUSABLE_REASON).sort(),
['FRONTMATTER_UNTERMINATED', 'LAST_ACTIVITY_UNPARSEABLE', 'ROADMAP_UNREADABLE', 'STATE_UNREADABLE'],
['CONFIG_UNREADABLE', 'FRONTMATTER_UNTERMINATED', 'LAST_ACTIVITY_UNPARSEABLE', 'ROADMAP_UNREADABLE', 'STATE_UNREADABLE'],
);
assert.strictEqual(UNUSABLE_REASON.FRONTMATTER_UNTERMINATED, 'frontmatter_unterminated');
});
@@ -134,6 +134,53 @@ describe('STATE_UNREADABLE', () => {
});
});
// ─── CONFIG_UNREADABLE: a config.json that exists but could not be read/parsed ─
describe('CONFIG_UNREADABLE', () => {
test('a genuinely unreadable config.json produces exactly one diagnostic', () => {
_resetUnusableInputWarningsForTests();
const emitted = emissionsDuring(() => {
const wrote = warnUnusableInput({
reason: UNUSABLE_REASON.CONFIG_UNREADABLE,
source: '/u/config-unreadable.json',
});
assert.strictEqual(wrote, true);
});
assert.strictEqual(emitted, 1);
});
test('the same config.json path reported twice yields one diagnostic', () => {
_resetUnusableInputWarningsForTests();
const source = '/u/config-unreadable-dedup/config.json';
const emitted = emissionsDuring(() => {
const first = warnUnusableInput({ reason: UNUSABLE_REASON.CONFIG_UNREADABLE, source });
const repeat = warnUnusableInput({ reason: UNUSABLE_REASON.CONFIG_UNREADABLE, source });
assert.strictEqual(first, true);
assert.strictEqual(repeat, false, 'same (path, cause) must dedup');
});
assert.strictEqual(emitted, 1);
});
test('two different config.json paths are never suppressed as one', () => {
_resetUnusableInputWarningsForTests();
const emitted = emissionsDuring(() => {
warnUnusableInput({
reason: UNUSABLE_REASON.CONFIG_UNREADABLE,
source: '/u/config-unreadable-a/config.json',
});
warnUnusableInput({
reason: UNUSABLE_REASON.CONFIG_UNREADABLE,
source: '/u/config-unreadable-b/config.json',
});
});
assert.strictEqual(emitted, 2, 'keying too coarsely would hide a real second fault');
});
test('the reason value is the frozen string "config_unreadable"', () => {
assert.strictEqual(UNUSABLE_REASON.CONFIG_UNREADABLE, 'config_unreadable');
});
});
// ─── The discriminator: truncated vs. everything that merely looks like it ───
describe('extractFrontmatter — flags a genuinely truncated frontmatter', () => {