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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cadet-agent",
3
- "version": "0.47.0",
3
+ "version": "0.48.0",
4
4
  "description": "Cross-IDE agent framework for Unity/C# game-development — one-command install",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -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
- inputTreeHash: computeInputTreeHash(opts.targetDir, [opts.story, ...(reportPath ? [reportPath] : [])]),
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: [opts.story, ...(reportPath ? [reportPath] : [])].map((f) => f.replace(/\\/g, '/')),
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';
@@ -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
  }