@agent-delivery-harness/cli 0.5.0 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "name": "@agent-delivery-harness/cli",
3
- "version": "0.5.0",
3
+ "version": "0.7.4",
4
4
  "type": "module",
5
5
  "description": "Delivery harness CLI: admit, prepare, review-context, submit-evidence, gate, record, verify, check",
6
6
  "license": "FSL-1.1-ALv2",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "git+https://github.com/kwam1na/agent-delivery-harness.git",
9
+ "url": "git+https://github.com/v26-technologies/agent-delivery-harness.git",
10
10
  "directory": "packages/cli"
11
11
  },
12
12
  "engines": {
13
13
  "node": ">=22.6.0"
14
14
  },
15
15
  "dependencies": {
16
- "@agent-delivery-harness/kernel": "0.5.0"
16
+ "@agent-delivery-harness/kernel": "0.7.4"
17
17
  },
18
18
  "exports": {
19
19
  ".": "./src/index.ts"
package/src/boundary.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { CheckSnapshotError } from "./check-snapshot.ts";
2
+ import { captureScopedCandidate } from "./scoped-candidate.ts";
1
3
  /**
2
4
  * The one boundary every CLI command runs behind.
3
5
  *
@@ -307,12 +309,13 @@ export async function importHarnessConfig(rootDir: string): Promise<HarnessConfi
307
309
  export async function wireRepo(rootDir: string, config: HarnessConfig): Promise<RepoWiring> {
308
310
  const storageOptions = { storageNamespace: config.storageNamespace };
309
311
  const storage = await resolveRecordStorage(rootDir, storageOptions);
310
- const captureCandidate = createCandidateCapture({
312
+ const captureOptions = {
311
313
  rootDir,
312
314
  config,
313
315
  workspaceId: storage.workspaceId,
314
316
  computeIdentity: withDeliverableIdentity(),
315
- });
317
+ };
318
+ const captureCandidate = config.scopedExecution ? () => captureScopedCandidate(captureOptions) : createCandidateCapture(captureOptions);
316
319
  const projectActivation = (candidate: CapturedCandidate): Promise<ReviewActivationProjection> =>
317
320
  evaluateCandidateActivation({ rootDir, candidate, config });
318
321
  return { rootDir, workspaceId: storage.workspaceId, captureCandidate, projectActivation, storageOptions };
@@ -458,6 +461,7 @@ async function runConfigFreeCommand(
458
461
  runtime.stderr(`${error.message}\n`);
459
462
  return EXIT_INTERRUPTED;
460
463
  }
464
+ if (error instanceof CheckSnapshotError) error = new BlockedError([commandBlocker({ code: error.code, sourceId: "delivery-harness.cli.scoped-check", summary: error.message, remediations: [{ id: "repair-scoped-execution", kind: "manual_action", summary: "Repair the scoped execution inputs or profile, prepare and retry." }] })]);
461
465
  if (error instanceof BlockedError) {
462
466
  runtime.stderr(`${renderBlockers(error.blockers)}\n`);
463
467
  return EXIT_POLICY;
@@ -602,6 +606,7 @@ async function runConfiguredCommand(
602
606
  runtime.stderr(`${error.message}\n`);
603
607
  return EXIT_INTERRUPTED;
604
608
  }
609
+ if (error instanceof CheckSnapshotError) error = new BlockedError([commandBlocker({ code: error.code, sourceId: "delivery-harness.cli.scoped-check", summary: error.message, remediations: [{ id: "repair-scoped-execution", kind: "manual_action", summary: "Repair the scoped execution inputs or profile, prepare and retry." }] })]);
605
610
  if (error instanceof BlockedError) {
606
611
  runtime.stderr(`${renderBlockers(error.blockers)}\n`);
607
612
  return EXIT_POLICY;
@@ -0,0 +1,167 @@
1
+ /** Private execution tree. Never shares writable source, objects, index or dependencies. */
2
+ import { spawn } from "node:child_process";
3
+ import { mkdir, mkdtemp, realpath, rename, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+ import { createExecPort, type ExecOutcome } from "@agent-delivery-harness/kernel";
7
+
8
+ import { snapshotInventoryWorker } from "./snapshot-inventory-worker.ts";
9
+
10
+ const SNAPSHOT_TIMEOUT_MS = 5 * 60_000;
11
+ export class CheckSnapshotError extends Error {
12
+ readonly code: string;
13
+ constructor(code: string, message: string) { super(message); this.code = code; }
14
+ }
15
+ export interface SnapshotRequest {
16
+ readonly rootDir: string;
17
+ readonly candidate: { readonly treeSha: string; readonly headSha: string; readonly base: { readonly ref: string; readonly tipSha: string; readonly mergeBaseSha: string } };
18
+ readonly outputs: readonly string[];
19
+ readonly gitContext?: "full" | "none";
20
+ readonly environment: Readonly<Record<string, string>>;
21
+ readonly dependencies?: { readonly command: readonly [string, ...string[]]; readonly timeoutMs: number };
22
+ readonly onDependencyResult?: (result: ExecOutcome, durationMs: number) => void;
23
+ readonly signal?: AbortSignal;
24
+ }
25
+ export interface CheckSnapshot {
26
+ readonly commandRoot: string;
27
+ readonly rootDir: string;
28
+ readonly environment: Readonly<Record<string, string>>;
29
+ readonly dependencyDigest: string;
30
+ verify(options?: { readonly timeoutMs: number }): Promise<void>;
31
+ cleanup(): Promise<void>;
32
+ }
33
+ function inside(root: string, target: string): boolean {
34
+ const relative = path.relative(root, target);
35
+ return relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
36
+ }
37
+ /** Kill the owned read-only process and await close before callers may clean up.
38
+ * AbortSignal is handled here: child_process's early AbortError event is not
39
+ * proof that the process has stopped touching the snapshot. */
40
+ async function snapshotProcess(command: string, args: string[], cwd: string, env: Readonly<Record<string, string>>, deadline: number, signal?: AbortSignal): Promise<{ code: number | null; stdout: string }> {
41
+ if (signal?.aborted) throw new CheckSnapshotError("check_snapshot_interrupted", "Snapshot verification was interrupted.");
42
+ const remaining = deadline - performance.now();
43
+ if (remaining <= 0) throw new CheckSnapshotError("check_snapshot_timeout", "Snapshot verification exceeded its deadline.");
44
+ return new Promise((resolve, reject) => {
45
+ const child = spawn(command, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
46
+ let failure: CheckSnapshotError | undefined, stdout = "", size = 0;
47
+ const stop = (code: string, message: string) => {
48
+ failure ??= new CheckSnapshotError(code, message);
49
+ child.kill("SIGKILL");
50
+ };
51
+ const abort = () => stop("check_snapshot_interrupted", "Snapshot verification was interrupted.");
52
+ const timer = setTimeout(() => stop("check_snapshot_timeout", "Snapshot verification exceeded its deadline."), remaining);
53
+ signal?.addEventListener("abort", abort, { once: true });
54
+ if (signal?.aborted) abort();
55
+ child.stdout.on("data", (bytes: Buffer) => {
56
+ size += bytes.length;
57
+ if (size > 64 * 1024) stop("check_snapshot_unavailable", "Snapshot verifier output exceeded its limit.");
58
+ else stdout += bytes.toString();
59
+ });
60
+ child.stderr.on("data", () => {}); // Drain; never expose private paths or source bytes.
61
+ child.on("error", () => { failure ??= new CheckSnapshotError("check_snapshot_unavailable", "Snapshot verifier could not execute."); });
62
+ child.once("close", code => {
63
+ clearTimeout(timer); signal?.removeEventListener("abort", abort);
64
+ if (!failure && performance.now() >= deadline) failure = new CheckSnapshotError("check_snapshot_timeout", "Snapshot verification exceeded its deadline.");
65
+ if (failure) reject(failure); else resolve({ code, stdout });
66
+ });
67
+ });
68
+ }
69
+ /** The inventory's blocking filesystem operations live only in a killable process. */
70
+ export async function snapshotInventory(root: string, outputs: readonly string[], excludeDependencies = false, deadline = performance.now() + SNAPSHOT_TIMEOUT_MS, signal?: AbortSignal, forbidGit = false): Promise<string> {
71
+ const result = await snapshotProcess(process.execPath, ["--input-type=module", "--eval", snapshotInventoryWorker], root,
72
+ { PATH: process.env["PATH"] ?? "/usr/bin:/bin", DELIVERY_SNAPSHOT_REQUEST: JSON.stringify({ root, outputs, excludeDependencies, forbidGit }) }, deadline, signal);
73
+ let parsed: { code?: string; digest?: string };
74
+ try { parsed = JSON.parse(result.stdout); } catch { throw new CheckSnapshotError("check_snapshot_unavailable", "Snapshot inventory returned no valid result."); }
75
+ if (result.code !== 0 || !/^[a-f0-9]{64}$/.test(parsed.digest ?? "")) throw new CheckSnapshotError(["check_snapshot_escape", "check_snapshot_drift"].includes(parsed.code ?? "") ? parsed.code! : "check_snapshot_unavailable", "Snapshot inventory could not verify private bytes and links.");
76
+ return parsed.digest!;
77
+ }
78
+ export function executionPath(root: string, value: string): string {
79
+ return value.split(path.delimiter).filter(p => p && path.isAbsolute(p) && !inside(root, p) && !p.split(path.sep).includes("node_modules")).join(path.delimiter) || "/usr/bin:/bin";
80
+ }
81
+ export async function createCheckSnapshot(input: SnapshotRequest): Promise<CheckSnapshot> {
82
+ const rootDir = await realpath(await mkdtemp(path.join(tmpdir(), "delivery-check-")));
83
+ const cleanEnv = { ...input.environment, PATH: executionPath(input.rootDir, input.environment["PATH"] ?? process.env["PATH"] ?? "/usr/bin:/bin"), GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_TERMINAL_PROMPT: "0", GIT_AUTHOR_DATE: "2000-01-01T00:00:00Z", GIT_COMMITTER_DATE: "2000-01-01T00:00:00Z" };
84
+ let privateControl: string | undefined;
85
+ let gitDirectory: string | undefined;
86
+ const git = async (...args: string[]) => {
87
+ const controller = new AbortController();
88
+ let timedOut = false;
89
+ const deadline = performance.now() + SNAPSHOT_TIMEOUT_MS;
90
+ const timer = setTimeout(() => { timedOut = true; controller.abort(); }, SNAPSHOT_TIMEOUT_MS);
91
+ const abort = () => controller.abort();
92
+ input.signal?.addEventListener("abort", abort, { once: true });
93
+ if (input.signal?.aborted) abort();
94
+ try {
95
+ const result = await createExecPort().run({ command: "git", args: gitDirectory ? [`--git-dir=${gitDirectory}`, `--work-tree=${rootDir}`, ...args] : args,
96
+ cwd: rootDir, env: cleanEnv, maxBuffer: 128 * 1024 * 1024, signal: controller.signal });
97
+ timedOut ||= performance.now() >= deadline;
98
+ if (timedOut || result.code !== 0) throw new CheckSnapshotError(timedOut ? "check_snapshot_timeout" : input.signal?.aborted ? "check_snapshot_interrupted" : "check_snapshot_unavailable", "Private Git materialization did not complete successfully.");
99
+ return result.stdout.trim();
100
+ } finally { clearTimeout(timer); input.signal?.removeEventListener("abort", abort); }
101
+ };
102
+ const cleanup = async () => {
103
+ const removed = await Promise.allSettled([rootDir, ...(privateControl ? [privateControl] : [])].map(dir => rm(dir, { recursive: true, force: true })));
104
+ if (removed.some(result => result.status === "rejected")) throw new CheckSnapshotError("check_snapshot_cleanup_failed", "Cannot remove the owned execution snapshot after retaining evidence.");
105
+ };
106
+ try {
107
+ await git("init", "-q");
108
+ // Fetch transfers object bytes; unlike worktrees/alternates/local clones it
109
+ // cannot create writable links back to the author's Git metadata.
110
+ // Accept the source's shallow boundary in this private repository. Without
111
+ // it, fetch can reject objects or succeed while dropping required grafts.
112
+ await git("fetch", "--update-shallow", "--no-tags", "--no-write-fetch-head", input.rootDir, input.candidate.headSha, input.candidate.base.tipSha, input.candidate.treeSha);
113
+ await git("update-ref", "HEAD", input.candidate.headSha);
114
+ await git("read-tree", input.candidate.treeSha);
115
+ await git("checkout-index", "--all", "--force");
116
+ if ((await git("ls-tree", "-r", "--name-only", input.candidate.treeSha)).split("\n").some(p => p.split("/").includes("node_modules"))) throw new CheckSnapshotError("check_dependency_source_overlap", "Tracked node_modules cannot be replaced by private dependency setup.");
117
+ const candidateRef = "refs/delivery/candidate", baseRef = "refs/delivery/base";
118
+ const candidateCommit = await git("-c", "user.name=Delivery", "-c", "user.email=delivery@example.invalid", "-c", "commit.gpgsign=false", "commit-tree", input.candidate.treeSha, "-p", input.candidate.headSha, "-m", "Prepared execution snapshot");
119
+ await git("update-ref", candidateRef, candidateCommit);
120
+ await git("update-ref", "refs/delivery/origin-head", input.candidate.headSha);
121
+ await git("update-ref", "HEAD", candidateCommit);
122
+ await git("update-ref", baseRef, input.candidate.base.tipSha);
123
+ await git("update-ref", "refs/delivery/merge-base", input.candidate.base.mergeBaseSha);
124
+ // Conventional branch refs also resolve to the pinned base for existing checks.
125
+ if (/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(input.candidate.base.ref)) await git("update-ref", input.candidate.base.ref.startsWith("refs/") ? input.candidate.base.ref : `refs/remotes/${input.candidate.base.ref}`, input.candidate.base.tipSha);
126
+ if (input.gitContext === "none") {
127
+ privateControl = await realpath(await mkdtemp(path.join(tmpdir(), "delivery-check-control-")));
128
+ gitDirectory = path.join(privateControl, "repository");
129
+ await rename(path.join(rootDir, ".git"), gitDirectory);
130
+ }
131
+ const controlRoot = privateControl ?? path.join(rootDir, ".git");
132
+ const inventory = (outputs: readonly string[], sourceOnly = false, deadline = performance.now() + SNAPSHOT_TIMEOUT_MS) => snapshotInventory(rootDir, outputs, sourceOnly, deadline, input.signal, input.gitContext === "none");
133
+ const sourceDigest = await inventory(input.outputs, true);
134
+ const environment: Record<string, string> = { ...input.environment, PATH: `${path.join(rootDir, "node_modules/.bin")}${path.delimiter}${cleanEnv["PATH"]}`, HOME: path.join(controlRoot, "home"), TMPDIR: path.join(controlRoot, "tmp"), GIT_CEILING_DIRECTORIES: path.dirname(rootDir),
135
+ ...(input.gitContext === "none" ? {} : {
136
+ DELIVERY_CHECK_BASE_REF: baseRef, DELIVERY_CHECK_CANDIDATE_REF: candidateRef, DELIVERY_CHECK_ORIGIN_HEAD: input.candidate.headSha, DELIVERY_CHECK_ORIGIN_TREE: input.candidate.treeSha, DELIVERY_CHECK_MERGE_BASE: input.candidate.base.mergeBaseSha }) };
137
+ await mkdir(environment["HOME"]!, { recursive: true }); await mkdir(environment["TMPDIR"]!, { recursive: true });
138
+ if (input.dependencies) {
139
+ const started = performance.now();
140
+ const result = await createExecPort().run({ command: input.dependencies.command[0], args: input.dependencies.command.slice(1), cwd: rootDir, env: environment, timeoutMs: input.dependencies.timeoutMs, maxBuffer: 1024 * 1024, ...(input.signal ? { signal: input.signal } : {}) });
141
+ input.onDependencyResult?.(result, Math.round(performance.now() - started));
142
+ if (result.code !== 0 || input.signal?.aborted) throw new CheckSnapshotError("check_dependency_failed", "Private dependency installation did not complete successfully.");
143
+ }
144
+ if (sourceDigest !== await inventory(input.outputs, true)) throw new CheckSnapshotError("check_snapshot_drift", "Dependency setup changed prepared source bytes.");
145
+ const dependencyDigest = await inventory(input.outputs);
146
+ // The second digest covers both source and installed dependency bytes and
147
+ // all links. It intentionally excludes only declared mutable output paths.
148
+ const verify = async (options?: { readonly timeoutMs: number }) => {
149
+ const timeoutMs = options?.timeoutMs ?? SNAPSHOT_TIMEOUT_MS;
150
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > SNAPSHOT_TIMEOUT_MS) throw new CheckSnapshotError("check_snapshot_timeout", "Snapshot deadline must be positive and cannot exceed five minutes.");
151
+ const deadline = performance.now() + timeoutMs;
152
+ // The worker also checks file-only Git metadata, so lstat cannot stall the parent.
153
+ const verifyGit = async (...args: string[]) => {
154
+ const result = await snapshotProcess("git", gitDirectory ? [`--git-dir=${gitDirectory}`, `--work-tree=${rootDir}`, ...args] : args, rootDir, cleanEnv, deadline, input.signal);
155
+ if (result.code !== 0) throw new CheckSnapshotError("check_snapshot_unavailable", "Snapshot Git identity could not be read.");
156
+ return result.stdout.trim();
157
+ };
158
+ await inventory([], false, deadline);
159
+ if (await verifyGit("write-tree") !== input.candidate.treeSha || await verifyGit("rev-parse", "HEAD") !== candidateCommit || await verifyGit("rev-parse", `${candidateRef}^{tree}`) !== input.candidate.treeSha || await verifyGit("rev-parse", baseRef) !== input.candidate.base.tipSha || dependencyDigest !== await inventory(input.outputs, false, deadline)) throw new CheckSnapshotError("check_snapshot_drift", "Execution changed the private source, dependencies or pinned Git context.");
160
+ };
161
+ return { rootDir, commandRoot: path.join(controlRoot, "commands"), environment, dependencyDigest, verify, cleanup };
162
+ } catch (error) {
163
+ await cleanup();
164
+ if (error instanceof CheckSnapshotError) throw error;
165
+ throw new CheckSnapshotError("check_snapshot_unavailable", "The exact prepared Git tree could not be materialized privately.");
166
+ }
167
+ }
@@ -1,3 +1,6 @@
1
+ import { ScopedChecks } from "../scoped-checks.ts";
2
+ import { CheckSnapshotError } from "../check-snapshot.ts";
3
+ import { commandBlocker } from "../boundary.ts";
1
4
  import { runDeclaredCheck } from "../declared-checks.ts";
2
5
  /**
3
6
  * `gate` — evaluate the delivery gate and, under a TTY, offer a scoped waiver.
@@ -20,10 +23,15 @@ import { oneLine } from "../run-surface.ts";
20
23
  * through the same admission adapter. Configs without provider commands take
21
24
  * the pre-existing path unchanged.
22
25
  */
23
- export async function runProviderBackedAdmission(
26
+ async function providerAdmission(
24
27
  context: CommandContext,
25
28
  options: { readonly allowPrompt: boolean; readonly includeInjectedLiveResults: boolean },
29
+ session?: ScopedChecks,
26
30
  ): Promise<AdmissionResult & { readonly observedLiveResults?: readonly LiveProviderResult[] }> {
31
+ const admit = async (input: Parameters<typeof runAdmission>[0], options: Parameters<typeof runAdmission>[1]) => runAdmission(input, { ...options, ...(session ? { scopedPlan: await session.plan(), readOutput: session.readOutput } : {}) });
32
+ if (session) {
33
+ await session.fenceNonReusable(context.config.providers.filter(p => p.check?.scope).map(p => p.id));
34
+ }
27
35
  const wiring = await context.wire();
28
36
  const admissionOptions = {
29
37
  captureCandidate: wiring.captureCandidate,
@@ -42,13 +50,14 @@ export async function runProviderBackedAdmission(
42
50
  };
43
51
 
44
52
  if (!context.config.providers.some((provider) => provider.command !== undefined || provider.check !== undefined)) {
45
- return runAdmission(input, finalAdmissionOptions);
53
+ return admit(input, finalAdmissionOptions);
46
54
  }
47
55
 
48
56
  const liveResults: LiveProviderResult[] = options.includeInjectedLiveResults ? [...(context.liveResults ?? [])] : [];
49
57
  const attempted = new Set<string>();
50
58
  const attemptBlockers: Blocker[] = [];
51
- let admission = await runAdmission(input, admissionOptions);
59
+ let admission = await admit(input, admissionOptions);
60
+ await session?.explainReuse((admission.decision?.resolutions ?? []).flatMap(r => r.kind === "satisfied_evidence" ? [r.providerId] : []));
52
61
 
53
62
  while (!admission.admitted && admission.decision !== undefined && admission.candidate !== undefined) {
54
63
  const requested = new Map<string, { obligationIds: string[]; requiresEvidence: boolean; needsLiveResult: boolean }>();
@@ -78,8 +87,14 @@ export async function runProviderBackedAdmission(
78
87
  attempted.add(providerId);
79
88
  const registration = context.config.providers.find(provider => provider.id === providerId)!;
80
89
  if (registration.check !== undefined) {
81
- attemptBlockers.push(...await runDeclaredCheck(context, registration, request.obligationIds, admission.candidate));
82
- admission = await runAdmission({ ...input, ...(liveResults.length === 0 ? {} : { liveResults }) }, admissionOptions);
90
+ if (registration.check.scope && session) {
91
+ try { await session.execute(registration, request.obligationIds); }
92
+ catch (error) {
93
+ if (!(error instanceof CheckSnapshotError)) throw error;
94
+ attemptBlockers.push(commandBlocker({ code: error.code, sourceId: "delivery-harness.cli.gate", summary: error.message, remediations: [{ id: "repair-scoped-check", kind: "manual_action", summary: "Repair the declared scoped check or its execution profile and run the gate again." }] }));
95
+ }
96
+ } else attemptBlockers.push(...await runDeclaredCheck(context, registration, request.obligationIds, admission.candidate));
97
+ admission = await admit({ ...input, ...(liveResults.length === 0 ? {} : { liveResults }) }, admissionOptions);
83
98
  continue;
84
99
  }
85
100
  const result = await context.invokeProvider?.({
@@ -103,14 +118,14 @@ export async function runProviderBackedAdmission(
103
118
  liveResults.push(result.liveResult);
104
119
  }
105
120
 
106
- admission = await runAdmission(
121
+ admission = await admit(
107
122
  { ...input, ...(liveResults.length === 0 ? {} : { liveResults }) },
108
123
  admissionOptions,
109
124
  );
110
125
  }
111
126
 
112
127
  if (admission.admitted) return { ...admission, observedLiveResults: liveResults };
113
- const final = await runAdmission(
128
+ const final = await admit(
114
129
  { ...input, ...(liveResults.length === 0 ? {} : { liveResults }) },
115
130
  finalAdmissionOptions,
116
131
  );
@@ -119,6 +134,17 @@ export async function runProviderBackedAdmission(
119
134
  : { ...final, observedLiveResults: liveResults, blockers: [...attemptBlockers, ...final.blockers] };
120
135
  }
121
136
 
137
+ export async function runProviderBackedAdmission(context: CommandContext, options: { readonly allowPrompt: boolean; readonly includeInjectedLiveResults: boolean }): Promise<AdmissionResult & { readonly observedLiveResults?: readonly LiveProviderResult[] }> {
138
+ let session: ScopedChecks | undefined;
139
+ try {
140
+ if (context.config.providers.some(p => p.check?.scope)) {
141
+ const capture = await (await context.wire()).captureCandidate();
142
+ if (capture.ok) session = await ScopedChecks.create(context, capture.candidate);
143
+ }
144
+ return await providerAdmission(context, options, session);
145
+ } finally { await session?.cleanup(); }
146
+ }
147
+
122
148
  export const gateCommand: CommandDescriptor = {
123
149
  name: "gate",
124
150
  sourceId: "delivery-harness.cli.gate",
@@ -35,6 +35,7 @@ const SOURCE_ID = "delivery-harness.cli.managed";
35
35
  /** Every operation this command answers, in the order its usage lists them. */
36
36
  const MANAGED_OPERATIONS: readonly string[] = Object.freeze([
37
37
  "status",
38
+ "deliveries",
38
39
  "next",
39
40
  "operations",
40
41
  "blockers",
@@ -98,21 +99,28 @@ interface ResolvedManaged {
98
99
  readonly fence: number | undefined;
99
100
  }
100
101
 
102
+ interface ResolvedInstallation {
103
+ readonly facade: ManagedDeliveryFacade;
104
+ readonly namespace: string;
105
+ /** Every delivery this installation registered, sorted. */
106
+ readonly deliveries: readonly string[];
107
+ /** Those whose journal has not committed a transition into a terminal state. */
108
+ readonly active: readonly string[];
109
+ }
110
+
101
111
  /**
102
- * Resolves the delivery this invocation addresses.
112
+ * Resolves the INSTALLATION this invocation runs against, without resolving a
113
+ * delivery.
103
114
  *
104
- * `requested` exists for the retention operations alone. The skeleton's rule —
105
- * one delivery in flight per repository — is right for every checkpoint
106
- * operation, but it makes the retention lane unusable the moment a repository
107
- * has finished more than one delivery: `active[0] ?? deliveries.at(-1)` then
108
- * addresses only the newest, and every earlier terminal delivery's durable
109
- * detail becomes unreachable from the CLI even though the facade can export and
110
- * delete it. Naming one explicitly is the whole remedy, and it is deliberately
111
- * NOT offered to the checkpoint operations: those bind an invocation fence
112
- * derived from the worktree, and a delivery named by flag would not be the one
113
- * that worktree is bound to.
115
+ * WHY THE SPLIT EXISTS. Delivery resolution refuses when a repository has zero
116
+ * registered deliveries or more than one in flight — correct for every
117
+ * checkpoint operation, which drives exactly one. But those are precisely the
118
+ * situations the installation-scoped listing exists to report, so a listing
119
+ * built on top of delivery resolution would refuse in the two cases it is for.
120
+ * Everything above delivery selection is shared and lives here; selection and
121
+ * the fence stay below.
114
122
  */
115
- async function resolveManaged(context: CommandContext, requested?: string): Promise<ResolvedManaged | CommandResult> {
123
+ async function resolveInstallation(context: CommandContext): Promise<ResolvedInstallation | CommandResult> {
116
124
  const common = await gitCommonDir(context.rootDir);
117
125
  if (common === undefined) {
118
126
  return blocked("not_a_repository", "The working directory is not a git repository.", "Run from the delivery worktree.");
@@ -130,7 +138,15 @@ async function resolveManaged(context: CommandContext, requested?: string): Prom
130
138
  }
131
139
  let deliveries: string[];
132
140
  try {
133
- deliveries = (await readdir(path.join(namespace, "deliveries"))).sort();
141
+ // Directories only, the same rule the facade's listing applies. A stray
142
+ // FILE in the namespace is not a delivery, and counting one as in flight is
143
+ // the two-surfaces-contradict state the listing exists to prevent: `status`
144
+ // would refuse with "several deliveries are in flight" while `deliveries`
145
+ // named it in neither list, leaving no id to reconcile the two counts by.
146
+ deliveries = (await readdir(path.join(namespace, "deliveries"), { withFileTypes: true }))
147
+ .filter((candidate) => candidate.isDirectory())
148
+ .map((candidate) => candidate.name)
149
+ .sort();
134
150
  } catch {
135
151
  deliveries = [];
136
152
  }
@@ -161,26 +177,6 @@ async function resolveManaged(context: CommandContext, requested?: string): Prom
161
177
  active.push(candidate);
162
178
  }
163
179
  }
164
- let deliveryId: string | undefined;
165
- if (requested !== undefined) {
166
- if (!deliveries.includes(requested)) {
167
- return blocked(
168
- "delivery_unresolved",
169
- `No delivery ${requested} is registered for this repository.`,
170
- "Name a delivery this repository registered; `managed status` reports the current one.",
171
- );
172
- }
173
- deliveryId = requested;
174
- } else {
175
- deliveryId = active[0] ?? deliveries[deliveries.length - 1];
176
- if (deliveryId === undefined || active.length > 1) {
177
- return blocked(
178
- "delivery_unresolved",
179
- active.length > 1 ? "Several deliveries are in flight; the skeleton drives one." : "No registered delivery exists.",
180
- "Register exactly one delivery for this repository, or name one with --delivery for export and delete.",
181
- );
182
- }
183
- }
184
180
  let policyBinding: CompiledAdopterPolicyBinding | undefined = context.policyBinding;
185
181
  if (policyBinding === undefined) {
186
182
  try {
@@ -206,6 +202,48 @@ async function resolveManaged(context: CommandContext, requested?: string): Prom
206
202
  installation: { installationPath: pointer.installationPath, receiptDir: pointer.receiptDir },
207
203
  hostVersion: pointer.hostVersion,
208
204
  });
205
+ return { facade, namespace, deliveries, active };
206
+ }
207
+
208
+ /**
209
+ * Resolves the delivery this invocation addresses.
210
+ *
211
+ * `requested` exists for the retention operations alone. The skeleton's rule —
212
+ * one delivery in flight per repository — is right for every checkpoint
213
+ * operation, but it makes the retention lane unusable the moment a repository
214
+ * has finished more than one delivery: `active[0] ?? deliveries.at(-1)` then
215
+ * addresses only the newest, and every earlier terminal delivery's durable
216
+ * detail becomes unreachable from the CLI even though the facade can export and
217
+ * delete it. Naming one explicitly is the whole remedy, and it is deliberately
218
+ * NOT offered to the checkpoint operations: those bind an invocation fence
219
+ * derived from the worktree, and a delivery named by flag would not be the one
220
+ * that worktree is bound to.
221
+ */
222
+ async function resolveManaged(context: CommandContext, requested?: string): Promise<ResolvedManaged | CommandResult> {
223
+ const installation = await resolveInstallation(context);
224
+ if (isCommandResult(installation)) return installation;
225
+ const { facade, namespace, deliveries, active } = installation;
226
+
227
+ let deliveryId: string | undefined;
228
+ if (requested !== undefined) {
229
+ if (!deliveries.includes(requested)) {
230
+ return blocked(
231
+ "delivery_unresolved",
232
+ `No delivery ${requested} is registered for this repository.`,
233
+ "Name a delivery this repository registered; `managed status` reports the current one.",
234
+ );
235
+ }
236
+ deliveryId = requested;
237
+ } else {
238
+ deliveryId = active[0] ?? deliveries[deliveries.length - 1];
239
+ if (deliveryId === undefined || active.length > 1) {
240
+ return blocked(
241
+ "delivery_unresolved",
242
+ active.length > 1 ? "Several deliveries are in flight; the skeleton drives one." : "No registered delivery exists.",
243
+ "Register exactly one delivery for this repository, or name one with --delivery for export and delete, or list them all with `managed deliveries`.",
244
+ );
245
+ }
246
+ }
209
247
 
210
248
  let fence: number | undefined;
211
249
  try {
@@ -222,7 +260,7 @@ async function resolveManaged(context: CommandContext, requested?: string): Prom
222
260
  return { facade, deliveryId, fence };
223
261
  }
224
262
 
225
- const isCommandResult = (value: ResolvedManaged | CommandResult): value is CommandResult => "kind" in value;
263
+ const isCommandResult = (value: ResolvedManaged | ResolvedInstallation | CommandResult): value is CommandResult => "kind" in value;
226
264
 
227
265
  export const managedCommand: CommandDescriptor = {
228
266
  name: "managed",
@@ -255,6 +293,38 @@ export const managedCommand: CommandDescriptor = {
255
293
  return { kind: "ok", summary: `${FACADE_OPERATIONS.length} facade operations` };
256
294
  }
257
295
 
296
+ // The installation-scoped listing mode of the status surface. It answers
297
+ // HERE, above delivery resolution, for the same reason `operations` does:
298
+ // the two situations it exists to report — no delivery registered, and
299
+ // several in flight — are exactly the two that delivery resolution
300
+ // refuses, so a listing resolved through it would be unavailable whenever
301
+ // it had something to say. It names no delivery and binds no fence.
302
+ //
303
+ // It still requires an INSTALLATION. A repository with no product
304
+ // namespace pointer has registered nothing at all, and there is then no
305
+ // installation to scope a listing to — which is a different answer from an
306
+ // installation whose registered deliveries happen to number zero, and the
307
+ // facade reports that one as an empty listing.
308
+ if (operation === "deliveries") {
309
+ const installation = await resolveInstallation(context);
310
+ if (isCommandResult(installation)) return installation;
311
+ const listing = await installation.facade.listDeliveries({ observedAt: nowInstant() });
312
+ if (!listing.ok) return { kind: "blocked", blockers: [...listing.blockers] };
313
+ context.write(`${JSON.stringify(listing.deliveries, null, 2)}\n`);
314
+ // An unreadable delivery is NAMED, not dropped. Delivery resolution below
315
+ // counts the same directory as registered, so a listing that quietly
316
+ // omitted it would leave `managed status` saying several deliveries are in
317
+ // flight while the surface it points at shows fewer — with no id to
318
+ // reconcile the two by. The id here is enough to ask `managed status
319
+ // --delivery <id>` and get the refusal that says what is wrong.
320
+ const unreadable =
321
+ listing.unreadable.length === 0 ? "" : `; ${listing.unreadable.length} unreadable (${listing.unreadable.join(", ")})`;
322
+ return {
323
+ kind: "ok",
324
+ summary: `${listing.deliveries.length} delivery(ies) registered for this installation${unreadable}`,
325
+ };
326
+ }
327
+
258
328
  // Only the retention operations may name a delivery; everything else binds
259
329
  // the fence of the worktree it runs in.
260
330
  const RETENTION_OPERATIONS = ["export", "delete"];
@@ -1,3 +1,5 @@
1
+ import { ScopedChecks } from "../scoped-checks.ts";
2
+ import { CheckSnapshotError } from "../check-snapshot.ts";
1
3
  /**
2
4
  * `prepare` — capture the candidate and publish its preparation receipt.
3
5
  *
@@ -32,6 +34,8 @@ export const prepareCommand: CommandDescriptor = {
32
34
  if (context.args.length > 1 || (context.args.length === 1 && context.args[0] !== "--refresh-record-neutral")) {
33
35
  return { kind: "usage", message: USAGE };
34
36
  }
37
+ if (context.config.providers.some(p => p.check?.scope) && !context.config.scopedExecution) throw new CheckSnapshotError("scoped_executor_required", "Scoped execution profiles are required before preparation commands may run.");
38
+ let scoped: ScopedChecks | undefined;
35
39
  const refreshRecordNeutral = context.args[0] === "--refresh-record-neutral";
36
40
  const wiring = await context.wire();
37
41
  let retainedAttemptId: string | undefined;
@@ -44,6 +48,18 @@ export const prepareCommand: CommandDescriptor = {
44
48
  let reusableFingerprint: string | undefined;
45
49
  let attemptId: string;
46
50
  try {
51
+ if (context.config.scopedExecution?.repairCommands?.length) {
52
+ ownedAttemptId = await invalidatePreparationReceipt(context.rootDir, context.config, wiring.storageOptions);
53
+ const beforeRepair = await computePreparationFingerprint(context.rootDir, context.config, wiring.storageOptions);
54
+ for (const repair of context.config.scopedExecution.repairCommands) {
55
+ context.write(`repairing ${repair.id}`);
56
+ const result = await createExecPort().run({ command: repair.command[0], args: repair.command.slice(1), cwd: context.rootDir,
57
+ env: Object.fromEntries(Object.entries(context.env).filter((e): e is [string, string] => e[1] !== undefined)), timeoutMs: repair.timeoutMs, maxBuffer: 1024 * 1024, ...(context.signal ? { signal: context.signal } : {}) });
58
+ if (context.signal?.aborted) throw new CliInterruption();
59
+ if (result.code !== 0) throw new CheckSnapshotError("preparation_repair_failed", `Source repair ${repair.id} failed; fix it and prepare again.`);
60
+ }
61
+ if (beforeRepair !== await computePreparationFingerprint(context.rootDir, context.config, wiring.storageOptions)) throw new CheckSnapshotError("preparation_candidate_changed", "Repair changed preparation wiring; reload the configuration and prepare again.");
62
+ }
47
63
  capture = await wiring.captureCandidate();
48
64
  if (capture.ok && refreshRecordNeutral) {
49
65
  const previous = await evaluatePreparationReceipt(context.rootDir,
@@ -54,7 +70,7 @@ export const prepareCommand: CommandDescriptor = {
54
70
  }
55
71
  }
56
72
  } finally {
57
- attemptId = retainedAttemptId ?? await invalidatePreparationReceipt(context.rootDir, context.config, wiring.storageOptions);
73
+ attemptId = retainedAttemptId ?? ownedAttemptId ?? await invalidatePreparationReceipt(context.rootDir, context.config, wiring.storageOptions);
58
74
  ownedAttemptId = attemptId;
59
75
  }
60
76
  if (!capture.ok) {
@@ -71,7 +87,7 @@ export const prepareCommand: CommandDescriptor = {
71
87
  ownedAttemptId = attemptId;
72
88
  retainedAttemptId = undefined;
73
89
  }
74
- const validationDigest = await computeDeliverableIdentity({ rootDir: context.rootDir, treeSha: capture.candidate.treeSha,
90
+ let validationDigest = await computeDeliverableIdentity({ rootDir: context.rootDir, treeSha: capture.candidate.treeSha,
75
91
  config: { ...context.config, computingIdentityVersion: "validation-tree/v1", reviewNeutral: context.config.recordNeutral } });
76
92
  if (reusable) context.write("reusing preparation checks: strict validation projection, base, policy and wiring unchanged");
77
93
  const exec = createExecPort();
@@ -111,12 +127,20 @@ export const prepareCommand: CommandDescriptor = {
111
127
  })],
112
128
  };
113
129
  }
130
+ if (context.config.scopedExecution) {
131
+ capture = finalCapture;
132
+ validationDigest = await computeDeliverableIdentity({ rootDir: context.rootDir, treeSha: capture.candidate.treeSha, config: { ...context.config, computingIdentityVersion: "validation-tree/v1", reviewNeutral: context.config.recordNeutral } });
133
+ scoped = await ScopedChecks.create(context, capture.candidate);
134
+ await scoped?.satisfyMechanical();
135
+ }
114
136
  const published = await publishPreparationReceipt(
115
137
  context.rootDir,
116
138
  { config: context.config, candidate: capture.candidate, attemptId, validationDigest },
117
139
  wiring.storageOptions,
118
140
  );
119
141
  if (context.signal?.aborted) throw new CliInterruption();
142
+ await scoped?.submitMechanical();
143
+ await scoped?.cleanup();
120
144
  refreshed = true;
121
145
  // The labelled line exists so a reader — an operator or a review round
122
146
  // about to bind itself to this candidate — has one unambiguous token to
@@ -131,8 +155,9 @@ export const prepareCommand: CommandDescriptor = {
131
155
  ].join("\n"),
132
156
  };
133
157
  } finally {
134
- if (ownedAttemptId !== undefined && !refreshed) {
135
- await revokePreparationAttempt(context.rootDir, context.config, ownedAttemptId, wiring.storageOptions);
158
+ try { await scoped?.cleanup(); }
159
+ finally {
160
+ if (ownedAttemptId !== undefined && !refreshed) await revokePreparationAttempt(context.rootDir, context.config, ownedAttemptId, wiring.storageOptions);
136
161
  }
137
162
  }
138
163
  },
@@ -24,7 +24,7 @@ import {
24
24
  import path from "node:path";
25
25
  import { commandBlocker } from "../boundary.ts";
26
26
  import type { CommandContext, CommandDescriptor, CommandResult } from "../boundary.ts";
27
- import { oneLine } from "../run-surface.ts";
27
+ import { oneLine, resolveRunSpan } from "../run-surface.ts";
28
28
  import { applyDeliveryRecordRetention } from "../record-retention.ts";
29
29
  import { runProviderBackedAdmission } from "./gate.ts";
30
30
 
@@ -127,7 +127,16 @@ export const recordCommand: CommandDescriptor = {
127
127
  await computePreparationFingerprint(context.rootDir, context.config));
128
128
  const compiledPolicy = context.policyBinding?.compiledPolicy ??
129
129
  await readCompiledRepositoryPolicy(await candidateTreeEvidenceReader(context.rootDir, recheck.candidate.treeSha));
130
+ // The delivery's own span, read off the run journal rather than off a
131
+ // clock: `startedAt` is the journal's first instant and `endedAt` the last
132
+ // it had reached by this write, which is the time this delivery had spent
133
+ // when it recorded. A delivery with no journal records exactly as it always
134
+ // did — the member is simply absent — because the record is evidence about
135
+ // a candidate and this is a cycle-time reading beside it, never a condition
136
+ // on writing one.
137
+ const runSpan = await resolveRunSpan({ cwd: context.rootDir, treeSha: recheck.candidate.treeSha, allowCurrentRun: true });
130
138
  const built = buildDeliveryRecord({ config: context.config, decision, evidenceRecords, context: evidenceContext,
139
+ ...(runSpan === undefined ? {} : { runSpan: { startedAt: runSpan.startedAt, endedAt: runSpan.endedAt } }),
131
140
  ...(compiledPolicy === null ? {} : { compiledPolicy, observedAt }) });
132
141
  if (!built.ok) {
133
142
  return { kind: "blocked", blockers: [...built.blockers] };