cadet-agent 0.53.0 → 0.56.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/src/cli.mjs CHANGED
@@ -6,9 +6,20 @@ 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, PHASES, manualConfirmation,
9
+ detectRepoRole, describeRepoRole, GATES, PHASES, manualConfirmation, computeStatus,
10
+ gateBuilder, describeGateRefusal,
10
11
  gitChangeSet, DEFAULT_REPORT_DIR,
11
12
  reconcileArtifacts, PLANS_DEFAULT_DIR,
13
+ parseDesignReviewArtifact, describeDesignReviewGaps, FINDING_DISPOSITIONS,
14
+ DESIGN_REVIEW_GATE, HUMAN_ACCEPTANCE_GATE,
15
+ CONTEXT_LEVELS, buildContextPlan, writeContextPlan, readContextPlan, buildContextRecord,
16
+ writeContextRecord, readContextRecord, parseTranscript, validateContextRecord,
17
+ describeContextState, ContextProtocolError,
18
+ enforcementMatrix, describeEnforcement, INTERCEPTION_ACTIONS,
19
+ ARCHITECTURE_GATE, architectureFitnessActive, runArchitectureChecks, summariseChecks,
20
+ describeCheckResults, DEFAULT_CHECK_TIMEOUT_MS,
21
+ ACCEPTANCE_TEMPLATE_RELATIVE, buildAcceptanceForm, parseAcceptanceForm, writeAcceptanceForm,
22
+ acceptanceFormPath, ACCEPTANCE_HUMAN_FIELDS,
12
23
  parseTestInventory, parseStoryCriteria, compareCoverage, describeCoverageGaps,
13
24
  parseReachabilityDeclaration, validateReachabilityDeclaration, collectWorkItems,
14
25
  findDeferralCycles, readSiblingDeclarations, normalizeWorkItemRef, describeReachabilityGaps,
@@ -45,6 +56,7 @@ function showHelp() {
45
56
  npx cadet-agent@latest sync Update framework, preserving local policies/plans
46
57
  npx cadet-agent@latest sync --target <dir> Sync a specific directory
47
58
 
59
+ cadet-agent state init Create the first .cadet/state.json (never overwrites one)
48
60
  cadet-agent state validate Validate .cadet/state.json against the schema
49
61
  cadet-agent state validate --verify-sealed Also read sealed evidence from commit trailers
50
62
  cadet-agent state migrate Atomically migrate state to the current version (backup on write)
@@ -60,11 +72,18 @@ function showHelp() {
60
72
  cadet-agent harness verify Run a bounded, classified verification loop
61
73
  cadet-agent harness verify-acs Verify declared AC↔test coverage against a test report
62
74
  cadet-agent harness verify-reachability Verify a story's declared reachability (opt-in)
75
+ cadet-agent harness verify-design-review Check the design-review artifact and record the gate (opt-in)
76
+ cadet-agent harness acceptance-form Write a human-acceptance form for an epic, filled in from state
77
+ cadet-agent harness verify-architecture Run the project's declared architecture checks (opt-in)
78
+ cadet-agent harness context plan Write what the phase requires, and why (opt-in protocol)
79
+ cadet-agent harness context record Record what the host loaded, and the level it can claim
80
+ cadet-agent harness context validate Compare plan, record and current files (read-only)
63
81
  cadet-agent harness report Summarize budget consumption and failures
64
82
  cadet-agent harness changes List the files a story changed, with status, counts, and links
65
83
  cadet-agent harness reconcile Reconcile the planning chain against state.json (read-only)
66
84
  cadet-agent harness cleanup Apply the retention policy to .cadet/runs/
67
85
  cadet-agent harness capabilities Report available CLI/Unity/MCP/hook/token/cost telemetry
86
+ cadet-agent harness status Print the one-line framework health line (ok, or the problem)
68
87
 
69
88
  Options:
70
89
  --target, -t Target directory (default: current working directory)
@@ -81,6 +100,10 @@ function showHelp() {
81
100
  --environment key=value,... describing what was verified (harness confirm)
82
101
  --scope Comma-separated scope of the confirmation (harness confirm)
83
102
  --story Story markdown declaring the acceptance criteria or reachability (harness verify-acs|verify-reachability)
103
+ --artifact Design-review artifact to check (verify-design-review), or the acceptance form to record from (confirm --gate humanAcceptanceConfirmed)
104
+ --epic Epic a generated form belongs to (harness acceptance-form)
105
+ --out Where to write the form (default: the epic's plan directory)
106
+
84
107
  --report Test report to derive the inventory from (harness verify-acs|matrix-check)
85
108
  --matrix TDD matrix markdown to check (harness matrix-check)
86
109
  --inventory Newline-separated test names, when no report is available (harness matrix-check)
@@ -95,6 +118,7 @@ function showHelp() {
95
118
  --epic Epic id of the work item being started (state begin)
96
119
  --commit-msg Path to write the prepared commit message to (state seal)
97
120
  --verify-sealed Also verify evidence sealed in commit trailers (state validate)
121
+ --verify-host Probe the configured host controls and report the measured level (harness capabilities)
98
122
  --dry-run Report what a mutating command would do and write nothing (all mutating commands)
99
123
  --yes, -y Never prompt; keep existing files (non-interactive installs)
100
124
  --help, -h Show this help (valid at any depth; never writes)
@@ -173,7 +197,10 @@ function parseArgs(argv) {
173
197
  // working-tree scan (which could bind evidence to Cadet's own files).
174
198
  case '--files': opts.filesGiven = true; opts.files = value(a).split(',').map((s) => s.trim()).filter(Boolean); break;
175
199
  case '--story': opts.story = value(a); break;
176
- // state begin: the epic the new work item belongs to.
200
+ case '--artifact': opts.artifact = value(a); break;
201
+ case '--out': opts.out = value(a); break;
202
+ // The epic the command acts on: `state begin`'s new work item, and the epic an
203
+ // acceptance form is generated for.
177
204
  case '--epic': opts.epicId = value(a); break;
178
205
  // state compact: keep every record inline instead of applying the
179
206
  // within-work-item retention rule. Made explicit at the call site, because
@@ -201,6 +228,27 @@ function parseArgs(argv) {
201
228
  case '--commit-msg': opts.commitMsgPath = value(a); break;
202
229
  // state validate: read commit trailers too, not just the live document.
203
230
  case '--verify-sealed': opts.verifySealed = true; break;
231
+ // The two flags the acceptance form replaced. They are PARSED so that they can be refused by
232
+ // name: without a case here they fell through into `opts.rest`, the command exited 0, and the
233
+ // values went nowhere — which contradicts this parser's own rule, written a few lines above,
234
+ // that a silently swallowed option is worse than a rejected one because the command still
235
+ // reports success. `harness confirm` refuses them (see the acceptance gate).
236
+ case '--witness': opts.witness = value(a); break;
237
+ case '--limitations': opts.limitations = value(a); break;
238
+ // state init: the first document's declared values. Each is validated before anything is
239
+ // written, so a typo cannot become a state file the rest of the framework then refuses.
240
+ case '--workflow-path': opts.workflowPath = value(a); break;
241
+ case '--tracking-mode': opts.trackingMode = value(a); break;
242
+ case '--learner-tier': opts.learnerTier = value(a); break;
243
+ case '--operating-mode': opts.operatingMode = value(a); break;
244
+ // `--phase` is declared once, above: state init and harness record both read opts.phase.
245
+ case '--verify-host': opts.verifyHost = true; break;
246
+ // The context protocol: what the host loaded, and what it can claim about it.
247
+ case '--level': opts.contextLevel = value(a); break;
248
+ case '--loaded': opts.contextLoaded = value(a).split(',').map((s) => s.trim()).filter(Boolean); break;
249
+ case '--enforced-by': opts.enforcedBy = value(a); break;
250
+ case '--transcript': opts.transcript = value(a); break;
251
+ case '--host': opts.host = value(a); break;
204
252
  case '--agents-md': opts.agentsMd = value(a); break;
205
253
  case '--yes': case '-y': opts.yes = true; break;
206
254
  default: opts.rest.push(a);
@@ -564,6 +612,80 @@ async function cmdState(opts) {
564
612
  return;
565
613
  }
566
614
 
615
+ if (sub === 'init') {
616
+ // The first state document, as a command.
617
+ //
618
+ // It had no command. `cadet-agent init` installs the framework, and then EVERY entry point
619
+ // refuses: `state begin` says "Initialise state before starting a work item", `state
620
+ // transition` says the same, `harness confirm` says the same — and there was no way to
621
+ // initialise it. The only route was a hand-written document, which is the pattern this
622
+ // framework condemns everywhere else, and the instruction that described it
623
+ // (`skills/Resume.md`) wrote `version: 1` with `session.workflowPath: null` — a document
624
+ // `state validate` REJECTS ("workflowPath is required", "unknown workflowPath \"null\"").
625
+ // So a new consumer's first document was unaudited and, followed literally, invalid; and
626
+ // every later gate reads that document.
627
+ const existing = readState(opts.targetDir);
628
+ if (existing.exists) {
629
+ fail(
630
+ opts,
631
+ 'A state document already exists. state init never overwrites one: '
632
+ + 'it is the document every gate reads, and rewriting it silently would discard the '
633
+ + 'evidence those gates were satisfied with. Delete or archive it first if that is '
634
+ + 'really what you want.',
635
+ () => 1,
636
+ { ok: false, code: 'state-exists', path: '.cadet/state.json' },
637
+ );
638
+ }
639
+
640
+ const workflowPath = opts.workflowPath || 'large';
641
+ const trackingMode = opts.trackingMode || 'markdown';
642
+ const currentPhase = opts.phase || PHASES[0];
643
+ const values = [
644
+ ['--workflow-path', workflowPath, ['large', 'small', 'no_test_required']],
645
+ ['--tracking-mode', trackingMode, ['markdown', 'github']],
646
+ ['--phase', currentPhase, PHASES],
647
+ ];
648
+ if (opts.learnerTier !== undefined) values.push(['--learner-tier', opts.learnerTier, ['beginner', 'intermediate', 'advanced', 'guided']]);
649
+ if (opts.operatingMode !== undefined) values.push(['--operating-mode', opts.operatingMode, ['instruction-first', 'implementation-first', 'hybrid']]);
650
+ for (const [flag, value, allowed] of values) {
651
+ if (!allowed.includes(value)) {
652
+ fail(opts, `unknown ${flag} value "${value}" (expected one of: ${allowed.join(', ')})`, () => 1, { ok: false, code: 'unknown-value', flag, value, allowed });
653
+ }
654
+ }
655
+
656
+ const initial = {
657
+ version: STATE_VERSION,
658
+ stateVersion: STATE_VERSION,
659
+ session: {
660
+ workflowPath,
661
+ currentPhase,
662
+ trackingMode,
663
+ ...(opts.learnerTier !== undefined ? { learnerTier: opts.learnerTier } : {}),
664
+ ...(opts.operatingMode !== undefined ? { operatingMode: opts.operatingMode } : {}),
665
+ },
666
+ activeWorkItem: null,
667
+ gates: Object.fromEntries(GATES.map((g) => [g, false])),
668
+ gateEvidence: [],
669
+ epics: {},
670
+ };
671
+
672
+ // Validate before writing, like every other writer here. A document that the framework
673
+ // would refuse must never reach disk: it is the file every gate reads.
674
+ const { errors } = validateState(initial, { rootDir: opts.targetDir });
675
+ if (errors.length > 0) {
676
+ fail(opts, `refusing to write an invalid state document: ${errors.map((e) => `${e.path}: ${e.message}`).join('; ')}`, () => 1, { ok: false, code: 'invalid-state', errors });
677
+ }
678
+
679
+ const written = writeState(opts.targetDir, initial);
680
+ emit(
681
+ opts,
682
+ `✅ Created .cadet/state.json (v${STATE_VERSION}): workflowPath "${workflowPath}", phase "${currentPhase}", trackingMode "${trackingMode}".\n`
683
+ + ' The framework is installed and the workflow starts here; nothing is tracked until the first work item ("cadet-agent state begin").',
684
+ { ok: true, version: STATE_VERSION, workflowPath, currentPhase, trackingMode, gates: Object.keys(initial.gates).length, appended: written?.appended ?? 0 },
685
+ );
686
+ return;
687
+ }
688
+
567
689
  if (sub === 'begin') {
568
690
  // The story boundary, as a command.
569
691
  //
@@ -759,16 +881,27 @@ async function cmdHarness(opts) {
759
881
  // depend on the agent reading it, because the dispatcher enforces the
760
882
  // registry regardless.
761
883
  const commands = describeAllCommands();
762
- if (opts.format === 'json') emit(opts, '', { ok: true, capabilities: caps, commands });
884
+ // What the host can actually stop, per action. `--verify-host` runs the probes; without it the
885
+ // declared position is printed and marked "declared", so a reader can always tell a measurement
886
+ // from a declaration — and never from the presence of a file.
887
+ const matrix = enforcementMatrix(opts.targetDir, { verify: opts.verifyHost === true });
888
+ if (opts.verifyHost === true) caps.hook.verified = matrix.hostHook.verified;
889
+ if (opts.format === 'json') emit(opts, '', { ok: true, capabilities: caps, enforcement: matrix, commands });
763
890
  else {
764
891
  console.log('Cadet-Agent capability report');
765
892
  console.log(` CLI: ${caps.cli ? 'available' : 'unavailable'}`);
766
893
  console.log(` Unity CLI: ${caps.unityCli.available ? `available (${caps.unityCli.version || 'version unknown'})` : 'unavailable — compile/analyzer gates fall back to manual confirmation'}`);
767
894
  console.log(` MCP: ${caps.mcp.available ? 'configured' : 'unavailable — live inspection not available'}`);
768
- console.log(` Copilot hook: ${caps.hook.copilot ? 'installed' : 'not installed'}`);
769
895
  console.log(` Token telemetry:${caps.tokenTelemetry.provider ? ' provider' : ' estimate/unknown'}`);
770
896
  console.log(` Cost telemetry: ${caps.costTelemetry.available ? 'available' : `unavailable (${caps.costTelemetry.reason})`}`);
771
- console.log(` Note: ${caps.hook.note}`);
897
+ console.log(` Host interception (${matrix.verified ? 'measured' : 'declared — pass --verify-host to measure'}):`);
898
+ for (const line of describeEnforcement(matrix)) console.log(line);
899
+ if (matrix.verified) {
900
+ console.log(` Host hook: ${matrix.hostHook.verified ? `verified — ${matrix.hostHook.reason}` : `not verified — ${matrix.hostHook.reason}`}`);
901
+ console.log(` Repo git hook: ${matrix.repoHook.verified ? `verified — ${matrix.repoHook.reason}` : `not verified — ${matrix.repoHook.reason}`}`);
902
+ } else {
903
+ console.log(` Note: ${caps.hook.note}`);
904
+ }
772
905
  console.log(' Commands (mutating commands honour --dry-run; nothing writes without it being declared):');
773
906
  for (const c of commands) {
774
907
  const bound = c.requiresForUnattended.length ? ` [unattended requires ${c.requiresForUnattended.join(', ')}]` : '';
@@ -787,6 +920,23 @@ async function cmdHarness(opts) {
787
920
  if (!exists) fail(opts, 'No .cadet/state.json found. Initialise state before recording confirmation.', () => 2);
788
921
  assertExpectedPhase(opts, state);
789
922
 
923
+ // A caller that passes the removed flags is refused by name. The acceptance is recorded from
924
+ // the form and from nothing else (see below); a witness or a limitation typed here would be
925
+ // discarded, and a discarded field that looked accepted is exactly the failure this framework
926
+ // is built to prevent.
927
+ const removedFlags = ['witness', 'limitations'].filter((k) => opts[k] !== undefined && opts[k] !== null);
928
+ if (removedFlags.length > 0) {
929
+ const flags = removedFlags.map((k) => `--${k}`);
930
+ fail(
931
+ opts,
932
+ `${flags.join(' and ')} no longer exist, so nothing was recorded. A human acceptance is recorded from its form: `
933
+ + 'run "cadet-agent harness acceptance-form --epic <epicId>", fill it in, and pass it back with --artifact <the form>. '
934
+ + 'The witness and the limitations live in that file, where a reviewer can read them.',
935
+ () => 1,
936
+ { ok: false, gate, code: 'flag-removed', flags },
937
+ );
938
+ }
939
+
790
940
  const strict = policy.strictClosure?.enabled === true ? policy.strictClosure : null;
791
941
  const mc = strict?.manualConfirmation || null;
792
942
  // One reference instant for the whole command, captured before any work.
@@ -797,18 +947,76 @@ async function cmdHarness(opts) {
797
947
 
798
948
  // Collect EVERY missing field so the caller fixes the record in one pass,
799
949
  // rather than discovering one omission per invocation.
950
+ //
951
+ // The two fields the acceptance ARTIFACT supplies are exempt when it is present. The check
952
+ // below refuses `--scope` and `--environment` beside `--artifact` (they would be a second,
953
+ // competing source for the same claim), so demanding them here made the command unsatisfiable
954
+ // in both directions under the shipped policy: with them it was `artifact-conflicts-with-flags`,
955
+ // without them `strict-metadata-missing`. A gate no caller can satisfy is not a strict gate.
956
+ // The form carries both, and its completeness is checked when it is parsed.
957
+ const artifactSuppliesScope = typeof opts.artifact === 'string' && opts.artifact !== '';
800
958
  const missing = [];
801
959
  if (mc?.requireReason !== false && strict && (!opts.reason || String(opts.reason).trim() === '')) missing.push('--reason');
802
960
  if (mc?.requireExpiresAt !== false && strict) {
803
961
  if (!opts.expiresAt) missing.push('--expires-at');
804
962
  else if (Number.isNaN(Date.parse(opts.expiresAt))) missing.push('--expires-at (not an ISO-8601 date-time)');
805
963
  }
806
- if (mc?.requireEnvironment !== false && strict && (!opts.environment || String(opts.environment).trim() === '')) missing.push('--environment');
807
- if (mc?.requireScope !== false && strict && (!opts.scope || opts.scope.length === 0)) missing.push('--scope');
964
+ if (mc?.requireEnvironment !== false && strict && !artifactSuppliesScope && (!opts.environment || String(opts.environment).trim() === '')) missing.push('--environment');
965
+ if (mc?.requireScope !== false && strict && !artifactSuppliesScope && (!opts.scope || opts.scope.length === 0)) missing.push('--scope');
808
966
  if (missing.length) {
809
967
  fail(opts, `strictClosure requires manual-confirmation metadata. Missing: ${missing.join(', ')}.`, () => 1, { ok: false, gate, code: 'strict-metadata-missing', missing });
810
968
  }
811
969
 
970
+ // A human acceptance is recorded from a form, and from nothing else.
971
+ //
972
+ // There is no flag route. Two routes to one gate means the weaker route defines the
973
+ // gate, and the flag route's only advantage was skipping the file — at the cost of a
974
+ // record no person can read later. The form costs two commands, leaves the acceptance
975
+ // somewhere a reviewer can open, and removes the second copy that could disagree with
976
+ // the record. `--witness` and `--limitations` no longer exist; a caller that passes
977
+ // them is refused here rather than silently recorded, which is why the check is on
978
+ // the missing artifact rather than on the flags.
979
+ if (gate === HUMAN_ACCEPTANCE_GATE && !opts.artifact) {
980
+ fail(opts, `a human acceptance is recorded from a form: run "cadet-agent harness acceptance-form --epic <epicId>" to write one pre-filled from state, fill in its blank fields, then pass it back with --artifact <the form>.`, () => 1, { ok: false, gate, code: 'acceptance-form-required' });
981
+ }
982
+
983
+ // `harness acceptance-form` writes the form pre-filled from state; this reads it back.
984
+ if (opts.artifact) {
985
+ if (gate !== HUMAN_ACCEPTANCE_GATE) {
986
+ fail(opts, `--artifact is only for ${HUMAN_ACCEPTANCE_GATE}: every other gate's evidence comes from its own command or from explicit fields.`, () => 1, { ok: false, gate, code: 'artifact-not-applicable' });
987
+ }
988
+ const conflicting = ['scope', 'environment'].filter((k) => opts[k]);
989
+ if (conflicting.length > 0) {
990
+ fail(opts, `--artifact already carries the acceptance, so ${conflicting.map((k) => `--${k}`).join(' and ')} would be a second, competing source. Pass the artifact alone.`, () => 1, { ok: false, gate, code: 'artifact-conflicts-with-flags', conflicting });
991
+ }
992
+ let formText;
993
+ try {
994
+ formText = readFileSync(opts.artifact, 'utf-8');
995
+ } catch (err) {
996
+ fail(opts, `the acceptance form could not be read (${err.message}).`, () => 1, { ok: false, gate, code: 'artifact-unreadable' });
997
+ }
998
+ const form = parseAcceptanceForm(formText);
999
+ if (form.incomplete.length > 0) {
1000
+ fail(opts, `the form still has unfilled fields: ${form.incomplete.join(', ')}. Fill them in ${opts.artifact} and run the command again — an acceptance nobody wrote down is not an acceptance.`, () => 1, { ok: false, gate, code: 'acceptance-form-incomplete', missing: form.incomplete });
1001
+ }
1002
+ if (state && form.epic && state.epics && !state.epics[form.epic]) {
1003
+ fail(opts, `the form accepts epic "${form.epic}", which does not exist in state.json. Fix the form, or accept the epic the repository actually has.`, () => 1, { ok: false, gate, code: 'acceptance-form-epic-unknown', epic: form.epic });
1004
+ }
1005
+ opts.witness = form.witness;
1006
+ opts.limitations = form.limitations;
1007
+ opts.environment = form.environment || null;
1008
+ opts.scope = [form.epic];
1009
+ if (!opts.files || opts.files.length === 0) opts.files = form.fileList;
1010
+ opts.files = opts.files && opts.files.length ? opts.files : null;
1011
+ opts.filesGiven = opts.files !== null;
1012
+ opts.acceptedBy = form.acceptor;
1013
+ }
1014
+
1015
+ // No check for empty witness/limitations is needed here: the form is the only route
1016
+ // to this gate, and `parseAcceptanceForm` refuses a form whose fields are blank or
1017
+ // still placeholders, so an empty value cannot reach this point. A second check would
1018
+ // be a guard that cannot fire, and the one that cannot fire is the one that rots.
1019
+
812
1020
  // A gate listed in disallowManualFor may never be satisfied by a human
813
1021
  // assertion; point at the automated path instead of accepting the record.
814
1022
  if (strict && Array.isArray(strict.disallowManualFor) && strict.disallowManualFor.includes(gate)) {
@@ -873,6 +1081,8 @@ async function cmdHarness(opts) {
873
1081
  rootDir: opts.targetDir,
874
1082
  approvedBy: opts.approvedBy || 'user',
875
1083
  commit: opts.commit || null,
1084
+ witness: opts.witness || null,
1085
+ limitations: opts.limitations || null,
876
1086
  at,
877
1087
  });
878
1088
 
@@ -942,6 +1152,20 @@ async function cmdHarness(opts) {
942
1152
  if (sub === 'verify') {
943
1153
  const gate = opts.gate;
944
1154
  if (!gate) fail(opts, 'harness verify requires --gate <gate>');
1155
+ // The gate NAME selects the evidence contract, so an unknown name is refused
1156
+ // before anything runs. A made-up gate used to be accepted with --command and
1157
+ // recorded as an automated pass, which put evidence into state.json for a name
1158
+ // no transition can read.
1159
+ if (!GATES.includes(gate)) {
1160
+ fail(opts, describeGateRefusal(gate), () => 1, { ok: false, gate, code: 'unknown-gate' });
1161
+ }
1162
+ // A project command may fill only the slots whose evidence IS a project
1163
+ // command. Anywhere else it would let an unrelated exit-zero command attest a
1164
+ // claim it does not prove — which is how `codeReviewCompleted` could be
1165
+ // satisfied by `node -e "process.exit(0)"`.
1166
+ if (opts.command && gateBuilder(gate)?.projectCommand !== true) {
1167
+ fail(opts, describeGateRefusal(gate), () => 1, { ok: false, gate, code: 'gate-not-overridable' });
1168
+ }
945
1169
  const { state } = readState(opts.targetDir);
946
1170
  assertExpectedPhase(opts, state);
947
1171
  const caps = detectCapabilities({ targetDir: opts.targetDir });
@@ -1083,10 +1307,12 @@ async function cmdHarness(opts) {
1083
1307
  }
1084
1308
 
1085
1309
  if (sub === 'verify-acs') {
1310
+ refuseCommandOverride('harness verify-acs', 'the acceptance criteria in the story and the test report');
1086
1311
  // Mechanical AC↔test verification (contract v4). Declared tests must appear
1087
1312
  // in the inventory of a run that actually executed them; a name that was
1088
1313
  // never written cannot be asserted into coverage.
1089
1314
  if (!opts.story) fail(opts, 'harness verify-acs requires --story <path>');
1315
+ const storyPath = resolve(opts.targetDir, opts.story);
1090
1316
  const { exists, state } = readState(opts.targetDir);
1091
1317
  assertExpectedPhase(opts, state);
1092
1318
  const strict = policy.strictClosure?.enabled === true;
@@ -1095,7 +1321,14 @@ async function cmdHarness(opts) {
1095
1321
 
1096
1322
  let criteria;
1097
1323
  try {
1098
- ({ criteria } = parseStoryCriteria(opts.story));
1324
+ // The story is READ from the target repository, exactly as verify-reachability reads it, and
1325
+ // not from the process working directory. Reading it from the CWD while the record below
1326
+ // binds `<target>/<story>` was two defects in one line: the command was unusable from
1327
+ // outside the project (`--target` with a CWD elsewhere), and — worse — when a file of that
1328
+ // name happened to exist under the CWD it parsed THAT file's criteria and wrote a record
1329
+ // attesting them against the target's path, which is the silently-inert binding the comment
1330
+ // below warns about.
1331
+ ({ criteria } = parseStoryCriteria(storyPath));
1099
1332
  } catch (err) {
1100
1333
  fail(opts, `cannot parse story "${opts.story}": ${err.message}`, () => 1, { ok: false, code: 'story-parse', story: opts.story });
1101
1334
  }
@@ -1111,7 +1344,10 @@ async function cmdHarness(opts) {
1111
1344
  let reportPath = null;
1112
1345
  if (opts.report) {
1113
1346
  try {
1114
- reportText = readFileSync(opts.report, 'utf-8');
1347
+ // Resolved against the target, like `--story` above and like every other path flag in this
1348
+ // CLI (matrix-check, harness changes). CWD-relative reads made the command unusable from
1349
+ // outside the project and could have read a report from a different tree.
1350
+ reportText = readFileSync(resolve(opts.targetDir, opts.report), 'utf-8');
1115
1351
  reportSource = 'explicit';
1116
1352
  reportPath = opts.report;
1117
1353
  } catch (err) {
@@ -1212,11 +1448,10 @@ async function cmdHarness(opts) {
1212
1448
  // the same treatment and is kept as `artifactPath` for audit, where nothing
1213
1449
  // re-hashes it.
1214
1450
  //
1215
- // The story path is made repo-relative for the same reason verify-reachability
1216
- // does it: an absolute path never resolves under the root when freshness is
1217
- // re-derived at transition time, so both hashes would be computed over a
1218
- // missing file and match — a binding that is silently inert.
1219
- const storyPath = resolve(opts.targetDir, opts.story);
1451
+ // The story path is made repo-relative (see the read above: it resolves against the target,
1452
+ // never the working directory) for the same reason verify-reachability does it: an absolute
1453
+ // path never resolves under the root when freshness is re-derived at transition time, so both
1454
+ // hashes would be computed over a missing file and match — a binding that is silently inert.
1220
1455
  const storyRel = relative(opts.targetDir, storyPath).replace(/\\/g, '/') || basename(storyPath);
1221
1456
  const evidence = createEvidence({
1222
1457
  evidenceId: newId(),
@@ -1287,6 +1522,7 @@ async function cmdHarness(opts) {
1287
1522
  }
1288
1523
 
1289
1524
  if (sub === 'verify-reachability') {
1525
+ refuseCommandOverride('harness verify-reachability', "the story's reachability declaration and the repository's own probe");
1290
1526
  // Mechanical reachability verification (contract v6 §2). A story declares how
1291
1527
  // its deliverable becomes witnessable, or which work item will make it so;
1292
1528
  // this checks that declaration against the work items that exist, and runs
@@ -1443,14 +1679,459 @@ async function cmdHarness(opts) {
1443
1679
  return;
1444
1680
  }
1445
1681
 
1682
+ if (sub === 'acceptance-form') {
1683
+ if (!opts.epicId) {
1684
+ fail(opts, 'harness acceptance-form needs --epic <epicId>: the form belongs to the epic being accepted.', () => 1, { ok: false, code: 'epic-required' });
1685
+ }
1686
+ const { exists, state } = readState(opts.targetDir);
1687
+ if (!exists) {
1688
+ fail(opts, 'No .cadet/state.json found. The form is generated from state, so there is nothing to fill it from yet.', () => 1, { ok: false, code: 'no-state' });
1689
+ }
1690
+ if (state.epics && !state.epics[opts.epicId]) {
1691
+ fail(opts, `epic "${opts.epicId}" does not exist in state.json. Known epics: ${Object.keys(state.epics).join(', ') || '(none)'}.`, () => 1, { ok: false, code: 'epic-unknown', epic: opts.epicId });
1692
+ }
1693
+ const templatePath = join(opts.targetDir, ...ACCEPTANCE_TEMPLATE_RELATIVE.split('/'));
1694
+ let template;
1695
+ try {
1696
+ template = readFileSync(templatePath, 'utf-8');
1697
+ } catch (err) {
1698
+ fail(opts, `the acceptance template is missing (${ACCEPTANCE_TEMPLATE_RELATIVE}). Run "cadet-agent sync" to restore it — the form is generated from that file so the template and the form cannot drift apart.`, () => 1, { ok: false, code: 'template-missing' });
1699
+ }
1700
+ const { text } = buildAcceptanceForm({ template, state, epicId: opts.epicId, targetDir: opts.targetDir });
1701
+ const result = writeAcceptanceForm(opts.targetDir, opts.epicId, text, { out: opts.out });
1702
+ if (!result.written) {
1703
+ fail(opts, `harness acceptance-form refuses to overwrite an existing form: ${result.reason} (${result.path}).`, () => 1, { ok: false, code: 'form-exists', path: result.path });
1704
+ }
1705
+ const shown = result.path.slice(opts.targetDir.length + 1).replace(/\\/g, '/');
1706
+ if (opts.format === 'json') {
1707
+ emit(opts, '', { ok: true, path: shown, epic: opts.epicId, next: `cadet-agent harness confirm --gate ${HUMAN_ACCEPTANCE_GATE} --artifact ${shown}` });
1708
+ } else {
1709
+ console.log(`Acceptance form written: ${shown}`);
1710
+ console.log(` Fill in the three unfilled fields, then run:`);
1711
+ console.log(` cadet-agent harness confirm --gate ${HUMAN_ACCEPTANCE_GATE} --artifact ${shown}`);
1712
+ }
1713
+ return;
1714
+ }
1715
+
1716
+ if (sub === 'context') {
1717
+ // The runtime context boundary: plan, record, validate.
1718
+ //
1719
+ // WHY THIS EXISTS. Cadet does not inject context into a model — hosts own model context — so
1720
+ // the honest thing to build is a protocol rather than a claim: say what a phase requires,
1721
+ // let the host report what it loaded, and compare the two. Without the record, "the agent
1722
+ // read the skill file" is an assertion nothing can check, and a framework whose whole point
1723
+ // is that claims carry evidence should not make an exception for its own central act.
1724
+ const action = opts.rest[1];
1725
+ if (!['plan', 'record', 'validate'].includes(action)) {
1726
+ fail(opts, `harness context needs an action: plan, record or validate (got "${action || '(none)'}").`, () => 1, { ok: false, code: 'context-action-required' });
1727
+ }
1728
+ const { exists, state } = readState(opts.targetDir);
1729
+
1730
+ if (action === 'plan') {
1731
+ const plan = buildContextPlan({ targetDir: opts.targetDir, policy, state });
1732
+ const planPath = writeContextPlan(opts.targetDir, plan);
1733
+ const shown = relative(opts.targetDir, planPath).replace(/\\/g, '/');
1734
+ const fit = plan.budget.fits === null ? 'no context budget declared'
1735
+ : plan.budget.fits ? `fits the ${plan.budget.hardContextTokens}-token budget`
1736
+ : `DOES NOT fit the ${plan.budget.hardContextTokens}-token budget (required ${plan.budget.requiredTokens})`;
1737
+ if (opts.format === 'json') {
1738
+ emit(opts, '', { ok: true, path: shown, phase: plan.phase, workItemId: plan.workItemId, required: plan.required, advisory: plan.advisory, absent: plan.absent, budget: plan.budget });
1739
+ } else {
1740
+ console.log(`Context plan for ${plan.phase}${plan.workItemId ? ` (${plan.workItemId})` : ''}: ${plan.required.length} required, ${plan.advisory.length} advisory — ${fit}`);
1741
+ for (const item of plan.required) {
1742
+ console.log(` ${item.present ? '📌' : '⚠️ '} ${item.reference} [${item.tier}] ${item.reason}${item.present ? ` — ${item.bytes} B, ~${item.estimatedTokens} tokens` : ' — MISSING'}`);
1743
+ }
1744
+ for (const item of plan.advisory) {
1745
+ console.log(` · ${item.reference} [${item.tier}] ${item.reason}${item.present ? '' : ' (absent)'}`);
1746
+ }
1747
+ console.log(` Written: ${shown}`);
1748
+ }
1749
+ return;
1750
+ }
1751
+
1752
+ if (action === 'record') {
1753
+ const plan = readContextPlan(opts.targetDir);
1754
+ let loaded = opts.contextLoaded || [];
1755
+ const notes = [];
1756
+ if (opts.transcript) {
1757
+ let text;
1758
+ try {
1759
+ text = readFileSync(opts.transcript, 'utf-8');
1760
+ } catch (err) {
1761
+ fail(opts, `the transcript could not be read (${err.message}).`, () => 1, { ok: false, code: 'transcript-unreadable' });
1762
+ }
1763
+ const parsed = parseTranscript(text);
1764
+ if (parsed.problems.length > 0) {
1765
+ fail(opts, `the transcript has ${parsed.problems.length} unusable line(s): ${parsed.problems.slice(0, 3).join('; ')}. Each line is one JSON object naming a reference.`, () => 1, { ok: false, code: 'transcript-malformed', problems: parsed.problems });
1766
+ }
1767
+ loaded = parsed.loaded;
1768
+ notes.push(`loads taken from the transcript at ${opts.transcript}`);
1769
+ }
1770
+ let record;
1771
+ try {
1772
+ record = buildContextRecord({
1773
+ targetDir: opts.targetDir, policy, state, plan,
1774
+ level: opts.contextLevel || 'recorded',
1775
+ loaded, enforcedBy: opts.enforcedBy || null, host: opts.host || null, notes,
1776
+ });
1777
+ } catch (err) {
1778
+ if (err instanceof ContextProtocolError) fail(opts, err.message, () => 1, { ok: false, code: err.code });
1779
+ throw err;
1780
+ }
1781
+ const recordPath = writeContextRecord(opts.targetDir, record);
1782
+ const shown = relative(opts.targetDir, recordPath).replace(/\\/g, '/');
1783
+ if (opts.format === 'json') {
1784
+ emit(opts, '', { ok: true, path: shown, level: record.level, enforcedBy: record.enforcedBy, workItemId: record.workItemId, loaded: record.loaded, notes: record.notes });
1785
+ } else {
1786
+ console.log(`Context record: level "${record.level}"${record.enforcedBy ? ` (enforced by ${record.enforcedBy})` : ''}, ${record.loaded.length} reference(s) reported`);
1787
+ console.log(` ${describeContextState({ plan, record }).line}`);
1788
+ console.log(` Written: ${shown}`);
1789
+ if (record.level === 'estimated' || record.level === 'unavailable') {
1790
+ console.log(' This level cannot certify a context-complete checkpoint: nobody observed what was loaded.');
1791
+ }
1792
+ }
1793
+ return;
1794
+ }
1795
+
1796
+ // validate — read-only, so it must write nothing.
1797
+ const plan = readContextPlan(opts.targetDir);
1798
+ const record = readContextRecord(opts.targetDir);
1799
+ if (!plan) {
1800
+ fail(opts, 'no context plan exists: run "cadet-agent harness context plan" first — a record with nothing to compare against cannot be validated.', () => 1, { ok: false, code: 'no-plan' });
1801
+ }
1802
+ // Rebuild the plan against the CURRENT phase and work item: a plan written for a phase the
1803
+ // session has since left describes context this turn does not need, and validating against it
1804
+ // would pass a checkpoint for the wrong turn.
1805
+ const current = buildContextPlan({ targetDir: opts.targetDir, policy, state });
1806
+ const verdict = validateContextRecord({ targetDir: opts.targetDir, plan: current, record });
1807
+ const state_ = describeContextState({ plan: current, record, verdict });
1808
+
1809
+ if (opts.format === 'json') {
1810
+ emit(opts, '', {
1811
+ ok: verdict.ok, code: verdict.code, level: verdict.level,
1812
+ workItemId: current.workItemId, phase: current.phase,
1813
+ required: current.required.length, loaded: (record?.loaded || []).length,
1814
+ missingRequired: verdict.missingRequired, staleRequired: verdict.staleRequired,
1815
+ advisoryMissing: verdict.advisoryMissing, absentRequired: verdict.absentRequired || [],
1816
+ reasons: verdict.reasons, plannedFor: record?.phase || null,
1817
+ });
1818
+ } else {
1819
+ console.log(`${verdict.ok ? '✅' : '❌'} ${state_.line}`);
1820
+ for (const reason of verdict.reasons) console.log(` ${reason}`);
1821
+ if (verdict.ok) console.log(' Required context is loaded and unchanged: a context-complete checkpoint can be claimed.');
1822
+ }
1823
+ process.exit(verdict.ok ? 0 : 1);
1824
+ }
1825
+
1826
+ /**
1827
+ * Refuse `--command` on a command whose evidence comes from somewhere else.
1828
+ *
1829
+ * The same rule the gate registry states for a gate: a command supplied at the call site proves
1830
+ * nothing about the repository, because the caller chooses both the question and the answer. The
1831
+ * flag was PARSED globally and then ignored by these commands, so it exited 0 having done
1832
+ * nothing with it — the silently swallowed option this CLI's own parser comments warn about.
1833
+ * `harness verify --gate <g>` refuses it with `gate-not-overridable` for the gates that take no
1834
+ * command; these refuse it with `command-not-accepted`.
1835
+ */
1836
+ // Declared as a function, not a const arrow: the two `verify-acs`/`verify-reachability` branches
1837
+ // run before this point in the dispatch, and a const would leave them in the temporal dead zone.
1838
+ function refuseCommandOverride(command, instead) {
1839
+ if (opts.command === undefined) return;
1840
+ fail(
1841
+ opts,
1842
+ `${command} takes no --command: it reads its evidence from ${instead}, and a command supplied here would `
1843
+ + 'let the caller choose both the question and the answer. Pass the artifact or the declaration instead.',
1844
+ () => 1,
1845
+ { ok: false, code: 'command-not-accepted', command },
1846
+ );
1847
+ }
1848
+
1849
+ if (sub === 'verify-architecture') {
1850
+ refuseCommandOverride('harness verify-architecture', 'the checks declared under architectureFitness in .cadet/harness.json');
1851
+ // The project's declared executable constraints, run and recorded.
1852
+ //
1853
+ // WHY THE POLICY IS THE ONLY SOURCE OF THE COMMANDS. A gate whose command can be
1854
+ // supplied at the call site proves nothing about the repository: the caller chooses
1855
+ // both the question and the answer. Here the questions are declared in
1856
+ // `.cadet/harness.json` — with ids, scopes and severities — and this command only runs
1857
+ // them and writes down what happened. There is deliberately no `--command`.
1858
+ //
1859
+ // NOT OPTED IN: report and write nothing, the compatibility rule every opt-in gate in
1860
+ // this repository follows.
1861
+ if (!architectureFitnessActive(policy)) {
1862
+ const declared = policy.architectureFitness?.checks?.length ?? 0;
1863
+ const detail = {
1864
+ ok: true, gateSet: false, enabled: policy.architectureFitness?.enabled === true,
1865
+ declared, checks: [], note: declared === 0
1866
+ ? 'architectureFitness declares no checks: nothing to verify, nothing recorded.'
1867
+ : 'architectureFitness.enabled is false: the checks are declared but nothing runs.',
1868
+ };
1869
+ if (opts.format === 'json') emit(opts, '', detail);
1870
+ else {
1871
+ console.log(`ℹ️ ${detail.note}`);
1872
+ console.log(' Declare checks under architectureFitness in .cadet/harness.json and set "enabled": true to require them.');
1873
+ }
1874
+ return;
1875
+ }
1876
+
1877
+ const { exists, state } = readState(opts.targetDir);
1878
+ assertExpectedPhase(opts, state);
1879
+
1880
+ // Which files do the checks judge? The same answer `harness verify` and `harness
1881
+ // confirm` give, for the same reason: a record that does not know what it judged
1882
+ // cannot go stale.
1883
+ const allowEmpty = policy?.allowEmptyFreshness === true;
1884
+ let relevantFiles;
1885
+ if (opts.files && opts.files.length) {
1886
+ relevantFiles = opts.files.map((f) => String(f).replace(/\\/g, '/'));
1887
+ } else {
1888
+ const changed = gitChangedFiles(opts.targetDir);
1889
+ if (!changed.available) {
1890
+ if (!allowEmpty) {
1891
+ const scoped = (policy.architectureFitness.checks || []).some((c) => c.files?.length);
1892
+ fail(opts, `cannot establish which files the checks should judge: ${changed.reason}.`
1893
+ + (scoped
1894
+ ? ' Pass --files <paths> — a project that scopes its checks needs to know which files changed, or a scoped check would silently skip.'
1895
+ : ' Pass --files <paths>, or enable allowEmptyFreshness in .cadet/harness.json.'), () => 1, { ok: false, code: 'freshness-unavailable' });
1896
+ }
1897
+ relevantFiles = [];
1898
+ } else {
1899
+ relevantFiles = changed.files;
1900
+ }
1901
+ }
1902
+
1903
+ const { results, skipped } = await runArchitectureChecks({
1904
+ checks: policy.architectureFitness.checks,
1905
+ relevantFiles,
1906
+ rootDir: opts.targetDir,
1907
+ defaultTimeoutMs: DEFAULT_CHECK_TIMEOUT_MS,
1908
+ evidenceDir: join(opts.targetDir, '.cadet', 'runs', 'architecture'),
1909
+ });
1910
+ const summary = summariseChecks(results, { declared: policy.architectureFitness.checks.length, skipped });
1911
+
1912
+ const workItemId = state ? workItemIdOf(state) : 'unscoped';
1913
+ const phase = state?.session?.currentPhase || 'implementation';
1914
+ const status = summary.gatePassed ? 'passed' : (summary.blocked.length > 0 && summary.failed.length === 0 ? 'blocked' : 'failed');
1915
+ const resultText = results.length === 0
1916
+ ? summary.note
1917
+ : `${results.filter((r) => r.status === 'passed').length}/${results.length} check(s) passed`
1918
+ + (summary.failed.length ? `; failed: ${summary.failed.join(', ')}` : '')
1919
+ + (summary.blocked.length ? `; blocked: ${summary.blocked.join(', ')}` : '')
1920
+ + (summary.advisoryFailed.length ? `; advisory (not blocking): ${summary.advisoryFailed.join(', ')}` : '');
1921
+
1922
+ // The artifacts a check declared are part of what this record attests, and they are bound
1923
+ // per check (`checks[].artifactPath` + `artifactHash`) — NOT folded into `relevantFiles` and
1924
+ // NOT hashed into `inputTreeHash`: a check that rewrites its own report would otherwise stale
1925
+ // a record that describes an unchanged tree.
1926
+ //
1927
+ // Folding them into `relevantFiles` was a defect, and a self-inconsistent one: `inputTreeHash`
1928
+ // covers `relevantFiles` as it was BEFORE the artifact paths were added, while the record then
1929
+ // stored the union. Freshness re-derives the hash from the record's own `relevantFiles`, so
1930
+ // every record from a check that declared an artifact read as stale the moment it was written
1931
+ // ("input tree hash changed since the evidence was recorded"), and the gate it satisfied could
1932
+ // never be used again — which made `implementation -> review` unreachable for any project whose
1933
+ // checks write a report. The record now hashes exactly the set it stores, and the artifacts stay
1934
+ // bound where they were already recorded: in the check entries.
1935
+ const artifactPaths = results.map((r) => r.artifactPath).filter(Boolean);
1936
+ const boundFiles = relevantFiles;
1937
+
1938
+ const evidence = createEvidence({
1939
+ evidenceId: newId(),
1940
+ workItemId,
1941
+ acceptanceCriterionId: null,
1942
+ phase,
1943
+ gate: ARCHITECTURE_GATE,
1944
+ status,
1945
+ command: `harness verify-architecture (${results.map((r) => r.id).join(',') || 'none applicable'})`,
1946
+ result: resultText,
1947
+ exitCode: 0,
1948
+ inputTreeHash: computeInputTreeHash(opts.targetDir, relevantFiles),
1949
+ criteriaHash: hashCriteria([]),
1950
+ relevantFiles,
1951
+ createdAt: new Date(),
1952
+ });
1953
+ evidence.checks = results.map((r) => ({
1954
+ id: r.id, severity: r.severity, status: r.status, exitCode: r.exitCode,
1955
+ artifactPath: r.artifactPath, artifactHash: r.artifactHash, refs: r.refs,
1956
+ }));
1957
+
1958
+ const ledger = new RunLedger({ targetDir: opts.targetDir, policy, runId: state?.activeRunId || null, workItemId, phase });
1959
+ ledger.addEvidence(evidence);
1960
+ for (const r of results) {
1961
+ ledger.addDecision({ kind: 'check', reason: `architecture check ${r.id}: ${r.status}${r.reason ? ` (${r.reason})` : ''}`, scope: r.cwd });
1962
+ }
1963
+ ledger.finalize({ status: summary.gatePassed ? 'ok' : 'failed' });
1964
+ const ledgerPath = ledger.persist();
1965
+
1966
+ // The gate follows the outcome. `recordEvidence` used to be called unconditionally, so a
1967
+ // failed or blocked run wrote `gates.architectureFitnessPassed = true` beside a `failed` record
1968
+ // — a document `state validate` then rejects ("gate is true but has no supporting evidence
1969
+ // record"), which the shipped pre-commit hook turns into a refused commit. A run that did not
1970
+ // pass now clears the gate, so a failure invalidates an earlier pass instead of leaving it
1971
+ // standing.
1972
+ if (exists) writeState(opts.targetDir, recordEvidence(state, evidence, { setGate: summary.gatePassed }));
1973
+
1974
+ const detail = {
1975
+ ok: summary.gatePassed, gateSet: exists && summary.gatePassed, gate: ARCHITECTURE_GATE,
1976
+ inputTreeHash: evidence.inputTreeHash,
1977
+ status, passed: summary.passed, failed: summary.failed, blocked: summary.blocked,
1978
+ advisoryFailed: summary.advisoryFailed, skipped: summary.skipped, note: summary.note,
1979
+ checks: results.map((r) => ({ id: r.id, severity: r.severity, status: r.status, exitCode: r.exitCode, artifactPath: r.artifactPath })),
1980
+ relevantFiles: boundFiles, evidenceId: evidence.evidenceId, runId: ledger.runId, path: ledgerPath,
1981
+ };
1982
+ if (opts.format === 'json') {
1983
+ emit(opts, '', detail);
1984
+ } else {
1985
+ const head = summary.gatePassed ? `✅ ${ARCHITECTURE_GATE}` : `❌ ${ARCHITECTURE_GATE}`;
1986
+ console.log(`${head}: ${resultText}`);
1987
+ for (const line of describeCheckResults(results, summary)) console.log(line);
1988
+ if (summary.note) console.log(` ${summary.note}`);
1989
+ console.log(` Bound to ${boundFiles.length} judged file(s), so a change to one stales this record.`
1990
+ + (artifactPaths.length ? ` Plus ${artifactPaths.length} artifact(s), bound per check and deliberately unhashed.` : ''));
1991
+ console.log(` Ledger: ${ledgerPath}`);
1992
+ if (!summary.gatePassed) {
1993
+ console.log(' Review cannot start until every required check passes. A check that cannot run is a tooling-gap exception, not a manual record.');
1994
+ }
1995
+ }
1996
+ process.exit(summary.gatePassed ? 0 : 1);
1997
+ }
1998
+
1999
+ if (sub === 'verify-design-review') {
2000
+ refuseCommandOverride('harness verify-design-review', 'the design-review artifact it is handed');
2001
+ // The formal design review: checked, then recorded.
2002
+ //
2003
+ // WHY THE ARTIFACT IS WHAT PROVES IT. Cadet cannot read a design and decide
2004
+ // whether it is good, so it does not pretend to. The reviewer's judgement lives
2005
+ // in the artifact; this command checks the properties an artifact must have to be
2006
+ // readable as a review at all, and blocks the one case the gate exists for — a
2007
+ // contested decision with nobody's name against it. The bound inputs give the
2008
+ // record its freshness, which is the only honest way to say "this review was of
2009
+ // THAT design".
2010
+ if (!opts.artifact) fail(opts, 'harness verify-design-review requires --artifact <path>');
2011
+ const { exists, state } = readState(opts.targetDir);
2012
+ assertExpectedPhase(opts, state);
2013
+
2014
+ const artifactPath = resolve(opts.targetDir, opts.artifact);
2015
+ const artifactRel = relative(opts.targetDir, artifactPath).replace(/\\/g, '/') || basename(artifactPath);
2016
+ let artifactText;
2017
+ try {
2018
+ artifactText = readFileSync(artifactPath, 'utf-8');
2019
+ } catch (err) {
2020
+ fail(opts, `cannot read the design-review artifact "${opts.artifact}": ${err.message}`, () => 1, { ok: false, code: 'artifact-unreadable', artifact: opts.artifact });
2021
+ }
2022
+
2023
+ // The review must bind what it reviewed. Without this the record would attest
2024
+ // "a review happened" while naming nothing it was a review OF.
2025
+ if (!opts.files || opts.files.length === 0) {
2026
+ fail(opts, 'the review must bind its inputs: pass --files <technical-design,requirements,ADRs,...> alongside --artifact.', () => 1, { ok: false, code: 'no-inputs-bound' });
2027
+ }
2028
+
2029
+ const parsed = parseDesignReviewArtifact(artifactText);
2030
+ const enabled = policy.designReview?.enabled === true;
2031
+
2032
+ if (parsed.errors.length > 0) {
2033
+ const detail = {
2034
+ ok: false,
2035
+ artifact: opts.artifact,
2036
+ code: parsed.errors[0].code,
2037
+ errors: parsed.errors,
2038
+ findings: parsed.findings.length,
2039
+ contested: parsed.contested,
2040
+ gateSet: false,
2041
+ enabled,
2042
+ };
2043
+ if (opts.format === 'json') emit(opts, '', detail);
2044
+ else {
2045
+ console.error(`❌ Cannot set ${DESIGN_REVIEW_GATE} from ${opts.artifact}:`);
2046
+ for (const line of describeDesignReviewGaps(parsed)) console.error(line);
2047
+ }
2048
+ process.exit(1);
2049
+ }
2050
+
2051
+ // NOT OPTED IN: report and write nothing, the same compatibility rule the other
2052
+ // opt-in gates follow. A caller who ran the command asked the question, so a
2053
+ // failure still exits nonzero.
2054
+ if (!enabled) {
2055
+ const summary = `design review readable: ${parsed.findings.length} finding(s), reviewer ${parsed.reviewer}`;
2056
+ if (opts.format === 'json') emit(opts, '', { ok: true, artifact: opts.artifact, review: { reviewer: parsed.reviewer, inputs: parsed.inputs, findings: parsed.findings.length, contested: parsed.contested }, gateSet: false, enabled: false });
2057
+ else {
2058
+ console.log(`✅ ${summary}`);
2059
+ console.log(' designReview.enabled is false — reported only, state.json unchanged.');
2060
+ }
2061
+ return;
2062
+ }
2063
+
2064
+ const workItemId = state ? workItemIdOf(state) : 'unscoped';
2065
+ const phase = state?.session?.currentPhase || 'implementation';
2066
+ const inputs = opts.files.map((f) => String(f).replace(/\\/g, '/'));
2067
+ const relevantFiles = [artifactRel, ...inputs.filter((f) => f !== artifactRel)];
2068
+
2069
+ const evidence = createEvidence({
2070
+ evidenceId: newId(),
2071
+ workItemId,
2072
+ acceptanceCriterionId: null,
2073
+ phase,
2074
+ gate: DESIGN_REVIEW_GATE,
2075
+ status: 'passed',
2076
+ command: `harness verify-design-review --artifact ${artifactRel}`,
2077
+ result: `design review complete: ${parsed.findings.length} finding(s), ${parsed.contested.length} contested with a named resolution; reviewer ${parsed.reviewer}`,
2078
+ exitCode: 0,
2079
+ inputTreeHash: computeInputTreeHash(opts.targetDir, relevantFiles),
2080
+ criteriaHash: hashCriteria([]),
2081
+ relevantFiles,
2082
+ createdAt: new Date(),
2083
+ });
2084
+
2085
+ const ledger = new RunLedger({ targetDir: opts.targetDir, policy, runId: state?.activeRunId || null, workItemId, phase });
2086
+ ledger.addEvidence(evidence);
2087
+ ledger.addDecision({ kind: 'stop', reason: `design review checked (${parsed.findings.length} finding(s))`, scope: artifactRel });
2088
+ ledger.finalize({ status: 'ok' });
2089
+ const ledgerPath = ledger.persist();
2090
+
2091
+ if (exists) writeState(opts.targetDir, recordEvidence(state, evidence));
2092
+
2093
+ if (opts.format === 'json') {
2094
+ emit(opts, '', { ok: true, artifact: opts.artifact, review: { reviewer: parsed.reviewer, inputs: parsed.inputs, findings: parsed.findings.length, contested: parsed.contested }, relevantFiles, evidenceId: evidence.evidenceId, gateSet: exists, runId: ledger.runId, path: ledgerPath });
2095
+ } else {
2096
+ console.log(`✅ ${DESIGN_REVIEW_GATE} for ${artifactRel}: ${parsed.findings.length} finding(s), reviewer ${parsed.reviewer}`);
2097
+ console.log(` Bound to ${relevantFiles.length} file(s), so a change to the design or the review stales this record.`);
2098
+ console.log(` Ledger: ${ledgerPath}`);
2099
+ }
2100
+ return;
2101
+ }
2102
+
2103
+ // The health line. This command exists so the framework's one line of output is
2104
+ // DERIVED rather than asserted: an agent composing its own `ok` is a claim, and
2105
+ // this repository's whole complaint about itself is claims that nothing checks.
2106
+ //
2107
+ // The exit code carries the same verdict as the line, because the CLI's contract
2108
+ // is that a command returns nonzero for invalid state. `process.exitCode` is set
2109
+ // rather than calling `process.exit()`, so buffered stdout cannot be truncated
2110
+ // when the caller is reading the line from a pipe.
2111
+ if (sub === 'status') {
2112
+ const status = computeStatus(opts.targetDir);
2113
+ emit(opts, status.line, { ok: status.ok, status });
2114
+ process.exitCode = status.ok ? 0 : 1;
2115
+ return;
2116
+ }
2117
+
1446
2118
  if (sub === 'report') {
1447
2119
  const runs = listRuns(opts.targetDir);
1448
2120
  const target = opts.runId || runs[0]?.runId;
1449
2121
  if (!target) fail(opts, 'No run records found in .cadet/runs/.', () => 2);
1450
2122
  const run = loadRun(opts.targetDir, target);
1451
2123
  if (!run) fail(opts, `Run ${target} not found.`, () => 2);
1452
- if (opts.format === 'json') emit(opts, '', { ok: true, report: buildReport(run) });
1453
- else console.log(formatReport(run));
2124
+ // The run report carries the context level, because it is the one place a reader looks to
2125
+ // ask what happened in a run. The level is reported as recorded — never upgraded: a report
2126
+ // that calls advisory loading "enforced" is worse than no report, because the reader stops
2127
+ // looking. Reading the plan and record writes nothing, which is what this command promises.
2128
+ const contextLine = describeContextState({
2129
+ plan: buildContextPlan({ targetDir: opts.targetDir, policy, state: readState(opts.targetDir).state }),
2130
+ record: readContextRecord(opts.targetDir),
2131
+ });
2132
+ if (opts.format === 'json') emit(opts, '', { ok: true, report: { ...buildReport(run), context: contextLine } });
2133
+ else console.log(`${formatReport(run)}
2134
+ ${contextLine.line}`);
1454
2135
  return;
1455
2136
  }
1456
2137
 
@@ -1660,7 +2341,7 @@ async function cmdHarness(opts) {
1660
2341
  return;
1661
2342
  }
1662
2343
 
1663
- fail(opts, `Unknown harness subcommand: ${sub || '(none)'}. Use record|confirm|verify|verify-acs|verify-reachability|matrix-check|report|changes|reconcile|cleanup|capabilities.`);
2344
+ fail(opts, `Unknown harness subcommand: ${sub || '(none)'}. Use record|confirm|verify|verify-acs|verify-reachability|verify-design-review|matrix-check|report|status|changes|reconcile|cleanup|capabilities.`);
1664
2345
  }
1665
2346
 
1666
2347
  export async function run(argv) {