cadet-agent 0.45.0 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,606 @@
1
+ /**
2
+ * Artifact reconciliation — does the planning chain still agree with itself?
3
+ *
4
+ * Why this exists: every per-story check can pass while the chain as a whole
5
+ * stops making sense. A story gets renamed and its neighbours still point at the
6
+ * old file; a story is marked done in state while its markdown still says
7
+ * planned; a deferral names a work item that finished three stories ago; an epic
8
+ * directory exists that no plan mentions. Each is a claim in one artifact that
9
+ * another artifact contradicts, and nothing looked at more than one document at a
10
+ * time — `designArtifactSyncConfirmed` ("Requirements, design, plan, epics
11
+ * mutually consistent") is the one gate on `validation -> closed` and, before
12
+ * this module, nothing in the codebase could back it.
13
+ *
14
+ * Scope. This module reads the planning tree and reports what it can *prove*
15
+ * from the artifacts themselves. It does not and cannot judge whether a design
16
+ * decision is still honoured, whether two requirements contradict each other, or
17
+ * whether the project drifted from its intent — that is the Reconciliation
18
+ * skill's semantic pass. The split is deliberate: a check that cannot be
19
+ * mechanised must not be dressed up as one, because a prose assertion that
20
+ * nothing verifies is the exact shape `docs/core/HarnessContract-v4.md` §0.1
21
+ * names as the anti-pattern.
22
+ *
23
+ * Gaps are reported for work that is still OPEN, not for history. A field added
24
+ * to a template in one release is not retroactively owed by every document
25
+ * written before it, and a check that says so fires on a correct project — which
26
+ * is how a report teaches its reader to ignore it. That is not a theory: the
27
+ * first version of this module was run against a real 88-story project and
28
+ * produced 15 blocking findings for epics that merely lived one directory deeper,
29
+ * two more for documents that existed under other names, and ~120 warnings for
30
+ * fields that predated the templates. So: a reachability declaration is owed by a
31
+ * story in flight (it is written during implementation), a witness checkpoint by
32
+ * an epic that is not closed, and a `done` story's evidence ALWAYS — a completion
33
+ * claim has to be traceable whenever it was made, and the framework's answer to
34
+ * an accepted historical gap is a recorded gate-exception, not silence.
35
+ *
36
+ * Discovery is by content, not by path: an epic is a directory containing
37
+ * `epic.md` wherever it sits, and a required document is matched by filename
38
+ * pattern wherever it sits, because a real project nests its artifacts under a
39
+ * named project folder and calls its requirements `mvp-requirements.md`.
40
+ *
41
+ * Read-only. It writes nothing: no state, no ledger, no report. The skill authors
42
+ * the report from this verdict.
43
+ *
44
+ * The honesty rule. `verdict` is `unknown` whenever any artifact or required
45
+ * field could not be read, because a clean verdict must never be reachable from
46
+ * input the module could not parse — "an unparseable report proves nothing"
47
+ * (`src/cli.mjs` verify-acs).
48
+ */
49
+
50
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
51
+ import { basename, isAbsolute, join, relative } from 'node:path';
52
+
53
+ import { PHASES } from './policy.mjs';
54
+ import {
55
+ collectWorkItems,
56
+ parseReachabilityDeclaration,
57
+ validateReachabilityDeclaration,
58
+ } from './reachability.mjs';
59
+
60
+ /** Where the framework puts planning artifacts when a policy does not relocate them. */
61
+ export const PLANS_DEFAULT_DIR = '.cadet/agent/project-plans';
62
+
63
+ /**
64
+ * The documents a large change is expected to produce, matched by filename
65
+ * PATTERN rather than by a fixed path.
66
+ *
67
+ * The fixed-path version of this check was wrong in practice. A real project
68
+ * (`dolven-tactics`) keeps its artifacts under a named project folder with its
69
+ * own document names — `.cadet/agent/project-plans/dolven-tactics-mvp/
70
+ * mvp-requirements.md` — so looking for `requirements.md` at the plans root
71
+ * reported two blocking findings against a project that had both documents. A
72
+ * check that fires on a correct project is worse than no check: it teaches the
73
+ * reader to ignore the output. Discovery is bounded and the path that satisfied
74
+ * each one is reported, so the reader can see which file counted.
75
+ *
76
+ * `project-plan.md` is advisory rather than required: no skill in the dispatch
77
+ * table produces one (Requirements, Architecture and StoryBreakdown cover the
78
+ * others), so demanding it would report a gap for a document the workflow never
79
+ * asked for.
80
+ */
81
+ export const REQUIRED_ARTIFACTS = [
82
+ { name: 'requirements', pattern: /requirements[^/\\]*\.md$/i, fromPhase: 'architectureComplete', severity: 'blocking' },
83
+ { name: 'technical-design', pattern: /technical-design[^/\\]*\.md$/i, fromPhase: 'story-breakdown', severity: 'blocking' },
84
+ { name: 'project-plan', pattern: /project-plan[^/\\]*\.md$/i, fromPhase: null, severity: 'info' },
85
+ ];
86
+
87
+ export const RECONCILE_SEVERITIES = ['blocking', 'warning', 'info'];
88
+ export const RECONCILE_VERDICTS = ['consistent', 'findings', 'unknown'];
89
+
90
+ /** A planning document larger than this is not read; the bound keeps a runaway file from stalling a run. */
91
+ export const DEFAULT_MAX_DOC_BYTES = 256 * 1024;
92
+
93
+ /**
94
+ * How deep the planning tree is walked. Real layouts nest: a project folder, then
95
+ * `epics/`, then `epic-N/`. Three levels is not enough to assume, so the walk is
96
+ * bounded rather than shallow — and bounded rather than unbounded so a symlink
97
+ * loop or a stray `node_modules` cannot turn a read into a crawl.
98
+ */
99
+ export const MAX_SCAN_DEPTH = 5;
100
+
101
+ /** Directories never worth scanning in a game repo. */
102
+ const SKIP_DIRS = new Set(['.git', 'node_modules', 'Library', 'obj', 'Temp', 'Logs', 'Build', 'UserSettings']);
103
+
104
+ const SEVERITY_RANK = { blocking: 0, warning: 1, info: 2 };
105
+
106
+ function toPosix(p) {
107
+ return String(p).replace(/\\/g, '/');
108
+ }
109
+
110
+ function repoRelative(targetDir, abs) {
111
+ const rel = toPosix(relative(targetDir, abs));
112
+ return rel || '.';
113
+ }
114
+
115
+ /** 1-based line of the first match, for citing a finding back to its source. */
116
+ function lineOf(text, pattern) {
117
+ const lines = text.split(/\r?\n/);
118
+ for (let i = 0; i < lines.length; i++) {
119
+ if (pattern.test(lines[i])) return i + 1;
120
+ }
121
+ return null;
122
+ }
123
+
124
+ /** The body of a `## Heading` section, up to the next `## ` heading. */
125
+ function sectionBody(text, heading) {
126
+ const lines = text.split(/\r?\n/);
127
+ const wanted = heading.toLowerCase();
128
+ let start = -1;
129
+ for (let i = 0; i < lines.length; i++) {
130
+ const m = lines[i].match(/^##\s+(.+?)\s*$/);
131
+ if (m && m[1].toLowerCase() === wanted) { start = i + 1; break; }
132
+ }
133
+ if (start === -1) return null;
134
+ const out = [];
135
+ for (let i = start; i < lines.length; i++) {
136
+ if (/^##\s+/.test(lines[i])) break;
137
+ out.push(lines[i]);
138
+ }
139
+ return out.join('\n').trim();
140
+ }
141
+
142
+ /**
143
+ * The path a link field points at. The templates allow either a bare path or a
144
+ * markdown link (`[text](../epic.md)`), and a consumer may write either, so both
145
+ * are accepted rather than one being silently unresolvable.
146
+ */
147
+ function linkTarget(value) {
148
+ if (!value) return null;
149
+ const md = String(value).match(/\]\(([^)]+)\)/);
150
+ const raw = (md ? md[1] : String(value)).trim().replace(/^<|>$/g, '');
151
+ if (!raw || /^(none|n\/a|todo|tbd)$/i.test(raw)) return null;
152
+ return raw;
153
+ }
154
+
155
+ /**
156
+ * Every path-like candidate in a link field.
157
+ *
158
+ * The template's `fmt="link"` promises one path, but a real epic wrote three
159
+ * separated by `·` with parenthetical annotations — `a.md (note) · b.md (note)`
160
+ * — and treating that whole string as one filename reported a perfectly good
161
+ * link as dangling. A link field is satisfied when ANY candidate resolves.
162
+ */
163
+ function linkCandidates(value) {
164
+ if (!value) return [];
165
+ const text = String(value);
166
+ const out = [];
167
+ for (const m of text.matchAll(/\]\(([^)]+)\)/g)) out.push(m[1]);
168
+ const withoutMarkdown = text.replace(/\[[^\]]*\]\([^)]*\)/g, ' · ');
169
+ for (const part of withoutMarkdown.split(/[·,;|]/)) {
170
+ const token = part.replace(/\([^)]*\)/g, ' ').trim().split(/\s+/)[0];
171
+ if (token && /\.md$/i.test(token)) out.push(token);
172
+ }
173
+ return [...new Set(out.map((s) => s.trim().replace(/^<|>$/g, '')))]
174
+ .filter((s) => s && !/^(none|n\/a|todo|tbd)$/i.test(s));
175
+ }
176
+
177
+ /**
178
+ * Does a story's declared link resolve?
179
+ *
180
+ * The story template writes `Parent Epic: ../epic.md`, while the documented
181
+ * layout puts `epic.md` in the *same* directory as its stories (see
182
+ * `docs/templates/EpicTemplate.md`'s directory listing, and
183
+ * `reachability.readSiblingDeclarations`, which finds siblings in that same
184
+ * directory). Both readings are accepted here, so neither convention is reported
185
+ * as broken — but a wrong *filename* still is, which is the case worth catching.
186
+ */
187
+ function resolvesStoryLink(storyPath, target) {
188
+ if (isAbsolute(target)) return existsSync(target);
189
+ if (existsSync(join(storyPath, '..', target))) return true;
190
+ return existsSync(join(storyPath, '..', basename(target)));
191
+ }
192
+
193
+ function readBounded(path, maxBytes) {
194
+ try {
195
+ const stat = statSync(path);
196
+ if (stat.size > maxBytes) return { error: `file is ${stat.size} bytes, above the ${maxBytes}-byte read bound` };
197
+ return { text: readFileSync(path, 'utf-8') };
198
+ } catch (err) {
199
+ return { error: `cannot read: ${err.message}` };
200
+ }
201
+ }
202
+
203
+ /**
204
+ * The fields a story must carry for the structural checks to run at all. A field
205
+ * that cannot be read is reported, never defaulted away: a missing `Status` must
206
+ * not silently read as "no mismatch".
207
+ */
208
+ export function parseStoryHeader(text) {
209
+ const field = (label) => {
210
+ const m = text.match(new RegExp(`^${label}:[ \\t]*(.*)$`, 'm'));
211
+ return m ? m[1].trim() : null;
212
+ };
213
+ return {
214
+ storyId: (text.match(/^(EPIC-\d+-STORY-\d+)\s*$/m) || [])[1] ?? null,
215
+ status: field('Status') || null,
216
+ parentEpic: field('Parent Epic') || null,
217
+ reachability: field('Reachability') || null,
218
+ designRefs: field('Design refs') || null,
219
+ statusLine: lineOf(text, /^Status:/),
220
+ };
221
+ }
222
+
223
+ export function parseEpicHeader(text) {
224
+ const field = (label) => {
225
+ const m = text.match(new RegExp(`^${label}:[ \\t]*(.*)$`, 'm'));
226
+ return m ? m[1].trim() : null;
227
+ };
228
+ const stories = sectionBody(text, 'Stories');
229
+ return {
230
+ epicId: (text.match(/^(EPIC-\d+)\s*$/m) || [])[1] ?? null,
231
+ status: field('Status') || null,
232
+ requirementsLinks: linkCandidates(field('Requirements')),
233
+ technicalDesignLinks: linkCandidates(field('Technical Design')),
234
+ witnessCheckpoint: sectionBody(text, 'Witness checkpoint'),
235
+ declaredStoryCount: stories
236
+ ? (stories.split(/\r?\n/).filter((l) => /^\s*-\s+\[/.test(l)).length || null)
237
+ : null,
238
+ statusLine: lineOf(text, /^Status:/),
239
+ };
240
+ }
241
+
242
+ /**
243
+ * Every directory under `root`, breadth-first and depth-bounded. Skipping the
244
+ * usual game-repo ballast keeps a walk of a real project cheap.
245
+ */
246
+ function walkDirs(root, maxDepth = MAX_SCAN_DEPTH) {
247
+ const found = [];
248
+ let level = [root];
249
+ for (let depth = 0; depth <= maxDepth && level.length > 0; depth++) {
250
+ const next = [];
251
+ for (const dir of level) {
252
+ found.push(dir);
253
+ let entries;
254
+ try {
255
+ entries = readdirSync(dir, { withFileTypes: true });
256
+ } catch { continue; }
257
+ for (const entry of entries) {
258
+ if (!entry.isDirectory()) continue;
259
+ if (SKIP_DIRS.has(entry.name)) continue;
260
+ next.push(join(dir, entry.name));
261
+ }
262
+ }
263
+ level = next;
264
+ }
265
+ return found;
266
+ }
267
+
268
+ /** The first file under `root` whose name matches, or null. */
269
+ function findDoc(root, pattern) {
270
+ for (const dir of walkDirs(root)) {
271
+ let entries;
272
+ try {
273
+ entries = readdirSync(dir, { withFileTypes: true });
274
+ } catch { continue; }
275
+ for (const entry of entries) {
276
+ if (!entry.isFile() || !pattern.test(entry.name)) continue;
277
+ return join(dir, entry.name);
278
+ }
279
+ }
280
+ return null;
281
+ }
282
+
283
+ /**
284
+ * Walk the planning tree. Returns what exists and what could be read — nothing is
285
+ * interpreted here, so a caller can report an unreadable tree as such.
286
+ *
287
+ * Both the epics and the documents are DISCOVERED rather than assumed to sit at a
288
+ * fixed depth. Real layouts nest (`<project>/epics/epic-N/`) and name their own
289
+ * documents, and a reconciler that assumes the packaged template's layout reports
290
+ * a correct project as broken.
291
+ */
292
+ export function collectArtifacts(targetDir, {
293
+ plansDir = PLANS_DEFAULT_DIR,
294
+ story = null,
295
+ maxBytes = DEFAULT_MAX_DOC_BYTES,
296
+ } = {}) {
297
+ const root = isAbsolute(plansDir) ? plansDir : join(targetDir, plansDir);
298
+ if (!existsSync(root)) {
299
+ return { available: false, reason: `no planning artifacts at ${toPosix(plansDir)}`, root, docs: [], epics: [], scopedEpic: null };
300
+ }
301
+
302
+ const dirs = walkDirs(root);
303
+
304
+ const docs = REQUIRED_ARTIFACTS.map(({ name, pattern }) => {
305
+ const path = findDoc(root, pattern);
306
+ return { name, path, relPath: path ? repoRelative(targetDir, path) : null, present: path !== null };
307
+ });
308
+
309
+ // Scope to one epic when a story path is given. State keys epics by directory
310
+ // NAME (`epic-1-player-movement`), not by path, so the scope is that name — and
311
+ // every epic lookup in this module uses the same key.
312
+ let scopedEpic = null;
313
+ if (story) {
314
+ const abs = isAbsolute(story) ? story : join(targetDir, story);
315
+ scopedEpic = basename(join(abs, '..'));
316
+ }
317
+
318
+ const epics = [];
319
+ for (const dir of dirs) {
320
+ const name = basename(dir);
321
+ const epicFile = join(dir, 'epic.md');
322
+ // An epic is identified by content, not by its directory name: a consumer may
323
+ // name the folder anything, and `adr/`, `spikes/` and `evidence/` live in the
324
+ // same tree — at whatever depth the project chose.
325
+ if (!existsSync(epicFile)) continue;
326
+ if (scopedEpic && name !== scopedEpic) continue;
327
+
328
+ const epicRead = readBounded(epicFile, maxBytes);
329
+ let storyFiles = [];
330
+ try {
331
+ storyFiles = readdirSync(dir)
332
+ .filter((f) => /^story-.*\.md$/i.test(f))
333
+ .sort();
334
+ } catch { storyFiles = []; }
335
+
336
+ const stories = storyFiles.map((file) => {
337
+ const path = join(dir, file);
338
+ const read = readBounded(path, maxBytes);
339
+ return { file, path, relPath: repoRelative(targetDir, path), ...read };
340
+ });
341
+
342
+ epics.push({
343
+ // `key` is the directory name, which is what state.json and work-item ids
344
+ // use (`epic-1-foo::story-2.md`). `dir` is the repo-relative path, for
345
+ // display only — conflating the two makes every lookup silently miss.
346
+ key: name,
347
+ dir: repoRelative(targetDir, dir),
348
+ dirPath: dir,
349
+ epicFile: { path: epicFile, relPath: repoRelative(targetDir, epicFile), ...epicRead },
350
+ stories,
351
+ });
352
+ }
353
+
354
+ epics.sort((a, b) => (a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0));
355
+ return { available: true, reason: null, root, docs, epics, scopedEpic };
356
+ }
357
+
358
+ /**
359
+ * Reconcile the planning tree against `state.json`.
360
+ *
361
+ * @returns {{ok: boolean, available: boolean, plansDir: string, verdict: string|null,
362
+ * findings: Array, summary: object, reason: string|null}}
363
+ */
364
+ export function reconcileArtifacts(targetDir, {
365
+ state = null,
366
+ plansDir = PLANS_DEFAULT_DIR,
367
+ story = null,
368
+ maxBytes = DEFAULT_MAX_DOC_BYTES,
369
+ } = {}) {
370
+ const collected = collectArtifacts(targetDir, { plansDir, story, maxBytes });
371
+ const plansDirRel = toPosix(plansDir);
372
+
373
+ if (!collected.available) {
374
+ return {
375
+ ok: true, available: false, plansDir: plansDirRel, verdict: null,
376
+ findings: [], summary: { total: 0, blocking: 0, warning: 0, info: 0 },
377
+ reason: collected.reason,
378
+ };
379
+ }
380
+
381
+ const findings = [];
382
+ const add = (code, severity, subject, artifact, detail, evidence = null) => {
383
+ findings.push({ code, severity, subject, artifact, detail, evidence });
384
+ };
385
+
386
+ const stateEpics = state?.epics && typeof state.epics === 'object' ? state.epics : {};
387
+ const phase = state?.session?.currentPhase ?? null;
388
+ const workflowPath = state?.session?.workflowPath ?? null;
389
+ const phaseIndex = PHASES.indexOf(phase);
390
+
391
+ // ── 1. Required top-level documents ───────────────────────────────────────
392
+ for (const { name, fromPhase, severity } of REQUIRED_ARTIFACTS) {
393
+ const doc = collected.docs.find((d) => d.name === name);
394
+ if (!doc || doc.present) continue;
395
+ // Only expect a document once the workflow has reached the phase that
396
+ // produces it, so an early-phase run does not report the future as a gap.
397
+ const expected = fromPhase === null
398
+ ? workflowPath === 'large'
399
+ : phaseIndex >= 0 && phaseIndex >= PHASES.indexOf(fromPhase);
400
+ if (!expected) continue;
401
+ add('missing-artifact', severity, `${name}.md`, `${plansDirRel}/**`,
402
+ fromPhase === null
403
+ ? 'no project plan was found anywhere under the plans directory. No skill in the dispatch produces one, so this is advisory — but a large change is expected to have one.'
404
+ : `no ${name} document was found anywhere under the plans directory, though the workflow reached \`${phase}\`. The chain has no root to reconcile against.`);
405
+ }
406
+
407
+ // ── 2. Epics: state vs disk, both directions ──────────────────────────────
408
+ const onDisk = new Set(collected.epics.map((e) => e.key));
409
+ for (const epicDir of Object.keys(stateEpics).sort()) {
410
+ if (!onDisk.has(epicDir)) {
411
+ add('missing-epic-dir', 'blocking', epicDir, `${plansDirRel}/${epicDir}`,
412
+ 'state.json tracks this epic, but no `epic.md` exists for it on disk. Every story under it is unreachable as an artifact.');
413
+ }
414
+ }
415
+
416
+ for (const epic of collected.epics) {
417
+ const stateEpic = stateEpics[epic.key];
418
+
419
+ if (!stateEpic) {
420
+ add('orphan-epic-dir', 'warning', epic.key, epic.epicFile.relPath,
421
+ 'this epic exists on disk but state.json does not track it. Nothing will ever mark its stories done.');
422
+ }
423
+
424
+ if (epic.epicFile.error) {
425
+ add('unparsable-artifact', 'warning', epic.key, epic.epicFile.relPath,
426
+ `the epic could not be read (${epic.epicFile.error}), so its status and links were not checked.`);
427
+ } else {
428
+ const header = parseEpicHeader(epic.epicFile.text);
429
+ if (!header.status) {
430
+ add('unparsable-artifact', 'warning', epic.key, epic.epicFile.relPath,
431
+ 'the epic has no readable `Status:` field, so its status was not reconciled.');
432
+ }
433
+ if (header.epicId === null) {
434
+ add('unparsable-artifact', 'warning', epic.key, epic.epicFile.relPath,
435
+ 'the epic declares no `EPIC-N` id, so its identity was not checked.');
436
+ }
437
+
438
+ // Epic -> requirements / design links must resolve. A field may carry more
439
+ // than one link, and it is satisfied when any one of them resolves.
440
+ for (const [label, targets] of [['Requirements', header.requirementsLinks], ['Technical Design', header.technicalDesignLinks]]) {
441
+ if (targets.length === 0) continue;
442
+ const resolved = targets.some((t) => existsSync(isAbsolute(t) ? t : join(epic.dirPath, t)));
443
+ if (!resolved) {
444
+ add('dangling-epic-link', 'warning', epic.key, epic.epicFile.relPath,
445
+ `the epic's \`${label}\` points at ${targets.map((t) => `\`${t}\``).join(', ')}, and none of them exist. The chain cannot be followed past this epic.`,
446
+ `line ${lineOf(epic.epicFile.text, new RegExp(`^${label}:`)) ?? '?'}`);
447
+ }
448
+ }
449
+
450
+ const epicStatus = String(stateEpic?.status ?? '').toLowerCase();
451
+ const epicClosed = epicStatus === 'complete' || epicStatus === 'done';
452
+ if (!header.witnessCheckpoint && !epicClosed) {
453
+ add('missing-witness-checkpoint', 'warning', epic.key, epic.epicFile.relPath,
454
+ 'the epic declares no Witness checkpoint. The template marks it REQUIRED: without it, nothing states which story first makes the epic reachable.');
455
+ }
456
+ }
457
+
458
+ if (epic.stories.length === 0) {
459
+ add('epic-without-stories', 'warning', epic.key, epic.epicFile.relPath,
460
+ 'this epic directory contains no `story-*.md` files. An epic with no stories delivers nothing and cannot be reviewed.');
461
+ }
462
+
463
+ const stateStories = stateEpic?.stories && typeof stateEpic.stories === 'object' ? stateEpic.stories : {};
464
+ const diskStories = new Set(epic.stories.map((s) => s.file));
465
+
466
+ for (const file of Object.keys(stateStories).sort()) {
467
+ if (!diskStories.has(file)) {
468
+ add('missing-story-file', 'blocking', `${epic.key}::${file}`, `${plansDirRel}/${epic.key}/${file}`,
469
+ 'state.json tracks this story, but its markdown file is absent. Its acceptance criteria are no longer written down anywhere.');
470
+ }
471
+ }
472
+ for (const storyFile of epic.stories) {
473
+ if (!Object.hasOwn(stateStories, storyFile.file)) {
474
+ add('orphan-story-file', 'warning', `${epic.key}::${storyFile.file}`, storyFile.relPath,
475
+ 'this story file is not tracked in state.json. It will never be marked done, and no gate refers to it.');
476
+ }
477
+ }
478
+
479
+ // ── 3. Per-story checks ─────────────────────────────────────────────────
480
+ for (const storyFile of epic.stories) {
481
+ const subject = `${epic.key}::${storyFile.file}`;
482
+
483
+ if (storyFile.error) {
484
+ add('unparsable-artifact', 'warning', subject, storyFile.relPath,
485
+ `the story could not be read (${storyFile.error}), so none of its fields were reconciled.`);
486
+ continue;
487
+ }
488
+
489
+ const header = parseStoryHeader(storyFile.text);
490
+
491
+ if (!header.status) {
492
+ add('unparsable-artifact', 'warning', subject, storyFile.relPath,
493
+ 'the story has no readable `Status:` field, so it was not reconciled against state.json.');
494
+ }
495
+
496
+ // Story status vs state status.
497
+ const stateStatus = stateStories[storyFile.file];
498
+ const stateStatusLower = String(stateStatus).toLowerCase();
499
+ const isDone = stateStatusLower === 'done';
500
+ const isInFlight = stateStatusLower === 'in-progress';
501
+ if (header.status && stateStatus) {
502
+ const md = header.status.toLowerCase();
503
+ const st = String(stateStatus).toLowerCase();
504
+ const agree = md === st || (md === 'done' && st === 'done') || (md === 'in progress' && st === 'in-progress');
505
+ if (!agree) {
506
+ add('status-mismatch', 'warning', subject, storyFile.relPath,
507
+ `the story file says \`${header.status}\` while state.json says \`${stateStatus}\`. One of the two is the record and the other is stale, and a reader cannot tell which.`,
508
+ `line ${header.statusLine ?? '?'}`);
509
+ }
510
+ }
511
+
512
+ // Parent Epic link must resolve.
513
+ const parent = linkTarget(header.parentEpic);
514
+ if (parent) {
515
+ if (!resolvesStoryLink(storyFile.path, parent)) {
516
+ add('dangling-parent-epic', 'blocking', subject, storyFile.relPath,
517
+ `\`Parent Epic: ${header.parentEpic}\` does not resolve. The story has no epic, so nothing owns its completion.`,
518
+ `line ${lineOf(storyFile.text, /^Parent Epic:/) ?? '?'}`);
519
+ }
520
+ } else {
521
+ add('unparsable-artifact', 'warning', subject, storyFile.relPath,
522
+ 'the story declares no readable `Parent Epic:`, so its place in the chain was not checked.');
523
+ }
524
+
525
+ // Reachability: reuse the shipped validator so reconcile and
526
+ // verify-reachability cannot disagree about the same declaration.
527
+ const declaration = parseReachabilityDeclaration(storyFile.path);
528
+ const workItems = state ? collectWorkItems(state) : null;
529
+ const verdict = validateReachabilityDeclaration(declaration, { workItems, self: subject });
530
+ if (!verdict.ok) {
531
+ if (verdict.code === 'malformed') {
532
+ add('unparsable-artifact', 'warning', subject, storyFile.relPath,
533
+ `the reachability declaration could not be parsed: ${verdict.message}`);
534
+ } else if (verdict.code === 'not-declared') {
535
+ // The declaration is written DURING implementation, so only a story in
536
+ // flight owes one. A story that has not started has nothing truthful to
537
+ // declare yet, and a story that closed before the field existed is
538
+ // history — reporting either buries the findings that are real.
539
+ if (isInFlight) {
540
+ add('missing-reachability', 'warning', subject, storyFile.relPath,
541
+ 'this story is in progress but declares no reachability. The declaration is required before it can pass review, and nothing yet states how its deliverable is reached.');
542
+ }
543
+ } else if (verdict.code === 'deferral-target-done') {
544
+ add('expired-deferral', 'blocking', subject, storyFile.relPath, verdict.message);
545
+ } else {
546
+ add('unresolved-deferral', 'warning', subject, storyFile.relPath, verdict.message);
547
+ }
548
+ }
549
+
550
+ // A story state calls done must own evidence, or "done" is a claim with
551
+ // nothing behind it.
552
+ if (isDone) {
553
+ const row = state?.evidenceCoverage?.[subject]
554
+ ?? Object.values(state?.evidenceCoverage ?? {}).find((r) => r?.workItemId === subject);
555
+ const count = Number(row?.recordCount ?? 0);
556
+ if (!row || !Number.isFinite(count) || count <= 0) {
557
+ add('done-without-evidence', 'blocking', subject, storyFile.relPath,
558
+ 'state.json marks this story done, but no evidence record is indexed against it. The completion cannot be traced to anything that ran.');
559
+ }
560
+ }
561
+ }
562
+ }
563
+
564
+ // ── 4. Sort, number, and summarise ────────────────────────────────────────
565
+ findings.sort((a, b) => {
566
+ const s = (SEVERITY_RANK[a.severity] ?? 9) - (SEVERITY_RANK[b.severity] ?? 9);
567
+ if (s !== 0) return s;
568
+ if (a.code !== b.code) return a.code < b.code ? -1 : 1;
569
+ return a.subject < b.subject ? -1 : a.subject > b.subject ? 1 : 0;
570
+ });
571
+ findings.forEach((f, i) => { f.id = `R-${i + 1}`; });
572
+
573
+ const summary = {
574
+ total: findings.length,
575
+ blocking: findings.filter((f) => f.severity === 'blocking').length,
576
+ warning: findings.filter((f) => f.severity === 'warning').length,
577
+ info: findings.filter((f) => f.severity === 'info').length,
578
+ };
579
+
580
+ // An unreadable input cannot yield a clean verdict, whatever else was found.
581
+ //
582
+ // Only blocking and warning findings make the chain inconsistent. An `info`
583
+ // finding is advisory — the project-plan check is one — and folding it into the
584
+ // verdict would mean no project could ever be called consistent without a
585
+ // document no skill produces, which would make the verdict useless rather than
586
+ // strict.
587
+ const anythingUnparsable = findings.some((f) => f.code === 'unparsable-artifact');
588
+ const inconsistent = summary.blocking > 0 || summary.warning > 0;
589
+ const verdict = anythingUnparsable ? 'unknown' : inconsistent ? 'findings' : 'consistent';
590
+
591
+ return {
592
+ ok: true,
593
+ available: true,
594
+ plansDir: plansDirRel,
595
+ scopedEpic: collected.scopedEpic,
596
+ verdict,
597
+ findings,
598
+ summary,
599
+ artifacts: {
600
+ docs: collected.docs.map((d) => ({ name: d.name, present: d.present, path: d.relPath })),
601
+ epicCount: collected.epics.length,
602
+ storyCount: collected.epics.reduce((n, e) => n + e.stories.length, 0),
603
+ },
604
+ reason: null,
605
+ };
606
+ }