cadet-agent 0.54.0 → 0.60.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,16 +6,31 @@ 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,
15
26
  REACHABILITY_GATE, runCommand,
27
+ parsePlayDeclaration, validatePlayDeclaration, readSiblingPlayDeclarations, describePlayGaps,
28
+ USER_PLAY_GATE, buildPlayForm, parsePlayForm, writePlayForm, readPlayTemplate,
29
+ PLAY_FORM_GATE, PLAY_FORM_SUFFIX,
16
30
  createEvidence, newId, computeInputTreeHash, hashCriteria,
17
31
  collectDeclaredTestNames, reconcileTestNames,
18
32
  resolveCommand, describeCommand, describeAllCommands, checkUnattendedRequirements, COMMANDS,
33
+ selfBoundFiles,
19
34
  STATE_VERSION, sealedEvidence, recordEvidence, appendEvidence, sealWorkItem, toStateV4,
20
35
  isHistoryExternal, HISTORY_ENTRIES_KEPT, resetGatesForNewWorkItem,
21
36
  } from './harness/index.mjs';
@@ -45,6 +60,7 @@ function showHelp() {
45
60
  npx cadet-agent@latest sync Update framework, preserving local policies/plans
46
61
  npx cadet-agent@latest sync --target <dir> Sync a specific directory
47
62
 
63
+ cadet-agent state init Create the first .cadet/state.json (never overwrites one)
48
64
  cadet-agent state validate Validate .cadet/state.json against the schema
49
65
  cadet-agent state validate --verify-sealed Also read sealed evidence from commit trailers
50
66
  cadet-agent state migrate Atomically migrate state to the current version (backup on write)
@@ -60,11 +76,20 @@ function showHelp() {
60
76
  cadet-agent harness verify Run a bounded, classified verification loop
61
77
  cadet-agent harness verify-acs Verify declared AC↔test coverage against a test report
62
78
  cadet-agent harness verify-reachability Verify a story's declared reachability (opt-in)
79
+ cadet-agent harness verify-play Verify a story's Play: declaration and record the deferral (opt-in)
80
+ cadet-agent harness play-form Write a user-playthrough form for a story, filled in from state (opt-in)
81
+ cadet-agent harness verify-design-review Check the design-review artifact and record the gate (opt-in)
82
+ cadet-agent harness acceptance-form Write a human-acceptance form for an epic, filled in from state
83
+ cadet-agent harness verify-architecture Run the project's declared architecture checks (opt-in)
84
+ cadet-agent harness context plan Write what the phase requires, and why (opt-in protocol)
85
+ cadet-agent harness context record Record what the host loaded, and the level it can claim
86
+ cadet-agent harness context validate Compare plan, record and current files (read-only)
63
87
  cadet-agent harness report Summarize budget consumption and failures
64
88
  cadet-agent harness changes List the files a story changed, with status, counts, and links
65
89
  cadet-agent harness reconcile Reconcile the planning chain against state.json (read-only)
66
90
  cadet-agent harness cleanup Apply the retention policy to .cadet/runs/
67
91
  cadet-agent harness capabilities Report available CLI/Unity/MCP/hook/token/cost telemetry
92
+ cadet-agent harness status Print the one-line framework health line (ok, or the problem)
68
93
 
69
94
  Options:
70
95
  --target, -t Target directory (default: current working directory)
@@ -80,7 +105,11 @@ function showHelp() {
80
105
  --expires-at ISO-8601 expiry bounding the confirmation (harness confirm)
81
106
  --environment key=value,... describing what was verified (harness confirm)
82
107
  --scope Comma-separated scope of the confirmation (harness confirm)
83
- --story Story markdown declaring the acceptance criteria or reachability (harness verify-acs|verify-reachability)
108
+ --story Story markdown declaring the acceptance criteria, reachability or play declaration (harness verify-acs|verify-reachability|verify-play|play-form)
109
+ --artifact Design-review artifact to check (verify-design-review), or the acceptance/playthrough form to record from (confirm --gate humanAcceptanceConfirmed|userPlaythroughConfirmed)
110
+ --epic Epic a generated form belongs to (harness acceptance-form)
111
+ --out Where to write the form (default: the epic's plan directory)
112
+
84
113
  --report Test report to derive the inventory from (harness verify-acs|matrix-check)
85
114
  --matrix TDD matrix markdown to check (harness matrix-check)
86
115
  --inventory Newline-separated test names, when no report is available (harness matrix-check)
@@ -95,6 +124,7 @@ function showHelp() {
95
124
  --epic Epic id of the work item being started (state begin)
96
125
  --commit-msg Path to write the prepared commit message to (state seal)
97
126
  --verify-sealed Also verify evidence sealed in commit trailers (state validate)
127
+ --verify-host Probe the configured host controls and report the measured level (harness capabilities)
98
128
  --dry-run Report what a mutating command would do and write nothing (all mutating commands)
99
129
  --yes, -y Never prompt; keep existing files (non-interactive installs)
100
130
  --help, -h Show this help (valid at any depth; never writes)
@@ -173,7 +203,10 @@ function parseArgs(argv) {
173
203
  // working-tree scan (which could bind evidence to Cadet's own files).
174
204
  case '--files': opts.filesGiven = true; opts.files = value(a).split(',').map((s) => s.trim()).filter(Boolean); break;
175
205
  case '--story': opts.story = value(a); break;
176
- // state begin: the epic the new work item belongs to.
206
+ case '--artifact': opts.artifact = value(a); break;
207
+ case '--out': opts.out = value(a); break;
208
+ // The epic the command acts on: `state begin`'s new work item, and the epic an
209
+ // acceptance form is generated for.
177
210
  case '--epic': opts.epicId = value(a); break;
178
211
  // state compact: keep every record inline instead of applying the
179
212
  // within-work-item retention rule. Made explicit at the call site, because
@@ -201,6 +234,27 @@ function parseArgs(argv) {
201
234
  case '--commit-msg': opts.commitMsgPath = value(a); break;
202
235
  // state validate: read commit trailers too, not just the live document.
203
236
  case '--verify-sealed': opts.verifySealed = true; break;
237
+ // The two flags the acceptance form replaced. They are PARSED so that they can be refused by
238
+ // name: without a case here they fell through into `opts.rest`, the command exited 0, and the
239
+ // values went nowhere — which contradicts this parser's own rule, written a few lines above,
240
+ // that a silently swallowed option is worse than a rejected one because the command still
241
+ // reports success. `harness confirm` refuses them (see the acceptance gate).
242
+ case '--witness': opts.witness = value(a); break;
243
+ case '--limitations': opts.limitations = value(a); break;
244
+ // state init: the first document's declared values. Each is validated before anything is
245
+ // written, so a typo cannot become a state file the rest of the framework then refuses.
246
+ case '--workflow-path': opts.workflowPath = value(a); break;
247
+ case '--tracking-mode': opts.trackingMode = value(a); break;
248
+ case '--learner-tier': opts.learnerTier = value(a); break;
249
+ case '--operating-mode': opts.operatingMode = value(a); break;
250
+ // `--phase` is declared once, above: state init and harness record both read opts.phase.
251
+ case '--verify-host': opts.verifyHost = true; break;
252
+ // The context protocol: what the host loaded, and what it can claim about it.
253
+ case '--level': opts.contextLevel = value(a); break;
254
+ case '--loaded': opts.contextLoaded = value(a).split(',').map((s) => s.trim()).filter(Boolean); break;
255
+ case '--enforced-by': opts.enforcedBy = value(a); break;
256
+ case '--transcript': opts.transcript = value(a); break;
257
+ case '--host': opts.host = value(a); break;
204
258
  case '--agents-md': opts.agentsMd = value(a); break;
205
259
  case '--yes': case '-y': opts.yes = true; break;
206
260
  default: opts.rest.push(a);
@@ -564,6 +618,80 @@ async function cmdState(opts) {
564
618
  return;
565
619
  }
566
620
 
621
+ if (sub === 'init') {
622
+ // The first state document, as a command.
623
+ //
624
+ // It had no command. `cadet-agent init` installs the framework, and then EVERY entry point
625
+ // refuses: `state begin` says "Initialise state before starting a work item", `state
626
+ // transition` says the same, `harness confirm` says the same — and there was no way to
627
+ // initialise it. The only route was a hand-written document, which is the pattern this
628
+ // framework condemns everywhere else, and the instruction that described it
629
+ // (`skills/Resume.md`) wrote `version: 1` with `session.workflowPath: null` — a document
630
+ // `state validate` REJECTS ("workflowPath is required", "unknown workflowPath \"null\"").
631
+ // So a new consumer's first document was unaudited and, followed literally, invalid; and
632
+ // every later gate reads that document.
633
+ const existing = readState(opts.targetDir);
634
+ if (existing.exists) {
635
+ fail(
636
+ opts,
637
+ 'A state document already exists. state init never overwrites one: '
638
+ + 'it is the document every gate reads, and rewriting it silently would discard the '
639
+ + 'evidence those gates were satisfied with. Delete or archive it first if that is '
640
+ + 'really what you want.',
641
+ () => 1,
642
+ { ok: false, code: 'state-exists', path: '.cadet/state.json' },
643
+ );
644
+ }
645
+
646
+ const workflowPath = opts.workflowPath || 'large';
647
+ const trackingMode = opts.trackingMode || 'markdown';
648
+ const currentPhase = opts.phase || PHASES[0];
649
+ const values = [
650
+ ['--workflow-path', workflowPath, ['large', 'small', 'no_test_required']],
651
+ ['--tracking-mode', trackingMode, ['markdown', 'github']],
652
+ ['--phase', currentPhase, PHASES],
653
+ ];
654
+ if (opts.learnerTier !== undefined) values.push(['--learner-tier', opts.learnerTier, ['beginner', 'intermediate', 'advanced', 'guided']]);
655
+ if (opts.operatingMode !== undefined) values.push(['--operating-mode', opts.operatingMode, ['instruction-first', 'implementation-first', 'hybrid']]);
656
+ for (const [flag, value, allowed] of values) {
657
+ if (!allowed.includes(value)) {
658
+ fail(opts, `unknown ${flag} value "${value}" (expected one of: ${allowed.join(', ')})`, () => 1, { ok: false, code: 'unknown-value', flag, value, allowed });
659
+ }
660
+ }
661
+
662
+ const initial = {
663
+ version: STATE_VERSION,
664
+ stateVersion: STATE_VERSION,
665
+ session: {
666
+ workflowPath,
667
+ currentPhase,
668
+ trackingMode,
669
+ ...(opts.learnerTier !== undefined ? { learnerTier: opts.learnerTier } : {}),
670
+ ...(opts.operatingMode !== undefined ? { operatingMode: opts.operatingMode } : {}),
671
+ },
672
+ activeWorkItem: null,
673
+ gates: Object.fromEntries(GATES.map((g) => [g, false])),
674
+ gateEvidence: [],
675
+ epics: {},
676
+ };
677
+
678
+ // Validate before writing, like every other writer here. A document that the framework
679
+ // would refuse must never reach disk: it is the file every gate reads.
680
+ const { errors } = validateState(initial, { rootDir: opts.targetDir });
681
+ if (errors.length > 0) {
682
+ fail(opts, `refusing to write an invalid state document: ${errors.map((e) => `${e.path}: ${e.message}`).join('; ')}`, () => 1, { ok: false, code: 'invalid-state', errors });
683
+ }
684
+
685
+ const written = writeState(opts.targetDir, initial);
686
+ emit(
687
+ opts,
688
+ `✅ Created .cadet/state.json (v${STATE_VERSION}): workflowPath "${workflowPath}", phase "${currentPhase}", trackingMode "${trackingMode}".\n`
689
+ + ' The framework is installed and the workflow starts here; nothing is tracked until the first work item ("cadet-agent state begin").',
690
+ { ok: true, version: STATE_VERSION, workflowPath, currentPhase, trackingMode, gates: Object.keys(initial.gates).length, appended: written?.appended ?? 0 },
691
+ );
692
+ return;
693
+ }
694
+
567
695
  if (sub === 'begin') {
568
696
  // The story boundary, as a command.
569
697
  //
@@ -759,16 +887,27 @@ async function cmdHarness(opts) {
759
887
  // depend on the agent reading it, because the dispatcher enforces the
760
888
  // registry regardless.
761
889
  const commands = describeAllCommands();
762
- if (opts.format === 'json') emit(opts, '', { ok: true, capabilities: caps, commands });
890
+ // What the host can actually stop, per action. `--verify-host` runs the probes; without it the
891
+ // declared position is printed and marked "declared", so a reader can always tell a measurement
892
+ // from a declaration — and never from the presence of a file.
893
+ const matrix = enforcementMatrix(opts.targetDir, { verify: opts.verifyHost === true });
894
+ if (opts.verifyHost === true) caps.hook.verified = matrix.hostHook.verified;
895
+ if (opts.format === 'json') emit(opts, '', { ok: true, capabilities: caps, enforcement: matrix, commands });
763
896
  else {
764
897
  console.log('Cadet-Agent capability report');
765
898
  console.log(` CLI: ${caps.cli ? 'available' : 'unavailable'}`);
766
899
  console.log(` Unity CLI: ${caps.unityCli.available ? `available (${caps.unityCli.version || 'version unknown'})` : 'unavailable — compile/analyzer gates fall back to manual confirmation'}`);
767
900
  console.log(` MCP: ${caps.mcp.available ? 'configured' : 'unavailable — live inspection not available'}`);
768
- console.log(` Copilot hook: ${caps.hook.copilot ? 'installed' : 'not installed'}`);
769
901
  console.log(` Token telemetry:${caps.tokenTelemetry.provider ? ' provider' : ' estimate/unknown'}`);
770
902
  console.log(` Cost telemetry: ${caps.costTelemetry.available ? 'available' : `unavailable (${caps.costTelemetry.reason})`}`);
771
- console.log(` Note: ${caps.hook.note}`);
903
+ console.log(` Host interception (${matrix.verified ? 'measured' : 'declared — pass --verify-host to measure'}):`);
904
+ for (const line of describeEnforcement(matrix)) console.log(line);
905
+ if (matrix.verified) {
906
+ console.log(` Host hook: ${matrix.hostHook.verified ? `verified — ${matrix.hostHook.reason}` : `not verified — ${matrix.hostHook.reason}`}`);
907
+ console.log(` Repo git hook: ${matrix.repoHook.verified ? `verified — ${matrix.repoHook.reason}` : `not verified — ${matrix.repoHook.reason}`}`);
908
+ } else {
909
+ console.log(` Note: ${caps.hook.note}`);
910
+ }
772
911
  console.log(' Commands (mutating commands honour --dry-run; nothing writes without it being declared):');
773
912
  for (const c of commands) {
774
913
  const bound = c.requiresForUnattended.length ? ` [unattended requires ${c.requiresForUnattended.join(', ')}]` : '';
@@ -787,6 +926,23 @@ async function cmdHarness(opts) {
787
926
  if (!exists) fail(opts, 'No .cadet/state.json found. Initialise state before recording confirmation.', () => 2);
788
927
  assertExpectedPhase(opts, state);
789
928
 
929
+ // A caller that passes the removed flags is refused by name. The acceptance is recorded from
930
+ // the form and from nothing else (see below); a witness or a limitation typed here would be
931
+ // discarded, and a discarded field that looked accepted is exactly the failure this framework
932
+ // is built to prevent.
933
+ const removedFlags = ['witness', 'limitations'].filter((k) => opts[k] !== undefined && opts[k] !== null);
934
+ if (removedFlags.length > 0) {
935
+ const flags = removedFlags.map((k) => `--${k}`);
936
+ fail(
937
+ opts,
938
+ `${flags.join(' and ')} no longer exist, so nothing was recorded. A human acceptance is recorded from its form: `
939
+ + 'run "cadet-agent harness acceptance-form --epic <epicId>", fill it in, and pass it back with --artifact <the form>. '
940
+ + 'The witness and the limitations live in that file, where a reviewer can read them.',
941
+ () => 1,
942
+ { ok: false, gate, code: 'flag-removed', flags },
943
+ );
944
+ }
945
+
790
946
  const strict = policy.strictClosure?.enabled === true ? policy.strictClosure : null;
791
947
  const mc = strict?.manualConfirmation || null;
792
948
  // One reference instant for the whole command, captured before any work.
@@ -797,18 +953,109 @@ async function cmdHarness(opts) {
797
953
 
798
954
  // Collect EVERY missing field so the caller fixes the record in one pass,
799
955
  // rather than discovering one omission per invocation.
956
+ //
957
+ // The two fields the acceptance ARTIFACT supplies are exempt when it is present. The check
958
+ // below refuses `--scope` and `--environment` beside `--artifact` (they would be a second,
959
+ // competing source for the same claim), so demanding them here made the command unsatisfiable
960
+ // in both directions under the shipped policy: with them it was `artifact-conflicts-with-flags`,
961
+ // without them `strict-metadata-missing`. A gate no caller can satisfy is not a strict gate.
962
+ // The form carries both, and its completeness is checked when it is parsed.
963
+ const artifactSuppliesScope = typeof opts.artifact === 'string' && opts.artifact !== '';
800
964
  const missing = [];
801
965
  if (mc?.requireReason !== false && strict && (!opts.reason || String(opts.reason).trim() === '')) missing.push('--reason');
802
966
  if (mc?.requireExpiresAt !== false && strict) {
803
967
  if (!opts.expiresAt) missing.push('--expires-at');
804
968
  else if (Number.isNaN(Date.parse(opts.expiresAt))) missing.push('--expires-at (not an ISO-8601 date-time)');
805
969
  }
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');
970
+ if (mc?.requireEnvironment !== false && strict && !artifactSuppliesScope && (!opts.environment || String(opts.environment).trim() === '')) missing.push('--environment');
971
+ if (mc?.requireScope !== false && strict && !artifactSuppliesScope && (!opts.scope || opts.scope.length === 0)) missing.push('--scope');
808
972
  if (missing.length) {
809
973
  fail(opts, `strictClosure requires manual-confirmation metadata. Missing: ${missing.join(', ')}.`, () => 1, { ok: false, gate, code: 'strict-metadata-missing', missing });
810
974
  }
811
975
 
976
+ // A human acceptance is recorded from a form, and from nothing else.
977
+ //
978
+ // There is no flag route. Two routes to one gate means the weaker route defines the
979
+ // gate, and the flag route's only advantage was skipping the file — at the cost of a
980
+ // record no person can read later. The form costs two commands, leaves the acceptance
981
+ // somewhere a reviewer can open, and removes the second copy that could disagree with
982
+ // the record. `--witness` and `--limitations` no longer exist; a caller that passes
983
+ // them is refused here rather than silently recorded, which is why the check is on
984
+ // the missing artifact rather than on the flags.
985
+ const FORM_RECORDED_GATES = new Set([HUMAN_ACCEPTANCE_GATE, USER_PLAY_GATE]);
986
+ if (gate === HUMAN_ACCEPTANCE_GATE && !opts.artifact) {
987
+ 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' });
988
+ }
989
+ // A playthrough is recorded from a form too, and from nothing else. The gate asks whether a
990
+ // PERSON played the delivered work, so the only route is that person's own account of it —
991
+ // which is also why `harness verify-play` refuses a `required` declaration.
992
+ if (gate === USER_PLAY_GATE && !opts.artifact) {
993
+ fail(opts, `a playthrough is recorded from a form: run "cadet-agent harness play-form --story <path>" to write one pre-filled from state, play the work, fill in its blank fields, then pass it back with --artifact <the form>.`, () => 1, { ok: false, gate, code: 'play-form-required' });
994
+ }
995
+
996
+ // `harness acceptance-form` and `harness play-form` write the form pre-filled from state;
997
+ // this reads it back.
998
+ if (opts.artifact) {
999
+ const isPlaythrough = gate === USER_PLAY_GATE;
1000
+ if (!FORM_RECORDED_GATES.has(gate)) {
1001
+ fail(opts, `--artifact is only for ${HUMAN_ACCEPTANCE_GATE} and ${USER_PLAY_GATE}: every other gate's evidence comes from its own command or from explicit fields.`, () => 1, { ok: false, gate, code: 'artifact-not-applicable' });
1002
+ }
1003
+ const conflicting = ['scope', 'environment'].filter((k) => opts[k]);
1004
+ if (conflicting.length > 0) {
1005
+ fail(opts, `--artifact already carries the record, 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 });
1006
+ }
1007
+ let formText;
1008
+ try {
1009
+ formText = readFileSync(opts.artifact, 'utf-8');
1010
+ } catch (err) {
1011
+ fail(opts, `the ${isPlaythrough ? 'playthrough' : 'acceptance'} form could not be read (${err.message}).`, () => 1, { ok: false, gate, code: 'artifact-unreadable' });
1012
+ }
1013
+
1014
+ if (isPlaythrough) {
1015
+ const form = parsePlayForm(formText);
1016
+ if (form.incomplete.length > 0) {
1017
+ fail(opts, `the form still has unfilled fields: ${form.incomplete.join(', ')}. Fill them in ${opts.artifact} and run the command again — a playthrough nobody described is not a playthrough.`, () => 1, { ok: false, gate, code: 'play-form-incomplete', missing: form.incomplete });
1018
+ }
1019
+ // The record binds to the ACTIVE work item, so a form that plays a different story is
1020
+ // refused rather than recorded against the one in flight — the same failure the
1021
+ // story-bound evidence rules exist to prevent, wearing a filled-in form.
1022
+ const activeStory = state?.activeWorkItem?.storyId || null;
1023
+ const formStory = String(form.story || '');
1024
+ if (activeStory && formStory && !formStory.includes(activeStory)) {
1025
+ fail(opts, `the form plays "${form.story}", but the active work item is "${activeStory}". Record a playthrough of the work in flight, or begin that story first.`, () => 1, { ok: false, gate, code: 'play-form-story-mismatch', story: form.story, active: activeStory });
1026
+ }
1027
+ opts.witness = form.witness;
1028
+ opts.limitations = form.limitations;
1029
+ opts.environment = form.environment || null;
1030
+ opts.scope = [form.story || activeStory || 'playthrough'];
1031
+ if (!opts.files || opts.files.length === 0) opts.files = form.fileList;
1032
+ opts.files = opts.files && opts.files.length ? opts.files : null;
1033
+ opts.filesGiven = opts.files !== null;
1034
+ opts.playedBy = form.player;
1035
+ } else {
1036
+ const form = parseAcceptanceForm(formText);
1037
+ if (form.incomplete.length > 0) {
1038
+ 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 });
1039
+ }
1040
+ if (state && form.epic && state.epics && !state.epics[form.epic]) {
1041
+ 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 });
1042
+ }
1043
+ opts.witness = form.witness;
1044
+ opts.limitations = form.limitations;
1045
+ opts.environment = form.environment || null;
1046
+ opts.scope = [form.epic];
1047
+ if (!opts.files || opts.files.length === 0) opts.files = form.fileList;
1048
+ opts.files = opts.files && opts.files.length ? opts.files : null;
1049
+ opts.filesGiven = opts.files !== null;
1050
+ opts.acceptedBy = form.acceptor;
1051
+ }
1052
+ }
1053
+
1054
+ // No check for empty witness/limitations is needed here: the form is the only route
1055
+ // to this gate, and `parseAcceptanceForm` refuses a form whose fields are blank or
1056
+ // still placeholders, so an empty value cannot reach this point. A second check would
1057
+ // be a guard that cannot fire, and the one that cannot fire is the one that rots.
1058
+
812
1059
  // A gate listed in disallowManualFor may never be satisfied by a human
813
1060
  // assertion; point at the automated path instead of accepting the record.
814
1061
  if (strict && Array.isArray(strict.disallowManualFor) && strict.disallowManualFor.includes(gate)) {
@@ -873,6 +1120,8 @@ async function cmdHarness(opts) {
873
1120
  rootDir: opts.targetDir,
874
1121
  approvedBy: opts.approvedBy || 'user',
875
1122
  commit: opts.commit || null,
1123
+ witness: opts.witness || null,
1124
+ limitations: opts.limitations || null,
876
1125
  at,
877
1126
  });
878
1127
 
@@ -942,6 +1191,20 @@ async function cmdHarness(opts) {
942
1191
  if (sub === 'verify') {
943
1192
  const gate = opts.gate;
944
1193
  if (!gate) fail(opts, 'harness verify requires --gate <gate>');
1194
+ // The gate NAME selects the evidence contract, so an unknown name is refused
1195
+ // before anything runs. A made-up gate used to be accepted with --command and
1196
+ // recorded as an automated pass, which put evidence into state.json for a name
1197
+ // no transition can read.
1198
+ if (!GATES.includes(gate)) {
1199
+ fail(opts, describeGateRefusal(gate), () => 1, { ok: false, gate, code: 'unknown-gate' });
1200
+ }
1201
+ // A project command may fill only the slots whose evidence IS a project
1202
+ // command. Anywhere else it would let an unrelated exit-zero command attest a
1203
+ // claim it does not prove — which is how `codeReviewCompleted` could be
1204
+ // satisfied by `node -e "process.exit(0)"`.
1205
+ if (opts.command && gateBuilder(gate)?.projectCommand !== true) {
1206
+ fail(opts, describeGateRefusal(gate), () => 1, { ok: false, gate, code: 'gate-not-overridable' });
1207
+ }
945
1208
  const { state } = readState(opts.targetDir);
946
1209
  assertExpectedPhase(opts, state);
947
1210
  const caps = detectCapabilities({ targetDir: opts.targetDir });
@@ -1083,10 +1346,12 @@ async function cmdHarness(opts) {
1083
1346
  }
1084
1347
 
1085
1348
  if (sub === 'verify-acs') {
1349
+ refuseCommandOverride('harness verify-acs', 'the acceptance criteria in the story and the test report');
1086
1350
  // Mechanical AC↔test verification (contract v4). Declared tests must appear
1087
1351
  // in the inventory of a run that actually executed them; a name that was
1088
1352
  // never written cannot be asserted into coverage.
1089
1353
  if (!opts.story) fail(opts, 'harness verify-acs requires --story <path>');
1354
+ const storyPath = resolve(opts.targetDir, opts.story);
1090
1355
  const { exists, state } = readState(opts.targetDir);
1091
1356
  assertExpectedPhase(opts, state);
1092
1357
  const strict = policy.strictClosure?.enabled === true;
@@ -1095,7 +1360,14 @@ async function cmdHarness(opts) {
1095
1360
 
1096
1361
  let criteria;
1097
1362
  try {
1098
- ({ criteria } = parseStoryCriteria(opts.story));
1363
+ // The story is READ from the target repository, exactly as verify-reachability reads it, and
1364
+ // not from the process working directory. Reading it from the CWD while the record below
1365
+ // binds `<target>/<story>` was two defects in one line: the command was unusable from
1366
+ // outside the project (`--target` with a CWD elsewhere), and — worse — when a file of that
1367
+ // name happened to exist under the CWD it parsed THAT file's criteria and wrote a record
1368
+ // attesting them against the target's path, which is the silently-inert binding the comment
1369
+ // below warns about.
1370
+ ({ criteria } = parseStoryCriteria(storyPath));
1099
1371
  } catch (err) {
1100
1372
  fail(opts, `cannot parse story "${opts.story}": ${err.message}`, () => 1, { ok: false, code: 'story-parse', story: opts.story });
1101
1373
  }
@@ -1111,7 +1383,10 @@ async function cmdHarness(opts) {
1111
1383
  let reportPath = null;
1112
1384
  if (opts.report) {
1113
1385
  try {
1114
- reportText = readFileSync(opts.report, 'utf-8');
1386
+ // Resolved against the target, like `--story` above and like every other path flag in this
1387
+ // CLI (matrix-check, harness changes). CWD-relative reads made the command unusable from
1388
+ // outside the project and could have read a report from a different tree.
1389
+ reportText = readFileSync(resolve(opts.targetDir, opts.report), 'utf-8');
1115
1390
  reportSource = 'explicit';
1116
1391
  reportPath = opts.report;
1117
1392
  } catch (err) {
@@ -1212,11 +1487,10 @@ async function cmdHarness(opts) {
1212
1487
  // the same treatment and is kept as `artifactPath` for audit, where nothing
1213
1488
  // re-hashes it.
1214
1489
  //
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);
1490
+ // The story path is made repo-relative (see the read above: it resolves against the target,
1491
+ // never the working directory) for the same reason verify-reachability does it: an absolute
1492
+ // path never resolves under the root when freshness is re-derived at transition time, so both
1493
+ // hashes would be computed over a missing file and match — a binding that is silently inert.
1220
1494
  const storyRel = relative(opts.targetDir, storyPath).replace(/\\/g, '/') || basename(storyPath);
1221
1495
  const evidence = createEvidence({
1222
1496
  evidenceId: newId(),
@@ -1287,6 +1561,7 @@ async function cmdHarness(opts) {
1287
1561
  }
1288
1562
 
1289
1563
  if (sub === 'verify-reachability') {
1564
+ refuseCommandOverride('harness verify-reachability', "the story's reachability declaration and the repository's own probe");
1290
1565
  // Mechanical reachability verification (contract v6 §2). A story declares how
1291
1566
  // its deliverable becomes witnessable, or which work item will make it so;
1292
1567
  // this checks that declaration against the work items that exist, and runs
@@ -1443,14 +1718,632 @@ async function cmdHarness(opts) {
1443
1718
  return;
1444
1719
  }
1445
1720
 
1721
+ if (sub === 'verify-play') {
1722
+ refuseCommandOverride('harness verify-play', "the story's Play: declaration");
1723
+ // Mechanical play-declaration verification (contract v7 §1). A story states whether a
1724
+ // person can play its deliverable, or which work item will make it playable; this checks
1725
+ // that declaration against the work items that exist.
1726
+ //
1727
+ // WHY THIS COMMAND CANNOT SET THE GATE FOR A PLAYABLE STORY. The gate asks whether a
1728
+ // PERSON played the work. So this records the gate for a `deferred` declaration — the
1729
+ // declaration is the answer, exactly as a reachability deferral is — and REFUSES to
1730
+ // record it for a `required` one, pointing at the form. An agent that could answer
1731
+ // "the user played it" in a sentence would make the gate worthless, which is the whole
1732
+ // reason `userPlaythroughConfirmed` is human-owned in the gate registry.
1733
+ if (!opts.story) fail(opts, 'harness verify-play requires --story <path>');
1734
+ const storyPath = resolve(opts.targetDir, opts.story);
1735
+ const storyRel = relative(opts.targetDir, storyPath).replace(/\\/g, '/') || basename(storyPath);
1736
+ const { exists, state } = readState(opts.targetDir);
1737
+ assertExpectedPhase(opts, state);
1738
+ const playEnabled = policy.userPlay?.enabled === true;
1739
+ const workItemId = state ? workItemIdOf(state) : 'unscoped';
1740
+ const phase = state?.session?.currentPhase || 'implementation';
1741
+
1742
+ let playDeclaration;
1743
+ try {
1744
+ playDeclaration = parsePlayDeclaration(storyPath);
1745
+ } catch (err) {
1746
+ fail(opts, `cannot read story "${opts.story}": ${err.message}`, () => 1, { ok: false, code: 'story-unreadable', story: opts.story });
1747
+ }
1748
+
1749
+ const playWorkItems = exists ? collectWorkItems(state) : null;
1750
+ const playValidation = validatePlayDeclaration(playDeclaration, { workItems: playWorkItems, self: basename(storyPath) });
1751
+ const playSiblings = readSiblingPlayDeclarations(storyPath, { workItems: playWorkItems });
1752
+ const playGraph = playSiblings.length > 0
1753
+ ? playSiblings
1754
+ : [{ id: basename(storyPath), aliases: [], declaration: playDeclaration }];
1755
+ const playCycles = exists ? findDeferralCycles(playGraph) : [];
1756
+ const playGaps = describePlayGaps({ validation: playValidation, cycles: playCycles, story: opts.story });
1757
+
1758
+ // A playable story. The gate is NOT set here, and the refusal names the route that can.
1759
+ if (playValidation.ok && playValidation.code === 'required') {
1760
+ const formHint = `run "cadet-agent harness play-form --story ${opts.story}" to write one pre-filled from state, play the game, fill in the three blank fields, then pass it back with --artifact <the form>.`;
1761
+ fail(
1762
+ opts,
1763
+ `${USER_PLAY_GATE} is a person's record, and this story declares its deliverable playable: ${playDeclaration.instruction}. ${formHint}`,
1764
+ () => 1,
1765
+ { ok: false, gate: USER_PLAY_GATE, code: 'play-form-required', story: opts.story, declaration: playDeclaration },
1766
+ );
1767
+ }
1768
+
1769
+ const playOk = playValidation.ok && playCycles.length === 0;
1770
+
1771
+ if (!playEnabled) {
1772
+ if (opts.format === 'json') {
1773
+ emit(opts, '', { ok: playOk, story: opts.story, declaration: playDeclaration, play: playValidation, cycles: playCycles, gateSet: false, enabled: false });
1774
+ } else if (playOk) {
1775
+ console.log(`✅ Play declaration for ${opts.story}: ${playValidation.message}`);
1776
+ console.log(' userPlay.enabled is false — reported only, state.json unchanged.');
1777
+ } else {
1778
+ console.error(`⚠️ Play gaps in ${opts.story} (userPlay.enabled is false — reported only):`);
1779
+ for (const line of playGaps) console.error(line);
1780
+ }
1781
+ if (!playOk) process.exit(1);
1782
+ return;
1783
+ }
1784
+
1785
+ if (!playOk) {
1786
+ const detail = {
1787
+ ok: false,
1788
+ story: opts.story,
1789
+ declaration: playDeclaration,
1790
+ play: playValidation,
1791
+ cycles: playCycles,
1792
+ gateSet: false,
1793
+ code: playValidation.ok !== true ? playValidation.code : 'deferral-cycle',
1794
+ };
1795
+ if (opts.format === 'json') emit(opts, '', detail);
1796
+ else {
1797
+ console.error(`❌ Cannot set ${USER_PLAY_GATE} for ${opts.story}:`);
1798
+ for (const line of playGaps) console.error(line);
1799
+ }
1800
+ process.exit(1);
1801
+ }
1802
+
1803
+ const playAt = new Date();
1804
+ const playEvidence = createEvidence({
1805
+ evidenceId: newId(),
1806
+ workItemId,
1807
+ acceptanceCriterionId: null,
1808
+ phase,
1809
+ gate: USER_PLAY_GATE,
1810
+ status: 'passed',
1811
+ command: `harness verify-play --story ${opts.story}`,
1812
+ result: `play deferred (${playValidation.code})`,
1813
+ exitCode: 0,
1814
+ commit: opts.commit || null,
1815
+ inputTreeHash: computeInputTreeHash(opts.targetDir, [storyRel]),
1816
+ criteriaHash: hashCriteria([
1817
+ workItemId,
1818
+ playValidation.code,
1819
+ playDeclaration.deferTo || playDeclaration.instruction || '',
1820
+ ]),
1821
+ relevantFiles: [storyRel],
1822
+ createdAt: playAt,
1823
+ expiresAt: null,
1824
+ freshnessPolicy: { scope: 'story' },
1825
+ source: 'automated',
1826
+ });
1827
+
1828
+ const playLedger = new RunLedger({ targetDir: opts.targetDir, policy, runId: state?.activeRunId || null, workItemId, phase });
1829
+ playLedger.addEvidence(playEvidence);
1830
+ playLedger.addDecision({ kind: 'stop', reason: `play deferred (${playValidation.code})`, scope: 'declaration only' });
1831
+ playLedger.finalize({ status: 'ok' });
1832
+ const playLedgerPath = playLedger.persist();
1833
+
1834
+ if (exists) {
1835
+ const next = recordEvidence(state, playEvidence);
1836
+ writeState(opts.targetDir, next);
1837
+ }
1838
+
1839
+ if (opts.format === 'json') {
1840
+ emit(opts, '', { ok: true, story: opts.story, play: playValidation, cycles: playCycles, evidenceId: playEvidence.evidenceId, gateSet: exists, runId: playLedger.runId, path: playLedgerPath });
1841
+ } else {
1842
+ console.log(`✅ ${USER_PLAY_GATE} for ${opts.story}: ${playValidation.message}`);
1843
+ console.log(' The story defers the playthrough, so the declaration is the record — no form is needed.');
1844
+ console.log(` Ledger: ${playLedgerPath}`);
1845
+ }
1846
+ return;
1847
+ }
1848
+
1849
+ if (sub === 'play-form') {
1850
+ if (!opts.story) {
1851
+ fail(opts, 'harness play-form needs --story <path>: the form belongs to the story being played.', () => 1, { ok: false, code: 'story-required' });
1852
+ }
1853
+ const { exists, state } = readState(opts.targetDir);
1854
+ if (!exists) {
1855
+ 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' });
1856
+ }
1857
+ const storyPath = resolve(opts.targetDir, opts.story);
1858
+ const storyRel = relative(opts.targetDir, storyPath).replace(/\\/g, '/') || basename(storyPath);
1859
+ let playDeclaration = null;
1860
+ try {
1861
+ playDeclaration = parsePlayDeclaration(storyPath);
1862
+ } catch (err) {
1863
+ fail(opts, `cannot read story "${opts.story}": ${err.message}`, () => 1, { ok: false, code: 'story-unreadable', story: opts.story });
1864
+ }
1865
+ let playTemplate;
1866
+ try {
1867
+ playTemplate = readPlayTemplate(opts.targetDir);
1868
+ } catch (err) {
1869
+ fail(opts, `${err.message}. 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' });
1870
+ }
1871
+ const playText = buildPlayForm({
1872
+ template: playTemplate,
1873
+ state,
1874
+ storyPath,
1875
+ storyRel,
1876
+ targetDir: opts.targetDir,
1877
+ declaration: playDeclaration,
1878
+ });
1879
+ const playResult = writePlayForm(opts.targetDir, storyRel, playText, { out: opts.out });
1880
+ if (!playResult.written) {
1881
+ fail(opts, `harness play-form refuses to overwrite an existing form: ${playResult.reason} (${playResult.path}).`, () => 1, { ok: false, code: 'form-exists', path: playResult.path });
1882
+ }
1883
+ const playShown = playResult.path.slice(opts.targetDir.length + 1).replace(/\\/g, '/');
1884
+ if (opts.format === 'json') {
1885
+ emit(opts, '', { ok: true, path: playShown, story: opts.story, next: `cadet-agent harness confirm --gate ${PLAY_FORM_GATE} --artifact ${playShown}` });
1886
+ } else {
1887
+ console.log(`Playthrough form written: ${playShown}`);
1888
+ console.log(' Play the work, fill in the three unfilled fields, then run:');
1889
+ console.log(` cadet-agent harness confirm --gate ${PLAY_FORM_GATE} --artifact ${playShown}`);
1890
+ }
1891
+ return;
1892
+ }
1893
+
1894
+ if (sub === 'acceptance-form') {
1895
+ if (!opts.epicId) {
1896
+ fail(opts, 'harness acceptance-form needs --epic <epicId>: the form belongs to the epic being accepted.', () => 1, { ok: false, code: 'epic-required' });
1897
+ }
1898
+ const { exists, state } = readState(opts.targetDir);
1899
+ if (!exists) {
1900
+ 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' });
1901
+ }
1902
+ if (state.epics && !state.epics[opts.epicId]) {
1903
+ 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 });
1904
+ }
1905
+ const templatePath = join(opts.targetDir, ...ACCEPTANCE_TEMPLATE_RELATIVE.split('/'));
1906
+ let template;
1907
+ try {
1908
+ template = readFileSync(templatePath, 'utf-8');
1909
+ } catch (err) {
1910
+ 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' });
1911
+ }
1912
+ const { text } = buildAcceptanceForm({ template, state, epicId: opts.epicId, targetDir: opts.targetDir });
1913
+ const result = writeAcceptanceForm(opts.targetDir, opts.epicId, text, { out: opts.out });
1914
+ if (!result.written) {
1915
+ fail(opts, `harness acceptance-form refuses to overwrite an existing form: ${result.reason} (${result.path}).`, () => 1, { ok: false, code: 'form-exists', path: result.path });
1916
+ }
1917
+ const shown = result.path.slice(opts.targetDir.length + 1).replace(/\\/g, '/');
1918
+ if (opts.format === 'json') {
1919
+ emit(opts, '', { ok: true, path: shown, epic: opts.epicId, next: `cadet-agent harness confirm --gate ${HUMAN_ACCEPTANCE_GATE} --artifact ${shown}` });
1920
+ } else {
1921
+ console.log(`Acceptance form written: ${shown}`);
1922
+ console.log(` Fill in the three unfilled fields, then run:`);
1923
+ console.log(` cadet-agent harness confirm --gate ${HUMAN_ACCEPTANCE_GATE} --artifact ${shown}`);
1924
+ }
1925
+ return;
1926
+ }
1927
+
1928
+ if (sub === 'context') {
1929
+ // The runtime context boundary: plan, record, validate.
1930
+ //
1931
+ // WHY THIS EXISTS. Cadet does not inject context into a model — hosts own model context — so
1932
+ // the honest thing to build is a protocol rather than a claim: say what a phase requires,
1933
+ // let the host report what it loaded, and compare the two. Without the record, "the agent
1934
+ // read the skill file" is an assertion nothing can check, and a framework whose whole point
1935
+ // is that claims carry evidence should not make an exception for its own central act.
1936
+ const action = opts.rest[1];
1937
+ if (!['plan', 'record', 'validate'].includes(action)) {
1938
+ fail(opts, `harness context needs an action: plan, record or validate (got "${action || '(none)'}").`, () => 1, { ok: false, code: 'context-action-required' });
1939
+ }
1940
+ const { exists, state } = readState(opts.targetDir);
1941
+
1942
+ if (action === 'plan') {
1943
+ const plan = buildContextPlan({ targetDir: opts.targetDir, policy, state });
1944
+ const planPath = writeContextPlan(opts.targetDir, plan);
1945
+ const shown = relative(opts.targetDir, planPath).replace(/\\/g, '/');
1946
+ const fit = plan.budget.fits === null ? 'no context budget declared'
1947
+ : plan.budget.fits ? `fits the ${plan.budget.hardContextTokens}-token budget`
1948
+ : `DOES NOT fit the ${plan.budget.hardContextTokens}-token budget (required ${plan.budget.requiredTokens})`;
1949
+ if (opts.format === 'json') {
1950
+ emit(opts, '', { ok: true, path: shown, phase: plan.phase, workItemId: plan.workItemId, required: plan.required, advisory: plan.advisory, absent: plan.absent, budget: plan.budget });
1951
+ } else {
1952
+ console.log(`Context plan for ${plan.phase}${plan.workItemId ? ` (${plan.workItemId})` : ''}: ${plan.required.length} required, ${plan.advisory.length} advisory — ${fit}`);
1953
+ for (const item of plan.required) {
1954
+ console.log(` ${item.present ? '📌' : '⚠️ '} ${item.reference} [${item.tier}] ${item.reason}${item.present ? ` — ${item.bytes} B, ~${item.estimatedTokens} tokens` : ' — MISSING'}`);
1955
+ }
1956
+ for (const item of plan.advisory) {
1957
+ console.log(` · ${item.reference} [${item.tier}] ${item.reason}${item.present ? '' : ' (absent)'}`);
1958
+ }
1959
+ console.log(` Written: ${shown}`);
1960
+ }
1961
+ return;
1962
+ }
1963
+
1964
+ if (action === 'record') {
1965
+ const plan = readContextPlan(opts.targetDir);
1966
+ let loaded = opts.contextLoaded || [];
1967
+ const notes = [];
1968
+ if (opts.transcript) {
1969
+ let text;
1970
+ try {
1971
+ text = readFileSync(opts.transcript, 'utf-8');
1972
+ } catch (err) {
1973
+ fail(opts, `the transcript could not be read (${err.message}).`, () => 1, { ok: false, code: 'transcript-unreadable' });
1974
+ }
1975
+ const parsed = parseTranscript(text);
1976
+ if (parsed.problems.length > 0) {
1977
+ 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 });
1978
+ }
1979
+ loaded = parsed.loaded;
1980
+ notes.push(`loads taken from the transcript at ${opts.transcript}`);
1981
+ }
1982
+ let record;
1983
+ try {
1984
+ record = buildContextRecord({
1985
+ targetDir: opts.targetDir, policy, state, plan,
1986
+ level: opts.contextLevel || 'recorded',
1987
+ loaded, enforcedBy: opts.enforcedBy || null, host: opts.host || null, notes,
1988
+ });
1989
+ } catch (err) {
1990
+ if (err instanceof ContextProtocolError) fail(opts, err.message, () => 1, { ok: false, code: err.code });
1991
+ throw err;
1992
+ }
1993
+ const recordPath = writeContextRecord(opts.targetDir, record);
1994
+ const shown = relative(opts.targetDir, recordPath).replace(/\\/g, '/');
1995
+ if (opts.format === 'json') {
1996
+ emit(opts, '', { ok: true, path: shown, level: record.level, enforcedBy: record.enforcedBy, workItemId: record.workItemId, loaded: record.loaded, notes: record.notes });
1997
+ } else {
1998
+ console.log(`Context record: level "${record.level}"${record.enforcedBy ? ` (enforced by ${record.enforcedBy})` : ''}, ${record.loaded.length} reference(s) reported`);
1999
+ console.log(` ${describeContextState({ plan, record }).line}`);
2000
+ console.log(` Written: ${shown}`);
2001
+ if (record.level === 'estimated' || record.level === 'unavailable') {
2002
+ console.log(' This level cannot certify a context-complete checkpoint: nobody observed what was loaded.');
2003
+ }
2004
+ }
2005
+ return;
2006
+ }
2007
+
2008
+ // validate — read-only, so it must write nothing.
2009
+ const plan = readContextPlan(opts.targetDir);
2010
+ const record = readContextRecord(opts.targetDir);
2011
+ if (!plan) {
2012
+ 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' });
2013
+ }
2014
+ // Rebuild the plan against the CURRENT phase and work item: a plan written for a phase the
2015
+ // session has since left describes context this turn does not need, and validating against it
2016
+ // would pass a checkpoint for the wrong turn.
2017
+ const current = buildContextPlan({ targetDir: opts.targetDir, policy, state });
2018
+ const verdict = validateContextRecord({ targetDir: opts.targetDir, plan: current, record });
2019
+ const state_ = describeContextState({ plan: current, record, verdict });
2020
+
2021
+ if (opts.format === 'json') {
2022
+ emit(opts, '', {
2023
+ ok: verdict.ok, code: verdict.code, level: verdict.level,
2024
+ workItemId: current.workItemId, phase: current.phase,
2025
+ required: current.required.length, loaded: (record?.loaded || []).length,
2026
+ missingRequired: verdict.missingRequired, staleRequired: verdict.staleRequired,
2027
+ advisoryMissing: verdict.advisoryMissing, absentRequired: verdict.absentRequired || [],
2028
+ reasons: verdict.reasons, plannedFor: record?.phase || null,
2029
+ });
2030
+ } else {
2031
+ console.log(`${verdict.ok ? '✅' : '❌'} ${state_.line}`);
2032
+ for (const reason of verdict.reasons) console.log(` ${reason}`);
2033
+ if (verdict.ok) console.log(' Required context is loaded and unchanged: a context-complete checkpoint can be claimed.');
2034
+ }
2035
+ process.exit(verdict.ok ? 0 : 1);
2036
+ }
2037
+
2038
+ /**
2039
+ * Refuse `--command` on a command whose evidence comes from somewhere else.
2040
+ *
2041
+ * The same rule the gate registry states for a gate: a command supplied at the call site proves
2042
+ * nothing about the repository, because the caller chooses both the question and the answer. The
2043
+ * flag was PARSED globally and then ignored by these commands, so it exited 0 having done
2044
+ * nothing with it — the silently swallowed option this CLI's own parser comments warn about.
2045
+ * `harness verify --gate <g>` refuses it with `gate-not-overridable` for the gates that take no
2046
+ * command; these refuse it with `command-not-accepted`.
2047
+ */
2048
+ // Declared as a function, not a const arrow: the two `verify-acs`/`verify-reachability` branches
2049
+ // run before this point in the dispatch, and a const would leave them in the temporal dead zone.
2050
+ function refuseCommandOverride(command, instead) {
2051
+ if (opts.command === undefined) return;
2052
+ fail(
2053
+ opts,
2054
+ `${command} takes no --command: it reads its evidence from ${instead}, and a command supplied here would `
2055
+ + 'let the caller choose both the question and the answer. Pass the artifact or the declaration instead.',
2056
+ () => 1,
2057
+ { ok: false, code: 'command-not-accepted', command },
2058
+ );
2059
+ }
2060
+
2061
+ if (sub === 'verify-architecture') {
2062
+ refuseCommandOverride('harness verify-architecture', 'the checks declared under architectureFitness in .cadet/harness.json');
2063
+ // The project's declared executable constraints, run and recorded.
2064
+ //
2065
+ // WHY THE POLICY IS THE ONLY SOURCE OF THE COMMANDS. A gate whose command can be
2066
+ // supplied at the call site proves nothing about the repository: the caller chooses
2067
+ // both the question and the answer. Here the questions are declared in
2068
+ // `.cadet/harness.json` — with ids, scopes and severities — and this command only runs
2069
+ // them and writes down what happened. There is deliberately no `--command`.
2070
+ //
2071
+ // NOT OPTED IN: report and write nothing, the compatibility rule every opt-in gate in
2072
+ // this repository follows.
2073
+ if (!architectureFitnessActive(policy)) {
2074
+ const declared = policy.architectureFitness?.checks?.length ?? 0;
2075
+ const detail = {
2076
+ ok: true, gateSet: false, enabled: policy.architectureFitness?.enabled === true,
2077
+ declared, checks: [], note: declared === 0
2078
+ ? 'architectureFitness declares no checks: nothing to verify, nothing recorded.'
2079
+ : 'architectureFitness.enabled is false: the checks are declared but nothing runs.',
2080
+ };
2081
+ if (opts.format === 'json') emit(opts, '', detail);
2082
+ else {
2083
+ console.log(`ℹ️ ${detail.note}`);
2084
+ console.log(' Declare checks under architectureFitness in .cadet/harness.json and set "enabled": true to require them.');
2085
+ }
2086
+ return;
2087
+ }
2088
+
2089
+ const { exists, state } = readState(opts.targetDir);
2090
+ assertExpectedPhase(opts, state);
2091
+
2092
+ // Which files do the checks judge? The same answer `harness verify` and `harness
2093
+ // confirm` give, for the same reason: a record that does not know what it judged
2094
+ // cannot go stale.
2095
+ const allowEmpty = policy?.allowEmptyFreshness === true;
2096
+ let relevantFiles;
2097
+ if (opts.files && opts.files.length) {
2098
+ relevantFiles = opts.files.map((f) => String(f).replace(/\\/g, '/'));
2099
+ } else {
2100
+ const changed = gitChangedFiles(opts.targetDir);
2101
+ if (!changed.available) {
2102
+ if (!allowEmpty) {
2103
+ const scoped = (policy.architectureFitness.checks || []).some((c) => c.files?.length);
2104
+ fail(opts, `cannot establish which files the checks should judge: ${changed.reason}.`
2105
+ + (scoped
2106
+ ? ' Pass --files <paths> — a project that scopes its checks needs to know which files changed, or a scoped check would silently skip.'
2107
+ : ' Pass --files <paths>, or enable allowEmptyFreshness in .cadet/harness.json.'), () => 1, { ok: false, code: 'freshness-unavailable' });
2108
+ }
2109
+ relevantFiles = [];
2110
+ } else {
2111
+ relevantFiles = changed.files;
2112
+ }
2113
+ }
2114
+
2115
+ const { results, skipped } = await runArchitectureChecks({
2116
+ checks: policy.architectureFitness.checks,
2117
+ relevantFiles,
2118
+ rootDir: opts.targetDir,
2119
+ defaultTimeoutMs: DEFAULT_CHECK_TIMEOUT_MS,
2120
+ evidenceDir: join(opts.targetDir, '.cadet', 'runs', 'architecture'),
2121
+ });
2122
+ const summary = summariseChecks(results, { declared: policy.architectureFitness.checks.length, skipped });
2123
+
2124
+ const workItemId = state ? workItemIdOf(state) : 'unscoped';
2125
+ const phase = state?.session?.currentPhase || 'implementation';
2126
+ const status = summary.gatePassed ? 'passed' : (summary.blocked.length > 0 && summary.failed.length === 0 ? 'blocked' : 'failed');
2127
+ const resultText = results.length === 0
2128
+ ? summary.note
2129
+ : `${results.filter((r) => r.status === 'passed').length}/${results.length} check(s) passed`
2130
+ + (summary.failed.length ? `; failed: ${summary.failed.join(', ')}` : '')
2131
+ + (summary.blocked.length ? `; blocked: ${summary.blocked.join(', ')}` : '')
2132
+ + (summary.advisoryFailed.length ? `; advisory (not blocking): ${summary.advisoryFailed.join(', ')}` : '');
2133
+
2134
+ // The artifacts a check declared are part of what this record attests, and they are bound
2135
+ // per check (`checks[].artifactPath` + `artifactHash`) — NOT folded into `relevantFiles` and
2136
+ // NOT hashed into `inputTreeHash`: a check that rewrites its own report would otherwise stale
2137
+ // a record that describes an unchanged tree.
2138
+ //
2139
+ // Folding them into `relevantFiles` was a defect, and a self-inconsistent one: `inputTreeHash`
2140
+ // covers `relevantFiles` as it was BEFORE the artifact paths were added, while the record then
2141
+ // stored the union. Freshness re-derives the hash from the record's own `relevantFiles`, so
2142
+ // every record from a check that declared an artifact read as stale the moment it was written
2143
+ // ("input tree hash changed since the evidence was recorded"), and the gate it satisfied could
2144
+ // never be used again — which made `implementation -> review` unreachable for any project whose
2145
+ // checks write a report. The record now hashes exactly the set it stores, and the artifacts stay
2146
+ // bound where they were already recorded: in the check entries.
2147
+ const artifactPaths = results.map((r) => r.artifactPath).filter(Boolean);
2148
+ const boundFiles = relevantFiles;
2149
+
2150
+ const evidence = createEvidence({
2151
+ evidenceId: newId(),
2152
+ workItemId,
2153
+ acceptanceCriterionId: null,
2154
+ phase,
2155
+ gate: ARCHITECTURE_GATE,
2156
+ status,
2157
+ command: `harness verify-architecture (${results.map((r) => r.id).join(',') || 'none applicable'})`,
2158
+ result: resultText,
2159
+ exitCode: 0,
2160
+ inputTreeHash: computeInputTreeHash(opts.targetDir, relevantFiles),
2161
+ criteriaHash: hashCriteria([]),
2162
+ relevantFiles,
2163
+ createdAt: new Date(),
2164
+ });
2165
+ evidence.checks = results.map((r) => ({
2166
+ id: r.id, severity: r.severity, status: r.status, exitCode: r.exitCode,
2167
+ artifactPath: r.artifactPath, artifactHash: r.artifactHash, refs: r.refs,
2168
+ }));
2169
+
2170
+ const ledger = new RunLedger({ targetDir: opts.targetDir, policy, runId: state?.activeRunId || null, workItemId, phase });
2171
+ ledger.addEvidence(evidence);
2172
+ for (const r of results) {
2173
+ ledger.addDecision({ kind: 'check', reason: `architecture check ${r.id}: ${r.status}${r.reason ? ` (${r.reason})` : ''}`, scope: r.cwd });
2174
+ }
2175
+ ledger.finalize({ status: summary.gatePassed ? 'ok' : 'failed' });
2176
+ const ledgerPath = ledger.persist();
2177
+
2178
+ // The gate follows the outcome. `recordEvidence` used to be called unconditionally, so a
2179
+ // failed or blocked run wrote `gates.architectureFitnessPassed = true` beside a `failed` record
2180
+ // — a document `state validate` then rejects ("gate is true but has no supporting evidence
2181
+ // record"), which the shipped pre-commit hook turns into a refused commit. A run that did not
2182
+ // pass now clears the gate, so a failure invalidates an earlier pass instead of leaving it
2183
+ // standing.
2184
+ if (exists) writeState(opts.targetDir, recordEvidence(state, evidence, { setGate: summary.gatePassed }));
2185
+
2186
+ const detail = {
2187
+ ok: summary.gatePassed, gateSet: exists && summary.gatePassed, gate: ARCHITECTURE_GATE,
2188
+ inputTreeHash: evidence.inputTreeHash,
2189
+ status, passed: summary.passed, failed: summary.failed, blocked: summary.blocked,
2190
+ advisoryFailed: summary.advisoryFailed, skipped: summary.skipped, note: summary.note,
2191
+ checks: results.map((r) => ({ id: r.id, severity: r.severity, status: r.status, exitCode: r.exitCode, artifactPath: r.artifactPath })),
2192
+ relevantFiles: boundFiles, evidenceId: evidence.evidenceId, runId: ledger.runId, path: ledgerPath,
2193
+ };
2194
+ if (opts.format === 'json') {
2195
+ emit(opts, '', detail);
2196
+ } else {
2197
+ const head = summary.gatePassed ? `✅ ${ARCHITECTURE_GATE}` : `❌ ${ARCHITECTURE_GATE}`;
2198
+ console.log(`${head}: ${resultText}`);
2199
+ for (const line of describeCheckResults(results, summary)) console.log(line);
2200
+ if (summary.note) console.log(` ${summary.note}`);
2201
+ console.log(` Bound to ${boundFiles.length} judged file(s), so a change to one stales this record.`
2202
+ + (artifactPaths.length ? ` Plus ${artifactPaths.length} artifact(s), bound per check and deliberately unhashed.` : ''));
2203
+ console.log(` Ledger: ${ledgerPath}`);
2204
+ if (!summary.gatePassed) {
2205
+ console.log(' Review cannot start until every required check passes. A check that cannot run is a tooling-gap exception, not a manual record.');
2206
+ }
2207
+ }
2208
+ process.exit(summary.gatePassed ? 0 : 1);
2209
+ }
2210
+
2211
+ if (sub === 'verify-design-review') {
2212
+ refuseCommandOverride('harness verify-design-review', 'the design-review artifact it is handed');
2213
+ // The formal design review: checked, then recorded.
2214
+ //
2215
+ // WHY THE ARTIFACT IS WHAT PROVES IT. Cadet cannot read a design and decide
2216
+ // whether it is good, so it does not pretend to. The reviewer's judgement lives
2217
+ // in the artifact; this command checks the properties an artifact must have to be
2218
+ // readable as a review at all, and blocks the one case the gate exists for — a
2219
+ // contested decision with nobody's name against it. The bound inputs give the
2220
+ // record its freshness, which is the only honest way to say "this review was of
2221
+ // THAT design".
2222
+ if (!opts.artifact) fail(opts, 'harness verify-design-review requires --artifact <path>');
2223
+ const { exists, state } = readState(opts.targetDir);
2224
+ assertExpectedPhase(opts, state);
2225
+
2226
+ const artifactPath = resolve(opts.targetDir, opts.artifact);
2227
+ const artifactRel = relative(opts.targetDir, artifactPath).replace(/\\/g, '/') || basename(artifactPath);
2228
+ let artifactText;
2229
+ try {
2230
+ artifactText = readFileSync(artifactPath, 'utf-8');
2231
+ } catch (err) {
2232
+ fail(opts, `cannot read the design-review artifact "${opts.artifact}": ${err.message}`, () => 1, { ok: false, code: 'artifact-unreadable', artifact: opts.artifact });
2233
+ }
2234
+
2235
+ // The review must bind what it reviewed. Without this the record would attest
2236
+ // "a review happened" while naming nothing it was a review OF.
2237
+ if (!opts.files || opts.files.length === 0) {
2238
+ fail(opts, 'the review must bind its inputs: pass --files <technical-design,requirements,ADRs,...> alongside --artifact.', () => 1, { ok: false, code: 'no-inputs-bound' });
2239
+ }
2240
+
2241
+ const parsed = parseDesignReviewArtifact(artifactText);
2242
+ const enabled = policy.designReview?.enabled === true;
2243
+
2244
+ if (parsed.errors.length > 0) {
2245
+ const detail = {
2246
+ ok: false,
2247
+ artifact: opts.artifact,
2248
+ code: parsed.errors[0].code,
2249
+ errors: parsed.errors,
2250
+ findings: parsed.findings.length,
2251
+ contested: parsed.contested,
2252
+ gateSet: false,
2253
+ enabled,
2254
+ };
2255
+ if (opts.format === 'json') emit(opts, '', detail);
2256
+ else {
2257
+ console.error(`❌ Cannot set ${DESIGN_REVIEW_GATE} from ${opts.artifact}:`);
2258
+ for (const line of describeDesignReviewGaps(parsed)) console.error(line);
2259
+ }
2260
+ process.exit(1);
2261
+ }
2262
+
2263
+ // NOT OPTED IN: report and write nothing, the same compatibility rule the other
2264
+ // opt-in gates follow. A caller who ran the command asked the question, so a
2265
+ // failure still exits nonzero.
2266
+ if (!enabled) {
2267
+ const summary = `design review readable: ${parsed.findings.length} finding(s), reviewer ${parsed.reviewer}`;
2268
+ 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 });
2269
+ else {
2270
+ console.log(`✅ ${summary}`);
2271
+ console.log(' designReview.enabled is false — reported only, state.json unchanged.');
2272
+ }
2273
+ return;
2274
+ }
2275
+
2276
+ const workItemId = state ? workItemIdOf(state) : 'unscoped';
2277
+ const phase = state?.session?.currentPhase || 'implementation';
2278
+ const inputs = opts.files.map((f) => String(f).replace(/\\/g, '/'));
2279
+ const relevantFiles = [artifactRel, ...inputs.filter((f) => f !== artifactRel)];
2280
+
2281
+ const evidence = createEvidence({
2282
+ evidenceId: newId(),
2283
+ workItemId,
2284
+ acceptanceCriterionId: null,
2285
+ phase,
2286
+ gate: DESIGN_REVIEW_GATE,
2287
+ status: 'passed',
2288
+ command: `harness verify-design-review --artifact ${artifactRel}`,
2289
+ result: `design review complete: ${parsed.findings.length} finding(s), ${parsed.contested.length} contested with a named resolution; reviewer ${parsed.reviewer}`,
2290
+ exitCode: 0,
2291
+ inputTreeHash: computeInputTreeHash(opts.targetDir, relevantFiles),
2292
+ criteriaHash: hashCriteria([]),
2293
+ relevantFiles,
2294
+ createdAt: new Date(),
2295
+ });
2296
+
2297
+ const ledger = new RunLedger({ targetDir: opts.targetDir, policy, runId: state?.activeRunId || null, workItemId, phase });
2298
+ ledger.addEvidence(evidence);
2299
+ ledger.addDecision({ kind: 'stop', reason: `design review checked (${parsed.findings.length} finding(s))`, scope: artifactRel });
2300
+ ledger.finalize({ status: 'ok' });
2301
+ const ledgerPath = ledger.persist();
2302
+
2303
+ if (exists) writeState(opts.targetDir, recordEvidence(state, evidence));
2304
+
2305
+ if (opts.format === 'json') {
2306
+ 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 });
2307
+ } else {
2308
+ console.log(`✅ ${DESIGN_REVIEW_GATE} for ${artifactRel}: ${parsed.findings.length} finding(s), reviewer ${parsed.reviewer}`);
2309
+ console.log(` Bound to ${relevantFiles.length} file(s), so a change to the design or the review stales this record.`);
2310
+ console.log(` Ledger: ${ledgerPath}`);
2311
+ }
2312
+ return;
2313
+ }
2314
+
2315
+ // The health line. This command exists so the framework's one line of output is
2316
+ // DERIVED rather than asserted: an agent composing its own `ok` is a claim, and
2317
+ // this repository's whole complaint about itself is claims that nothing checks.
2318
+ //
2319
+ // The exit code carries the same verdict as the line, because the CLI's contract
2320
+ // is that a command returns nonzero for invalid state. `process.exitCode` is set
2321
+ // rather than calling `process.exit()`, so buffered stdout cannot be truncated
2322
+ // when the caller is reading the line from a pipe.
2323
+ if (sub === 'status') {
2324
+ const status = computeStatus(opts.targetDir);
2325
+ emit(opts, status.line, { ok: status.ok, status });
2326
+ process.exitCode = status.ok ? 0 : 1;
2327
+ return;
2328
+ }
2329
+
1446
2330
  if (sub === 'report') {
1447
2331
  const runs = listRuns(opts.targetDir);
1448
2332
  const target = opts.runId || runs[0]?.runId;
1449
2333
  if (!target) fail(opts, 'No run records found in .cadet/runs/.', () => 2);
1450
2334
  const run = loadRun(opts.targetDir, target);
1451
2335
  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));
2336
+ // The run report carries the context level, because it is the one place a reader looks to
2337
+ // ask what happened in a run. The level is reported as recorded — never upgraded: a report
2338
+ // that calls advisory loading "enforced" is worse than no report, because the reader stops
2339
+ // looking. Reading the plan and record writes nothing, which is what this command promises.
2340
+ const contextLine = describeContextState({
2341
+ plan: buildContextPlan({ targetDir: opts.targetDir, policy, state: readState(opts.targetDir).state }),
2342
+ record: readContextRecord(opts.targetDir),
2343
+ });
2344
+ if (opts.format === 'json') emit(opts, '', { ok: true, report: { ...buildReport(run), context: contextLine } });
2345
+ else console.log(`${formatReport(run)}
2346
+ ${contextLine.line}`);
1454
2347
  return;
1455
2348
  }
1456
2349
 
@@ -1660,7 +2553,7 @@ async function cmdHarness(opts) {
1660
2553
  return;
1661
2554
  }
1662
2555
 
1663
- fail(opts, `Unknown harness subcommand: ${sub || '(none)'}. Use record|confirm|verify|verify-acs|verify-reachability|matrix-check|report|changes|reconcile|cleanup|capabilities.`);
2556
+ 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
2557
  }
1665
2558
 
1666
2559
  export async function run(argv) {
@@ -1682,6 +2575,41 @@ export async function run(argv) {
1682
2575
  const opts = parseArgs(argv);
1683
2576
  const commandKey = resolveCommand(argv);
1684
2577
 
2578
+ // A command may not bind its own outputs as the evidence it records.
2579
+ //
2580
+ // A record's `inputTreeHash` covers the files `--files` names, and `harness verify`,
2581
+ // `harness confirm`, `harness verify-acs`, `harness verify-reachability`,
2582
+ // `harness verify-design-review` and `harness verify-architecture` then write the ledger
2583
+ // and `.cadet/state.json`. Binding one of those makes the record stale at the instant it
2584
+ // is created — the write it describes changes a file the hash covers — so the next
2585
+ // `state transition --dry-run` refuses the boundary for a record the harness itself just
2586
+ // wrote. Refused here, in the dispatcher, rather than at each of the four sites that
2587
+ // resolve `--files`: the registry already declares what each command writes, so the check
2588
+ // covers a new command and a new flag the moment they are registered, which is the same
2589
+ // reason `--dry-run` is driven from here (contract C13).
2590
+ if (commandKey && opts.files && opts.files.length) {
2591
+ const selfBound = selfBoundFiles(commandKey, opts.files);
2592
+ if (selfBound.length > 0) {
2593
+ const named = selfBound.join(', ');
2594
+ fail(
2595
+ opts,
2596
+ `--files names ${selfBound.length === 1 ? 'a path' : 'paths'} that "${commandKey}" writes itself: ${named}. `
2597
+ + 'Bind the files this gate JUDGES — sources, tests, and the story or epic markdown — never the ledger '
2598
+ + `or state document the evidence is recorded in: "${commandKey}" writes ${named} as it records the `
2599
+ + 'record, so a binding to it reads as stale the moment it is written ("input tree hash changed since '
2600
+ + 'the evidence was recorded"), and the gate can never be fresh.',
2601
+ () => 1,
2602
+ {
2603
+ ok: false,
2604
+ command: commandKey,
2605
+ code: 'self-bound-files',
2606
+ files: selfBound,
2607
+ writes: COMMANDS[commandKey].writes || [],
2608
+ },
2609
+ );
2610
+ }
2611
+ }
2612
+
1685
2613
  // Global `--dry-run`, driven by the registry rather than by each handler.
1686
2614
  //
1687
2615
  // This is the structural fix for the class of bug where a mutating command