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 +2 -2
- package/package.json +1 -1
- package/src/cli.mjs +184 -6
- package/src/harness/commands.mjs +9 -0
- package/src/harness/index.mjs +9 -0
- package/src/harness/policy.mjs +96 -1
- package/src/harness/reachability.mjs +431 -0
- package/src/harness/routing.mjs +161 -153
- package/src/harness/state.mjs +101 -5
- package/src/harness/verification.mjs +219 -5
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
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/src/harness/commands.mjs
CHANGED
|
@@ -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.',
|
package/src/harness/index.mjs
CHANGED
|
@@ -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,
|
package/src/harness/policy.mjs
CHANGED
|
@@ -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
|
-
|
|
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,
|