cadet-agent 0.56.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.
@@ -15,6 +15,7 @@ import {
15
15
  REACHABILITY_GATE, REACHABILITY_TRANSITION_FROM,
16
16
  DESIGN_REVIEW_GATE, DESIGN_REVIEW_TRANSITION_FROM,
17
17
  HUMAN_ACCEPTANCE_GATE, HUMAN_ACCEPTANCE_TRANSITION_FROM,
18
+ USER_PLAY_GATE, USER_PLAY_TRANSITION_FROM,
18
19
  ARCHITECTURE_GATE, ARCHITECTURE_TRANSITION_FROM, ARCHITECTURE_TRANSITION_TO,
19
20
  architectureFitnessActive,
20
21
  } from './policy.mjs';
@@ -435,11 +436,50 @@ export function validateState(state, context = {}) {
435
436
  const scope = Array.isArray(entry.scope) ? entry.scope : (entry.scope ? [entry.scope] : []);
436
437
  for (const s of scope) excepted.add(String(s));
437
438
  }
439
+ // The same ambiguity, seen from the other side: a story the session moved on
440
+ // FROM, with evidence behind it, that still reads `planned` — which makes a
441
+ // finished story indistinguishable from one that was never started.
442
+ //
443
+ // The data already tells the two apart. `storyCompletions` records the
444
+ // boundary and how much the item left behind, and its comment says why the
445
+ // count is there: so "a completion with nothing behind it is visible rather
446
+ // than implied". A row with nothing behind it is a hollow completion — an
447
+ // item begun and then abandoned — and is left alone here. A row with records
448
+ // in it is a story that was carried to a verdict, and `planned` is then a
449
+ // false statement about it.
450
+ //
451
+ // It is an ERROR, not a warning, for the reason the rule below gives, and its
452
+ // remedy has the same shape: say what happened. A story that was superseded
453
+ // rather than delivered is not `planned` either.
454
+ //
455
+ // `in-progress` is deliberately NOT checked: `state begin` on an item that
456
+ // still carries its old completion row leaves it active, so active-plus-row
457
+ // is a legitimate combination and refusing it would be a false accusation.
458
+ const completedWith = new Map();
459
+ if (Array.isArray(state.storyCompletions)) {
460
+ for (const row of state.storyCompletions) {
461
+ if (!isPlainObject(row)) continue;
462
+ if (typeof row.workItemId !== 'string' || row.workItemId.length === 0) continue;
463
+ const records = Number(row.evidenceRecords);
464
+ if (!(records > 0)) continue;
465
+ completedWith.set(row.workItemId, records);
466
+ }
467
+ }
438
468
  for (const [epicId, epic] of Object.entries(state.epics)) {
439
469
  if (!isPlainObject(epic) || !isPlainObject(epic.stories)) continue;
440
470
  for (const [storyId, status] of Object.entries(epic.stories)) {
441
- if (status !== 'done') continue;
442
471
  const workItemId = `${epicId}::${storyId}`;
472
+ if (status === 'planned' && completedWith.has(workItemId)) {
473
+ errors.push({
474
+ path: `epics.${epicId}.stories.${storyId}`,
475
+ message: `story "${storyId}" reads "planned" but the completion row for its work item `
476
+ + `"${workItemId}" records ${completedWith.get(workItemId)} evidence record(s). A story the `
477
+ + 'session moved on from, with evidence behind it, is not planned: it is done, or it is '
478
+ + 'superseded. Leaving it planned makes a finished story indistinguishable from one that '
479
+ + 'was never started.',
480
+ });
481
+ }
482
+ if (status !== 'done') continue;
443
483
  if (evidenced.has(workItemId)) continue;
444
484
  if (excepted.has(workItemId)) continue;
445
485
  errors.push({
@@ -500,15 +540,25 @@ function validateEvidenceShape(ev, strict = null) {
500
540
  if (ev.evidenceId !== undefined && !isUuid(ev.evidenceId)) errors.push({ path: 'evidenceId', message: 'evidenceId must be a UUIDv4' });
501
541
  if (ev.phase !== undefined && !PHASES.includes(ev.phase)) errors.push({ path: 'phase', message: `unknown phase "${ev.phase}"` });
502
542
  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
- }
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
+ });
512
562
  }
513
563
  }
514
564
  // The architecture record is a claim about checks, so it must say which checks. One
@@ -1093,6 +1143,8 @@ export function createEvidence({
1093
1143
  command = null,
1094
1144
  result = null,
1095
1145
  exitCode = null,
1146
+ durationMs = null,
1147
+ outputBytes = null,
1096
1148
  artifactPath = null,
1097
1149
  artifactHash = null,
1098
1150
  inputTreeHash,
@@ -1122,6 +1174,13 @@ export function createEvidence({
1122
1174
  command,
1123
1175
  result,
1124
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,
1125
1184
  artifactPath,
1126
1185
  artifactHash,
1127
1186
  inputTreeHash,
@@ -1316,7 +1375,6 @@ export function activeExceptions(state, { workItemId, now = new Date() } = {}) {
1316
1375
 
1317
1376
  // ── Transitions ─────────────────────────────────────────────────────────────
1318
1377
 
1319
- /** Required gates for a transition target, or null when the target is not gated. */
1320
1378
  /**
1321
1379
  * Gates required on an edge that carries no entry in `TRANSITIONS`.
1322
1380
  *
@@ -1336,7 +1394,17 @@ export function conditionalEdgeGates(fromPhase, toPhase, policy = null) {
1336
1394
  return policy?.designReview?.enabled === true ? [DESIGN_REVIEW_GATE] : [];
1337
1395
  }
1338
1396
 
1339
- export function requiredGates(toPhase, { reachability = false, humanAcceptance = false, architectureFitness = false } = {}) {
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
+ */
1407
+ export function requiredGates(toPhase, { reachability = false, humanAcceptance = false, architectureFitness = false, userPlay = false } = {}) {
1340
1408
  for (const [from, spec] of Object.entries(TRANSITIONS)) {
1341
1409
  if (spec.to === toPhase) {
1342
1410
  const gates = [...spec.gates];
@@ -1359,6 +1427,16 @@ export function requiredGates(toPhase, { reachability = false, humanAcceptance =
1359
1427
  // revalidated gate is judged without its phase stamp — which for this one would
1360
1428
  // mean accepting a record written in any phase at all.
1361
1429
  if (humanAcceptance && from === HUMAN_ACCEPTANCE_TRANSITION_FROM) gates.push(HUMAN_ACCEPTANCE_GATE);
1430
+ // User play joins the STORY boundary (`review -> validation`) and nothing else, and
1431
+ // only when the repository has asked for it. Placement: a playthrough is a claim
1432
+ // about a delivered, reviewed story, and this is the edge a story crosses before the
1433
+ // next one starts — `validation -> implementation` is the next-story loop and stays
1434
+ // unblocked, which is why the requirement cannot live there. It is deliberately NOT
1435
+ // in `revalidate`: at `validation -> closed` the epic's own
1436
+ // `humanAcceptanceConfirmed` covers the whole epic, and demanding every story's play
1437
+ // again at closure would multiply the person's work at the exact moment they are
1438
+ // being asked to accept the finished thing.
1439
+ if (userPlay && from === USER_PLAY_TRANSITION_FROM) gates.push(USER_PLAY_GATE);
1362
1440
  // Architecture fitness joins `implementation -> review`, and only when the project
1363
1441
  // has declared checks. It is appended at evaluation time rather than written into
1364
1442
  // the transition table, so a project that declares nothing keeps the frozen lists
@@ -1579,6 +1657,7 @@ export function evaluateTransition(state, toPhase, context = {}) {
1579
1657
  reachability: context.policy?.reachability?.enabled === true,
1580
1658
  humanAcceptance: context.policy?.humanAcceptance?.enabled === true,
1581
1659
  architectureFitness: architectureFitnessActive(context.policy),
1660
+ userPlay: context.policy?.userPlay?.enabled === true,
1582
1661
  });
1583
1662
  // An edge with no `TRANSITIONS` entry can still carry a gate. It is resolved HERE,
1584
1663
  // before the ungated-edge shortcut below, because that shortcut is what would
@@ -2017,6 +2096,213 @@ export function writeJsonAtomic(path, value) {
2017
2096
  return path;
2018
2097
  }
2019
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
+
2020
2306
  /** Atomically write state.json. */
2021
2307
  export function writeState(targetDir, state) {
2022
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