@agent-delivery-harness/cli 0.1.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 +10 -5
  4. package/src/boundary.ts +302 -9
  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 +419 -0
  10. package/src/commands/gate.ts +127 -18
  11. package/src/commands/maintain.ts +203 -0
  12. package/src/commands/managed.ts +478 -0
  13. package/src/commands/prepare.ts +123 -15
  14. package/src/commands/record.ts +100 -8
  15. package/src/commands/resume.ts +53 -0
  16. package/src/commands/review-context.ts +19 -0
  17. package/src/commands/runs.ts +640 -0
  18. package/src/commands/save-context.ts +51 -0
  19. package/src/commands/submit-evidence.ts +46 -7
  20. package/src/commands/verify.ts +173 -5
  21. package/src/declared-checks.ts +50 -0
  22. package/src/index.ts +40 -4
  23. package/src/main.ts +38 -42
  24. package/src/ordinary-context.ts +99 -0
  25. package/src/provider-rails.ts +24 -0
  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 +396 -0
  35. package/src/run-server.ts +905 -0
  36. package/src/run-surface.ts +334 -0
  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
package/src/boundary.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * The one boundary every CLI command runs behind.
3
3
  *
4
- * WHY ONE BOUNDARY. Seven commands, one place that loads config, wires the repo,
5
- * classifies exit codes, and renders failures. A command never touches
6
- * `process`, never prints a stack, never chooses an exit code of its own: it
4
+ * WHY ONE BOUNDARY. Nine config-loading commands, one place that loads config,
5
+ * wires the repo, classifies exit codes, and renders failures. A command never
6
+ * touches `process`, never prints a stack, never chooses an exit code of its own: it
7
7
  * returns a typed result and the boundary maps it. That is what keeps the three
8
8
  * exit semantics — policy block, usage error, interruption — identical across
9
9
  * commands, and what keeps every operator-facing byte flowing through the one
@@ -26,6 +26,7 @@
26
26
  * disagreement unconstructible in the first place.
27
27
  */
28
28
  import path from "node:path";
29
+ import { randomUUID } from "node:crypto";
29
30
  import { pathToFileURL } from "node:url";
30
31
  import {
31
32
  BlockedError,
@@ -37,20 +38,30 @@ import {
37
38
  evaluateCandidateActivation,
38
39
  renderBlockers,
39
40
  resolveRecordStorage,
41
+ submitManifest,
40
42
  validateHarnessConfig,
41
43
  withDeliverableIdentity,
42
44
  type ArtifactsPort,
43
45
  type Blocker,
44
46
  type CaptureCandidate,
45
47
  type CapturedCandidate,
48
+ type CompiledAdopterPolicyBinding,
46
49
  type EnvSnapshot,
47
50
  type ExecutionContext,
48
51
  type HarnessConfig,
49
52
  type LiveProviderResult,
50
53
  type NonEmptyTuple,
51
54
  type ReviewActivationProjection,
55
+ type RunPreparationObservation,
52
56
  type WaiverPrompt,
53
57
  } from "@agent-delivery-harness/kernel";
58
+ import {
59
+ invokeProviderRail,
60
+ openProviderRailProcess,
61
+ type ProviderRailInvocationResult,
62
+ type ProviderRailSession,
63
+ } from "./provider-rails.ts";
64
+ import { beginCommandObservation, type CommandObservation } from "./command-activity.ts";
54
65
 
55
66
  // ── Exit codes ───────────────────────────────────────────────────────────────
56
67
 
@@ -78,7 +89,7 @@ export class CliInterruption extends Error {
78
89
  * the typed blockers to render; `usage` is an argument or invocation error.
79
90
  */
80
91
  export type CommandResult =
81
- | { readonly kind: "ok"; readonly summary?: string }
92
+ | { readonly kind: "ok"; readonly summary?: string; readonly digest?: string; readonly preparation?: RunPreparationObservation }
82
93
  | { readonly kind: "blocked"; readonly blockers: readonly Blocker[] }
83
94
  | { readonly kind: "usage"; readonly message: string };
84
95
 
@@ -96,11 +107,15 @@ export interface RepoWiring {
96
107
  export interface CommandContext {
97
108
  readonly rootDir: string;
98
109
  readonly config: HarnessConfig;
110
+ /** Optional adopter binding; managed commands also load the persisted binding after registration. */
111
+ readonly policyBinding?: CompiledAdopterPolicyBinding;
99
112
  readonly env: EnvSnapshot;
100
113
  readonly stdinIsTTY: boolean;
101
114
  readonly stdoutIsTTY: boolean;
102
115
  /** Positional and flag arguments after the command name. */
103
116
  readonly args: readonly string[];
117
+ readonly readStdin?: () => Promise<string>;
118
+ readonly signal?: AbortSignal;
104
119
  /**
105
120
  * Wires capture and the store from this repo, memoized. Lazy so `--help`
106
121
  * wires nothing and so a command owns how it renders a store that will not
@@ -111,6 +126,12 @@ export interface CommandContext {
111
126
  /** Present only when the run can ask a human; the boundary gates it on a TTY. */
112
127
  readonly promptForWaiver?: WaiverPrompt;
113
128
  readonly liveResults?: readonly LiveProviderResult[];
129
+ /** Runs one configured provider through the neutral stdio rail, if it has a command. */
130
+ readonly invokeProvider?: (input: {
131
+ readonly providerId: string;
132
+ readonly payload: Readonly<Record<string, unknown>>;
133
+ readonly requiresEvidence: boolean;
134
+ }) => Promise<ProviderRailInvocationResult | undefined>;
114
135
  /** Emits one line of operator-facing output to stdout. */
115
136
  readonly write: (text: string) => void;
116
137
  /** Classifies the execution context from this invocation's env + TTY. */
@@ -122,9 +143,71 @@ export interface CommandDescriptor {
122
143
  /** The blocker `source.id` this command stamps on failures it raises itself. */
123
144
  readonly sourceId: string;
124
145
  readonly summary: string;
146
+ /**
147
+ * What a lone `--help`/`-h` on this command is answered with, printed by the
148
+ * boundary before anything is loaded. It is the command's own text — its
149
+ * invocation form and, where the command has one, the sentence explaining
150
+ * what its flags decide. Most commands build their own usage errors from it
151
+ * too, but nothing enforces that: `emit-review-evidence`, `maintain`,
152
+ * `managed` and `submit-evidence`'s missing-manifest arm phrase theirs from
153
+ * separate literals, so this member is the help answer and not a
154
+ * single-authority claim over every usage message.
155
+ */
156
+ readonly usage: string;
125
157
  run(context: CommandContext): Promise<CommandResult>;
126
158
  }
127
159
 
160
+ // ── The config-free command class ────────────────────────────────────────────
161
+
162
+ /**
163
+ * What a config-free command may reach. Deliberately much less than
164
+ * {@link CommandContext}: no config, no wiring, no provider rail, no artifacts
165
+ * port. A run event belongs to the repository, not to a configured gate, and
166
+ * `emit` has to work in a repository that has no `harness.config.ts` at all.
167
+ *
168
+ * The ONE thing a config-free command may do with `harness.config.ts` is ask
169
+ * whether the path exists (`lstat`, no follow) at a worktree root. It never
170
+ * imports it, loads it, or parses it — which is why the config loader is not
171
+ * reachable from here even as a seam.
172
+ */
173
+ export interface ConfigFreeCommandContext {
174
+ readonly rootDir: string;
175
+ readonly env: EnvSnapshot;
176
+ /** Whether omitted payload input would read from an interactive terminal. */
177
+ readonly stdinIsTTY?: boolean;
178
+ /** Positional and flag arguments after the command name. */
179
+ readonly args: readonly string[];
180
+ /** The payload channel: everything on stdin, when a command reads one. */
181
+ readStdin(): Promise<string>;
182
+ /** Emits one line of operator-facing output to stdout. */
183
+ readonly write: (text: string) => void;
184
+ /**
185
+ * The invocation's cancellation, for the one config-free command that does
186
+ * not return on its own. `runs serve` holds a socket open until the operator
187
+ * ends it; without a signal it serves forever, which is exactly right for a
188
+ * terminal and useless for a caller that has to get its process back.
189
+ */
190
+ readonly signal?: AbortSignal;
191
+ }
192
+
193
+ export interface ConfigFreeCommandDescriptor {
194
+ readonly name: string;
195
+ readonly sourceId: string;
196
+ readonly summary: string;
197
+ /** As {@link CommandDescriptor.usage}: one help contract across both classes. */
198
+ readonly usage: string;
199
+ /** The discriminator the boundary dispatches on, before any config load. */
200
+ readonly configFree: true;
201
+ run(context: ConfigFreeCommandContext): Promise<CommandResult>;
202
+ }
203
+
204
+ /** Either command class; the registry holds both. */
205
+ export type AnyCommandDescriptor = CommandDescriptor | ConfigFreeCommandDescriptor;
206
+
207
+ export function isConfigFreeCommand(descriptor: AnyCommandDescriptor): descriptor is ConfigFreeCommandDescriptor {
208
+ return (descriptor as ConfigFreeCommandDescriptor).configFree === true;
209
+ }
210
+
128
211
  // ── Runtime the boundary is driven with ──────────────────────────────────────
129
212
 
130
213
  export interface CliRuntime {
@@ -136,11 +219,23 @@ export interface CliRuntime {
136
219
  readonly stderr: (text: string) => void;
137
220
  /** Loads the consumer config. Defaults to importing `harness.config.ts`. */
138
221
  readonly loadConfig?: (rootDir: string) => Promise<HarnessConfig>;
222
+ /** An embedding adopter may pass its already-compiled policy directly. */
223
+ readonly policyBinding?: CompiledAdopterPolicyBinding;
139
224
  /** The interactive waiver prompt. Only ever offered under a TTY. */
140
225
  readonly promptForWaiver?: WaiverPrompt;
141
226
  /** The filesystem port. Defaults to one rooted in the system temp directory. */
142
227
  readonly artifacts?: ArtifactsPort;
143
228
  readonly liveResults?: readonly LiveProviderResult[];
229
+ readonly signal?: AbortSignal;
230
+ /** Reads the whole of stdin, for the one command whose payload arrives there. */
231
+ readonly readStdin?: () => Promise<string>;
232
+ /** Test/embedding seam. The ordinary runtime opens the provider's configured command. */
233
+ readonly openProviderRail?: (input: {
234
+ readonly providerId: string;
235
+ readonly command: readonly [string, ...string[]];
236
+ readonly cwd: string;
237
+ readonly env: EnvSnapshot;
238
+ }) => Promise<ProviderRailSession>;
144
239
  }
145
240
 
146
241
  // ── Blocker helpers ──────────────────────────────────────────────────────────
@@ -225,13 +320,58 @@ export async function wireRepo(rootDir: string, config: HarnessConfig): Promise<
225
320
 
226
321
  // ── The boundary ─────────────────────────────────────────────────────────────
227
322
 
228
- const USAGE = (commands: readonly CommandDescriptor[]): string =>
229
- [
323
+ /**
324
+ * The listing column is computed from the longest registered name, never a
325
+ * constant: a fixed pad width silently fused `emit-review-evidence` (twenty
326
+ * characters) into its own summary, and every later command name long enough
327
+ * to reach the width would have done the same. Two spaces is the separator, so
328
+ * the longest name is still visibly separated from its summary.
329
+ */
330
+ const NAME_GAP = 2;
331
+
332
+ const USAGE = (commands: readonly AnyCommandDescriptor[]): string => {
333
+ const column = Math.max(0, ...commands.map((command) => command.name.length)) + NAME_GAP;
334
+ return [
230
335
  "Usage: delivery-harness <command> [options]",
231
336
  "",
232
337
  "Commands:",
233
- ...commands.map((command) => ` ${command.name.padEnd(16)}${command.summary}`),
338
+ ...commands.map((command) => ` ${command.name.padEnd(column)}${command.summary}`),
234
339
  ].join("\n");
340
+ };
341
+
342
+ /**
343
+ * A lone `--help`/`-h`, and nothing else. Deliberately exact: `--help` among
344
+ * other arguments is an invocation the command itself has to judge, and
345
+ * answering it as help would let a real invocation be silently swallowed.
346
+ */
347
+ const isHelpRequest = (args: readonly string[]): boolean =>
348
+ args.length === 1 && (args[0] === "--help" || args[0] === "-h");
349
+
350
+ /**
351
+ * THE WRAPPED COMMANDS, NAMED ONE BY ONE.
352
+ *
353
+ * Exactly the candidate-facing loop plus its preflight. `managed` and
354
+ * `maintain` are deliberately absent: they are host-facing and
355
+ * installation-scoped, and a completion event for them would describe
356
+ * something that is not a step of the delivery run the journal is about.
357
+ * `emit` and `runs` are absent because a viewer that recorded its own
358
+ * invocations would fill the journal it renders.
359
+ *
360
+ * An allowlist rather than a denylist: a command added to the registry is
361
+ * unwrapped until someone decides it belongs here, which is the direction that
362
+ * fails safe for a store nothing authoritative may read.
363
+ */
364
+ export const COMPLETION_WRAPPED_COMMANDS: readonly string[] = [
365
+ "admit",
366
+ "check",
367
+ "prepare",
368
+ "review-context",
369
+ "emit-review-evidence",
370
+ "submit-evidence",
371
+ "gate",
372
+ "record",
373
+ "verify",
374
+ ];
235
375
 
236
376
  /**
237
377
  * Runs one CLI invocation to an exit code. Total: it maps every command result
@@ -240,7 +380,7 @@ const USAGE = (commands: readonly CommandDescriptor[]): string =>
240
380
  */
241
381
  export async function runCliBoundary(
242
382
  argv: readonly string[],
243
- commands: readonly CommandDescriptor[],
383
+ commands: readonly AnyCommandDescriptor[],
244
384
  runtime: CliRuntime,
245
385
  ): Promise<number> {
246
386
  const [commandName, ...args] = argv;
@@ -256,6 +396,89 @@ export async function runCliBoundary(
256
396
  return EXIT_USAGE;
257
397
  }
258
398
 
399
+ // HELP IS ANSWERED HERE, AND ONLY HERE. After the descriptor is resolved —
400
+ // so the answer is that command's own usage — and before config loading,
401
+ // wiring, the command observation, or the action. Answering it inside each
402
+ // command would put the answer after the config load the command class
403
+ // performs for it, which is how `gate --help`, `record --help` and
404
+ // `check --help` came to run the gate, the record and the preflight: those
405
+ // commands parse no arguments at all, so a help request reached their action
406
+ // unexamined. A read-only question is answered read-only, for every command
407
+ // in both classes, from one predicate.
408
+ if (isHelpRequest(args)) {
409
+ runtime.stdout(`${descriptor.usage}\n`);
410
+ return EXIT_OK;
411
+ }
412
+
413
+ // CONFIG-FREE COMMANDS ARE DISPATCHED FIRST, before any config load. That
414
+ // ordering is the whole point of the class: `emit` runs in a repository with
415
+ // no `harness.config.ts`, and no other command's `config_unloadable` timing
416
+ // moves because of it.
417
+ if (isConfigFreeCommand(descriptor)) {
418
+ return runConfigFreeCommand(descriptor, args, runtime);
419
+ }
420
+
421
+ const startedAt = Date.now();
422
+ const observation = COMPLETION_WRAPPED_COMMANDS.includes(descriptor.name)
423
+ ? await beginCommandObservation(runtime.cwd, descriptor.name) : undefined;
424
+ let completed: Extract<CommandResult, { kind: "ok" }> | undefined;
425
+ const code = await runConfiguredCommand(descriptor, args, runtime, value => { completed = value; }, observation);
426
+ await observation?.finish(code, Date.now() - startedAt, completed?.digest, completed?.preparation);
427
+ return code;
428
+ }
429
+
430
+ /** The config-free path: a typed result mapped to an exit code, and nothing else. */
431
+ async function runConfigFreeCommand(
432
+ descriptor: ConfigFreeCommandDescriptor,
433
+ args: readonly string[],
434
+ runtime: CliRuntime,
435
+ ): Promise<number> {
436
+ try {
437
+ const result = await descriptor.run({
438
+ rootDir: runtime.cwd,
439
+ env: runtime.env,
440
+ stdinIsTTY: runtime.stdinIsTTY,
441
+ args,
442
+ readStdin: runtime.readStdin ?? (async () => ""),
443
+ write: (text) => runtime.stdout(`${text}\n`),
444
+ ...(runtime.signal === undefined ? {} : { signal: runtime.signal }),
445
+ });
446
+ if (result.kind === "ok") {
447
+ if (result.summary !== undefined && result.summary !== "") runtime.stdout(`${result.summary}\n`);
448
+ return EXIT_OK;
449
+ }
450
+ if (result.kind === "usage") {
451
+ runtime.stderr(`${result.message}\n`);
452
+ return EXIT_USAGE;
453
+ }
454
+ runtime.stderr(`${renderBlockers(result.blockers)}\n`);
455
+ return EXIT_POLICY;
456
+ } catch (error) {
457
+ if (error instanceof CliInterruption) {
458
+ runtime.stderr(`${error.message}\n`);
459
+ return EXIT_INTERRUPTED;
460
+ }
461
+ if (error instanceof BlockedError) {
462
+ runtime.stderr(`${renderBlockers(error.blockers)}\n`);
463
+ return EXIT_POLICY;
464
+ }
465
+ const blocker = createInternalErrorBlocker({
466
+ source: { kind: "command", id: descriptor.sourceId },
467
+ error,
468
+ reproduce: ["delivery-harness", descriptor.name],
469
+ });
470
+ runtime.stderr(`${renderBlockers([blocker])}\n`);
471
+ return EXIT_POLICY;
472
+ }
473
+ }
474
+
475
+ async function runConfiguredCommand(
476
+ descriptor: CommandDescriptor,
477
+ args: readonly string[],
478
+ runtime: CliRuntime,
479
+ observeSuccess: (result: Extract<CommandResult, { kind: "ok" }>) => void,
480
+ observation?: CommandObservation,
481
+ ): Promise<number> {
259
482
  const loadConfig = runtime.loadConfig ?? importHarnessConfig;
260
483
  const artifacts = runtime.artifacts ?? createArtifactsPort();
261
484
 
@@ -268,21 +491,89 @@ export async function runCliBoundary(
268
491
  return wiringPromise;
269
492
  };
270
493
 
494
+ if (observation?.needsCandidate) {
495
+ try {
496
+ const capture = await (await wire()).captureCandidate();
497
+ if (capture.ok) await observation.start(capture.candidate.treeSha);
498
+ } catch { /* Missing candidate observations never block execution. */ }
499
+ }
271
500
  const context: CommandContext = {
272
501
  rootDir: runtime.cwd,
273
502
  config,
503
+ ...(runtime.policyBinding === undefined ? {} : { policyBinding: runtime.policyBinding }),
274
504
  env: runtime.env,
275
505
  stdinIsTTY: runtime.stdinIsTTY,
276
506
  stdoutIsTTY: runtime.stdoutIsTTY,
277
507
  args,
278
508
  wire,
509
+ readStdin: runtime.readStdin ?? (async () => ""),
279
510
  artifacts,
511
+ ...(runtime.signal === undefined ? {} : { signal: runtime.signal }),
280
512
  // The waiver prompt is offered only under a real TTY. A non-interactive
281
513
  // invocation never prompts — it blocks — no matter what the run wired.
282
514
  ...(runtime.stdinIsTTY && runtime.stdoutIsTTY && runtime.promptForWaiver !== undefined
283
- ? { promptForWaiver: runtime.promptForWaiver }
515
+ ? { promptForWaiver: observation?.prompt(runtime.promptForWaiver) ?? runtime.promptForWaiver }
284
516
  : {}),
285
517
  ...(runtime.liveResults === undefined ? {} : { liveResults: runtime.liveResults }),
518
+ invokeProvider: async ({ providerId, payload, requiresEvidence }) => {
519
+ const provider = config.providers.find((registration) => registration.id === providerId);
520
+ if (provider?.command === undefined) return undefined;
521
+ const command = provider.command;
522
+ const requestId = randomUUID();
523
+ const allocation = await artifacts.allocateRunRoot({ providerId, runId: requestId });
524
+ if (!allocation.ok) {
525
+ throw new BlockedError([
526
+ commandBlocker({
527
+ code: "provider_rail_run_root_refused",
528
+ sourceId: "delivery-harness.cli.provider-rails",
529
+ summary: "The provider run root could not be allocated.",
530
+ details: `${providerId}/${requestId}: ${allocation.reason}`,
531
+ remediations: [
532
+ {
533
+ id: "check-provider-identity",
534
+ kind: "code_change",
535
+ summary: "Correct the provider id or restore the harness run-root location, then retry.",
536
+ },
537
+ ],
538
+ }),
539
+ ]);
540
+ }
541
+ const wiring = await wire();
542
+ const interruptController = runtime.signal === undefined ? new AbortController() : undefined;
543
+ const onInterrupt = (): void => interruptController?.abort();
544
+ if (interruptController !== undefined) process.once("SIGINT", onInterrupt);
545
+ try {
546
+ return await invokeProviderRail(
547
+ {
548
+ providerId,
549
+ requestId,
550
+ idempotencyKey: randomUUID(),
551
+ payload: { ...payload, runId: requestId, runRoot: allocation.runRoot.path },
552
+ requiresEvidence,
553
+ },
554
+ {
555
+ open: () =>
556
+ runtime.openProviderRail === undefined
557
+ ? openProviderRailProcess({ command, cwd: runtime.cwd, env: { ...runtime.env } })
558
+ : runtime.openProviderRail({ providerId, command, cwd: runtime.cwd, env: runtime.env }),
559
+ publishManifest: (manifestPath) =>
560
+ submitManifest(
561
+ { rootDir: runtime.cwd, config, manifestPath },
562
+ {
563
+ captureCandidate: wiring.captureCandidate,
564
+ artifacts,
565
+ expectedProviderAttempt: { providerId, runId: requestId, runRootPath: allocation.runRoot.path },
566
+ ...wiring.storageOptions,
567
+ },
568
+ ),
569
+ signal: runtime.signal ?? interruptController?.signal,
570
+ cancellationId: randomUUID(),
571
+ },
572
+ );
573
+ } finally {
574
+ if (interruptController !== undefined) process.off("SIGINT", onInterrupt);
575
+ }
576
+ },
286
577
  write: (text) => runtime.stdout(`${text}\n`),
287
578
  classifyContext: () =>
288
579
  classifyExecutionContext({
@@ -294,7 +585,9 @@ export async function runCliBoundary(
294
585
  };
295
586
 
296
587
  const result = await descriptor.run(context);
588
+ if (runtime.signal?.aborted) throw new CliInterruption();
297
589
  if (result.kind === "ok") {
590
+ observeSuccess(result);
298
591
  if (result.summary !== undefined && result.summary !== "") runtime.stdout(`${result.summary}\n`);
299
592
  return EXIT_OK;
300
593
  }
@@ -0,0 +1,75 @@
1
+ /** Best-effort CLI observations. No caller uses these results for admission. */
2
+ import { randomUUID } from "node:crypto";
3
+ import type { RunEventKind, RunPreparationObservation, WaiverPrompt } from "@agent-delivery-harness/kernel";
4
+ import { buildRunEvent, resolveRunSurface } from "./run-surface.ts";
5
+
6
+ export interface CommandObservation {
7
+ readonly needsCandidate: boolean;
8
+ start(candidateTreeSha: string): Promise<void>;
9
+ prompt(prompt: WaiverPrompt): WaiverPrompt;
10
+ finish(exitCode: number, durationMs: number, digest?: string, preparation?: RunPreparationObservation): Promise<void>;
11
+ }
12
+ const absent: CommandObservation = { needsCandidate: false, start: async () => {}, prompt: prompt => prompt, finish: async () => {} };
13
+ function outcome(code: number) { return code === 0 ? "ok" : code === 2 ? "usage" : code === 130 ? "interrupted" : "policy"; }
14
+
15
+ /** Pin the current run once, so a pointer change cannot split an invocation. */
16
+ export async function beginCommandObservation(cwd: string, command: string): Promise<CommandObservation> {
17
+ try {
18
+ const resolved = await resolveRunSurface(cwd);
19
+ if (!resolved.ok) return absent;
20
+ const { store, commonDir, worktreeKey } = resolved.surface;
21
+ const current = await store.current(worktreeKey);
22
+ if (!current.ok || current.runId === undefined) return absent;
23
+ const runId = current.runId;
24
+ const history = await store.read(runId);
25
+ if (!history.ok) return absent;
26
+ // Capability is selected before emission. Never upgrade an existing writer.
27
+ const version = history.events[0]?.version ?? "run-event/1";
28
+ const attemptId = `command-${randomUUID()}`;
29
+ const activityId = attemptId;
30
+ let candidateTreeSha: string | undefined;
31
+ async function emit(kind: RunEventKind, payload: Record<string, unknown>) {
32
+ try {
33
+ await store.append(runId, buildRunEvent({runId,commonDir,kind,role:"cli",version,
34
+ ...(version === "run-event/2" ? {eventId:randomUUID()} : {}),payload}));
35
+ } catch { /* Observations never change the command's result. */ }
36
+ }
37
+ const binding = () => ({activityId,attemptId,candidateTreeSha});
38
+ async function activity(state: string, nextStep: string) {
39
+ if (version !== "run-event/2" || candidateTreeSha === undefined) return;
40
+ await emit("activity.observed", {...binding(),state,owner:"delivery-harness.cli",phase:command,nextStep});
41
+ }
42
+ return {
43
+ needsCandidate: version === "run-event/2",
44
+ async start(tree) {
45
+ candidateTreeSha = tree;
46
+ await activity("running", "Await command outcome; intermediate progress is unavailable unless explicitly observed.");
47
+ },
48
+ prompt(prompt) {
49
+ return async (decision, obligations) => {
50
+ if (version !== "run-event/2" || candidateTreeSha === undefined) return prompt(decision, obligations);
51
+ const waitId = `wait-${randomUUID()}`;
52
+ const scope = "This command invocation and the waiver decision's candidate and obligations only; this observation grants no authority.";
53
+ await activity("waiting", "Respond to the native waiver prompt.");
54
+ await emit("wait.started", {...binding(),waitId,owner:"operator",waitingOn:"human",reason:"The executing command opened its native waiver prompt.",nextAction:"Answer the native prompt.",scope});
55
+ try {
56
+ const answer = await prompt(decision, obligations);
57
+ await emit("wait.resolved", {...binding(),waitId,resolution:answer === false ? "Native prompt declined." : "Native prompt answered; approval remains subject to the gate.",scope});
58
+ return answer;
59
+ } catch (error) {
60
+ await emit("wait.resolved", {...binding(),waitId,resolution:"Native prompt ended without a decision.",scope});
61
+ throw error;
62
+ } finally {
63
+ await activity("running", "Await command outcome.");
64
+ }
65
+ };
66
+ },
67
+ async finish(code, durationMs, digest, preparation) {
68
+ await activity(code === 0 ? "completed" : code === 130 ? "interrupted" : "failed", `Command returned ${outcome(code)} (exit ${code}); continue the declared delivery workflow.`);
69
+ await emit("command.completed", {command,outcome:outcome(code),durationMs,
70
+ ...(code === 0 && digest !== undefined ? {digest} : {}),
71
+ ...(version === "run-event/2" && command === "prepare" && code === 0 && preparation !== undefined ? {preparation} : {})});
72
+ },
73
+ };
74
+ } catch { return absent; }
75
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * `admit` — carry a concluded host review through the existing admission path.
3
+ *
4
+ * The command does not run reviewers and does not construct an approval. The
5
+ * supplied review-outcome/1 document must already name the digest of the exact
6
+ * review-context/1 document the current prepared candidate produces. Rebuilding
7
+ * that deterministic context after preparation lets the existing emitter retain
8
+ * it with the supplied outcome while refusing any candidate, base, policy,
9
+ * wiring, release, or charter drift.
10
+ */
11
+ import { readFile } from "node:fs/promises";
12
+ import path from "node:path";
13
+
14
+ import { commandBlocker, type CommandContext, type CommandDescriptor, type CommandResult } from "../boundary.ts";
15
+ import { emitReviewEvidence, OutcomeError } from "../review-evidence.ts";
16
+ import { gateCommand } from "./gate.ts";
17
+ import { prepareCommand } from "./prepare.ts";
18
+ import { recordCommand } from "./record.ts";
19
+ import { reviewContextCommand } from "./review-context.ts";
20
+ import { submitEvidenceCommand } from "./submit-evidence.ts";
21
+
22
+ const USAGE = [
23
+ "Usage: delivery-harness admit --outcome <review-outcome.json>",
24
+ "Prepares the candidate, binds the concluded outcome to its exact current review context, submits it, gates it, and writes the tracked record.",
25
+ "The command never runs reviewers or commits the record.",
26
+ ].join("\n");
27
+
28
+ function withArgs(context: CommandContext, args: readonly string[]): CommandContext {
29
+ return { ...context, args };
30
+ }
31
+
32
+ function report(context: CommandContext, label: string, result: CommandResult): CommandResult | undefined {
33
+ if (result.kind !== "ok") return result;
34
+ if (result.summary !== undefined && result.summary !== "") context.write(`${label}: ${result.summary}`);
35
+ return undefined;
36
+ }
37
+
38
+ function invalidOutcome(message: string): CommandResult {
39
+ return {
40
+ kind: "blocked",
41
+ blockers: [commandBlocker({
42
+ code: "review_outcome_invalid",
43
+ sourceId: "delivery-harness.cli.admit",
44
+ summary: message,
45
+ remediations: [{
46
+ id: "supply-reviewed-current-context",
47
+ kind: "manual_action",
48
+ summary: "Prepare the candidate, retain review-context --json before independent review, and supply the concluded outcome naming that exact digest.",
49
+ }],
50
+ })],
51
+ };
52
+ }
53
+
54
+ export const admitCommand: CommandDescriptor = {
55
+ name: "admit",
56
+ sourceId: "delivery-harness.cli.admit",
57
+ summary: "Prepare and admit a concluded review outcome through the tracked record write.",
58
+ usage: USAGE,
59
+ async run(context: CommandContext): Promise<CommandResult> {
60
+ if (context.args.length !== 2 || context.args[0] !== "--outcome" || !context.args[1] || context.args[1].startsWith("-")) {
61
+ return { kind: "usage", message: `admit requires --outcome <review-outcome.json>.\n${USAGE}` };
62
+ }
63
+
64
+ let outcome: unknown;
65
+ try {
66
+ outcome = JSON.parse(await readFile(path.resolve(context.rootDir, context.args[1]), "utf8"));
67
+ } catch {
68
+ return { kind: "usage", message: `The review outcome must be a readable JSON document.\n${USAGE}` };
69
+ }
70
+
71
+ const prepared = await prepareCommand.run(withArgs(context, []));
72
+ const stoppedAfterPrepare = report(context, "prepared", prepared);
73
+ if (stoppedAfterPrepare !== undefined) return stoppedAfterPrepare;
74
+
75
+ const reviewed = await reviewContextCommand.run(withArgs(context, ["--json"]));
76
+ if (reviewed.kind !== "ok") return reviewed;
77
+ if (reviewed.summary === undefined) return invalidOutcome("the prepared candidate produced no review context");
78
+
79
+ let originalContext: unknown;
80
+ try {
81
+ originalContext = JSON.parse(reviewed.summary);
82
+ } catch {
83
+ return invalidOutcome("the prepared candidate produced an unreadable review context");
84
+ }
85
+
86
+ let manifestPath: string;
87
+ try {
88
+ const emitted = await emitReviewEvidence(context, originalContext, outcome);
89
+ manifestPath = emitted.manifestPath;
90
+ } catch (error) {
91
+ if (!(error instanceof OutcomeError)) throw error;
92
+ return invalidOutcome(error.message);
93
+ }
94
+ context.write(`review evidence: ${manifestPath}`);
95
+
96
+ const submitted = await submitEvidenceCommand.run(withArgs(context, ["--manifest", manifestPath]));
97
+ const stoppedAfterSubmission = report(context, "submitted", submitted);
98
+ if (stoppedAfterSubmission !== undefined) return stoppedAfterSubmission;
99
+
100
+ const gated = await gateCommand.run(withArgs(context, []));
101
+ const stoppedAfterGate = report(context, "gate", gated);
102
+ if (stoppedAfterGate !== undefined) return stoppedAfterGate;
103
+
104
+ return recordCommand.run(withArgs(context, []));
105
+ },
106
+ };
@@ -10,8 +10,9 @@
10
10
  * behind. An unresolvable or unwritable store is a typed block, never a crash.
11
11
  */
12
12
  import path from "node:path";
13
- import { resolveRecordStorage, BlockedError } from "@agent-delivery-harness/kernel";
13
+ import { deliveryRecordPathFor, resolveRecordStorage, BlockedError } from "@agent-delivery-harness/kernel";
14
14
  import type { CommandContext, CommandDescriptor, CommandResult } from "../boundary.ts";
15
+ import { oneLine } from "../run-surface.ts";
15
16
 
16
17
  /** Named so an interrupted run leaves something obviously disposable. */
17
18
  const PROBE_FILE = ".delivery-harness-write-probe";
@@ -20,19 +21,29 @@ export const checkCommand: CommandDescriptor = {
20
21
  name: "check",
21
22
  sourceId: "delivery-harness.cli.check",
22
23
  summary: "Confirm the config loads and the evidence store is usable.",
24
+ usage: "Usage: delivery-harness check\nTakes no arguments.",
23
25
  async run(context: CommandContext): Promise<CommandResult> {
26
+ const unexpected = context.args[0];
27
+ if (unexpected !== undefined) {
28
+ return { kind: "usage", message: `check takes no arguments, and ${oneLine(unexpected, 64)} is one.\n${checkCommand.usage}` };
29
+ }
24
30
  try {
25
31
  const storage = await resolveRecordStorage(context.rootDir, { storageNamespace: context.config.storageNamespace });
26
32
  const probe = path.join(storage.storageDir, PROBE_FILE);
27
33
  await context.artifacts.writeTextFile(probe, "probe\n", { mode: 0o600 });
28
34
  await context.artifacts.removeFile(probe);
35
+ const recordPath = deliveryRecordPathFor(context.config, "<deliverableDigest>");
36
+ const baseMovement = context.config.deliveryRecordVerification.baseMovement === "stale"
37
+ ? "base movement stale: verification refuses base-ref, base-tip, or merge-base drift; admit again against the current base"
38
+ : "base movement allow: verification may accept base drift and names every relaxed drift class";
29
39
  return {
30
40
  kind: "ok",
31
41
  summary: [
32
42
  `ok: gate ${context.config.gateId}`,
33
43
  ` ${context.config.obligations.length} obligation(s), ${context.config.providers.length} provider(s)`,
34
44
  ` store ${storage.storageDir} (writable)`,
35
- ` delivery record path ${context.config.deliveryRecordPath} (base movement: ${context.config.deliveryRecordVerification.baseMovement})`,
45
+ ` delivery record path ${recordPath}`,
46
+ ` ${baseMovement}`,
36
47
  ].join("\n"),
37
48
  };
38
49
  } catch (error) {