cadet-agent 0.30.0 → 0.32.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,37 +1,37 @@
1
- {
2
- "name": "cadet-agent",
3
- "version": "0.30.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.32.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 } from 'node:fs';
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { install, sync } from './install.mjs';
@@ -6,7 +6,9 @@ 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,
9
+ detectRepoRole, describeRepoRole, GATES, manualConfirmation,
10
+ parseTestInventory, parseStoryCriteria, compareCoverage, describeCoverageGaps,
11
+ createEvidence, newId, computeInputTreeHash, hashCriteria,
10
12
  } from './harness/index.mjs';
11
13
 
12
14
  const __filename = fileURLToPath(import.meta.url);
@@ -39,7 +41,9 @@ function showHelp() {
39
41
  cadet-agent state transition --to <phase> Enforce the transition matrix + evidence
40
42
 
41
43
  cadet-agent harness record Append a sanitized span/evidence/decision event
44
+ cadet-agent harness confirm Record manual-confirmation evidence (writes ledger + state)
42
45
  cadet-agent harness verify Run a bounded, classified verification loop
46
+ cadet-agent harness verify-acs Verify declared AC↔test coverage against a test report
43
47
  cadet-agent harness report Summarize budget consumption and failures
44
48
  cadet-agent harness cleanup Apply the retention policy to .cadet/runs/
45
49
  cadet-agent harness capabilities Report available CLI/Unity/MCP/hook/token/cost telemetry
@@ -49,9 +53,13 @@ function showHelp() {
49
53
  --source Release API URL override (for forked deployments)
50
54
  --format human|json (default: human)
51
55
  --to Target phase (state transition)
52
- --gate Gate name (harness verify)
56
+ --gate Gate name (harness verify|confirm)
53
57
  --command Command override (harness verify)
54
- --files Comma-separated relevant files to bind evidence to (harness verify)
58
+ --files Comma-separated relevant files to bind evidence to (harness verify|confirm)
59
+ --reason Why automation was unavailable (harness confirm)
60
+ --expires-at ISO-8601 expiry bounding the confirmation (harness confirm)
61
+ --environment key=value,... describing what was verified (harness confirm)
62
+ --scope Comma-separated scope of the confirmation (harness confirm)
55
63
  --agents-md keep|overwrite|merge for an existing AGENTS.md (init/sync)
56
64
  --yes, -y Never prompt; keep existing files (non-interactive installs)
57
65
  --help, -h Show this help
@@ -77,8 +85,14 @@ function parseArgs(argv) {
77
85
  case '--run': opts.runId = argv[++i]; break;
78
86
  case '--type': opts.type = argv[++i]; break;
79
87
  case '--reason': opts.reason = argv[++i]; break;
88
+ case '--expires-at': opts.expiresAt = argv[++i]; break;
89
+ case '--environment': opts.environment = argv[++i]; break;
90
+ case '--scope': opts.scope = (argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean); break;
80
91
  case '--evidence-status': opts.evidenceStatus = argv[++i]; break;
81
92
  case '--files': opts.files = (argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean); break;
93
+ case '--story': opts.story = argv[++i]; break;
94
+ case '--report': opts.report = argv[++i]; break;
95
+ case '--write-coverage': opts.writeCoverage = true; break;
82
96
  case '--older-than-ms': opts.olderThanMs = Number(argv[++i]); break;
83
97
  case '--agents-md': opts.agentsMd = argv[++i]; break;
84
98
  case '--yes': case '-y': opts.yes = true; break;
@@ -96,6 +110,28 @@ function emit(opts, human, json) {
96
110
  }
97
111
  }
98
112
 
113
+ /**
114
+ * Parse `--environment "projectPath=...,editorVersion=...,tool=...,host=..."`
115
+ * into an object. Unknown keys are preserved: an unusual environment is still
116
+ * evidence, and silently dropping a field would misrepresent what was verified.
117
+ */
118
+ function parseEnvironment(raw) {
119
+ const env = {};
120
+ if (!raw) return env;
121
+ for (const part of String(raw).split(',')) {
122
+ const eq = part.indexOf('=');
123
+ if (eq === -1) {
124
+ const key = part.trim();
125
+ if (key) env[key] = true;
126
+ continue;
127
+ }
128
+ const key = part.slice(0, eq).trim();
129
+ const value = part.slice(eq + 1).trim();
130
+ if (key) env[key] = value;
131
+ }
132
+ return env;
133
+ }
134
+
99
135
  function fail(opts, message, code = json => json.exitCode || 1, json = {}) {
100
136
  const exitCode = code(json);
101
137
  if (opts.format === 'json') {
@@ -127,8 +163,12 @@ async function cmdState(opts) {
127
163
  );
128
164
  return;
129
165
  }
130
- // Pass rootDir so stale/foreign evidence is caught at validation time.
131
- const result = validateState(state, { rootDir: opts.targetDir });
166
+ // Pass rootDir so stale/foreign evidence is caught at validation time, and
167
+ // the resolved policy so strict-closure rules are actually enforced. Without
168
+ // the policy, `strictClosure` was invisible here and every strict rule was
169
+ // silently skipped — the feature would "install cleanly and do nothing".
170
+ const policy = loadPolicy(opts.targetDir);
171
+ const result = validateState(state, { rootDir: opts.targetDir, strictClosure: policy.strictClosure });
132
172
  const role = detectRepoRole(opts.targetDir);
133
173
  const repoRoleDetail = describeRepoRole(role);
134
174
  if (opts.format === 'json') {
@@ -212,6 +252,137 @@ async function cmdHarness(opts) {
212
252
  return;
213
253
  }
214
254
 
255
+ if (sub === 'confirm') {
256
+ const gate = opts.gate;
257
+ if (!gate) fail(opts, 'harness confirm requires --gate <gate>');
258
+ if (!GATES.includes(gate)) fail(opts, `unknown gate "${gate}". Valid gates: ${GATES.join(', ')}`);
259
+
260
+ const { exists, state } = readState(opts.targetDir);
261
+ if (!exists) fail(opts, 'No .cadet/state.json found. Initialise state before recording confirmation.', () => 2);
262
+
263
+ const strict = policy.strictClosure?.enabled === true ? policy.strictClosure : null;
264
+ const mc = strict?.manualConfirmation || null;
265
+ // One reference instant for the whole command, captured before any work.
266
+ // Reading Date.now() at the check instead made the validity boundary
267
+ // non-deterministic: process latency absorbed a small overage, so the same
268
+ // input could pass or fail run to run.
269
+ const requestedAt = new Date();
270
+
271
+ // Collect EVERY missing field so the caller fixes the record in one pass,
272
+ // rather than discovering one omission per invocation.
273
+ const missing = [];
274
+ if (mc?.requireReason !== false && strict && (!opts.reason || String(opts.reason).trim() === '')) missing.push('--reason');
275
+ if (mc?.requireExpiresAt !== false && strict) {
276
+ if (!opts.expiresAt) missing.push('--expires-at');
277
+ else if (Number.isNaN(Date.parse(opts.expiresAt))) missing.push('--expires-at (not an ISO-8601 date-time)');
278
+ }
279
+ if (mc?.requireEnvironment !== false && strict && (!opts.environment || String(opts.environment).trim() === '')) missing.push('--environment');
280
+ if (mc?.requireScope !== false && strict && (!opts.scope || opts.scope.length === 0)) missing.push('--scope');
281
+ if (missing.length) {
282
+ fail(opts, `strictClosure requires manual-confirmation metadata. Missing: ${missing.join(', ')}.`, () => 1, { ok: false, gate, code: 'strict-metadata-missing', missing });
283
+ }
284
+
285
+ // A gate listed in disallowManualFor may never be satisfied by a human
286
+ // assertion; point at the automated path instead of accepting the record.
287
+ if (strict && Array.isArray(strict.disallowManualFor) && strict.disallowManualFor.includes(gate)) {
288
+ fail(opts, `manual-confirmation is not permitted for gate "${gate}" under strictClosure.disallowManualFor; run "cadet-agent harness verify --gate ${gate}" instead.`, () => 1, { ok: false, gate, code: 'manual-disallowed' });
289
+ }
290
+
291
+ // Bound the validity window: an expiry far in the future is how a manual
292
+ // assertion silently becomes permanent. Measured against `requestedAt`, the
293
+ // single instant captured at command start, so the boundary is deterministic
294
+ // and agrees with `validateState` (which anchors to the present too).
295
+ if (mc?.maxValidityMs !== null && mc?.maxValidityMs !== undefined && opts.expiresAt) {
296
+ const window = Date.parse(opts.expiresAt) - requestedAt.getTime();
297
+ if (Number.isFinite(window) && window > mc.maxValidityMs) {
298
+ fail(opts, `requested validity ${window}ms exceeds strictClosure.manualConfirmation.maxValidityMs (${mc.maxValidityMs}ms).`, () => 1, { ok: false, gate, code: 'validity-exceeded', requestedMs: window, maxValidityMs: mc.maxValidityMs });
299
+ }
300
+ }
301
+
302
+ // Freshness binding mirrors `harness verify`: never record a gate against an
303
+ // unknown input tree unless the repository explicitly opted out.
304
+ const allowEmpty = policy?.allowEmptyFreshness === true;
305
+ let relevantFiles;
306
+ if (opts.files && opts.files.length) {
307
+ relevantFiles = opts.files.map((f) => f.replace(/\\/g, '/'));
308
+ } else {
309
+ const changed = gitChangedFiles(opts.targetDir);
310
+ if (!changed.available) {
311
+ if (!allowEmpty) {
312
+ fail(opts, `cannot establish freshness coverage: ${changed.reason}. Pass --files <paths>, or enable allowEmptyFreshness in .cadet/harness.json.`, () => 1, { ok: false, gate, code: 'freshness-unavailable' });
313
+ }
314
+ relevantFiles = [];
315
+ } else {
316
+ relevantFiles = changed.files;
317
+ }
318
+ }
319
+
320
+ const workItemId = state ? workItemIdOf(state) : 'unscoped';
321
+ const phase = state?.session?.currentPhase || 'implementation';
322
+ // Reuse the command-start instant so the recorded createdAt and the validity
323
+ // check describe the same moment.
324
+ const at = requestedAt;
325
+ const environment = parseEnvironment(opts.environment);
326
+
327
+ const { evidence } = manualConfirmation({
328
+ gate,
329
+ workItemId,
330
+ phase,
331
+ projectPath: environment.projectPath || null,
332
+ editorVersion: environment.editorVersion || null,
333
+ scope: opts.scope || [],
334
+ reason: opts.reason || null,
335
+ expiresAt: opts.expiresAt || null,
336
+ environment,
337
+ relevantFiles,
338
+ rootDir: opts.targetDir,
339
+ approvedBy: opts.approvedBy || 'user',
340
+ at,
341
+ });
342
+
343
+ // Ledger first, then state. The ledger is append-only and merely references
344
+ // the evidence id; state.json carries the gate claim. Writing state first
345
+ // would let an interruption leave a gate claimed true with no ledger entry.
346
+ // This order fails toward "less proven", never "claimed but unbacked".
347
+ const ledger = new RunLedger({
348
+ targetDir: opts.targetDir,
349
+ policy,
350
+ runId: state?.activeRunId || null,
351
+ workItemId,
352
+ phase,
353
+ });
354
+ ledger.addEvidence(evidence);
355
+ ledger.addDecision({
356
+ kind: 'manual-confirmation',
357
+ reason: opts.reason || 'manual confirmation recorded',
358
+ gate,
359
+ evidenceId: evidence.evidenceId,
360
+ approvedBy: evidence.approvedBy || 'user',
361
+ });
362
+ ledger.finalize({ status: 'ok' });
363
+ const ledgerPath = ledger.persist();
364
+
365
+ const next = { ...state };
366
+ const prior = Array.isArray(state.gateEvidence) ? state.gateEvidence : [];
367
+ next.gateEvidence = [
368
+ // Immutability: supersede prior passing evidence, never delete it.
369
+ ...prior.map((e) => (e.gate === gate && (e.status === 'passed' || e.status === 'manual-confirmation')
370
+ ? { ...e, status: 'superseded', supersededBy: evidence.evidenceId }
371
+ : e)),
372
+ evidence,
373
+ ];
374
+ next.gates = { ...(state.gates || {}), [gate]: true };
375
+ writeState(opts.targetDir, next);
376
+
377
+ const superseded = prior.filter((e) => e.gate === gate && (e.status === 'passed' || e.status === 'manual-confirmation')).length;
378
+ emit(
379
+ opts,
380
+ `✅ Recorded manual confirmation for gate "${gate}". Evidence: ${evidence.evidenceId}\n Ledger: ${ledgerPath}`,
381
+ { ok: true, gate, evidenceId: evidence.evidenceId, runId: ledger.runId, path: ledgerPath, stateUpdated: true, superseded },
382
+ );
383
+ return;
384
+ }
385
+
215
386
  if (sub === 'record') {
216
387
  const { state } = readState(opts.targetDir);
217
388
  const ledger = new RunLedger({
@@ -372,6 +543,164 @@ async function cmdHarness(opts) {
372
543
  return;
373
544
  }
374
545
 
546
+ if (sub === 'verify-acs') {
547
+ // Mechanical AC↔test verification (contract v4). Declared tests must appear
548
+ // in the inventory of a run that actually executed them; a name that was
549
+ // never written cannot be asserted into coverage.
550
+ if (!opts.story) fail(opts, 'harness verify-acs requires --story <path>');
551
+ const { exists, state } = readState(opts.targetDir);
552
+ const strict = policy.strictClosure?.enabled === true;
553
+ const workItemId = state ? workItemIdOf(state) : 'unscoped';
554
+ const phase = state?.session?.currentPhase || 'implementation';
555
+
556
+ let criteria;
557
+ try {
558
+ ({ criteria } = parseStoryCriteria(opts.story));
559
+ } catch (err) {
560
+ fail(opts, `cannot parse story "${opts.story}": ${err.message}`, () => 1, { ok: false, code: 'story-parse', story: opts.story });
561
+ }
562
+ if (criteria.length === 0) {
563
+ fail(opts, `story "${opts.story}" declares no acceptance criteria (expected a "## Acceptance Criteria" section).`, () => 1, { ok: false, code: 'no-criteria', story: opts.story });
564
+ }
565
+
566
+ // Resolve the inventory: an explicit --report, else the artifact of the most
567
+ // recent passing testsPassed evidence. Neither resolving is `blocked`, never
568
+ // a pass — an unproven inventory cannot satisfy coverage.
569
+ let reportText = null;
570
+ let reportSource = null;
571
+ let reportPath = null;
572
+ if (opts.report) {
573
+ try {
574
+ reportText = readFileSync(opts.report, 'utf-8');
575
+ reportSource = 'explicit';
576
+ reportPath = opts.report;
577
+ } catch (err) {
578
+ fail(opts, `cannot read --report "${opts.report}": ${err.message}`, () => 1, { ok: false, code: 'report-unreadable', report: opts.report });
579
+ }
580
+ } else if (exists) {
581
+ const prior = Array.isArray(state.gateEvidence) ? state.gateEvidence : [];
582
+ const passing = prior
583
+ .filter((e) => e.gate === 'testsPassed' && e.status === 'passed' && e.artifactPath)
584
+ .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
585
+ const newest = passing[0];
586
+ if (newest) {
587
+ try {
588
+ reportText = readFileSync(newest.artifactPath, 'utf-8');
589
+ reportSource = 'testsPassed-evidence';
590
+ reportPath = newest.artifactPath;
591
+ } catch { /* fall through to blocked */ }
592
+ }
593
+ }
594
+ if (reportText === null) {
595
+ const detail = { ok: false, story: opts.story, blocked: true, code: 'no-test-report', reason: 'no test report available: pass --report <path>, or run `cadet-agent harness verify --gate testsPassed` first so its artifact can be read.' };
596
+ if (opts.format === 'json') emit(opts, '', detail);
597
+ else console.error(`❌ ${detail.reason}`);
598
+ process.exit(1);
599
+ }
600
+
601
+ const inventory = parseTestInventory(reportText);
602
+ const coverage = compareCoverage(criteria, inventory);
603
+ const gaps = describeCoverageGaps(coverage);
604
+
605
+ // Under strict closure an unknown/empty inventory can never prove coverage,
606
+ // even if every AC declared no tests in a way that looked consistent.
607
+ const unknownInventory = inventory.format === 'unknown' || inventory.names.length === 0;
608
+ const effectiveOk = coverage.ok && !unknownInventory;
609
+
610
+ if (!strict) {
611
+ // v2/v3 parity: report, write nothing, exit 0.
612
+ if (opts.format === 'json') {
613
+ emit(opts, '', { ok: effectiveOk, story: opts.story, ac: coverage.ac, inventorySize: coverage.inventorySize, format: inventory.format, gateSet: false, reportPath });
614
+ } else if (effectiveOk) {
615
+ console.log(`✅ AC coverage verified for ${opts.story} (${coverage.ac.length} criteria, ${coverage.inventorySize} tests in inventory).`);
616
+ console.log(' strictClosure is off — reported only, state.json unchanged.');
617
+ } else {
618
+ console.error(`⚠️ AC coverage gaps in ${opts.story} (strictClosure off — reported only):`);
619
+ if (unknownInventory) console.error(` no test inventory could be derived from ${reportPath || 'the report'} (format: ${inventory.format}).`);
620
+ for (const g of gaps) console.error(g);
621
+ }
622
+ if (!effectiveOk) process.exit(1);
623
+ return;
624
+ }
625
+
626
+ if (!effectiveOk) {
627
+ const detail = { ok: false, story: opts.story, ac: coverage.ac, inventorySize: coverage.inventorySize, format: inventory.format, gateSet: false, code: unknownInventory ? 'inventory-unknown' : 'coverage-gap' };
628
+ if (opts.format === 'json') emit(opts, '', detail);
629
+ else {
630
+ console.error(`❌ Cannot set acceptanceCriteriaValidated for ${opts.story}:`);
631
+ if (unknownInventory) console.error(` no test inventory could be derived from ${reportPath || 'the report'} (format: ${inventory.format}). An unparseable report proves nothing.`);
632
+ for (const g of gaps) console.error(g);
633
+ }
634
+ process.exit(1);
635
+ }
636
+
637
+ const at = new Date();
638
+ const criteriaStrings = coverage.ac.flatMap((a) => [a.id, ...a.declared]);
639
+ const nowIso = at.toISOString();
640
+ const evidence = createEvidence({
641
+ evidenceId: newId(),
642
+ workItemId,
643
+ acceptanceCriterionId: null,
644
+ phase,
645
+ gate: 'acceptanceCriteriaValidated',
646
+ status: 'passed',
647
+ command: `harness verify-acs --story ${opts.story}`,
648
+ result: `AC coverage verified: ${coverage.ac.length} criteria, inventory ${coverage.inventorySize} (${inventory.format})`,
649
+ exitCode: 0,
650
+ inputTreeHash: computeInputTreeHash(opts.targetDir, [opts.story, ...(reportPath ? [reportPath] : [])]),
651
+ criteriaHash: hashCriteria(criteriaStrings),
652
+ relevantFiles: [opts.story, ...(reportPath ? [reportPath] : [])].map((f) => f.replace(/\\/g, '/')),
653
+ createdAt: at,
654
+ expiresAt: null,
655
+ freshnessPolicy: 'current-story',
656
+ source: 'automated',
657
+ });
658
+
659
+ let coveragePath = null;
660
+ if (opts.writeCoverage) {
661
+ const base = opts.story.replace(/\.md$/, '');
662
+ coveragePath = `${base}.coverage.json`;
663
+ const doc = {
664
+ schemaVersion: 1,
665
+ story: opts.story.replace(/\\/g, '/'),
666
+ generatedAt: nowIso,
667
+ ac: coverage.ac,
668
+ inventorySize: coverage.inventorySize,
669
+ format: inventory.format,
670
+ };
671
+ try { writeFileSync(coveragePath, `${JSON.stringify(doc, null, 2)}\n`, 'utf-8'); } catch { coveragePath = null; }
672
+ }
673
+
674
+ // Ledger first, then state — the v3 ordering: fail toward "less proven".
675
+ const ledger = new RunLedger({ targetDir: opts.targetDir, policy, runId: state?.activeRunId || null, workItemId, phase });
676
+ ledger.addEvidence(evidence);
677
+ ledger.addDecision({ kind: 'stop', reason: `AC coverage verified via ${reportSource}`, scope: `${coverage.ac.length} criteria` });
678
+ ledger.finalize({ status: 'ok' });
679
+ const ledgerPath = ledger.persist();
680
+
681
+ if (exists) {
682
+ const next = { ...state };
683
+ const priorEv = Array.isArray(state.gateEvidence) ? state.gateEvidence : [];
684
+ next.gateEvidence = [
685
+ ...priorEv.map((e) => (e.gate === 'acceptanceCriteriaValidated' && (e.status === 'passed' || e.status === 'manual-confirmation')
686
+ ? { ...e, status: 'superseded', supersededBy: evidence.evidenceId }
687
+ : e)),
688
+ evidence,
689
+ ];
690
+ next.gates = { ...(state.gates || {}), acceptanceCriteriaValidated: true };
691
+ writeState(opts.targetDir, next);
692
+ }
693
+
694
+ if (opts.format === 'json') {
695
+ emit(opts, '', { ok: true, story: opts.story, ac: coverage.ac, inventorySize: coverage.inventorySize, format: inventory.format, gateSet: exists, coveragePath, evidenceId: evidence.evidenceId, runId: ledger.runId, path: ledgerPath });
696
+ } else {
697
+ console.log(`✅ acceptanceCriteriaValidated for ${opts.story} (${coverage.ac.length} criteria, inventory ${coverage.inventorySize}, ${inventory.format}).`);
698
+ console.log(` Ledger: ${ledgerPath}`);
699
+ if (coveragePath) console.log(` Coverage: ${coveragePath}`);
700
+ }
701
+ return;
702
+ }
703
+
375
704
  if (sub === 'report') {
376
705
  const runs = listRuns(opts.targetDir);
377
706
  const target = opts.runId || runs[0]?.runId;
@@ -391,7 +720,7 @@ async function cmdHarness(opts) {
391
720
  return;
392
721
  }
393
722
 
394
- fail(opts, `Unknown harness subcommand: ${sub || '(none)'}. Use record|verify|report|cleanup|capabilities.`);
723
+ fail(opts, `Unknown harness subcommand: ${sub || '(none)'}. Use record|confirm|verify|verify-acs|report|cleanup|capabilities.`);
395
724
  }
396
725
 
397
726
  export async function run(argv) {
@@ -8,7 +8,8 @@
8
8
  export {
9
9
  PHASES, GATES, TRANSITIONS, EVIDENCE_STATUSES, RETRY_CLASSES, CONTEXT_TIERS,
10
10
  DEFAULT_BUDGETS, HARD_CEILINGS, DEFAULT_ARCHIVE_LIMITS, DEFAULT_OUTPUT_POLICY,
11
- DEFAULT_RETENTION, DEFAULT_ESTIMATION, DEFAULT_HOOK_POLICY,
11
+ DEFAULT_RETENTION, DEFAULT_ESTIMATION, DEFAULT_HOOK_POLICY, DEFAULT_STRICT_CLOSURE,
12
+ EXCEPTION_CATEGORIES, EXCEPTION_EXPIRY_DAYS, EXCEPTION_REQUIRES_REVIEW_NOTE, AGENT_OWNED_GATES,
12
13
  validatePolicy, defaultPolicy, loadPolicy, budgetForScope, policyPath, PolicyError,
13
14
  } from './policy.mjs';
14
15
 
@@ -22,9 +23,9 @@ export {
22
23
  } from './util.mjs';
23
24
 
24
25
  export {
25
- STATE_VERSION, validateState, migrateStateV1toV2, migrateStateFile,
26
+ STATE_VERSION, READABLE_STATE_VERSIONS, validateState, migrateStateV1toV2, migrateStateFile,
26
27
  createEvidence, computeInputTreeHash, workItemIdOf, evidenceFreshness,
27
- latestEvidenceForGate, activeExceptions, requiredGates, evaluateTransition,
28
+ latestEvidenceForGate, activeExceptions, requiredGates, evaluateTransition, resolveStrict,
28
29
  applyTransition, resetGatesForNewWorkItem, statePathFor, readState, writeState, writeJsonAtomic, StateError,
29
30
  } from './state.mjs';
30
31
 
@@ -62,3 +63,9 @@ export {
62
63
  export {
63
64
  REPO_ROLES, REPO_ROLE_MARKER, detectRepoRole, isFrameworkSourceWithoutWorkItem, describeRepoRole,
64
65
  } from './repo-role.mjs';
66
+
67
+ export {
68
+ INVENTORY_FORMATS, COVERAGE_STATUSES, DEFAULT_MAX_REPORT_BYTES, DEFAULT_MAX_INVENTORY_ENTRIES,
69
+ normalizeTestName, parseTestInventory, parseStoryCriteria, parseStoryCriteriaText,
70
+ compareCoverage, describeCoverageGaps,
71
+ } from './verify-acs.mjs';