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.
@@ -13,7 +13,12 @@ import {
13
13
  PHASES, GATES, TRANSITIONS, EVIDENCE_STATUSES, DEFAULT_STRICT_CLOSURE,
14
14
  EXCEPTION_CATEGORIES, EXCEPTION_EXPIRY_DAYS, EXCEPTION_REQUIRES_REVIEW_NOTE,
15
15
  REACHABILITY_GATE, REACHABILITY_TRANSITION_FROM,
16
+ DESIGN_REVIEW_GATE, DESIGN_REVIEW_TRANSITION_FROM,
17
+ HUMAN_ACCEPTANCE_GATE, HUMAN_ACCEPTANCE_TRANSITION_FROM,
18
+ ARCHITECTURE_GATE, ARCHITECTURE_TRANSITION_FROM, ARCHITECTURE_TRANSITION_TO,
19
+ architectureFitnessActive,
16
20
  } from './policy.mjs';
21
+ import { gateContractId } from './gates.mjs';
17
22
  import { hashTree, hashFile, hashCriteria, timestamp, isUuid } from './util.mjs';
18
23
  import { encodeEvidenceTrailers } from './gitmemo.mjs';
19
24
  import {
@@ -194,6 +199,7 @@ export function validateState(state, context = {}) {
194
199
  });
195
200
  }
196
201
  }
202
+ const v4 = isHistoryExternal(state);
197
203
  // Gate exceptions are categorised under strict closure (contract v3 §4).
198
204
  //
199
205
  // v4 gives them their own field, because they are *live state* — scoped to a
@@ -203,6 +209,31 @@ export function validateState(state, context = {}) {
203
209
  // each is reported under the path it actually lives at.
204
210
  if (Array.isArray(state.changeHistory)) {
205
211
  state.changeHistory.forEach((entry, i) => {
212
+ if (entry?.type !== 'gate-exception') {
213
+ // A change-log entry is a pointer, not a record.
214
+ //
215
+ // An ERROR rather than the warning the other size rules get, because this is not a
216
+ // preference about tidiness: the entry duplicates an artifact git already holds, and
217
+ // the documentation-only version of this rule is what let it drift back in through
218
+ // every skill except the one it was written for. It is repairable in place —
219
+ // `state compact` moves the entry to the archive and loses nothing — so a document
220
+ // that predates the bound is not stranded by it.
221
+ //
222
+ // v4 only, for the same reason the foreign-record check below is v4 only: a v1-v3
223
+ // document records prose in the log by design, and an error there would cry wolf on
224
+ // documents that were correct when they were written. Their repair is
225
+ // `state migrate`, which compacts the log as it moves the document to v4.
226
+ const length = v4 ? historyEntryLength(entry) : 0;
227
+ if (length > MAX_HISTORY_ENTRY_CHARS) {
228
+ errors.push({
229
+ path: `changeHistory[${i}]`,
230
+ message: `change-log entry is ${length} characters of text across change/reason/rationale (limit ${MAX_HISTORY_ENTRY_CHARS}). `
231
+ + 'A change-log entry is a pointer: name the artifact path, and the commit when there is one, and let the artifact say the rest. '
232
+ + 'Prose has a home — the handoff, the review, the ADR, the policy file, or the commit message that carries the change — and the entry names it rather than repeating it. '
233
+ + 'Run "cadet-agent state compact --keep active" to move over-long entries to .cadet/archive/history.jsonl; nothing is lost.',
234
+ });
235
+ }
236
+ }
206
237
  if (entry?.type !== 'gate-exception') return;
207
238
  if (!strict) return;
208
239
  for (const e of validateGateException(entry, strict)) {
@@ -469,6 +500,37 @@ function validateEvidenceShape(ev, strict = null) {
469
500
  if (ev.evidenceId !== undefined && !isUuid(ev.evidenceId)) errors.push({ path: 'evidenceId', message: 'evidenceId must be a UUIDv4' });
470
501
  if (ev.phase !== undefined && !PHASES.includes(ev.phase)) errors.push({ path: 'phase', message: `unknown phase "${ev.phase}"` });
471
502
  if (ev.gate !== undefined && !GATES.includes(ev.gate)) errors.push({ path: 'gate', message: `unknown gate "${ev.gate}"` });
503
+ // A human acceptance with no witness, or with no statement of its limitations, is
504
+ // a signature on nothing. Checked at validation as well as at creation, because a
505
+ // hand-edited state.json is exactly what this gate must not be satisfiable by.
506
+ if (ev.gate === HUMAN_ACCEPTANCE_GATE) {
507
+ for (const field of ['witness', 'limitations']) {
508
+ const value = ev[field];
509
+ if (typeof value !== 'string' || value.trim() === '') {
510
+ errors.push({ path: field, message: `a ${HUMAN_ACCEPTANCE_GATE} record must carry a non-empty "${field}"` });
511
+ }
512
+ }
513
+ }
514
+ // The architecture record is a claim about checks, so it must say which checks. One
515
+ // entry per check, each with an id and an outcome, or the record would attest
516
+ // "constraints hold" while naming none of them.
517
+ if (ev.gate === ARCHITECTURE_GATE) {
518
+ if (!Array.isArray(ev.checks) || ev.checks.length === 0) {
519
+ errors.push({ path: 'checks', message: `a ${ARCHITECTURE_GATE} record must carry the checks it ran` });
520
+ } else {
521
+ ev.checks.forEach((check, i) => {
522
+ if (typeof check?.id !== 'string' || check.id === '') {
523
+ errors.push({ path: `checks[${i}].id`, message: 'an architecture check result must name the check id' });
524
+ }
525
+ if (!['passed', 'failed', 'blocked'].includes(check?.status)) {
526
+ errors.push({ path: `checks[${i}].status`, message: 'an architecture check result must be passed, failed or blocked' });
527
+ }
528
+ });
529
+ }
530
+ }
531
+ if (ev.gateContract !== undefined && ev.gateContract !== null && typeof ev.gateContract !== 'string') {
532
+ errors.push({ path: 'gateContract', message: 'gateContract must be a contract id such as "testsPassed@1", or null' });
533
+ }
472
534
  if (ev.status !== undefined && !EVIDENCE_STATUSES.includes(ev.status)) errors.push({ path: 'status', message: `unknown evidence status "${ev.status}"` });
473
535
  if (ev.relevantFiles !== undefined && !Array.isArray(ev.relevantFiles)) errors.push({ path: 'relevantFiles', message: 'relevantFiles must be an array' });
474
536
  if (ev.inputTreeHash !== undefined && !/^[0-9a-f]{64}$/.test(String(ev.inputTreeHash))) {
@@ -738,6 +800,38 @@ export function migrateStateV1toV2(v1) {
738
800
  */
739
801
  export const HISTORY_ENTRIES_KEPT = 25;
740
802
 
803
+ /**
804
+ * How long one change-log entry's text may be.
805
+ *
806
+ * An entry is a **pointer**: the artifact path, and the commit when there is one. It is not a
807
+ * record of what happened — the artifact is, and the artifact is in git. The rule was already
808
+ * written for `Handoff` ("a reference, not an essay") after 116 handoff entries averaging
809
+ * 1.9 KB each duplicated files already on disk, and it drifted straight back through the other
810
+ * skills: on the audited repository the longest entry was a 1.2 KB retelling of a review report
811
+ * that names its own path in the same sentence.
812
+ *
813
+ * 400 characters holds every legitimate entry measured — a path, a verdict word, a commit —
814
+ * with room to spare, and refuses the retelling. A `reason` field on the entry is where a *why*
815
+ * belongs, and it is bounded by the same discipline: if it needs more than a sentence, it
816
+ * belongs in the artifact.
817
+ */
818
+ export const MAX_HISTORY_ENTRY_CHARS = 400;
819
+
820
+ /**
821
+ * The text of a change-log entry, across every field it can sit in.
822
+ *
823
+ * Not `change` alone: measured on the audited consumer, `reason` held 12.3 KB of the 18.9 KB the
824
+ * surviving log occupied — 65% of the bytes, in the field the first version of this bound did not
825
+ * look at. A rule that bounds one field and not the others does not bound prose, it relocates it.
826
+ */
827
+ export function historyEntryLength(entry) {
828
+ let total = 0;
829
+ for (const field of ['change', 'reason', 'rationale', 'closureReviewNote']) {
830
+ if (typeof entry?.[field] === 'string') total += entry[field].length;
831
+ }
832
+ return total;
833
+ }
834
+
741
835
  /**
742
836
  * Split a change log into the tail that stays inline and the overflow to archive.
743
837
  *
@@ -745,13 +839,22 @@ export const HISTORY_ENTRIES_KEPT = 25;
745
839
  * handoff cross-checks against. The overflow is returned rather than discarded so
746
840
  * the caller can persist it: an audit trail may move, but it must not evaporate.
747
841
  */
748
- export function compactHistory(entries, { keepRecent = HISTORY_ENTRIES_KEPT } = {}) {
842
+ export function compactHistory(entries, { keepRecent = HISTORY_ENTRIES_KEPT, maxEntryChars = MAX_HISTORY_ENTRY_CHARS } = {}) {
749
843
  const list = Array.isArray(entries) ? entries : [];
750
- if (list.length <= keepRecent) return { kept: list, archived: [] };
751
- return {
752
- kept: list.slice(list.length - keepRecent),
753
- archived: list.slice(0, list.length - keepRecent),
754
- };
844
+ const boundary = Math.max(0, list.length - keepRecent);
845
+ const kept = [];
846
+ const archived = [];
847
+ list.forEach((entry, i) => {
848
+ // Live state is never archived, whatever it measures: a legacy gate exception still
849
+ // filed in the log is promoted by `toStateV4` before this runs, and this is the second
850
+ // guard against a caller that reaches compaction by another route.
851
+ if (entry?.type === 'gate-exception') { kept.push(entry); return; }
852
+ // Recency is the wrong selector for an over-bound entry: the newest one is exactly the
853
+ // one an agent just wrote, and it is the one that must not sit in the document.
854
+ if (i < boundary || historyEntryLength(entry) > maxEntryChars) archived.push(entry);
855
+ else kept.push(entry);
856
+ });
857
+ return { kept, archived };
755
858
  }
756
859
 
757
860
  /**
@@ -1026,6 +1129,12 @@ export function createEvidence({
1026
1129
  relevantFiles,
1027
1130
  toolVersion,
1028
1131
  commit: normalizedCommit,
1132
+ // The builder contract this record was written under. Stamped only on
1133
+ // automated evidence: a manual confirmation is not produced by a builder, so
1134
+ // there is no contract for it to satisfy, and `evidenceFreshness` accepts a
1135
+ // null contract rather than invalidating every record written before this
1136
+ // field existed.
1137
+ gateContract: source === 'automated' ? gateContractId(gate) : null,
1029
1138
  createdAt: timestamp(createdAt),
1030
1139
  expiresAt: expiresAt ? timestamp(expiresAt) : null,
1031
1140
  freshnessPolicy,
@@ -1062,6 +1171,7 @@ export function evidenceFreshness(evidence, context) {
1062
1171
  phase = null,
1063
1172
  inputTreeHash = null,
1064
1173
  criteriaHash = null,
1174
+ gateContract = null,
1065
1175
  } = context || {};
1066
1176
 
1067
1177
  if (evidence.status === 'superseded') reasons.push('evidence was superseded');
@@ -1077,6 +1187,15 @@ export function evidenceFreshness(evidence, context) {
1077
1187
  if (criteriaHash && evidence.criteriaHash && evidence.criteriaHash !== criteriaHash) {
1078
1188
  reasons.push('acceptance criteria changed since the evidence was recorded');
1079
1189
  }
1190
+ // A gate's evidence contract is part of the record's identity. When the builder's
1191
+ // rules change, a record written under the old rules no longer answers the
1192
+ // question the gate asks, so it is refused as stale rather than re-read as if
1193
+ // nothing had changed. A record that declares no contract predates this check or
1194
+ // is a manual confirmation; it is accepted, so no existing evidence is
1195
+ // retroactively invalidated by the field arriving.
1196
+ if (gateContract && evidence.gateContract && evidence.gateContract !== gateContract) {
1197
+ reasons.push(`evidence was recorded against gate contract "${evidence.gateContract}", not the current "${gateContract}"`);
1198
+ }
1080
1199
  if (evidence.expiresAt && new Date(evidence.expiresAt).getTime() <= now.getTime()) {
1081
1200
  reasons.push('evidence expired');
1082
1201
  }
@@ -1129,7 +1248,7 @@ export function appendEvidence(state, evidence) {
1129
1248
  * Centralised so `harness confirm`, `harness verify`, `harness verify-acs` and the
1130
1249
  * tests cannot drift.
1131
1250
  */
1132
- export function recordEvidence(state, evidence) {
1251
+ export function recordEvidence(state, evidence, { setGate = true } = {}) {
1133
1252
  const gate = evidence?.gate;
1134
1253
  const superseding = { ...state };
1135
1254
  if (gate) {
@@ -1139,7 +1258,12 @@ export function recordEvidence(state, evidence) {
1139
1258
  : e));
1140
1259
  }
1141
1260
  const next = appendEvidence(superseding, evidence);
1142
- if (gate) next.gates = { ...(state.gates || {}), [gate]: true };
1261
+ // `setGate` exists because a run that did NOT pass must not leave the gate true. The default
1262
+ // keeps every existing caller's behaviour (they record evidence that passed, and the gate they
1263
+ // satisfy). A caller recording a failure passes false, which also clears a gate an earlier pass
1264
+ // had set — the claim is no longer supported by anything, and `state validate` rejects a true
1265
+ // gate whose latest record is not a pass, so leaving it true wrote an invalid document.
1266
+ if (gate) next.gates = { ...(state.gates || {}), [gate]: setGate === true };
1143
1267
  return next;
1144
1268
  }
1145
1269
 
@@ -1193,7 +1317,26 @@ export function activeExceptions(state, { workItemId, now = new Date() } = {}) {
1193
1317
  // ── Transitions ─────────────────────────────────────────────────────────────
1194
1318
 
1195
1319
  /** Required gates for a transition target, or null when the target is not gated. */
1196
- export function requiredGates(toPhase, { reachability = false } = {}) {
1320
+ /**
1321
+ * Gates required on an edge that carries no entry in `TRANSITIONS`.
1322
+ *
1323
+ * Why this exists: `requiredGates` resolves a transition by its TARGET phase, so a
1324
+ * gate that belongs to one specific `(from, to)` pair has nowhere to live in that
1325
+ * table — and `story-breakdown` is reached from two places (`architectureComplete`
1326
+ * and `spikes`), so an entry keyed by target would gate the wrong route as well.
1327
+ * The design review belongs to the design route only: a spike that produced a
1328
+ * finding and went straight to breakdown is not a design being approved.
1329
+ *
1330
+ * Returns `[]` when the edge carries nothing, so the caller can treat "no gates"
1331
+ * and "no conditional gates" the same way.
1332
+ */
1333
+ export function conditionalEdgeGates(fromPhase, toPhase, policy = null) {
1334
+ if (fromPhase !== DESIGN_REVIEW_TRANSITION_FROM) return [];
1335
+ if (toPhase !== 'story-breakdown') return [];
1336
+ return policy?.designReview?.enabled === true ? [DESIGN_REVIEW_GATE] : [];
1337
+ }
1338
+
1339
+ export function requiredGates(toPhase, { reachability = false, humanAcceptance = false, architectureFitness = false } = {}) {
1197
1340
  for (const [from, spec] of Object.entries(TRANSITIONS)) {
1198
1341
  if (spec.to === toPhase) {
1199
1342
  const gates = [...spec.gates];
@@ -1210,7 +1353,25 @@ export function requiredGates(toPhase, { reachability = false } = {}) {
1210
1353
  // single-argument call — and every existing caller and test — sees
1211
1354
  // unchanged behaviour. See REACHABILITY_GATE.
1212
1355
  if (reachability && from === REACHABILITY_TRANSITION_FROM) gates.push(REACHABILITY_GATE);
1213
- return { from, gates, revalidate: spec.revalidate || [] };
1356
+ // Human acceptance joins only the closure transition, and only when the
1357
+ // repository has asked for it. It is deliberately NOT in `revalidate`: the
1358
+ // closure transition is where it belongs, so it is a primary gate there, and a
1359
+ // revalidated gate is judged without its phase stamp — which for this one would
1360
+ // mean accepting a record written in any phase at all.
1361
+ if (humanAcceptance && from === HUMAN_ACCEPTANCE_TRANSITION_FROM) gates.push(HUMAN_ACCEPTANCE_GATE);
1362
+ // Architecture fitness joins `implementation -> review`, and only when the project
1363
+ // has declared checks. It is appended at evaluation time rather than written into
1364
+ // the transition table, so a project that declares nothing keeps the frozen lists
1365
+ // exactly as the contract states them.
1366
+ if (architectureFitness && from === ARCHITECTURE_TRANSITION_FROM && spec.to === ARCHITECTURE_TRANSITION_TO) {
1367
+ gates.push(ARCHITECTURE_GATE);
1368
+ }
1369
+ // And it is re-examined at closure, because a dependency it governs can be broken
1370
+ // by a LATER story inside the same epic — by the time the epic closes, the check
1371
+ // that passed during implementation may describe a tree that no longer exists.
1372
+ const revalidate = [...(spec.revalidate || [])];
1373
+ if (architectureFitness && spec.to === 'closed') revalidate.push(ARCHITECTURE_GATE);
1374
+ return { from, gates, revalidate };
1214
1375
  }
1215
1376
  }
1216
1377
  return null;
@@ -1312,6 +1473,7 @@ function checkGate({ gate, state, gates, exceptions, now, workItemId, fromPhase,
1312
1473
  phase: phaseScoped ? fromPhase : null,
1313
1474
  inputTreeHash: currentTreeHash,
1314
1475
  criteriaHash: critHash,
1476
+ gateContract: gateContractId(gate),
1315
1477
  });
1316
1478
 
1317
1479
  const allReasons = [...reasons];
@@ -1413,8 +1575,16 @@ export function evaluateTransition(state, toPhase, context = {}) {
1413
1575
 
1414
1576
  // The reachability gate joins the requirement only when the repository has
1415
1577
  // opted in, so a project that has not sees the pre-existing gate list exactly.
1416
- const spec = requiredGates(toPhase, { reachability: context.policy?.reachability?.enabled === true });
1417
- if (!spec) {
1578
+ const spec = requiredGates(toPhase, {
1579
+ reachability: context.policy?.reachability?.enabled === true,
1580
+ humanAcceptance: context.policy?.humanAcceptance?.enabled === true,
1581
+ architectureFitness: architectureFitnessActive(context.policy),
1582
+ });
1583
+ // An edge with no `TRANSITIONS` entry can still carry a gate. It is resolved HERE,
1584
+ // before the ungated-edge shortcut below, because that shortcut is what would
1585
+ // otherwise let the edge through with the gate never consulted at all.
1586
+ const conditional = spec ? [] : conditionalEdgeGates(fromPhase, toPhase, context.policy);
1587
+ if (!spec && conditional.length === 0) {
1418
1588
  // Ungated transitions are legal ONLY along the declared forward edges
1419
1589
  // (bootstrap + planning progression). A target that is neither gated nor a
1420
1590
  // declared forward edge is rejected — most importantly, this makes `closed`
@@ -1425,7 +1595,7 @@ export function evaluateTransition(state, toPhase, context = {}) {
1425
1595
  errors.push(`illegal transition "${fromPhase}" → "${toPhase}" (not a gated transition, and not a declared forward edge)`);
1426
1596
  return { allowed: false, fromPhase, toPhase, missingGates, staleEvidence, errors, revalidated: [] };
1427
1597
  }
1428
- if (spec.from !== fromPhase) {
1598
+ if (spec && spec.from !== fromPhase) {
1429
1599
  errors.push(`illegal transition "${fromPhase}" → "${toPhase}" (expected from "${spec.from}")`);
1430
1600
  return { allowed: false, fromPhase, toPhase, missingGates: [...spec.gates], staleEvidence, errors, revalidated: [] };
1431
1601
  }
@@ -1442,7 +1612,7 @@ export function evaluateTransition(state, toPhase, context = {}) {
1442
1612
 
1443
1613
  const shared = { state, gates, exceptions, now, workItemId, fromPhase, rootDir, computeTreeHash, inputTreeHash, critHash };
1444
1614
 
1445
- for (const gate of spec.gates) {
1615
+ for (const gate of (spec ? spec.gates : conditional)) {
1446
1616
  const r = checkGate({ ...shared, gate });
1447
1617
  missingGates.push(...r.missingGates);
1448
1618
  staleEvidence.push(...r.staleEvidence);
@@ -1463,7 +1633,7 @@ export function evaluateTransition(state, toPhase, context = {}) {
1463
1633
  const recencyFloor = strict.requireFreshRevalidation !== false && state?.lastTransition?.at
1464
1634
  ? new Date(state.lastTransition.at)
1465
1635
  : null;
1466
- for (const gate of spec.revalidate) {
1636
+ for (const gate of (spec?.revalidate || [])) {
1467
1637
  if (spec.gates.includes(gate)) continue; // already checked as a primary gate
1468
1638
  revalidated.push(gate);
1469
1639
  const r = checkGate({ ...shared, gate, recencyFloor, phaseScoped: false });
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The health line — the framework's entire per-reply output.
3
+ *
4
+ * WHY THIS EXISTS. The Response Contract shipped in 0.52.0 as a six-field status
5
+ * table (`Item`, `Phase`, `Gates open`, `Blocking`, `You owe`, `Next`). Its own
6
+ * rule was "no line that cannot change a decision the reader is making", and
7
+ * four of those fields broke it on a normal turn: `Item` and `Phase` change about
8
+ * once per story, `Gates open` is a progress bar for the framework rather than
9
+ * for the work, and `Next` restates the prose that follows it. So the contract
10
+ * paid six lines on every reply to fund the rare turn where one of them mattered.
11
+ *
12
+ * The line this command prints replaces all six. It says `ok` when the tracking
13
+ * record is sound, and otherwise it says what is wrong. Nothing else about the
14
+ * framework is printed, ever.
15
+ *
16
+ * WHAT `ok` MEANS, and this is the whole design. The workflow record is readable,
17
+ * valid and FRESH, and no recorded run stopped on a budget or failed to run.
18
+ *
19
+ * WHAT `ok` DOES NOT MEAN. It does not mean every gate for the next phase is
20
+ * satisfied. Gates unmet because the work is not finished yet are NORMAL: the
21
+ * four implementation gates are unmet for most of every story. Reporting them
22
+ * would print a problem on nearly every reply, which is precisely the defect this
23
+ * replaces. Only things that are WRONG are reported:
24
+ *
25
+ * - the state file is missing in a consumer repository, or unreadable;
26
+ * - `validateState` raises an ERROR (schema, a foreign or stale record, an
27
+ * expired one, an unbacked `true` gate). WARNINGS are advisory here on
28
+ * purpose: this repository's own `harness.json` records nineteen permanently
29
+ * expected reconciliation warnings, and treating those as problems would
30
+ * reproduce the "permanently blocking finding" defect one directory up;
31
+ * - the last run's status is `exhausted` or `blocked`. That is the difference
32
+ * between the framework stopping and the framework reporting: a `failed` run
33
+ * is a RED, which is how TDD is supposed to work, and is never a problem.
34
+ *
35
+ * NOTHING HERE IS ASSERTED. Every problem is derived from a check that already
36
+ * exists — `validateState` and the run ledger — so the line cannot claim a health
37
+ * the framework has not verified. That is why the framework prints the line from
38
+ * this command rather than letting the agent compose an `ok` of its own.
39
+ *
40
+ * READ-ONLY. Declared `mutates: false` in the registry, so the test suite sweeps
41
+ * it under every write-shaped flag and asserts it writes nothing.
42
+ */
43
+ import { readState, validateState, workItemIdOf } from './state.mjs';
44
+ import { REPO_ROLES, detectRepoRole } from './repo-role.mjs';
45
+ import { loadPolicy } from './policy.mjs';
46
+ import { listRuns, loadRun } from './ledger.mjs';
47
+
48
+ /** The prefix every health line carries, so the reader knows which system spoke. */
49
+ export const STATUS_PREFIX = 'cadet-agent:';
50
+
51
+ /** The healthy line. One line, no fields, no version, no work item. */
52
+ export const STATUS_OK_LINE = `${STATUS_PREFIX} ok`;
53
+
54
+ /** Run statuses that mean the framework stopped rather than reported an outcome. */
55
+ const STOPPED_RUN_STATUSES = Object.freeze(['exhausted', 'blocked']);
56
+
57
+ /**
58
+ * Compute the health line for `targetDir`.
59
+ *
60
+ * Returns `{ ok, line, problems, role, workItemId, stateExists, checkedAt }`.
61
+ * `ok` is true when there are no problems; the line is always exactly one line.
62
+ */
63
+ export function computeStatus(targetDir, { now = new Date() } = {}) {
64
+ const problems = [];
65
+ const { exists, state } = readState(targetDir);
66
+ const role = detectRepoRole(targetDir);
67
+ const workItemId = exists && state ? workItemIdOf(state) || null : null;
68
+
69
+ if (!exists) {
70
+ // A framework-source repository has no workflow state by design, and saying so
71
+ // is the whole line. Anywhere else, a missing record is a real problem: the
72
+ // gates, the transitions and the archive all read from it.
73
+ if (role.role !== REPO_ROLES.FRAMEWORK) {
74
+ problems.push({
75
+ code: 'no-state',
76
+ message: 'no .cadet/state.json, so the workflow has no record',
77
+ blocks: 'every state command and every gate',
78
+ remedy: 'restore state.json, or install into a new project with cadet-agent init',
79
+ });
80
+ }
81
+ } else {
82
+ const policy = loadPolicy(targetDir);
83
+ const validation = validateState(state, { rootDir: targetDir, strictClosure: policy.strictClosure });
84
+ for (const error of validation.errors) {
85
+ problems.push({
86
+ code: 'state-invalid',
87
+ message: `${error.path} ${error.message}`,
88
+ blocks: 'the gate or transition that depends on this record',
89
+ remedy: 'cadet-agent state validate',
90
+ });
91
+ }
92
+ }
93
+
94
+ // The last run, not the active run: `activeRunId` is null between commands, and
95
+ // a stop that was never resolved is still the last thing that happened.
96
+ const runs = listRuns(targetDir);
97
+ const lastRun = runs.length > 0 ? loadRun(targetDir, runs[0].runId) : null;
98
+ if (lastRun && STOPPED_RUN_STATUSES.includes(lastRun.status)) {
99
+ const exhausted = lastRun.status === 'exhausted';
100
+ problems.push({
101
+ code: exhausted ? 'budget-exhausted' : 'run-blocked',
102
+ message: `the last harness run (${String(lastRun.runId).slice(0, 8)}) ${exhausted ? 'stopped on a hard budget' : 'could not run'}`,
103
+ blocks: 'the verification it was performing, so no gate was satisfied by it',
104
+ remedy: 'cadet-agent harness report',
105
+ });
106
+ }
107
+
108
+ return {
109
+ ok: problems.length === 0,
110
+ line: buildLine({ problems, exists, role }),
111
+ problems,
112
+ role: role.role,
113
+ workItemId,
114
+ stateExists: exists,
115
+ checkedAt: now.toISOString(),
116
+ };
117
+ }
118
+
119
+ /** One line, always. The healthy form carries nothing that cannot change a decision. */
120
+ function buildLine({ problems, exists, role }) {
121
+ if (problems.length === 0) {
122
+ if (!exists && role.role === REPO_ROLES.FRAMEWORK) {
123
+ return `${STATUS_OK_LINE} — framework-source repository, no workflow state`;
124
+ }
125
+ return STATUS_OK_LINE;
126
+ }
127
+
128
+ const first = problems[0];
129
+ const more = problems.length > 1 ? ` (+${problems.length - 1} more)` : '';
130
+ const remedy = first.remedy ? ` — ${first.remedy}` : '';
131
+ return `${STATUS_PREFIX} ${first.message}${more}${remedy}`;
132
+ }
@@ -15,6 +15,7 @@ import { hashCriteria, sha256Bytes, timestamp, newId } from './util.mjs';
15
15
  import { BudgetTracker, budgetExhaustedResult, evaluateHardStop } from './budget.mjs';
16
16
  import { redactString } from './redaction.mjs';
17
17
  import { whichAll } from './routing.mjs';
18
+ import { gateBuilder, describeGateRefusal, describeMissingCommand } from './gates.mjs';
18
19
 
19
20
  export const RETRY_CLASSES = Object.freeze(['deterministic', 'transient', 'repair', 'unknown']);
20
21
  export const RESULT_STATUSES = Object.freeze(['passed', 'failed', 'flaky', 'blocked', 'timed-out']);
@@ -342,35 +343,43 @@ export function runCommand(command, {
342
343
  * Project-specific commands (analyzer/compile/test) come from `.cadet/harness.json`.
343
344
  */
344
345
  export function commandForGate(gate, { projectPath = '.', policy = null, unityAvailable = false } = {}) {
345
- switch (gate) {
346
- case 'testsPassed':
347
- return policy?.testCommand
348
- ? { command: policy.testCommand, tool: 'test', automated: true }
349
- : { command: 'npm test', tool: 'test', automated: true };
350
- case 'compileCheckConfirmed':
351
- if (policy?.compileCommand) {
352
- return { command: policy.compileCommand, tool: 'unity-build', automated: true };
353
- }
354
- if (unityAvailable) {
355
- return {
356
- command: `unity build ${projectPath} --target StandaloneWindows64 -o "${join(projectPath, 'Temp', 'cadet-build')}" --format json`,
357
- tool: 'unity-build',
358
- automated: true,
359
- };
360
- }
346
+ // The registry decides, not a switch in this file: a gate whose contract is not
347
+ // a project command has no default command to run, and one whose contract IS a
348
+ // project command may still have nothing configured yet.
349
+ const builder = gateBuilder(gate);
350
+ if (!builder) {
351
+ return { command: null, tool: 'unknown-gate', automated: false, reason: describeGateRefusal(gate) };
352
+ }
353
+ const declared = builder.command;
354
+ if (!declared) {
355
+ const tool = builder.owner === 'agent' ? 'agent-owned' : builder.owner;
356
+ const reason = builder.projectCommand ? describeMissingCommand(gate) : describeGateRefusal(gate);
357
+ return { command: null, tool, automated: false, reason };
358
+ }
359
+
360
+ const configured = declared.policyKey ? policy?.[declared.policyKey] : null;
361
+ if (configured) {
362
+ return declared.tool === 'unity-analyzer'
363
+ ? { command: `unity run ${projectPath} --command ${configured} --format json`, tool: declared.tool, automated: true }
364
+ : { command: configured, tool: declared.tool, automated: true };
365
+ }
366
+ if (declared.fallback) {
367
+ return { command: declared.fallback, tool: declared.tool, automated: true };
368
+ }
369
+ if (declared.unityBuild) {
370
+ if (!unityAvailable) {
361
371
  return { command: null, tool: 'manual-confirmation', automated: false, reason: 'Unity CLI unavailable' };
362
- case 'unityAnalyzerClean':
363
- if (policy?.analyzerCommand) {
364
- return {
365
- command: `unity run ${projectPath} --command ${policy.analyzerCommand} --format json`,
366
- tool: 'unity-analyzer',
367
- automated: true,
368
- };
369
- }
370
- return { command: null, tool: 'unity-analyzer', automated: false, reason: 'analyzer command not declared in .cadet/harness.json' };
371
- default:
372
- return { command: null, tool: 'agent-owned', automated: false, reason: `${gate} is agent-owned` };
372
+ }
373
+ return {
374
+ command: `unity build ${projectPath} --target StandaloneWindows64 -o "${join(projectPath, 'Temp', 'cadet-build')}" --format json`,
375
+ tool: declared.tool,
376
+ automated: true,
377
+ };
373
378
  }
379
+ if (declared.tool === 'unity-analyzer') {
380
+ return { command: null, tool: 'unity-analyzer', automated: false, reason: 'analyzer command not declared in .cadet/harness.json' };
381
+ }
382
+ return { command: null, tool: 'agent-owned', automated: false, reason: describeMissingCommand(gate) };
374
383
  }
375
384
 
376
385
  /** Detect zero `UNT*` diagnostics in a Unity analyzer JSON envelope. */
@@ -678,6 +687,7 @@ export function manualConfirmation({
678
687
  gate, workItemId, phase, projectPath, editorVersion, scope, acceptanceCriterionId = null,
679
688
  relevantFiles = [], criteria = [], rootDir = process.cwd(), approvedBy = 'user', at = new Date(),
680
689
  reason = null, expiresAt = null, environment = null, expiresInMs = null, commit = null,
690
+ witness = null, limitations = null,
681
691
  } = {}) {
682
692
  const inputTreeHash = computeInputTreeHash(rootDir, relevantFiles);
683
693
  // v3 quality fields. `scope` is declared both as the free-text `result` line
@@ -689,6 +699,8 @@ export function manualConfirmation({
689
699
  // Redaction has no bypass (contract §8).
690
700
  const scopeList = (Array.isArray(scope) ? scope : (scope ? [scope] : [])).map(redactString);
691
701
  const safeReason = reason === null || reason === undefined ? null : redactString(String(reason));
702
+ const safeWitness = witness === null || witness === undefined ? null : redactString(String(witness));
703
+ const safeLimitations = limitations === null || limitations === undefined ? null : redactString(String(limitations));
692
704
  const env = Object.fromEntries(
693
705
  Object.entries(environment || (projectPath || editorVersion
694
706
  ? { projectPath: projectPath || null, editorVersion: editorVersion || null }
@@ -728,8 +740,19 @@ export function manualConfirmation({
728
740
  ...(safeReason !== null ? { reason: safeReason } : {}),
729
741
  ...(hasEnv ? { environment: env } : {}),
730
742
  ...(scopeList.length ? { scope: scopeList } : {}),
743
+ // A human acceptance states what was witnessed and what was accepted as missing.
744
+ // Both are free prose from a person, so both are redacted like every other field
745
+ // that reaches state.json. `state` validation requires them for
746
+ // `humanAcceptanceConfirmed`, which is why they are recorded rather than folded
747
+ // into `reason`: `reason` says why automation could not answer, these say what the
748
+ // person actually accepted.
749
+ ...(safeWitness !== null ? { witness: safeWitness } : {}),
750
+ ...(safeLimitations !== null ? { limitations: safeLimitations } : {}),
751
+ };
752
+ return {
753
+ evidence, approvedBy, projectPath, editorVersion, scope: scopeList, reason: safeReason,
754
+ environment: env, witness: safeWitness, limitations: safeLimitations, recordedAt: timestamp(at),
731
755
  };
732
- return { evidence, approvedBy, projectPath, editorVersion, scope: scopeList, reason: safeReason, environment: env, recordedAt: timestamp(at) };
733
756
  }
734
757
 
735
758
  /** Convenience: is the verification result an exhaustion that must not read as success? */