@agent-delivery-harness/cli 0.7.3 → 0.8.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.
@@ -2,6 +2,7 @@ import { ScopedChecks } from "../scoped-checks.ts";
2
2
  import { CheckSnapshotError } from "../check-snapshot.ts";
3
3
  import { commandBlocker } from "../boundary.ts";
4
4
  import { runDeclaredCheck } from "../declared-checks.ts";
5
+ import { refuseOverBudget } from "../scoped-budget.ts";
5
6
  /**
6
7
  * `gate` — evaluate the delivery gate and, under a TTY, offer a scoped waiver.
7
8
  *
@@ -15,8 +16,11 @@ import { runDeclaredCheck } from "../declared-checks.ts";
15
16
  */
16
17
  import { computeDeliverableIdentity, runAdmission, type AdmissionResult, type Blocker, type LiveProviderResult } from "@agent-delivery-harness/kernel";
17
18
  import { CliInterruption, type CommandContext, type CommandDescriptor, type CommandResult } from "../boundary.ts";
19
+ import { focusedOutcome } from "../delivery-posture.ts";
18
20
  import { oneLine } from "../run-surface.ts";
19
21
 
22
+ const scopedCheckBlocker = (error: CheckSnapshotError): Blocker => 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." }] });
23
+
20
24
  /**
21
25
  * Runs the ordinary admission first, invokes only configured providers that can
22
26
  * answer the resulting missing-evidence/live-result blocks, then re-evaluates
@@ -28,7 +32,7 @@ async function providerAdmission(
28
32
  options: { readonly allowPrompt: boolean; readonly includeInjectedLiveResults: boolean },
29
33
  session?: ScopedChecks,
30
34
  ): 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 } : {}) });
35
+ const admit = async (input: Parameters<typeof runAdmission>[0], options: Parameters<typeof runAdmission>[1]) => runAdmission(input, { ...options, ...(session ? { scopedPlan: await session.plan(), readOutput: session.readOutput, resourceBindings: await session.resourceBindings() } : {}) });
32
36
  if (session) {
33
37
  await session.fenceNonReusable(context.config.providers.filter(p => p.check?.scope).map(p => p.id));
34
38
  }
@@ -58,8 +62,11 @@ async function providerAdmission(
58
62
  const attemptBlockers: Blocker[] = [];
59
63
  let admission = await admit(input, admissionOptions);
60
64
  await session?.explainReuse((admission.decision?.resolutions ?? []).flatMap(r => r.kind === "satisfied_evidence" ? [r.providerId] : []));
65
+ // Under `scopedExecution.failFast` the first failing scoped check ends the run of checks: nothing further starts.
66
+ const failFast = context.config.scopedExecution?.failFast === true;
67
+ let failedFast = false;
61
68
 
62
- while (!admission.admitted && admission.decision !== undefined && admission.candidate !== undefined) {
69
+ while (!failedFast && !admission.admitted && admission.decision !== undefined && admission.candidate !== undefined) {
63
70
  const requested = new Map<string, { obligationIds: string[]; requiresEvidence: boolean; needsLiveResult: boolean }>();
64
71
  for (const resolution of admission.decision.resolutions) {
65
72
  if (resolution.kind !== "blocked") continue;
@@ -70,7 +77,7 @@ async function providerAdmission(
70
77
  if (finding.code !== missingCode || finding.providerId === undefined || attempted.has(finding.providerId)) continue;
71
78
  const registration = context.config.providers.find((provider) => provider.id === finding.providerId);
72
79
  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;
80
+ 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.some(spec => spec === "review.green/1" || spec === "review.green/2")))) continue;
74
81
  const entry = requested.get(registration.id) ?? { obligationIds: [], requiresEvidence: false, needsLiveResult: false };
75
82
  entry.obligationIds.push(obligation.id);
76
83
  entry.requiresEvidence ||= obligation.freshness === "exact_candidate";
@@ -79,6 +86,16 @@ async function providerAdmission(
79
86
  }
80
87
  }
81
88
 
89
+ // A pool runs every requested scoped check at once; a single worker keeps the one-at-a-time loop below.
90
+ const scoped = session !== undefined && session.concurrency > 1 ? [...requested].filter(([id]) => context.config.providers.find(p => p.id === id)!.check?.scope) : [];
91
+ if (session !== undefined && scoped.length > 0) {
92
+ for (const [id] of scoped) attempted.add(id);
93
+ const failures = await session.executeAll(scoped.map(([id, request]) => ({ provider: context.config.providers.find(p => p.id === id)!, obligationIds: request.obligationIds })));
94
+ attemptBlockers.push(...failures.map(scopedCheckBlocker));
95
+ failedFast = failFast && failures.length > 0;
96
+ admission = await admit({ ...input, ...(liveResults.length === 0 ? {} : { liveResults }) }, admissionOptions);
97
+ continue;
98
+ }
82
99
  const next = requested.entries().next().value as
83
100
  | [string, { obligationIds: string[]; requiresEvidence: boolean; needsLiveResult: boolean }]
84
101
  | undefined;
@@ -91,9 +108,13 @@ async function providerAdmission(
91
108
  try { await session.execute(registration, request.obligationIds); }
92
109
  catch (error) {
93
110
  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." }] }));
111
+ attemptBlockers.push(scopedCheckBlocker(error));
112
+ if (failFast) {
113
+ failedFast = true;
114
+ await session.cancelUnstarted([...requested.keys()].filter(id => !attempted.has(id) && context.config.providers.find(p => p.id === id)!.check?.scope), providerId);
115
+ }
95
116
  }
96
- } else attemptBlockers.push(...await runDeclaredCheck(context, registration, request.obligationIds, admission.candidate));
117
+ } else attemptBlockers.push(...await runDeclaredCheck(context, registration, request.obligationIds, admission.candidate, session ? { scopedPlan: await session.plan(), readOutput: session.readOutput, resourceBindings: await session.resourceBindings() } : {}));
97
118
  admission = await admit({ ...input, ...(liveResults.length === 0 ? {} : { liveResults }) }, admissionOptions);
98
119
  continue;
99
120
  }
@@ -135,6 +156,8 @@ async function providerAdmission(
135
156
  }
136
157
 
137
158
  export async function runProviderBackedAdmission(context: CommandContext, options: { readonly allowPrompt: boolean; readonly includeInjectedLiveResults: boolean }): Promise<AdmissionResult & { readonly observedLiveResults?: readonly LiveProviderResult[] }> {
159
+ // Before any capture or check: a selection over its declared budget runs nothing.
160
+ refuseOverBudget(context.config);
138
161
  let session: ScopedChecks | undefined;
139
162
  try {
140
163
  if (context.config.providers.some(p => p.check?.scope)) {
@@ -145,7 +168,35 @@ export async function runProviderBackedAdmission(context: CommandContext, option
145
168
  } finally { await session?.cleanup(); }
146
169
  }
147
170
 
171
+ /**
172
+ * The gate itself, with no posture. `admit` runs this directly, because it keeps
173
+ * its behaviour under either posture: only the `gate` command honours `focused`.
174
+ */
175
+ export async function runGate(context: CommandContext): Promise<CommandResult> {
176
+ const result = await runProviderBackedAdmission(context, { allowPrompt: true, includeInjectedLiveResults: true });
177
+
178
+ if (result.admitted) {
179
+ const waiverNote =
180
+ result.waiver === "accepted"
181
+ ? ` (waived: ${result.waivedObligationIds.join(", ")})`
182
+ : "";
183
+ const kinds = (result.decision?.resolutions ?? []).map((resolution) => `${resolution.obligationId}=${resolution.kind}`);
184
+ // This observation describes the admitted immutable tree. Failure to
185
+ // observe it cannot change admission; the run journal is not evidence.
186
+ let digest: string | undefined;
187
+ if (result.candidate !== undefined) {
188
+ try {
189
+ digest = await computeDeliverableIdentity({ rootDir: context.rootDir, treeSha: result.candidate.treeSha,
190
+ config: { ...context.config, computingIdentityVersion: "validation-tree/v1", reviewNeutral: context.config.recordNeutral } });
191
+ } catch { /* Best-effort observability, like the completion append. */ }
192
+ }
193
+ return { kind: "ok", summary: `admitted${waiverNote}: ${kinds.join(", ")}`, ...(digest === undefined ? {} : { digest }) };
194
+ }
195
+ return { kind: "blocked", blockers: [...result.blockers] };
196
+ }
197
+
148
198
  export const gateCommand: CommandDescriptor = {
199
+ capturesCandidate: true,
149
200
  name: "gate",
150
201
  sourceId: "delivery-harness.cli.gate",
151
202
  summary: "Evaluate the delivery gate for the current candidate.",
@@ -157,25 +208,9 @@ export const gateCommand: CommandDescriptor = {
157
208
  if (unexpected !== undefined) {
158
209
  return { kind: "usage", message: `gate takes no arguments, and ${oneLine(unexpected, 64)} is one.\n${gateCommand.usage}` };
159
210
  }
160
- const result = await runProviderBackedAdmission(context, { allowPrompt: true, includeInjectedLiveResults: true });
211
+ const focused = await focusedOutcome(context);
212
+ if (focused !== undefined) return focused;
161
213
 
162
- if (result.admitted) {
163
- const waiverNote =
164
- result.waiver === "accepted"
165
- ? ` (waived: ${result.waivedObligationIds.join(", ")})`
166
- : "";
167
- const kinds = (result.decision?.resolutions ?? []).map((resolution) => `${resolution.obligationId}=${resolution.kind}`);
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 }) };
178
- }
179
- return { kind: "blocked", blockers: [...result.blockers] };
214
+ return runGate(context);
180
215
  },
181
216
  };
@@ -1,5 +1,6 @@
1
1
  import { ScopedChecks } from "../scoped-checks.ts";
2
2
  import { CheckSnapshotError } from "../check-snapshot.ts";
3
+ import { refuseOverBudget } from "../scoped-budget.ts";
3
4
  /**
4
5
  * `prepare` — capture the candidate and publish its preparation receipt.
5
6
  *
@@ -24,17 +25,19 @@ import { CliInterruption, type CommandContext, type CommandDescriptor, type Comm
24
25
  const USAGE = "Usage: delivery-harness prepare [--refresh-record-neutral]";
25
26
 
26
27
  export const prepareCommand: CommandDescriptor = {
28
+ capturesCandidate: true,
27
29
  name: "prepare",
28
30
  sourceId: "delivery-harness.cli.prepare",
29
31
  summary: "Run preparation checks; --refresh-record-neutral permits proven artifact-only receipt refresh.",
30
32
  // The text prepare has always answered `--help` with, unchanged; the
31
33
  // 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.`,
34
+ 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.\nScoped mechanical checks run the first declared check first and the rest cheapest first by recorded duration, one dependency setup at a time, stopping at the first failure.`,
33
35
  async run(context: CommandContext): Promise<CommandResult> {
34
36
  if (context.args.length > 1 || (context.args.length === 1 && context.args[0] !== "--refresh-record-neutral")) {
35
37
  return { kind: "usage", message: USAGE };
36
38
  }
37
39
  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.");
40
+ refuseOverBudget(context.config);
38
41
  let scoped: ScopedChecks | undefined;
39
42
  const refreshRecordNeutral = context.args[0] === "--refresh-record-neutral";
40
43
  const wiring = await context.wire();
@@ -48,7 +51,16 @@ export const prepareCommand: CommandDescriptor = {
48
51
  let reusableFingerprint: string | undefined;
49
52
  let attemptId: string;
50
53
  try {
51
- if (context.config.scopedExecution?.repairCommands?.length) {
54
+ capture = await wiring.captureCandidate();
55
+ if (capture.ok && refreshRecordNeutral) {
56
+ const previous = await evaluatePreparationReceipt(context.rootDir,
57
+ { config: context.config, candidate: capture.candidate }, { ...wiring.storageOptions, allowValidationEquivalent: true });
58
+ if (previous.prepared && previous.receipt.attemptId !== undefined) {
59
+ reusableFingerprint = previous.receipt.preparationFingerprint;
60
+ retainedAttemptId = previous.receipt.attemptId;
61
+ }
62
+ }
63
+ if (retainedAttemptId === undefined && context.config.scopedExecution?.repairCommands?.length) {
52
64
  ownedAttemptId = await invalidatePreparationReceipt(context.rootDir, context.config, wiring.storageOptions);
53
65
  const beforeRepair = await computePreparationFingerprint(context.rootDir, context.config, wiring.storageOptions);
54
66
  for (const repair of context.config.scopedExecution.repairCommands) {
@@ -59,15 +71,7 @@ export const prepareCommand: CommandDescriptor = {
59
71
  if (result.code !== 0) throw new CheckSnapshotError("preparation_repair_failed", `Source repair ${repair.id} failed; fix it and prepare again.`);
60
72
  }
61
73
  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
- }
74
+ capture = await wiring.captureCandidate();
71
75
  }
72
76
  } finally {
73
77
  attemptId = retainedAttemptId ?? ownedAttemptId ?? await invalidatePreparationReceipt(context.rootDir, context.config, wiring.storageOptions);
@@ -86,6 +90,7 @@ export const prepareCommand: CommandDescriptor = {
86
90
  attemptId = await invalidatePreparationReceipt(context.rootDir, context.config, wiring.storageOptions);
87
91
  ownedAttemptId = attemptId;
88
92
  retainedAttemptId = undefined;
93
+ if (context.config.scopedExecution?.repairCommands?.length) throw new CheckSnapshotError("preparation_candidate_changed", "Preparation wiring changed after repairs were skipped; reload the configuration and prepare again.");
89
94
  }
90
95
  let validationDigest = await computeDeliverableIdentity({ rootDir: context.rootDir, treeSha: capture.candidate.treeSha,
91
96
  config: { ...context.config, computingIdentityVersion: "validation-tree/v1", reviewNeutral: context.config.recordNeutral } });
@@ -130,8 +135,12 @@ export const prepareCommand: CommandDescriptor = {
130
135
  if (context.config.scopedExecution) {
131
136
  capture = finalCapture;
132
137
  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();
138
+ if (context.config.scopedExecution.mechanicalProviders.length === 0) {
139
+ await ScopedChecks.validatePreparation(context, capture.candidate);
140
+ } else {
141
+ scoped = await ScopedChecks.create(context, capture.candidate);
142
+ await scoped?.satisfyMechanical();
143
+ }
135
144
  }
136
145
  const published = await publishPreparationReceipt(
137
146
  context.rootDir,
@@ -23,6 +23,7 @@ import {
23
23
  } from "@agent-delivery-harness/kernel";
24
24
  import path from "node:path";
25
25
  import { commandBlocker } from "../boundary.ts";
26
+ import { focusedOutcome } from "../delivery-posture.ts";
26
27
  import type { CommandContext, CommandDescriptor, CommandResult } from "../boundary.ts";
27
28
  import { oneLine, resolveRunSpan } from "../run-surface.ts";
28
29
  import { applyDeliveryRecordRetention } from "../record-retention.ts";
@@ -59,139 +60,152 @@ function parseRetentionOptions(args: readonly string[]): RetentionOptions | unde
59
60
  return { scope, keepSuperseded };
60
61
  }
61
62
 
63
+ /**
64
+ * The write itself, with no posture. `admit` runs this directly, because it
65
+ * keeps its behaviour under either posture: only the `record` command honours
66
+ * `focused`.
67
+ */
68
+ export async function writeRecord(context: CommandContext, retention?: RetentionOptions): Promise<CommandResult> {
69
+ const observedAt = `${new Date().toISOString().slice(0, 19)}Z`;
70
+ const wiring = await context.wire();
71
+
72
+ // The gate is run without a prompt: `record` is not the waiver surface. If a
73
+ // waiver is needed, the operator runs `gate` first; here a non-admitting gate
74
+ // is simply a refusal.
75
+ const admission = await runProviderBackedAdmission(context, { allowPrompt: false, includeInjectedLiveResults: false });
76
+ if (!admission.admitted || admission.decision === undefined) {
77
+ return { kind: "blocked", blockers: [...admission.blockers] };
78
+ }
79
+ const decision = admission.decision;
80
+
81
+ // Refuse when the gate result no longer describes the current deliverable
82
+ // identity: a re-capture adjacent to the write.
83
+ const recheck = await wiring.captureCandidate();
84
+ if (!recheck.ok) {
85
+ return { kind: "blocked", blockers: [...recheck.blockers] };
86
+ }
87
+ if (recheck.candidate.deliverable.digest !== decision.candidate.deliverable.digest) {
88
+ return {
89
+ kind: "blocked",
90
+ blockers: [
91
+ commandBlocker({
92
+ code: "record_identity_changed",
93
+ sourceId: "delivery-harness.cli.record",
94
+ summary: "The deliverable identity changed after the gate; nothing was recorded.",
95
+ details: `gate ${decision.candidate.deliverable.digest} but current ${recheck.candidate.deliverable.digest}`,
96
+ remediations: [
97
+ {
98
+ id: "reprepare-and-record",
99
+ kind: "command",
100
+ command: ["delivery-harness", "prepare"],
101
+ summary: "Re-prepare the candidate and re-run the gate before recording.",
102
+ },
103
+ ],
104
+ }),
105
+ ],
106
+ };
107
+ }
108
+
109
+ // Gather the evidence records backing the decision so each evidence claim can
110
+ // be stamped with its manifest digest.
111
+ const evidenceRecords: EvidenceRecord[] = [];
112
+ for (const obligation of context.config.obligations) {
113
+ const discovery = await discoverRecords(context.rootDir, {
114
+ gateId: context.config.gateId,
115
+ obligationId: obligation.id,
116
+ ...wiring.storageOptions,
117
+ });
118
+ evidenceRecords.push(...discovery.records);
119
+ }
120
+
121
+ const evidenceReader = repositoryEvidenceReader(context.rootDir, context.artifacts);
122
+ const evidenceContext = await capturePortableEvidenceContext(context.config, evidenceReader,
123
+ await computePreparationFingerprint(context.rootDir, context.config), recheck.candidate.deliverable.digest);
124
+ const compiledPolicy = context.policyBinding?.compiledPolicy ??
125
+ await readCompiledRepositoryPolicy(await candidateTreeEvidenceReader(context.rootDir, recheck.candidate.treeSha));
126
+ // The delivery's own span, read off the run journal rather than off a
127
+ // clock: `startedAt` is the journal's first instant and `endedAt` the last
128
+ // it had reached by this write, which is the time this delivery had spent
129
+ // when it recorded. A delivery with no journal records exactly as it always
130
+ // did — the member is simply absent — because the record is evidence about
131
+ // a candidate and this is a cycle-time reading beside it, never a condition
132
+ // on writing one.
133
+ const runSpan = await resolveRunSpan({ cwd: context.rootDir, treeSha: recheck.candidate.treeSha, allowCurrentRun: true });
134
+ const built = buildDeliveryRecord({ config: context.config, decision, evidenceRecords, context: evidenceContext,
135
+ ...(runSpan === undefined ? {} : { runSpan: { startedAt: runSpan.startedAt, endedAt: runSpan.endedAt } }),
136
+ ...(compiledPolicy === null ? {} : { compiledPolicy, observedAt }) });
137
+ if (!built.ok) {
138
+ return { kind: "blocked", blockers: [...built.blockers] };
139
+ }
140
+
141
+ const verificationInputs = await capturePortableVerificationInputs(context.rootDir, context.config, recheck.candidate, built.record);
142
+ const checked = verifyDeliveryRecord(context.config, built.record,
143
+ { deliverableDigest: recheck.candidate.deliverable.digest, identityToken: recheck.candidate.deliverable.identity }, recheck.candidate.base,
144
+ { ...verificationInputs, observedAt, liveResults: admission.observedLiveResults ?? [], executionContext: context.classifyContext() });
145
+ if (!checked.ok) return { kind: "blocked", blockers: [...checked.blockers] };
146
+ const relativePath = deliveryRecordPathFor(context.config, decision.candidate.deliverable.digest);
147
+ const absolutePath = path.join(context.rootDir, relativePath);
148
+ const bytes = deliveryRecordBytes(built.record);
149
+ if (retention === undefined) {
150
+ await context.artifacts.writeTextFile(absolutePath, bytes);
151
+ return { kind: "ok", summary: `recorded ${relativePath}` };
152
+ }
153
+
154
+ const retained = await applyDeliveryRecordRetention({
155
+ rootDir: context.rootDir,
156
+ storageNamespace: context.config.storageNamespace,
157
+ scope: retention.scope,
158
+ keepSuperseded: retention.keepSuperseded,
159
+ recordBasePath: context.config.deliveryRecordPath,
160
+ current: {
161
+ relativePath,
162
+ deliverableDigest: decision.candidate.deliverable.digest,
163
+ bytes,
164
+ },
165
+ writeCurrent: () => context.artifacts.writeTextFile(absolutePath, bytes),
166
+ });
167
+ if (!retained.ok) {
168
+ return {
169
+ kind: "blocked",
170
+ blockers: [
171
+ commandBlocker({
172
+ code: retained.code,
173
+ sourceId: "delivery-harness.cli.record",
174
+ summary: "Tracked delivery-record retention did not complete.",
175
+ details: retained.detail,
176
+ remediations: [
177
+ {
178
+ id: "repair-record-retention",
179
+ kind: "manual_action",
180
+ summary: "Preserve the exact owned record bytes in Git, repair the named ownership conflict, then retry record with the same scope and bound.",
181
+ },
182
+ ],
183
+ }),
184
+ ],
185
+ };
186
+ }
187
+
188
+ return {
189
+ kind: "ok",
190
+ summary: `recorded ${relativePath}${retained.pruned.length === 0 ? "" : `; pruned Git-preserved ${retained.pruned.join(", ")}`}`,
191
+ };
192
+ }
193
+
62
194
  export const recordCommand: CommandDescriptor = {
195
+ capturesCandidate: true,
63
196
  name: "record",
64
197
  sourceId: "delivery-harness.cli.record",
65
198
  summary: "Write the tracked delivery record for an admitted gate.",
66
199
  usage: "Usage: delivery-harness record [--retention-scope <delivery-key> --keep-superseded <0-100>]\nRetention is opt-in; run gate first if a waiver is needed.",
67
200
  async run(context: CommandContext): Promise<CommandResult> {
68
- const observedAt = `${new Date().toISOString().slice(0, 19)}Z`;
69
201
  // Arguments before anything is wired, admitted, or written.
70
202
  const retention = parseRetentionOptions(context.args);
71
203
  if (typeof retention === "string") {
72
204
  return { kind: "usage", message: `${retention}.\n${recordCommand.usage}` };
73
205
  }
74
- const wiring = await context.wire();
75
-
76
- // The gate is run without a prompt: `record` is not the waiver surface. If a
77
- // waiver is needed, the operator runs `gate` first; here a non-admitting gate
78
- // is simply a refusal.
79
- const admission = await runProviderBackedAdmission(context, { allowPrompt: false, includeInjectedLiveResults: false });
80
- if (!admission.admitted || admission.decision === undefined) {
81
- return { kind: "blocked", blockers: [...admission.blockers] };
82
- }
83
- const decision = admission.decision;
84
-
85
- // Refuse when the gate result no longer describes the current deliverable
86
- // identity: a re-capture adjacent to the write.
87
- const recheck = await wiring.captureCandidate();
88
- if (!recheck.ok) {
89
- return { kind: "blocked", blockers: [...recheck.blockers] };
90
- }
91
- if (recheck.candidate.deliverable.digest !== decision.candidate.deliverable.digest) {
92
- return {
93
- kind: "blocked",
94
- blockers: [
95
- commandBlocker({
96
- code: "record_identity_changed",
97
- sourceId: "delivery-harness.cli.record",
98
- summary: "The deliverable identity changed after the gate; nothing was recorded.",
99
- details: `gate ${decision.candidate.deliverable.digest} but current ${recheck.candidate.deliverable.digest}`,
100
- remediations: [
101
- {
102
- id: "reprepare-and-record",
103
- kind: "command",
104
- command: ["delivery-harness", "prepare"],
105
- summary: "Re-prepare the candidate and re-run the gate before recording.",
106
- },
107
- ],
108
- }),
109
- ],
110
- };
111
- }
112
-
113
- // Gather the evidence records backing the decision so each evidence claim can
114
- // be stamped with its manifest digest.
115
- const evidenceRecords: EvidenceRecord[] = [];
116
- for (const obligation of context.config.obligations) {
117
- const discovery = await discoverRecords(context.rootDir, {
118
- gateId: context.config.gateId,
119
- obligationId: obligation.id,
120
- ...wiring.storageOptions,
121
- });
122
- evidenceRecords.push(...discovery.records);
123
- }
206
+ const focused = await focusedOutcome(context);
207
+ if (focused !== undefined) return focused;
124
208
 
125
- const evidenceReader = repositoryEvidenceReader(context.rootDir, context.artifacts);
126
- const evidenceContext = await capturePortableEvidenceContext(context.config, evidenceReader,
127
- await computePreparationFingerprint(context.rootDir, context.config));
128
- const compiledPolicy = context.policyBinding?.compiledPolicy ??
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 });
138
- const built = buildDeliveryRecord({ config: context.config, decision, evidenceRecords, context: evidenceContext,
139
- ...(runSpan === undefined ? {} : { runSpan: { startedAt: runSpan.startedAt, endedAt: runSpan.endedAt } }),
140
- ...(compiledPolicy === null ? {} : { compiledPolicy, observedAt }) });
141
- if (!built.ok) {
142
- return { kind: "blocked", blockers: [...built.blockers] };
143
- }
144
-
145
- const verificationInputs = await capturePortableVerificationInputs(context.rootDir, context.config, recheck.candidate, built.record);
146
- const checked = verifyDeliveryRecord(context.config, built.record,
147
- { deliverableDigest: recheck.candidate.deliverable.digest, identityToken: recheck.candidate.deliverable.identity }, recheck.candidate.base,
148
- { ...verificationInputs, observedAt, liveResults: admission.observedLiveResults ?? [], executionContext: context.classifyContext() });
149
- if (!checked.ok) return { kind: "blocked", blockers: [...checked.blockers] };
150
- const relativePath = deliveryRecordPathFor(context.config, decision.candidate.deliverable.digest);
151
- const absolutePath = path.join(context.rootDir, relativePath);
152
- const bytes = deliveryRecordBytes(built.record);
153
- if (retention === undefined) {
154
- await context.artifacts.writeTextFile(absolutePath, bytes);
155
- return { kind: "ok", summary: `recorded ${relativePath}` };
156
- }
157
-
158
- const retained = await applyDeliveryRecordRetention({
159
- rootDir: context.rootDir,
160
- storageNamespace: context.config.storageNamespace,
161
- scope: retention.scope,
162
- keepSuperseded: retention.keepSuperseded,
163
- recordBasePath: context.config.deliveryRecordPath,
164
- current: {
165
- relativePath,
166
- deliverableDigest: decision.candidate.deliverable.digest,
167
- bytes,
168
- },
169
- writeCurrent: () => context.artifacts.writeTextFile(absolutePath, bytes),
170
- });
171
- if (!retained.ok) {
172
- return {
173
- kind: "blocked",
174
- blockers: [
175
- commandBlocker({
176
- code: retained.code,
177
- sourceId: "delivery-harness.cli.record",
178
- summary: "Tracked delivery-record retention did not complete.",
179
- details: retained.detail,
180
- remediations: [
181
- {
182
- id: "repair-record-retention",
183
- kind: "manual_action",
184
- summary: "Preserve the exact owned record bytes in Git, repair the named ownership conflict, then retry record with the same scope and bound.",
185
- },
186
- ],
187
- }),
188
- ],
189
- };
190
- }
191
-
192
- return {
193
- kind: "ok",
194
- summary: `recorded ${relativePath}${retained.pruned.length === 0 ? "" : `; pruned Git-preserved ${retained.pruned.join(", ")}`}`,
195
- };
209
+ return writeRecord(context, retention);
196
210
  },
197
211
  };
@@ -1,5 +1,6 @@
1
1
  import { classifyCandidateDrift, digestCanonical, evaluatePreparationReceipt, runAdmission, type CandidateBinding } from "@agent-delivery-harness/kernel";
2
2
  import { installedRelease, policyDigest, reconciliationActions, recoveryBlocker, recoveryRun } from "../ordinary-context.ts";
3
+ import { focusedOutcome } from "../delivery-posture.ts";
3
4
  import type { CommandDescriptor } from "../boundary.ts";
4
5
 
5
6
  const USAGE = "Usage: delivery-harness resume [--run <run-id>]";
@@ -10,6 +11,8 @@ export const resumeCommand: CommandDescriptor = {
10
11
  usage: USAGE,
11
12
  async run(context) {
12
13
  if (context.args.length !== 0 && (context.args.length !== 2 || context.args[0] !== "--run")) return { kind: "usage", message: USAGE };
14
+ const focused = await focusedOutcome(context);
15
+ if (focused !== undefined) return focused;
13
16
  const run = await recoveryRun(context.rootDir, context.args[1]);
14
17
  if (!run.ok) return { kind: "blocked", blockers: [run.blocker] };
15
18
  const saved = [...run.events].reverse().find(event => event.kind === "context.saved");
@@ -13,6 +13,7 @@ import { buildReviewContext, OutcomeError } from "../review-evidence.ts";
13
13
  const USAGE = "Usage: delivery-harness review-context [--json]";
14
14
 
15
15
  export const reviewContextCommand: CommandDescriptor = {
16
+ capturesCandidate: true,
16
17
  name: "review-context",
17
18
  sourceId: "delivery-harness.cli.review-context",
18
19
  summary: "Show the reviewable-change context for the prepared candidate.",
@@ -1,13 +1,20 @@
1
+ import { describeRunEventPayload, RUN_EVENT_SPEC, type RunEventPayloadMemberGrammar } from "@agent-delivery-harness/kernel";
1
2
  import { buildRunEvent } from "../run-surface.ts";
2
3
  import { installedRelease, ordinaryEventWriter, policyDigest, recoveryBlocker, recoveryRun, rejectionDetails } from "../ordinary-context.ts";
3
4
  import type { CommandDescriptor } from "../boundary.ts";
4
5
 
5
6
  const USAGE = "Usage: delivery-harness save-context --json '{\"contract\":{\"objective\":\"...\",\"acceptanceCriteria\":[\"...\"],\"finishLine\":\"merge-ready\"},\"stage\":\"work\"}'";
7
+ // Read from the store's own grammar, so the stated limits are the enforced ones.
8
+ const members = describeRunEventPayload("context.saved", RUN_EVENT_SPEC)!.members;
9
+ const bound = (name: string, member: RunEventPayloadMemberGrammar) => ` ${name}: ${member.constraint}${member.items ? `, each ${member.items.constraint}` : ""}`;
10
+ const BOUNDS = ["Bounds:",
11
+ ...members.find(m => m.name === "contract")!.members!.map(m => bound(`contract.${m.name}`, m)),
12
+ bound("stage", members.find(m => m.name === "stage")!)].join("\n");
6
13
 
7
14
  export const saveContextCommand: CommandDescriptor = {
8
15
  name: "save-context", sourceId: "delivery-harness.cli.save-context",
9
16
  summary: "Save a bounded delivery contract and stage observation in the current run.",
10
- usage: USAGE,
17
+ usage: `${USAGE}\n${BOUNDS}`,
11
18
  async run(context) {
12
19
  if (context.args.length !== 2 || context.args[0] !== "--json") return { kind: "usage", message: USAGE };
13
20
  let input: { contract?: unknown; stage?: unknown };
@@ -44,7 +51,7 @@ export const saveContextCommand: CommandDescriptor = {
44
51
  { reuseExistingTimestamp: true },
45
52
  );
46
53
  if (!appended.ok) return { kind: "blocked", blockers: [recoveryBlocker("resume_context_invalid",
47
- "Context was refused by the bounded run-event contract; inspect contract, stage, and secret-free inputs.",
54
+ "Context was refused by the bounded run-event contract; inspect contract, stage, and secret-free inputs. `save-context --help` lists every bound.",
48
55
  `run ${run.runId}: ${rejectionDetails(appended.rejections)}`)] };
49
56
  return { kind: "ok", summary: `saved ordinary context for ${run.runId}; stage is an observation, not evidence` };
50
57
  },
@@ -10,6 +10,7 @@
10
10
  import { submitManifest } from "@agent-delivery-harness/kernel";
11
11
  import type { CommandContext, CommandDescriptor, CommandResult } from "../boundary.ts";
12
12
  import { oneLine } from "../run-surface.ts";
13
+ import { ScopedChecks } from "../scoped-checks.ts";
13
14
 
14
15
  function manifestPathsFrom(args: readonly string[]): string[] | undefined {
15
16
  const paths: string[] = [];
@@ -44,6 +45,7 @@ function unknownFlagIn(args: readonly string[]): string | undefined {
44
45
  }
45
46
 
46
47
  export const submitEvidenceCommand: CommandDescriptor = {
48
+ capturesCandidate: true,
47
49
  name: "submit-evidence",
48
50
  sourceId: "delivery-harness.cli.submit-evidence",
49
51
  summary: "Validate a provider manifest and publish its evidence records.",
@@ -68,10 +70,22 @@ export const submitEvidenceCommand: CommandDescriptor = {
68
70
  }
69
71
 
70
72
  const wiring = await context.wire();
71
- const outcome = await submitManifest(
72
- { rootDir: context.rootDir, manifestPath, config: context.config },
73
- { captureCandidate: wiring.captureCandidate, artifacts: context.artifacts, ...wiring.storageOptions },
74
- );
73
+ // Scoped check bindings are captured for every manifest, so a scoped
74
+ // provider needs the plan rebuilt for the current candidate and base, as
75
+ // the gate builds it. The kernel still refuses a plan that is not current.
76
+ let session: ScopedChecks | undefined;
77
+ let outcome: Awaited<ReturnType<typeof submitManifest>>;
78
+ try {
79
+ if (context.config.providers.some(p => p.check?.scope)) {
80
+ const capture = await wiring.captureCandidate();
81
+ if (capture.ok) session = await ScopedChecks.create(context, capture.candidate);
82
+ }
83
+ outcome = await submitManifest(
84
+ { rootDir: context.rootDir, manifestPath, config: context.config },
85
+ { captureCandidate: wiring.captureCandidate, artifacts: context.artifacts, ...wiring.storageOptions,
86
+ ...(session ? { scopedPlan: await session.plan(), readOutput: session.readOutput } : {}) },
87
+ );
88
+ } finally { await session?.cleanup(); }
75
89
 
76
90
  if (outcome.status === "accepted") {
77
91
  const lines = outcome.records.map(
@@ -43,6 +43,7 @@ import {
43
43
  type RunJournalRow,
44
44
  } from "@agent-delivery-harness/kernel";
45
45
  import { commandBlocker } from "../boundary.ts";
46
+ import { focusedOutcome } from "../delivery-posture.ts";
46
47
  import type { CommandContext, CommandDescriptor, CommandResult } from "../boundary.ts";
47
48
  import { RUN_JOURNAL_ADMISSION_ROW, oneLine, resolveRunJournalRow, resolveRunSpan, runJournalRows } from "../run-surface.ts";
48
49
  import { durationLabel } from "../run-projection.ts";
@@ -233,6 +234,7 @@ async function runSpanRows(
233
234
  }
234
235
 
235
236
  export const verifyCommand: CommandDescriptor = {
237
+ capturesCandidate: true,
236
238
  name: "verify",
237
239
  sourceId: "delivery-harness.cli.verify",
238
240
  summary: "Verify the tracked delivery record against the current candidate.",
@@ -244,6 +246,9 @@ export const verifyCommand: CommandDescriptor = {
244
246
  const parsedArgs = parseArgs(context.args);
245
247
  if (!parsedArgs.ok) return { kind: "usage", message: parsedArgs.message };
246
248
 
249
+ const focused = await focusedOutcome(context);
250
+ if (focused !== undefined) return focused;
251
+
247
252
  const wiring = await context.wire();
248
253
  const capture = await wiring.captureCandidate();
249
254
  if (!capture.ok) {