@agent-delivery-harness/cli 0.2.0 → 0.7.3

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 (47) hide show
  1. package/LICENSE +104 -201
  2. package/NOTICE +1 -1
  3. package/package.json +5 -5
  4. package/src/boundary.ts +84 -63
  5. package/src/check-snapshot.ts +167 -0
  6. package/src/command-activity.ts +75 -0
  7. package/src/commands/admit.ts +106 -0
  8. package/src/commands/check.ts +13 -2
  9. package/src/commands/emit-review-evidence.ts +38 -0
  10. package/src/commands/emit.ts +80 -12
  11. package/src/commands/gate.ts +64 -13
  12. package/src/commands/maintain.ts +1 -0
  13. package/src/commands/managed.ts +105 -34
  14. package/src/commands/prepare.ts +148 -22
  15. package/src/commands/record.ts +107 -3
  16. package/src/commands/resume.ts +53 -0
  17. package/src/commands/review-context.ts +19 -0
  18. package/src/commands/runs.ts +461 -30
  19. package/src/commands/save-context.ts +51 -0
  20. package/src/commands/submit-evidence.ts +46 -7
  21. package/src/commands/verify.ts +156 -10
  22. package/src/declared-checks.ts +50 -0
  23. package/src/index.ts +17 -1
  24. package/src/main.ts +56 -65
  25. package/src/ordinary-context.ts +83 -0
  26. package/src/provider-rails.ts +23 -704
  27. package/src/record-retention.ts +325 -0
  28. package/src/review-evidence.ts +284 -0
  29. package/src/run-archive-commands.ts +164 -0
  30. package/src/run-archive.ts +127 -0
  31. package/src/run-artifact-commands.ts +163 -0
  32. package/src/run-attachments.ts +112 -0
  33. package/src/run-export.ts +202 -0
  34. package/src/run-live.ts +102 -0
  35. package/src/run-projection.ts +327 -12
  36. package/src/run-server.ts +403 -149
  37. package/src/run-surface.ts +196 -9
  38. package/src/run-view-command.ts +77 -0
  39. package/src/run-view-html.ts +138 -0
  40. package/src/run-view-record.ts +119 -0
  41. package/src/run-view.ts +343 -0
  42. package/src/scoped-attempts.ts +86 -0
  43. package/src/scoped-candidate.ts +28 -0
  44. package/src/scoped-checks.ts +196 -0
  45. package/src/scoped-diagnostics.ts +124 -0
  46. package/src/scoped-observations.ts +51 -0
  47. package/src/snapshot-inventory-worker.ts +40 -0
@@ -1,3 +1,7 @@
1
+ import { ScopedChecks } from "../scoped-checks.ts";
2
+ import { CheckSnapshotError } from "../check-snapshot.ts";
3
+ import { commandBlocker } from "../boundary.ts";
4
+ import { runDeclaredCheck } from "../declared-checks.ts";
1
5
  /**
2
6
  * `gate` — evaluate the delivery gate and, under a TTY, offer a scoped waiver.
3
7
  *
@@ -9,8 +13,9 @@
9
13
  * only ever offers a waiver to a `human` context, all-or-nothing over waivable
10
14
  * findings; the CLI adds no waiver logic of its own.
11
15
  */
12
- import { runAdmission, type AdmissionResult, type Blocker, type LiveProviderResult } from "@agent-delivery-harness/kernel";
16
+ import { computeDeliverableIdentity, runAdmission, type AdmissionResult, type Blocker, type LiveProviderResult } from "@agent-delivery-harness/kernel";
13
17
  import { CliInterruption, type CommandContext, type CommandDescriptor, type CommandResult } from "../boundary.ts";
18
+ import { oneLine } from "../run-surface.ts";
14
19
 
15
20
  /**
16
21
  * Runs the ordinary admission first, invokes only configured providers that can
@@ -18,10 +23,15 @@ import { CliInterruption, type CommandContext, type CommandDescriptor, type Comm
18
23
  * through the same admission adapter. Configs without provider commands take
19
24
  * the pre-existing path unchanged.
20
25
  */
21
- export async function runProviderBackedAdmission(
26
+ async function providerAdmission(
22
27
  context: CommandContext,
23
28
  options: { readonly allowPrompt: boolean; readonly includeInjectedLiveResults: boolean },
24
- ): Promise<AdmissionResult> {
29
+ session?: ScopedChecks,
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
+ }
25
35
  const wiring = await context.wire();
26
36
  const admissionOptions = {
27
37
  captureCandidate: wiring.captureCandidate,
@@ -39,14 +49,15 @@ export async function runProviderBackedAdmission(
39
49
  ...(options.allowPrompt && context.promptForWaiver !== undefined ? { promptForWaiver: context.promptForWaiver } : {}),
40
50
  };
41
51
 
42
- if (!context.config.providers.some((provider) => provider.command !== undefined)) {
43
- return runAdmission(input, finalAdmissionOptions);
52
+ if (!context.config.providers.some((provider) => provider.command !== undefined || provider.check !== undefined)) {
53
+ return admit(input, finalAdmissionOptions);
44
54
  }
45
55
 
46
56
  const liveResults: LiveProviderResult[] = options.includeInjectedLiveResults ? [...(context.liveResults ?? [])] : [];
47
57
  const attempted = new Set<string>();
48
58
  const attemptBlockers: Blocker[] = [];
49
- 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] : []));
50
61
 
51
62
  while (!admission.admitted && admission.decision !== undefined && admission.candidate !== undefined) {
52
63
  const requested = new Map<string, { obligationIds: string[]; requiresEvidence: boolean; needsLiveResult: boolean }>();
@@ -58,7 +69,8 @@ export async function runProviderBackedAdmission(
58
69
  for (const finding of resolution.providerFindings ?? []) {
59
70
  if (finding.code !== missingCode || finding.providerId === undefined || attempted.has(finding.providerId)) continue;
60
71
  const registration = context.config.providers.find((provider) => provider.id === finding.providerId);
61
- if (registration?.command === undefined || !obligation.providers.includes(registration.id)) continue;
72
+ if (registration === undefined || (registration.command === undefined && registration.check === undefined) || !obligation.providers.includes(registration.id)) continue;
73
+ if (registration.check !== undefined && (obligation.freshness !== "exact_candidate" || !obligation.acceptedPayloadSpecs.includes("checks.passed/1") || admission.decision.resolutions.some(resolution => resolution.kind !== "satisfied_evidence" && resolution.kind !== "not_applicable" && resolution.kind !== "waived" && context.config.obligations.find(entry => entry.id === resolution.obligationId)?.acceptedPayloadSpecs.includes("review.green/1")))) continue;
62
74
  const entry = requested.get(registration.id) ?? { obligationIds: [], requiresEvidence: false, needsLiveResult: false };
63
75
  entry.obligationIds.push(obligation.id);
64
76
  entry.requiresEvidence ||= obligation.freshness === "exact_candidate";
@@ -73,6 +85,18 @@ export async function runProviderBackedAdmission(
73
85
  if (next === undefined) break;
74
86
  const [providerId, request] = next;
75
87
  attempted.add(providerId);
88
+ const registration = context.config.providers.find(provider => provider.id === providerId)!;
89
+ if (registration.check !== undefined) {
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);
98
+ continue;
99
+ }
76
100
  const result = await context.invokeProvider?.({
77
101
  providerId,
78
102
  requiresEvidence: request.requiresEvidence,
@@ -94,27 +118,45 @@ export async function runProviderBackedAdmission(
94
118
  liveResults.push(result.liveResult);
95
119
  }
96
120
 
97
- admission = await runAdmission(
121
+ admission = await admit(
98
122
  { ...input, ...(liveResults.length === 0 ? {} : { liveResults }) },
99
123
  admissionOptions,
100
124
  );
101
125
  }
102
126
 
103
- if (admission.admitted) return admission;
104
- const final = await runAdmission(
127
+ if (admission.admitted) return { ...admission, observedLiveResults: liveResults };
128
+ const final = await admit(
105
129
  { ...input, ...(liveResults.length === 0 ? {} : { liveResults }) },
106
130
  finalAdmissionOptions,
107
131
  );
108
132
  return attemptBlockers.length === 0 || final.admitted
109
- ? final
110
- : { ...final, blockers: [...attemptBlockers, ...final.blockers] };
133
+ ? { ...final, observedLiveResults: liveResults }
134
+ : { ...final, observedLiveResults: liveResults, blockers: [...attemptBlockers, ...final.blockers] };
135
+ }
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(); }
111
146
  }
112
147
 
113
148
  export const gateCommand: CommandDescriptor = {
114
149
  name: "gate",
115
150
  sourceId: "delivery-harness.cli.gate",
116
151
  summary: "Evaluate the delivery gate for the current candidate.",
152
+ usage: "Usage: delivery-harness gate\nTakes no arguments; a waiver is offered only under a real TTY.",
117
153
  async run(context: CommandContext): Promise<CommandResult> {
154
+ // Arguments before admission: `gate` accepts none, and a call it cannot
155
+ // honour is answered about the call rather than performed approximately.
156
+ const unexpected = context.args[0];
157
+ if (unexpected !== undefined) {
158
+ return { kind: "usage", message: `gate takes no arguments, and ${oneLine(unexpected, 64)} is one.\n${gateCommand.usage}` };
159
+ }
118
160
  const result = await runProviderBackedAdmission(context, { allowPrompt: true, includeInjectedLiveResults: true });
119
161
 
120
162
  if (result.admitted) {
@@ -123,7 +165,16 @@ export const gateCommand: CommandDescriptor = {
123
165
  ? ` (waived: ${result.waivedObligationIds.join(", ")})`
124
166
  : "";
125
167
  const kinds = (result.decision?.resolutions ?? []).map((resolution) => `${resolution.obligationId}=${resolution.kind}`);
126
- return { kind: "ok", summary: `admitted${waiverNote}: ${kinds.join(", ")}` };
168
+ // This observation describes the admitted immutable tree. Failure to
169
+ // observe it cannot change admission; the run journal is not evidence.
170
+ let digest: string | undefined;
171
+ if (result.candidate !== undefined) {
172
+ try {
173
+ digest = await computeDeliverableIdentity({ rootDir: context.rootDir, treeSha: result.candidate.treeSha,
174
+ config: { ...context.config, computingIdentityVersion: "validation-tree/v1", reviewNeutral: context.config.recordNeutral } });
175
+ } catch { /* Best-effort observability, like the completion append. */ }
176
+ }
177
+ return { kind: "ok", summary: `admitted${waiverNote}: ${kinds.join(", ")}`, ...(digest === undefined ? {} : { digest }) };
127
178
  }
128
179
  return { kind: "blocked", blockers: [...result.blockers] };
129
180
  },
@@ -129,6 +129,7 @@ export const maintainCommand: CommandDescriptor = {
129
129
  name: "maintain",
130
130
  sourceId: SOURCE_ID,
131
131
  summary: "Maintain the product installation (update, rollback, trust-state pin/revoke/unrevoke/high-water-mark).",
132
+ usage: `Usage: delivery-harness maintain <operation> [options]\nOperations: ${MAINTAIN_OPERATIONS.join(" | ")}`,
132
133
  async run(context: CommandContext): Promise<CommandResult> {
133
134
  const [operation, ...rest] = context.args;
134
135
  if (operation === undefined) {
@@ -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,12 +260,13 @@ 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",
229
267
  sourceId: SOURCE_ID,
230
268
  summary: "Drive the managed delivery's next checkpoint (status, stages, sensor, review, admission, record, finish).",
269
+ usage: `Usage: delivery-harness managed <operation> [options]\nOperations: ${MANAGED_OPERATIONS.join(" | ")}`,
231
270
  async run(context: CommandContext): Promise<CommandResult> {
232
271
  const [operation, ...rest] = context.args;
233
272
  if (operation === undefined) {
@@ -254,6 +293,38 @@ export const managedCommand: CommandDescriptor = {
254
293
  return { kind: "ok", summary: `${FACADE_OPERATIONS.length} facade operations` };
255
294
  }
256
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
+
257
328
  // Only the retention operations may name a delivery; everything else binds
258
329
  // the fence of the worktree it runs in.
259
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
  *
@@ -5,34 +7,158 @@
5
7
  * admission. Capturing an unprepared tree (dirty or with untracked files) is a
6
8
  * typed block, not a silent skip.
7
9
  */
8
- import { publishPreparationReceipt } from "@agent-delivery-harness/kernel";
9
- import type { CommandContext, CommandDescriptor, CommandResult } from "../boundary.ts";
10
+ import {
11
+ classifyCandidateDrift,
12
+ computePreparationFingerprint,
13
+ computeDeliverableIdentity,
14
+ evaluatePreparationReceipt,
15
+ createBlocker,
16
+ createExecPort,
17
+ invalidatePreparationReceipt,
18
+ revokePreparationAttempt,
19
+ publishPreparationReceipt,
20
+ type RunPreparationObservation,
21
+ } from "@agent-delivery-harness/kernel";
22
+ import { CliInterruption, type CommandContext, type CommandDescriptor, type CommandResult } from "../boundary.ts";
23
+
24
+ const USAGE = "Usage: delivery-harness prepare [--refresh-record-neutral]";
10
25
 
11
26
  export const prepareCommand: CommandDescriptor = {
12
27
  name: "prepare",
13
28
  sourceId: "delivery-harness.cli.prepare",
14
- summary: "Capture the candidate and publish its preparation receipt.",
29
+ summary: "Run preparation checks; --refresh-record-neutral permits proven artifact-only receipt refresh.",
30
+ // The text prepare has always answered `--help` with, unchanged; the
31
+ // boundary is what prints it now, for every command rather than this one.
32
+ usage: `${USAGE}\nOrdinary prepare always runs mechanical checks. The refresh flag reuses prior success only when strict validation, policy, wiring and base are unchanged; otherwise it runs the checks.`,
15
33
  async run(context: CommandContext): Promise<CommandResult> {
34
+ if (context.args.length > 1 || (context.args.length === 1 && context.args[0] !== "--refresh-record-neutral")) {
35
+ return { kind: "usage", message: USAGE };
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;
39
+ const refreshRecordNeutral = context.args[0] === "--refresh-record-neutral";
16
40
  const wiring = await context.wire();
17
- const capture = await wiring.captureCandidate();
18
- if (!capture.ok) {
19
- return { kind: "blocked", blockers: [...capture.blockers] };
41
+ let retainedAttemptId: string | undefined;
42
+ let ownedAttemptId: string | undefined;
43
+ let refreshed = false;
44
+ try {
45
+ let capture: Awaited<ReturnType<typeof wiring.captureCandidate>>;
46
+ // A refresh continues the successful attempt it evaluated. It cannot
47
+ // transfer that success to new ownership after another attempt revokes it.
48
+ let reusableFingerprint: string | undefined;
49
+ let attemptId: string;
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
+ }
63
+ capture = await wiring.captureCandidate();
64
+ if (capture.ok && refreshRecordNeutral) {
65
+ const previous = await evaluatePreparationReceipt(context.rootDir,
66
+ { config: context.config, candidate: capture.candidate }, { ...wiring.storageOptions, allowValidationEquivalent: true });
67
+ if (previous.prepared && previous.receipt.attemptId !== undefined) {
68
+ reusableFingerprint = previous.receipt.preparationFingerprint;
69
+ retainedAttemptId = previous.receipt.attemptId;
70
+ }
71
+ }
72
+ } finally {
73
+ attemptId = retainedAttemptId ?? ownedAttemptId ?? await invalidatePreparationReceipt(context.rootDir, context.config, wiring.storageOptions);
74
+ ownedAttemptId = attemptId;
75
+ }
76
+ if (!capture.ok) {
77
+ return { kind: "blocked", blockers: [...capture.blockers] };
78
+ }
79
+ const fingerprint = await computePreparationFingerprint(context.rootDir, context.config, wiring.storageOptions);
80
+ const reusable = reusableFingerprint === fingerprint;
81
+ const preparation: RunPreparationObservation = reusable
82
+ ? { checks: "reused", reason: "validation-equivalent" }
83
+ : { checks: "executed", reason: !refreshRecordNeutral ? "ordinary"
84
+ : retainedAttemptId === undefined ? "receipt-not-reusable" : "preparation-fingerprint-changed" };
85
+ if (!reusable && retainedAttemptId !== undefined) {
86
+ attemptId = await invalidatePreparationReceipt(context.rootDir, context.config, wiring.storageOptions);
87
+ ownedAttemptId = attemptId;
88
+ retainedAttemptId = undefined;
89
+ }
90
+ let validationDigest = await computeDeliverableIdentity({ rootDir: context.rootDir, treeSha: capture.candidate.treeSha,
91
+ config: { ...context.config, computingIdentityVersion: "validation-tree/v1", reviewNeutral: context.config.recordNeutral } });
92
+ if (reusable) context.write("reusing preparation checks: strict validation projection, base, policy and wiring unchanged");
93
+ const exec = createExecPort();
94
+ const env = Object.fromEntries(Object.entries(context.env).filter((entry): entry is [string, string] => entry[1] !== undefined));
95
+ for (const check of reusable ? [] : context.config.preparationCommands ?? []) {
96
+ context.write(`preparing ${check.id}`);
97
+ const result = await exec.run({
98
+ command: check.command[0], args: check.command.slice(1), cwd: context.rootDir,
99
+ env, timeoutMs: check.timeoutMs, maxBuffer: 1024 * 1024,
100
+ ...(context.signal === undefined ? {} : { signal: context.signal }),
101
+ });
102
+ if (result.code !== 0 || context.signal?.aborted) {
103
+ return {
104
+ kind: "blocked",
105
+ blockers: [createBlocker({
106
+ code: "preparation_command_failed", source: { kind: "command", id: "delivery-harness.cli.prepare" },
107
+ summary: `Preparation check ${check.id} failed; no receipt was published.`,
108
+ details: `exit ${result.code}${result.errorCode === undefined ? "" : ` (${result.errorCode})`}\n${`${result.stdout}\n${result.stderr}`.slice(-4000)}`,
109
+ remediations: [{ id: "repair-preparation-check", kind: "manual_action", summary: `Fix ${check.id}, then run prepare again.` }],
110
+ })],
111
+ };
112
+ }
113
+ }
114
+ if (context.signal?.aborted) throw new CliInterruption();
115
+ const finalCapture = await wiring.captureCandidate();
116
+ if (!finalCapture.ok) return { kind: "blocked", blockers: [...finalCapture.blockers] };
117
+ const after = finalCapture.candidate;
118
+ if (classifyCandidateDrift(capture.candidate, after).length > 0 ||
119
+ capture.candidate.headSha !== after.headSha || capture.candidate.mode !== after.mode ||
120
+ fingerprint !== await computePreparationFingerprint(context.rootDir, context.config, wiring.storageOptions)) {
121
+ return {
122
+ kind: "blocked",
123
+ blockers: [createBlocker({
124
+ code: "preparation_candidate_changed", source: { kind: "command", id: "delivery-harness.cli.prepare" },
125
+ summary: "The candidate, base, or preparation wiring changed while checks ran; no receipt was published.",
126
+ remediations: [{ id: "prepare-stable-candidate", kind: "manual_action", summary: "Commit any intended changes and prepare the stable candidate again." }],
127
+ })],
128
+ };
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
+ }
136
+ const published = await publishPreparationReceipt(
137
+ context.rootDir,
138
+ { config: context.config, candidate: capture.candidate, attemptId, validationDigest },
139
+ wiring.storageOptions,
140
+ );
141
+ if (context.signal?.aborted) throw new CliInterruption();
142
+ await scoped?.submitMechanical();
143
+ await scoped?.cleanup();
144
+ refreshed = true;
145
+ // The labelled line exists so a reader — an operator or a review round
146
+ // about to bind itself to this candidate — has one unambiguous token to
147
+ // copy. It is the same value the record carries as `treeSha` and the same
148
+ // value `review-context` reports as `candidate tree`.
149
+ return {
150
+ kind: "ok",
151
+ preparation,
152
+ summary: [
153
+ `prepared ${context.config.gateId}: tree ${capture.candidate.treeSha} (${capture.candidate.mode}); receipt ${published.path}`,
154
+ ` treeSha ${capture.candidate.treeSha}`,
155
+ ].join("\n"),
156
+ };
157
+ } finally {
158
+ try { await scoped?.cleanup(); }
159
+ finally {
160
+ if (ownedAttemptId !== undefined && !refreshed) await revokePreparationAttempt(context.rootDir, context.config, ownedAttemptId, wiring.storageOptions);
161
+ }
20
162
  }
21
- const published = await publishPreparationReceipt(
22
- context.rootDir,
23
- { config: context.config, candidate: capture.candidate },
24
- wiring.storageOptions,
25
- );
26
- // The labelled line exists so a reader — an operator or a review round
27
- // about to bind itself to this candidate — has one unambiguous token to
28
- // copy. It is the same value the record carries as `treeSha` and the same
29
- // value `review-context` reports as `candidate tree`.
30
- return {
31
- kind: "ok",
32
- summary: [
33
- `prepared ${context.config.gateId}: tree ${capture.candidate.treeSha} (${capture.candidate.mode}); receipt ${published.path}`,
34
- ` treeSha ${capture.candidate.treeSha}`,
35
- ].join("\n"),
36
- };
37
163
  },
38
164
  };