cadet-agent 0.43.1 → 0.45.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
@@ -141,7 +141,7 @@ flowchart TD
141
141
  BREAKDOWN --> IMPL
142
142
 
143
143
  IMPL -->|"story complete"| REVIEW
144
- REVIEW -->|"gate: codeReviewCompleted ✅<br/>gate: securityReviewPassed ✅"| VALIDATE
144
+ REVIEW -->|"gate: codeReviewCompleted ✅<br/>gate: securityReviewPassed ✅<br/>gate: reachabilityAddressed ✅ (opt-in)"| VALIDATE
145
145
  VALIDATE -->|"gate: designArtifactSyncConfirmed ✅"| NEXT_STORY
146
146
  NEXT_STORY -->|"yes"| IMPL
147
147
  NEXT_STORY -->|"no"| CLOSED
@@ -165,7 +165,7 @@ Hard gates are enforced at every phase transition. The agent reads `.cadet/state
165
165
  | Transition | Required Gates |
166
166
  |---|---|
167
167
  | implementation → review | `testsPassed`, `compileCheckConfirmed`, `unityAnalyzerClean`, `storyTrackingUpdated` |
168
- | review → validation | `codeReviewCompleted`, `securityReviewPassed`, `acceptanceCriteriaValidated` |
168
+ | review → validation | `codeReviewCompleted`, `securityReviewPassed`, `acceptanceCriteriaValidated`, and `reachabilityAddressed` when `reachability.enabled` is set |
169
169
  | validation → closed | `designArtifactSyncConfirmed` |
170
170
 
171
171
  **`closed` is end-of-epic, not per-story.** `validation → closed` is taken only when no stories remain (`NEXT_STORY → no → CLOSED` above). When an epic still has stories, the next story re-enters from `validation → implementation` (`NEXT_STORY → yes → IMPL`). Do not close a story individually: `closed` is terminal, and there is no transition out of it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cadet-agent",
3
- "version": "0.43.1",
3
+ "version": "0.45.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
@@ -1,6 +1,6 @@
1
1
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, copyFileSync } from 'node:fs';
2
2
  import { fileURLToPath } from 'node:url';
3
- import { dirname, join, resolve } from 'node:path';
3
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
4
4
  import { install, sync } from './install.mjs';
5
5
  import {
6
6
  validateState, migrateStateFile, readState, writeState, evaluateTransition, applyTransition,
@@ -8,6 +8,9 @@ import {
8
8
  runVerificationLoop, commandForGate, detectCapabilities, runsDir, gitChangedFiles, PolicyError, StateError,
9
9
  detectRepoRole, describeRepoRole, GATES, manualConfirmation,
10
10
  parseTestInventory, parseStoryCriteria, compareCoverage, describeCoverageGaps,
11
+ parseReachabilityDeclaration, validateReachabilityDeclaration, collectWorkItems,
12
+ findDeferralCycles, readSiblingDeclarations, normalizeWorkItemRef, describeReachabilityGaps,
13
+ REACHABILITY_GATE, runCommand,
11
14
  createEvidence, newId, computeInputTreeHash, hashCriteria,
12
15
  collectDeclaredTestNames, reconcileTestNames,
13
16
  resolveCommand, describeCommand, describeAllCommands, checkUnattendedRequirements, COMMANDS,
@@ -53,6 +56,7 @@ function showHelp() {
53
56
  cadet-agent harness confirm Record manual-confirmation evidence (writes ledger + state)
54
57
  cadet-agent harness verify Run a bounded, classified verification loop
55
58
  cadet-agent harness verify-acs Verify declared AC↔test coverage against a test report
59
+ cadet-agent harness verify-reachability Verify a story's declared reachability (opt-in)
56
60
  cadet-agent harness report Summarize budget consumption and failures
57
61
  cadet-agent harness cleanup Apply the retention policy to .cadet/runs/
58
62
  cadet-agent harness capabilities Report available CLI/Unity/MCP/hook/token/cost telemetry
@@ -70,7 +74,7 @@ function showHelp() {
70
74
  --expires-at ISO-8601 expiry bounding the confirmation (harness confirm)
71
75
  --environment key=value,... describing what was verified (harness confirm)
72
76
  --scope Comma-separated scope of the confirmation (harness confirm)
73
- --story Story markdown declaring the acceptance criteria (harness verify-acs)
77
+ --story Story markdown declaring the acceptance criteria or reachability (harness verify-acs|verify-reachability)
74
78
  --report Test report to derive the inventory from (harness verify-acs|matrix-check)
75
79
  --matrix TDD matrix markdown to check (harness matrix-check)
76
80
  --inventory Newline-separated test names, when no report is available (harness matrix-check)
@@ -552,7 +556,18 @@ async function cmdState(opts) {
552
556
  // ask "would this transition be allowed?" — running the command without the
553
557
  // flag applies the transition. A check that is documented as a dry run must
554
558
  // not have side effects, so the write below is gated on `!opts.dryRun`.
555
- const evaluation = evaluateTransition(state, opts.to, { rootDir: opts.targetDir });
559
+ // `policy` is loaded here: `cmdState` does not otherwise need it, but the
560
+ // transition verdict does — the reachability gate joins the requirement only
561
+ // for a repository that has opted in (see requiredGates / REACHABILITY_GATE).
562
+ const transitionPolicy = loadPolicy(opts.targetDir);
563
+ // `strictClosure` is passed alongside the policy so strict closure (v3) is
564
+ // decided from the resolved repository policy on the CLI path too — the
565
+ // same verdict a library caller gets by passing the block explicitly.
566
+ const evaluation = evaluateTransition(state, opts.to, {
567
+ rootDir: opts.targetDir,
568
+ policy: transitionPolicy,
569
+ strictClosure: transitionPolicy.strictClosure,
570
+ });
556
571
  if (!evaluation.allowed) {
557
572
  const detail = {
558
573
  ok: false,
@@ -579,7 +594,13 @@ async function cmdState(opts) {
579
594
  );
580
595
  return;
581
596
  }
582
- const next = applyTransition(state, opts.to, { rootDir: opts.targetDir });
597
+ // The same policy context the pre-check used, so the applied transition is
598
+ // judged by exactly the same rules that allowed it (reachability, strict closure).
599
+ const next = applyTransition(state, opts.to, {
600
+ rootDir: opts.targetDir,
601
+ policy: transitionPolicy,
602
+ strictClosure: transitionPolicy.strictClosure,
603
+ });
583
604
  writeState(opts.targetDir, next);
584
605
  emit(opts, `✅ Transitioned to ${opts.to}.`, { ok: true, allowed: true, dryRun: false, applied: true, to: opts.to });
585
606
  return;
@@ -665,7 +686,12 @@ async function cmdHarness(opts) {
665
686
  // A gate listed in disallowManualFor may never be satisfied by a human
666
687
  // assertion; point at the automated path instead of accepting the record.
667
688
  if (strict && Array.isArray(strict.disallowManualFor) && strict.disallowManualFor.includes(gate)) {
668
- fail(opts, `manual-confirmation is not permitted for gate "${gate}" under strictClosure.disallowManualFor; run "cadet-agent harness verify --gate ${gate}" instead.`, () => 1, { ok: false, gate, code: 'manual-disallowed' });
689
+ // The reachability gate's automated path is its dedicated command, not
690
+ // `harness verify` — which is blocked for it as an agent-checkable gate.
691
+ const automatedPath = gate === REACHABILITY_GATE
692
+ ? '"cadet-agent harness verify-reachability --story <path>"'
693
+ : `"cadet-agent harness verify --gate ${gate}"`;
694
+ fail(opts, `manual-confirmation is not permitted for gate "${gate}" under strictClosure.disallowManualFor; run ${automatedPath} instead.`, () => 1, { ok: false, gate, code: 'manual-disallowed' });
669
695
  }
670
696
 
671
697
  // Bound the validity window: an expiry far in the future is how a manual
@@ -1104,6 +1130,158 @@ async function cmdHarness(opts) {
1104
1130
  return;
1105
1131
  }
1106
1132
 
1133
+ if (sub === 'verify-reachability') {
1134
+ // Mechanical reachability verification (contract v6 §2). A story declares how
1135
+ // its deliverable becomes witnessable, or which work item will make it so;
1136
+ // this checks that declaration against the work items that exist, and runs
1137
+ // the repository's own probe when one is configured.
1138
+ //
1139
+ // WHY THE PROBE IS WHAT PROVES IT. Cadet cannot know how a given repository
1140
+ // wires its pieces together, so a `witnessed` declaration is a statement and
1141
+ // not a proof. The proof is the project's command, whose exit code is the
1142
+ // verdict. Without one, the declaration level is all that is enforceable, and
1143
+ // the output says so rather than implying more.
1144
+ if (!opts.story) fail(opts, 'harness verify-reachability requires --story <path>');
1145
+ // The story is resolved against the target repository, and the evidence
1146
+ // binds to the REPO-RELATIVE path. An absolute path never resolves under
1147
+ // the root when freshness is re-derived at transition time, so both hashes
1148
+ // would be computed over a missing file and match — the staleness binding
1149
+ // would be silently inert.
1150
+ const storyPath = resolve(opts.targetDir, opts.story);
1151
+ const storyRel = relative(opts.targetDir, storyPath).replace(/\\/g, '/') || basename(storyPath);
1152
+ const { exists, state } = readState(opts.targetDir);
1153
+ const enabled = policy.reachability?.enabled === true;
1154
+ const probeCommand = policy.reachability?.command || null;
1155
+ const workItemId = state ? workItemIdOf(state) : 'unscoped';
1156
+ const phase = state?.session?.currentPhase || 'implementation';
1157
+
1158
+ let declaration;
1159
+ try {
1160
+ declaration = parseReachabilityDeclaration(storyPath);
1161
+ } catch (err) {
1162
+ fail(opts, `cannot read story "${opts.story}": ${err.message}`, () => 1, { ok: false, code: 'story-unreadable', story: opts.story });
1163
+ }
1164
+
1165
+ const workItems = exists ? collectWorkItems(state) : null;
1166
+ const validation = validateReachabilityDeclaration(declaration, { workItems, self: basename(storyPath) });
1167
+
1168
+ // The deferral graph over this story's own epic. A cycle is the gap no single
1169
+ // declaration can reveal: every item in the loop points at another to explain
1170
+ // why it is not witnessed. `workItems` supplies the epic-key aliases, so the
1171
+ // long `epicKey::story.md` form and the bare file name resolve to one node
1172
+ // regardless of where the story file physically sits.
1173
+ const siblings = readSiblingDeclarations(storyPath, { workItems });
1174
+ const graph = siblings.length > 0
1175
+ ? siblings
1176
+ : [{ id: basename(storyPath), aliases: [], declaration }];
1177
+ const cycles = exists ? findDeferralCycles(graph) : [];
1178
+
1179
+ let probe = null;
1180
+ if (enabled && probeCommand) {
1181
+ const res = await runCommand(probeCommand, { cwd: opts.targetDir });
1182
+ probe = {
1183
+ command: probeCommand,
1184
+ exitCode: res.exitCode,
1185
+ ok: res.exitCode === 0,
1186
+ durationMs: res.durationMs,
1187
+ preview: String(res.preview || '').trim(),
1188
+ };
1189
+ }
1190
+
1191
+ const gaps = describeReachabilityGaps({ validation, cycles, story: opts.story });
1192
+ const ok = validation.ok && cycles.length === 0 && (probe === null || probe.ok === true);
1193
+
1194
+ // NOT OPTED IN: report and write nothing. This is the compatibility rule that
1195
+ // makes adopting the framework version a no-op for a repository that has not
1196
+ // enabled the policy, and it mirrors how verify-acs behaves with
1197
+ // strictClosure off. The finding still exits nonzero, because a caller who
1198
+ // ran the command explicitly asked the question.
1199
+ if (!enabled) {
1200
+ if (opts.format === 'json') {
1201
+ emit(opts, '', { ok, story: opts.story, declaration, reachability: validation, cycles, probe, gateSet: false, enabled: false });
1202
+ } else if (ok) {
1203
+ console.log(`✅ Reachability declared for ${opts.story}: ${validation.message}`);
1204
+ console.log(' reachability.enabled is false — reported only, state.json unchanged.');
1205
+ } else {
1206
+ console.error(`⚠️ Reachability gaps in ${opts.story} (reachability.enabled is false — reported only):`);
1207
+ for (const line of gaps) console.error(line);
1208
+ }
1209
+ if (!ok) process.exit(1);
1210
+ return;
1211
+ }
1212
+
1213
+ if (!ok) {
1214
+ const detail = {
1215
+ ok: false,
1216
+ story: opts.story,
1217
+ declaration,
1218
+ reachability: validation,
1219
+ cycles,
1220
+ probe,
1221
+ gateSet: false,
1222
+ code: validation.ok !== true ? validation.code : (cycles.length > 0 ? 'deferral-cycle' : 'probe-failed'),
1223
+ };
1224
+ if (opts.format === 'json') emit(opts, '', detail);
1225
+ else {
1226
+ console.error(`❌ Cannot set ${REACHABILITY_GATE} for ${opts.story}:`);
1227
+ for (const line of gaps) console.error(line);
1228
+ if (probe && probe.ok !== true) {
1229
+ console.error(` the project probe "${probe.command}" exited ${probe.exitCode}: the declared reachability is not what the project can demonstrate.`);
1230
+ if (probe.preview) console.error(` probe output: ${probe.preview}`);
1231
+ }
1232
+ }
1233
+ process.exit(1);
1234
+ }
1235
+
1236
+ const at = new Date();
1237
+ const evidence = createEvidence({
1238
+ evidenceId: newId(),
1239
+ workItemId,
1240
+ acceptanceCriterionId: null,
1241
+ phase,
1242
+ gate: REACHABILITY_GATE,
1243
+ status: 'passed',
1244
+ command: `harness verify-reachability --story ${opts.story}`,
1245
+ result: probe
1246
+ ? `reachability addressed (${validation.code}); project probe exit ${probe.exitCode}`
1247
+ : `reachability addressed (${validation.code}); no project probe configured`,
1248
+ exitCode: 0,
1249
+ inputTreeHash: computeInputTreeHash(opts.targetDir, [storyRel]),
1250
+ criteriaHash: hashCriteria([
1251
+ workItemId,
1252
+ validation.code,
1253
+ declaration.deferTo || declaration.witness || '',
1254
+ ]),
1255
+ relevantFiles: [storyRel],
1256
+ createdAt: at,
1257
+ expiresAt: null,
1258
+ freshnessPolicy: { scope: 'story' },
1259
+ source: 'automated',
1260
+ });
1261
+
1262
+ // Ledger first, then state — the v3 ordering: fail toward "less proven".
1263
+ const ledger = new RunLedger({ targetDir: opts.targetDir, policy, runId: state?.activeRunId || null, workItemId, phase });
1264
+ ledger.addEvidence(evidence);
1265
+ ledger.addDecision({ kind: 'stop', reason: `reachability addressed (${validation.code})`, scope: probe ? `probe exit ${probe.exitCode}` : 'declaration only' });
1266
+ ledger.finalize({ status: 'ok' });
1267
+ const ledgerPath = ledger.persist();
1268
+
1269
+ if (exists) {
1270
+ const next = recordEvidence(state, evidence);
1271
+ writeState(opts.targetDir, next);
1272
+ }
1273
+
1274
+ if (opts.format === 'json') {
1275
+ emit(opts, '', { ok: true, story: opts.story, reachability: validation, cycles, probe, evidenceId: evidence.evidenceId, gateSet: exists, runId: ledger.runId, path: ledgerPath });
1276
+ } else {
1277
+ console.log(`✅ ${REACHABILITY_GATE} for ${opts.story}: ${validation.message}`);
1278
+ if (probe) console.log(` Project probe "${probe.command}" exited 0 (${probe.durationMs} ms).`);
1279
+ else console.log(' No reachability.command configured — the declaration is checked, the wiring is not proven.');
1280
+ console.log(` Ledger: ${ledgerPath}`);
1281
+ }
1282
+ return;
1283
+ }
1284
+
1107
1285
  if (sub === 'report') {
1108
1286
  const runs = listRuns(opts.targetDir);
1109
1287
  const target = opts.runId || runs[0]?.runId;
@@ -1212,7 +1390,7 @@ async function cmdHarness(opts) {
1212
1390
  return;
1213
1391
  }
1214
1392
 
1215
- fail(opts, `Unknown harness subcommand: ${sub || '(none)'}. Use record|confirm|verify|verify-acs|matrix-check|report|cleanup|capabilities.`);
1393
+ fail(opts, `Unknown harness subcommand: ${sub || '(none)'}. Use record|confirm|verify|verify-acs|verify-reachability|matrix-check|report|cleanup|capabilities.`);
1216
1394
  }
1217
1395
 
1218
1396
  export async function run(argv) {
@@ -110,6 +110,15 @@ export const COMMANDS = {
110
110
  writes: ['.cadet/runs/**', '.cadet/state.json', '*.coverage.json'],
111
111
  unattended: true,
112
112
  },
113
+ 'harness verify-reachability': {
114
+ mutates: true,
115
+ summary: 'Verify a story\'s declared reachability, and run the project probe when configured.',
116
+ // Same posture as verify-acs: it records evidence for its gate, so it writes
117
+ // the ledger and state. It writes no artifact of its own — the declaration
118
+ // lives in the story and the project probe owns its own output.
119
+ writes: ['.cadet/runs/**', '.cadet/state.json'],
120
+ unattended: true,
121
+ },
113
122
  'harness report': {
114
123
  mutates: false,
115
124
  summary: 'Summarize budget consumption and failures.',
@@ -10,6 +10,7 @@ export {
10
10
  DEFAULT_BUDGETS, HARD_CEILINGS, DEFAULT_ARCHIVE_LIMITS, DEFAULT_OUTPUT_POLICY,
11
11
  DEFAULT_RETENTION, DEFAULT_ESTIMATION, DEFAULT_HOOK_POLICY, DEFAULT_STRICT_CLOSURE,
12
12
  EXCEPTION_CATEGORIES, EXCEPTION_EXPIRY_DAYS, EXCEPTION_REQUIRES_REVIEW_NOTE, AGENT_OWNED_GATES,
13
+ DEFAULT_REACHABILITY, REACHABILITY_GATE,
13
14
  validatePolicy, defaultPolicy, loadPolicy, budgetForScope, policyPath, PolicyError,
14
15
  } from './policy.mjs';
15
16
 
@@ -82,6 +83,14 @@ export {
82
83
  collectDeclaredTestNames, reconcileTestNames, inventoryFromCSharpSources,
83
84
  } from './matrix-check.mjs';
84
85
 
86
+ export {
87
+ REACHABILITY_KINDS, DEFAULT_MAX_STORY_BYTES, DEFAULT_MAX_SIBLING_STORIES,
88
+ normalizeWorkItemRef, collectWorkItems,
89
+ parseReachabilityDeclaration, parseReachabilityDeclarationText,
90
+ validateReachabilityDeclaration, findDeferralCycles, readSiblingDeclarations,
91
+ describeReachabilityGaps,
92
+ } from './reachability.mjs';
93
+
85
94
  export {
86
95
  COMMANDS, mutatingCommands, readOnlyCommands, resolveCommand,
87
96
  describeCommand, describeAllCommands, checkUnattendedRequirements,
@@ -37,8 +37,51 @@ export const GATES = Object.freeze([
37
37
  'acceptanceCriteriaValidated',
38
38
  'securityReviewPassed',
39
39
  'designArtifactSyncConfirmed',
40
+ // APPENDED, never reordered: C3 forbids renaming a gate, and every recorded
41
+ // name must keep its meaning. This one is additionally OPT-IN — see
42
+ // REACHABILITY_GATE and DEFAULT_REACHABILITY below.
43
+ 'reachabilityAddressed',
40
44
  ]);
41
45
 
46
+ /**
47
+ * The gate that is required only when a repository enables the reachability
48
+ * policy.
49
+ *
50
+ * WHY IT IS CONDITIONAL RATHER THAN SIMPLY REQUIRED. Every existing consumer has
51
+ * stories written before the declaration existed, so making this mandatory at
52
+ * the matrix level would block every in-flight story on a framework update — the
53
+ * one thing a compatibility-preserving change must not do. The precedent is
54
+ * `strictClosure` and `allowEmptyFreshness`: a new guarantee ships behind a
55
+ * switch whose OFF state is byte-identical to the previous behaviour.
56
+ *
57
+ * WHAT TURNS IT ON: `reachability.enabled` in `.cadet/harness.json`. When it is
58
+ * on, `review -> validation` requires this gate; when it is off (the default)
59
+ * the gate list is exactly what it was before this gate existed.
60
+ */
61
+ export const REACHABILITY_GATE = 'reachabilityAddressed';
62
+
63
+ /**
64
+ * The transition (`from` phase) the reachability gate attaches to: entering
65
+ * `validation`, i.e. `review -> validation`. Named rather than inlined because
66
+ * the placement is a decision, and a later edit that silently moved it to
67
+ * implementation would ask for the wiring before the story has been reviewed.
68
+ */
69
+ export const REACHABILITY_TRANSITION_FROM = 'review';
70
+
71
+ /**
72
+ * Default reachability policy (contract v6 §2).
73
+ *
74
+ * `enabled: false` is deliberate and load-bearing: it is what makes adopting
75
+ * this framework version a no-op for a repository that has not opted in.
76
+ * `command: null` means no project-owned probe is configured, in which case the
77
+ * declaration is checked and the CLI states plainly that the wiring itself was
78
+ * not proven — rather than implying a guarantee it did not establish.
79
+ */
80
+ export const DEFAULT_REACHABILITY = Object.freeze({
81
+ enabled: false,
82
+ command: null,
83
+ });
84
+
42
85
  /**
43
86
  * Legal phase transitions (compatibility invariant C4, revised in contract v3).
44
87
  *
@@ -215,7 +258,13 @@ export const DEFAULT_STRICT_CLOSURE = Object.freeze({
215
258
  // rejected as future-dated.
216
259
  clockSkewToleranceMs: 60 * 1000,
217
260
  }),
218
- disallowManualFor: Object.freeze(['testsPassed']),
261
+ // `reachabilityAddressed` is in the default set because, whenever the
262
+ // repository has opted in, the gate is mechanically checkable by
263
+ // `harness verify-reachability` — the declaration check runs even with no
264
+ // probe configured — so a manual assertion can add nothing and can skip the
265
+ // declaration entirely (contract v6 §2). With strictClosure off the refusal
266
+ // does not apply, matching how `testsPassed` is treated.
267
+ disallowManualFor: Object.freeze(['testsPassed', 'reachabilityAddressed']),
219
268
  });
220
269
 
221
270
  const STRICT_CLOSURE_KEYS = new Set([
@@ -397,6 +446,49 @@ function resolveStrictClosure(raw) {
397
446
  return out;
398
447
  }
399
448
 
449
+ /**
450
+ * Resolve and validate the `reachability` policy block (contract v6 §2).
451
+ *
452
+ * Rejected rather than tolerated:
453
+ * - a `command` set while `enabled` is false, because the probe would never
454
+ * run. An inert setting is worse than an absent one: it reads as a guard
455
+ * that exists.
456
+ * - an empty-string command, which is not a probe.
457
+ * - any unknown key, so a typo fails loudly instead of silently defaulting.
458
+ */
459
+ function resolveReachability(raw) {
460
+ if (raw === undefined) return { ...DEFAULT_REACHABILITY };
461
+ if (!isPlainObject(raw)) throw new PolicyError('"reachability" must be an object.');
462
+
463
+ for (const key of Object.keys(raw)) {
464
+ if (key !== 'enabled' && key !== 'command') {
465
+ throw new PolicyError(`Unknown "reachability" key "${key}".`);
466
+ }
467
+ }
468
+ if (raw.enabled !== undefined && typeof raw.enabled !== 'boolean') {
469
+ throw new PolicyError('"reachability.enabled" must be a boolean.');
470
+ }
471
+ if (raw.command !== undefined && raw.command !== null && typeof raw.command !== 'string') {
472
+ throw new PolicyError('"reachability.command" must be a string or null.');
473
+ }
474
+ // A command key that is present but blank is a probe that would never run —
475
+ // rejected, rather than silently normalized to null and forgotten.
476
+ if (typeof raw.command === 'string' && raw.command.trim() === '') {
477
+ throw new PolicyError('"reachability.command" is empty; omit it, or give the probe command to run.');
478
+ }
479
+
480
+ const out = {
481
+ enabled: raw.enabled === true,
482
+ command: typeof raw.command === 'string' ? raw.command.trim() : null,
483
+ };
484
+
485
+ if (out.enabled !== true && out.command !== null) {
486
+ throw new PolicyError('"reachability.command" is set but "reachability.enabled" is false; the probe would never run. Enable reachability or remove the command.');
487
+ }
488
+
489
+ return out;
490
+ }
491
+
400
492
  /**
401
493
  * Parse and validate a repository harness policy document.
402
494
  * Unknown top-level keys are rejected so misconfiguration fails loudly.
@@ -410,6 +502,7 @@ export function validatePolicy(raw, defaults = DEFAULT_BUDGETS) {
410
502
  'budgets', 'archive', 'output', 'retention', 'estimation', 'hook',
411
503
  'allowBudgetCeilingOverride', 'scopes', 'model', 'analyzerCommand',
412
504
  'compileCommand', 'testCommand', 'allowEmptyFreshness', 'strictClosure',
505
+ 'reachability',
413
506
  ]);
414
507
  for (const key of Object.keys(raw)) {
415
508
  if (!allowed.has(key)) {
@@ -476,6 +569,7 @@ export function validatePolicy(raw, defaults = DEFAULT_BUDGETS) {
476
569
 
477
570
  const allowCeilingOverride = raw.allowBudgetCeilingOverride === true;
478
571
  const strictClosure = resolveStrictClosure(raw.strictClosure);
572
+ const reachability = resolveReachability(raw.reachability);
479
573
 
480
574
  const resolved = {
481
575
  budgets,
@@ -487,6 +581,7 @@ export function validatePolicy(raw, defaults = DEFAULT_BUDGETS) {
487
581
  allowBudgetCeilingOverride: allowCeilingOverride,
488
582
  allowEmptyFreshness: raw.allowEmptyFreshness === true,
489
583
  strictClosure,
584
+ reachability,
490
585
  scopes: raw.scopes || { perRun: {}, perStory: {} },
491
586
  model: raw.model || null,
492
587
  analyzerCommand: raw.analyzerCommand || null,
@@ -0,0 +1,431 @@
1
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
2
+ import { basename, dirname, join } from 'node:path';
3
+
4
+ /**
5
+ * Mechanical reachability verification (Harness contract v6).
6
+ *
7
+ * Closes a defect class the framework previously had no check for at all: work
8
+ * that is fully tested and fully compiled while being reachable from nothing.
9
+ * Every gate could be green for many stories in a row and no user could reach a
10
+ * single one of them, because nothing asserted that a delivered capability is
11
+ * WIRED to anything a user or operator can touch.
12
+ *
13
+ * Three responsibilities:
14
+ * 1. parseReachabilityDeclaration — read a story's declared reachability: how
15
+ * its deliverable becomes witnessable, or which work item will make it so.
16
+ * 2. validateReachabilityDeclaration — check the declaration against the work
17
+ * items that exist, so a deferral cannot name a phantom target.
18
+ * 3. reconcileDeferrals — the falsifiability check. A deferral is a claim
19
+ * about the future, so it is re-examined once its target is `done`: a
20
+ * deferral that outlives its owner is a gap wearing a plan's clothes.
21
+ *
22
+ * WHAT THIS DELIBERATELY DOES NOT DO: it cannot know how a given project wires
23
+ * things, so a `witnessed` declaration is treated as a STATEMENT, not a proof.
24
+ * The proof comes from the project's own command
25
+ * (`reachability.command` in .cadet/harness.json), which the CLI runs and whose
26
+ * exit code is the verdict. That split is the point: a generic rule that tried
27
+ * to guess per-project wiring would be wrong often enough to be switched off,
28
+ * which is how a check erodes. No project command configured means the
29
+ * declaration level is all that is enforceable, and the CLI says so rather than
30
+ * implying a stronger guarantee.
31
+ *
32
+ * Nothing here passes on missing input: a story that declares nothing is a
33
+ * failure, not a default. Silence is not reachability, exactly as an acceptance
34
+ * criterion that declares no test is not coverage.
35
+ */
36
+
37
+ export const REACHABILITY_KINDS = Object.freeze(['witnessed', 'deferred']);
38
+
39
+ /** Bound on how much of a story is scanned, mirroring the report bound in verify-acs. */
40
+ export const DEFAULT_MAX_STORY_BYTES = 1024 * 1024;
41
+
42
+ /** Bound on sibling stories scanned for the deferral graph. */
43
+ export const DEFAULT_MAX_SIBLING_STORIES = 200;
44
+
45
+ /**
46
+ * Normalize a work-item reference so `epic::story.md`, `story.md` and a bare
47
+ * epic id can be compared.
48
+ *
49
+ * The canonical form is `epicKey::storyFile` (what state.json stores). Anything
50
+ * else is resolved leniently: a bare file name matches an existing story file,
51
+ * and an epic key matches that epic. Case-insensitive, because a hand-written
52
+ * deferral target is prose-adjacent and casing drift is not the defect this
53
+ * check exists to catch.
54
+ */
55
+ export function normalizeWorkItemRef(ref) {
56
+ if (ref === null || ref === undefined) return '';
57
+ const s = String(ref).trim().replace(/\\/g, '/');
58
+ const withoutAnchor = s.replace(/^#/, '');
59
+ return withoutAnchor.toLowerCase();
60
+ }
61
+
62
+ /**
63
+ * Extract the set of work items that exist, from a state document.
64
+ *
65
+ * Includes stories (`epic::story`), bare story file names and epic keys, plus
66
+ * spike ids — deferring to a spike is legitimate, because a spike is exactly how
67
+ * an unverified assumption becomes a deliverable.
68
+ *
69
+ * Returns `{ refs, status }` where `refs` is a Set of normalized references and
70
+ * `status` maps a normalized reference to `'done' | 'planned' | 'in-progress' |
71
+ * 'complete' | 'planned'` so the caller can tell an in-flight target from a
72
+ * finished one.
73
+ */
74
+ export function collectWorkItems(state) {
75
+ const refs = new Set();
76
+ const status = new Map();
77
+
78
+ const add = (ref, value) => {
79
+ const key = normalizeWorkItemRef(ref);
80
+ if (!key) return;
81
+ refs.add(key);
82
+ if (value) status.set(key, String(value).toLowerCase());
83
+ };
84
+
85
+ const epics = state && typeof state === 'object' && state.epics && typeof state.epics === 'object'
86
+ ? state.epics
87
+ : {};
88
+
89
+ for (const [epicKey, epic] of Object.entries(epics)) {
90
+ const epicStatus = epic && typeof epic === 'object' ? epic.status : undefined;
91
+ add(epicKey, epicStatus);
92
+ const stories = epic && typeof epic.stories === 'object' ? epic.stories : {};
93
+ for (const [storyFile, storyStatus] of Object.entries(stories)) {
94
+ const full = `${epicKey}::${storyFile}`;
95
+ add(full, storyStatus);
96
+ add(storyFile, storyStatus);
97
+ }
98
+ }
99
+
100
+ const spikes = state && typeof state === 'object' && state.spikes && typeof state.spikes === 'object'
101
+ ? state.spikes
102
+ : {};
103
+ for (const [spikeId, spikeStatus] of Object.entries(spikes)) {
104
+ add(spikeId, spikeStatus);
105
+ }
106
+
107
+ const active = state && typeof state === 'object' ? state.activeWorkItem : null;
108
+ if (active && active.epicId && active.storyId) {
109
+ add(`${active.epicId}::${active.storyId}`);
110
+ add(active.storyId);
111
+ }
112
+
113
+ return { refs, status };
114
+ }
115
+
116
+ /**
117
+ * Parse a story's reachability declaration.
118
+ *
119
+ * Expected shape (per the story template), one line, in the story header block:
120
+ *
121
+ * Reachability: witnessed — <what a user/operator does and what they see>
122
+ * Reachability: deferred to <work-item ref> — <why it cannot be witnessed yet>
123
+ *
124
+ * Returns `{ declared, kind, witness, deferTo, reason, line, errors }`.
125
+ * `errors` is non-empty only for a MALFORMED declaration (a recognised keyword
126
+ * with no content). A story with no declaration at all is `declared: false`,
127
+ * which the validator reports as a gap rather than a parse error — the two are
128
+ * different findings and the caller keeps them apart.
129
+ *
130
+ * Fenced code blocks are skipped, so a story may quote an example declaration in
131
+ * a note without it being mistaken for its own.
132
+ */
133
+ export function parseReachabilityDeclarationText(text, { maxBytes = DEFAULT_MAX_STORY_BYTES } = {}) {
134
+ const raw = typeof text === 'string' ? text : String(text ?? '');
135
+ const body = raw.length > maxBytes ? raw.slice(0, maxBytes) : raw;
136
+ const lines = body.split(/\r?\n/);
137
+
138
+ const errors = [];
139
+ let inFence = false;
140
+
141
+ for (let i = 0; i < lines.length; i++) {
142
+ const trimmed = lines[i].trim();
143
+ if (/^```/.test(trimmed)) {
144
+ inFence = !inFence;
145
+ continue;
146
+ }
147
+ if (inFence) continue;
148
+
149
+ const m = /^Reachability\s*:\s*(.*)$/i.exec(trimmed);
150
+ if (!m) continue;
151
+
152
+ const rest = m[1].trim();
153
+ const line = i + 1;
154
+ if (rest === '') {
155
+ errors.push(`line ${line}: "Reachability:" declares nothing — state either "witnessed — <how>" or "deferred to <work item> — <why>".`);
156
+ return { declared: false, kind: null, witness: null, deferTo: null, reason: '', line, errors };
157
+ }
158
+
159
+ const deferred = /^deferred\s+to\s+(\S+)\s*(?:[—-]\s*(.*))?$/i.exec(rest);
160
+ if (deferred) {
161
+ const target = deferred[1].replace(/[.,;]$/, '');
162
+ const reason = (deferred[2] || '').trim();
163
+ if (!reason) {
164
+ errors.push(`line ${line}: a deferral must say WHY it cannot be witnessed yet ("deferred to ${target} — <reason>"); an unexplained deferral is how a gap becomes permanent.`);
165
+ }
166
+ return { declared: true, kind: 'deferred', witness: null, deferTo: target, reason, line, errors };
167
+ }
168
+
169
+ const witnessed = /^witnessed\s*(?:[—-]\s*(.*))?$/i.exec(rest);
170
+ if (witnessed) {
171
+ const witness = (witnessed[1] || '').trim();
172
+ if (witness === '') {
173
+ errors.push(`line ${line}: "witnessed" must say what a user or operator does and what they see ("witnessed — <how>").`);
174
+ }
175
+ return { declared: true, kind: 'witnessed', witness, deferTo: null, reason: '', line, errors };
176
+ }
177
+
178
+ // A line that begins "Reachability:" with an unrecognised form. Reported
179
+ // rather than ignored: an ignored declaration is indistinguishable from a
180
+ // missing one, and a story that is wrong in a new way must not read as a
181
+ // story that is fine.
182
+ errors.push(`line ${line}: unrecognised reachability declaration "${rest}" — expected "witnessed — <how>" or "deferred to <work item> — <why>".`);
183
+ return { declared: false, kind: null, witness: null, deferTo: null, reason: '', line, errors };
184
+ }
185
+
186
+ return { declared: false, kind: null, witness: null, deferTo: null, reason: '', line: null, errors };
187
+ }
188
+
189
+ /** File variant of parseReachabilityDeclarationText. */
190
+ export function parseReachabilityDeclaration(storyPath) {
191
+ const text = readFileSync(storyPath, 'utf-8');
192
+ return parseReachabilityDeclarationText(text);
193
+ }
194
+
195
+ /**
196
+ * Validate one declaration against the work items that exist.
197
+ *
198
+ * Returns `{ ok, code, message }`. Codes are stable so a caller can branch and a
199
+ * test can assert the FINDING rather than the prose:
200
+ * malformed — the declaration parsed with errors. Checked FIRST:
201
+ * a reasonless deferral or a content-free "witnessed"
202
+ * parses far enough to be typed, and must still be
203
+ * refused — the parser said no, and the parser's no
204
+ * is the rule (contract v6 §1).
205
+ * not-declared — the story declares nothing at all.
206
+ * deferral-self — a deferral names the story itself (`self`).
207
+ * unknown-target — a deferral names a work item that does not exist.
208
+ * deferral-target-done — a deferral names a work item that is already done,
209
+ * so the witness it promised can never arrive.
210
+ *
211
+ * `self` is the story's own reference (bare file name, or a list of its
212
+ * references) so a story cannot be made "reachable" by deferring to itself.
213
+ *
214
+ * `deferral-target-done` is the tooth that matters. A deferral is only honest
215
+ * while its owner is still ahead; once the owner lands, the deferral is a claim
216
+ * that has been overtaken by events, and it is reported as a gap rather than
217
+ * inherited forever.
218
+ */
219
+ export function validateReachabilityDeclaration(declaration, { workItems = null, self = null } = {}) {
220
+ if (declaration && Array.isArray(declaration.errors) && declaration.errors.length > 0) {
221
+ return { ok: false, code: 'malformed', message: declaration.errors.join(' ') };
222
+ }
223
+
224
+ if (!declaration || declaration.declared !== true) {
225
+ return {
226
+ ok: false,
227
+ code: 'not-declared',
228
+ message: 'the story declares no reachability — add "Reachability: witnessed — <how a user/operator reaches and sees this>" or "Reachability: deferred to <work item> — <why>". A story that says nothing about reachability is indistinguishable from one whose deliverable cannot be reached.',
229
+ };
230
+ }
231
+
232
+ if (declaration.kind === 'witnessed') {
233
+ return { ok: true, code: 'witnessed', message: `witnessed: ${declaration.witness}` };
234
+ }
235
+
236
+ // Deferred.
237
+ const target = declaration.deferTo;
238
+ const selfRefs = Array.isArray(self)
239
+ ? self.map(normalizeWorkItemRef)
240
+ : (self ? [normalizeWorkItemRef(self)] : []);
241
+ if (selfRefs.length > 0 && selfRefs.includes(normalizeWorkItemRef(target))) {
242
+ return {
243
+ ok: false,
244
+ code: 'deferral-self',
245
+ message: `the deferral names "${target}", which is this story itself. A story cannot be made reachable by deferring to itself: declare "witnessed", or name the work item that will wire it.`,
246
+ };
247
+ }
248
+ if (!workItems) {
249
+ // No state to check against — the target cannot be verified, and "cannot
250
+ // verify" is reported as such rather than assumed fine.
251
+ return { ok: true, code: 'deferred-unchecked', message: `deferred to ${target} (no state document to check the target against)` };
252
+ }
253
+
254
+ const key = normalizeWorkItemRef(target);
255
+ if (!workItems.refs.has(key)) {
256
+ const known = [...workItems.refs].filter((r) => r.includes('::')).slice(0, 8);
257
+ return {
258
+ ok: false,
259
+ code: 'unknown-target',
260
+ message: `the deferral names "${target}", which is not a work item in state.json. A deferral to something that does not exist never expires and never lands. Known work items include: ${known.join(', ') || '(none)'}.`,
261
+ };
262
+ }
263
+
264
+ const status = workItems.status.get(key);
265
+ if (status === 'done' || status === 'complete') {
266
+ return {
267
+ ok: false,
268
+ code: 'deferral-target-done',
269
+ message: `the deferral names "${target}", which is already ${status}. The work item that was going to make this reachable has landed, so the deferral has expired: either this story is reachable now (declare "witnessed") or the wiring was missed when "${target}" closed.`,
270
+ };
271
+ }
272
+
273
+ return { ok: true, code: 'deferred', message: `deferred to ${target} (${status || 'unknown status'})` };
274
+ }
275
+
276
+ /**
277
+ * Build a deferral graph from a set of declarations and report every cycle.
278
+ *
279
+ * A chain of deferrals that closes on itself is not a plan: nothing in the loop
280
+ * is ever witnessed, and each item can point at another to explain why. The
281
+ * cycle is reported as one finding naming the whole chain, because naming a
282
+ * single node would hide the shape that makes it a gap.
283
+ *
284
+ * `declarations` is an array of `{ id, aliases?, declaration }`. `aliases` lets
285
+ * one node carry both the `epicKey::story.md` form and the bare file name.
286
+ */
287
+ export function findDeferralCycles(declarations) {
288
+ // An entry may carry ALIASES (`epicKey::story.md` and the bare `story.md` are
289
+ // the same node). Without them, a deferral written in the long form and a
290
+ // sibling found by file name would be two disconnected nodes and a real cycle
291
+ // would go unreported - a check that cannot see the edge it exists to find.
292
+ const aliasToNode = new Map();
293
+ for (const entry of declarations || []) {
294
+ if (!entry || !entry.id) continue;
295
+ const ids = [entry.id, ...(Array.isArray(entry.aliases) ? entry.aliases : [])];
296
+ for (const alias of ids) aliasToNode.set(normalizeWorkItemRef(alias), normalizeWorkItemRef(entry.id));
297
+ }
298
+
299
+ const resolve = (ref) => aliasToNode.get(normalizeWorkItemRef(ref)) ?? normalizeWorkItemRef(ref);
300
+
301
+ const target = new Map();
302
+ for (const entry of declarations || []) {
303
+ if (entry && entry.declaration && entry.declaration.kind === 'deferred' && entry.declaration.deferTo) {
304
+ target.set(normalizeWorkItemRef(entry.id), resolve(entry.declaration.deferTo));
305
+ }
306
+ }
307
+
308
+ const cycles = [];
309
+ const seenCycleKeys = new Set();
310
+
311
+ for (const start of target.keys()) {
312
+ const path = [];
313
+ const onPath = new Set();
314
+ let node = start;
315
+
316
+ while (node && target.has(node)) {
317
+ if (onPath.has(node)) {
318
+ const at = path.indexOf(node);
319
+ const chain = path.slice(at);
320
+ // Canonicalize so the same cycle found from two entry points is one finding.
321
+ const key = [...chain].sort().join('|');
322
+ if (!seenCycleKeys.has(key)) {
323
+ seenCycleKeys.add(key);
324
+ cycles.push([...chain, node]);
325
+ }
326
+ break;
327
+ }
328
+ onPath.add(node);
329
+ path.push(node);
330
+ node = target.get(node);
331
+ }
332
+ }
333
+
334
+ return cycles;
335
+ }
336
+
337
+ /**
338
+ * Read a story and every sibling `story-*.md` in its directory, and parse each
339
+ * declaration, so the deferral graph covers the epic rather than one story. The
340
+ * story itself is ALWAYS a node — its own declaration must participate in the
341
+ * cycle graph even when its file name does not match the `story-*` pattern.
342
+ *
343
+ * Aliases tie the `epicKey::story.md` form and the bare file name to ONE node;
344
+ * without them a real cycle written in the long form goes unreported (contract
345
+ * v6 §4.2). The epic key is taken from the caller's work-item index when
346
+ * supplied — every `epicKey::name` ref that actually exists in state.json —
347
+ * because deriving it from the directory name is only a heuristic: a bare
348
+ * relative filename has dirname `.`, and any other layout may not be named
349
+ * after the epic at all. The directory-name derivation remains as a fallback
350
+ * for callers without state.
351
+ *
352
+ * Returns `[{ id, aliases, path, declaration }]`. Unreadable files are skipped
353
+ * rather than fatal: the check is about the story under test, and an unreadable
354
+ * sibling must not turn a reachability verdict into a filesystem error.
355
+ */
356
+ export function readSiblingDeclarations(storyPath, { max = DEFAULT_MAX_SIBLING_STORIES, workItems = null } = {}) {
357
+ const dir = dirname(storyPath);
358
+ // Heuristic fallback: the epic directory's own name is often the epic key
359
+ // state.json uses. Unreliable on its own — see the docstring above.
360
+ const dirKey = dir.replace(/\\/g, '/').split('/').filter(Boolean).pop() || '';
361
+
362
+ const aliasesFor = (name) => {
363
+ const key = normalizeWorkItemRef(name);
364
+ const aliases = new Set();
365
+ if (dirKey) aliases.add(normalizeWorkItemRef(`${dirKey}::${name}`));
366
+ if (workItems && workItems.refs) {
367
+ for (const ref of workItems.refs) {
368
+ if (ref.endsWith(`::${key}`)) aliases.add(ref);
369
+ }
370
+ }
371
+ aliases.delete(key);
372
+ return [...aliases];
373
+ };
374
+
375
+ const out = [];
376
+ const seen = new Set();
377
+ const add = (name, path) => {
378
+ const id = normalizeWorkItemRef(name);
379
+ if (seen.has(id)) return;
380
+ seen.add(id);
381
+ try {
382
+ out.push({
383
+ id: name,
384
+ aliases: aliasesFor(name),
385
+ path,
386
+ declaration: parseReachabilityDeclaration(path),
387
+ });
388
+ } catch {
389
+ // A file that cannot be read is not this verdict's business.
390
+ }
391
+ };
392
+
393
+ // The story itself, always — its own deferral edges are the ones being judged.
394
+ add(basename(storyPath), storyPath);
395
+
396
+ let entries = null;
397
+ try {
398
+ entries = readdirSync(dir);
399
+ } catch {
400
+ entries = null;
401
+ }
402
+ if (entries) {
403
+ for (const name of entries.sort()) {
404
+ if (out.length >= max) break;
405
+ if (!/^story-.*\.md$/i.test(name)) continue;
406
+ const path = join(dir, name);
407
+ if (!existsSync(path)) continue;
408
+ add(name, path);
409
+ }
410
+ }
411
+ return out;
412
+ }
413
+
414
+ /**
415
+ * Format reachability gaps as concrete, actionable lines, in the shape
416
+ * `describeCoverageGaps` uses for AC gaps so the two read consistently in a
417
+ * terminal.
418
+ */
419
+ export function describeReachabilityGaps({ validation, cycles = [], story } = {}) {
420
+ const lines = [];
421
+ if (validation && validation.ok !== true) {
422
+ lines.push(` reachability: ${validation.message}`);
423
+ }
424
+ for (const cycle of cycles) {
425
+ lines.push(` reachability deferral cycle: ${cycle.join(' -> ')} — nothing in this loop can ever be witnessed; at least one item must become "witnessed" or the chain is a gap.`);
426
+ }
427
+ if (lines.length > 0 && story) {
428
+ lines.unshift(` story: ${story}`);
429
+ }
430
+ return lines;
431
+ }
@@ -8,13 +8,18 @@
8
8
  */
9
9
 
10
10
  import { readFileSync, writeFileSync, renameSync, copyFileSync, existsSync, rmSync } from 'node:fs';
11
- import { join } from 'node:path';
11
+ import { basename, isAbsolute, join } from 'node:path';
12
12
  import {
13
13
  PHASES, GATES, TRANSITIONS, EVIDENCE_STATUSES, DEFAULT_STRICT_CLOSURE,
14
14
  EXCEPTION_CATEGORIES, EXCEPTION_EXPIRY_DAYS, EXCEPTION_REQUIRES_REVIEW_NOTE,
15
+ REACHABILITY_GATE, REACHABILITY_TRANSITION_FROM,
15
16
  } from './policy.mjs';
16
17
  import { hashTree, hashFile, hashCriteria, timestamp, isUuid } from './util.mjs';
17
18
  import { encodeEvidenceTrailers } from './gitmemo.mjs';
19
+ import {
20
+ parseReachabilityDeclaration, validateReachabilityDeclaration, collectWorkItems,
21
+ findDeferralCycles, readSiblingDeclarations,
22
+ } from './reachability.mjs';
18
23
 
19
24
  export { PHASES, GATES, TRANSITIONS, EVIDENCE_STATUSES };
20
25
  export { EXCEPTION_CATEGORIES, EXCEPTION_EXPIRY_DAYS };
@@ -1092,9 +1097,25 @@ export function activeExceptions(state, { workItemId, now = new Date() } = {}) {
1092
1097
  // ── Transitions ─────────────────────────────────────────────────────────────
1093
1098
 
1094
1099
  /** Required gates for a transition target, or null when the target is not gated. */
1095
- export function requiredGates(toPhase) {
1100
+ export function requiredGates(toPhase, { reachability = false } = {}) {
1096
1101
  for (const [from, spec] of Object.entries(TRANSITIONS)) {
1097
- if (spec.to === toPhase) return { from, gates: spec.gates, revalidate: spec.revalidate || [] };
1102
+ if (spec.to === toPhase) {
1103
+ const gates = [...spec.gates];
1104
+ // The reachability gate is appended ONLY to the review -> validation
1105
+ // transition, and only when the repository has opted in
1106
+ // (`reachability.enabled`). Two reasons for both halves of that:
1107
+ //
1108
+ // - PLACEMENT: reachability is a claim about a delivered, reviewed story,
1109
+ // so it is checked at the same point as the review gates rather than at
1110
+ // implementation, where the wiring may legitimately not exist yet.
1111
+ // - OPT-IN: an entering-`review` requirement would block every in-flight
1112
+ // story in every existing consumer on a framework update. With the
1113
+ // default OFF the list is exactly what the matrix declares, so a
1114
+ // single-argument call — and every existing caller and test — sees
1115
+ // unchanged behaviour. See REACHABILITY_GATE.
1116
+ if (reachability && from === REACHABILITY_TRANSITION_FROM) gates.push(REACHABILITY_GATE);
1117
+ return { from, gates, revalidate: spec.revalidate || [] };
1118
+ }
1098
1119
  }
1099
1120
  return null;
1100
1121
  }
@@ -1205,6 +1226,60 @@ function checkGate({ gate, state, gates, exceptions, now, workItemId, fromPhase,
1205
1226
  return { missingGates, staleEvidence };
1206
1227
  }
1207
1228
 
1229
+ /**
1230
+ * Re-derive the opted-in reachability declaration against the CURRENT state at
1231
+ * `validation -> closed` (contract v6 §4, closure re-examination). A deferral
1232
+ * is a claim about the future, so closure re-reads the story's declaration and
1233
+ * re-validates it the same way `harness verify-reachability` does: a deferral
1234
+ * whose target is now `done`, one whose target has vanished, or a cycle that
1235
+ * has since formed all refuse closure. The evidence record is used only to
1236
+ * LOCATE the story file — its hashes and phase stamp are deliberately not
1237
+ * re-checked, because the record is legitimately created during `review` and
1238
+ * the phase/recency freshness machinery would wrongly reject it here.
1239
+ */
1240
+ function recheckReachabilityAtClosure({ state, rootDir }) {
1241
+ const missingGates = [];
1242
+ const staleEvidence = [];
1243
+ const refuse = (reasons) => {
1244
+ missingGates.push(REACHABILITY_GATE);
1245
+ staleEvidence.push({ gate: REACHABILITY_GATE, reasons });
1246
+ return { missingGates, staleEvidence };
1247
+ };
1248
+
1249
+ const record = latestEvidenceForGate(state, REACHABILITY_GATE);
1250
+ if (!record) {
1251
+ return refuse(['no reachability evidence record for the opted-in gate']);
1252
+ }
1253
+ const relevant = Array.isArray(record.relevantFiles) ? record.relevantFiles : [];
1254
+ const storyRef = relevant[0];
1255
+ if (!storyRef) {
1256
+ return refuse(['the reachability evidence record names no story file']);
1257
+ }
1258
+ // Records written since the path-binding fix hold a repo-relative path; older
1259
+ // ones may hold an absolute path, which is used as-is.
1260
+ const storyPath = isAbsolute(storyRef) ? storyRef : join(rootDir, storyRef);
1261
+
1262
+ let declaration;
1263
+ try {
1264
+ declaration = parseReachabilityDeclaration(storyPath);
1265
+ } catch (err) {
1266
+ return refuse([`cannot read the declared story "${storyRef}": ${err.message}`]);
1267
+ }
1268
+
1269
+ const workItems = collectWorkItems(state);
1270
+ const selfName = basename(storyRef.replace(/\\/g, '/'));
1271
+ const validation = validateReachabilityDeclaration(declaration, { workItems, self: selfName });
1272
+ const cycles = findDeferralCycles(readSiblingDeclarations(storyPath, { workItems }));
1273
+
1274
+ const reasons = [];
1275
+ if (validation.ok !== true) reasons.push(validation.message);
1276
+ for (const cycle of cycles) {
1277
+ reasons.push(`reachability deferral cycle: ${cycle.join(' -> ')} — nothing in this loop can ever be witnessed`);
1278
+ }
1279
+ if (reasons.length > 0) return refuse(reasons);
1280
+ return { missingGates, staleEvidence };
1281
+ }
1282
+
1208
1283
  /**
1209
1284
  * Evaluate whether a transition is legal and evidence-backed.
1210
1285
  * Returns a machine-readable result:
@@ -1231,7 +1306,9 @@ export function evaluateTransition(state, toPhase, context = {}) {
1231
1306
  return { allowed: false, fromPhase, toPhase, missingGates, staleEvidence, errors, revalidated: [] };
1232
1307
  }
1233
1308
 
1234
- const spec = requiredGates(toPhase);
1309
+ // The reachability gate joins the requirement only when the repository has
1310
+ // opted in, so a project that has not sees the pre-existing gate list exactly.
1311
+ const spec = requiredGates(toPhase, { reachability: context.policy?.reachability?.enabled === true });
1235
1312
  if (!spec) {
1236
1313
  // Ungated transitions are legal ONLY along the declared forward edges
1237
1314
  // (bootstrap + planning progression). A target that is neither gated nor a
@@ -1280,6 +1357,21 @@ export function evaluateTransition(state, toPhase, context = {}) {
1280
1357
  missingGates.push(...r.missingGates);
1281
1358
  staleEvidence.push(...r.staleEvidence);
1282
1359
  }
1360
+
1361
+ // Contract v6 §4: an opted-in reachability declaration is ALSO re-examined
1362
+ // at `validation -> closed`. This is deliberately NOT routed through the
1363
+ // freshness machinery the other revalidated gates use — the record is
1364
+ // legitimately created during `review`, so phase- and recency-staleness
1365
+ // would wrongly reject it — so the conditional append lives here rather
1366
+ // than in the TRANSITIONS table (which stays exactly as C4 declares it).
1367
+ if (toPhase === 'closed' && context.policy?.reachability?.enabled === true) {
1368
+ revalidated.push(REACHABILITY_GATE);
1369
+ if (!exceptions[REACHABILITY_GATE]) {
1370
+ const r = recheckReachabilityAtClosure({ state, rootDir });
1371
+ missingGates.push(...r.missingGates);
1372
+ staleEvidence.push(...r.staleEvidence);
1373
+ }
1374
+ }
1283
1375
  }
1284
1376
 
1285
1377
  return {
@@ -1299,12 +1391,16 @@ export function evaluateTransition(state, toPhase, context = {}) {
1299
1391
  * Resets the target transition's gates is NOT done here — gates reset when a new
1300
1392
  * work item starts (see `resetGatesForNewWorkItem`).
1301
1393
  */
1302
- export function applyTransition(state, toPhase, { evidenceIds = [], at = new Date(), inputTreeHash = undefined, criteriaHash = undefined, rootDir = undefined, strictClosure = undefined } = {}) {
1394
+ export function applyTransition(state, toPhase, { evidenceIds = [], at = new Date(), inputTreeHash = undefined, criteriaHash = undefined, rootDir = undefined, strictClosure = undefined, policy = undefined } = {}) {
1303
1395
  const context = { now: at };
1304
1396
  if (inputTreeHash !== undefined) context.inputTreeHash = inputTreeHash;
1305
1397
  if (criteriaHash !== undefined) context.criteriaHash = criteriaHash;
1306
1398
  if (rootDir !== undefined) context.rootDir = rootDir;
1307
1399
  if (strictClosure !== undefined) context.strictClosure = strictClosure;
1400
+ // The resolved policy, so conditional gates (contract v6 `reachability`) are
1401
+ // enforced by the internal re-evaluation too — not just by the caller's own
1402
+ // pre-check. A library caller that omits it gets the pre-v6 gate list.
1403
+ if (policy !== undefined) context.policy = policy;
1308
1404
  const evaluation = evaluateTransition(state, toPhase, context);
1309
1405
  if (!evaluation.allowed) {
1310
1406
  throw new StateError(