@agent-delivery-harness/cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,14 +1,19 @@
1
1
  {
2
2
  "name": "@agent-delivery-harness/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Delivery harness CLI: prepare, review-context, submit-evidence, gate, record, verify, check",
6
6
  "license": "Apache-2.0",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/kwam1na/agent-delivery-harness.git",
10
+ "directory": "packages/cli"
11
+ },
7
12
  "engines": {
8
- "node": ">=22"
13
+ "node": ">=22.6.0"
9
14
  },
10
15
  "dependencies": {
11
- "@agent-delivery-harness/kernel": "0.1.0"
16
+ "@agent-delivery-harness/kernel": "0.2.0"
12
17
  },
13
18
  "exports": {
14
19
  ".": "./src/index.ts"
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,12 +38,14 @@ 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,
@@ -51,6 +54,13 @@ import {
51
54
  type ReviewActivationProjection,
52
55
  type WaiverPrompt,
53
56
  } from "@agent-delivery-harness/kernel";
57
+ import {
58
+ invokeProviderRail,
59
+ openProviderRailProcess,
60
+ type ProviderRailInvocationResult,
61
+ type ProviderRailSession,
62
+ } from "./provider-rails.ts";
63
+ import { buildRunEvent, resolveRunSurface } from "./run-surface.ts";
54
64
 
55
65
  // ── Exit codes ───────────────────────────────────────────────────────────────
56
66
 
@@ -96,6 +106,8 @@ export interface RepoWiring {
96
106
  export interface CommandContext {
97
107
  readonly rootDir: string;
98
108
  readonly config: HarnessConfig;
109
+ /** Optional adopter binding; managed commands also load the persisted binding after registration. */
110
+ readonly policyBinding?: CompiledAdopterPolicyBinding;
99
111
  readonly env: EnvSnapshot;
100
112
  readonly stdinIsTTY: boolean;
101
113
  readonly stdoutIsTTY: boolean;
@@ -111,6 +123,12 @@ export interface CommandContext {
111
123
  /** Present only when the run can ask a human; the boundary gates it on a TTY. */
112
124
  readonly promptForWaiver?: WaiverPrompt;
113
125
  readonly liveResults?: readonly LiveProviderResult[];
126
+ /** Runs one configured provider through the neutral stdio rail, if it has a command. */
127
+ readonly invokeProvider?: (input: {
128
+ readonly providerId: string;
129
+ readonly payload: Readonly<Record<string, unknown>>;
130
+ readonly requiresEvidence: boolean;
131
+ }) => Promise<ProviderRailInvocationResult | undefined>;
114
132
  /** Emits one line of operator-facing output to stdout. */
115
133
  readonly write: (text: string) => void;
116
134
  /** Classifies the execution context from this invocation's env + TTY. */
@@ -125,6 +143,53 @@ export interface CommandDescriptor {
125
143
  run(context: CommandContext): Promise<CommandResult>;
126
144
  }
127
145
 
146
+ // ── The config-free command class ────────────────────────────────────────────
147
+
148
+ /**
149
+ * What a config-free command may reach. Deliberately much less than
150
+ * {@link CommandContext}: no config, no wiring, no provider rail, no artifacts
151
+ * port. A run event belongs to the repository, not to a configured gate, and
152
+ * `emit` has to work in a repository that has no `harness.config.ts` at all.
153
+ *
154
+ * The ONE thing a config-free command may do with `harness.config.ts` is ask
155
+ * whether the path exists (`lstat`, no follow) at a worktree root. It never
156
+ * imports it, loads it, or parses it — which is why the config loader is not
157
+ * reachable from here even as a seam.
158
+ */
159
+ export interface ConfigFreeCommandContext {
160
+ readonly rootDir: string;
161
+ readonly env: EnvSnapshot;
162
+ /** Positional and flag arguments after the command name. */
163
+ readonly args: readonly string[];
164
+ /** The payload channel: everything on stdin, when a command reads one. */
165
+ readStdin(): Promise<string>;
166
+ /** Emits one line of operator-facing output to stdout. */
167
+ readonly write: (text: string) => void;
168
+ /**
169
+ * The invocation's cancellation, for the one config-free command that does
170
+ * not return on its own. `runs serve` holds a socket open until the operator
171
+ * ends it; without a signal it serves forever, which is exactly right for a
172
+ * terminal and useless for a caller that has to get its process back.
173
+ */
174
+ readonly signal?: AbortSignal;
175
+ }
176
+
177
+ export interface ConfigFreeCommandDescriptor {
178
+ readonly name: string;
179
+ readonly sourceId: string;
180
+ readonly summary: string;
181
+ /** The discriminator the boundary dispatches on, before any config load. */
182
+ readonly configFree: true;
183
+ run(context: ConfigFreeCommandContext): Promise<CommandResult>;
184
+ }
185
+
186
+ /** Either command class; the registry holds both. */
187
+ export type AnyCommandDescriptor = CommandDescriptor | ConfigFreeCommandDescriptor;
188
+
189
+ export function isConfigFreeCommand(descriptor: AnyCommandDescriptor): descriptor is ConfigFreeCommandDescriptor {
190
+ return (descriptor as ConfigFreeCommandDescriptor).configFree === true;
191
+ }
192
+
128
193
  // ── Runtime the boundary is driven with ──────────────────────────────────────
129
194
 
130
195
  export interface CliRuntime {
@@ -136,11 +201,23 @@ export interface CliRuntime {
136
201
  readonly stderr: (text: string) => void;
137
202
  /** Loads the consumer config. Defaults to importing `harness.config.ts`. */
138
203
  readonly loadConfig?: (rootDir: string) => Promise<HarnessConfig>;
204
+ /** An embedding adopter may pass its already-compiled policy directly. */
205
+ readonly policyBinding?: CompiledAdopterPolicyBinding;
139
206
  /** The interactive waiver prompt. Only ever offered under a TTY. */
140
207
  readonly promptForWaiver?: WaiverPrompt;
141
208
  /** The filesystem port. Defaults to one rooted in the system temp directory. */
142
209
  readonly artifacts?: ArtifactsPort;
143
210
  readonly liveResults?: readonly LiveProviderResult[];
211
+ readonly signal?: AbortSignal;
212
+ /** Reads the whole of stdin, for the one command whose payload arrives there. */
213
+ readonly readStdin?: () => Promise<string>;
214
+ /** Test/embedding seam. The ordinary runtime opens the provider's configured command. */
215
+ readonly openProviderRail?: (input: {
216
+ readonly providerId: string;
217
+ readonly command: readonly [string, ...string[]];
218
+ readonly cwd: string;
219
+ readonly env: EnvSnapshot;
220
+ }) => Promise<ProviderRailSession>;
144
221
  }
145
222
 
146
223
  // ── Blocker helpers ──────────────────────────────────────────────────────────
@@ -225,7 +302,7 @@ export async function wireRepo(rootDir: string, config: HarnessConfig): Promise<
225
302
 
226
303
  // ── The boundary ─────────────────────────────────────────────────────────────
227
304
 
228
- const USAGE = (commands: readonly CommandDescriptor[]): string =>
305
+ const USAGE = (commands: readonly AnyCommandDescriptor[]): string =>
229
306
  [
230
307
  "Usage: delivery-harness <command> [options]",
231
308
  "",
@@ -233,6 +310,76 @@ const USAGE = (commands: readonly CommandDescriptor[]): string =>
233
310
  ...commands.map((command) => ` ${command.name.padEnd(16)}${command.summary}`),
234
311
  ].join("\n");
235
312
 
313
+ /**
314
+ * THE WRAPPED COMMANDS, NAMED ONE BY ONE.
315
+ *
316
+ * Exactly the candidate-facing loop plus its preflight. `managed` and
317
+ * `maintain` are deliberately absent: they are host-facing and
318
+ * installation-scoped, and a completion event for them would describe
319
+ * something that is not a step of the delivery run the journal is about.
320
+ * `emit` and `runs` are absent because a viewer that recorded its own
321
+ * invocations would fill the journal it renders.
322
+ *
323
+ * An allowlist rather than a denylist: a command added to the registry is
324
+ * unwrapped until someone decides it belongs here, which is the direction that
325
+ * fails safe for a store nothing authoritative may read.
326
+ */
327
+ export const COMPLETION_WRAPPED_COMMANDS: readonly string[] = [
328
+ "check",
329
+ "prepare",
330
+ "review-context",
331
+ "submit-evidence",
332
+ "gate",
333
+ "record",
334
+ "verify",
335
+ ];
336
+
337
+ /** The four exit codes, as the closed outcome enum `command.completed` carries. */
338
+ function outcomeOfExit(code: number): "ok" | "policy" | "usage" | "interrupted" {
339
+ if (code === EXIT_OK) return "ok";
340
+ if (code === EXIT_USAGE) return "usage";
341
+ if (code === EXIT_INTERRUPTED) return "interrupted";
342
+ return "policy";
343
+ }
344
+
345
+ /**
346
+ * Appends this invocation's `command.completed`, when — and only when — a run
347
+ * is current for the invoking worktree.
348
+ *
349
+ * BEST-EFFORT, TOTAL, AND SILENT. Every failure is swallowed: an unresolvable
350
+ * repository, a refused pointer, a store that will not accept the append. The
351
+ * caller has already decided the exit code, and a run journal that could
352
+ * change a gate's verdict would be evidence. It is not evidence, so it is not
353
+ * allowed to matter. A refused append still lands one bounded line in the
354
+ * run's note, which the store writes.
355
+ */
356
+ async function recordCommandCompletion(input: {
357
+ readonly cwd: string;
358
+ readonly command: string;
359
+ readonly exitCode: number;
360
+ readonly durationMs: number;
361
+ }): Promise<void> {
362
+ try {
363
+ const resolved = await resolveRunSurface(input.cwd);
364
+ if (!resolved.ok) return;
365
+ const { store, commonDir, worktreeKey } = resolved.surface;
366
+ const current = await store.current(worktreeKey);
367
+ if (!current.ok || current.runId === undefined) return;
368
+ await store.append(
369
+ current.runId,
370
+ buildRunEvent({
371
+ runId: current.runId,
372
+ commonDir,
373
+ kind: "command.completed",
374
+ role: "cli",
375
+ payload: { command: input.command, outcome: outcomeOfExit(input.exitCode), durationMs: input.durationMs },
376
+ }),
377
+ );
378
+ } catch {
379
+ // Deliberately silent. See the paragraph above.
380
+ }
381
+ }
382
+
236
383
  /**
237
384
  * Runs one CLI invocation to an exit code. Total: it maps every command result
238
385
  * and every throw to one of the four codes, and renders every failure through
@@ -240,7 +387,7 @@ const USAGE = (commands: readonly CommandDescriptor[]): string =>
240
387
  */
241
388
  export async function runCliBoundary(
242
389
  argv: readonly string[],
243
- commands: readonly CommandDescriptor[],
390
+ commands: readonly AnyCommandDescriptor[],
244
391
  runtime: CliRuntime,
245
392
  ): Promise<number> {
246
393
  const [commandName, ...args] = argv;
@@ -256,6 +403,76 @@ export async function runCliBoundary(
256
403
  return EXIT_USAGE;
257
404
  }
258
405
 
406
+ // CONFIG-FREE COMMANDS ARE DISPATCHED FIRST, before any config load. That
407
+ // ordering is the whole point of the class: `emit` runs in a repository with
408
+ // no `harness.config.ts`, and no other command's `config_unloadable` timing
409
+ // moves because of it.
410
+ if (isConfigFreeCommand(descriptor)) {
411
+ return runConfigFreeCommand(descriptor, args, runtime);
412
+ }
413
+
414
+ const startedAt = Date.now();
415
+ const code = await runConfiguredCommand(descriptor, args, runtime);
416
+ if (COMPLETION_WRAPPED_COMMANDS.includes(descriptor.name)) {
417
+ await recordCommandCompletion({
418
+ cwd: runtime.cwd,
419
+ command: descriptor.name,
420
+ exitCode: code,
421
+ durationMs: Date.now() - startedAt,
422
+ });
423
+ }
424
+ return code;
425
+ }
426
+
427
+ /** The config-free path: a typed result mapped to an exit code, and nothing else. */
428
+ async function runConfigFreeCommand(
429
+ descriptor: ConfigFreeCommandDescriptor,
430
+ args: readonly string[],
431
+ runtime: CliRuntime,
432
+ ): Promise<number> {
433
+ try {
434
+ const result = await descriptor.run({
435
+ rootDir: runtime.cwd,
436
+ env: runtime.env,
437
+ args,
438
+ readStdin: runtime.readStdin ?? (async () => ""),
439
+ write: (text) => runtime.stdout(`${text}\n`),
440
+ ...(runtime.signal === undefined ? {} : { signal: runtime.signal }),
441
+ });
442
+ if (result.kind === "ok") {
443
+ if (result.summary !== undefined && result.summary !== "") runtime.stdout(`${result.summary}\n`);
444
+ return EXIT_OK;
445
+ }
446
+ if (result.kind === "usage") {
447
+ runtime.stderr(`${result.message}\n`);
448
+ return EXIT_USAGE;
449
+ }
450
+ runtime.stderr(`${renderBlockers(result.blockers)}\n`);
451
+ return EXIT_POLICY;
452
+ } catch (error) {
453
+ if (error instanceof CliInterruption) {
454
+ runtime.stderr(`${error.message}\n`);
455
+ return EXIT_INTERRUPTED;
456
+ }
457
+ if (error instanceof BlockedError) {
458
+ runtime.stderr(`${renderBlockers(error.blockers)}\n`);
459
+ return EXIT_POLICY;
460
+ }
461
+ const blocker = createInternalErrorBlocker({
462
+ source: { kind: "command", id: descriptor.sourceId },
463
+ error,
464
+ reproduce: ["delivery-harness", descriptor.name],
465
+ });
466
+ runtime.stderr(`${renderBlockers([blocker])}\n`);
467
+ return EXIT_POLICY;
468
+ }
469
+ }
470
+
471
+ async function runConfiguredCommand(
472
+ descriptor: CommandDescriptor,
473
+ args: readonly string[],
474
+ runtime: CliRuntime,
475
+ ): Promise<number> {
259
476
  const loadConfig = runtime.loadConfig ?? importHarnessConfig;
260
477
  const artifacts = runtime.artifacts ?? createArtifactsPort();
261
478
 
@@ -271,6 +488,7 @@ export async function runCliBoundary(
271
488
  const context: CommandContext = {
272
489
  rootDir: runtime.cwd,
273
490
  config,
491
+ ...(runtime.policyBinding === undefined ? {} : { policyBinding: runtime.policyBinding }),
274
492
  env: runtime.env,
275
493
  stdinIsTTY: runtime.stdinIsTTY,
276
494
  stdoutIsTTY: runtime.stdoutIsTTY,
@@ -283,6 +501,65 @@ export async function runCliBoundary(
283
501
  ? { promptForWaiver: runtime.promptForWaiver }
284
502
  : {}),
285
503
  ...(runtime.liveResults === undefined ? {} : { liveResults: runtime.liveResults }),
504
+ invokeProvider: async ({ providerId, payload, requiresEvidence }) => {
505
+ const provider = config.providers.find((registration) => registration.id === providerId);
506
+ if (provider?.command === undefined) return undefined;
507
+ const command = provider.command;
508
+ const requestId = randomUUID();
509
+ const allocation = await artifacts.allocateRunRoot({ providerId, runId: requestId });
510
+ if (!allocation.ok) {
511
+ throw new BlockedError([
512
+ commandBlocker({
513
+ code: "provider_rail_run_root_refused",
514
+ sourceId: "delivery-harness.cli.provider-rails",
515
+ summary: "The provider run root could not be allocated.",
516
+ details: `${providerId}/${requestId}: ${allocation.reason}`,
517
+ remediations: [
518
+ {
519
+ id: "check-provider-identity",
520
+ kind: "code_change",
521
+ summary: "Correct the provider id or restore the harness run-root location, then retry.",
522
+ },
523
+ ],
524
+ }),
525
+ ]);
526
+ }
527
+ const wiring = await wire();
528
+ const interruptController = runtime.signal === undefined ? new AbortController() : undefined;
529
+ const onInterrupt = (): void => interruptController?.abort();
530
+ if (interruptController !== undefined) process.once("SIGINT", onInterrupt);
531
+ try {
532
+ return await invokeProviderRail(
533
+ {
534
+ providerId,
535
+ requestId,
536
+ idempotencyKey: randomUUID(),
537
+ payload: { ...payload, runId: requestId, runRoot: allocation.runRoot.path },
538
+ requiresEvidence,
539
+ },
540
+ {
541
+ open: () =>
542
+ runtime.openProviderRail === undefined
543
+ ? openProviderRailProcess({ command, cwd: runtime.cwd, env: { ...runtime.env } })
544
+ : runtime.openProviderRail({ providerId, command, cwd: runtime.cwd, env: runtime.env }),
545
+ publishManifest: (manifestPath) =>
546
+ submitManifest(
547
+ { rootDir: runtime.cwd, config, manifestPath },
548
+ {
549
+ captureCandidate: wiring.captureCandidate,
550
+ artifacts,
551
+ expectedProviderAttempt: { providerId, runId: requestId, runRootPath: allocation.runRoot.path },
552
+ ...wiring.storageOptions,
553
+ },
554
+ ),
555
+ signal: runtime.signal ?? interruptController?.signal,
556
+ cancellationId: randomUUID(),
557
+ },
558
+ );
559
+ } finally {
560
+ if (interruptController !== undefined) process.off("SIGINT", onInterrupt);
561
+ }
562
+ },
286
563
  write: (text) => runtime.stdout(`${text}\n`),
287
564
  classifyContext: () =>
288
565
  classifyExecutionContext({