cadet-agent 0.47.0 → 0.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli.mjs +67 -3
- package/src/harness/state.mjs +20 -3
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
validateState, migrateStateFile, readState, writeState, evaluateTransition, applyTransition,
|
|
7
7
|
workItemIdOf, loadPolicy, RunLedger, loadRun, listRuns, cleanupRuns, buildReport, formatReport,
|
|
8
8
|
runVerificationLoop, commandForGate, detectCapabilities, runsDir, gitChangedFiles, PolicyError, StateError,
|
|
9
|
-
detectRepoRole, describeRepoRole, GATES, manualConfirmation,
|
|
9
|
+
detectRepoRole, describeRepoRole, GATES, PHASES, manualConfirmation,
|
|
10
10
|
gitChangeSet, DEFAULT_REPORT_DIR,
|
|
11
11
|
reconcileArtifacts, PLANS_DEFAULT_DIR,
|
|
12
12
|
parseTestInventory, parseStoryCriteria, compareCoverage, describeCoverageGaps,
|
|
@@ -74,6 +74,7 @@ function showHelp() {
|
|
|
74
74
|
--command Command override (harness verify)
|
|
75
75
|
--files Comma-separated relevant files to bind evidence to (harness verify|confirm)
|
|
76
76
|
--commit Revision the gate attests, as a hex SHA (harness verify|confirm)
|
|
77
|
+
--expect-phase Refuse to record a gate unless the current phase matches (harness verify|confirm|verify-acs|verify-reachability)
|
|
77
78
|
--reason Why automation was unavailable (harness confirm)
|
|
78
79
|
--expires-at ISO-8601 expiry bounding the confirmation (harness confirm)
|
|
79
80
|
--environment key=value,... describing what was verified (harness confirm)
|
|
@@ -154,6 +155,9 @@ function parseArgs(argv) {
|
|
|
154
155
|
case '--command': opts.command = value(a); break;
|
|
155
156
|
case '--work-item': opts.workItemId = value(a); break;
|
|
156
157
|
case '--phase': opts.phase = value(a); break;
|
|
158
|
+
// --expect-phase: a guard against recording a gate into a phase the caller
|
|
159
|
+
// did not intend. See assertExpectedPhase.
|
|
160
|
+
case '--expect-phase': opts.expectPhase = value(a); break;
|
|
157
161
|
case '--run': opts.runId = value(a); break;
|
|
158
162
|
case '--type': opts.type = value(a); break;
|
|
159
163
|
case '--reason': opts.reason = value(a); break;
|
|
@@ -236,6 +240,40 @@ function fail(opts, message, code = json => json.exitCode || 1, json = {}) {
|
|
|
236
240
|
process.exit(exitCode);
|
|
237
241
|
}
|
|
238
242
|
|
|
243
|
+
/**
|
|
244
|
+
* `--expect-phase <phase>` — refuse to record gate evidence into a phase the
|
|
245
|
+
* caller did not intend.
|
|
246
|
+
*
|
|
247
|
+
* The failure this closes is a caller error, not a framework one: `state
|
|
248
|
+
* transition` already reports `allowed: false` and exits 1, but an agent that
|
|
249
|
+
* chains commands with `;` and filters the output reads the *next* command's
|
|
250
|
+
* success as the transition's, and goes on to record the following gates into the
|
|
251
|
+
* phase it never left. The verdict was correct and ignored; the record was then
|
|
252
|
+
* written anyway. This is a check because the mistake recurred after being
|
|
253
|
+
* documented, and a check is what the framework's own doctrine asks for at that
|
|
254
|
+
* point.
|
|
255
|
+
*
|
|
256
|
+
* The flag is opt-in and cheap: omitting it changes nothing. A mismatch is
|
|
257
|
+
* refused before any write, so a stray `--expect-phase` cannot corrupt state —
|
|
258
|
+
* it can only stop the command.
|
|
259
|
+
*/
|
|
260
|
+
function assertExpectedPhase(opts, state) {
|
|
261
|
+
if (!opts.expectPhase) return;
|
|
262
|
+
if (!PHASES.includes(opts.expectPhase)) {
|
|
263
|
+
fail(opts, `--expect-phase "${opts.expectPhase}" is not a known phase. Valid phases: ${PHASES.join(', ')}.`, () => 1, { ok: false, code: 'unknown-phase', expectedPhase: opts.expectPhase });
|
|
264
|
+
}
|
|
265
|
+
const actual = state?.session?.currentPhase ?? null;
|
|
266
|
+
if (actual === opts.expectPhase) return;
|
|
267
|
+
fail(
|
|
268
|
+
opts,
|
|
269
|
+
`--expect-phase ${opts.expectPhase}, but the current phase is "${actual ?? '(none)'}". `
|
|
270
|
+
+ 'Refusing to record evidence for a phase the caller did not intend — re-read .cadet/state.json '
|
|
271
|
+
+ '(or run `state transition --dry-run`) and retry once the phase is what you expected.',
|
|
272
|
+
() => 1,
|
|
273
|
+
{ ok: false, code: 'phase-mismatch', expectedPhase: opts.expectPhase, actualPhase: actual },
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
239
277
|
// ── evidence archive (contract v5) ──────────────────────────────────────────
|
|
240
278
|
|
|
241
279
|
/**
|
|
@@ -672,6 +710,7 @@ async function cmdHarness(opts) {
|
|
|
672
710
|
|
|
673
711
|
const { exists, state } = readState(opts.targetDir);
|
|
674
712
|
if (!exists) fail(opts, 'No .cadet/state.json found. Initialise state before recording confirmation.', () => 2);
|
|
713
|
+
assertExpectedPhase(opts, state);
|
|
675
714
|
|
|
676
715
|
const strict = policy.strictClosure?.enabled === true ? policy.strictClosure : null;
|
|
677
716
|
const mc = strict?.manualConfirmation || null;
|
|
@@ -829,6 +868,7 @@ async function cmdHarness(opts) {
|
|
|
829
868
|
const gate = opts.gate;
|
|
830
869
|
if (!gate) fail(opts, 'harness verify requires --gate <gate>');
|
|
831
870
|
const { state } = readState(opts.targetDir);
|
|
871
|
+
assertExpectedPhase(opts, state);
|
|
832
872
|
const caps = detectCapabilities({ targetDir: opts.targetDir });
|
|
833
873
|
const descriptor = opts.command
|
|
834
874
|
? { command: opts.command, tool: 'custom', automated: true }
|
|
@@ -973,6 +1013,7 @@ async function cmdHarness(opts) {
|
|
|
973
1013
|
// never written cannot be asserted into coverage.
|
|
974
1014
|
if (!opts.story) fail(opts, 'harness verify-acs requires --story <path>');
|
|
975
1015
|
const { exists, state } = readState(opts.targetDir);
|
|
1016
|
+
assertExpectedPhase(opts, state);
|
|
976
1017
|
const strict = policy.strictClosure?.enabled === true;
|
|
977
1018
|
const workItemId = state ? workItemIdOf(state) : 'unscoped';
|
|
978
1019
|
const phase = state?.session?.currentPhase || 'implementation';
|
|
@@ -1082,6 +1123,26 @@ async function cmdHarness(opts) {
|
|
|
1082
1123
|
const at = new Date();
|
|
1083
1124
|
const criteriaStrings = coverage.ac.flatMap((a) => [a.id, ...a.declared]);
|
|
1084
1125
|
const nowIso = at.toISOString();
|
|
1126
|
+
// The story is the only INPUT to the AC claim: it carries the declared
|
|
1127
|
+
// AC→test mapping, and `criteriaHash` binds those names (C12), so editing the
|
|
1128
|
+
// mapping invalidates the record.
|
|
1129
|
+
//
|
|
1130
|
+
// The test report is an OUTPUT of the run that satisfied `testsPassed`, not an
|
|
1131
|
+
// input, and binding it was a defect: a repository whose test script rewrites a
|
|
1132
|
+
// fixed report path (e.g. `test-results-junit.xml`) staled this record the
|
|
1133
|
+
// moment it re-ran the tests, because the file the record had just read changed
|
|
1134
|
+
// underneath it. This is the same class Harness §5 already excludes
|
|
1135
|
+
// (`.cadet/state.json`, `.cadet/runs/**`) — "binding evidence to either would
|
|
1136
|
+
// make a gate stale the instant it was written" — so a generated report gets
|
|
1137
|
+
// the same treatment and is kept as `artifactPath` for audit, where nothing
|
|
1138
|
+
// re-hashes it.
|
|
1139
|
+
//
|
|
1140
|
+
// The story path is made repo-relative for the same reason verify-reachability
|
|
1141
|
+
// does it: an absolute path never resolves under the root when freshness is
|
|
1142
|
+
// re-derived at transition time, so both hashes would be computed over a
|
|
1143
|
+
// missing file and match — a binding that is silently inert.
|
|
1144
|
+
const storyPath = resolve(opts.targetDir, opts.story);
|
|
1145
|
+
const storyRel = relative(opts.targetDir, storyPath).replace(/\\/g, '/') || basename(storyPath);
|
|
1085
1146
|
const evidence = createEvidence({
|
|
1086
1147
|
evidenceId: newId(),
|
|
1087
1148
|
workItemId,
|
|
@@ -1092,9 +1153,11 @@ async function cmdHarness(opts) {
|
|
|
1092
1153
|
command: `harness verify-acs --story ${opts.story}`,
|
|
1093
1154
|
result: `AC coverage verified: ${coverage.ac.length} criteria, inventory ${coverage.inventorySize} (${inventory.format})`,
|
|
1094
1155
|
exitCode: 0,
|
|
1095
|
-
|
|
1156
|
+
// Audit pointer only. Not a relevant file: see above.
|
|
1157
|
+
artifactPath: reportPath ? reportPath.replace(/\\/g, '/') : null,
|
|
1158
|
+
inputTreeHash: computeInputTreeHash(opts.targetDir, [storyRel]),
|
|
1096
1159
|
criteriaHash: hashCriteria(criteriaStrings),
|
|
1097
|
-
relevantFiles: [
|
|
1160
|
+
relevantFiles: [storyRel],
|
|
1098
1161
|
createdAt: at,
|
|
1099
1162
|
expiresAt: null,
|
|
1100
1163
|
// Schema + validator require an object carrying a `scope`, not a bare
|
|
@@ -1162,6 +1225,7 @@ async function cmdHarness(opts) {
|
|
|
1162
1225
|
const storyPath = resolve(opts.targetDir, opts.story);
|
|
1163
1226
|
const storyRel = relative(opts.targetDir, storyPath).replace(/\\/g, '/') || basename(storyPath);
|
|
1164
1227
|
const { exists, state } = readState(opts.targetDir);
|
|
1228
|
+
assertExpectedPhase(opts, state);
|
|
1165
1229
|
const enabled = policy.reachability?.enabled === true;
|
|
1166
1230
|
const probeCommand = policy.reachability?.command || null;
|
|
1167
1231
|
const workItemId = state ? workItemIdOf(state) : 'unscoped';
|
package/src/harness/state.mjs
CHANGED
|
@@ -1177,8 +1177,17 @@ export function isUngatedForwardEdge(fromPhase, toPhase) {
|
|
|
1177
1177
|
* must have been created at or after that instant. Without it, "fresh" would mean
|
|
1178
1178
|
* only "not yet expired", which lets a long phase carry evidence that predates
|
|
1179
1179
|
* the work it is meant to attest.
|
|
1180
|
+
*
|
|
1181
|
+
* `phaseScoped` is false for the strict-closure `revalidate` set. A revalidated
|
|
1182
|
+
* gate asks "is this still true *now*?" — answered by the input-tree hash, the
|
|
1183
|
+
* criteria hash, and the expiry — not "was it recorded in the phase I am leaving?".
|
|
1184
|
+
* Enforcing the phase stamp on a revalidated gate made the answer "no" for every
|
|
1185
|
+
* record written in an earlier phase, so the whole suite had to be re-recorded in
|
|
1186
|
+
* `review` and again in `validation` on a tree that had not changed by a byte.
|
|
1187
|
+
* Primary gates keep the phase scope: a record still has to be written in the
|
|
1188
|
+
* phase it belongs to.
|
|
1180
1189
|
*/
|
|
1181
|
-
function checkGate({ gate, state, gates, exceptions, now, workItemId, fromPhase, rootDir, computeTreeHash, inputTreeHash, critHash, recencyFloor = null }) {
|
|
1190
|
+
function checkGate({ gate, state, gates, exceptions, now, workItemId, fromPhase, rootDir, computeTreeHash, inputTreeHash, critHash, recencyFloor = null, phaseScoped = true }) {
|
|
1182
1191
|
const missingGates = [];
|
|
1183
1192
|
const staleEvidence = [];
|
|
1184
1193
|
|
|
@@ -1204,7 +1213,7 @@ function checkGate({ gate, state, gates, exceptions, now, workItemId, fromPhase,
|
|
|
1204
1213
|
const { fresh, reasons } = evidenceFreshness(evidence, {
|
|
1205
1214
|
now,
|
|
1206
1215
|
workItemId,
|
|
1207
|
-
phase: fromPhase,
|
|
1216
|
+
phase: phaseScoped ? fromPhase : null,
|
|
1208
1217
|
inputTreeHash: currentTreeHash,
|
|
1209
1218
|
criteriaHash: critHash,
|
|
1210
1219
|
});
|
|
@@ -1344,6 +1353,14 @@ export function evaluateTransition(state, toPhase, context = {}) {
|
|
|
1344
1353
|
}
|
|
1345
1354
|
|
|
1346
1355
|
// Strict closure: re-derive the earlier gates at this transition.
|
|
1356
|
+
//
|
|
1357
|
+
// A revalidated gate is checked WITHOUT the phase scope (`phaseScoped: false`).
|
|
1358
|
+
// Its question is "is this still true now?", which the input-tree hash, the
|
|
1359
|
+
// criteria hash, and the expiry answer; the phase stamp answers only "which
|
|
1360
|
+
// phase wrote it down", which is exactly the fact revalidation is not doubting.
|
|
1361
|
+
// With the phase scope on, every record written in an earlier phase was rejected
|
|
1362
|
+
// as stale, so an unchanged tree still forced the whole suite to be re-recorded
|
|
1363
|
+
// in `review` and again in `validation`. See checkGate.
|
|
1347
1364
|
const strict = resolveStrict(context);
|
|
1348
1365
|
const revalidated = [];
|
|
1349
1366
|
if (strict && strict.revalidateOnClosure !== false) {
|
|
@@ -1353,7 +1370,7 @@ export function evaluateTransition(state, toPhase, context = {}) {
|
|
|
1353
1370
|
for (const gate of spec.revalidate) {
|
|
1354
1371
|
if (spec.gates.includes(gate)) continue; // already checked as a primary gate
|
|
1355
1372
|
revalidated.push(gate);
|
|
1356
|
-
const r = checkGate({ ...shared, gate, recencyFloor });
|
|
1373
|
+
const r = checkGate({ ...shared, gate, recencyFloor, phaseScoped: false });
|
|
1357
1374
|
missingGates.push(...r.missingGates);
|
|
1358
1375
|
staleEvidence.push(...r.staleEvidence);
|
|
1359
1376
|
}
|