@agent-delivery-harness/cli 0.2.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/LICENSE +104 -201
  2. package/NOTICE +1 -1
  3. package/package.json +4 -4
  4. package/src/boundary.ts +77 -61
  5. package/src/command-activity.ts +75 -0
  6. package/src/commands/admit.ts +106 -0
  7. package/src/commands/check.ts +13 -2
  8. package/src/commands/emit-review-evidence.ts +38 -0
  9. package/src/commands/emit.ts +80 -12
  10. package/src/commands/gate.ts +33 -8
  11. package/src/commands/maintain.ts +1 -0
  12. package/src/commands/managed.ts +1 -0
  13. package/src/commands/prepare.ts +123 -22
  14. package/src/commands/record.ts +98 -3
  15. package/src/commands/resume.ts +53 -0
  16. package/src/commands/review-context.ts +19 -0
  17. package/src/commands/runs.ts +415 -30
  18. package/src/commands/save-context.ts +51 -0
  19. package/src/commands/submit-evidence.ts +46 -7
  20. package/src/commands/verify.ts +29 -8
  21. package/src/declared-checks.ts +50 -0
  22. package/src/index.ts +14 -1
  23. package/src/main.ts +20 -47
  24. package/src/ordinary-context.ts +99 -0
  25. package/src/provider-rails.ts +23 -704
  26. package/src/record-retention.ts +325 -0
  27. package/src/review-evidence.ts +284 -0
  28. package/src/run-archive-commands.ts +164 -0
  29. package/src/run-archive.ts +127 -0
  30. package/src/run-artifact-commands.ts +163 -0
  31. package/src/run-attachments.ts +112 -0
  32. package/src/run-export.ts +164 -0
  33. package/src/run-live.ts +102 -0
  34. package/src/run-projection.ts +129 -11
  35. package/src/run-server.ts +394 -149
  36. package/src/run-surface.ts +81 -8
  37. package/src/run-view-command.ts +77 -0
  38. package/src/run-view-html.ts +138 -0
  39. package/src/run-view-record.ts +119 -0
  40. package/src/run-view.ts +343 -0
@@ -0,0 +1,164 @@
1
+ import {
2
+ validateRunAttachments,
3
+ type RunAttachments,
4
+ } from "./run-attachments.ts";
5
+ /** Retained observability, projected and checked by the same product code. */
6
+ import {
7
+ canonicalize,
8
+ MAX_PORTABLE_RECORD_BYTES,
9
+ applySecretDiscipline,
10
+ evaluateRunJournal,
11
+ RUN_STORE_ID,
12
+ validateRunEvent,
13
+ type RunEvent,
14
+ } from "@agent-delivery-harness/kernel";
15
+ import {
16
+ READOUT_LABELS,
17
+ projectCosts,
18
+ projectRunProgress,
19
+ readoutOf,
20
+ summarize,
21
+ } from "./run-projection.ts";
22
+
23
+ export function buildRunExport(input: {
24
+ readonly runId: string;
25
+ readonly events: readonly RunEvent[];
26
+ readonly rootDir?: string;
27
+ readonly refusedAppends?: readonly unknown[];
28
+ }) {
29
+ const v2 = input.events[0]?.version === "run-event/2";
30
+ return {
31
+ spec: v2
32
+ ? ("delivery-run-export/2" as const)
33
+ : ("delivery-run-export/1" as const),
34
+ labels: READOUT_LABELS,
35
+ runId: input.runId,
36
+ events: input.events,
37
+ summary: summarize(input.events),
38
+ costs: projectCosts(input.events),
39
+ readout: readoutOf(
40
+ input.events,
41
+ evaluateRunJournal(input.events),
42
+ input.rootDir,
43
+ ),
44
+ refusedAppends: input.refusedAppends ?? [],
45
+ // Exported progress is a historical projection at the last observation,
46
+ // never a claim about whether the producer is executing now.
47
+ ...(v2
48
+ ? {
49
+ progress: projectRunProgress(
50
+ input.events,
51
+ input.events.at(-1)?.at ?? "",
52
+ ),
53
+ }
54
+ : {}),
55
+ };
56
+ }
57
+
58
+ export type DeliveryRunExport = ReturnType<typeof buildRunExport> & {
59
+ readonly attachments?: RunAttachments;
60
+ };
61
+ export type RunExportParseResult =
62
+ | { readonly ok: true; readonly value: DeliveryRunExport }
63
+ | { readonly ok: false; readonly code: "run_export_invalid" };
64
+
65
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
66
+ value !== null && typeof value === "object" && !Array.isArray(value);
67
+
68
+ /** Validate observations and recompute totals; this never establishes admission. */
69
+ export function parseRunExport(text: string): RunExportParseResult {
70
+ const invalid = { ok: false, code: "run_export_invalid" } as const;
71
+ try {
72
+ if (Buffer.byteLength(text, "utf8") > MAX_PORTABLE_RECORD_BYTES)
73
+ return invalid;
74
+ const value: unknown = JSON.parse(text);
75
+ if (
76
+ !isRecord(value) ||
77
+ !["delivery-run-export/1", "delivery-run-export/2"].includes(
78
+ String(value["spec"]),
79
+ ) ||
80
+ value["labels"] !== READOUT_LABELS ||
81
+ typeof value["runId"] !== "string" ||
82
+ value["runId"].length > 128 ||
83
+ !RUN_STORE_ID.test(value["runId"]) ||
84
+ !Array.isArray(value["events"]) ||
85
+ !Array.isArray(value["refusedAppends"]) ||
86
+ !isRecord(value["readout"])
87
+ )
88
+ return invalid;
89
+ const v2 = value["spec"] === "delivery-run-export/2";
90
+ const hasAttachments = Object.prototype.hasOwnProperty.call(
91
+ value,
92
+ "attachments",
93
+ );
94
+ if (hasAttachments && (!v2 || !applySecretDiscipline(value, new Set()).ok))
95
+ return invalid;
96
+ if (
97
+ Object.keys(value)
98
+ .filter((key) => key !== "attachments")
99
+ .sort()
100
+ .join(",") !==
101
+ (v2
102
+ ? "costs,events,labels,progress,readout,refusedAppends,runId,spec,summary"
103
+ : "costs,events,labels,readout,refusedAppends,runId,spec,summary")
104
+ )
105
+ return invalid;
106
+ for (const [index, event] of value["events"].entries()) {
107
+ if (
108
+ !validateRunEvent(event).ok ||
109
+ !isRecord(event) ||
110
+ event["runId"] !== value["runId"] ||
111
+ event["seq"] !== index + 1 ||
112
+ event["version"] !== (v2 ? "run-event/2" : "run-event/1")
113
+ )
114
+ return invalid;
115
+ }
116
+ if (
117
+ hasAttachments &&
118
+ !validateRunAttachments(
119
+ value["attachments"],
120
+ value["events"] as RunEvent[],
121
+ )
122
+ )
123
+ return invalid;
124
+ const expected = buildRunExport({
125
+ runId: value["runId"],
126
+ events: value["events"] as RunEvent[],
127
+ });
128
+ if (
129
+ expected.spec !== value["spec"] ||
130
+ canonicalize(value["progress"] ?? null) !==
131
+ canonicalize(expected.progress ?? null)
132
+ )
133
+ return invalid;
134
+ if (
135
+ canonicalize(value["summary"]) !== canonicalize(expected.summary) ||
136
+ canonicalize(value["costs"]) !== canonicalize(expected.costs)
137
+ )
138
+ return invalid;
139
+ // The optional note describes config presence in the exporting workspace;
140
+ // it cannot be recomputed in a different checkout and grants no authority.
141
+ const { note, ...readout } = value["readout"];
142
+ if (
143
+ (note !== undefined && typeof note !== "string") ||
144
+ canonicalize(readout) !== canonicalize(expected.readout)
145
+ )
146
+ return invalid;
147
+ return {
148
+ ok: true,
149
+ value: {
150
+ ...expected,
151
+ ...(hasAttachments
152
+ ? { attachments: value["attachments"] as RunAttachments }
153
+ : {}),
154
+ refusedAppends: value["refusedAppends"],
155
+ readout: {
156
+ ...expected.readout,
157
+ ...(note === undefined ? {} : { note }),
158
+ },
159
+ },
160
+ };
161
+ } catch {
162
+ return invalid;
163
+ }
164
+ }
@@ -0,0 +1,102 @@
1
+ /** Fixed, hash-authorized enhancement. No executor content enters this script. */
2
+ export const RUN_LIVE_SCRIPT = String.raw`(() => {
3
+ const root = document.querySelector('main');
4
+ const toggle = document.querySelector('[data-live-toggle]');
5
+ const status = document.querySelector('[data-live-status]');
6
+ if (!root || !toggle || !status) return;
7
+ let paused = false;
8
+ let timer;
9
+ let interactingUntil = 0;
10
+ const interval = Number(root.dataset.pollSeconds) * 1000;
11
+ const interact = () => { interactingUntil = Date.now() + 1500; };
12
+ for (const event of ['pointerdown', 'keydown', 'wheel', 'touchstart']) {
13
+ document.addEventListener(event, interact, { passive: true });
14
+ }
15
+ toggle.addEventListener('click', event => {
16
+ event.preventDefault();
17
+ paused = !paused;
18
+ toggle.textContent = paused ? 'Resume live updates' : 'Pause updates';
19
+ status.textContent = paused ? 'Updates paused' : 'Live';
20
+ });
21
+ // Preserve existing nodes wherever possible, including focused controls and
22
+ // native disclosure state. Stable keys keep newly inserted cards from taking
23
+ // the identity of an earlier card the operator is reading.
24
+ const key = node => node.nodeType === 1 ? node.id || node.getAttribute('data-key') : null;
25
+ function patch(current, next) {
26
+ if (current.nodeType !== next.nodeType || current.nodeName !== next.nodeName) {
27
+ current.replaceWith(next.cloneNode(true));
28
+ return;
29
+ }
30
+ if (current.nodeType !== 1) {
31
+ if (current.nodeValue !== next.nodeValue) current.nodeValue = next.nodeValue;
32
+ return;
33
+ }
34
+ if (current.hasAttribute('data-live-controls')) return;
35
+ for (const attr of [...current.attributes]) {
36
+ if (current.tagName === 'DETAILS' && attr.name === 'open') continue;
37
+ if (!next.hasAttribute(attr.name)) current.removeAttribute(attr.name);
38
+ }
39
+ for (const attr of next.attributes) {
40
+ if (current.tagName !== 'DETAILS' || attr.name !== 'open') current.setAttribute(attr.name, attr.value);
41
+ }
42
+ const children = [...current.childNodes];
43
+ const used = new Set();
44
+ let cursor = current.firstChild;
45
+ for (const incoming of next.childNodes) {
46
+ const incomingKey = key(incoming);
47
+ const existing = incomingKey
48
+ ? children.find(child => !used.has(child) && key(child) === incomingKey)
49
+ : children.find(child => !used.has(child) && !key(child) && child.nodeType === incoming.nodeType && child.nodeName === incoming.nodeName);
50
+ if (existing) {
51
+ used.add(existing);
52
+ if (existing !== cursor) current.insertBefore(existing, cursor);
53
+ patch(existing, incoming);
54
+ cursor = existing.nextSibling;
55
+ } else {
56
+ const added = incoming.cloneNode(true);
57
+ current.insertBefore(added, cursor);
58
+ }
59
+ }
60
+ for (const child of children) if (!used.has(child)) child.remove();
61
+ }
62
+ const busy = () => Date.now() < interactingUntil || !window.getSelection()?.isCollapsed;
63
+ async function poll() {
64
+ if (paused || document.hidden || busy()) {
65
+ timer = setTimeout(poll, interval);
66
+ return;
67
+ }
68
+ try {
69
+ const response = await fetch(location.pathname, { cache: 'no-store', signal: AbortSignal.timeout(10000) });
70
+ if (!response.ok) throw Error('read');
71
+ const page = new DOMParser().parseFromString(await response.text(), 'text/html');
72
+ const next = page.querySelector('main');
73
+ if (!next) throw Error('read');
74
+ if (paused || document.hidden || busy()) { timer = setTimeout(poll, interval); return; }
75
+ const focused = document.activeElement;
76
+ if (focused && focused !== document.body && !focused.closest('[data-live-controls]')) {
77
+ const context = focused.closest('[id], [data-key]');
78
+ const replacement = context && [...next.querySelectorAll('[id], [data-key]')].find(element => key(element) === key(context));
79
+ // A completed attempt may move into collapsed history. Keep the current
80
+ // reading snapshot until focus leaves it, rather than removing the
81
+ // focused control or presenting a stale duplicate as current work.
82
+ if (!replacement || context.closest('section')?.id !== replacement.closest('section')?.id) {
83
+ status.textContent = 'New update ready · Finish reading to apply';
84
+ timer = setTimeout(poll, interval);
85
+ return;
86
+ }
87
+ }
88
+ const anchor = [...root.querySelectorAll('[id], [data-key]')].find(element => element.getBoundingClientRect().top >= 0 && element.getBoundingClientRect().top < window.innerHeight && element.getBoundingClientRect().height > 0);
89
+ const offset = anchor?.getBoundingClientRect().top;
90
+ const scroll = window.scrollY;
91
+ patch(root, next);
92
+ if (focused?.isConnected && document.activeElement !== focused) focused.focus({ preventScroll: true });
93
+ if (anchor?.isConnected && offset !== undefined) window.scrollBy(0, anchor.getBoundingClientRect().top - offset);
94
+ else window.scrollTo(0, scroll);
95
+ status.textContent = next.dataset.live === 'true' ? 'Live' : 'Saved observations';
96
+ if (next.dataset.live !== 'true') { toggle.hidden = true; return; }
97
+ } catch { status.textContent = 'Update unavailable · Retrying'; }
98
+ timer = setTimeout(poll, interval);
99
+ }
100
+ timer = setTimeout(poll, interval);
101
+ window.addEventListener('pagehide', () => clearTimeout(timer), { once: true });
102
+ })();`;
@@ -21,6 +21,8 @@
21
21
  import {
22
22
  RUN_JOURNAL_REQUIRED_ENTRIES,
23
23
  runJournalCarries,
24
+ runPrimaryTicket,
25
+ projectRunActivities,
24
26
  type RunEvent,
25
27
  type RunJournalEvaluation,
26
28
  type RunJournalRequiredEntry,
@@ -30,9 +32,91 @@ import { harnessConfigPresentAt, oneLine, oneLineOf } from "./run-surface.ts";
30
32
  /** The three labels every readout carries, so no reader mistakes this for evidence. */
31
33
  export const READOUT_LABELS = "self-attested; observability, not evidence; unbound to a record";
32
34
 
35
+ /** A single progress view shared by renderers; callers supply their observation clock. */
36
+ export function projectRunProgress(events: readonly RunEvent[], now: string, freshnessWindowMs?: number) {
37
+ const projection = projectRunActivities(events, { now, ...(freshnessWindowMs === undefined ? {} : { freshnessWindowMs }) });
38
+ const latestFindings = new Map<string, (typeof projection.findings)[number]>();
39
+ for (const finding of projection.findings) {
40
+ if (finding.current) latestFindings.set(String(finding.payload["findingId"]), finding);
41
+ }
42
+ return {
43
+ asOf: now,
44
+ ...projection,
45
+ currentFindings: [...latestFindings.values()],
46
+ findingsCoverage: events.some(e => e.kind === "finding.observed") ? "reported" as const : "unreported" as const,
47
+ };
48
+ }
49
+
50
+ export function costLabel(value: unknown): string {
51
+ const cost = typeof value === "object" && value !== null ? value as Record<string, unknown> : {};
52
+ if (cost["coverage"] === "unreported" || cost["total"] === undefined) return "unreported";
53
+ const measured = `${oneLineOf(cost["total"])} ${oneLineOf(cost["unit"])}`.trim();
54
+ return cost["coverage"] === "partial" ? `${measured} (partial coverage)` : measured;
55
+ }
56
+
57
+ /** Aggregate only comparable review counters; the run-wide counter overlaps them. */
58
+ export function projectCosts(events: readonly RunEvent[]) {
59
+ const allClosed = events.filter((event) => event.kind === "review.round.closed");
60
+ // v2 round costs are reported cumulative snapshots. Repeated/reopened round
61
+ // snapshots cannot be summed as independent consumption. Keep the latest
62
+ // comparable value and mark coverage partial when supersession occurred.
63
+ const snapshots = new Map<string, RunEvent>();
64
+ for (const event of allClosed) {
65
+ const payload = payloadOf(event);
66
+ const cost = payload["cost"] as Record<string, unknown>;
67
+ const key = event.version === "run-event/2"
68
+ ? JSON.stringify([payload["roundId"], cost["reportedBy"], cost["unit"] ?? "unreported"])
69
+ : `legacy-${event.seq}`;
70
+ snapshots.set(key, event);
71
+ }
72
+ const closed = [...snapshots.values()];
73
+ const totals = new Map<string, { unit: string; reportedBy: string; total: number | null }>();
74
+ let unreportedEntries = 0;
75
+ let partial = allClosed.length !== closed.length || events.filter((event) => event.kind === "review.round.opened").length !== closed.length;
76
+ for (const event of closed) {
77
+ const cost = payloadOf(event)["cost"] as Record<string, unknown>;
78
+ if (cost["coverage"] === "unreported") {
79
+ unreportedEntries += 1;
80
+ continue;
81
+ }
82
+ if (cost["coverage"] === "partial") partial = true;
83
+ const unit = cost["unit"] as string;
84
+ const reportedBy = cost["reportedBy"] as string;
85
+ const key = JSON.stringify([reportedBy, unit]);
86
+ const prior = totals.get(key);
87
+ const sum = prior?.total === null ? Infinity : (prior?.total ?? 0) + (cost["total"] as number);
88
+ // The validated event keeps the original finite measurement even if a sum
89
+ // exceeds the numeric range. Null is unavailable, never measured zero.
90
+ if (!Number.isFinite(sum)) partial = true;
91
+ totals.set(key, { unit, total: Number.isFinite(sum) ? sum : null, reportedBy });
92
+ }
93
+ const ended = events.find((event) => event.kind === "run.ended");
94
+ return {
95
+ review: {
96
+ coverage: totals.size === 0 ? "unreported" : partial || unreportedEntries > 0 ? "partial" : "complete",
97
+ unreportedEntries,
98
+ totals: [...totals.values()],
99
+ },
100
+ run: ended === undefined ? { coverage: "unreported" } : payloadOf(ended)["cost"],
101
+ };
102
+ }
103
+
33
104
  export const payloadOf = (event: RunEvent): Record<string, unknown> =>
34
105
  typeof event.payload === "object" && event.payload !== null ? (event.payload as Record<string, unknown>) : {};
35
106
 
107
+ /**
108
+ * The ticket an entry BOUND ITSELF to, as a row suffix, or nothing.
109
+ *
110
+ * Only the entry's own binding is rendered. An entry that omits `ticket` binds
111
+ * to the run's primary, which the runs table already names in the row above
112
+ * every timeline — repeating it on each unbound line would turn the one thing
113
+ * the member exists to say, that THIS entry belongs to THAT ticket, into
114
+ * boilerplate an operator learns to skip. The envelope is read rather than the
115
+ * payload because the validator holds the two to exact agreement and the
116
+ * envelope is what every other reader here reads.
117
+ */
118
+ const boundTicket = (event: RunEvent): string => (event.ticket === undefined ? "" : ` for ${oneLineOf(event.ticket, 128)}`);
119
+
36
120
  /**
37
121
  * One row's detail for one event: enough to read the run without printing the
38
122
  * whole payload back at the operator.
@@ -43,11 +127,11 @@ export function detailOf(event: RunEvent): string {
43
127
  case "run.started":
44
128
  return oneLineOf(payload["host"]) + (payload["displacedRunId"] === undefined ? "" : ` displaced ${oneLineOf(payload["displacedRunId"])}`);
45
129
  case "run.ended":
46
- return `${oneLineOf(payload["result"])} cost ${oneLineOf((payload["cost"] as { total?: unknown } | undefined)?.total)}`;
130
+ return `${oneLineOf(payload["result"])} cost ${costLabel(payload["cost"])}`;
47
131
  case "ticket.read":
48
132
  return `${oneLineOf(payload["ticket"])} via ${oneLineOf(payload["tracker"])}`;
49
133
  case "posture.declared":
50
- return oneLineOf(payload["posture"]);
134
+ return oneLineOf(payload["posture"]) + boundTicket(event);
51
135
  case "lens.selected":
52
136
  return `mandated ${oneLineOf(payload["mandated"])} selected ${oneLineOf(payload["selected"])} — ${oneLineOf(payload["rationale"])}`;
53
137
  case "review.round.opened":
@@ -57,15 +141,20 @@ export function detailOf(event: RunEvent): string {
57
141
  case "command.completed":
58
142
  return `${oneLineOf(payload["command"])} ${oneLineOf(payload["outcome"])} in ${oneLineOf(payload["durationMs"])}ms`;
59
143
  case "gate.reported":
60
- return `${oneLineOf(payload["command"])} ${oneLineOf(payload["outcome"])} in ${oneLineOf(payload["durationMs"])}ms`;
144
+ return `${oneLineOf(payload["command"])} ${oneLineOf(payload["outcome"])} in ${oneLineOf(payload["durationMs"])}ms${boundTicket(event)}`;
61
145
  case "pr.opened":
62
- return `${oneLineOf(payload["url"], 400)} on ${oneLineOf(event.candidateTreeSha)}`;
146
+ return `${oneLineOf(payload["url"], 400)} on ${oneLineOf(event.candidateTreeSha)}${boundTicket(event)}`;
63
147
  case "blocker.recorded":
64
148
  return `${oneLineOf(payload["code"])} — ${oneLineOf(payload["summary"])}`;
65
149
  case "decision.recorded":
66
150
  return `${oneLineOf(payload["fork"])} — ${oneLineOf(payload["choice"])}${payload["cited"] === undefined ? "" : ` (cited ${oneLineOf(payload["cited"])})`}`;
67
151
  case "compounding.recorded":
68
152
  return `${oneLineOf(payload["outcome"])}${payload["reference"] === undefined ? "" : ` — ${oneLineOf(payload["reference"])}`}`;
153
+ case "context.saved":
154
+ return `stage ${oneLineOf(payload["stage"])} (observation only)`;
155
+ case "action.intent":
156
+ case "action.observed":
157
+ return `${oneLineOf(payload["actionId"])} ${oneLineOf(payload["outcome"] ?? "unknown")} reference ${oneLineOf(payload["reference"])}`;
69
158
  default:
70
159
  return "";
71
160
  }
@@ -109,6 +198,20 @@ export function roundEntries(events: readonly RunEvent[]): readonly RoundEntry[]
109
198
  });
110
199
  }
111
200
 
201
+ /**
202
+ * The lenses a round carried, or the fact that it never opened.
203
+ *
204
+ * A round with no `review.round.opened` has no lens set to name, and the two
205
+ * surfaces have to say the SAME thing about that. The terminal already said
206
+ * `never opened`; the page rendered nothing, which an operator reads as a
207
+ * round that opened carrying no lens. That is a second answer to one question
208
+ * about one file, which is exactly what this module exists to prevent — so the
209
+ * words live here, once, and each surface only escapes and places them.
210
+ */
211
+ export function roundLenses(entry: RoundEntry): string {
212
+ return entry.opened === undefined ? "never opened" : oneLineOf(payloadOf(entry.opened)["lenses"]);
213
+ }
214
+
112
215
  /** One text row per round, each carrying the candidate it was bound to. */
113
216
  export function roundRows(events: readonly RunEvent[]): readonly string[] {
114
217
  return roundEntries(events).map((entry) => {
@@ -116,8 +219,8 @@ export function roundRows(events: readonly RunEvent[]): readonly string[] {
116
219
  return [
117
220
  ` round ${entry.round}`,
118
221
  `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)}`,
222
+ entry.opened === undefined ? roundLenses(entry) : `lenses ${roundLenses(entry)}`,
223
+ closed === undefined ? "open" : `${oneLineOf(closed["outcome"])} findings ${oneLineOf(closed["findings"])} cost ${costLabel(closed["cost"])}`,
121
224
  ].join(" ");
122
225
  });
123
226
  }
@@ -146,10 +249,10 @@ export interface Readout {
146
249
  * and a reader would eventually be told an entry is both present and missing —
147
250
  * which is exactly what a rewritten copy of the pairing rule did.
148
251
  */
149
- export function readoutOf(events: readonly RunEvent[], evaluation: RunJournalEvaluation, rootDir: string): Readout {
252
+ export function readoutOf(events: readonly RunEvent[], evaluation: RunJournalEvaluation, rootDir?: string): Readout {
150
253
  const present = RUN_JOURNAL_REQUIRED_ENTRIES.filter((entry) => runJournalCarries(events, entry));
151
254
  const note =
152
- evaluation.status === "complete-executor-only" && harnessConfigPresentAt(rootDir)
255
+ evaluation.status === "complete-executor-only" && rootDir !== undefined && harnessConfigPresentAt(rootDir)
153
256
  ? `no CLI gate completion in this journal; harness.config.ts present at ${oneLine(rootDir, 400)}`
154
257
  : undefined;
155
258
  return {
@@ -187,7 +290,12 @@ export interface WrittenOutcome {
187
290
  }
188
291
 
189
292
  export interface RunSummary {
190
- /** From the envelope of the first event that carries one; `emit` mirrors it from the payload. */
293
+ /**
294
+ * The run's PRIMARY ticket, by the family's own rule: the first the journal
295
+ * names. A run that read two — a dogfood item and the ordinary item it
296
+ * delivered — is named here by the first, and every entry that bound no
297
+ * ticket of its own belongs to it.
298
+ */
191
299
  readonly ticket: string;
192
300
  /** A run with no `run.ended` is open, whatever else it holds. */
193
301
  readonly open: boolean;
@@ -218,8 +326,18 @@ export interface RunSummary {
218
326
  readonly result?: string;
219
327
  }
220
328
 
329
+ /**
330
+ * The CLI-written completion of one command that governs, which is the LAST of
331
+ * them — the same binding `evaluateRunJournal`'s own `cliCompletion` settled
332
+ * under V26-1709, for the same reason: a command is re-run to supersede its
333
+ * earlier outcome. The table and the completeness block sit on one row of one
334
+ * page, so a projection reading the first would report the outcome of a gate
335
+ * the delivery abandoned beside a verdict taken on the re-run —
336
+ * `run-01c68dea9d1d5fd0` in this repository's own store is a journal that gates
337
+ * twice and would read that way.
338
+ */
221
339
  const cliCompletionFor = (events: readonly RunEvent[], command: string): RunEvent | undefined =>
222
- events.find(
340
+ events.findLast(
223
341
  (event) => event.kind === "command.completed" && event.actor.role === "cli" && payloadOf(event)["command"] === command,
224
342
  );
225
343
 
@@ -256,7 +374,7 @@ export function summarize(events: readonly RunEvent[]): RunSummary {
256
374
  : undefined;
257
375
 
258
376
  return {
259
- ticket: oneLineOf(events.find((event) => event.ticket !== undefined)?.ticket, 128),
377
+ ticket: oneLineOf(runPrimaryTicket(events), 128),
260
378
  open: ended === undefined,
261
379
  startedAt,
262
380
  lastAt,