@agent-delivery-harness/cli 0.1.0 → 0.2.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.
@@ -0,0 +1,278 @@
1
+ /**
2
+ * One projection of a run journal, shared by every surface that renders it.
3
+ *
4
+ * WHY THIS MODULE EXISTS. `runs show` renders a journal to a terminal and
5
+ * `runs serve` renders the same journal to a page. Two renderers is fine; two
6
+ * ANSWERS is not. If each surface decided for itself which round a
7
+ * `review.round.closed` pairs with, what a `gate.reported` means next to a
8
+ * CLI-written `command.completed`, or which required entries a journal
9
+ * carries, an operator reading the page and the terminal would eventually be
10
+ * told two different things about one file on disk. So the semantics live here
11
+ * once — pairing, writer roles, gate and record outcomes, the readout rows —
12
+ * and the surfaces own only their own escaping and layout.
13
+ *
14
+ * WHAT STAYS WITH THE SURFACE. Neutralization. A terminal and a browser are
15
+ * hostile to different bytes: the terminal to escape sequences and newlines
16
+ * that forge a row, the browser to markup. `detailOf` collapses a value to one
17
+ * line's worth of neutralized text because BOTH surfaces want that; the page
18
+ * then escapes the result as markup and the terminal prints it. Nothing here
19
+ * emits markup, and nothing here decides a layout.
20
+ */
21
+ import {
22
+ RUN_JOURNAL_REQUIRED_ENTRIES,
23
+ runJournalCarries,
24
+ type RunEvent,
25
+ type RunJournalEvaluation,
26
+ type RunJournalRequiredEntry,
27
+ } from "@agent-delivery-harness/kernel";
28
+ import { harnessConfigPresentAt, oneLine, oneLineOf } from "./run-surface.ts";
29
+
30
+ /** The three labels every readout carries, so no reader mistakes this for evidence. */
31
+ export const READOUT_LABELS = "self-attested; observability, not evidence; unbound to a record";
32
+
33
+ export const payloadOf = (event: RunEvent): Record<string, unknown> =>
34
+ typeof event.payload === "object" && event.payload !== null ? (event.payload as Record<string, unknown>) : {};
35
+
36
+ /**
37
+ * One row's detail for one event: enough to read the run without printing the
38
+ * whole payload back at the operator.
39
+ */
40
+ export function detailOf(event: RunEvent): string {
41
+ const payload = payloadOf(event);
42
+ switch (event.kind) {
43
+ case "run.started":
44
+ return oneLineOf(payload["host"]) + (payload["displacedRunId"] === undefined ? "" : ` displaced ${oneLineOf(payload["displacedRunId"])}`);
45
+ case "run.ended":
46
+ return `${oneLineOf(payload["result"])} cost ${oneLineOf((payload["cost"] as { total?: unknown } | undefined)?.total)}`;
47
+ case "ticket.read":
48
+ return `${oneLineOf(payload["ticket"])} via ${oneLineOf(payload["tracker"])}`;
49
+ case "posture.declared":
50
+ return oneLineOf(payload["posture"]);
51
+ case "lens.selected":
52
+ return `mandated ${oneLineOf(payload["mandated"])} selected ${oneLineOf(payload["selected"])} — ${oneLineOf(payload["rationale"])}`;
53
+ case "review.round.opened":
54
+ return `round ${oneLineOf(payload["round"])} on ${oneLineOf(event.candidateTreeSha)} lenses ${oneLineOf(payload["lenses"])}`;
55
+ case "review.round.closed":
56
+ return `round ${oneLineOf(payload["round"])} ${oneLineOf(payload["outcome"])} findings ${oneLineOf(payload["findings"])}`;
57
+ case "command.completed":
58
+ return `${oneLineOf(payload["command"])} ${oneLineOf(payload["outcome"])} in ${oneLineOf(payload["durationMs"])}ms`;
59
+ case "gate.reported":
60
+ return `${oneLineOf(payload["command"])} ${oneLineOf(payload["outcome"])} in ${oneLineOf(payload["durationMs"])}ms`;
61
+ case "pr.opened":
62
+ return `${oneLineOf(payload["url"], 400)} on ${oneLineOf(event.candidateTreeSha)}`;
63
+ case "blocker.recorded":
64
+ return `${oneLineOf(payload["code"])} — ${oneLineOf(payload["summary"])}`;
65
+ case "decision.recorded":
66
+ return `${oneLineOf(payload["fork"])} — ${oneLineOf(payload["choice"])}${payload["cited"] === undefined ? "" : ` (cited ${oneLineOf(payload["cited"])})`}`;
67
+ case "compounding.recorded":
68
+ return `${oneLineOf(payload["outcome"])}${payload["reference"] === undefined ? "" : ` — ${oneLineOf(payload["reference"])}`}`;
69
+ default:
70
+ return "";
71
+ }
72
+ }
73
+
74
+ // ── Rounds ───────────────────────────────────────────────────────────────────
75
+
76
+ export interface RoundEntry {
77
+ /** The round label as the executor wrote it, reduced to one line. */
78
+ readonly round: string;
79
+ /**
80
+ * The candidate this round was bound to, taken from the ENVELOPE of the
81
+ * opening event (or the closing one when a round was never opened). The
82
+ * envelope is what the store holds to agreement with the payload, so it is
83
+ * the member every reader reads.
84
+ */
85
+ readonly candidateTreeSha: string;
86
+ readonly opened?: RunEvent;
87
+ readonly closed?: RunEvent;
88
+ }
89
+
90
+ /** One entry per round, in the order the journal first mentions it. */
91
+ export function roundEntries(events: readonly RunEvent[]): readonly RoundEntry[] {
92
+ const rounds = new Map<string, { opened?: RunEvent; closed?: RunEvent }>();
93
+ for (const event of events) {
94
+ if (event.kind !== "review.round.opened" && event.kind !== "review.round.closed") continue;
95
+ const key = oneLineOf(payloadOf(event)["round"], 32);
96
+ const entry = rounds.get(key) ?? {};
97
+ if (event.kind === "review.round.opened") entry.opened = event;
98
+ else entry.closed = event;
99
+ rounds.set(key, entry);
100
+ }
101
+ return [...rounds.entries()].map(([round, entry]) => {
102
+ const anchor = entry.opened ?? entry.closed;
103
+ return {
104
+ round,
105
+ candidateTreeSha: oneLineOf(anchor?.candidateTreeSha),
106
+ ...(entry.opened === undefined ? {} : { opened: entry.opened }),
107
+ ...(entry.closed === undefined ? {} : { closed: entry.closed }),
108
+ };
109
+ });
110
+ }
111
+
112
+ /** One text row per round, each carrying the candidate it was bound to. */
113
+ export function roundRows(events: readonly RunEvent[]): readonly string[] {
114
+ return roundEntries(events).map((entry) => {
115
+ const closed = entry.closed === undefined ? undefined : payloadOf(entry.closed);
116
+ return [
117
+ ` round ${entry.round}`,
118
+ `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)}`,
121
+ ].join(" ");
122
+ });
123
+ }
124
+
125
+ // ── The readout ──────────────────────────────────────────────────────────────
126
+
127
+ export interface Readout {
128
+ readonly status: RunJournalEvaluation["status"];
129
+ readonly present: readonly RunJournalRequiredEntry[];
130
+ readonly missing: readonly RunJournalRequiredEntry[];
131
+ readonly violations: readonly string[];
132
+ /** The config-presence note, present on exactly the status that it explains. */
133
+ readonly note?: string;
134
+ }
135
+
136
+ /**
137
+ * What a journal carries and what it lacks.
138
+ *
139
+ * Present is read off the journal, not inferred by subtracting `missing` from
140
+ * the required list. The two are not complements: `gate.reported` is required
141
+ * only of an executor-only journal, so a journal that never carried one would
142
+ * otherwise be reported as HAVING it.
143
+ *
144
+ * The predicate is the evaluator's own, imported rather than restated. A
145
+ * second implementation here would be a second answer to the same question,
146
+ * and a reader would eventually be told an entry is both present and missing —
147
+ * which is exactly what a rewritten copy of the pairing rule did.
148
+ */
149
+ export function readoutOf(events: readonly RunEvent[], evaluation: RunJournalEvaluation, rootDir: string): Readout {
150
+ const present = RUN_JOURNAL_REQUIRED_ENTRIES.filter((entry) => runJournalCarries(events, entry));
151
+ const note =
152
+ evaluation.status === "complete-executor-only" && harnessConfigPresentAt(rootDir)
153
+ ? `no CLI gate completion in this journal; harness.config.ts present at ${oneLine(rootDir, 400)}`
154
+ : undefined;
155
+ return {
156
+ status: evaluation.status,
157
+ present,
158
+ missing: evaluation.missing,
159
+ violations: evaluation.violations,
160
+ ...(note === undefined ? {} : { note }),
161
+ };
162
+ }
163
+
164
+ /**
165
+ * The readout as text rows. Labeled three ways, listing what the journal has
166
+ * and what it lacks under the ordered rule, with the config-presence note
167
+ * attached to the one status it explains.
168
+ */
169
+ export function readoutRows(events: readonly RunEvent[], evaluation: RunJournalEvaluation, rootDir: string): readonly string[] {
170
+ const readout = readoutOf(events, evaluation, rootDir);
171
+ const rows = [
172
+ ` completeness: ${readout.status} (${READOUT_LABELS})`,
173
+ ` present: ${readout.present.length === 0 ? "(none)" : readout.present.join(", ")}`,
174
+ ` missing: ${readout.missing.length === 0 ? "(none)" : [...readout.missing].join(", ")}`,
175
+ ];
176
+ if (readout.violations.length > 0) rows.push(` violations: ${[...readout.violations].join(", ")}`);
177
+ if (readout.note !== undefined) rows.push(` note: ${readout.note}`);
178
+ return rows;
179
+ }
180
+
181
+ // ── The run's headline ───────────────────────────────────────────────────────
182
+
183
+ /** One command outcome and the role of whoever wrote it down. */
184
+ export interface WrittenOutcome {
185
+ readonly outcome: string;
186
+ readonly writer: "cli" | "executor";
187
+ }
188
+
189
+ export interface RunSummary {
190
+ /** From the envelope of the first event that carries one; `emit` mirrors it from the payload. */
191
+ readonly ticket: string;
192
+ /** A run with no `run.ended` is open, whatever else it holds. */
193
+ readonly open: boolean;
194
+ readonly startedAt: string;
195
+ readonly lastAt: string;
196
+ /**
197
+ * The journal's own span, first event to last, in whole seconds.
198
+ *
199
+ * NOT "now minus the start". The viewer renders a file, and a file's span is
200
+ * a property of the file: two operators refreshing the page a minute apart
201
+ * must be told the same thing about the same run, and a run whose journal
202
+ * ends is not still accruing duration. `at` is second-granularity, so this
203
+ * is too.
204
+ */
205
+ readonly durationSeconds: number;
206
+ readonly roundsOpened: number;
207
+ readonly roundsClosed: number;
208
+ readonly findings: { readonly P0: number; readonly P1: number; readonly P2: number; readonly P3: number };
209
+ /**
210
+ * The gate outcome and who claimed it: the CLI-written `command.completed`
211
+ * for `gate` when there is one, the executor's `gate.reported` otherwise.
212
+ * The writer is the point of the label — an adopter whose gate is not a
213
+ * product command has only the executor's word for it.
214
+ */
215
+ readonly gate?: WrittenOutcome;
216
+ readonly record?: WrittenOutcome;
217
+ /** `run.ended`'s result, when the run has ended. */
218
+ readonly result?: string;
219
+ }
220
+
221
+ const cliCompletionFor = (events: readonly RunEvent[], command: string): RunEvent | undefined =>
222
+ events.find(
223
+ (event) => event.kind === "command.completed" && event.actor.role === "cli" && payloadOf(event)["command"] === command,
224
+ );
225
+
226
+ const severityOf = (value: unknown, key: string): number => {
227
+ const findings = typeof value === "object" && value !== null ? (value as Record<string, unknown>) : {};
228
+ const count = findings[key];
229
+ return typeof count === "number" && Number.isFinite(count) ? count : 0;
230
+ };
231
+
232
+ /** Seconds between two contract instants, or zero when either cannot be read. */
233
+ function spanSeconds(from: string, to: string): number {
234
+ const start = Date.parse(from);
235
+ const end = Date.parse(to);
236
+ if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return 0;
237
+ return Math.round((end - start) / 1000);
238
+ }
239
+
240
+ export function summarize(events: readonly RunEvent[]): RunSummary {
241
+ const first = events[0];
242
+ const last = events[events.length - 1];
243
+ const startedAt = first?.at ?? "";
244
+ const lastAt = last?.at ?? "";
245
+ const ended = events.find((event) => event.kind === "run.ended");
246
+ const gateCompletion = cliCompletionFor(events, "gate");
247
+ const gateReported = events.find((event) => event.kind === "gate.reported");
248
+ const recordCompletion = cliCompletionFor(events, "record");
249
+ const closed = events.filter((event) => event.kind === "review.round.closed");
250
+
251
+ const gate =
252
+ gateCompletion !== undefined
253
+ ? { outcome: oneLineOf(payloadOf(gateCompletion)["outcome"], 64), writer: "cli" as const }
254
+ : gateReported !== undefined
255
+ ? { outcome: oneLineOf(payloadOf(gateReported)["outcome"], 64), writer: "executor" as const }
256
+ : undefined;
257
+
258
+ return {
259
+ ticket: oneLineOf(events.find((event) => event.ticket !== undefined)?.ticket, 128),
260
+ open: ended === undefined,
261
+ startedAt,
262
+ lastAt,
263
+ durationSeconds: spanSeconds(startedAt, lastAt),
264
+ roundsOpened: events.filter((event) => event.kind === "review.round.opened").length,
265
+ roundsClosed: closed.length,
266
+ findings: {
267
+ P0: closed.reduce((total, event) => total + severityOf(payloadOf(event)["findings"], "P0"), 0),
268
+ P1: closed.reduce((total, event) => total + severityOf(payloadOf(event)["findings"], "P1"), 0),
269
+ P2: closed.reduce((total, event) => total + severityOf(payloadOf(event)["findings"], "P2"), 0),
270
+ P3: closed.reduce((total, event) => total + severityOf(payloadOf(event)["findings"], "P3"), 0),
271
+ },
272
+ ...(gate === undefined ? {} : { gate }),
273
+ ...(recordCompletion === undefined
274
+ ? {}
275
+ : { record: { outcome: oneLineOf(payloadOf(recordCompletion)["outcome"], 64), writer: "cli" as const } }),
276
+ ...(ended === undefined ? {} : { result: oneLineOf(payloadOf(ended)["result"], 64) }),
277
+ };
278
+ }