cadet-agent 0.60.0 → 0.63.1

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.
@@ -193,8 +193,9 @@ export const GATE_BUILDERS = Object.freeze({
193
193
  // humanAcceptanceConfirmed has neither: the gate asks whether a PERSON played the
194
194
  // delivered work. A command cannot answer it, and neither can a reviewer's record —
195
195
  // the record's substance is the person's own account of what they played and what
196
- // they saw, which is why the route is a form they fill in and `verify-play` refuses
197
- // to record a `required` declaration on their behalf.
196
+ // they saw, so the route is to ask them for it and record their answer with
197
+ // `harness confirm --gate userPlaythroughConfirmed --reason "<their answer>"`, and
198
+ // `verify-play` refuses to record a `required` declaration on their behalf.
198
199
  automatedPath: null,
199
200
  command: null,
200
201
  projectCommand: false,
@@ -47,6 +47,7 @@ export {
47
47
  HISTORY_ENTRIES_KEPT, MAX_HISTORY_ENTRY_CHARS, historyEntryLength, compactHistory, DEFAULT_MAX_LIVE_EVIDENCE, retainLiveRecords,
48
48
  createEvidence, computeInputTreeHash, workItemIdOf, evidenceFreshness,
49
49
  latestEvidenceForGate, activeExceptions, requiredGates, conditionalEdgeGates, isUngatedForwardEdge,
50
+ buildStateBrief, renderStateBrief, legalEdgesFrom,
50
51
  evaluateTransition, resolveStrict,
51
52
  applyTransition, resetGatesForNewWorkItem, statePathFor, readState, writeState, writeJsonAtomic, StateError,
52
53
  } from './state.mjs';
@@ -72,7 +73,8 @@ export {
72
73
  } from './hosts.mjs';
73
74
 
74
75
  export {
75
- CONTEXT_LEVELS, CONTEXT_DIR, CONTEXT_PLAN_FILE, CONTEXT_RECORD_FILE, PHASE_SKILL,
76
+ CONTEXT_LEVELS, CONTEXT_DIR, CONTEXT_PLAN_FILE, CONTEXT_RECORD_FILE, CONTEXT_BRIEF_FILE, PHASE_SKILL,
77
+ writeStateBrief, resolveWorkItemDocs,
76
78
  ContextProtocolError, planEntries, buildContextPlan, writeContextPlan, readContextPlan,
77
79
  buildContextRecord, writeContextRecord, readContextRecord, parseTranscript,
78
80
  validateContextRecord, describeContextState, declaresContextEnforcement,
@@ -83,12 +85,6 @@ export {
83
85
  runArchitectureChecks, summariseChecks, describeCheckResults,
84
86
  } from './architecture.mjs';
85
87
 
86
- export {
87
- ACCEPTANCE_FORM_GATE, ACCEPTANCE_FORM_FILENAME, ACCEPTANCE_TEMPLATE_RELATIVE,
88
- ACCEPTANCE_HUMAN_FIELDS, acceptanceFormPath, acceptanceCandidates, buildAcceptanceForm,
89
- parseAcceptanceForm, writeAcceptanceForm, isUnfilled,
90
- } from './acceptance-form.mjs';
91
-
92
88
  export {
93
89
  ContextManifest, buildBaseManifest, tier0References, TIER_REASONS_REQUIRED,
94
90
  DEFAULT_MAX_EXPANSION_PER_STEP, ContextError,
@@ -142,14 +138,23 @@ export {
142
138
  validatePlayDeclaration, readSiblingPlayDeclarations, describePlayGaps,
143
139
  } from './play.mjs';
144
140
 
145
- export {
146
- PLAY_FORM_GATE, PLAY_FORM_SUFFIX, PLAY_TEMPLATE_RELATIVE, PLAY_HUMAN_FIELDS,
147
- playFormPath, buildPlayForm, parsePlayForm, writePlayForm, readPlayTemplate,
148
- } from './play-form.mjs';
149
-
150
141
  export {
151
142
  COMMANDS, mutatingCommands, readOnlyCommands, resolveCommand, selfBoundFiles,
152
143
  describeCommand, describeAllCommands, checkUnattendedRequirements,
153
144
  } from './commands.mjs';
154
145
 
155
146
  export { computeStatus, STATUS_OK_LINE, STATUS_PREFIX } from './status.mjs';
147
+
148
+ export {
149
+ DEFAULT_EXCLUDED_DIRS, scanPackages, scanComponents, componentEdges,
150
+ } from './csharp.mjs';
151
+
152
+ export {
153
+ DIAGRAM_OUT_DIR, DIAGRAM_MANIFEST, STATUS_COLORS, STATUS_CLASS,
154
+ collectWorkItemFiles, indexComponents, componentWorkMap, statusesFromWork, componentStatusMap,
155
+ componentSlugs, buildDiagramModel, buildStructureModel,
156
+ renderNoml, renderIndexHtml, renderPackageHtml, renderComponentHtml,
157
+ writeDiagramFiles, syncDiagramTree, generateDiagrams, diagramDrift,
158
+ } from './diagram.mjs';
159
+
160
+ export { renderSvg as renderNomlSvg, nomnomlVersion } from './noml.mjs';
@@ -0,0 +1,47 @@
1
+ /**
2
+ * nomnoml renderer wrapper — the only place the framework's UML dependency is
3
+ * touched.
4
+ *
5
+ * nomnoml is CommonJS; this package is ESM. `createRequire` is the interop that
6
+ * works regardless of how Node's named-export detection reads the bundle, and it
7
+ * keeps the load lazy so a run that never renders a diagram never pays for it.
8
+ *
9
+ * The dependency is deliberate and is recorded in the plan and the architecture
10
+ * decision: it breaks the framework's long-standing "dependency-free" property in
11
+ * exchange for a real UML renderer instead of a hand-drawn one.
12
+ */
13
+
14
+ import { createRequire } from 'node:module';
15
+
16
+ const require = createRequire(import.meta.url);
17
+ let cached = null;
18
+
19
+ function loadRenderer() {
20
+ if (cached) return cached;
21
+ try {
22
+ cached = require('nomnoml');
23
+ } catch (err) {
24
+ throw new Error(
25
+ `the UML renderer "nomnoml" is not installed (${err.message}). Run "npm install" in the cadet-agent package.`,
26
+ );
27
+ }
28
+ return cached;
29
+ }
30
+
31
+ /** Render nomnoml source to an SVG string. */
32
+ export function renderSvg(source) {
33
+ const nomnoml = loadRenderer();
34
+ if (typeof nomnoml.renderSvg !== 'function') {
35
+ throw new Error('nomnoml did not expose renderSvg; the installed version is not the expected one.');
36
+ }
37
+ return nomnoml.renderSvg(String(source));
38
+ }
39
+
40
+ /** The installed nomnoml version, or null when it cannot be read. */
41
+ export function nomnomlVersion() {
42
+ try {
43
+ return loadRenderer().version || null;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
@@ -15,8 +15,8 @@
15
15
  * Play: required — <what the user does, and what they should see>
16
16
  * Play: deferred to <work item> — <why it cannot be played yet>
17
17
  *
18
- * A `required` declaration is satisfied only by a person's own record (the form route in
19
- * `play-form.mjs`); `harness verify-play` refuses to record it. A `deferred` declaration
18
+ * A `required` declaration is satisfied only by a person's own record, asked for and recorded
19
+ * in the conversation; `harness verify-play` refuses to record it. A `deferred` declaration
20
20
  * is satisfied by the declaration itself, exactly as a reachability deferral is, and it
21
21
  * expires the moment its target is done — a gap with an owner and a term, never a parked
22
22
  * excuse. There is deliberately no third form: a story with no playable surface of its
@@ -56,6 +56,7 @@ import { basename, dirname, isAbsolute, join, relative } from 'node:path';
56
56
 
57
57
  import { PHASES } from './policy.mjs';
58
58
  import { activeExceptionEntries } from './state.mjs';
59
+ import { diagramDrift, DIAGRAM_OUT_DIR } from './diagram.mjs';
59
60
  import {
60
61
  collectWorkItems,
61
62
  parseReachabilityDeclaration,
@@ -247,8 +248,13 @@ export function parseEpicHeader(text) {
247
248
  /**
248
249
  * Every directory under `root`, breadth-first and depth-bounded. Skipping the
249
250
  * usual game-repo ballast keeps a walk of a real project cheap.
251
+ *
252
+ * EXPORTED, because the context plan needs the same discovery this module worked out for the
253
+ * reconciler. A real project nests its plans (`<project>/epics/<epic-N>/`) and names its own files,
254
+ * so a plan built from fixed paths reports a correct project's own story as missing — the lesson
255
+ * REQUIRED_ARTIFACTS above already records. One walk, two consumers.
250
256
  */
251
- function walkDirs(root, maxDepth = MAX_SCAN_DEPTH) {
257
+ export function walkDirs(root, maxDepth = MAX_SCAN_DEPTH) {
252
258
  const found = [];
253
259
  let level = [root];
254
260
  for (let depth = 0; depth <= maxDepth && level.length > 0; depth++) {
@@ -659,7 +665,33 @@ export function reconcileArtifacts(targetDir, {
659
665
  }
660
666
  }
661
667
 
662
- // ── 4. Sort, number, and summarise ────────────────────────────────────────
668
+ // ── 4. The generated diagram tree ─────────────────────────────────────────
669
+ //
670
+ // The diagrams are DERIVED — from the code and from `state.json` — so a tree
671
+ // that no longer matches is a projection of the record that has stopped
672
+ // agreeing with the record: exactly the class of inconsistency this module
673
+ // exists to find, one step further down the chain.
674
+ //
675
+ // BLOCKING, not a warning. `designArtifactSyncConfirmed` is supported only when
676
+ // the run reports no unexcused blocking finding, so a warning here would report
677
+ // the drift and gate nothing.
678
+ //
679
+ // Opt-in by PRESENCE: a repository that has never generated the tree has no
680
+ // manifest and is not checked, so adopting this release changes nothing until
681
+ // diagrams are actually produced.
682
+ const drift = diagramDrift({ rootDir: targetDir, state });
683
+ if (drift.available && !drift.ok) {
684
+ const counts = [];
685
+ if (drift.missing.length) counts.push(`${drift.missing.length} missing`);
686
+ if (drift.changed.length) counts.push(`${drift.changed.length} changed`);
687
+ if (drift.extra.length) counts.push(`${drift.extra.length} extra`);
688
+ add('diagram-drift', 'blocking', 'diagrams', `${toPosix(DIAGRAM_OUT_DIR)}/`,
689
+ `the committed diagram tree no longer matches the code and the work record (${counts.join(', ')} file(s)). `
690
+ + 'Run `cadet-agent harness diagram` and commit the result, so the diagrams travel with the change that moved them.',
691
+ [...drift.missing, ...drift.changed, ...drift.extra].slice(0, 3).join(', ') || null);
692
+ }
693
+
694
+ // ── 5. Sort, number, and summarise ────────────────────────────────────────
663
695
  findings.sort((a, b) => {
664
696
  const s = (SEVERITY_RANK[a.severity] ?? 9) - (SEVERITY_RANK[b.severity] ?? 9);
665
697
  if (s !== 0) return s;
@@ -540,15 +540,25 @@ function validateEvidenceShape(ev, strict = null) {
540
540
  if (ev.evidenceId !== undefined && !isUuid(ev.evidenceId)) errors.push({ path: 'evidenceId', message: 'evidenceId must be a UUIDv4' });
541
541
  if (ev.phase !== undefined && !PHASES.includes(ev.phase)) errors.push({ path: 'phase', message: `unknown phase "${ev.phase}"` });
542
542
  if (ev.gate !== undefined && !GATES.includes(ev.gate)) errors.push({ path: 'gate', message: `unknown gate "${ev.gate}"` });
543
- // A human acceptance with no witness, or with no statement of its limitations, is
544
- // a signature on nothing. Checked at validation as well as at creation, because a
545
- // hand-edited state.json is exactly what this gate must not be satisfiable by.
546
- if (ev.gate === HUMAN_ACCEPTANCE_GATE) {
547
- for (const field of ['witness', 'limitations']) {
548
- const value = ev[field];
549
- if (typeof value !== 'string' || value.trim() === '') {
550
- errors.push({ path: field, message: `a ${HUMAN_ACCEPTANCE_GATE} record must carry a non-empty "${field}"` });
551
- }
543
+ // A human-owned gate's record IS a person's answer, so the record must carry one. Checked at
544
+ // validation as well as at creation, because a hand-edited state.json is exactly what this gate
545
+ // must not be satisfiable by.
546
+ //
547
+ // Both gates carried `witness` and `limitations` here until 2026-10-03, when the form that
548
+ // produced them was removed; the substance moved into `reason`, which now holds what the person
549
+ // said. Two fields on two gates existed only to mirror that form.
550
+ if (ev.gate === HUMAN_ACCEPTANCE_GATE || ev.gate === USER_PLAY_GATE) {
551
+ const hasReason = typeof ev.reason === 'string' && ev.reason.trim() !== '';
552
+ // `witness` + `limitations` are the shape the removed form wrote: two fields that existed only
553
+ // to mirror it. A record already written under that shape keeps validating, because the
554
+ // framework never invalidates evidence it once accepted.
555
+ const hasLegacyPair = ['witness', 'limitations']
556
+ .every((field) => typeof ev[field] === 'string' && ev[field].trim() !== '');
557
+ if (!hasReason && !hasLegacyPair) {
558
+ errors.push({
559
+ path: 'reason',
560
+ message: `a ${ev.gate} record must carry the person's own answer in a non-empty "reason"`,
561
+ });
552
562
  }
553
563
  }
554
564
  // The architecture record is a claim about checks, so it must say which checks. One
@@ -1133,6 +1143,8 @@ export function createEvidence({
1133
1143
  command = null,
1134
1144
  result = null,
1135
1145
  exitCode = null,
1146
+ durationMs = null,
1147
+ outputBytes = null,
1136
1148
  artifactPath = null,
1137
1149
  artifactHash = null,
1138
1150
  inputTreeHash,
@@ -1162,6 +1174,13 @@ export function createEvidence({
1162
1174
  command,
1163
1175
  result,
1164
1176
  exitCode,
1177
+ // WHAT THE RUN COST. Both are measured for the attempt span already, and neither was written where
1178
+ // an operator reads a gate: `state.json -> gateEvidence` carried the command, the exit code and the
1179
+ // verdict, but not how long the command took or whether it said anything. The wall clock is the
1180
+ // cheapest way to tell a real red from a process that never launched, and this repository's own
1181
+ // policy tells a reader to do exactly that. Null when nothing ran.
1182
+ durationMs,
1183
+ outputBytes,
1165
1184
  artifactPath,
1166
1185
  artifactHash,
1167
1186
  inputTreeHash,
@@ -1356,7 +1375,6 @@ export function activeExceptions(state, { workItemId, now = new Date() } = {}) {
1356
1375
 
1357
1376
  // ── Transitions ─────────────────────────────────────────────────────────────
1358
1377
 
1359
- /** Required gates for a transition target, or null when the target is not gated. */
1360
1378
  /**
1361
1379
  * Gates required on an edge that carries no entry in `TRANSITIONS`.
1362
1380
  *
@@ -1376,6 +1394,16 @@ export function conditionalEdgeGates(fromPhase, toPhase, policy = null) {
1376
1394
  return policy?.designReview?.enabled === true ? [DESIGN_REVIEW_GATE] : [];
1377
1395
  }
1378
1396
 
1397
+ /**
1398
+ * The gates a transition to `toPhase` requires: `{ from, gates, revalidate }`, or `null` when the target
1399
+ * is not the target of a gated transition.
1400
+ *
1401
+ * The shape is an OBJECT, not a bare list. The caller reads `.gates`, and `.revalidate` names the gates
1402
+ * this edge re-examines under strict closure. Corrected here because the previous one-line comment said
1403
+ * only "required gates for a transition target" and said nothing about the object, and a caller that read
1404
+ * it as a list then got an object it treated as empty — which reported a gated edge as ungated. A comment
1405
+ * a reader can act on wrongly is worse than no comment.
1406
+ */
1379
1407
  export function requiredGates(toPhase, { reachability = false, humanAcceptance = false, architectureFitness = false, userPlay = false } = {}) {
1380
1408
  for (const [from, spec] of Object.entries(TRANSITIONS)) {
1381
1409
  if (spec.to === toPhase) {
@@ -2068,6 +2096,213 @@ export function writeJsonAtomic(path, value) {
2068
2096
  return path;
2069
2097
  }
2070
2098
 
2099
+ /**
2100
+ * The legal edges out of a phase: what `state transition --to <phase>` would accept.
2101
+ *
2102
+ * WHY THIS EXISTS. Resuming asks "what happens next", and the tool that answers it —
2103
+ * `state transition --to <phase> --dry-run` — takes a TARGET phase the reader has to guess
2104
+ * before it will say anything. Both halves of the answer are already declared in this
2105
+ * framework: `TRANSITIONS` for the gated story loop, and `UNGATED_FORWARD_EDGES` for the
2106
+ * planning progression and the next-story loop. The gate list comes from `requiredGates`,
2107
+ * the same function the transition itself calls, so an opt-in gate (reachability, the
2108
+ * playthrough, human acceptance, architecture fitness) is reported exactly when the resolved
2109
+ * policy would require it — and a repository that enables none of them sees the frozen lists
2110
+ * the contract declares, unchanged.
2111
+ *
2112
+ * Pure: a function of (phase, policy). It runs no transition and reads no file, so a brief
2113
+ * cannot claim an edge the machinery would refuse.
2114
+ */
2115
+ export function legalEdgesFrom(phase, policy = null) {
2116
+ if (!phase) return [];
2117
+ const edges = [];
2118
+ const spec = TRANSITIONS[phase];
2119
+ if (spec) {
2120
+ // `requiredGates` returns `{ from, gates, revalidate }` — an OBJECT, not a bare list. Read `.gates`;
2121
+ // a caller that treats the return value as an array gets an empty list and reports a gated edge as
2122
+ // ungated. The `revalidate` half is deliberately not carried: on this edge it repeats these gates,
2123
+ // and the transition dry run is what reports revalidation.
2124
+ const required = requiredGates(spec.to, {
2125
+ reachability: policy?.reachability?.enabled === true,
2126
+ humanAcceptance: policy?.humanAcceptance?.enabled === true,
2127
+ architectureFitness: architectureFitnessActive(policy),
2128
+ userPlay: policy?.userPlay?.enabled === true,
2129
+ });
2130
+ edges.push({ to: spec.to, gates: required?.gates || [] });
2131
+ }
2132
+ for (const [from, to] of UNGATED_FORWARD_EDGES) {
2133
+ if (from !== phase) continue;
2134
+ const gates = conditionalEdgeGates(from, to, policy);
2135
+ edges.push({ to, gates, ungated: gates.length === 0 });
2136
+ }
2137
+ return edges;
2138
+ }
2139
+
2140
+ /**
2141
+ * The state brief: a derived summary of the state document, small enough to load
2142
+ * every turn.
2143
+ *
2144
+ * WHY THIS EXISTS AND WHAT IT REPLACES. The context plan named `.cadet/state.json`
2145
+ * itself as a tier-0 always-load reference, and that document is not the current
2146
+ * story: it is the current story's live evidence plus append-only lists of every
2147
+ * work item ever closed and every change checkpoint ever recorded. Measured in a
2148
+ * real consumer, it reached 94,547 B — about 31,500 tokens — of which the current
2149
+ * story was 31%, and it is re-sent on every turn. A host that loads it pays for the
2150
+ * project's whole history to answer "what phase am I in".
2151
+ *
2152
+ * WHAT IT CARRIES. Only what the phase reads: the phase and the session settings,
2153
+ * the active work item and its story and epic status, what is left in that epic, the legal
2154
+ * edge out of the current phase with the gates it carries, every gate in the policy with the
2155
+ * newest evidence record for each, the last transition, and how much of the document
2156
+ * compaction would archive. Nothing here is decided twice: every field is read
2157
+ * from the document, and `latestEvidenceForGate` is the same reader a gate check
2158
+ * uses, so the brief cannot disagree with the machinery about which record is live.
2159
+ *
2160
+ * WHAT IT DELIBERATELY OMITS. Evidence record bodies, the coverage index, the
2161
+ * archive and the change log. A reader who needs those opens `.cadet/state.json`;
2162
+ * the brief says so in its own first line. It is a summary, not a second record.
2163
+ *
2164
+ * Pure: no I/O, so a test drives it from a document and a caller decides where it
2165
+ * is written. The resolved work-item paths come IN through `docs`, because finding
2166
+ * them needs the filesystem: the caller resolves them, and this stays a function of
2167
+ * its arguments.
2168
+ */
2169
+ export function buildStateBrief(state, policy = null, docs = null) {
2170
+ const s = isPlainObject(state) ? state : {};
2171
+ const session = isPlainObject(s.session) ? s.session : {};
2172
+ const work = isPlainObject(s.activeWorkItem) ? s.activeWorkItem : null;
2173
+ const claimed = isPlainObject(s.gates) ? s.gates : {};
2174
+ const epics = isPlainObject(s.epics) ? s.epics : {};
2175
+
2176
+ const gates = GATES.map((gate) => {
2177
+ const latest = latestEvidenceForGate(s, gate);
2178
+ return {
2179
+ gate,
2180
+ claimed: claimed[gate] === true,
2181
+ status: latest ? latest.status : null,
2182
+ evidenceId: latest ? latest.evidenceId : null,
2183
+ at: latest ? latest.createdAt : null,
2184
+ // WHAT THE RUN COST, so a reader can tell a real red from a command that never launched without
2185
+ // opening the run ledger. Absent on every record written before the fields existed.
2186
+ durationMs: latest && typeof latest.durationMs === 'number' ? latest.durationMs : null,
2187
+ outputBytes: latest && typeof latest.outputBytes === 'number' ? latest.outputBytes : null,
2188
+ };
2189
+ });
2190
+
2191
+ const epic = work?.epicId && isPlainObject(epics[work.epicId]) ? epics[work.epicId] : null;
2192
+ const stories = isPlainObject(epic?.stories) ? epic.stories : {};
2193
+ const storyStatuses = Object.values(stories);
2194
+
2195
+ // WHAT IS LEFT IN THE ACTIVE EPIC, named rather than counted. `Resume` compares state against the
2196
+ // epic markdown story by story, and a count cannot be compared with anything, so the brief names
2197
+ // each story still not `done` with its status. Derived from the same map the counts come from.
2198
+ const remaining = Object.entries(stories)
2199
+ .filter(([, status]) => status !== 'done')
2200
+ .map(([id, status]) => ({ id, status }));
2201
+
2202
+ // The legal edge out of the current phase, and the gates it carries. See `legalEdgesFrom`.
2203
+ const next = legalEdgesFrom(session.currentPhase, policy);
2204
+
2205
+ // What `state compact --keep active` would move, counted by the same two pure
2206
+ // functions compaction itself uses. Reported rather than implied, because the
2207
+ // number growing without bound is the thing nobody notices.
2208
+ const inline = Array.isArray(s.gateEvidence) ? s.gateEvidence.length : 0;
2209
+ const { archived: archivableEvidence } = splitEvidence(s, { keep: 'active' });
2210
+ const historyInline = Array.isArray(s.changeHistory) ? s.changeHistory.length : 0;
2211
+ const { archived: archivableHistory } = compactHistory(
2212
+ (Array.isArray(s.changeHistory) ? s.changeHistory : []).filter((e) => e?.type !== 'gate-exception'),
2213
+ );
2214
+
2215
+ return {
2216
+ workItemId: work ? `${work.epicId}::${work.storyId}` : null,
2217
+ phase: session.currentPhase || null,
2218
+ workflowPath: session.workflowPath || null,
2219
+ trackingMode: session.trackingMode || null,
2220
+ learnerTier: session.learnerTier || null,
2221
+ operatingMode: session.operatingMode || null,
2222
+ story: work?.storyId
2223
+ ? { id: work.storyId, status: stories[work.storyId] || null, at: docs?.story || null }
2224
+ : null,
2225
+ epic: epic
2226
+ ? {
2227
+ id: work.epicId,
2228
+ status: epic.status || null,
2229
+ storiesDone: storyStatuses.filter((v) => v === 'done').length,
2230
+ storiesTotal: storyStatuses.length,
2231
+ remaining,
2232
+ at: docs?.epic || null,
2233
+ }
2234
+ : null,
2235
+ next,
2236
+ gates,
2237
+ evidence: { inline, archivable: archivableEvidence.length, superseded: (Array.isArray(s.gateEvidence) ? s.gateEvidence : []).filter((r) => r?.status === 'superseded').length },
2238
+ history: { inline: historyInline, archivable: archivableHistory.length, entryLimit: HISTORY_ENTRIES_KEPT, charLimit: MAX_HISTORY_ENTRY_CHARS },
2239
+ lastTransition: isPlainObject(s.lastTransition)
2240
+ ? { from: s.lastTransition.from || null, to: s.lastTransition.to || null, at: s.lastTransition.at || null }
2241
+ : null,
2242
+ activeRunId: s.activeRunId || null,
2243
+ };
2244
+ }
2245
+
2246
+ /** Render a state brief as the text a host loads. */
2247
+ export function renderStateBrief(brief) {
2248
+ const b = isPlainObject(brief) ? brief : {};
2249
+ const lines = [];
2250
+
2251
+ lines.push(`state brief — ${b.workItemId || '(no active work item)'}`);
2252
+ lines.push('The tier-0 summary of .cadet/state.json. Open that document for evidence records,');
2253
+ lines.push('the coverage index and the archive; this file carries what the phase reads.');
2254
+ lines.push('');
2255
+ lines.push(`phase: ${b.phase || '(unset)'} · workflow: ${b.workflowPath || '(unset)'} · tracking: ${b.trackingMode || '(unset)'}`);
2256
+ if (b.story) {
2257
+ lines.push(`story: ${b.story.status || '(unset)'}`);
2258
+ // WHERE THE STORY IS. A resume needs the file, and the path is not derivable from the document:
2259
+ // a real project nests its plans, so the host either resolves them (as `harness context plan` does)
2260
+ // or goes looking. The caller resolves and passes it in, which keeps this builder pure.
2261
+ if (b.story.at) lines.push(` at: ${b.story.at}`);
2262
+ }
2263
+ if (b.epic) {
2264
+ lines.push(`epic: ${b.epic.status || '(unset)'} — ${b.epic.storiesDone} of ${b.epic.storiesTotal} stories done`);
2265
+ if (b.epic.at) lines.push(` at: ${b.epic.at}`);
2266
+ const remaining = Array.isArray(b.epic.remaining) ? b.epic.remaining : [];
2267
+ if (remaining.length) {
2268
+ lines.push(` remaining: ${remaining.map((r) => `${r.id} (${r.status || 'unset'})`).join(', ')}`);
2269
+ }
2270
+ }
2271
+ // The edge out of this phase, so the transition dry run confirms a known edge instead of being the
2272
+ // thing that discovers one. A phase with no legal edge (`closed`) prints nothing, which is the fact.
2273
+ for (const edge of (Array.isArray(b.next) ? b.next : [])) {
2274
+ const needs = Array.isArray(edge.gates) && edge.gates.length ? `needs ${edge.gates.join(', ')}` : 'ungated';
2275
+ lines.push(`next: ${b.phase} -> ${edge.to} (${needs})`);
2276
+ }
2277
+ lines.push('');
2278
+ lines.push('gates (the newest evidence record for each)');
2279
+ for (const row of Array.isArray(b.gates) ? b.gates : []) {
2280
+ const state = row.claimed ? 'claimed' : 'UNMET';
2281
+ // The wall clock beside the verdict. This repository's false reds took 182 ms and its genuine Unity
2282
+ // pass takes tens of seconds, so the number is the difference between a test result and a command
2283
+ // that never ran. Omitted when the record does not carry one, rather than shown as zero.
2284
+ const took = typeof row.durationMs === 'number' ? `${Math.round(row.durationMs)} ms ` : '';
2285
+ const record = row.status
2286
+ ? `${row.status} ${took}${row.evidenceId || '(no id)'} ${row.at || ''}`.trimEnd()
2287
+ : '(no evidence)';
2288
+ lines.push(` ${row.gate.padEnd(27)} ${state.padEnd(9)} ${record}`);
2289
+ }
2290
+ lines.push('');
2291
+ const ev = b.evidence || {};
2292
+ lines.push(`evidence inline: ${ev.inline} record(s), ${ev.superseded} superseded, ${ev.archivable} archivable`);
2293
+ const hist = b.history || {};
2294
+ lines.push(`change log: ${hist.inline} entr(ies), ${hist.archivable} archivable (keeps the most recent ${hist.entryLimit}, or ${hist.charLimit} characters per entry)`);
2295
+ if (ev.archivable || hist.archivable) {
2296
+ lines.push(' archive them with: cadet-agent state compact --keep active');
2297
+ }
2298
+ if (b.lastTransition) {
2299
+ lines.push(`last transition: ${b.lastTransition.from} -> ${b.lastTransition.to} at ${b.lastTransition.at}`);
2300
+ }
2301
+ if (b.activeRunId) lines.push(`active run: ${b.activeRunId}`);
2302
+
2303
+ return `${lines.join('\n')}\n`;
2304
+ }
2305
+
2071
2306
  /** Atomically write state.json. */
2072
2307
  export function writeState(targetDir, state) {
2073
2308
  return writeJsonAtomic(statePathFor(targetDir), state);
@@ -531,6 +531,11 @@ export async function runVerificationLoop({
531
531
  ? `budget-blocked: ${hardStop.reason}`
532
532
  : (launch.launchFailed ? `launch-failed: ${launch.reason}` : describeResult(result))),
533
533
  exitCode: result.exitCode,
534
+ // Carried onto the record, not only onto the attempt below it. An operator reading
535
+ // `gateEvidence` sees how long the command took and how much it wrote, which is how a real red is
536
+ // told from a command that never launched.
537
+ durationMs: result.durationMs ?? null,
538
+ outputBytes: result.outputBytes ?? null,
534
539
  artifactPath: result.artifactPath,
535
540
  artifactHash: result.artifactHash,
536
541
  inputTreeHash,
@@ -687,7 +692,6 @@ export function manualConfirmation({
687
692
  gate, workItemId, phase, projectPath, editorVersion, scope, acceptanceCriterionId = null,
688
693
  relevantFiles = [], criteria = [], rootDir = process.cwd(), approvedBy = 'user', at = new Date(),
689
694
  reason = null, expiresAt = null, environment = null, expiresInMs = null, commit = null,
690
- witness = null, limitations = null,
691
695
  } = {}) {
692
696
  const inputTreeHash = computeInputTreeHash(rootDir, relevantFiles);
693
697
  // v3 quality fields. `scope` is declared both as the free-text `result` line
@@ -699,8 +703,6 @@ export function manualConfirmation({
699
703
  // Redaction has no bypass (contract §8).
700
704
  const scopeList = (Array.isArray(scope) ? scope : (scope ? [scope] : [])).map(redactString);
701
705
  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));
704
706
  const env = Object.fromEntries(
705
707
  Object.entries(environment || (projectPath || editorVersion
706
708
  ? { projectPath: projectPath || null, editorVersion: editorVersion || null }
@@ -740,18 +742,10 @@ export function manualConfirmation({
740
742
  ...(safeReason !== null ? { reason: safeReason } : {}),
741
743
  ...(hasEnv ? { environment: env } : {}),
742
744
  ...(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
745
  };
752
746
  return {
753
747
  evidence, approvedBy, projectPath, editorVersion, scope: scopeList, reason: safeReason,
754
- environment: env, witness: safeWitness, limitations: safeLimitations, recordedAt: timestamp(at),
748
+ environment: env, recordedAt: timestamp(at),
755
749
  };
756
750
  }
757
751