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 +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/state.mjs +101 -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,
|
|
@@ -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
|
+
}
|
package/src/harness/state.mjs
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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(
|