cadet-agent 0.44.0 → 0.46.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.44.0",
3
+ "version": "0.46.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,