@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.
- package/LICENSE +104 -201
- package/NOTICE +1 -1
- package/package.json +10 -5
- package/src/boundary.ts +302 -9
- package/src/command-activity.ts +75 -0
- package/src/commands/admit.ts +106 -0
- package/src/commands/check.ts +13 -2
- package/src/commands/emit-review-evidence.ts +38 -0
- package/src/commands/emit.ts +419 -0
- package/src/commands/gate.ts +127 -18
- package/src/commands/maintain.ts +203 -0
- package/src/commands/managed.ts +478 -0
- package/src/commands/prepare.ts +123 -15
- package/src/commands/record.ts +100 -8
- package/src/commands/resume.ts +53 -0
- package/src/commands/review-context.ts +19 -0
- package/src/commands/runs.ts +640 -0
- package/src/commands/save-context.ts +51 -0
- package/src/commands/submit-evidence.ts +46 -7
- package/src/commands/verify.ts +173 -5
- package/src/declared-checks.ts +50 -0
- package/src/index.ts +40 -4
- package/src/main.ts +38 -42
- package/src/ordinary-context.ts +99 -0
- package/src/provider-rails.ts +24 -0
- package/src/record-retention.ts +325 -0
- package/src/review-evidence.ts +284 -0
- package/src/run-archive-commands.ts +164 -0
- package/src/run-archive.ts +127 -0
- package/src/run-artifact-commands.ts +163 -0
- package/src/run-attachments.ts +112 -0
- package/src/run-export.ts +164 -0
- package/src/run-live.ts +102 -0
- package/src/run-projection.ts +396 -0
- package/src/run-server.ts +905 -0
- package/src/run-surface.ts +334 -0
- package/src/run-view-command.ts +77 -0
- package/src/run-view-html.ts +138 -0
- package/src/run-view-record.ts +119 -0
- 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.
|
|
5
|
-
* classifies exit codes, and renders failures. A command never
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
+
};
|
package/src/commands/check.ts
CHANGED
|
@@ -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 ${
|
|
45
|
+
` delivery record path ${recordPath}`,
|
|
46
|
+
` ${baseMovement}`,
|
|
36
47
|
].join("\n"),
|
|
37
48
|
};
|
|
38
49
|
} catch (error) {
|