@agent-delivery-harness/cli 0.5.0 → 0.7.3

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.
@@ -31,13 +31,18 @@ import {
31
31
  describeRunEventPayload,
32
32
  evaluateRunJournal,
33
33
  type RunEventPayloadGrammar,
34
+ type RunEventPayloadMemberGrammar,
35
+ type RunEventValueGrammar,
34
36
  type RunEventVersion,
35
37
  } from "@agent-delivery-harness/kernel";
36
38
  import {
37
39
  READOUT_LABELS,
38
40
  detailOf,
41
+ durationLabel,
42
+ phaseRows,
39
43
  readoutRows,
40
44
  roundRows,
45
+ summarize,
41
46
  } from "../run-projection.ts";
42
47
  import { buildRunExport } from "../run-export.ts";
43
48
  import { startRunServer, type RunServerHandle } from "../run-server.ts";
@@ -195,11 +200,31 @@ function parseGrammarArgs(args: readonly string[]): GrammarParse {
195
200
  return { ok: true, args: { kind, version, json } };
196
201
  }
197
202
 
203
+ /**
204
+ * One described value, and everything beneath it. Types, nested tables, array
205
+ * items and variants are printed rather than summarised: the whole point of
206
+ * this view is that a caller never has to open the validator to find out what
207
+ * a member holds.
208
+ */
209
+ function renderValue(value: RunEventValueGrammar, indent: string, lead: string, context: ConfigFreeCommandContext): void {
210
+ context.write(`${indent}${lead}${value.type} ${value.constraint}${value.values === undefined ? "" : ` values: ${value.values.join(", ")}`}`);
211
+ context.write(`${indent} example: ${JSON.stringify(value.example)}`);
212
+ for (const member of value.members ?? []) renderMember(member, `${indent} `, context);
213
+ if (value.items !== undefined) renderValue(value.items, `${indent} `, "items ", context);
214
+ for (const [index, variant] of (value.variants ?? []).entries()) {
215
+ renderValue(variant, `${indent} `, `variant ${index + 1} `, context);
216
+ }
217
+ }
218
+
219
+ function renderMember(member: RunEventPayloadMemberGrammar, indent: string, context: ConfigFreeCommandContext): void {
220
+ renderValue(member, indent, `${member.name} ${member.required ? "required" : "optional"} `, context);
221
+ }
222
+
198
223
  function renderGrammar(grammar: RunEventPayloadGrammar, context: ConfigFreeCommandContext): void {
199
224
  context.write(`${grammar.kind} (${grammar.version})`);
200
- for (const member of grammar.members) {
201
- context.write(` ${member.name} ${member.required ? "required" : "optional"}${member.values === undefined ? "" : ` values: ${member.values.join(", ")}`}`);
202
- }
225
+ for (const member of grammar.members) renderMember(member, " ", context);
226
+ context.write(" example:");
227
+ context.write(JSON.stringify(grammar.example, null, 2));
203
228
  }
204
229
 
205
230
  function showGrammar(context: ConfigFreeCommandContext, args: readonly string[]): CommandResult {
@@ -330,12 +355,23 @@ async function sizeOf(runsDir: string, runId: string): Promise<number> {
330
355
  }
331
356
  }
332
357
 
333
- /** One run as both surfaces see it: the same five facts, rendered or serialized. */
358
+ /** One run as both surfaces see it: the same six facts, rendered or serialized. */
334
359
  interface InventoryRow {
335
360
  readonly runId: string;
336
361
  readonly status: string;
337
362
  readonly open: boolean;
338
363
  readonly current: boolean;
364
+ /**
365
+ * The journal's own span in whole seconds, and `null` where there is no
366
+ * journal to take a span from.
367
+ *
368
+ * NULL, NEVER ZERO. An unreadable journal has no duration; a run that began
369
+ * and ended inside one second has a duration of zero. Spelling both `0` would
370
+ * make the listing's one cycle-time figure unreadable exactly where the
371
+ * status column already says something is wrong — and an agent summing the
372
+ * column would count the unreadable ones as instantaneous.
373
+ */
374
+ readonly durationSeconds: number | null;
339
375
  readonly bytes: number;
340
376
  }
341
377
 
@@ -367,11 +403,12 @@ async function inventoryOf(
367
403
  status: evaluateRunJournal(read.events).status,
368
404
  open: !read.events.some((event) => event.kind === "run.ended"),
369
405
  current: runId === currentRunId,
406
+ durationSeconds: summarize(read.events).durationSeconds,
370
407
  bytes,
371
408
  }
372
409
  : // Nothing was read, so no lifecycle or completeness state is claimed.
373
410
  // The worktree pointer and on-disk size are independent readable facts.
374
- { runId, status: RUN_LIST_UNREADABLE, open: false, current: runId === currentRunId, bytes },
411
+ { runId, status: RUN_LIST_UNREADABLE, open: false, current: runId === currentRunId, durationSeconds: null, bytes },
375
412
  );
376
413
  }
377
414
  return { rows, currentRunId };
@@ -426,11 +463,16 @@ async function listRuns(surface: RunSurface, context: ConfigFreeCommandContext,
426
463
  // for first, before it knows which id to show; an unlabeled `complete` here
427
464
  // is the misreading the labels exist to prevent.
428
465
  const lines: string[] = [`runs in ${oneLine(surface.runsDir, 400)}`, ` (${READOUT_LABELS})`];
466
+ // The duration goes AHEAD of the size, which is the demotion this column was
467
+ // added for: an operator scanning this listing is asking which delivery took
468
+ // a day, and the journal's size on disk answers a question nobody has except
469
+ // when something is wrong with the file. The size stays, because it is the
470
+ // only readable fact about an unreadable journal.
429
471
  for (const row of shown) {
430
472
  lines.push(
431
- row.status === RUN_LIST_UNREADABLE
473
+ row.status === RUN_LIST_UNREADABLE || row.durationSeconds === null
432
474
  ? ` ${row.runId} ${RUN_LIST_UNREADABLE} ${row.bytes} bytes`
433
- : ` ${row.runId} ${row.status} ${row.open ? "open" : "ended"}${row.current ? " current" : ""} ${row.bytes} bytes`,
475
+ : ` ${row.runId} ${row.status} ${row.open ? "open" : "ended"}${row.current ? " current" : ""} ${durationLabel(row.durationSeconds)} ${row.bytes} bytes`,
434
476
  );
435
477
  }
436
478
  lines.push(`total ${totalBytes} bytes across ${selected.length} run(s)`);
@@ -481,6 +523,10 @@ async function showRun(surface: RunSurface, context: ConfigFreeCommandContext, r
481
523
  context.write(` ${event.seq} ${event.at} ${event.kind.padEnd(20)} ${event.actor.role.padEnd(8)} ${detailOf(event)}`);
482
524
  }
483
525
 
526
+ // The phase breakdown sits directly under the timeline, before the rounds it
527
+ // divides the run by, because it is the summary those rows are the detail of.
528
+ for (const row of phaseRows(events)) context.write(row);
529
+
484
530
  const rounds = roundRows(events);
485
531
  if (rounds.length > 0) {
486
532
  context.write(" rounds:");
@@ -44,7 +44,8 @@ import {
44
44
  } from "@agent-delivery-harness/kernel";
45
45
  import { commandBlocker } from "../boundary.ts";
46
46
  import type { CommandContext, CommandDescriptor, CommandResult } from "../boundary.ts";
47
- import { oneLine, resolveRunJournalRow, runJournalRows } from "../run-surface.ts";
47
+ import { RUN_JOURNAL_ADMISSION_ROW, oneLine, resolveRunJournalRow, resolveRunSpan, runJournalRows } from "../run-surface.ts";
48
+ import { durationLabel } from "../run-projection.ts";
48
49
 
49
50
  const USAGE = "Usage: delivery-harness verify [--require-run-journal] [--mandated-lens <id>]...";
50
51
 
@@ -98,11 +99,61 @@ function parseArgs(args: readonly string[]): ArgParse {
98
99
  function runJournalBlocker(row: RunJournalRow) {
99
100
  const missing = row.missing.length === 0 ? "(none)" : row.missing.join(", ");
100
101
  const violations = row.violations === undefined || row.violations.length === 0 ? "(none)" : row.violations.join(", ");
102
+ // The same per-violation reasons the row prints. A refusal that named only
103
+ // the identifiers would make the operator run the command again with the
104
+ // flag dropped just to read why.
105
+ //
106
+ // EVERYTHING THE REMEDIATION NAMES COMES FIRST, BECAUSE A DETAIL IS CUT FROM
107
+ // THE END. A blocker's detail reaches a terminal through `renderBlockers`,
108
+ // which bounds it at 600 characters, so a journal with several warnings
109
+ // pushes whatever is last off the end. Two things must never be the
110
+ // casualty: the line saying none of this blocks admission, because a
111
+ // truncated list of violated constraints with no such line reads as a
112
+ // verdict; and the `missing:` list, because the remediation tells the
113
+ // operator to emit the run events this delivery did not journal and that
114
+ // list is the only place this refusal says which ones. So the identifiers,
115
+ // the missing list and the admission sentence all precede the per-warning
116
+ // reasons, which are the right thing to lose to the cap — the row on stdout,
117
+ // which is written line by line and is not bounded this way, always carries
118
+ // all of them. Inside a warning's own segment the `(a consequence of …)`
119
+ // clause goes before the reason for the same arithmetic: the reason is the
120
+ // long part, so a clause written after it is the first thing the cap
121
+ // destroys. With the clause after the reason it vanished from the
122
+ // reproduction shape once the run id reached 36 characters, and `runId` is
123
+ // accepted up to 128; with it before, it survives at every run id length
124
+ // through 128, as do the missing list and the admission sentence. Two
125
+ // warnings that restate one fact, printed as peers with nothing saying so,
126
+ // are the exact misreading this row exists to prevent.
127
+ //
128
+ // Do not read the 120 as making the whole detail fit. It never does on a
129
+ // journal with more than one warning: the reproduction's two render 733
130
+ // characters at this bound (753 at 130, 813 at 160, and 1045 at 400 and at
131
+ // every larger bound — the longer of its two reasons is 315 characters, so
132
+ // nothing above that is binding) against a 600-character cut, and the later
133
+ // warnings' reasons are lost at every one of them — that is what the
134
+ // unbounded stdout row is for.
135
+ //
136
+ // What 120 buys is narrower than it looks, and the narrowness is the point,
137
+ // because two earlier rounds of this delivery each shipped a confident and
138
+ // wrong statement about this budget. Measured on the reproduction's two
139
+ // warnings at the run id this store actually mints (`run-` + 16 hex = 20
140
+ // characters), the second warning's identifier begins inside the cut — as
141
+ // `round-not-bou…`, 13 of its 25 characters — and at 121 and above it does
142
+ // not. That is the whole of it. The margin is one character: at a 21-
143
+ // character run id, which `isLegalRunId` admits up to 128, it is already
144
+ // gone, and a journal carrying a third warning loses the later segments
145
+ // whatever the bound is. What a larger bound costs is a later warning's own
146
+ // SEGMENT, never its name: the `violations:` list and any `(a consequence
147
+ // of …)` clause naming it are ahead of the cut and survive regardless.
148
+ const why = (row.explanations ?? []).map(
149
+ (explanation) =>
150
+ `; ${oneLine(explanation.violation, 64)}${explanation.consequenceOf === undefined ? "" : ` (a consequence of ${oneLine(explanation.consequenceOf, 64)})`}: ${oneLine(explanation.because, 120)}`,
151
+ ).join("");
101
152
  return commandBlocker({
102
153
  code: "run_journal_incomplete",
103
154
  sourceId: "delivery-harness.cli.verify",
104
155
  summary: "The run journal for this candidate is not complete, and --require-run-journal was given.",
105
- details: `status ${row.status}${row.runId === undefined ? "" : ` (run ${oneLine(row.runId, 128)})`}; missing: ${missing}; violations: ${violations}`,
156
+ details: `status ${row.status}${row.runId === undefined ? "" : ` (run ${oneLine(row.runId, 128)})`}; violations: ${violations}; missing: ${missing}; ${RUN_JOURNAL_ADMISSION_ROW}${why}`,
106
157
  remediations: [
107
158
  {
108
159
  id: "emit-the-missing-run-events",
@@ -113,6 +164,74 @@ function runJournalBlocker(row: RunJournalRow) {
113
164
  });
114
165
  }
115
166
 
167
+ /**
168
+ * The record's own span, checked against the journal that binds its candidate.
169
+ *
170
+ * WHAT IS CHECKED, AND WHY THOSE TWO THINGS. `startedAt` must EQUAL the
171
+ * journal's first instant: a journal is append-only, so its first event never
172
+ * moves and a record that disagrees about when the delivery began is describing
173
+ * some other run. `endedAt` must lie inside the journal's span, because the
174
+ * journal keeps growing after the record is written — `verify`, `pr.opened` and
175
+ * `run.ended` all land later — so equality there would fail on every honest
176
+ * record, while an `endedAt` after the journal's last instant or before its
177
+ * first could not have been read off it at all.
178
+ *
179
+ * WHY A MISS IS NOT A REFUSAL. A journal is self-attested observability that
180
+ * anything the owner executes can append to, and this command runs in CI over
181
+ * checkouts that carry no run store whatever. So the refusal is available only
182
+ * where a journal BINDS this record's candidate — never on whichever run is
183
+ * current in the checkout someone verified from, and never as a demand that a
184
+ * journal exist. Where none binds it the span is reported unchecked, which is
185
+ * what `verify` could honestly say before this member existed too.
186
+ */
187
+ async function runSpanRows(
188
+ rootDir: string,
189
+ record: { readonly runSpan?: { readonly startedAt: string; readonly endedAt: string }; readonly candidateBinding: { readonly treeSha: string } },
190
+ ): Promise<{ readonly rows: readonly string[]; readonly blocker?: ReturnType<typeof commandBlocker> }> {
191
+ const span = record.runSpan;
192
+ if (span === undefined) return { rows: [] };
193
+ const spent = (Date.parse(span.endedAt) - Date.parse(span.startedAt)) / 1000;
194
+ const recorded = `recorded run span: ${span.startedAt} to ${span.endedAt} (${durationLabel(spent)})`;
195
+ // `preferStartedAt` is what keeps a SECOND run in the same worktree — the one
196
+ // this verify is itself running under, say — from refusing a record whose own
197
+ // run reports exactly this span. A refusal stands only where no journal
198
+ // binding this candidate starts where the record says it did.
199
+ const journal = await resolveRunSpan({
200
+ cwd: rootDir,
201
+ treeSha: record.candidateBinding.treeSha,
202
+ preferStartedAt: span.startedAt,
203
+ });
204
+ if (journal === undefined) {
205
+ return { rows: [`${recorded}; unchecked: no run journal in this repository binds this candidate`] };
206
+ }
207
+ const disagreement =
208
+ span.startedAt !== journal.startedAt
209
+ ? `the record starts at ${span.startedAt} but run ${oneLine(journal.runId, 128)} starts at ${journal.startedAt}`
210
+ : span.endedAt > journal.endedAt
211
+ ? `the record ends at ${span.endedAt}, after the last instant run ${oneLine(journal.runId, 128)} reached (${journal.endedAt})`
212
+ : undefined;
213
+ if (disagreement === undefined) {
214
+ return { rows: [`${recorded}; checked against run ${oneLine(journal.runId, 128)}`] };
215
+ }
216
+ return {
217
+ rows: [],
218
+ blocker: commandBlocker({
219
+ code: "record_run_span_mismatch",
220
+ sourceId: "delivery-harness.cli.verify",
221
+ summary: "The delivery record's run span disagrees with the run journal that binds its candidate.",
222
+ details: disagreement,
223
+ remediations: [
224
+ {
225
+ id: "re-record-the-span",
226
+ kind: "command",
227
+ command: ["delivery-harness", "record"],
228
+ summary: "Re-record this candidate so its span is the one its own run journal reports.",
229
+ },
230
+ ],
231
+ }),
232
+ };
233
+ }
234
+
116
235
  export const verifyCommand: CommandDescriptor = {
117
236
  name: "verify",
118
237
  sourceId: "delivery-harness.cli.verify",
@@ -230,6 +349,11 @@ export const verifyCommand: CommandDescriptor = {
230
349
  return { kind: "blocked", blockers: [runJournalBlocker(runJournal)] };
231
350
  }
232
351
 
352
+ // Judged in the same place and for the same reason: a record that fails its
353
+ // own verification is never told its span is the problem.
354
+ const span = await runSpanRows(context.rootDir, parsed.record);
355
+ if (span.blocker !== undefined) return { kind: "blocked", blockers: [span.blocker] };
356
+
233
357
  const relaxation = check.baseMovementRelaxed
234
358
  ? ` (base movement relaxed by policy: ${check.relaxedDriftClasses.join(", ")})`
235
359
  : "";
@@ -243,6 +367,7 @@ export const verifyCommand: CommandDescriptor = {
243
367
  `verified ${relativePath}${relaxation}; attestation: ${check.attestationLabel}`,
244
368
  `recorded base: ${oneLine(parsed.record.candidateBinding.baseRef, 256)} at ${parsed.record.candidateBinding.baseTipSha}`,
245
369
  `observed base: ${oneLine(base.ref, 256)} at ${base.tipSha}`,
370
+ ...span.rows,
246
371
  ...hostedCheckRow,
247
372
  ...runJournalRows(runJournal),
248
373
  ].join("\n"),
package/src/index.ts CHANGED
@@ -102,3 +102,6 @@ export { resumeCommand } from "./commands/resume.ts";
102
102
  export function runCli(argv: readonly string[], runtime: CliRuntime): Promise<number> {
103
103
  return runCliBoundary(argv, COMMANDS, runtime);
104
104
  }
105
+
106
+ export { readScopedCheckObservations, type ScopedCheckObservations } from "./scoped-observations.ts";
107
+ export { readScopedCheckDiagnostics, type ScopedCheckDiagnostics, type ScopedAttemptDiagnostic, type RecordedScopedAttemptDiagnostic, type ScopedDiagnosticPhase, type ScopedDiagnosticFailureCode, type ScopedDiagnosticExecutionCode, type ScopedDiagnosticCommand, type ScopedDependencyDiagnostic } from "./scoped-diagnostics.ts";
package/src/main.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  * a Ctrl-C into the typed {@link CliInterruption} the boundary maps to exit 130.
10
10
  */
11
11
  import { createInterface } from "node:readline";
12
- import { CliInterruption, EXIT_POLICY, runCli, type CliRuntime } from "./index.ts";
12
+ import { CliInterruption, EXIT_INTERRUPTED, EXIT_POLICY, runCli, type CliRuntime } from "./index.ts";
13
13
  import { invokedDirectly, type WaiverPrompt } from "@agent-delivery-harness/kernel";
14
14
 
15
15
  /**
@@ -35,23 +35,25 @@ import { invokedDirectly, type WaiverPrompt } from "@agent-delivery-harness/kern
35
35
  * would reject and then resolve, and a settled promise silently ignoring its
36
36
  * second settlement is exactly how this class of bug hides.
37
37
  */
38
- export function createWaiverPrompt(input: NodeJS.ReadableStream, output: NodeJS.WritableStream): WaiverPrompt {
38
+ export function createWaiverPrompt(input: NodeJS.ReadableStream, output: NodeJS.WritableStream, signal?: AbortSignal): WaiverPrompt {
39
39
  return (decision, obligationIds) =>
40
40
  new Promise<Awaited<ReturnType<WaiverPrompt>>>((resolve, reject) => {
41
+ if (signal?.aborted) { reject(new CliInterruption()); return; }
41
42
  const rl = createInterface({ input, output });
42
43
  let settled = false;
43
44
  const settle = (action: () => void): void => {
44
45
  if (settled) return;
45
46
  settled = true;
47
+ signal?.removeEventListener("abort", interrupt);
46
48
  action();
47
49
  };
48
50
 
49
- rl.on("SIGINT", () => {
50
- settle(() => {
51
- rl.close();
52
- reject(new CliInterruption("Waiver prompt interrupted."));
53
- });
51
+ const interrupt = () => settle(() => {
52
+ rl.close();
53
+ reject(new CliInterruption("Waiver prompt interrupted."));
54
54
  });
55
+ rl.on("SIGINT", interrupt);
56
+ signal?.addEventListener("abort", interrupt, { once: true });
55
57
  // Ctrl-D, a closed pipe, or any other end of input.
56
58
  rl.on("close", () => {
57
59
  settle(() => resolve(false));
@@ -93,20 +95,29 @@ export const readlineWaiverPrompt: WaiverPrompt = (decision, obligationIds) =>
93
95
  * reads as empty, and the store refuses the empty payload with a diagnostic,
94
96
  * which is a far better answer than silence.
95
97
  */
96
- export function readStdinText(input: NodeJS.ReadableStream & { isTTY?: boolean }): Promise<string> {
98
+ export function readStdinText(input: NodeJS.ReadableStream & { isTTY?: boolean }, signal?: AbortSignal): Promise<string> {
99
+ if (signal?.aborted) return Promise.reject(new CliInterruption());
97
100
  if (input.isTTY === true) return Promise.resolve("");
98
- return new Promise((resolve) => {
101
+ return new Promise((resolve, reject) => {
99
102
  let text = "";
103
+ const cleanup = () => {
104
+ input.removeListener("data", data);
105
+ input.removeListener("error", finish);
106
+ input.removeListener("end", finish);
107
+ signal?.removeEventListener("abort", interrupt);
108
+ };
109
+ const data = (chunk: string) => { text += chunk; };
110
+ const finish = () => { cleanup(); resolve(text); };
111
+ const interrupt = () => { cleanup(); input.pause(); reject(new CliInterruption()); };
100
112
  input.setEncoding("utf8");
101
- input.on("data", (chunk: string) => {
102
- text += chunk;
103
- });
104
- input.once("error", () => resolve(text));
105
- input.once("end", () => resolve(text));
113
+ input.on("data", data);
114
+ input.once("error", finish);
115
+ input.once("end", finish);
116
+ signal?.addEventListener("abort", interrupt, { once: true });
106
117
  });
107
118
  }
108
119
 
109
- export function defaultRuntime(): CliRuntime {
120
+ export function defaultRuntime(signal?: AbortSignal): CliRuntime {
110
121
  return {
111
122
  cwd: process.cwd(),
112
123
  env: process.env,
@@ -114,13 +125,20 @@ export function defaultRuntime(): CliRuntime {
114
125
  stdoutIsTTY: process.stdout.isTTY === true,
115
126
  stdout: (text) => process.stdout.write(text),
116
127
  stderr: (text) => process.stderr.write(text),
117
- promptForWaiver: readlineWaiverPrompt,
118
- readStdin: () => readStdinText(process.stdin),
128
+ ...(signal ? { signal } : {}),
129
+ promptForWaiver: createWaiverPrompt(process.stdin, process.stderr, signal),
130
+ readStdin: () => readStdinText(process.stdin, signal),
119
131
  };
120
132
  }
121
133
 
122
134
  export async function main(argv: readonly string[]): Promise<number> {
123
- return runCli(argv, defaultRuntime());
135
+ const controller = new AbortController();
136
+ const interrupt = () => controller.abort();
137
+ process.on("SIGINT", interrupt);
138
+ try {
139
+ const code = await runCli(argv, defaultRuntime(controller.signal));
140
+ return controller.signal.aborted ? EXIT_INTERRUPTED : code;
141
+ } finally { process.removeListener("SIGINT", interrupt); }
124
142
  }
125
143
 
126
144
  if (invokedDirectly(process.argv[1], import.meta.url)) {
@@ -79,21 +79,5 @@ export async function installedRelease(rootDir: string) {
79
79
 
80
80
  export const policyDigest = (context: CommandContext) => digestCanonical({ config: context.config, policyBinding: context.policyBinding ?? null });
81
81
 
82
- /** Observed action outcomes are a host handoff, never permission to invoke an action. */
83
- export function reconciliationActions(events: readonly RunEvent[]) {
84
- const actions = new Map<string, { actionId: string; operation?: unknown; reference: unknown; outcome: string; inconsistent?: boolean }>();
85
- for (const event of events) {
86
- const p = event.payload;
87
- if (event.kind === "action.intent") {
88
- const actionId = p["actionId"] as string;
89
- const prior = actions.get(actionId);
90
- actions.set(actionId, { actionId, operation: p["operation"], reference: p["reference"], outcome: "unknown", ...(prior ? { inconsistent: true } : {}) });
91
- } else if (event.kind === "action.observed") {
92
- const actionId = p["actionId"] as string;
93
- const prior = actions.get(actionId);
94
- actions.set(actionId, { ...prior, actionId, reference: p["reference"], outcome: p["outcome"] as string,
95
- ...(!prior || prior.inconsistent || (prior.outcome !== "unknown" && prior.outcome !== p["outcome"]) ? { inconsistent: true } : {}) });
96
- }
97
- }
98
- return [...actions.values()];
99
- }
82
+ // One reduction is shared with the atomic journal append validator.
83
+ export { reconciliationActions } from "@agent-delivery-harness/kernel";
package/src/run-export.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  evaluateRunJournal,
11
11
  RUN_STORE_ID,
12
12
  validateRunEvent,
13
+ runActionReconciliationError,
13
14
  type RunEvent,
14
15
  } from "@agent-delivery-harness/kernel";
15
16
  import {
@@ -55,6 +56,37 @@ export function buildRunExport(input: {
55
56
  };
56
57
  }
57
58
 
59
+ /**
60
+ * The readout members a stored export is CHECKED against.
61
+ *
62
+ * The per-violation sentences are derived from the same events by the same
63
+ * pass on every read, so an export never has to carry them and is never
64
+ * refused for lacking them: an archive written before the readout explained
65
+ * itself stays valid, and one carrying invented sentences gains nothing,
66
+ * because the value returned below is rebuilt from the recomputation either
67
+ * way. Compare what the events fix; recompute what they derive.
68
+ */
69
+ function comparableReadout(readout: object): Record<string, unknown> {
70
+ const comparable: Record<string, unknown> = { ...readout };
71
+ delete comparable["explanations"];
72
+ return comparable;
73
+ }
74
+
75
+ /**
76
+ * The summary members a stored export is CHECKED against.
77
+ *
78
+ * `phases` is derived from the same events by the same pass on every read, for
79
+ * exactly the reason the readout's sentences are: an archive written before the
80
+ * phase breakdown existed stays valid, and one carrying invented phases gains
81
+ * nothing, because the value returned below is rebuilt from the recomputation
82
+ * either way. Compare what the events fix; recompute what they derive.
83
+ */
84
+ function comparableSummary(summary: object): Record<string, unknown> {
85
+ const comparable: Record<string, unknown> = { ...summary };
86
+ delete comparable["phases"];
87
+ return comparable;
88
+ }
89
+
58
90
  export type DeliveryRunExport = ReturnType<typeof buildRunExport> & {
59
91
  readonly attachments?: RunAttachments;
60
92
  };
@@ -103,6 +135,7 @@ export function parseRunExport(text: string): RunExportParseResult {
103
135
  : "costs,events,labels,readout,refusedAppends,runId,spec,summary")
104
136
  )
105
137
  return invalid;
138
+ const prefix: RunEvent[] = [];
106
139
  for (const [index, event] of value["events"].entries()) {
107
140
  if (
108
141
  !validateRunEvent(event).ok ||
@@ -112,6 +145,9 @@ export function parseRunExport(text: string): RunExportParseResult {
112
145
  event["version"] !== (v2 ? "run-event/2" : "run-event/1")
113
146
  )
114
147
  return invalid;
148
+ const validated = event as unknown as RunEvent;
149
+ if (runActionReconciliationError(prefix, validated)) return invalid;
150
+ prefix.push(validated);
115
151
  }
116
152
  if (
117
153
  hasAttachments &&
@@ -132,7 +168,8 @@ export function parseRunExport(text: string): RunExportParseResult {
132
168
  )
133
169
  return invalid;
134
170
  if (
135
- canonicalize(value["summary"]) !== canonicalize(expected.summary) ||
171
+ !isRecord(value["summary"]) ||
172
+ canonicalize(comparableSummary(value["summary"])) !== canonicalize(comparableSummary(expected.summary)) ||
136
173
  canonicalize(value["costs"]) !== canonicalize(expected.costs)
137
174
  )
138
175
  return invalid;
@@ -141,7 +178,8 @@ export function parseRunExport(text: string): RunExportParseResult {
141
178
  const { note, ...readout } = value["readout"];
142
179
  if (
143
180
  (note !== undefined && typeof note !== "string") ||
144
- canonicalize(readout) !== canonicalize(expected.readout)
181
+ canonicalize(comparableReadout(readout)) !==
182
+ canonicalize(comparableReadout(expected.readout))
145
183
  )
146
184
  return invalid;
147
185
  return {