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