@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
@@ -1,3 +1,7 @@
1
+ import { runViewCommand } from "../run-view-command.ts";
2
+ import { readArchiveFile } from "../run-archive-commands.ts";
3
+ import { runArchiveCommand } from "../run-archive-commands.ts";
4
+ import { runArtifactCommand } from "../run-artifact-commands.ts";
1
5
  /**
2
6
  * `runs` — read the run store back: `runs list`, `runs show <id>`, and
3
7
  * `runs serve`, the local page over the same files.
@@ -18,21 +22,51 @@
18
22
  */
19
23
  import { stat } from "node:fs/promises";
20
24
  import path from "node:path";
21
- import { evaluateRunJournal } from "@agent-delivery-harness/kernel";
25
+ import {
26
+ RUN_EVENT_KINDS,
27
+ RUN_EVENT_KINDS_V1,
28
+ RUN_EVENT_SPEC,
29
+ RUN_EVENT_SPEC_V2,
30
+ RUN_JOURNAL_STATUSES,
31
+ describeRunEventPayload,
32
+ evaluateRunJournal,
33
+ type RunEventPayloadGrammar,
34
+ type RunEventPayloadMemberGrammar,
35
+ type RunEventValueGrammar,
36
+ type RunEventVersion,
37
+ } from "@agent-delivery-harness/kernel";
22
38
  import {
23
39
  READOUT_LABELS,
24
40
  detailOf,
41
+ durationLabel,
42
+ phaseRows,
25
43
  readoutRows,
26
44
  roundRows,
45
+ summarize,
27
46
  } from "../run-projection.ts";
47
+ import { buildRunExport } from "../run-export.ts";
28
48
  import { startRunServer, type RunServerHandle } from "../run-server.ts";
29
- import { oneLine, oneLineOf, resolveRunSurface, runSurfaceBlocker, type RunSurface } from "../run-surface.ts";
49
+ import {
50
+ oneLine,
51
+ oneLineOf,
52
+ resolveRunSurface,
53
+ resolveWorktreeRoot,
54
+ runSurfaceBlocker,
55
+ type RunSurface,
56
+ } from "../run-surface.ts";
30
57
  import type { CommandResult, ConfigFreeCommandContext, ConfigFreeCommandDescriptor } from "../boundary.ts";
31
58
 
32
59
  const USAGE = [
33
- "Usage: delivery-harness runs list",
34
- " delivery-harness runs show <run-id>",
35
- " delivery-harness runs serve [--repo <path>]... [--port <n>]",
60
+ "Usage: delivery-harness runs capabilities --json",
61
+ " delivery-harness runs list [--json] [--limit <n>] [--status <status>] [--open|--ended]",
62
+ " delivery-harness runs show [<run-id>] [--json]",
63
+ " delivery-harness runs grammar <kind> [--version 1|2] [--json]",
64
+ " delivery-harness runs view <run-id> [--json] [--record <repository-relative-path>]",
65
+ " delivery-harness runs export <run-id> --output <file>",
66
+ " delivery-harness runs archive <file> [--artifact <id>]",
67
+ " delivery-harness runs capture <run-id> --json <request>",
68
+ " delivery-harness runs artifact <run-id> <artifact-id> [--json]",
69
+ " delivery-harness runs serve [--repo <path>]... [--archive <file>]... [--port <n>] [--freshness-seconds <n>] [--record <repository-relative-path>]",
36
70
  ].join("\n");
37
71
 
38
72
  const unresolvable = (reason: string): CommandResult => ({
@@ -54,32 +88,264 @@ export const runsCommand: ConfigFreeCommandDescriptor = {
54
88
  name: "runs",
55
89
  sourceId: "delivery-harness.cli.runs",
56
90
  summary: "List, show, and serve the delivery runs this repository has recorded.",
91
+ usage: USAGE,
57
92
  configFree: true,
58
93
  async run(context: ConfigFreeCommandContext): Promise<CommandResult> {
59
94
  const [subcommand, ...rest] = context.args;
95
+ if (subcommand === "capabilities") {
96
+ if (rest.length !== 1 || rest[0] !== "--json") return { kind: "usage", message: "Usage: runs capabilities --json" };
97
+ context.write(`${JSON.stringify({ spec: "run-capabilities/1", writerVersions: ["run-event/1", "run-event/2"], artifactCapture: true })}\n`);
98
+ return { kind: "ok" };
99
+ }
100
+ if (subcommand === "grammar") return showGrammar(context, rest);
101
+ if (subcommand === "view") return runViewCommand(context, rest);
102
+ if (subcommand === "export" || subcommand === "archive") return runArchiveCommand(context, subcommand, rest);
103
+ if (subcommand === "capture" || subcommand === "artifact") return runArtifactCommand(context, subcommand, rest);
60
104
  if (subcommand === undefined) return { kind: "usage", message: `runs needs a subcommand.\n${USAGE}` };
61
105
  if (subcommand !== "list" && subcommand !== "show" && subcommand !== "serve") {
62
106
  return { kind: "usage", message: `Unknown runs subcommand ${oneLine(subcommand, 64)}.\n${USAGE}` };
63
107
  }
64
- if (subcommand === "show" && rest[0] === undefined) {
65
- return { kind: "usage", message: `runs show needs a run id.\n${USAGE}` };
66
- }
67
108
  // `serve` resolves its OWN repositories — one per `--repo`, none of them
68
109
  // necessarily the invoking worktree — so it never asks the invoking
69
110
  // worktree's store to resolve first.
70
111
  if (subcommand === "serve") return serveRuns(context, rest);
71
112
 
113
+ // `list`'s argument surface is decided BEFORE the store is resolved. A
114
+ // mistyped bound is a usage error wherever it was typed, and an operator
115
+ // standing outside a repository should not have to fix the repository to
116
+ // find out the flag was wrong.
117
+ const listArgs = subcommand === "list" ? parseListArgs(rest) : undefined;
118
+ if (listArgs !== undefined && !listArgs.ok) return { kind: "usage", message: listArgs.message };
119
+ const showArgs = subcommand === "show" ? parseShowArgs(rest) : undefined;
120
+ if (showArgs !== undefined && !showArgs.ok) return { kind: "usage", message: showArgs.message };
121
+
72
122
  const resolved = await resolveRunSurface(context.rootDir);
73
123
  if (!resolved.ok) return unresolvable(resolved.reason);
74
124
 
75
- return subcommand === "list"
76
- ? listRuns(resolved.surface, context)
77
- : showRun(resolved.surface, context, rest[0]!);
125
+ if (listArgs !== undefined && listArgs.ok) return listRuns(resolved.surface, context, listArgs.args);
126
+ if (showArgs === undefined || !showArgs.ok) throw new Error("runs show arguments were not parsed");
127
+ let runId = showArgs.args.runId;
128
+ if (runId === undefined) {
129
+ const current = await resolved.surface.store.current(resolved.surface.worktreeKey);
130
+ if (!current.ok || current.runId === undefined) {
131
+ return {
132
+ kind: "blocked",
133
+ blockers: [runSurfaceBlocker({
134
+ code: "run_unresolvable",
135
+ summary: "There is no current run that can be resolved in this worktree.",
136
+ details: current.ok
137
+ ? "no run id was supplied and the worktree has no readable current run"
138
+ : `no run id was supplied; current-run lookup was refused: ${oneLine(current.rejections[0]?.message ?? "unreadable", 200)}`,
139
+ remediation: {
140
+ id: "start-or-name-a-run",
141
+ summary: "Start a run with `delivery-harness emit run.started`, or pass a run id from `delivery-harness runs list`.",
142
+ },
143
+ })],
144
+ };
145
+ }
146
+ runId = current.runId;
147
+ }
148
+ return showRun(resolved.surface, context, runId, showArgs.args.json);
78
149
  },
79
150
  };
80
151
 
152
+ interface ShowArgs { readonly runId?: string; readonly json: boolean }
153
+ type ShowParse = { readonly ok: true; readonly args: ShowArgs } | { readonly ok: false; readonly message: string };
154
+
155
+ function parseShowArgs(args: readonly string[]): ShowParse {
156
+ let runId: string | undefined;
157
+ let json = false;
158
+ for (const token of args) {
159
+ if (token === "--json") {
160
+ if (json) return { ok: false, message: `runs show: --json was given twice.\n${USAGE}` };
161
+ json = true;
162
+ continue;
163
+ }
164
+ if (token.startsWith("-")) return { ok: false, message: `runs show: unknown flag ${oneLine(token, 64)}.\n${USAGE}` };
165
+ if (runId !== undefined) return { ok: false, message: `runs show accepts at most one run id.\n${USAGE}` };
166
+ runId = token;
167
+ }
168
+ return { ok: true, args: { json, ...(runId === undefined ? {} : { runId }) } };
169
+ }
170
+
171
+ interface GrammarArgs { readonly kind: string; readonly version: RunEventVersion; readonly json: boolean }
172
+ type GrammarParse = { readonly ok: true; readonly args: GrammarArgs } | { readonly ok: false; readonly message: string };
173
+
174
+ function parseGrammarArgs(args: readonly string[]): GrammarParse {
175
+ let kind: string | undefined;
176
+ let version: RunEventVersion = RUN_EVENT_SPEC_V2;
177
+ let versionSeen = false;
178
+ let json = false;
179
+ for (let index = 0; index < args.length; index += 1) {
180
+ const token = args[index]!;
181
+ if (token === "--json") {
182
+ if (json) return { ok: false, message: `runs grammar: --json was given twice.\n${USAGE}` };
183
+ json = true;
184
+ continue;
185
+ }
186
+ if (token === "--version") {
187
+ if (versionSeen) return { ok: false, message: `runs grammar: --version was given twice.\n${USAGE}` };
188
+ const value = args[index + 1];
189
+ if (value !== "1" && value !== "2") return { ok: false, message: `runs grammar: --version needs 1 or 2.\n${USAGE}` };
190
+ version = value === "1" ? RUN_EVENT_SPEC : RUN_EVENT_SPEC_V2;
191
+ versionSeen = true;
192
+ index += 1;
193
+ continue;
194
+ }
195
+ if (token.startsWith("-")) return { ok: false, message: `runs grammar: unknown flag ${oneLine(token, 64)}.\n${USAGE}` };
196
+ if (kind !== undefined) return { ok: false, message: `runs grammar accepts exactly one event kind.\n${USAGE}` };
197
+ kind = token;
198
+ }
199
+ if (kind === undefined) return { ok: false, message: `runs grammar needs an event kind.\n${USAGE}` };
200
+ return { ok: true, args: { kind, version, json } };
201
+ }
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
+
223
+ function renderGrammar(grammar: RunEventPayloadGrammar, context: ConfigFreeCommandContext): void {
224
+ context.write(`${grammar.kind} (${grammar.version})`);
225
+ for (const member of grammar.members) renderMember(member, " ", context);
226
+ context.write(" example:");
227
+ context.write(JSON.stringify(grammar.example, null, 2));
228
+ }
229
+
230
+ function showGrammar(context: ConfigFreeCommandContext, args: readonly string[]): CommandResult {
231
+ const parsed = parseGrammarArgs(args);
232
+ if (!parsed.ok) return { kind: "usage", message: parsed.message };
233
+ const grammar = describeRunEventPayload(parsed.args.kind, parsed.args.version);
234
+ if (grammar === undefined) {
235
+ const kinds = parsed.args.version === RUN_EVENT_SPEC ? RUN_EVENT_KINDS_V1 : RUN_EVENT_KINDS;
236
+ return {
237
+ kind: "usage",
238
+ message: `runs grammar: ${oneLine(parsed.args.kind, 128)} is not a ${parsed.args.version} event kind; accepted kinds: ${kinds.join(", ")}.\n${USAGE}`,
239
+ };
240
+ }
241
+ if (parsed.args.json) context.write(JSON.stringify(grammar, null, 2));
242
+ else renderGrammar(grammar, context);
243
+ return { kind: "ok" };
244
+ }
245
+
81
246
  // ── list ─────────────────────────────────────────────────────────────────────
82
247
 
248
+ /**
249
+ * The one label this listing prints for a journal it could not read at all.
250
+ * It is not a completeness verdict — nothing was evaluated — so it is not one
251
+ * of the kernel's statuses, and it is spelled once, here.
252
+ */
253
+ export const RUN_LIST_UNREADABLE = "unreadable";
254
+
255
+ /**
256
+ * `absent` is the completeness vocabulary's answer to "no journal bound this
257
+ * candidate", which an inventory of the journals that exist can never be. It
258
+ * is excluded rather than accepted-and-never-matched, because a selector that
259
+ * cannot select anything whatever the store holds is a false affordance: an
260
+ * agent filtering on it would read the empty result as "no such runs" rather
261
+ * than as "this question cannot be asked here".
262
+ */
263
+ const STATUS_NEVER_LISTED = "absent";
264
+
265
+ /**
266
+ * Exactly the statuses a row of this listing can carry, which is what
267
+ * `--status` accepts. Derived from the kernel's closed vocabulary so a status
268
+ * renamed there is a compile-and-test problem here rather than a filter that
269
+ * silently stops matching.
270
+ */
271
+ export const RUN_LIST_STATUSES: readonly string[] = Object.freeze([
272
+ ...RUN_JOURNAL_STATUSES.filter((status) => status !== STATUS_NEVER_LISTED),
273
+ RUN_LIST_UNREADABLE,
274
+ ]);
275
+
276
+ /** The spec every machine-readable inventory carries, beside `runs show --json`'s export spec. */
277
+ export const RUN_INVENTORY_SPEC = "run-inventory/1";
278
+
279
+ interface ListArgs {
280
+ readonly json: boolean;
281
+ readonly limit?: number;
282
+ readonly status?: string;
283
+ /** True selects the runs with no `run.ended`; false selects the ended ones. */
284
+ readonly open?: boolean;
285
+ }
286
+
287
+ type ListParse = { readonly ok: true; readonly args: ListArgs } | { readonly ok: false; readonly message: string };
288
+
289
+ /**
290
+ * The separate-argument form the rest of this command uses, and the same
291
+ * refusal discipline: an unknown flag, a repeated one, a positional, and a
292
+ * value that is not a bound are all usage errors rather than conveniences.
293
+ *
294
+ * A LIMIT IS A COUNT OF ROWS, so it is a positive integer and nothing else.
295
+ * `0` is refused rather than read as "no rows" or as "no bound": both readings
296
+ * are plausible, an operator cannot tell which one they got from the output,
297
+ * and neither is a thing anyone means to ask for.
298
+ */
299
+ function parseListArgs(args: readonly string[]): ListParse {
300
+ const refuse = (reason: string): ListParse => ({ ok: false, message: `runs list: ${reason}.\n${USAGE}` });
301
+ let json = false;
302
+ let limit: number | undefined;
303
+ let status: string | undefined;
304
+ let open: boolean | undefined;
305
+
306
+ for (let index = 0; index < args.length; index += 1) {
307
+ const token = args[index]!;
308
+ if (token === "--json") {
309
+ if (json) return refuse("--json was given twice");
310
+ json = true;
311
+ continue;
312
+ }
313
+ if (token === "--open" || token === "--ended") {
314
+ // Two names for one question, so asking it twice — however spelled —
315
+ // has no answer to give.
316
+ if (open !== undefined) return refuse("use at most one of --open and --ended");
317
+ open = token === "--open";
318
+ continue;
319
+ }
320
+ if (token === "--limit" || token === "--status") {
321
+ const value = args[index + 1];
322
+ if (value === undefined) return refuse(`${token} needs a value`);
323
+ index += 1;
324
+ if (token === "--limit") {
325
+ if (limit !== undefined) return refuse("--limit was given twice");
326
+ if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) < 1) {
327
+ return refuse(`--limit needs a positive whole number, not ${oneLine(value, 64)}`);
328
+ }
329
+ limit = Number(value);
330
+ continue;
331
+ }
332
+ if (status !== undefined) return refuse("--status was given twice");
333
+ if (!RUN_LIST_STATUSES.includes(value)) {
334
+ return refuse(`--status accepts ${RUN_LIST_STATUSES.join(", ")}, not ${oneLine(value, 64)}`);
335
+ }
336
+ status = value;
337
+ continue;
338
+ }
339
+ if (token.startsWith("--")) return refuse(`unknown flag ${oneLine(token, 64)}`);
340
+ return refuse("it takes no positional arguments");
341
+ }
342
+
343
+ return {
344
+ ok: true,
345
+ args: { json, ...(limit === undefined ? {} : { limit }), ...(status === undefined ? {} : { status }), ...(open === undefined ? {} : { open }) },
346
+ };
347
+ }
348
+
83
349
  /** The journal's size on disk, or zero where it cannot be measured. */
84
350
  async function sizeOf(runsDir: string, runId: string): Promise<number> {
85
351
  try {
@@ -89,39 +355,137 @@ async function sizeOf(runsDir: string, runId: string): Promise<number> {
89
355
  }
90
356
  }
91
357
 
92
- async function listRuns(surface: RunSurface, context: ConfigFreeCommandContext): Promise<CommandResult> {
358
+ /** One run as both surfaces see it: the same six facts, rendered or serialized. */
359
+ interface InventoryRow {
360
+ readonly runId: string;
361
+ readonly status: string;
362
+ readonly open: boolean;
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;
375
+ readonly bytes: number;
376
+ }
377
+
378
+ /**
379
+ * Every run the store holds, in the store's own order.
380
+ *
381
+ * THE ORDER IS THE STORE'S, and the store sorts run ids ascending. That is
382
+ * what makes a bounded listing reproducible: the same store bounded the same
383
+ * way returns the same rows, and the row a `--limit 1` returns is the one an
384
+ * unbounded listing printed first. Nothing here re-orders it, so the order
385
+ * this surface documents is the order the store defines and not a second one
386
+ * that happens to agree today.
387
+ */
388
+ async function inventoryOf(
389
+ surface: RunSurface,
390
+ ): Promise<{ readonly rows: readonly InventoryRow[]; readonly currentRunId: string | undefined }> {
93
391
  const runIds = await surface.store.list();
94
392
  const current = await surface.store.current(surface.worktreeKey);
95
393
  const currentRunId = current.ok ? current.runId : undefined;
96
394
 
395
+ const rows: InventoryRow[] = [];
396
+ for (const runId of runIds) {
397
+ const bytes = await sizeOf(surface.runsDir, runId);
398
+ const read = await surface.store.read(runId);
399
+ rows.push(
400
+ read.ok
401
+ ? {
402
+ runId,
403
+ status: evaluateRunJournal(read.events).status,
404
+ open: !read.events.some((event) => event.kind === "run.ended"),
405
+ current: runId === currentRunId,
406
+ durationSeconds: summarize(read.events).durationSeconds,
407
+ bytes,
408
+ }
409
+ : // Nothing was read, so no lifecycle or completeness state is claimed.
410
+ // The worktree pointer and on-disk size are independent readable facts.
411
+ { runId, status: RUN_LIST_UNREADABLE, open: false, current: runId === currentRunId, durationSeconds: null, bytes },
412
+ );
413
+ }
414
+ return { rows, currentRunId };
415
+ }
416
+
417
+ /**
418
+ * `runs list`, human and machine.
419
+ *
420
+ * THE FILTER RUNS BEFORE THE BOUND. A bound applied first would spend its rows
421
+ * on runs the filter then discards, so `--status x --limit 1` could answer
422
+ * "none" for a store that holds one — the bound is a bound on the ANSWER, not
423
+ * on how far the store was read.
424
+ *
425
+ * WHAT `total` COUNTS is the set the filters selected, before the bound. That
426
+ * is the number a reader needs to know it was bounded (`returned` below it
427
+ * means rows were cut), and with no filter it is the whole store — which is
428
+ * why the unfiltered human listing's total line is unchanged.
429
+ */
430
+ async function listRuns(surface: RunSurface, context: ConfigFreeCommandContext, args: ListArgs): Promise<CommandResult> {
431
+ const { rows, currentRunId } = await inventoryOf(surface);
432
+ const selected = rows.filter(
433
+ (row) =>
434
+ (args.status === undefined || row.status === args.status) &&
435
+ (args.open === undefined || (row.status !== RUN_LIST_UNREADABLE && row.open === args.open)),
436
+ );
437
+ const totalBytes = selected.reduce((sum, row) => sum + row.bytes, 0);
438
+ const shown = args.limit === undefined ? selected : selected.slice(0, args.limit);
439
+ const truncated = shown.length < selected.length;
440
+
441
+ if (args.json) {
442
+ context.write(
443
+ JSON.stringify(
444
+ {
445
+ spec: RUN_INVENTORY_SPEC,
446
+ labels: READOUT_LABELS,
447
+ runsDir: surface.runsDir,
448
+ current: currentRunId ?? null,
449
+ runs: shown,
450
+ total: { count: selected.length, bytes: totalBytes },
451
+ returned: shown.length,
452
+ truncated,
453
+ },
454
+ null,
455
+ 2,
456
+ ),
457
+ );
458
+ return { kind: "ok" };
459
+ }
460
+
97
461
  // The status column carries a completeness verdict, so this readout is
98
462
  // labeled exactly like `show`'s. `list` is the command an operator reaches
99
463
  // for first, before it knows which id to show; an unlabeled `complete` here
100
464
  // is the misreading the labels exist to prevent.
101
465
  const lines: string[] = [`runs in ${oneLine(surface.runsDir, 400)}`, ` (${READOUT_LABELS})`];
102
- let total = 0;
103
- for (const runId of runIds) {
104
- const size = await sizeOf(surface.runsDir, runId);
105
- total += size;
106
- const read = await surface.store.read(runId);
107
- if (!read.ok) {
108
- lines.push(` ${runId} unreadable ${size} bytes`);
109
- continue;
110
- }
111
- const evaluation = evaluateRunJournal(read.events);
112
- const open = !read.events.some((event) => event.kind === "run.ended");
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.
471
+ for (const row of shown) {
113
472
  lines.push(
114
- ` ${runId} ${evaluation.status} ${open ? "open" : "ended"}${runId === currentRunId ? " current" : ""} ${size} bytes`,
473
+ row.status === RUN_LIST_UNREADABLE || row.durationSeconds === null
474
+ ? ` ${row.runId} ${RUN_LIST_UNREADABLE} ${row.bytes} bytes`
475
+ : ` ${row.runId} ${row.status} ${row.open ? "open" : "ended"}${row.current ? " current" : ""} ${durationLabel(row.durationSeconds)} ${row.bytes} bytes`,
115
476
  );
116
477
  }
117
- lines.push(`total ${total} bytes across ${runIds.length} run(s)`);
478
+ lines.push(`total ${totalBytes} bytes across ${selected.length} run(s)`);
479
+ // Said only when something was actually cut, so the unbounded listing keeps
480
+ // the bytes it always had.
481
+ if (truncated) lines.push(`showing ${shown.length} of ${selected.length} run(s) (--limit ${args.limit})`);
118
482
  for (const line of lines) context.write(line);
119
483
  return { kind: "ok" };
120
484
  }
121
485
 
122
486
  // ── show ─────────────────────────────────────────────────────────────────────
123
487
 
124
- async function showRun(surface: RunSurface, context: ConfigFreeCommandContext, runId: string): Promise<CommandResult> {
488
+ async function showRun(surface: RunSurface, context: ConfigFreeCommandContext, runId: string, json: boolean): Promise<CommandResult> {
125
489
  const read = await surface.store.read(runId);
126
490
  if (!read.ok) {
127
491
  return {
@@ -138,6 +502,17 @@ async function showRun(surface: RunSurface, context: ConfigFreeCommandContext, r
138
502
  }
139
503
 
140
504
  const events = read.events;
505
+ if (json) {
506
+ const worktreeRoot = await resolveWorktreeRoot(context.rootDir);
507
+ const rootDir = worktreeRoot.ok ? worktreeRoot.root : context.rootDir;
508
+ context.write(JSON.stringify(buildRunExport({
509
+ runId,
510
+ events,
511
+ rootDir,
512
+ refusedAppends: await surface.store.readNotes(runId),
513
+ }), null, 2));
514
+ return { kind: "ok" };
515
+ }
141
516
  const open = !events.some((event) => event.kind === "run.ended");
142
517
  const current = await surface.store.current(surface.worktreeKey);
143
518
  const isCurrent = current.ok && current.runId === runId;
@@ -148,6 +523,10 @@ async function showRun(surface: RunSurface, context: ConfigFreeCommandContext, r
148
523
  context.write(` ${event.seq} ${event.at} ${event.kind.padEnd(20)} ${event.actor.role.padEnd(8)} ${detailOf(event)}`);
149
524
  }
150
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
+
151
530
  const rounds = roundRows(events);
152
531
  if (rounds.length > 0) {
153
532
  context.write(" rounds:");
@@ -173,19 +552,56 @@ async function showRun(surface: RunSurface, context: ConfigFreeCommandContext, r
173
552
 
174
553
  // No record tree sha and no mandated pair: the viewer has neither, and
175
554
  // pretending otherwise would turn an observation into a claim.
176
- for (const row of readoutRows(events, evaluateRunJournal(events), context.rootDir)) context.write(row);
555
+ //
556
+ // THE ROOT IS THE WORKTREE'S, NOT THE INVOCATION'S. The config-presence note
557
+ // is a question about the repository, and `runs serve` answers it at the
558
+ // toplevel of the path it was given. Answering it here at the cwd would tell
559
+ // an operator standing in `packages/cli` that this repository carries no
560
+ // config while the page, over the same journal, says it carries one — two
561
+ // answers to one question, which is the thing the shared projection exists
562
+ // to prevent. The cwd stands in only where git can name no root at all, and
563
+ // then it is the only root there is.
564
+ const worktreeRoot = await resolveWorktreeRoot(context.rootDir);
565
+ const rootDir = worktreeRoot.ok ? worktreeRoot.root : context.rootDir;
566
+ for (const row of readoutRows(events, evaluateRunJournal(events), rootDir)) context.write(row);
177
567
  return { kind: "ok" };
178
568
  }
179
569
 
180
570
  // ── serve ────────────────────────────────────────────────────────────────────
181
571
 
182
572
  interface ServeArgs {
573
+ readonly archives:readonly string[];
574
+ readonly recordPath?:string;
575
+ readonly freshnessWindowMs?:number;
183
576
  readonly repos: readonly string[];
184
577
  readonly port?: number;
185
578
  }
186
579
 
187
580
  type ServeParse = { readonly ok: true; readonly args: ServeArgs } | { readonly ok: false; readonly message: string };
188
581
 
582
+ /**
583
+ * The ports a browser leaves out of the `Host` header, being the defaults of
584
+ * the schemes it would dial them under.
585
+ *
586
+ * `run-server.ts` decides whether a request is addressed to the socket it
587
+ * arrived on by comparing `Host` against the bound `host:port` for EXACT
588
+ * equality — it parses nothing, normalizes nothing, and matches no list of
589
+ * names that "mean" loopback. That exactness is the property; it is what makes
590
+ * the page's answer to a DNS rebind checkable rather than a matter of parsing
591
+ * taste. A page served on one of these ports is one no browser can ever reach:
592
+ * the request arrives carrying `Host: 127.0.0.1`, with no port at all, and is
593
+ * answered 403 — the same 403 a rebind gets, with nothing to tell them apart.
594
+ *
595
+ * So the choice is between widening the check and refusing the port, and it is
596
+ * settled by what each costs. Widening costs the property. Refusing costs an
597
+ * operator nothing they wanted: a loopback viewer of a run store has no reason
598
+ * to sit on a privileged port, and the ephemeral default is one flag away.
599
+ * The refusal lives HERE, at the one surface an operator types a port at,
600
+ * rather than beside the check it protects — a second guard inside the server
601
+ * would make neither one provable.
602
+ */
603
+ const BROWSER_ELIDED_PORTS: readonly number[] = [80, 443];
604
+
189
605
  /**
190
606
  * The separate-argument form every other command uses. `--flag=value` is
191
607
  * REFUSED rather than accepted as a convenience: one spelling means an
@@ -194,14 +610,20 @@ type ServeParse = { readonly ok: true; readonly args: ServeArgs } | { readonly o
194
610
  */
195
611
  function parseServeArgs(args: readonly string[], rootDir: string): ServeParse {
196
612
  const repos: string[] = [];
613
+ const archives:string[]=[];
614
+ let recordPath:string|undefined;
615
+ let freshnessWindowMs:number|undefined;
197
616
  let port: number | undefined;
198
617
 
199
618
  for (let index = 0; index < args.length; index += 1) {
200
619
  const token = args[index]!;
201
- if (token === "--repo" || token === "--port") {
620
+ if (token === "--repo" || token === "--port" || token === "--archive" || token === "--freshness-seconds" || token === "--record") {
202
621
  const value = args[index + 1];
203
622
  if (value === undefined) return { ok: false, message: `${token} needs a value.\n${USAGE}` };
204
623
  index += 1;
624
+ if(token==="--record"){if(recordPath!==undefined)return {ok:false,message:"Use one explicit record path"};recordPath=value;continue;}
625
+ if(token==="--archive"){archives.push(path.resolve(rootDir,value));continue;}
626
+ if(token==="--freshness-seconds"){if(!/^\d+$/.test(value)||!Number.isSafeInteger(Number(value))||Number(value)>86400)return {ok:false,message:"freshness seconds must be between 0 and 86400"};freshnessWindowMs=Number(value)*1000;continue;}
205
627
  if (token === "--repo") {
206
628
  repos.push(path.resolve(rootDir, value));
207
629
  continue;
@@ -209,6 +631,14 @@ function parseServeArgs(args: readonly string[], rootDir: string): ServeParse {
209
631
  if (!/^\d{1,5}$/.test(value)) return { ok: false, message: `--port needs a port number.\n${USAGE}` };
210
632
  const parsed = Number(value);
211
633
  if (parsed > 65535) return { ok: false, message: `--port needs a port number.\n${USAGE}` };
634
+ if (BROWSER_ELIDED_PORTS.includes(parsed)) {
635
+ return {
636
+ ok: false,
637
+ message:
638
+ `--port ${parsed} cannot be served: a browser omits a scheme's default port from the Host header, ` +
639
+ `and this page answers only to the exact host:port it bound.\n${USAGE}`,
640
+ };
641
+ }
212
642
  port = parsed;
213
643
  continue;
214
644
  }
@@ -218,7 +648,7 @@ function parseServeArgs(args: readonly string[], rootDir: string): ServeParse {
218
648
 
219
649
  // No `--repo` means the worktree the operator is standing in, which is the
220
650
  // only repository they can have meant.
221
- return { ok: true, args: { repos: repos.length === 0 ? [rootDir] : repos, ...(port === undefined ? {} : { port }) } };
651
+ return { ok: true, args: { repos: repos.length === 0 && archives.length===0 ? [rootDir] : repos, archives,...(recordPath===undefined?{}:{recordPath}),...(freshnessWindowMs===undefined?{}:{freshnessWindowMs}), ...(port === undefined ? {} : { port }) } };
222
652
  }
223
653
 
224
654
  /**
@@ -233,7 +663,8 @@ async function serveRuns(context: ConfigFreeCommandContext, args: readonly strin
233
663
  const parsed = parseServeArgs(args, context.rootDir);
234
664
  if (!parsed.ok) return { kind: "usage", message: parsed.message };
235
665
 
236
- const started = await startRunServer({ repos: parsed.args.repos, ...(parsed.args.port === undefined ? {} : { port: parsed.args.port }) });
666
+ let archives:{label:string;text:string}[];try{archives=await Promise.all(parsed.args.archives.map(async file=>({label:path.basename(file),text:await readArchiveFile(file)})));}catch{return unresolvable("An explicitly selected archive could not be read within its size limit.");}
667
+ const started = await startRunServer({ repos: parsed.args.repos,archives,...(parsed.args.recordPath===undefined?{}:{recordPath:parsed.args.recordPath}),...(parsed.args.freshnessWindowMs===undefined?{}:{freshnessWindowMs:parsed.args.freshnessWindowMs}), ...(parsed.args.port === undefined ? {} : { port: parsed.args.port }) });
237
668
  if (!started.ok) return unresolvable(started.reason);
238
669
 
239
670
  const server: RunServerHandle = started.server;