@agent-delivery-harness/cli 0.2.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/LICENSE +104 -201
  2. package/NOTICE +1 -1
  3. package/package.json +4 -4
  4. package/src/boundary.ts +77 -61
  5. package/src/command-activity.ts +75 -0
  6. package/src/commands/admit.ts +106 -0
  7. package/src/commands/check.ts +13 -2
  8. package/src/commands/emit-review-evidence.ts +38 -0
  9. package/src/commands/emit.ts +80 -12
  10. package/src/commands/gate.ts +33 -8
  11. package/src/commands/maintain.ts +1 -0
  12. package/src/commands/managed.ts +1 -0
  13. package/src/commands/prepare.ts +123 -22
  14. package/src/commands/record.ts +98 -3
  15. package/src/commands/resume.ts +53 -0
  16. package/src/commands/review-context.ts +19 -0
  17. package/src/commands/runs.ts +415 -30
  18. package/src/commands/save-context.ts +51 -0
  19. package/src/commands/submit-evidence.ts +46 -7
  20. package/src/commands/verify.ts +29 -8
  21. package/src/declared-checks.ts +50 -0
  22. package/src/index.ts +14 -1
  23. package/src/main.ts +20 -47
  24. package/src/ordinary-context.ts +99 -0
  25. package/src/provider-rails.ts +23 -704
  26. package/src/record-retention.ts +325 -0
  27. package/src/review-evidence.ts +284 -0
  28. package/src/run-archive-commands.ts +164 -0
  29. package/src/run-archive.ts +127 -0
  30. package/src/run-artifact-commands.ts +163 -0
  31. package/src/run-attachments.ts +112 -0
  32. package/src/run-export.ts +164 -0
  33. package/src/run-live.ts +102 -0
  34. package/src/run-projection.ts +129 -11
  35. package/src/run-server.ts +394 -149
  36. package/src/run-surface.ts +81 -8
  37. package/src/run-view-command.ts +77 -0
  38. package/src/run-view-html.ts +138 -0
  39. package/src/run-view-record.ts +119 -0
  40. package/src/run-view.ts +343 -0
@@ -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
  }
@@ -37,6 +37,8 @@ import {
37
37
  parseDeliveryRecord,
38
38
  runGitCommand,
39
39
  verifyDeliveryRecord,
40
+ capturePortableVerificationInputs,
41
+ collectLiveProviderResults,
40
42
  type CandidateTreeEntry,
41
43
  type RunJournalRow,
42
44
  } from "@agent-delivery-harness/kernel";
@@ -115,7 +117,9 @@ export const verifyCommand: CommandDescriptor = {
115
117
  name: "verify",
116
118
  sourceId: "delivery-harness.cli.verify",
117
119
  summary: "Verify the tracked delivery record against the current candidate.",
120
+ usage: USAGE,
118
121
  async run(context: CommandContext): Promise<CommandResult> {
122
+ const observedAt = `${new Date().toISOString().slice(0, 19)}Z`;
119
123
  // Arguments first: a malformed invocation is a usage error and captures
120
124
  // nothing, exactly as `emit` and `submit-evidence` order it.
121
125
  const parsedArgs = parseArgs(context.args);
@@ -196,19 +200,29 @@ export const verifyCommand: CommandDescriptor = {
196
200
  }
197
201
  }
198
202
 
199
- // The row is resolved from the RECORD'S tree sha, not the recomputed
200
- // identity: the identity digest excludes the review-neutral paths, and what
201
- // a review round and a `pr.opened` bind is the raw tree the record carries.
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.
202
218
  const runJournal = await resolveRunJournalRow({
203
219
  cwd: context.rootDir,
204
220
  treeSha: parsed.record.candidateBinding.treeSha,
221
+ reviewedCandidateTreeShas: verified.reviewedCandidateTreeShas,
205
222
  ...(parsedArgs.args.mandatedLensIds.length === 0 ? {} : { mandatedLensIds: parsedArgs.args.mandatedLensIds }),
206
223
  });
207
-
208
- const check = verifyDeliveryRecord(context.config, parsed.record, identity, base, { candidateTreePaths, runJournal });
209
- if (!check.ok) {
210
- return { kind: "blocked", blockers: [...check.blockers] };
211
- }
224
+ const check = verifyDeliveryRecord(context.config, parsed.record, identity, base,
225
+ { candidateTreePaths, runJournal, ...inputs, observedAt, liveResults:live.liveResults, executionContext: context.classifyContext() });
212
226
 
213
227
  // The opt-in is judged AFTER the record's own verification, so a delivery
214
228
  // whose record is bad is never told its journal is the problem.
@@ -219,10 +233,17 @@ export const verifyCommand: CommandDescriptor = {
219
233
  const relaxation = check.baseMovementRelaxed
220
234
  ? ` (base movement relaxed by policy: ${check.relaxedDriftClasses.join(", ")})`
221
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
+ ];
222
240
  return {
223
241
  kind: "ok",
224
242
  summary: [
225
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,
226
247
  ...runJournalRows(runJournal),
227
248
  ].join("\n"),
228
249
  };
@@ -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 eleven-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,6 +12,7 @@
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";
15
16
  import { emitCommand } from "./commands/emit.ts";
16
17
  import { gateCommand } from "./commands/gate.ts";
17
18
  import { maintainCommand } from "./commands/maintain.ts";
@@ -19,12 +20,16 @@ import { managedCommand } from "./commands/managed.ts";
19
20
  import { prepareCommand } from "./commands/prepare.ts";
20
21
  import { recordCommand } from "./commands/record.ts";
21
22
  import { reviewContextCommand } from "./commands/review-context.ts";
23
+ import { emitReviewEvidenceCommand } from "./commands/emit-review-evidence.ts";
22
24
  import { runsCommand } from "./commands/runs.ts";
23
25
  import { submitEvidenceCommand } from "./commands/submit-evidence.ts";
24
26
  import { verifyCommand } from "./commands/verify.ts";
27
+ import { saveContextCommand } from "./commands/save-context.ts";
28
+ import { resumeCommand } from "./commands/resume.ts";
25
29
  import { runCliBoundary, type AnyCommandDescriptor, type CliRuntime } from "./boundary.ts";
26
30
 
27
31
  export const PACKAGE_NAME = "@agent-delivery-harness/cli";
32
+ export { buildRunExport, parseRunExport, type DeliveryRunExport, type RunExportParseResult } from "./run-export.ts";
28
33
 
29
34
  /**
30
35
  * The command registry. The order here is the order `--help` lists them, and it
@@ -38,8 +43,10 @@ export const PACKAGE_NAME = "@agent-delivery-harness/cli";
38
43
  * write the run store rather than anything a delivery decision depends on.
39
44
  */
40
45
  export const COMMANDS: readonly AnyCommandDescriptor[] = [
46
+ admitCommand,
41
47
  prepareCommand,
42
48
  reviewContextCommand,
49
+ emitReviewEvidenceCommand,
43
50
  submitEvidenceCommand,
44
51
  gateCommand,
45
52
  recordCommand,
@@ -49,6 +56,8 @@ export const COMMANDS: readonly AnyCommandDescriptor[] = [
49
56
  maintainCommand,
50
57
  emitCommand,
51
58
  runsCommand,
59
+ saveContextCommand,
60
+ resumeCommand,
52
61
  ];
53
62
 
54
63
  export {
@@ -74,7 +83,9 @@ export {
74
83
  } from "./boundary.ts";
75
84
 
76
85
  export { prepareCommand } from "./commands/prepare.ts";
86
+ export { admitCommand } from "./commands/admit.ts";
77
87
  export { reviewContextCommand } from "./commands/review-context.ts";
88
+ export { emitReviewEvidenceCommand } from "./commands/emit-review-evidence.ts";
78
89
  export { submitEvidenceCommand } from "./commands/submit-evidence.ts";
79
90
  export { gateCommand } from "./commands/gate.ts";
80
91
  export { recordCommand } from "./commands/record.ts";
@@ -84,6 +95,8 @@ export { managedCommand } from "./commands/managed.ts";
84
95
  export { maintainCommand } from "./commands/maintain.ts";
85
96
  export { emitCommand } from "./commands/emit.ts";
86
97
  export { runsCommand } from "./commands/runs.ts";
98
+ export { saveContextCommand } from "./commands/save-context.ts";
99
+ export { resumeCommand } from "./commands/resume.ts";
87
100
 
88
101
  /** Runs the CLI against a runtime and returns the process exit code. */
89
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,45 +84,6 @@ 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
- /**
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.
94
- *
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.
102
- */
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);
112
- }
113
-
114
87
  /**
115
88
  * Reads the whole of stdin as UTF-8.
116
89
  *
@@ -0,0 +1,99 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { HARNESS_VERSION, digestCanonical, type Blocker, type RunEvent, type RunEventVersion } from "@agent-delivery-harness/kernel";
4
+ import { commandBlocker, type CommandContext } from "./boundary.ts";
5
+ import { oneLine, resolveRunSurface, type RunSurface } from "./run-surface.ts";
6
+
7
+ export function recoveryBlocker(code: string, summary: string, details?: string): Blocker {
8
+ return commandBlocker({ code, sourceId: "delivery-harness.cli.resume", summary,
9
+ ...(details === undefined ? {} : { details }),
10
+ remediations: [{ id: "reconcile-delivery-context", kind: "manual_action", summary: "Inspect the saved context and actual workspace/external state with host tools; rerun preparation and gates where evidence is not current. Never replay an uncertain action automatically." }],
11
+ });
12
+ }
13
+
14
+ /**
15
+ * What a store rejection may say to an operator: the store's own code, the
16
+ * JSON pointer at the offending member, and the store's own message. All three
17
+ * are written by the store rather than taken from the refused payload, so the
18
+ * diagnostic names the field without ever echoing its value — a contract a
19
+ * caller must keep, because the payload it refused is the one place a
20
+ * credential could have been.
21
+ */
22
+ export function rejectionDetails(rejections: readonly { readonly code: string; readonly pointer: string; readonly message: string }[]): string {
23
+ const first = rejections[0];
24
+ if (first === undefined) return "the store refused the append";
25
+ return `${oneLine(first.code, 64)} at ${oneLine(first.pointer, 128) || "/"}: ${oneLine(first.message, 200)}`;
26
+ }
27
+
28
+ /**
29
+ * The writer members an ordinary-context event carries, for the version the
30
+ * selected run was started at.
31
+ *
32
+ * WHY THE ID IS DERIVED FROM THE OBSERVATION. A v2 event id is a retry key:
33
+ * the store admits a repeat of an id only when the whole event matches, and
34
+ * refuses the id outright for different content. An id derived from the
35
+ * payload's canonical digest is therefore exactly the key the contract wants —
36
+ * the same save repeated is the same event and appends nothing new, while a
37
+ * changed observation is a different id and a new entry, so neither an
38
+ * interrupted retry nor a genuine second save can produce a refusal an
39
+ * operator has to reason about. It is also stable across processes and hosts,
40
+ * which a random id is not.
41
+ */
42
+ export function ordinaryEventWriter(version: RunEventVersion, kind: "context.saved", payload: unknown): {
43
+ readonly version: RunEventVersion;
44
+ readonly eventId?: string;
45
+ } {
46
+ if (version !== "run-event/2") return { version };
47
+ // `kind` is narrowed to the one kind this writer serves, so the prefix is a
48
+ // constant that keeps the id readable and inside the store's 128-character
49
+ // run-id charset. It is deliberately not a general scoping claim: dots are
50
+ // folded to hyphens, which is not injective over arbitrary kinds, so a
51
+ // second kind must not be added here without an injective encoding.
52
+ return { version, eventId: `${kind.replaceAll(".", "-")}-${digestCanonical(payload)}` };
53
+ }
54
+
55
+ export async function recoveryRun(rootDir: string, named?: string): Promise<
56
+ { ok: true; surface: RunSurface; runId: string; events: readonly RunEvent[]; version: RunEventVersion } | { ok: false; blocker: Blocker }
57
+ > {
58
+ const resolved = await resolveRunSurface(rootDir);
59
+ if (!resolved.ok) return { ok: false, blocker: recoveryBlocker("resume_context_invalid", "The repository run store cannot be read.") };
60
+ const current = await resolved.surface.store.current(resolved.surface.worktreeKey);
61
+ const runId = named ?? (current.ok ? current.runId : undefined);
62
+ if (!runId) return { ok: false, blocker: recoveryBlocker("resume_context_missing", "No current delivery run was found; start a run or name its existing id with --run.") };
63
+ const read = await resolved.surface.store.read(runId);
64
+ if (!read.ok) return { ok: false, blocker: recoveryBlocker("resume_context_invalid", "The delivery journal is missing, corrupt, or inaccessible.", rejectionDetails(read.rejections)) };
65
+ // The writer version is selected when the run starts and never upgraded, so
66
+ // every writer into this journal reads it from the journal's first event.
67
+ return { ok: true, surface: resolved.surface, runId, events: read.events, version: read.events[0]?.version ?? "run-event/1" };
68
+ }
69
+
70
+ export async function installedRelease(rootDir: string) {
71
+ const document: unknown = JSON.parse(await readFile(path.join(rootDir, ".agent-skills/active.json"), "utf8"));
72
+ const release = (document as { release?: { releaseId?: unknown; profile?: unknown; archiveSha256?: unknown } } | null)?.release;
73
+ if (!release || typeof release.releaseId !== "string" || typeof release.profile !== "string" ||
74
+ typeof release.archiveSha256 !== "string" || !/^[0-9a-f]{64}$/.test(release.archiveSha256)) {
75
+ throw new Error("Installed workflow release identity is missing or malformed.");
76
+ }
77
+ return { runtimeVersion: HARNESS_VERSION, releaseId: release.releaseId, profile: release.profile, archiveSha256: release.archiveSha256 };
78
+ }
79
+
80
+ export const policyDigest = (context: CommandContext) => digestCanonical({ config: context.config, policyBinding: context.policyBinding ?? null });
81
+
82
+ /** Observed action outcomes are a host handoff, never permission to invoke an action. */
83
+ export function reconciliationActions(events: readonly RunEvent[]) {
84
+ const actions = new Map<string, { actionId: string; operation?: unknown; reference: unknown; outcome: string; inconsistent?: boolean }>();
85
+ for (const event of events) {
86
+ const p = event.payload;
87
+ if (event.kind === "action.intent") {
88
+ const actionId = p["actionId"] as string;
89
+ const prior = actions.get(actionId);
90
+ actions.set(actionId, { actionId, operation: p["operation"], reference: p["reference"], outcome: "unknown", ...(prior ? { inconsistent: true } : {}) });
91
+ } else if (event.kind === "action.observed") {
92
+ const actionId = p["actionId"] as string;
93
+ const prior = actions.get(actionId);
94
+ actions.set(actionId, { ...prior, actionId, reference: p["reference"], outcome: p["outcome"] as string,
95
+ ...(!prior || prior.inconsistent || (prior.outcome !== "unknown" && prior.outcome !== p["outcome"]) ? { inconsistent: true } : {}) });
96
+ }
97
+ }
98
+ return [...actions.values()];
99
+ }