cadet-agent 0.46.0 → 0.48.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.
package/README.md CHANGED
@@ -9,7 +9,7 @@ Cadet-Agent is **not a one-shot code generator**. It won't spit out a finished g
9
9
  - `cadet-agent.md` is the thin global directive: identity, non-negotiable rules, workflow routing, hard-gate protocol, and skill dispatch.
10
10
  - `Harness.md` is the canonical harness contract: budgets, evidence-backed gates, retries, context tiers, tool routing, privacy, and escalation.
11
11
  - `harness.schema.json` and `state.schema.json` are the machine-readable schemas for harness records and session state.
12
- - `skills/` contains scoped workflow-phase skills (PlanningReview, Requirements, Architecture, Spike, StoryBreakdown, TDD, Debugging, CodeReview, Resume, MCPSetup, AgentReviewer).
12
+ - `skills/` contains scoped workflow-phase skills (PlanningReview, Requirements, Architecture, Spike, StoryBreakdown, TDD, Debugging, CodeReview, Resume, MCPSetup, AgentReviewer, Handoff, Reconciliation).
13
13
  - `templates/` contains runtime templates for planning artifacts.
14
14
  - `.cadet/harness.json` holds repository-local budget/policy overrides (preserved by sync).
15
15
  - `.cadet/runs/` holds sanitized run ledgers (preserved by sync; no secrets or raw prompts by default).
@@ -28,7 +28,7 @@ Cadet-Agent is **not a one-shot code generator**. It won't spit out a finished g
28
28
 
29
29
  ## Cross-IDE Support
30
30
 
31
- Cadet-Agent provides full workflow parity across five IDEs. The same 10 skills + reviewer are available in each:
31
+ Cadet-Agent provides full workflow parity across five IDEs. The same 11 skills + reviewer are available in each:
32
32
 
33
33
  | Feature | GitHub Copilot | Cursor | Continue | Claude Code | Deep Code |
34
34
  |---|---|---|---|---|---|
@@ -44,6 +44,7 @@ Cadet-Agent provides full workflow parity across five IDEs. The same 10 skills +
44
44
  | Code Review | ✅ | ✅ | ✅ | ✅ | ✅ |
45
45
  | Resume | ✅ | ✅ | ✅ | ✅ | ✅ |
46
46
  | MCP Setup | ✅ | ✅ | ✅ | ✅ | ✅ |
47
+ | Reconciliation | ✅ | ✅ | ✅ | ✅ | ✅ |
47
48
  | Reviewer mode | Agent picker | Rule toggle | `/cadet-agent-reviewer` | `/cadet-agent-reviewer` | `cadet-agent-reviewer` skill |
48
49
  | Git guard | PreToolUse hook | Manual | Manual | Manual | `permissions.ask` (`mutate-git-log`) |
49
50
 
@@ -199,6 +200,7 @@ cadet-agent state seal # write the active work item's
199
200
  cadet-agent state transition --to review # enforce the matrix + evidence
200
201
  cadet-agent harness verify --gate testsPassed --files src/a.cs # bounded, classified loop
201
202
  cadet-agent harness report # budget consumption and failures (no secrets)
203
+ cadet-agent harness reconcile # reconcile the planning chain against state.json (read-only)
202
204
  cadet-agent harness cleanup --older-than-ms <n> # apply the retention policy (bound required)
203
205
  cadet-agent harness capabilities # available CLI/Unity/MCP/hook/token/cost telemetry
204
206
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cadet-agent",
3
- "version": "0.46.0",
3
+ "version": "0.48.0",
4
4
  "description": "Cross-IDE agent framework for Unity/C# game-development — one-command install",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -6,7 +6,9 @@ import {
6
6
  validateState, migrateStateFile, readState, writeState, evaluateTransition, applyTransition,
7
7
  workItemIdOf, loadPolicy, RunLedger, loadRun, listRuns, cleanupRuns, buildReport, formatReport,
8
8
  runVerificationLoop, commandForGate, detectCapabilities, runsDir, gitChangedFiles, PolicyError, StateError,
9
- detectRepoRole, describeRepoRole, GATES, manualConfirmation,
9
+ detectRepoRole, describeRepoRole, GATES, PHASES, manualConfirmation,
10
+ gitChangeSet, DEFAULT_REPORT_DIR,
11
+ reconcileArtifacts, PLANS_DEFAULT_DIR,
10
12
  parseTestInventory, parseStoryCriteria, compareCoverage, describeCoverageGaps,
11
13
  parseReachabilityDeclaration, validateReachabilityDeclaration, collectWorkItems,
12
14
  findDeferralCycles, readSiblingDeclarations, normalizeWorkItemRef, describeReachabilityGaps,
@@ -58,6 +60,8 @@ function showHelp() {
58
60
  cadet-agent harness verify-acs Verify declared AC↔test coverage against a test report
59
61
  cadet-agent harness verify-reachability Verify a story's declared reachability (opt-in)
60
62
  cadet-agent harness report Summarize budget consumption and failures
63
+ cadet-agent harness changes List the files a story changed, with status, counts, and links
64
+ cadet-agent harness reconcile Reconcile the planning chain against state.json (read-only)
61
65
  cadet-agent harness cleanup Apply the retention policy to .cadet/runs/
62
66
  cadet-agent harness capabilities Report available CLI/Unity/MCP/hook/token/cost telemetry
63
67
 
@@ -70,6 +74,7 @@ function showHelp() {
70
74
  --command Command override (harness verify)
71
75
  --files Comma-separated relevant files to bind evidence to (harness verify|confirm)
72
76
  --commit Revision the gate attests, as a hex SHA (harness verify|confirm)
77
+ --expect-phase Refuse to record a gate unless the current phase matches (harness verify|confirm|verify-acs|verify-reachability)
73
78
  --reason Why automation was unavailable (harness confirm)
74
79
  --expires-at ISO-8601 expiry bounding the confirmation (harness confirm)
75
80
  --environment key=value,... describing what was verified (harness confirm)
@@ -78,6 +83,10 @@ function showHelp() {
78
83
  --report Test report to derive the inventory from (harness verify-acs|matrix-check)
79
84
  --matrix TDD matrix markdown to check (harness matrix-check)
80
85
  --inventory Newline-separated test names, when no report is available (harness matrix-check)
86
+ --range Base revision to diff instead of the working tree (harness changes)
87
+ --relative-to Directory the emitted links are relative to (harness changes; default .cadet/reports)
88
+ --include-cadet Keep .cadet/ bookkeeping among the listed files (harness changes)
89
+ --plans-dir Directory holding the planning artifacts (harness reconcile; default .cadet/agent/project-plans)
81
90
  --agents-md keep|overwrite|merge for an existing AGENTS.md (init/sync)
82
91
  --older-than-ms Age bound, in ms, for records cleanup may delete (harness cleanup; required)
83
92
  --keep always|active|<work-item ids> for what stays in state.json (state compact; required)
@@ -146,6 +155,9 @@ function parseArgs(argv) {
146
155
  case '--command': opts.command = value(a); break;
147
156
  case '--work-item': opts.workItemId = value(a); break;
148
157
  case '--phase': opts.phase = value(a); break;
158
+ // --expect-phase: a guard against recording a gate into a phase the caller
159
+ // did not intend. See assertExpectedPhase.
160
+ case '--expect-phase': opts.expectPhase = value(a); break;
149
161
  case '--run': opts.runId = value(a); break;
150
162
  case '--type': opts.type = value(a); break;
151
163
  case '--reason': opts.reason = value(a); break;
@@ -164,6 +176,10 @@ function parseArgs(argv) {
164
176
  case '--commit': opts.commitGiven = true; opts.commit = value(a); break;
165
177
  case '--matrix': opts.matrix = value(a); break;
166
178
  case '--inventory': opts.inventory = value(a); break;
179
+ case '--range': opts.range = value(a); break;
180
+ case '--relative-to': opts.relativeTo = value(a); break;
181
+ case '--include-cadet': opts.includeCadet = true; break;
182
+ case '--plans-dir': opts.plansDir = value(a); break;
167
183
  case '--write-coverage': opts.writeCoverage = true; break;
168
184
  case '--strict-orphans': opts.strictOrphans = true; break;
169
185
  case '--dry-run': opts.dryRun = true; break;
@@ -224,6 +240,40 @@ function fail(opts, message, code = json => json.exitCode || 1, json = {}) {
224
240
  process.exit(exitCode);
225
241
  }
226
242
 
243
+ /**
244
+ * `--expect-phase <phase>` — refuse to record gate evidence into a phase the
245
+ * caller did not intend.
246
+ *
247
+ * The failure this closes is a caller error, not a framework one: `state
248
+ * transition` already reports `allowed: false` and exits 1, but an agent that
249
+ * chains commands with `;` and filters the output reads the *next* command's
250
+ * success as the transition's, and goes on to record the following gates into the
251
+ * phase it never left. The verdict was correct and ignored; the record was then
252
+ * written anyway. This is a check because the mistake recurred after being
253
+ * documented, and a check is what the framework's own doctrine asks for at that
254
+ * point.
255
+ *
256
+ * The flag is opt-in and cheap: omitting it changes nothing. A mismatch is
257
+ * refused before any write, so a stray `--expect-phase` cannot corrupt state —
258
+ * it can only stop the command.
259
+ */
260
+ function assertExpectedPhase(opts, state) {
261
+ if (!opts.expectPhase) return;
262
+ if (!PHASES.includes(opts.expectPhase)) {
263
+ fail(opts, `--expect-phase "${opts.expectPhase}" is not a known phase. Valid phases: ${PHASES.join(', ')}.`, () => 1, { ok: false, code: 'unknown-phase', expectedPhase: opts.expectPhase });
264
+ }
265
+ const actual = state?.session?.currentPhase ?? null;
266
+ if (actual === opts.expectPhase) return;
267
+ fail(
268
+ opts,
269
+ `--expect-phase ${opts.expectPhase}, but the current phase is "${actual ?? '(none)'}". `
270
+ + 'Refusing to record evidence for a phase the caller did not intend — re-read .cadet/state.json '
271
+ + '(or run `state transition --dry-run`) and retry once the phase is what you expected.',
272
+ () => 1,
273
+ { ok: false, code: 'phase-mismatch', expectedPhase: opts.expectPhase, actualPhase: actual },
274
+ );
275
+ }
276
+
227
277
  // ── evidence archive (contract v5) ──────────────────────────────────────────
228
278
 
229
279
  /**
@@ -660,6 +710,7 @@ async function cmdHarness(opts) {
660
710
 
661
711
  const { exists, state } = readState(opts.targetDir);
662
712
  if (!exists) fail(opts, 'No .cadet/state.json found. Initialise state before recording confirmation.', () => 2);
713
+ assertExpectedPhase(opts, state);
663
714
 
664
715
  const strict = policy.strictClosure?.enabled === true ? policy.strictClosure : null;
665
716
  const mc = strict?.manualConfirmation || null;
@@ -817,6 +868,7 @@ async function cmdHarness(opts) {
817
868
  const gate = opts.gate;
818
869
  if (!gate) fail(opts, 'harness verify requires --gate <gate>');
819
870
  const { state } = readState(opts.targetDir);
871
+ assertExpectedPhase(opts, state);
820
872
  const caps = detectCapabilities({ targetDir: opts.targetDir });
821
873
  const descriptor = opts.command
822
874
  ? { command: opts.command, tool: 'custom', automated: true }
@@ -961,6 +1013,7 @@ async function cmdHarness(opts) {
961
1013
  // never written cannot be asserted into coverage.
962
1014
  if (!opts.story) fail(opts, 'harness verify-acs requires --story <path>');
963
1015
  const { exists, state } = readState(opts.targetDir);
1016
+ assertExpectedPhase(opts, state);
964
1017
  const strict = policy.strictClosure?.enabled === true;
965
1018
  const workItemId = state ? workItemIdOf(state) : 'unscoped';
966
1019
  const phase = state?.session?.currentPhase || 'implementation';
@@ -1070,6 +1123,26 @@ async function cmdHarness(opts) {
1070
1123
  const at = new Date();
1071
1124
  const criteriaStrings = coverage.ac.flatMap((a) => [a.id, ...a.declared]);
1072
1125
  const nowIso = at.toISOString();
1126
+ // The story is the only INPUT to the AC claim: it carries the declared
1127
+ // AC→test mapping, and `criteriaHash` binds those names (C12), so editing the
1128
+ // mapping invalidates the record.
1129
+ //
1130
+ // The test report is an OUTPUT of the run that satisfied `testsPassed`, not an
1131
+ // input, and binding it was a defect: a repository whose test script rewrites a
1132
+ // fixed report path (e.g. `test-results-junit.xml`) staled this record the
1133
+ // moment it re-ran the tests, because the file the record had just read changed
1134
+ // underneath it. This is the same class Harness §5 already excludes
1135
+ // (`.cadet/state.json`, `.cadet/runs/**`) — "binding evidence to either would
1136
+ // make a gate stale the instant it was written" — so a generated report gets
1137
+ // the same treatment and is kept as `artifactPath` for audit, where nothing
1138
+ // re-hashes it.
1139
+ //
1140
+ // The story path is made repo-relative for the same reason verify-reachability
1141
+ // does it: an absolute path never resolves under the root when freshness is
1142
+ // re-derived at transition time, so both hashes would be computed over a
1143
+ // missing file and match — a binding that is silently inert.
1144
+ const storyPath = resolve(opts.targetDir, opts.story);
1145
+ const storyRel = relative(opts.targetDir, storyPath).replace(/\\/g, '/') || basename(storyPath);
1073
1146
  const evidence = createEvidence({
1074
1147
  evidenceId: newId(),
1075
1148
  workItemId,
@@ -1080,9 +1153,11 @@ async function cmdHarness(opts) {
1080
1153
  command: `harness verify-acs --story ${opts.story}`,
1081
1154
  result: `AC coverage verified: ${coverage.ac.length} criteria, inventory ${coverage.inventorySize} (${inventory.format})`,
1082
1155
  exitCode: 0,
1083
- inputTreeHash: computeInputTreeHash(opts.targetDir, [opts.story, ...(reportPath ? [reportPath] : [])]),
1156
+ // Audit pointer only. Not a relevant file: see above.
1157
+ artifactPath: reportPath ? reportPath.replace(/\\/g, '/') : null,
1158
+ inputTreeHash: computeInputTreeHash(opts.targetDir, [storyRel]),
1084
1159
  criteriaHash: hashCriteria(criteriaStrings),
1085
- relevantFiles: [opts.story, ...(reportPath ? [reportPath] : [])].map((f) => f.replace(/\\/g, '/')),
1160
+ relevantFiles: [storyRel],
1086
1161
  createdAt: at,
1087
1162
  expiresAt: null,
1088
1163
  // Schema + validator require an object carrying a `scope`, not a bare
@@ -1150,6 +1225,7 @@ async function cmdHarness(opts) {
1150
1225
  const storyPath = resolve(opts.targetDir, opts.story);
1151
1226
  const storyRel = relative(opts.targetDir, storyPath).replace(/\\/g, '/') || basename(storyPath);
1152
1227
  const { exists, state } = readState(opts.targetDir);
1228
+ assertExpectedPhase(opts, state);
1153
1229
  const enabled = policy.reachability?.enabled === true;
1154
1230
  const probeCommand = policy.reachability?.command || null;
1155
1231
  const workItemId = state ? workItemIdOf(state) : 'unscoped';
@@ -1293,6 +1369,109 @@ async function cmdHarness(opts) {
1293
1369
  return;
1294
1370
  }
1295
1371
 
1372
+ // Read-only. Produces the deterministic half of a Change Report — which files
1373
+ // changed, how, and by how much — so the agent never assembles that table from
1374
+ // memory. The other half, why each file changed, is not knowable from git and
1375
+ // stays the agent's job. See .cadet/agent/core/skills/CodeReview.md.
1376
+ //
1377
+ // A missing git is NOT a usage error here. This command informs a review that
1378
+ // can still be completed, so it reports the limitation and exits 0 rather than
1379
+ // blocking the review; the report records it under Limits.
1380
+ if (sub === 'changes') {
1381
+ const relativeTo = opts.relativeTo || DEFAULT_REPORT_DIR;
1382
+ const changes = gitChangeSet(opts.targetDir, {
1383
+ range: opts.range || null,
1384
+ relativeTo,
1385
+ includeCadet: opts.includeCadet === true,
1386
+ });
1387
+
1388
+ const { exists, state } = readState(opts.targetDir);
1389
+ const item = exists ? state?.activeWorkItem ?? null : null;
1390
+ const workItem = item ? { epicId: item.epicId ?? null, storyId: item.storyId ?? null } : null;
1391
+
1392
+ const payload = {
1393
+ ok: true,
1394
+ available: changes.available,
1395
+ workItem,
1396
+ range: opts.range || 'working-tree',
1397
+ relativeTo,
1398
+ files: changes.files,
1399
+ counts: changes.counts,
1400
+ reason: changes.reason,
1401
+ };
1402
+ if (opts.format === 'json') {
1403
+ emit(opts, '', payload);
1404
+ return;
1405
+ }
1406
+
1407
+ if (!changes.available) {
1408
+ console.log(`\n⚠️ Change inventory unavailable: ${changes.reason}`);
1409
+ console.log(' Do not list files from memory — record this as a limit of the report.');
1410
+ return;
1411
+ }
1412
+
1413
+ const c = changes.counts;
1414
+ console.log(`\nChange inventory (${payload.range}) — ${changes.files.length} file(s)`);
1415
+ console.log(` ${c.added} added · ${c.modified} modified · ${c.renamed} renamed · ${c.deleted} deleted`);
1416
+ if (changes.files.length === 0) {
1417
+ console.log('\n (no changes)');
1418
+ } else {
1419
+ console.log('');
1420
+ for (const f of changes.files) {
1421
+ const lines = f.added === null && f.deleted === null ? 'new' : `+${f.added ?? 0}/-${f.deleted ?? 0}`;
1422
+ console.log(` ${f.status} ${lines.padEnd(10)} ${f.path}`);
1423
+ }
1424
+ }
1425
+ const label = workItem ? `${workItem.epicId || 'none'}::${workItem.storyId || 'none'}` : 'none';
1426
+ console.log(`\n Links relative to ${relativeTo} · work item: ${label}`);
1427
+ return;
1428
+ }
1429
+
1430
+ // Read-only. Reconciles the planning chain against state.json: the mechanical
1431
+ // half of the Reconciliation skill. It reports the inconsistencies it can prove
1432
+ // from the artifacts and never repairs one — the skill proposes repairs for the
1433
+ // user to approve. See .cadet/agent/core/skills/Reconciliation.md.
1434
+ //
1435
+ // Exit 0 whatever the verdict: the verdict is the payload, and a caller reading
1436
+ // `--format json` must not have to tolerate a failure exit to get it. A run with
1437
+ // no planning artifacts at all is a legitimate state (a framework-source repo, a
1438
+ // small change), not an error.
1439
+ if (sub === 'reconcile') {
1440
+ const { exists, state } = readState(opts.targetDir);
1441
+ const result = reconcileArtifacts(opts.targetDir, {
1442
+ state: exists ? state : null,
1443
+ plansDir: opts.plansDir || PLANS_DEFAULT_DIR,
1444
+ story: opts.story || null,
1445
+ });
1446
+
1447
+ if (opts.format === 'json') {
1448
+ emit(opts, '', result);
1449
+ return;
1450
+ }
1451
+ if (!result.available) {
1452
+ console.log(`\nℹ️ Nothing to reconcile: ${result.reason}`);
1453
+ return;
1454
+ }
1455
+
1456
+ const s = result.summary;
1457
+ console.log(`\nReconcile ${result.plansDir}${result.scopedEpic ? ` (${result.scopedEpic})` : ''} — verdict: ${result.verdict}`);
1458
+ console.log(` ${result.artifacts.epicCount} epic(s), ${result.artifacts.storyCount} story file(s)`);
1459
+ console.log(` ${s.total} finding(s): ${s.blocking} blocking · ${s.warning} warning · ${s.info} info`);
1460
+ if (s.total === 0) {
1461
+ console.log('\n ✅ The chain is internally consistent.');
1462
+ } else {
1463
+ console.log('');
1464
+ for (const f of result.findings) {
1465
+ console.log(` [${f.severity}] ${f.id} ${f.code} — ${f.subject}`);
1466
+ console.log(` ${f.detail}${f.evidence ? ` (${f.evidence})` : ''}`);
1467
+ }
1468
+ }
1469
+ if (result.verdict === 'unknown') {
1470
+ console.log('\n ⚠️ At least one artifact could not be read, so consistency cannot be certified.');
1471
+ }
1472
+ return;
1473
+ }
1474
+
1296
1475
  // AR-5. Reconcile a TDD matrix's DELIVERED test-name claims against a compiled
1297
1476
  // inventory. Read-only: it reports, and never writes state, so it can be run at
1298
1477
  // authoring time (before anything has been implemented) as well as in a gate.
@@ -1390,7 +1569,7 @@ async function cmdHarness(opts) {
1390
1569
  return;
1391
1570
  }
1392
1571
 
1393
- fail(opts, `Unknown harness subcommand: ${sub || '(none)'}. Use record|confirm|verify|verify-acs|verify-reachability|matrix-check|report|cleanup|capabilities.`);
1572
+ fail(opts, `Unknown harness subcommand: ${sub || '(none)'}. Use record|confirm|verify|verify-acs|verify-reachability|matrix-check|report|changes|reconcile|cleanup|capabilities.`);
1394
1573
  }
1395
1574
 
1396
1575
  export async function run(argv) {
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Change inventory — what a story actually touched, and how much.
3
+ *
4
+ * Why this exists: the Change Report's file table has to be *the same table*
5
+ * every run. When the agent assembled that list by hand — a `git status` here, a
6
+ * remembered path there — the result varied by run: a file dropped, a status
7
+ * guessed, a link that did not resolve, a line count invented. The rows are the
8
+ * part of the report that is mechanically knowable, so they are computed here
9
+ * and the agent supplies only the prose that is not.
10
+ *
11
+ * This is a read-only probe. It runs `git` and parses output; it never writes.
12
+ *
13
+ * Availability contract: mirrors `gitChangedFiles` (`util.mjs`). Returns
14
+ * `{ available: false, reason }` when git cannot be asked — "not a repository"
15
+ * and "no changes" must not look alike, so callers state the limitation rather
16
+ * than reporting an empty change set as a clean one.
17
+ */
18
+
19
+ import { spawnSync } from 'node:child_process';
20
+ import { isAbsolute, join, relative } from 'node:path';
21
+
22
+ /** Default report directory, from which file links are made relative. */
23
+ export const DEFAULT_REPORT_DIR = '.cadet/reports';
24
+
25
+ /**
26
+ * Cadet's own bookkeeping. A story's change report should not lead with the
27
+ * ledger and state files its own gate checks rewrote. Same set and same reason
28
+ * as `util.mjs#CADET_MACHINERY`.
29
+ */
30
+ const CADET_MACHINERY = ['.cadet/state.json', '.cadet/runs/', '.cadet/archive/'];
31
+
32
+ function isCadetMachinery(relPath) {
33
+ return CADET_MACHINERY.some((p) => (p.endsWith('/') ? relPath.startsWith(p) : relPath === p));
34
+ }
35
+
36
+ /**
37
+ * Collapse porcelain's two status columns (`XY`) to the single letter the
38
+ * report shows. The staged column wins when it says something, because that is
39
+ * the status the eventual commit will carry; the worktree column is the
40
+ * fallback. `??` is an untracked file, which a reader reads as "added".
41
+ */
42
+ const STATUS_LETTERS = { R: 'R', C: 'R', A: 'A', D: 'D', M: 'M', T: 'M' };
43
+
44
+ function statusFromPorcelain(xy) {
45
+ if (xy === '??') return 'A';
46
+ const [staged, unstaged] = xy;
47
+ return STATUS_LETTERS[staged] || STATUS_LETTERS[unstaged] || 'M';
48
+ }
49
+
50
+ /**
51
+ * Normalize a `--numstat` path field, which spells renames three ways:
52
+ * `new`, `old => new`, and `dir/{old => new}/file`. Only the new path is kept,
53
+ * matching the name-status side, so the two maps join on the same key.
54
+ */
55
+ function normalizeNumstatPath(raw) {
56
+ const path = String(raw || '').trim();
57
+ if (!path.includes(' => ')) return path;
58
+ const braced = path.match(/^(.*)\{(.*) => (.*)\}(.*)$/);
59
+ if (braced) return `${braced[1]}${braced[3]}${braced[4]}`.replace(/\/{2,}/g, '/');
60
+ return path.split(' => ').pop().trim();
61
+ }
62
+
63
+ function defaultGitRunner(cmd, args) {
64
+ try {
65
+ return spawnSync(cmd, args, { encoding: 'utf-8', windowsHide: true });
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Run one git command and return its stdout, or `{ error }` describing why the
73
+ * question could not be asked. Every call goes through here so a single probe
74
+ * failure is reported the same way regardless of which command failed.
75
+ */
76
+ function runGit(cwd, args, runner) {
77
+ let res;
78
+ try {
79
+ res = runner('git', ['-C', cwd, ...args]);
80
+ } catch (err) {
81
+ return { error: `git invocation failed: ${err.message}` };
82
+ }
83
+ if (!res) return { error: 'git is not available' };
84
+ if (res.error || res.status === null) {
85
+ return { error: 'git is not installed or could not be executed' };
86
+ }
87
+ if (res.status !== 0) {
88
+ return { error: String(res.stderr || '').trim() || `git exited ${res.status}` };
89
+ }
90
+ return { stdout: String(res.stdout || '') };
91
+ }
92
+
93
+ /**
94
+ * Files and statuses for the change set. Untracked files appear only in the
95
+ * working-tree form; a `--range` diff is a commit-to-commit question and cannot
96
+ * see them.
97
+ */
98
+ function readNameStatus(cwd, { runner, range }) {
99
+ if (range) {
100
+ const res = runGit(cwd, ['diff', '--name-status', range, '--'], runner);
101
+ if (res.error) return { error: res.error };
102
+ const files = [];
103
+ for (const line of res.stdout.split(/\r?\n/)) {
104
+ if (!line.trim()) continue;
105
+ // "M\tpath" — or "R100\told\tnew", where the new path is the one that exists.
106
+ const fields = line.split('\t');
107
+ if (fields.length < 2) continue;
108
+ const path = fields[fields.length - 1].trim();
109
+ if (!path) continue;
110
+ files.push({ path: path.replace(/\\/g, '/'), status: fields[0][0] });
111
+ }
112
+ return { files };
113
+ }
114
+
115
+ const res = runGit(cwd, ['status', '--porcelain', '--untracked-files=all'], runner);
116
+ if (res.error) return { error: res.error };
117
+ const files = [];
118
+ for (const line of res.stdout.split(/\r?\n/)) {
119
+ if (!line.trim()) continue;
120
+ // Porcelain v1: XY<space>path, with renames written "old -> new".
121
+ const xy = line.slice(0, 2);
122
+ let path = line.slice(3).trim();
123
+ if (path.includes(' -> ')) path = path.split(' -> ').pop().trim();
124
+ path = path.replace(/^"|"$/g, '');
125
+ if (!path) continue;
126
+ files.push({ path: path.replace(/\\/g, '/'), status: statusFromPorcelain(xy) });
127
+ }
128
+ return { files };
129
+ }
130
+
131
+ /**
132
+ * Accumulate one count onto a prior one. A file can appear in both the staged
133
+ * and the unstaged numstat, and those counts add. `null` means git reported no
134
+ * number (a binary file); it stays unknown unless a real number was seen.
135
+ */
136
+ function addCount(prior, value) {
137
+ if (value === null) return prior === undefined ? null : prior;
138
+ return (prior ?? 0) + value;
139
+ }
140
+
141
+ /** Added/deleted counts per path, merged across the staged and unstaged diffs. */
142
+ function mergeNumstat(target, stdout) {
143
+ for (const line of String(stdout || '').split(/\r?\n/)) {
144
+ if (!line.trim()) continue;
145
+ const [added, deleted, ...rest] = line.split('\t');
146
+ if (rest.length === 0) continue;
147
+ const path = normalizeNumstatPath(rest.join('\t'));
148
+ if (!path) continue;
149
+ // A binary file reports "-" for both; that is genuinely unknown, not zero.
150
+ const a = /^\d+$/.test(added) ? Number(added) : null;
151
+ const d = /^\d+$/.test(deleted) ? Number(deleted) : null;
152
+ const prior = target.get(path);
153
+ target.set(path, {
154
+ added: addCount(prior?.added, a),
155
+ deleted: addCount(prior?.deleted, d),
156
+ });
157
+ }
158
+ }
159
+
160
+ function readNumstat(cwd, { runner, range }) {
161
+ const counts = new Map();
162
+ if (range) {
163
+ const res = runGit(cwd, ['diff', '--numstat', range, '--'], runner);
164
+ if (res.error) return { error: res.error };
165
+ mergeNumstat(counts, res.stdout);
166
+ return { counts };
167
+ }
168
+ const unstaged = runGit(cwd, ['diff', '--numstat', '--'], runner);
169
+ if (unstaged.error) return { error: unstaged.error };
170
+ mergeNumstat(counts, unstaged.stdout);
171
+ const staged = runGit(cwd, ['diff', '--cached', '--numstat', '--'], runner);
172
+ if (staged.error) return { error: staged.error };
173
+ mergeNumstat(counts, staged.stdout);
174
+ return { counts };
175
+ }
176
+
177
+ /**
178
+ * The change inventory for a directory.
179
+ *
180
+ * @param {string} cwd Repository root to ask about.
181
+ * @param {object} [options]
182
+ * @param {Function} [options.runner] Injectable git runner, for tests.
183
+ * @param {string|null} [options.range] Diff a base revision instead of the working tree.
184
+ * @param {string} [options.relativeTo] Directory the `link` fields are made relative to.
185
+ * @param {boolean} [options.includeCadet] Keep `.cadet/` bookkeeping in the list.
186
+ * @returns {{available: boolean, files: Array, counts: object, reason: string|null}}
187
+ */
188
+ export function gitChangeSet(cwd, {
189
+ runner = defaultGitRunner,
190
+ range = null,
191
+ relativeTo = DEFAULT_REPORT_DIR,
192
+ includeCadet = false,
193
+ } = {}) {
194
+ const empty = { added: 0, modified: 0, deleted: 0, renamed: 0 };
195
+
196
+ const named = readNameStatus(cwd, { runner, range });
197
+ if (named.error) return { available: false, files: [], counts: empty, reason: named.error };
198
+
199
+ const counted = readNumstat(cwd, { runner, range });
200
+ if (counted.error) return { available: false, files: [], counts: empty, reason: counted.error };
201
+
202
+ // Links are relative to the report, not the repository root: a report at
203
+ // `.cadet/reports/x.md` must point at `../../Assets/Foo.cs` or the click does
204
+ // nothing. Computing it here is the whole reason the link is not hand-written.
205
+ const baseDir = isAbsolute(relativeTo) ? relativeTo : join(cwd, relativeTo);
206
+
207
+ const seen = new Map();
208
+ for (const file of named.files) {
209
+ if (!includeCadet && isCadetMachinery(file.path)) continue;
210
+ if (seen.has(file.path)) continue;
211
+ const count = counted.counts.get(file.path);
212
+ const linkTarget = relative(baseDir, join(cwd, file.path)).replace(/\\/g, '/');
213
+ seen.set(file.path, {
214
+ path: file.path,
215
+ status: file.status,
216
+ // Untracked files have no diff, so no count exists. Null, not zero: a
217
+ // zero would read as "changed nothing", which is a different claim.
218
+ added: count?.added ?? null,
219
+ deleted: count?.deleted ?? null,
220
+ link: `[${file.path}](${linkTarget})`,
221
+ });
222
+ }
223
+
224
+ const files = [...seen.values()].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
225
+ const counts = { ...empty };
226
+ for (const f of files) {
227
+ if (f.status === 'A') counts.added += 1;
228
+ else if (f.status === 'D') counts.deleted += 1;
229
+ else if (f.status === 'R') counts.renamed += 1;
230
+ else counts.modified += 1;
231
+ }
232
+
233
+ return { available: true, files, counts, reason: null };
234
+ }
@@ -123,6 +123,20 @@ export const COMMANDS = {
123
123
  mutates: false,
124
124
  summary: 'Summarize budget consumption and failures.',
125
125
  },
126
+ 'harness changes': {
127
+ mutates: false,
128
+ summary: 'List the files a story changed, with status, line counts, and links.',
129
+ // Read-only by construction: it runs `git status`/`git diff` and parses the
130
+ // output. It does not author the Change Report — the agent does, from the
131
+ // template — so there is no artifact for it to write and nothing to dry-run.
132
+ },
133
+ 'harness reconcile': {
134
+ mutates: false,
135
+ summary: 'Reconcile the planning chain (requirements, design, plan, epics, stories) against state.json.',
136
+ // Read-only, and deliberately so: it reports the provable inconsistencies and
137
+ // never repairs one. An agent that could reconcile artifacts unattended could
138
+ // rewrite the design it is meant to be checking against.
139
+ },
126
140
  'harness matrix-check': {
127
141
  mutates: false,
128
142
  summary: 'Reconcile a TDD matrix against a compiled test inventory.',
@@ -23,6 +23,13 @@ export {
23
23
  newId, isUuid, sha256, sha256Bytes, hashFile, hashTree, hashCriteria, timestamp, canonicalJson, changedFiles, gitChangedFiles,
24
24
  } from './util.mjs';
25
25
 
26
+ export { DEFAULT_REPORT_DIR, gitChangeSet } from './changes.mjs';
27
+
28
+ export {
29
+ PLANS_DEFAULT_DIR, REQUIRED_ARTIFACTS, RECONCILE_SEVERITIES, RECONCILE_VERDICTS,
30
+ DEFAULT_MAX_DOC_BYTES, parseStoryHeader, parseEpicHeader, collectArtifacts, reconcileArtifacts,
31
+ } from './reconcile.mjs';
32
+
26
33
  export {
27
34
  STATE_VERSION, READABLE_STATE_VERSIONS, HISTORY_EXTERNAL_SINCE, isHistoryExternal,
28
35
  validateState, migrateStateV1toV2, migrateStateDocument, migrateStateFile, parseTargetVersion,
@@ -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
+ }
@@ -1177,8 +1177,17 @@ export function isUngatedForwardEdge(fromPhase, toPhase) {
1177
1177
  * must have been created at or after that instant. Without it, "fresh" would mean
1178
1178
  * only "not yet expired", which lets a long phase carry evidence that predates
1179
1179
  * the work it is meant to attest.
1180
+ *
1181
+ * `phaseScoped` is false for the strict-closure `revalidate` set. A revalidated
1182
+ * gate asks "is this still true *now*?" — answered by the input-tree hash, the
1183
+ * criteria hash, and the expiry — not "was it recorded in the phase I am leaving?".
1184
+ * Enforcing the phase stamp on a revalidated gate made the answer "no" for every
1185
+ * record written in an earlier phase, so the whole suite had to be re-recorded in
1186
+ * `review` and again in `validation` on a tree that had not changed by a byte.
1187
+ * Primary gates keep the phase scope: a record still has to be written in the
1188
+ * phase it belongs to.
1180
1189
  */
1181
- function checkGate({ gate, state, gates, exceptions, now, workItemId, fromPhase, rootDir, computeTreeHash, inputTreeHash, critHash, recencyFloor = null }) {
1190
+ function checkGate({ gate, state, gates, exceptions, now, workItemId, fromPhase, rootDir, computeTreeHash, inputTreeHash, critHash, recencyFloor = null, phaseScoped = true }) {
1182
1191
  const missingGates = [];
1183
1192
  const staleEvidence = [];
1184
1193
 
@@ -1204,7 +1213,7 @@ function checkGate({ gate, state, gates, exceptions, now, workItemId, fromPhase,
1204
1213
  const { fresh, reasons } = evidenceFreshness(evidence, {
1205
1214
  now,
1206
1215
  workItemId,
1207
- phase: fromPhase,
1216
+ phase: phaseScoped ? fromPhase : null,
1208
1217
  inputTreeHash: currentTreeHash,
1209
1218
  criteriaHash: critHash,
1210
1219
  });
@@ -1344,6 +1353,14 @@ export function evaluateTransition(state, toPhase, context = {}) {
1344
1353
  }
1345
1354
 
1346
1355
  // Strict closure: re-derive the earlier gates at this transition.
1356
+ //
1357
+ // A revalidated gate is checked WITHOUT the phase scope (`phaseScoped: false`).
1358
+ // Its question is "is this still true now?", which the input-tree hash, the
1359
+ // criteria hash, and the expiry answer; the phase stamp answers only "which
1360
+ // phase wrote it down", which is exactly the fact revalidation is not doubting.
1361
+ // With the phase scope on, every record written in an earlier phase was rejected
1362
+ // as stale, so an unchanged tree still forced the whole suite to be re-recorded
1363
+ // in `review` and again in `validation`. See checkGate.
1347
1364
  const strict = resolveStrict(context);
1348
1365
  const revalidated = [];
1349
1366
  if (strict && strict.revalidateOnClosure !== false) {
@@ -1353,7 +1370,7 @@ export function evaluateTransition(state, toPhase, context = {}) {
1353
1370
  for (const gate of spec.revalidate) {
1354
1371
  if (spec.gates.includes(gate)) continue; // already checked as a primary gate
1355
1372
  revalidated.push(gate);
1356
- const r = checkGate({ ...shared, gate, recencyFloor });
1373
+ const r = checkGate({ ...shared, gate, recencyFloor, phaseScoped: false });
1357
1374
  missingGates.push(...r.missingGates);
1358
1375
  staleEvidence.push(...r.staleEvidence);
1359
1376
  }