@agent-delivery-harness/cli 0.2.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.
Files changed (47) hide show
  1. package/LICENSE +104 -201
  2. package/NOTICE +1 -1
  3. package/package.json +5 -5
  4. package/src/boundary.ts +84 -63
  5. package/src/check-snapshot.ts +167 -0
  6. package/src/command-activity.ts +75 -0
  7. package/src/commands/admit.ts +106 -0
  8. package/src/commands/check.ts +13 -2
  9. package/src/commands/emit-review-evidence.ts +38 -0
  10. package/src/commands/emit.ts +80 -12
  11. package/src/commands/gate.ts +64 -13
  12. package/src/commands/maintain.ts +1 -0
  13. package/src/commands/managed.ts +105 -34
  14. package/src/commands/prepare.ts +148 -22
  15. package/src/commands/record.ts +107 -3
  16. package/src/commands/resume.ts +53 -0
  17. package/src/commands/review-context.ts +19 -0
  18. package/src/commands/runs.ts +461 -30
  19. package/src/commands/save-context.ts +51 -0
  20. package/src/commands/submit-evidence.ts +46 -7
  21. package/src/commands/verify.ts +156 -10
  22. package/src/declared-checks.ts +50 -0
  23. package/src/index.ts +17 -1
  24. package/src/main.ts +56 -65
  25. package/src/ordinary-context.ts +83 -0
  26. package/src/provider-rails.ts +23 -704
  27. package/src/record-retention.ts +325 -0
  28. package/src/review-evidence.ts +284 -0
  29. package/src/run-archive-commands.ts +164 -0
  30. package/src/run-archive.ts +127 -0
  31. package/src/run-artifact-commands.ts +163 -0
  32. package/src/run-attachments.ts +112 -0
  33. package/src/run-export.ts +202 -0
  34. package/src/run-live.ts +102 -0
  35. package/src/run-projection.ts +327 -12
  36. package/src/run-server.ts +403 -149
  37. package/src/run-surface.ts +196 -9
  38. package/src/run-view-command.ts +77 -0
  39. package/src/run-view-html.ts +138 -0
  40. package/src/run-view-record.ts +119 -0
  41. package/src/run-view.ts +343 -0
  42. package/src/scoped-attempts.ts +86 -0
  43. package/src/scoped-candidate.ts +28 -0
  44. package/src/scoped-checks.ts +196 -0
  45. package/src/scoped-diagnostics.ts +124 -0
  46. package/src/scoped-observations.ts +51 -0
  47. package/src/snapshot-inventory-worker.ts +40 -0
@@ -20,9 +20,13 @@
20
20
  */
21
21
  import {
22
22
  RUN_JOURNAL_REQUIRED_ENTRIES,
23
+ explainRunJournal,
23
24
  runJournalCarries,
25
+ runPrimaryTicket,
26
+ projectRunActivities,
24
27
  type RunEvent,
25
28
  type RunJournalEvaluation,
29
+ type RunJournalExplanation,
26
30
  type RunJournalRequiredEntry,
27
31
  } from "@agent-delivery-harness/kernel";
28
32
  import { harnessConfigPresentAt, oneLine, oneLineOf } from "./run-surface.ts";
@@ -30,9 +34,91 @@ import { harnessConfigPresentAt, oneLine, oneLineOf } from "./run-surface.ts";
30
34
  /** The three labels every readout carries, so no reader mistakes this for evidence. */
31
35
  export const READOUT_LABELS = "self-attested; observability, not evidence; unbound to a record";
32
36
 
37
+ /** A single progress view shared by renderers; callers supply their observation clock. */
38
+ export function projectRunProgress(events: readonly RunEvent[], now: string, freshnessWindowMs?: number) {
39
+ const projection = projectRunActivities(events, { now, ...(freshnessWindowMs === undefined ? {} : { freshnessWindowMs }) });
40
+ const latestFindings = new Map<string, (typeof projection.findings)[number]>();
41
+ for (const finding of projection.findings) {
42
+ if (finding.current) latestFindings.set(String(finding.payload["findingId"]), finding);
43
+ }
44
+ return {
45
+ asOf: now,
46
+ ...projection,
47
+ currentFindings: [...latestFindings.values()],
48
+ findingsCoverage: events.some(e => e.kind === "finding.observed") ? "reported" as const : "unreported" as const,
49
+ };
50
+ }
51
+
52
+ export function costLabel(value: unknown): string {
53
+ const cost = typeof value === "object" && value !== null ? value as Record<string, unknown> : {};
54
+ if (cost["coverage"] === "unreported" || cost["total"] === undefined) return "unreported";
55
+ const measured = `${oneLineOf(cost["total"])} ${oneLineOf(cost["unit"])}`.trim();
56
+ return cost["coverage"] === "partial" ? `${measured} (partial coverage)` : measured;
57
+ }
58
+
59
+ /** Aggregate only comparable review counters; the run-wide counter overlaps them. */
60
+ export function projectCosts(events: readonly RunEvent[]) {
61
+ const allClosed = events.filter((event) => event.kind === "review.round.closed");
62
+ // v2 round costs are reported cumulative snapshots. Repeated/reopened round
63
+ // snapshots cannot be summed as independent consumption. Keep the latest
64
+ // comparable value and mark coverage partial when supersession occurred.
65
+ const snapshots = new Map<string, RunEvent>();
66
+ for (const event of allClosed) {
67
+ const payload = payloadOf(event);
68
+ const cost = payload["cost"] as Record<string, unknown>;
69
+ const key = event.version === "run-event/2"
70
+ ? JSON.stringify([payload["roundId"], cost["reportedBy"], cost["unit"] ?? "unreported"])
71
+ : `legacy-${event.seq}`;
72
+ snapshots.set(key, event);
73
+ }
74
+ const closed = [...snapshots.values()];
75
+ const totals = new Map<string, { unit: string; reportedBy: string; total: number | null }>();
76
+ let unreportedEntries = 0;
77
+ let partial = allClosed.length !== closed.length || events.filter((event) => event.kind === "review.round.opened").length !== closed.length;
78
+ for (const event of closed) {
79
+ const cost = payloadOf(event)["cost"] as Record<string, unknown>;
80
+ if (cost["coverage"] === "unreported") {
81
+ unreportedEntries += 1;
82
+ continue;
83
+ }
84
+ if (cost["coverage"] === "partial") partial = true;
85
+ const unit = cost["unit"] as string;
86
+ const reportedBy = cost["reportedBy"] as string;
87
+ const key = JSON.stringify([reportedBy, unit]);
88
+ const prior = totals.get(key);
89
+ const sum = prior?.total === null ? Infinity : (prior?.total ?? 0) + (cost["total"] as number);
90
+ // The validated event keeps the original finite measurement even if a sum
91
+ // exceeds the numeric range. Null is unavailable, never measured zero.
92
+ if (!Number.isFinite(sum)) partial = true;
93
+ totals.set(key, { unit, total: Number.isFinite(sum) ? sum : null, reportedBy });
94
+ }
95
+ const ended = events.find((event) => event.kind === "run.ended");
96
+ return {
97
+ review: {
98
+ coverage: totals.size === 0 ? "unreported" : partial || unreportedEntries > 0 ? "partial" : "complete",
99
+ unreportedEntries,
100
+ totals: [...totals.values()],
101
+ },
102
+ run: ended === undefined ? { coverage: "unreported" } : payloadOf(ended)["cost"],
103
+ };
104
+ }
105
+
33
106
  export const payloadOf = (event: RunEvent): Record<string, unknown> =>
34
107
  typeof event.payload === "object" && event.payload !== null ? (event.payload as Record<string, unknown>) : {};
35
108
 
109
+ /**
110
+ * The ticket an entry BOUND ITSELF to, as a row suffix, or nothing.
111
+ *
112
+ * Only the entry's own binding is rendered. An entry that omits `ticket` binds
113
+ * to the run's primary, which the runs table already names in the row above
114
+ * every timeline — repeating it on each unbound line would turn the one thing
115
+ * the member exists to say, that THIS entry belongs to THAT ticket, into
116
+ * boilerplate an operator learns to skip. The envelope is read rather than the
117
+ * payload because the validator holds the two to exact agreement and the
118
+ * envelope is what every other reader here reads.
119
+ */
120
+ const boundTicket = (event: RunEvent): string => (event.ticket === undefined ? "" : ` for ${oneLineOf(event.ticket, 128)}`);
121
+
36
122
  /**
37
123
  * One row's detail for one event: enough to read the run without printing the
38
124
  * whole payload back at the operator.
@@ -43,11 +129,11 @@ export function detailOf(event: RunEvent): string {
43
129
  case "run.started":
44
130
  return oneLineOf(payload["host"]) + (payload["displacedRunId"] === undefined ? "" : ` displaced ${oneLineOf(payload["displacedRunId"])}`);
45
131
  case "run.ended":
46
- return `${oneLineOf(payload["result"])} cost ${oneLineOf((payload["cost"] as { total?: unknown } | undefined)?.total)}`;
132
+ return `${oneLineOf(payload["result"])} cost ${costLabel(payload["cost"])}`;
47
133
  case "ticket.read":
48
134
  return `${oneLineOf(payload["ticket"])} via ${oneLineOf(payload["tracker"])}`;
49
135
  case "posture.declared":
50
- return oneLineOf(payload["posture"]);
136
+ return oneLineOf(payload["posture"]) + boundTicket(event);
51
137
  case "lens.selected":
52
138
  return `mandated ${oneLineOf(payload["mandated"])} selected ${oneLineOf(payload["selected"])} — ${oneLineOf(payload["rationale"])}`;
53
139
  case "review.round.opened":
@@ -57,15 +143,22 @@ export function detailOf(event: RunEvent): string {
57
143
  case "command.completed":
58
144
  return `${oneLineOf(payload["command"])} ${oneLineOf(payload["outcome"])} in ${oneLineOf(payload["durationMs"])}ms`;
59
145
  case "gate.reported":
60
- return `${oneLineOf(payload["command"])} ${oneLineOf(payload["outcome"])} in ${oneLineOf(payload["durationMs"])}ms`;
146
+ return `${oneLineOf(payload["command"])} ${oneLineOf(payload["outcome"])} in ${oneLineOf(payload["durationMs"])}ms${boundTicket(event)}`;
61
147
  case "pr.opened":
62
- return `${oneLineOf(payload["url"], 400)} on ${oneLineOf(event.candidateTreeSha)}`;
148
+ return `${oneLineOf(payload["url"], 400)} on ${oneLineOf(event.candidateTreeSha)}${boundTicket(event)}`;
63
149
  case "blocker.recorded":
64
150
  return `${oneLineOf(payload["code"])} — ${oneLineOf(payload["summary"])}`;
65
151
  case "decision.recorded":
66
152
  return `${oneLineOf(payload["fork"])} — ${oneLineOf(payload["choice"])}${payload["cited"] === undefined ? "" : ` (cited ${oneLineOf(payload["cited"])})`}`;
67
153
  case "compounding.recorded":
68
154
  return `${oneLineOf(payload["outcome"])}${payload["reference"] === undefined ? "" : ` — ${oneLineOf(payload["reference"])}`}`;
155
+ case "context.saved":
156
+ return `stage ${oneLineOf(payload["stage"])} (observation only)`;
157
+ case "action.reconciled":
158
+ return `${oneLineOf(payload["actionId"])} reconciled ${oneLineOf(payload["outcome"])} (host-attested) evidence ${oneLineOf(payload["evidenceReference"])}`;
159
+ case "action.intent":
160
+ case "action.observed":
161
+ return `${oneLineOf(payload["actionId"])} ${oneLineOf(payload["outcome"] ?? "unknown")} reference ${oneLineOf(payload["reference"])}`;
69
162
  default:
70
163
  return "";
71
164
  }
@@ -109,6 +202,20 @@ export function roundEntries(events: readonly RunEvent[]): readonly RoundEntry[]
109
202
  });
110
203
  }
111
204
 
205
+ /**
206
+ * The lenses a round carried, or the fact that it never opened.
207
+ *
208
+ * A round with no `review.round.opened` has no lens set to name, and the two
209
+ * surfaces have to say the SAME thing about that. The terminal already said
210
+ * `never opened`; the page rendered nothing, which an operator reads as a
211
+ * round that opened carrying no lens. That is a second answer to one question
212
+ * about one file, which is exactly what this module exists to prevent — so the
213
+ * words live here, once, and each surface only escapes and places them.
214
+ */
215
+ export function roundLenses(entry: RoundEntry): string {
216
+ return entry.opened === undefined ? "never opened" : oneLineOf(payloadOf(entry.opened)["lenses"]);
217
+ }
218
+
112
219
  /** One text row per round, each carrying the candidate it was bound to. */
113
220
  export function roundRows(events: readonly RunEvent[]): readonly string[] {
114
221
  return roundEntries(events).map((entry) => {
@@ -116,12 +223,180 @@ export function roundRows(events: readonly RunEvent[]): readonly string[] {
116
223
  return [
117
224
  ` round ${entry.round}`,
118
225
  `candidate ${entry.candidateTreeSha || "(none)"}`,
119
- entry.opened === undefined ? "never opened" : `lenses ${oneLineOf(payloadOf(entry.opened)["lenses"])}`,
120
- closed === undefined ? "open" : `${oneLineOf(closed["outcome"])} findings ${oneLineOf(closed["findings"])} cost ${oneLineOf((closed["cost"] as { total?: unknown } | undefined)?.total)}`,
226
+ entry.opened === undefined ? roundLenses(entry) : `lenses ${roundLenses(entry)}`,
227
+ closed === undefined ? "open" : `${oneLineOf(closed["outcome"])} findings ${oneLineOf(closed["findings"])} cost ${costLabel(closed["cost"])}`,
121
228
  ].join(" ");
122
229
  });
123
230
  }
124
231
 
232
+ // ── Cycle time ───────────────────────────────────────────────────────────────
233
+
234
+ /**
235
+ * A span of seconds as one short label, for a column an operator scans.
236
+ *
237
+ * WHY A LABEL AND NOT THE NUMBER. `runs list` prints one row per run and the
238
+ * question it answers is "which of these took a working day"; 47_112 answers
239
+ * that only after arithmetic. The machine-readable surfaces carry the seconds
240
+ * themselves, so nothing is lost: `durationSeconds` is beside this everywhere
241
+ * the label is serialized.
242
+ *
243
+ * The smaller unit is zero-padded so the column aligns under a proportional-
244
+ * enough terminal font, and the largest unit is never dropped: a run of 104
245
+ * hours reads `104h 03m` rather than being folded into days, because a journal
246
+ * spans hours and a day boundary is not a thing this file knows about.
247
+ */
248
+ export function durationLabel(seconds: number): string {
249
+ const whole = Number.isFinite(seconds) && seconds > 0 ? Math.round(seconds) : 0;
250
+ const hours = Math.floor(whole / 3600);
251
+ const minutes = Math.floor((whole % 3600) / 60);
252
+ const rest = whole % 60;
253
+ if (hours > 0) return `${hours}h ${String(minutes).padStart(2, "0")}m`;
254
+ if (minutes > 0) return `${minutes}m ${String(rest).padStart(2, "0")}s`;
255
+ return `${rest}s`;
256
+ }
257
+
258
+ /**
259
+ * The gate time a journal can actually account for.
260
+ *
261
+ * `counted` is how many gate completions were journaled and `totalMs` their
262
+ * sum; `unseen` says the journal carries none at all. That last one is the
263
+ * honest answer to the thing this ticket was written for: a delivery whose gate
264
+ * ran outside the CLI wrapper and emitted no `gate.reported` has a tail that
265
+ * looks free, and a zero printed with no label is indistinguishable from a gate
266
+ * that took no time. `unreadable` counts a gate completion whose `durationMs`
267
+ * could not be read as a number, so a partial sum is never passed off as whole.
268
+ *
269
+ * EVERY journaled gate is summed, including one superseded by a re-run. The
270
+ * headline outcome takes the last completion (a re-run supersedes its verdict),
271
+ * but the time is time: a delivery that gated twice spent both.
272
+ */
273
+ export interface RunGateTime {
274
+ readonly totalMs: number;
275
+ readonly counted: number;
276
+ readonly unreadable: number;
277
+ readonly unseen: boolean;
278
+ }
279
+
280
+ /**
281
+ * The run's three phases, in seconds, plus what they were spent on.
282
+ *
283
+ * The boundaries are the journal's own events, so the three spans tile the
284
+ * run's whole span and sum back to `durationSeconds` within per-phase rounding:
285
+ *
286
+ * - implementation: the first event to the first round event;
287
+ * - review: that first round event to the last `review.round.closed`, which
288
+ * deliberately INCLUDES the fix time between rounds — an operator asking what
289
+ * review cost is asking what the loop cost, not what the reviewers were busy;
290
+ * - tail: the last closed round to the last event, which is `run.ended` on an
291
+ * ended run and whatever the run has reached on an open one.
292
+ *
293
+ * A run with no round event has no review and no tail: implementation is the
294
+ * whole span, which is what a delivery that never opened a round actually did.
295
+ */
296
+ export interface RunPhases {
297
+ readonly implementationSeconds: number;
298
+ readonly reviewSeconds: number;
299
+ readonly tailSeconds: number;
300
+ /** Logical rounds the journal mentions, opened or closed. */
301
+ readonly rounds: number;
302
+ readonly gate: RunGateTime;
303
+ }
304
+
305
+ /** The two kinds that bound the review phase. */
306
+ const ROUND_KINDS: ReadonlySet<string> = new Set(["review.round.opened", "review.round.closed"]);
307
+
308
+ /**
309
+ * An instant held inside `[low, high]`, so the phases tile the span.
310
+ *
311
+ * The journal is append-only and its instants are written by whoever holds the
312
+ * clock, which is not one clock: an executor on a laptop and a CLI inside a
313
+ * container can disagree by seconds. Without this, one skewed `at` gives a
314
+ * negative span — reported as zero by `spanSeconds` — and the three phases
315
+ * silently stop summing to the total. Clamping keeps the arithmetic true and
316
+ * loses only the skew, which was never measurable anyway. An unparseable
317
+ * instant is treated as `high`, the same way a missing one is.
318
+ */
319
+ function heldWithin(value: string | undefined, low: string, high: string): string {
320
+ const at = value === undefined ? Number.NaN : Date.parse(value);
321
+ if (!Number.isFinite(at)) return high;
322
+ const lowAt = Date.parse(low);
323
+ const highAt = Date.parse(high);
324
+ if (!Number.isFinite(lowAt) || !Number.isFinite(highAt)) return high;
325
+ if (at < lowAt) return low;
326
+ if (at > highAt) return high;
327
+ return value as string;
328
+ }
329
+
330
+ /** A gate completion's duration, or nothing when it cannot be read as one. */
331
+ const gateDurationOf = (event: RunEvent): number | undefined => {
332
+ const value = payloadOf(event)["durationMs"];
333
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
334
+ };
335
+
336
+ /**
337
+ * The gate completions a journal carries: the CLI's own for the `gate` command,
338
+ * and the executor's `gate.reported` for a repository gate that is not a
339
+ * product command. Both are counted because they are different gates, not two
340
+ * writers' accounts of one — an adopter whose `npm run check` is not a product
341
+ * command journals only the second.
342
+ */
343
+ export function projectGateTime(events: readonly RunEvent[]): RunGateTime {
344
+ const gates = events.filter(
345
+ (event) =>
346
+ event.kind === "gate.reported" ||
347
+ (event.kind === "command.completed" && event.actor.role === "cli" && payloadOf(event)["command"] === "gate"),
348
+ );
349
+ let totalMs = 0;
350
+ let unreadable = 0;
351
+ for (const gate of gates) {
352
+ const duration = gateDurationOf(gate);
353
+ if (duration === undefined) unreadable += 1;
354
+ else totalMs += duration;
355
+ }
356
+ return { totalMs, counted: gates.length, unreadable, unseen: gates.length === 0 };
357
+ }
358
+
359
+ /** The run's phase breakdown, derived from the journal and nothing else. */
360
+ export function projectRunPhases(events: readonly RunEvent[]): RunPhases {
361
+ const startedAt = events[0]?.at ?? "";
362
+ const lastAt = events[events.length - 1]?.at ?? "";
363
+ const reviewStart = heldWithin(events.find((event) => ROUND_KINDS.has(event.kind))?.at, startedAt, lastAt);
364
+ const reviewEnd = heldWithin(events.findLast((event) => event.kind === "review.round.closed")?.at, reviewStart, lastAt);
365
+ return {
366
+ implementationSeconds: spanSeconds(startedAt, reviewStart),
367
+ reviewSeconds: spanSeconds(reviewStart, reviewEnd),
368
+ tailSeconds: spanSeconds(reviewEnd, lastAt),
369
+ rounds: roundEntries(events).length,
370
+ gate: projectGateTime(events),
371
+ };
372
+ }
373
+
374
+ /**
375
+ * The phase breakdown as text rows, shared by every surface that prints it.
376
+ *
377
+ * The total row repeats `durationSeconds` rather than re-deriving it, so a
378
+ * reader can check the three phases against the one figure `runs list` printed
379
+ * for the same run; `(open)` is on the total because an open run's last event
380
+ * is not an ending and the tail is still accruing.
381
+ */
382
+ export function phaseRows(events: readonly RunEvent[]): readonly string[] {
383
+ const summary = summarize(events);
384
+ const phases = summary.phases;
385
+ const gate = phases.gate;
386
+ const gateRow = gate.unseen
387
+ ? "unseen — no gate completion is journaled, so any gate this delivery ran is missing from the tail above"
388
+ : `${durationLabel(gate.totalMs / 1000)} summed over ${gate.counted} journaled gate completion(s)` +
389
+ (gate.unreadable === 0 ? "" : `; ${gate.unreadable} journaled no readable duration, so the sum under-reports`);
390
+ return [
391
+ " phases:",
392
+ ` implementation ${durationLabel(phases.implementationSeconds)} (first event to the first round)`,
393
+ ` review ${durationLabel(phases.reviewSeconds)} over ${phases.rounds} round(s), fix time between rounds included`,
394
+ ` tail ${durationLabel(phases.tailSeconds)} (last closed round to the last event)`,
395
+ ` gate time ${gateRow}`,
396
+ ` total ${durationLabel(summary.durationSeconds)}${summary.open ? " (open; the tail is still accruing)" : ""}`,
397
+ ];
398
+ }
399
+
125
400
  // ── The readout ──────────────────────────────────────────────────────────────
126
401
 
127
402
  export interface Readout {
@@ -129,6 +404,12 @@ export interface Readout {
129
404
  readonly present: readonly RunJournalRequiredEntry[];
130
405
  readonly missing: readonly RunJournalRequiredEntry[];
131
406
  readonly violations: readonly string[];
407
+ /**
408
+ * One per violation above, in the same order. The readout evaluates unbound
409
+ * to any record, so these explain the journal's own ordering only; the
410
+ * record-bound explanations are `verify`'s.
411
+ */
412
+ readonly explanations?: readonly RunJournalExplanation[];
132
413
  /** The config-presence note, present on exactly the status that it explains. */
133
414
  readonly note?: string;
134
415
  }
@@ -146,17 +427,22 @@ export interface Readout {
146
427
  * and a reader would eventually be told an entry is both present and missing —
147
428
  * which is exactly what a rewritten copy of the pairing rule did.
148
429
  */
149
- export function readoutOf(events: readonly RunEvent[], evaluation: RunJournalEvaluation, rootDir: string): Readout {
430
+ export function readoutOf(events: readonly RunEvent[], evaluation: RunJournalEvaluation, rootDir?: string): Readout {
150
431
  const present = RUN_JOURNAL_REQUIRED_ENTRIES.filter((entry) => runJournalCarries(events, entry));
151
432
  const note =
152
- evaluation.status === "complete-executor-only" && harnessConfigPresentAt(rootDir)
433
+ evaluation.status === "complete-executor-only" && rootDir !== undefined && harnessConfigPresentAt(rootDir)
153
434
  ? `no CLI gate completion in this journal; harness.config.ts present at ${oneLine(rootDir, 400)}`
154
435
  : undefined;
436
+ // Explained with the arguments this readout is taken with — none — so the
437
+ // sentences describe exactly the violations printed beside them. `verify`
438
+ // supplies a record and gets the record-bound explanations instead.
439
+ const explanations = evaluation.violations.length === 0 ? undefined : explainRunJournal(events).explanations;
155
440
  return {
156
441
  status: evaluation.status,
157
442
  present,
158
443
  missing: evaluation.missing,
159
444
  violations: evaluation.violations,
445
+ ...(explanations === undefined ? {} : { explanations }),
160
446
  ...(note === undefined ? {} : { note }),
161
447
  };
162
448
  }
@@ -173,7 +459,13 @@ export function readoutRows(events: readonly RunEvent[], evaluation: RunJournalE
173
459
  ` present: ${readout.present.length === 0 ? "(none)" : readout.present.join(", ")}`,
174
460
  ` missing: ${readout.missing.length === 0 ? "(none)" : [...readout.missing].join(", ")}`,
175
461
  ];
176
- if (readout.violations.length > 0) rows.push(` violations: ${[...readout.violations].join(", ")}`);
462
+ if (readout.violations.length > 0) {
463
+ rows.push(` violations: ${[...readout.violations].join(", ")}`);
464
+ for (const explanation of readout.explanations ?? []) {
465
+ rows.push(` ${explanation.violation}: ${oneLine(explanation.because, 400)}`);
466
+ }
467
+ rows.push(" admission: none of the above blocks admission; no gate, admission, or record decision reads a journal.");
468
+ }
177
469
  if (readout.note !== undefined) rows.push(` note: ${readout.note}`);
178
470
  return rows;
179
471
  }
@@ -187,7 +479,12 @@ export interface WrittenOutcome {
187
479
  }
188
480
 
189
481
  export interface RunSummary {
190
- /** From the envelope of the first event that carries one; `emit` mirrors it from the payload. */
482
+ /**
483
+ * The run's PRIMARY ticket, by the family's own rule: the first the journal
484
+ * names. A run that read two — a dogfood item and the ordinary item it
485
+ * delivered — is named here by the first, and every entry that bound no
486
+ * ticket of its own belongs to it.
487
+ */
191
488
  readonly ticket: string;
192
489
  /** A run with no `run.ended` is open, whatever else it holds. */
193
490
  readonly open: boolean;
@@ -203,6 +500,13 @@ export interface RunSummary {
203
500
  * is too.
204
501
  */
205
502
  readonly durationSeconds: number;
503
+ /**
504
+ * The same span, divided into the three phases a delivery is actually made
505
+ * of. Derived from the events beside it, never carried by a journal: an
506
+ * archive written before this member existed is projected with it, and one
507
+ * carrying invented phases is re-derived over its own events.
508
+ */
509
+ readonly phases: RunPhases;
206
510
  readonly roundsOpened: number;
207
511
  readonly roundsClosed: number;
208
512
  readonly findings: { readonly P0: number; readonly P1: number; readonly P2: number; readonly P3: number };
@@ -218,8 +522,18 @@ export interface RunSummary {
218
522
  readonly result?: string;
219
523
  }
220
524
 
525
+ /**
526
+ * The CLI-written completion of one command that governs, which is the LAST of
527
+ * them — the same binding `evaluateRunJournal`'s own `cliCompletion` settled
528
+ * under V26-1709, for the same reason: a command is re-run to supersede its
529
+ * earlier outcome. The table and the completeness block sit on one row of one
530
+ * page, so a projection reading the first would report the outcome of a gate
531
+ * the delivery abandoned beside a verdict taken on the re-run —
532
+ * `run-01c68dea9d1d5fd0` in this repository's own store is a journal that gates
533
+ * twice and would read that way.
534
+ */
221
535
  const cliCompletionFor = (events: readonly RunEvent[], command: string): RunEvent | undefined =>
222
- events.find(
536
+ events.findLast(
223
537
  (event) => event.kind === "command.completed" && event.actor.role === "cli" && payloadOf(event)["command"] === command,
224
538
  );
225
539
 
@@ -256,11 +570,12 @@ export function summarize(events: readonly RunEvent[]): RunSummary {
256
570
  : undefined;
257
571
 
258
572
  return {
259
- ticket: oneLineOf(events.find((event) => event.ticket !== undefined)?.ticket, 128),
573
+ ticket: oneLineOf(runPrimaryTicket(events), 128),
260
574
  open: ended === undefined,
261
575
  startedAt,
262
576
  lastAt,
263
577
  durationSeconds: spanSeconds(startedAt, lastAt),
578
+ phases: projectRunPhases(events),
264
579
  roundsOpened: events.filter((event) => event.kind === "review.round.opened").length,
265
580
  roundsClosed: closed.length,
266
581
  findings: {