@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
@@ -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
+ };
@@ -9,21 +9,60 @@
9
9
  */
10
10
  import { submitManifest } from "@agent-delivery-harness/kernel";
11
11
  import type { CommandContext, CommandDescriptor, CommandResult } from "../boundary.ts";
12
+ import { oneLine } from "../run-surface.ts";
12
13
 
13
- function manifestPathFrom(args: readonly string[]): string | undefined {
14
- const flagIndex = args.indexOf("--manifest");
15
- if (flagIndex !== -1) return args[flagIndex + 1];
16
- // A lone positional is accepted too, but never a flag mistaken for a path.
17
- const positional = args.find((argument) => !argument.startsWith("-"));
18
- return positional;
14
+ function manifestPathsFrom(args: readonly string[]): string[] | undefined {
15
+ const paths: string[] = [];
16
+ for (let index = 0; index < args.length; index += 1) {
17
+ const token = args[index]!;
18
+ if (token === "--manifest") {
19
+ const value = args[++index];
20
+ if (value === undefined || value === "") return undefined;
21
+ paths.push(value);
22
+ } else if (!token.startsWith("-")) paths.push(token);
23
+ }
24
+ return paths;
25
+ }
26
+
27
+ /**
28
+ * The one flag this command has, and everything else that looks like a flag.
29
+ * `--manifest`'s VALUE is skipped rather than scanned, so a path that happens
30
+ * to begin with a hyphen is still that flag's value and not an unknown flag —
31
+ * rejecting legitimate arguments generically is the failure mode this guard
32
+ * has to avoid while it closes the silently-ignored one.
33
+ */
34
+ function unknownFlagIn(args: readonly string[]): string | undefined {
35
+ for (let index = 0; index < args.length; index += 1) {
36
+ const token = args[index]!;
37
+ if (token === "--manifest") {
38
+ index += 1;
39
+ continue;
40
+ }
41
+ if (token.startsWith("-")) return token;
42
+ }
43
+ return undefined;
19
44
  }
20
45
 
21
46
  export const submitEvidenceCommand: CommandDescriptor = {
22
47
  name: "submit-evidence",
23
48
  sourceId: "delivery-harness.cli.submit-evidence",
24
49
  summary: "Validate a provider manifest and publish its evidence records.",
50
+ usage: "Usage: delivery-harness submit-evidence --manifest <path>",
25
51
  async run(context: CommandContext): Promise<CommandResult> {
26
- const manifestPath = manifestPathFrom(context.args);
52
+ // An unrecognized flag is refused before the manifest is looked for, so a
53
+ // mistyped flag can never be ignored into a positional read of the argv.
54
+ const unknown = unknownFlagIn(context.args);
55
+ if (unknown !== undefined) {
56
+ return { kind: "usage", message: `Unknown flag ${oneLine(unknown, 64)}.\n${submitEvidenceCommand.usage}` };
57
+ }
58
+ const paths = manifestPathsFrom(context.args);
59
+ if (paths === undefined) {
60
+ return { kind: "usage", message: "submit-evidence requires --manifest <path>." };
61
+ }
62
+ if (paths.length > 1) {
63
+ return { kind: "usage", message: `submit-evidence requires one manifest path; received ${paths.map(value => oneLine(value, 256)).join(", ")}.` };
64
+ }
65
+ const manifestPath = paths[0];
27
66
  if (manifestPath === undefined || manifestPath === "") {
28
67
  return { kind: "usage", message: "submit-evidence requires --manifest <path>." };
29
68
  }
@@ -7,18 +7,124 @@
7
7
  * the pure `verifyDeliveryRecord` core. A missing record names the command that
8
8
  * writes it; a failed check surfaces the named drift class. When the base-movement
9
9
  * policy is `allow`, a passing check that relaxed base drift names the relaxation.
10
+ *
11
+ * THE RUN-JOURNAL ROW, AND WHY IT IS LOCAL ONLY. `verify` is the one caller
12
+ * that holds both halves of the question "was this candidate journaled": a
13
+ * record binding an exact tree sha, and a repository whose run store it can
14
+ * scan for a journal bound to the same one. So it resolves the row and reports
15
+ * it — and reporting is all it does by default. The row changes no exit code
16
+ * unless the operator asks for that with `--require-run-journal`, a LOCAL
17
+ * opt-in: it is not passed by the GitHub Action, not read by the gate, and not
18
+ * consulted by admission. A run journal is self-attested observability that
19
+ * anything the owner executes can append to, so a delivery that could be
20
+ * admitted or refused on one would be resting its gate on a file its own
21
+ * candidate scripts can write.
22
+ *
23
+ * `--mandated-lens <id>` is the second half of the same opt-in: supplied, the
24
+ * evaluator checks the journal's declared mandated pair against these ids
25
+ * rather than merely checking that it declared two non-empty ones. Repeatable,
26
+ * separate-argument form, and bounded to the run family's own id charset before
27
+ * it reaches the kernel.
10
28
  */
11
29
  import { readFile } from "node:fs/promises";
12
30
  import path from "node:path";
13
- import { deliveryRecordPathFor, parseDeliveryRecord, verifyDeliveryRecord } from "@agent-delivery-harness/kernel";
31
+ import {
32
+ MAX_RUN_PROVIDER_ID,
33
+ RUN_PROVIDER_ID,
34
+ deliveryRecordPathFor,
35
+ needsCommittedSymlinkTarget,
36
+ parseCandidateTreeListing,
37
+ parseDeliveryRecord,
38
+ runGitCommand,
39
+ verifyDeliveryRecord,
40
+ capturePortableVerificationInputs,
41
+ collectLiveProviderResults,
42
+ type CandidateTreeEntry,
43
+ type RunJournalRow,
44
+ } from "@agent-delivery-harness/kernel";
14
45
  import { commandBlocker } from "../boundary.ts";
15
46
  import type { CommandContext, CommandDescriptor, CommandResult } from "../boundary.ts";
47
+ import { oneLine, resolveRunJournalRow, runJournalRows } from "../run-surface.ts";
48
+
49
+ const USAGE = "Usage: delivery-harness verify [--require-run-journal] [--mandated-lens <id>]...";
50
+
51
+ interface ParsedArgs {
52
+ readonly requireRunJournal: boolean;
53
+ readonly mandatedLensIds: readonly string[];
54
+ }
55
+
56
+ type ArgParse = { readonly ok: true; readonly args: ParsedArgs } | { readonly ok: false; readonly message: string };
57
+
58
+ /**
59
+ * The separate-argument form every other command's flags take: `--flag value`,
60
+ * never `--flag=value`. The joined form is not silently split, because a
61
+ * `--mandated-lens=x` that quietly worked here and nowhere else would be a
62
+ * second grammar for the same CLI. It falls through to the unknown-flag arm,
63
+ * which is a usage error naming the token.
64
+ */
65
+ function parseArgs(args: readonly string[]): ArgParse {
66
+ let requireRunJournal = false;
67
+ const mandatedLensIds: string[] = [];
68
+
69
+ for (let index = 0; index < args.length; index += 1) {
70
+ const token = args[index]!;
71
+ if (token === "--require-run-journal") {
72
+ requireRunJournal = true;
73
+ continue;
74
+ }
75
+ if (token === "--mandated-lens") {
76
+ const value = args[index + 1];
77
+ if (value === undefined) return { ok: false, message: `${token} needs a value.\n${USAGE}` };
78
+ // Bounded before the kernel sees it: an id is compared against journal
79
+ // content, and an unbounded one would be echoed into the row it produces.
80
+ if (value.length > MAX_RUN_PROVIDER_ID || !RUN_PROVIDER_ID.test(value)) {
81
+ return { ok: false, message: `${token} takes a bounded lens id, not ${oneLine(value, 64)}.\n${USAGE}` };
82
+ }
83
+ mandatedLensIds.push(value);
84
+ index += 1;
85
+ continue;
86
+ }
87
+ if (token.startsWith("-")) return { ok: false, message: `Unknown flag ${oneLine(token, 64)}.\n${USAGE}` };
88
+ return { ok: false, message: `verify takes no positional arguments, and ${oneLine(token, 64)} is one.\n${USAGE}` };
89
+ }
90
+ return { ok: true, args: { requireRunJournal, mandatedLensIds } };
91
+ }
92
+
93
+ /**
94
+ * The opt-in's refusal. It names the status, the missing entries, and the
95
+ * violated constraints, all of them product-defined names from the evaluator's
96
+ * two closed sets — never journal-derived text.
97
+ */
98
+ function runJournalBlocker(row: RunJournalRow) {
99
+ const missing = row.missing.length === 0 ? "(none)" : row.missing.join(", ");
100
+ const violations = row.violations === undefined || row.violations.length === 0 ? "(none)" : row.violations.join(", ");
101
+ return commandBlocker({
102
+ code: "run_journal_incomplete",
103
+ sourceId: "delivery-harness.cli.verify",
104
+ summary: "The run journal for this candidate is not complete, and --require-run-journal was given.",
105
+ details: `status ${row.status}${row.runId === undefined ? "" : ` (run ${oneLine(row.runId, 128)})`}; missing: ${missing}; violations: ${violations}`,
106
+ remediations: [
107
+ {
108
+ id: "emit-the-missing-run-events",
109
+ kind: "manual_action",
110
+ summary: "Emit the run events this delivery did not journal, or drop --require-run-journal: the row is observability, not evidence.",
111
+ },
112
+ ],
113
+ });
114
+ }
16
115
 
17
116
  export const verifyCommand: CommandDescriptor = {
18
117
  name: "verify",
19
118
  sourceId: "delivery-harness.cli.verify",
20
119
  summary: "Verify the tracked delivery record against the current candidate.",
120
+ usage: USAGE,
21
121
  async run(context: CommandContext): Promise<CommandResult> {
122
+ const observedAt = `${new Date().toISOString().slice(0, 19)}Z`;
123
+ // Arguments first: a malformed invocation is a usage error and captures
124
+ // nothing, exactly as `emit` and `submit-evidence` order it.
125
+ const parsedArgs = parseArgs(context.args);
126
+ if (!parsedArgs.ok) return { kind: "usage", message: parsedArgs.message };
127
+
22
128
  const wiring = await context.wire();
23
129
  const capture = await wiring.captureCandidate();
24
130
  if (!capture.ok) {
@@ -67,17 +173,79 @@ export const verifyCommand: CommandDescriptor = {
67
173
  return { kind: "blocked", blockers: [...parsed.blockers] };
68
174
  }
69
175
 
70
- const check = verifyDeliveryRecord(context.config, parsed.record, identity, base);
71
- if (!check.ok) {
72
- return { kind: "blocked", blockers: [...check.blockers] };
176
+ // The tracked tree's own entries, so this command rejects a candidate
177
+ // carrying a projection or discovery-configuration path exactly as the
178
+ // Action does. An unreadable listing supplies no entries rather than a
179
+ // false clean bill: the check simply does not run.
180
+ //
181
+ // Mode and object, not just the name: the one admitted exception under
182
+ // `.claude/skills/` turns on the entry being a symlink and on where its
183
+ // committed target resolves, and both facts live in the tree.
184
+ const listing = await runGitCommand(["git", "ls-tree", "-r", "-z", "--full-tree", "HEAD"], {
185
+ cwd: context.rootDir,
186
+ });
187
+ const candidateTreePaths: CandidateTreeEntry[] = [];
188
+ if (listing.exitCode === 0) {
189
+ for (const entry of parseCandidateTreeListing(listing.stdout)) {
190
+ if (!needsCommittedSymlinkTarget(entry)) {
191
+ candidateTreePaths.push(entry);
192
+ continue;
193
+ }
194
+ // The target read out of the committed blob, never off the filesystem:
195
+ // the working tree's link may differ from the one under review. A blob
196
+ // that will not read leaves the target absent, and an entry with no
197
+ // target cannot reach the exception.
198
+ const blob = await runGitCommand(["git", "cat-file", "blob", entry.objectSha], { cwd: context.rootDir });
199
+ candidateTreePaths.push(blob.exitCode === 0 ? { ...entry, symlinkTarget: blob.stdout } : entry);
200
+ }
201
+ }
202
+
203
+ const inputs = await capturePortableVerificationInputs(context.rootDir, context.config, capture.candidate, parsed.record);
204
+ const live = await collectLiveProviderResults({rootDir:context.rootDir,config:context.config,candidate:capture.candidate,
205
+ projection:inputs.projection,evidenceContext:inputs.evidenceContext,env:context.env,...(context.signal===undefined?{}:{signal:context.signal})});
206
+ // Verify the portable record before letting any retained projection widen
207
+ // the observational tree coordinates. Corrupt or invented projection bytes
208
+ // therefore fail as record evidence and never influence journal matching.
209
+ const verified = verifyDeliveryRecord(context.config, parsed.record, identity, base,
210
+ { candidateTreePaths, ...inputs, observedAt, liveResults:live.liveResults, executionContext: context.classifyContext() });
211
+ if (!verified.ok) {
212
+ return { kind: "blocked", blockers: [...live.blockers, ...verified.blockers] };
213
+ }
214
+
215
+ // The record tree remains the primary coordinate. A product-validated
216
+ // review-neutral projection may additionally name the earlier raw tree the
217
+ // reviewers actually read; the journal stays observational either way.
218
+ const runJournal = await resolveRunJournalRow({
219
+ cwd: context.rootDir,
220
+ treeSha: parsed.record.candidateBinding.treeSha,
221
+ reviewedCandidateTreeShas: verified.reviewedCandidateTreeShas,
222
+ ...(parsedArgs.args.mandatedLensIds.length === 0 ? {} : { mandatedLensIds: parsedArgs.args.mandatedLensIds }),
223
+ });
224
+ const check = verifyDeliveryRecord(context.config, parsed.record, identity, base,
225
+ { candidateTreePaths, runJournal, ...inputs, observedAt, liveResults:live.liveResults, executionContext: context.classifyContext() });
226
+
227
+ // The opt-in is judged AFTER the record's own verification, so a delivery
228
+ // whose record is bad is never told its journal is the problem.
229
+ if (parsedArgs.args.requireRunJournal && runJournal.status !== "complete") {
230
+ return { kind: "blocked", blockers: [runJournalBlocker(runJournal)] };
73
231
  }
74
232
 
75
233
  const relaxation = check.baseMovementRelaxed
76
234
  ? ` (base movement relaxed by policy: ${check.relaxedDriftClasses.join(", ")})`
77
235
  : "";
236
+ const exemption = check.hostedChecks.exemption;
237
+ const hostedCheckRow = exemption === undefined ? [] : [
238
+ `hosted checks: exempted for ${oneLine(exemption.scope.repositoryId, 128)} at ${oneLine(exemption.scope.baseRef, 256)}; granted by ${oneLine(exemption.grantedBy, 256)}; until ${exemption.until}; reason: ${oneLine(exemption.reason, 512)}`,
239
+ ];
78
240
  return {
79
241
  kind: "ok",
80
- summary: `verified ${relativePath}${relaxation}; attestation: ${check.attestationLabel}`,
242
+ summary: [
243
+ `verified ${relativePath}${relaxation}; attestation: ${check.attestationLabel}`,
244
+ `recorded base: ${oneLine(parsed.record.candidateBinding.baseRef, 256)} at ${parsed.record.candidateBinding.baseTipSha}`,
245
+ `observed base: ${oneLine(base.ref, 256)} at ${base.tipSha}`,
246
+ ...hostedCheckRow,
247
+ ...runJournalRows(runJournal),
248
+ ].join("\n"),
81
249
  };
82
250
  },
83
251
  };
@@ -0,0 +1,50 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import path from "node:path";
3
+ import { captureCheckBindings, captureCheckOutputSnapshots, digestCanonical, classifyCandidateDrift, computeCheckWiringFingerprint, createBlocker, createExecPort, sha256Hex, submitManifest, type Blocker, type CandidateBinding, type ProviderRegistration } from "@agent-delivery-harness/kernel";
4
+ import type { CommandContext } from "./boundary.ts";
5
+
6
+ /** Runs one declared argv check and submits its product-constructed terminal evidence. */
7
+ export async function runDeclaredCheck(context: CommandContext, provider: ProviderRegistration, obligationIds: readonly string[], before: CandidateBinding): Promise<readonly Blocker[]> {
8
+ const check = provider.check!;
9
+ const wiring = await context.wire();
10
+ const fail = (code: string, summary: string, details?: string): readonly Blocker[] => [createBlocker({ code, source: { kind: "command", id: "delivery-harness.cli.gate" }, summary,
11
+ ...(details === undefined ? {} : { details }), remediations: [{ id: "repair-declared-check", kind: "manual_action", summary: "Fix the declared check or its inputs, prepare the stable candidate and run gate again." }] })];
12
+ const start = await wiring.captureCandidate();
13
+ if (!start.ok) return start.blockers;
14
+ if (classifyCandidateDrift(before, start.candidate).length) return fail("check_candidate_changed", "The candidate changed after review admission and before the check started.");
15
+ const fingerprint = await computeCheckWiringFingerprint(context.rootDir, context.config, wiring.storageOptions);
16
+ context.write(`checking ${provider.id}`);
17
+ const result = await createExecPort().run({ command: check.command[0], args: check.command.slice(1), cwd: context.rootDir,
18
+ env: Object.fromEntries(Object.entries(context.env).filter((entry): entry is [string, string] => entry[1] !== undefined)),
19
+ timeoutMs: check.timeoutMs, maxBuffer: 1024 * 1024, ...(context.signal === undefined ? {} : { signal: context.signal }) });
20
+ if (result.code !== 0 || context.signal?.aborted) return fail("check_command_failed", `Declared check ${provider.id} did not complete successfully.`, `exit ${result.code}${result.errorCode === undefined ? "" : ` (${result.errorCode})`}\n${`${result.stdout}\n${result.stderr}`.slice(-4000)}`);
21
+ const captured = await wiring.captureCandidate();
22
+ if (!captured.ok) return captured.blockers;
23
+ if (classifyCandidateDrift(before, captured.candidate).length || start.candidate.headSha !== captured.candidate.headSha || fingerprint !== await computeCheckWiringFingerprint(context.rootDir, context.config, wiring.storageOptions)) return fail("check_candidate_changed", "The candidate, base or wiring changed while the check ran.");
24
+ const binding = (await captureCheckBindings(context.rootDir, context.config, captured.candidate, wiring.storageOptions))[provider.id];
25
+ if (binding === undefined) return fail("check_output_missing", `Declared check ${provider.id} has a missing, unreadable, oversized or escaped output.`);
26
+ const runId = randomUUID(), finalPassId = "pass-1";
27
+ const allocation = await context.artifacts.allocateRunRoot({ providerId: provider.id, runId });
28
+ if (!allocation.ok) return fail("check_artifact_unavailable", "Cannot allocate the declared check evidence root.");
29
+ const snapshots = await captureCheckOutputSnapshots(context.rootDir, check.outputs ?? []);
30
+ if (snapshots === undefined || digestCanonical(snapshots.map(({ path, sha256 }) => ({ path, sha256 }))) !== binding.outputsDigest) return fail("check_output_missing", "Declared outputs changed before evidence retention.");
31
+ const outputArtifacts: { path: string; sha256: string; role: string }[] = [];
32
+ for (const [index, output] of snapshots.entries()) {
33
+ const contents = JSON.stringify({ path: output.path, base64: output.base64 });
34
+ const artifactPath = `check-output-${index}.json`;
35
+ await context.artifacts.writeTextFile(path.join(allocation.runRoot.path, artifactPath), contents);
36
+ outputArtifacts.push({ path: artifactPath, sha256: sha256Hex(contents), role: "check-output" });
37
+ }
38
+ const payload = { verdict: "green", exitCode: 0, binding };
39
+ const terminal = JSON.stringify({ providerId: provider.id, runId, finalPassId, ...payload });
40
+ await context.artifacts.writeTextFile(path.join(allocation.runRoot.path, "check-result.json"), terminal);
41
+ const candidate = captured.candidate;
42
+ const manifest = { spec: "delivery-evidence/1", provider: { id: provider.id, runId, finalPassId },
43
+ candidate: { vcs: "git", treeSha: candidate.treeSha, headSha: candidate.headSha, deliverable: candidate.deliverable, base: candidate.base, workspaceId: candidate.workspaceId },
44
+ runHistory: [{ preparedTreeSha: candidate.treeSha, evaluatedInPassId: finalPassId }], artifacts: [{ path: "check-result.json", sha256: sha256Hex(terminal), role: "check-result" }, ...outputArtifacts],
45
+ attestation: { level: "self", signatures: [] }, recordedAt: new Date().toISOString(), claims: obligationIds.map(obligation => ({ obligation, payloadSpec: "checks.passed/1", payload })) };
46
+ const manifestPath = path.join(allocation.runRoot.path, "manifest.json");
47
+ await context.artifacts.writeTextFile(manifestPath, JSON.stringify(manifest));
48
+ const outcome = await submitManifest({ rootDir: context.rootDir, config: context.config, manifestPath }, { captureCandidate: wiring.captureCandidate, artifacts: context.artifacts, ...wiring.storageOptions });
49
+ return outcome.status === "accepted" ? [] : outcome.blockers;
50
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Delivery harness CLI: the seven-command operator surface.
2
+ * Delivery harness CLI: the fifteen-command operator surface.
3
3
  *
4
4
  * THE COMMAND REGISTRY. `COMMANDS` is the single source of truth for which
5
5
  * commands exist. Every command module under `commands/` must appear here, and
@@ -12,55 +12,91 @@
12
12
  * in `boundary.ts`; each command is a thin, testable unit behind it.
13
13
  */
14
14
  import { checkCommand } from "./commands/check.ts";
15
+ import { admitCommand } from "./commands/admit.ts";
16
+ import { emitCommand } from "./commands/emit.ts";
15
17
  import { gateCommand } from "./commands/gate.ts";
18
+ import { maintainCommand } from "./commands/maintain.ts";
19
+ import { managedCommand } from "./commands/managed.ts";
16
20
  import { prepareCommand } from "./commands/prepare.ts";
17
21
  import { recordCommand } from "./commands/record.ts";
18
22
  import { reviewContextCommand } from "./commands/review-context.ts";
23
+ import { emitReviewEvidenceCommand } from "./commands/emit-review-evidence.ts";
24
+ import { runsCommand } from "./commands/runs.ts";
19
25
  import { submitEvidenceCommand } from "./commands/submit-evidence.ts";
20
26
  import { verifyCommand } from "./commands/verify.ts";
21
- import { runCliBoundary, type CliRuntime, type CommandDescriptor } from "./boundary.ts";
27
+ import { saveContextCommand } from "./commands/save-context.ts";
28
+ import { resumeCommand } from "./commands/resume.ts";
29
+ import { runCliBoundary, type AnyCommandDescriptor, type CliRuntime } from "./boundary.ts";
22
30
 
23
31
  export const PACKAGE_NAME = "@agent-delivery-harness/cli";
32
+ export { buildRunExport, parseRunExport, type DeliveryRunExport, type RunExportParseResult } from "./run-export.ts";
24
33
 
25
34
  /**
26
35
  * The command registry. The order here is the order `--help` lists them, and it
27
36
  * follows the loop an operator walks: prepare, review, submit, gate, record,
28
- * verify — with `check` last as the standalone preflight.
37
+ * verify — with `check` as the standalone preflight and `managed` as the
38
+ * host-facing managed-delivery checkpoint surface and `maintain` as the
39
+ * installation-scoped maintenance lane.
40
+ *
41
+ * `emit` and `runs` come last because they are a different class: config-free
42
+ * commands, dispatched before `harness.config.ts` is loaded, that read and
43
+ * write the run store rather than anything a delivery decision depends on.
29
44
  */
30
- export const COMMANDS: readonly CommandDescriptor[] = [
45
+ export const COMMANDS: readonly AnyCommandDescriptor[] = [
46
+ admitCommand,
31
47
  prepareCommand,
32
48
  reviewContextCommand,
49
+ emitReviewEvidenceCommand,
33
50
  submitEvidenceCommand,
34
51
  gateCommand,
35
52
  recordCommand,
36
53
  verifyCommand,
37
54
  checkCommand,
55
+ managedCommand,
56
+ maintainCommand,
57
+ emitCommand,
58
+ runsCommand,
59
+ saveContextCommand,
60
+ resumeCommand,
38
61
  ];
39
62
 
40
63
  export {
64
+ COMPLETION_WRAPPED_COMMANDS,
41
65
  EXIT_INTERRUPTED,
42
66
  EXIT_OK,
43
67
  EXIT_POLICY,
44
68
  EXIT_USAGE,
45
69
  CliInterruption,
70
+ isConfigFreeCommand,
46
71
  runCliBoundary,
47
72
  wireRepo,
48
73
  importHarnessConfig,
49
74
  commandBlocker,
75
+ type AnyCommandDescriptor,
50
76
  type CliRuntime,
51
77
  type CommandContext,
52
78
  type CommandDescriptor,
53
79
  type CommandResult,
80
+ type ConfigFreeCommandContext,
81
+ type ConfigFreeCommandDescriptor,
54
82
  type RepoWiring,
55
83
  } from "./boundary.ts";
56
84
 
57
85
  export { prepareCommand } from "./commands/prepare.ts";
86
+ export { admitCommand } from "./commands/admit.ts";
58
87
  export { reviewContextCommand } from "./commands/review-context.ts";
88
+ export { emitReviewEvidenceCommand } from "./commands/emit-review-evidence.ts";
59
89
  export { submitEvidenceCommand } from "./commands/submit-evidence.ts";
60
90
  export { gateCommand } from "./commands/gate.ts";
61
91
  export { recordCommand } from "./commands/record.ts";
62
92
  export { verifyCommand } from "./commands/verify.ts";
63
93
  export { checkCommand } from "./commands/check.ts";
94
+ export { managedCommand } from "./commands/managed.ts";
95
+ export { maintainCommand } from "./commands/maintain.ts";
96
+ export { emitCommand } from "./commands/emit.ts";
97
+ export { runsCommand } from "./commands/runs.ts";
98
+ export { saveContextCommand } from "./commands/save-context.ts";
99
+ export { resumeCommand } from "./commands/resume.ts";
64
100
 
65
101
  /** Runs the CLI against a runtime and returns the process exit code. */
66
102
  export function runCli(argv: readonly string[], runtime: CliRuntime): Promise<number> {
package/src/main.ts CHANGED
@@ -8,11 +8,9 @@
8
8
  * prints every obligation one "yes" would cover, reads a single line, and turns
9
9
  * a Ctrl-C into the typed {@link CliInterruption} the boundary maps to exit 130.
10
10
  */
11
- import { realpathSync } from "node:fs";
12
11
  import { createInterface } from "node:readline";
13
- import { fileURLToPath } from "node:url";
14
12
  import { CliInterruption, EXIT_POLICY, runCli, type CliRuntime } from "./index.ts";
15
- import type { WaiverPrompt } from "@agent-delivery-harness/kernel";
13
+ import { invokedDirectly, type WaiverPrompt } from "@agent-delivery-harness/kernel";
16
14
 
17
15
  /**
18
16
  * Reads a yes/no answer after naming every obligation the waiver would cover.
@@ -38,8 +36,8 @@ import type { WaiverPrompt } from "@agent-delivery-harness/kernel";
38
36
  * second settlement is exactly how this class of bug hides.
39
37
  */
40
38
  export function createWaiverPrompt(input: NodeJS.ReadableStream, output: NodeJS.WritableStream): WaiverPrompt {
41
- return (_decision, obligationIds) =>
42
- new Promise<boolean>((resolve, reject) => {
39
+ return (decision, obligationIds) =>
40
+ new Promise<Awaited<ReturnType<WaiverPrompt>>>((resolve, reject) => {
43
41
  const rl = createInterface({ input, output });
44
42
  let settled = false;
45
43
  const settle = (action: () => void): void => {
@@ -60,10 +58,24 @@ export function createWaiverPrompt(input: NodeJS.ReadableStream, output: NodeJS.
60
58
  });
61
59
 
62
60
  output.write(`Waiving covers ${obligationIds.length} obligation(s): ${obligationIds.join(", ")}.\n`);
61
+ output.write(`Candidate: ${decision.candidate.treeSha}. Approval covers only these findings under the current policy; live obligations require new approval each invocation.\n`);
62
+ for (const resolution of decision.resolutions) {
63
+ if (resolution.kind === "blocked" && obligationIds.includes(resolution.obligationId)) {
64
+ for (const blocker of resolution.blockers) output.write(`${resolution.obligationId}: [${blocker.code}] ${blocker.summary}\n`);
65
+ }
66
+ }
67
+ const finish = (value: Awaited<ReturnType<WaiverPrompt>>) => settle(() => {
68
+ rl.close();
69
+ resolve(value);
70
+ });
63
71
  rl.question("Waive all of them? [y/N] ", (answer) => {
64
- settle(() => {
65
- rl.close();
66
- resolve(/^\s*y(es)?\s*$/i.test(answer));
72
+ if (!/^\s*y(es)?\s*$/i.test(answer)) return finish(false);
73
+ rl.question("Author: ", (author) => {
74
+ if (!author.trim() || author.length > 256) return finish(false);
75
+ rl.question("Reason: ", (reason) => {
76
+ if (!reason.trim() || reason.length > 4096) return finish(false);
77
+ finish({ author: author.trim(), reason: reason.trim() });
78
+ });
67
79
  });
68
80
  });
69
81
  });
@@ -72,43 +84,26 @@ export function createWaiverPrompt(input: NodeJS.ReadableStream, output: NodeJS.
72
84
  export const readlineWaiverPrompt: WaiverPrompt = (decision, obligationIds) =>
73
85
  createWaiverPrompt(process.stdin, process.stderr)(decision, obligationIds);
74
86
 
75
- /** The spelling the filesystem can vouch for: the realpath where it can answer, the spelling itself where it cannot. */
76
- function canonicalEntryPath(entryPath: string): string {
77
- try {
78
- return realpathSync(entryPath);
79
- } catch {
80
- return entryPath;
81
- }
82
- }
83
-
84
87
  /**
85
- * Whether this module is the entry the process was started with.
86
- *
87
- * argv and `import.meta.url` may spell the same file differently: argv is the
88
- * caller's spelling, and Node builds the module URL from the realpath by
89
- * default but from the caller's spelling under `--preserve-symlinks-main`. So
90
- * each side is canonicalized independently and the canonical forms compared:
91
- * a symlinked spelling matches its realpath whenever the link can be read
92
- * (`/tmp` → `/private/tmp` on macOS, a wrapper script's stored path, a pnpm
93
- * workspace link), and equal spellings still match when neither side resolves.
88
+ * Reads the whole of stdin as UTF-8.
94
89
  *
95
- * What is NOT claimed: a symlink the filesystem cannot resolve cannot be seen
96
- * through, and the failing-exit-code floor below sits inside this guard, so an
97
- * under-match exits 0 in silence — the CLI reporting success having verified
98
- * nothing. The floor cannot be hoisted above the guard: that would stamp a
99
- * failing exit code on every process that merely *imports* this module. And a
100
- * non-`file:` module href (a bundled or single-executable build) never
101
- * matches — such a build must invoke `main` explicitly.
90
+ * `emit` takes its payload here, and the one thing that must never happen is a
91
+ * hang: a terminal with no pipe attached would otherwise leave the command
92
+ * waiting forever for a line nobody is going to type. A TTY stdin therefore
93
+ * reads as empty, and the store refuses the empty payload with a diagnostic,
94
+ * which is a far better answer than silence.
102
95
  */
103
- export function invokedDirectly(argvEntry: string | undefined, moduleHref: string): boolean {
104
- if (argvEntry === undefined) return false;
105
- let modulePath: string;
106
- try {
107
- modulePath = fileURLToPath(moduleHref);
108
- } catch {
109
- return false;
110
- }
111
- return canonicalEntryPath(argvEntry) === canonicalEntryPath(modulePath);
96
+ export function readStdinText(input: NodeJS.ReadableStream & { isTTY?: boolean }): Promise<string> {
97
+ if (input.isTTY === true) return Promise.resolve("");
98
+ return new Promise((resolve) => {
99
+ let text = "";
100
+ input.setEncoding("utf8");
101
+ input.on("data", (chunk: string) => {
102
+ text += chunk;
103
+ });
104
+ input.once("error", () => resolve(text));
105
+ input.once("end", () => resolve(text));
106
+ });
112
107
  }
113
108
 
114
109
  export function defaultRuntime(): CliRuntime {
@@ -120,6 +115,7 @@ export function defaultRuntime(): CliRuntime {
120
115
  stdout: (text) => process.stdout.write(text),
121
116
  stderr: (text) => process.stderr.write(text),
122
117
  promptForWaiver: readlineWaiverPrompt,
118
+ readStdin: () => readStdinText(process.stdin),
123
119
  };
124
120
  }
125
121