cadet-agent 0.41.0 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -184,13 +184,18 @@ Gates are backed by **evidence**, not assertion. Each claimed gate must have a f
184
184
  - When Git is unavailable and no `--files` are given, verification blocks (`freshness-unavailable`) rather than recording unscoped evidence.
185
185
  - `state validate` rejects a `true` gate whose evidence is missing, stale, expired, superseded, or bound to another work item; evidence records are schema-validated in full (`command`, `result`, `criteriaHash`, and a freshness bound).
186
186
  - Evidence must include a UUID, work item, phase, gate, status, command/result, input-tree hash, criteria hash, relevant files, timestamp, and either `expiresAt` or `freshnessPolicy`.
187
+ - **Evidence history does not live in `state.json`.** A v4 document keeps only the active work item's records inline; a closed work item's evidence is written into the commit that closes it, as `Cadet-*` trailers, and archived to `.cadet/archive/`. `evidenceCoverage` indexes what left, so the "a done story owns evidence" check still works offline. Cadet still never commits: `state seal` prepares a message file and you commit with `git commit -F`.
187
188
  - Command output counts against the output budget; a configured cost budget cannot be satisfied by unmeasurable cost (the run is blocked, `budget-blocked`).
188
189
  - State and run ledgers are written atomically, so an interrupted write cannot truncate a record; persisted artifacts are redacted before hashing or writing.
189
190
  - Empty freshness coverage is an explicit policy decision: set `allowEmptyFreshness: true` in `.cadet/harness.json` only when unscoped evidence is acceptable.
190
191
 
191
192
  ```bash
192
- cadet-agent state validate # validate state against the schema
193
- cadet-agent state migrate # atomically upgrade v1 → v2 (no writes if it fails)
193
+ cadet-agent state validate # validate state against the schema (read-only)
194
+ cadet-agent state validate --verify-sealed # also read evidence out of commit trailers
195
+ cadet-agent state migrate # atomically upgrade v1 → the current version
196
+ cadet-agent state migrate --to 4 # archive closed work items' evidence; build the index
197
+ cadet-agent state compact --keep active # routine housekeeping on a v4 state
198
+ cadet-agent state seal # write the active work item's evidence as commit trailers
194
199
  cadet-agent state transition --to review # enforce the matrix + evidence
195
200
  cadet-agent harness verify --gate testsPassed --files src/a.cs # bounded, classified loop
196
201
  cadet-agent harness report # budget consumption and failures (no secrets)
package/package.json CHANGED
@@ -1,37 +1,37 @@
1
- {
2
- "name": "cadet-agent",
3
- "version": "0.41.0",
4
- "description": "Cross-IDE agent framework for Unity/C# game-development — one-command install",
5
- "type": "module",
6
- "bin": {
7
- "cadet-agent": "bin/cli.mjs"
8
- },
9
- "scripts": {
10
- "test": "node --test test/*.test.mjs",
11
- "lint": "lychee --offline --include-fragments \"**/*.md\"",
12
- "verify": "npm test && npm run lint"
13
- },
14
- "files": [
15
- "bin/",
16
- "src/"
17
- ],
18
- "keywords": [
19
- "cadet",
20
- "cadet-agent",
21
- "unity",
22
- "game-development",
23
- "ai-agent",
24
- "copilot",
25
- "cursor",
26
- "claude-code"
27
- ],
28
- "license": "CC-BY-4.0",
29
- "repository": {
30
- "type": "git",
31
- "url": "git+https://github.com/naishtech/cadet-agent.git"
32
- },
33
- "homepage": "https://github.com/naishtech/cadet-agent#readme",
34
- "engines": {
35
- "node": ">=18.0.0"
36
- }
37
- }
1
+ {
2
+ "name": "cadet-agent",
3
+ "version": "0.43.0",
4
+ "description": "Cross-IDE agent framework for Unity/C# game-development — one-command install",
5
+ "type": "module",
6
+ "bin": {
7
+ "cadet-agent": "bin/cli.mjs"
8
+ },
9
+ "scripts": {
10
+ "test": "node --test test/*.test.mjs",
11
+ "lint": "lychee --offline --include-fragments \"**/*.md\"",
12
+ "verify": "npm test && npm run lint"
13
+ },
14
+ "files": [
15
+ "bin/",
16
+ "src/"
17
+ ],
18
+ "keywords": [
19
+ "cadet",
20
+ "cadet-agent",
21
+ "unity",
22
+ "game-development",
23
+ "ai-agent",
24
+ "copilot",
25
+ "cursor",
26
+ "claude-code"
27
+ ],
28
+ "license": "CC-BY-4.0",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/naishtech/cadet-agent.git"
32
+ },
33
+ "homepage": "https://github.com/naishtech/cadet-agent#readme",
34
+ "engines": {
35
+ "node": ">=18.0.0"
36
+ }
37
+ }
package/src/cli.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { readFileSync, writeFileSync } from 'node:fs';
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, copyFileSync } from 'node:fs';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { dirname, join, resolve } from 'node:path';
4
4
  import { install, sync } from './install.mjs';
@@ -11,6 +11,8 @@ import {
11
11
  createEvidence, newId, computeInputTreeHash, hashCriteria,
12
12
  collectDeclaredTestNames, reconcileTestNames,
13
13
  resolveCommand, describeCommand, describeAllCommands, checkUnattendedRequirements, COMMANDS,
14
+ STATE_VERSION, sealedEvidence, recordEvidence, appendEvidence, sealWorkItem, toStateV4,
15
+ isHistoryExternal, HISTORY_ENTRIES_KEPT,
14
16
  } from './harness/index.mjs';
15
17
 
16
18
  const __filename = fileURLToPath(import.meta.url);
@@ -38,8 +40,12 @@ function showHelp() {
38
40
  npx cadet-agent@latest sync Update framework, preserving local policies/plans
39
41
  npx cadet-agent@latest sync --target <dir> Sync a specific directory
40
42
 
41
- cadet-agent state validate Validate .cadet/state.json against the v2 schema
42
- cadet-agent state migrate Atomically migrate v1 state to v2 (backup on write)
43
+ cadet-agent state validate Validate .cadet/state.json against the schema
44
+ cadet-agent state validate --verify-sealed Also read sealed evidence from commit trailers
45
+ cadet-agent state migrate Atomically migrate state to the current version (backup on write)
46
+ cadet-agent state migrate --to 4 Compact: archive closed work items' evidence, build the index
47
+ cadet-agent state compact --keep <bound> Move closed work items' evidence into .cadet/archive/
48
+ cadet-agent state seal Write the active work item's evidence as commit trailers
43
49
  cadet-agent state transition --to <phase> Enforce the transition matrix + evidence
44
50
  cadet-agent state transition --to <phase> --dry-run Check only; writes nothing
45
51
 
@@ -70,6 +76,9 @@ function showHelp() {
70
76
  --inventory Newline-separated test names, when no report is available (harness matrix-check)
71
77
  --agents-md keep|overwrite|merge for an existing AGENTS.md (init/sync)
72
78
  --older-than-ms Age bound, in ms, for records cleanup may delete (harness cleanup; required)
79
+ --keep always|active|<work-item ids> for what stays in state.json (state compact; required)
80
+ --commit-msg Path to write the prepared commit message to (state seal)
81
+ --verify-sealed Also verify evidence sealed in commit trailers (state validate)
73
82
  --dry-run Report what a mutating command would do and write nothing (all mutating commands)
74
83
  --yes, -y Never prompt; keep existing files (non-interactive installs)
75
84
  --help, -h Show this help (valid at any depth; never writes)
@@ -155,6 +164,14 @@ function parseArgs(argv) {
155
164
  case '--strict-orphans': opts.strictOrphans = true; break;
156
165
  case '--dry-run': opts.dryRun = true; break;
157
166
  case '--older-than-ms': opts.olderThanMs = Number(value(a)); break;
167
+ // state compact: the bound on what may leave state.json. Content-bearing
168
+ // rather than a confirmation flag, so an unattended agent must state which
169
+ // work items it is keeping inline.
170
+ case '--keep': opts.keep = value(a); break;
171
+ // state seal: where the prepared commit message goes.
172
+ case '--commit-msg': opts.commitMsgPath = value(a); break;
173
+ // state validate: read commit trailers too, not just the live document.
174
+ case '--verify-sealed': opts.verifySealed = true; break;
158
175
  case '--agents-md': opts.agentsMd = value(a); break;
159
176
  case '--yes': case '-y': opts.yes = true; break;
160
177
  default: opts.rest.push(a);
@@ -203,6 +220,101 @@ function fail(opts, message, code = json => json.exitCode || 1, json = {}) {
203
220
  process.exit(exitCode);
204
221
  }
205
222
 
223
+ // ── evidence archive (contract v5) ──────────────────────────────────────────
224
+
225
+ /**
226
+ * `.cadet/archive/evidence/` — the append-only home for evidence that has left
227
+ * `state.json`.
228
+ *
229
+ * A directory of per-work-item JSONL files rather than one file, because a single
230
+ * log for a whole repository would be rewritten on every append by anything that
231
+ * wanted to dedupe it, and an audit trail should not be rewritten. One file per
232
+ * work item also means the common read — "what evidence did this story have?" —
233
+ * touches one small file instead of parsing everything.
234
+ */
235
+ function evidenceArchiveDir(targetDir) {
236
+ return join(targetDir, '.cadet', 'archive', 'evidence');
237
+ }
238
+
239
+ /** A filesystem-safe file name for a work item id, which contains `::`. */
240
+ function archiveFileName(workItemId) {
241
+ const safe = String(workItemId).replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 120);
242
+ return `${safe || 'unscoped'}.jsonl`;
243
+ }
244
+
245
+ /** Every evidenceId already archived, so a repeated compaction is idempotent. */
246
+ function readArchivedIds(targetDir) {
247
+ const dir = evidenceArchiveDir(targetDir);
248
+ const ids = new Set();
249
+ if (!existsSync(dir)) return ids;
250
+ for (const file of readdirSync(dir)) {
251
+ if (!file.endsWith('.jsonl')) continue;
252
+ let text;
253
+ try { text = readFileSync(join(dir, file), 'utf-8'); } catch { continue; }
254
+ for (const line of text.split(/\r?\n/)) {
255
+ if (!line.trim()) continue;
256
+ try {
257
+ const record = JSON.parse(line);
258
+ if (record?.evidenceId) ids.add(record.evidenceId);
259
+ } catch {
260
+ // A malformed line is skipped rather than fatal: this is an append-only
261
+ // log, and refusing to read the rest of it because of one bad line would
262
+ // make the archive less durable than the file it replaced.
263
+ }
264
+ }
265
+ }
266
+ return ids;
267
+ }
268
+
269
+ /**
270
+ * Append records to the archive, one JSON object per line, grouped by work item.
271
+ *
272
+ * Records whose `evidenceId` is already archived are skipped, so re-running a
273
+ * compaction after a partial failure cannot duplicate history — which matters
274
+ * because this log is the only remaining copy of the records it holds.
275
+ */
276
+ function appendEvidenceArchive(targetDir, records) {
277
+ const known = readArchivedIds(targetDir);
278
+ const byFile = new Map();
279
+ let skipped = 0;
280
+ for (const record of records) {
281
+ if (!record || typeof record !== 'object') continue;
282
+ if (record.evidenceId && known.has(record.evidenceId)) { skipped += 1; continue; }
283
+ const name = archiveFileName(record.workItemId || 'unscoped');
284
+ if (!byFile.has(name)) byFile.set(name, []);
285
+ byFile.get(name).push(JSON.stringify(record));
286
+ }
287
+ if (byFile.size === 0) return { files: [], appended: 0, skipped };
288
+ const dir = evidenceArchiveDir(targetDir);
289
+ mkdirSync(dir, { recursive: true });
290
+ const files = [];
291
+ let appended = 0;
292
+ for (const [name, lines] of byFile) {
293
+ const path = join(dir, name);
294
+ writeFileSync(path, `${lines.join('\n')}\n`, { flag: 'a', encoding: 'utf-8' });
295
+ files.push(path);
296
+ appended += lines.length;
297
+ }
298
+ return { files, appended, skipped };
299
+ }
300
+
301
+ /**
302
+ * Append change-log entries that no longer fit inline to `.cadet/archive/history.jsonl`.
303
+ *
304
+ * `changeHistory` is not retired — eight skills write artifact paths into it and
305
+ * `Resume` reads its tail — so compaction bounds it rather than dropping it. The
306
+ * overflow goes here in full, one entry per line, so bounding the document never
307
+ * destroys the audit trail it used to hold.
308
+ */
309
+ function appendHistoryArchive(targetDir, entries) {
310
+ if (!Array.isArray(entries) || entries.length === 0) return { path: null, appended: 0 };
311
+ const dir = join(targetDir, '.cadet', 'archive');
312
+ mkdirSync(dir, { recursive: true });
313
+ const path = join(dir, 'history.jsonl');
314
+ writeFileSync(path, `${entries.map((e) => JSON.stringify(e)).join('\n')}\n`, { flag: 'a', encoding: 'utf-8' });
315
+ return { path, appended: entries.length };
316
+ }
317
+
206
318
  // ── state commands ──────────────────────────────────────────────────────────
207
319
 
208
320
  async function cmdState(opts) {
@@ -232,8 +344,64 @@ async function cmdState(opts) {
232
344
  const result = validateState(state, { rootDir: opts.targetDir, strictClosure: policy.strictClosure });
233
345
  const role = detectRepoRole(opts.targetDir);
234
346
  const repoRoleDetail = describeRepoRole(role);
347
+
348
+ // `--verify-sealed` consults git for evidence sealed into commit trailers,
349
+ // which is where a closed work item's records live from v4 on.
350
+ //
351
+ // It is additive by construction: it can only clear an error that a real
352
+ // sealed record backs, never raise a new one. That direction is deliberate —
353
+ // a check that could fail because git was unavailable would make the
354
+ // read-only validation command depend on the environment it is auditing.
355
+ let sealed = null;
356
+ if (opts.verifySealed) {
357
+ const probe = sealedEvidence(opts.targetDir, { workItemId: workItemIdOf(state) });
358
+ if (!probe.available) {
359
+ // Never a silent pass: "not verified" and "verified clean" must not look
360
+ // the same, matching how a missing rootDir is reported for freshness.
361
+ result.warnings.push({
362
+ path: 'sealedEvidence',
363
+ message: `sealed evidence was not verified: ${probe.reason}. Live evidence only; a gate satisfied by a sealed record will be reported as unbacked.`,
364
+ });
365
+ } else {
366
+ const latestFor = (gate) => probe.records
367
+ .filter((r) => r.gate === gate && (r.status === 'passed' || r.status === 'manual-confirmation') && !r.partial)
368
+ .reduce((a, b) => {
369
+ if (!a) return b;
370
+ return (Date.parse(a.createdAt ?? '') || 0) >= (Date.parse(b.createdAt ?? '') || 0) ? a : b;
371
+ }, null);
372
+ const resolvedBySeal = [];
373
+ const unresolved = [];
374
+ for (const err of result.errors) {
375
+ const match = /^gates\.([A-Za-z]+)$/.exec(String(err.path));
376
+ const record = match ? latestFor(match[1]) : null;
377
+ if (record) {
378
+ resolvedBySeal.push({ gate: match[1], sealedCommit: record.sealedCommit });
379
+ continue;
380
+ }
381
+ unresolved.push(err);
382
+ }
383
+ result.errors = unresolved;
384
+ result.valid = unresolved.length === 0;
385
+ sealed = {
386
+ available: true,
387
+ records: probe.records.length,
388
+ gates: [...new Set(probe.records.map((r) => r.gate))].filter(Boolean).sort(),
389
+ resolvedBySeal,
390
+ diagnostics: probe.diagnostics,
391
+ };
392
+ }
393
+ }
394
+
235
395
  if (opts.format === 'json') {
236
- emit(opts, '', { ok: result.valid, valid: result.valid, errors: result.errors, warnings: result.warnings, repoRole: role.role, repoRoleDetail });
396
+ emit(opts, '', {
397
+ ok: result.valid,
398
+ valid: result.valid,
399
+ errors: result.errors,
400
+ warnings: result.warnings,
401
+ repoRole: role.role,
402
+ repoRoleDetail,
403
+ ...(sealed ? { sealed } : {}),
404
+ });
237
405
  } else {
238
406
  if (result.valid) console.log(`✅ state.json is valid (v${state.version}).`);
239
407
  else {
@@ -241,6 +409,12 @@ async function cmdState(opts) {
241
409
  for (const e of result.errors) console.error(` ${e.path}: ${e.message}`);
242
410
  }
243
411
  for (const w of result.warnings) console.log(` ⚠️ ${w.path}: ${w.message}`);
412
+ if (sealed) {
413
+ if (sealed.available) {
414
+ console.log(` Sealed evidence: ${sealed.records} record(s) in commit trailers; gates: ${sealed.gates.join(', ') || '(none)'}`);
415
+ for (const r of sealed.resolvedBySeal) console.log(` ✅ ${r.gate} backed by sealed record in ${String(r.sealedCommit).slice(0, 8)}`);
416
+ }
417
+ }
244
418
  console.log(` Repo role: ${role.role} — ${repoRoleDetail}`);
245
419
  }
246
420
  if (!result.valid) process.exit(1);
@@ -248,14 +422,122 @@ async function cmdState(opts) {
248
422
  }
249
423
 
250
424
  if (sub === 'migrate') {
251
- const result = migrateStateFile(statePath, { backup: true });
425
+ // The archive is written through `beforeWrite`, which runs after the migrated
426
+ // document validates but before the backup and the rename — so a migration
427
+ // that would fail writes nothing at all, while a crash after the archive
428
+ // leaves records in both places rather than neither.
429
+ const archivePaths = [];
430
+ let historyArchived = 0;
431
+ const result = migrateStateFile(statePath, {
432
+ backup: true,
433
+ to: opts.to ?? null,
434
+ keep: opts.keep ?? 'active',
435
+ beforeWrite: ({ archived, archivedHistory }) => {
436
+ const written = appendEvidenceArchive(opts.targetDir, archived);
437
+ archivePaths.push(...written.files);
438
+ const history = appendHistoryArchive(opts.targetDir, archivedHistory);
439
+ if (history.path) archivePaths.push(history.path);
440
+ historyArchived = history.appended;
441
+ },
442
+ });
443
+ const detail = {
444
+ ok: true,
445
+ migrated: result.migrated,
446
+ statePath: result.statePath,
447
+ version: STATE_VERSION,
448
+ archived: result.archived.length,
449
+ archivedHistory: historyArchived,
450
+ promotedExceptions: result.promoted,
451
+ droppedHistoryEntries: result.droppedHistory,
452
+ archivePaths,
453
+ backupPath: result.backupPath ?? null,
454
+ };
252
455
  if (opts.format === 'json') {
253
- emit(opts, '', { ok: true, migrated: result.migrated, statePath: result.statePath });
456
+ emit(opts, '', detail);
254
457
  } else if (result.migrated) {
255
- console.log(`✅ Migrated ${statePath} to v2 (backup: ${statePath}.v1.bak).`);
458
+ console.log(`✅ Migrated ${statePath} to v${STATE_VERSION} (backup: ${result.backupPath}).`);
459
+ if (result.archived.length) {
460
+ console.log(` Archived ${result.archived.length} evidence record(s) to ${evidenceArchiveDir(opts.targetDir)}`);
461
+ }
462
+ if (historyArchived) console.log(` Archived ${historyArchived} change-log entr(ies) to .cadet/archive/history.jsonl (the last ${HISTORY_ENTRIES_KEPT} stay inline).`);
463
+ if (result.promoted) console.log(` Promoted ${result.promoted} gate exception(s) out of changeHistory.`);
256
464
  } else {
257
- console.log('✅ state.json is already v2 — nothing to migrate.');
465
+ console.log(`✅ state.json is already v${STATE_VERSION} — nothing to migrate.`);
466
+ }
467
+ return;
468
+ }
469
+
470
+ if (sub === 'compact') {
471
+ const { exists, state } = readState(opts.targetDir);
472
+ if (!exists) fail(opts, 'No .cadet/state.json found.', () => 2);
473
+ // Compaction is the v4 shape, so a v2/v3 document needs the explicit version
474
+ // migration first. Doing it implicitly here would hide a version change
475
+ // inside what a caller thinks is housekeeping.
476
+ if (!isHistoryExternal(state)) {
477
+ fail(opts, `state.json is v${state.version ?? state.stateVersion}; compaction requires v${STATE_VERSION}. Run "cadet-agent state migrate --to ${STATE_VERSION}" first.`, () => 1, { ok: false, code: 'compact-requires-v4' });
258
478
  }
479
+ const keep = parseKeepBound(opts.keep);
480
+ const { state: next, archived, archivedHistory } = toStateV4(state, { keep });
481
+ const written = appendEvidenceArchive(opts.targetDir, archived);
482
+ const history = appendHistoryArchive(opts.targetDir, archivedHistory);
483
+ const changed = archived.length > 0 || archivedHistory.length > 0;
484
+ if (changed) writeState(opts.targetDir, next);
485
+ emit(
486
+ opts,
487
+ changed
488
+ ? `✅ Compacted state.json: archived ${archived.length} evidence record(s), ${history.appended} change-log entr(ies); kept ${next.gateEvidence.length} record(s) and ${(next.changeHistory || []).length} entr(ies) inline.\n Archive: ${join(opts.targetDir, '.cadet', 'archive')}`
489
+ : '✅ Nothing to compact: every evidence record and change-log entry is already kept inline.',
490
+ {
491
+ ok: true,
492
+ archived: archived.length,
493
+ archivedHistory: history.appended,
494
+ kept: next.gateEvidence.length,
495
+ appended: written.appended,
496
+ skipped: written.skipped,
497
+ archivePaths: [...written.files, ...(history.path ? [history.path] : [])],
498
+ coverageRows: Object.keys(next.evidenceCoverage || {}).length,
499
+ },
500
+ );
501
+ return;
502
+ }
503
+
504
+ if (sub === 'seal') {
505
+ const { exists, state } = readState(opts.targetDir);
506
+ if (!exists) fail(opts, 'No .cadet/state.json found.', () => 2);
507
+ const policy = loadPolicy(opts.targetDir);
508
+ const { workItemId, records, lines, partial } = sealWorkItem(state, {
509
+ workItemId: opts.workItemId || null,
510
+ maxBytes: policy.output?.maxInlineBytes,
511
+ });
512
+ if (records.length === 0) {
513
+ fail(opts, `no inline evidence to seal for ${workItemId || '(no active work item)'}. Evidence for a closed work item lives in its commit and .cadet/archive/.`, () => 1, { ok: false, code: 'nothing-to-seal', workItemId });
514
+ }
515
+ // The message file is what makes this compatible with C5: Cadet prepares the
516
+ // message, and the commit is still the user's action.
517
+ const messagePath = opts.commitMsgPath || join(opts.targetDir, '.cadet', 'seal.commit-msg');
518
+ const header = [
519
+ `chore(gates): seal evidence for ${workItemId}`,
520
+ '',
521
+ 'Evidence for this work item, written here so it travels with the code.',
522
+ 'Edit the subject line to describe the change; keep the trailer block intact —',
523
+ 'the records are read back out of it, and altering one changes the commit id.',
524
+ '',
525
+ ].join('\n');
526
+ writeFileSync(messagePath, `${header}${lines.join('\n')}`, 'utf-8');
527
+ const archived = appendEvidenceArchive(opts.targetDir, records);
528
+ const { state: next } = toStateV4(state, { keep: opts.keep ?? 'active' });
529
+ writeState(opts.targetDir, next);
530
+ emit(
531
+ opts,
532
+ [
533
+ `✅ Prepared ${records.length} evidence record(s) for commit.`,
534
+ ` Message: ${messagePath}`,
535
+ ` Commit: git commit -F "${messagePath}"`,
536
+ archived.appended ? ` Archived: ${archived.appended} record(s) to ${evidenceArchiveDir(opts.targetDir)}` : ' Archive: already up to date',
537
+ partial.length ? ` ⚠️ ${partial.length} record(s) exceeded the trailer bound and were marked partial (they cannot satisfy a gate).` : null,
538
+ ].filter(Boolean).join('\n'),
539
+ { ok: true, workItemId, records: records.length, messagePath, archived: archived.appended, partial },
540
+ );
259
541
  return;
260
542
  }
261
543
 
@@ -303,7 +585,18 @@ async function cmdState(opts) {
303
585
  return;
304
586
  }
305
587
 
306
- fail(opts, `Unknown state subcommand: ${sub || '(none)'}. Use validate|migrate|transition.`);
588
+ fail(opts, `Unknown state subcommand: ${sub || '(none)'}. Use validate|migrate|compact|seal|transition.`);
589
+ }
590
+
591
+ /**
592
+ * Parse the `--keep` bound for `state compact`: `always`, `active`, or a
593
+ * comma-separated list of work-item ids. Returns what `toStateV4` expects.
594
+ */
595
+ function parseKeepBound(raw) {
596
+ const text = String(raw ?? '').trim();
597
+ if (text === 'always') return 'always';
598
+ if (text === 'active' || text === '') return 'active';
599
+ return text.split(',').map((s) => s.trim()).filter(Boolean);
307
600
  }
308
601
 
309
602
  // ── harness commands ────────────────────────────────────────────────────────
@@ -453,19 +746,15 @@ async function cmdHarness(opts) {
453
746
  ledger.finalize({ status: 'ok' });
454
747
  const ledgerPath = ledger.persist();
455
748
 
456
- const next = { ...state };
749
+ // Supersede-and-append plus the coverage index live in `recordEvidence`, so
750
+ // this path and `harness verify` cannot disagree about either. Before that
751
+ // helper the two commands built the array separately, which is how an index
752
+ // would have ended up maintained by one of them and not the other.
457
753
  const prior = Array.isArray(state.gateEvidence) ? state.gateEvidence : [];
458
- next.gateEvidence = [
459
- // Immutability: supersede prior passing evidence, never delete it.
460
- ...prior.map((e) => (e.gate === gate && (e.status === 'passed' || e.status === 'manual-confirmation')
461
- ? { ...e, status: 'superseded', supersededBy: evidence.evidenceId }
462
- : e)),
463
- evidence,
464
- ];
465
- next.gates = { ...(state.gates || {}), [gate]: true };
754
+ const superseded = prior.filter((e) => e.gate === gate && (e.status === 'passed' || e.status === 'manual-confirmation')).length;
755
+ const next = recordEvidence(state, evidence);
466
756
  writeState(opts.targetDir, next);
467
757
 
468
- const superseded = prior.filter((e) => e.gate === gate && (e.status === 'passed' || e.status === 'manual-confirmation')).length;
469
758
  emit(
470
759
  opts,
471
760
  `✅ Recorded manual confirmation for gate "${gate}". Evidence: ${evidence.evidenceId}\n Ledger: ${ledgerPath}`,
@@ -610,18 +899,20 @@ async function cmdHarness(opts) {
610
899
  // only when it is evidence-backed; a failing one records the attempt.
611
900
  let stateUpdated = false;
612
901
  if (state) {
613
- const next = { ...state };
614
- next.gateEvidence = [...(Array.isArray(state.gateEvidence) ? state.gateEvidence : []), ...result.attempts.map((a) => a.evidence)];
615
- if (Array.isArray(next.gateEvidence)) {
616
- // Mark prior evidence for this gate as superseded by the new record.
617
- const newest = result.finalEvidence?.evidenceId;
618
- next.gateEvidence = next.gateEvidence.map((e) =>
619
- e.gate === gate && e.evidenceId !== newest && e.status === 'passed' && result.ok
620
- ? { ...e, status: 'superseded', supersededBy: newest }
621
- : e);
622
- }
623
- if (result.ok) {
624
- next.gates = { ...(state.gates || {}), [gate]: true };
902
+ // The failed attempts from this run are appended first, then the passing
903
+ // record supersedes prior passing evidence for the gate. Red-before-green is
904
+ // why the attempts are not filtered out on success: the red record is the
905
+ // thing that made the green one permissible, and dropping it would leave a
906
+ // green gate whose justification no longer exists.
907
+ const attempts = result.attempts.map((a) => a.evidence);
908
+ let next = state;
909
+ if (result.ok && result.finalEvidence) {
910
+ for (const ev of attempts) {
911
+ if (ev?.evidenceId !== result.finalEvidence.evidenceId) next = appendEvidence(next, ev);
912
+ }
913
+ next = recordEvidence(next, result.finalEvidence);
914
+ } else {
915
+ for (const ev of attempts) next = appendEvidence(next, ev);
625
916
  }
626
917
  writeState(opts.targetDir, next);
627
918
  stateUpdated = true;
@@ -799,15 +1090,7 @@ async function cmdHarness(opts) {
799
1090
  const ledgerPath = ledger.persist();
800
1091
 
801
1092
  if (exists) {
802
- const next = { ...state };
803
- const priorEv = Array.isArray(state.gateEvidence) ? state.gateEvidence : [];
804
- next.gateEvidence = [
805
- ...priorEv.map((e) => (e.gate === 'acceptanceCriteriaValidated' && (e.status === 'passed' || e.status === 'manual-confirmation')
806
- ? { ...e, status: 'superseded', supersededBy: evidence.evidenceId }
807
- : e)),
808
- evidence,
809
- ];
810
- next.gates = { ...(state.gates || {}), acceptanceCriteriaValidated: true };
1093
+ const next = recordEvidence(state, evidence);
811
1094
  writeState(opts.targetDir, next);
812
1095
  }
813
1096
 
@@ -37,12 +37,14 @@ export const COMMANDS = {
37
37
 
38
38
  'state validate': {
39
39
  mutates: false,
40
- summary: 'Validate .cadet/state.json against the schema.',
40
+ summary: 'Validate .cadet/state.json against the current schema.',
41
+ // `--verify-sealed` reads commit trailers. It is a read: verifying a seal
42
+ // must never repair one, so the flag cannot write.
41
43
  },
42
44
  'state migrate': {
43
45
  mutates: true,
44
- summary: 'Atomically migrate v1 state to the current version.',
45
- writes: ['.cadet/state.json', '.cadet/state.json.v1.bak'],
46
+ summary: 'Atomically migrate state to the current version (backup on write).',
47
+ writes: ['.cadet/state.json', '.cadet/state.json.v*.bak', '.cadet/archive/**'],
46
48
  unattended: false,
47
49
  // A failed migration must leave the tree exactly as it found it: no backup,
48
50
  // no partial write. The backup is an artifact of a *successful* migration,
@@ -52,6 +54,24 @@ export const COMMANDS = {
52
54
  // before validation — verified by reintroducing the original ordering.
53
55
  atomicFailure: true,
54
56
  },
57
+ 'state seal': {
58
+ mutates: true,
59
+ summary: 'Write a work item\'s evidence as commit trailers, for `git commit -F`.',
60
+ // Cadet does not commit (contract C5). Sealing prepares a message file and
61
+ // archives the records; the commit itself stays a user action.
62
+ writes: ['.cadet/archive/**', '*.commit-msg'],
63
+ unattended: true,
64
+ },
65
+ 'state compact': {
66
+ mutates: true,
67
+ summary: 'Move closed work items\' evidence out of state.json into .cadet/archive/.',
68
+ writes: ['.cadet/state.json', '.cadet/archive/**'],
69
+ // Irreversible in the sense that matters: records leave the document that
70
+ // every gate check reads. An unattended agent must say what to keep, so the
71
+ // bound is content-bearing rather than a bare confirmation.
72
+ unattended: false,
73
+ requiresForUnattended: ['--keep'],
74
+ },
55
75
  'state transition': {
56
76
  mutates: true,
57
77
  summary: 'Enforce the transition matrix and evidence; applies the transition.',
@@ -184,12 +204,17 @@ export function checkUnattendedRequirements(key, opts) {
184
204
  if (flag === '--older-than-ms') {
185
205
  return !Number.isFinite(opts.olderThanMs);
186
206
  }
207
+ if (flag === '--keep') {
208
+ // `always`, `active`, or a comma-separated work-item list. An empty value
209
+ // is not a bound, so it must fail the same way a missing flag does.
210
+ return !(typeof opts.keep === 'string' && opts.keep.trim() !== '');
211
+ }
187
212
  return true;
188
213
  });
189
214
  if (missing.length === 0) return { ok: true };
190
215
  return {
191
216
  ok: false,
192
217
  missing,
193
- reason: `"${key}" deletes records irreversibly, so it requires ${missing.join(', ')} when run unattended.`,
218
+ reason: `"${key}" removes records that gate checks read, so it requires ${missing.join(', ')} when run unattended.`,
194
219
  };
195
220
  }