@agent-delivery-harness/cli 0.1.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/LICENSE +104 -201
  2. package/NOTICE +1 -1
  3. package/package.json +10 -5
  4. package/src/boundary.ts +302 -9
  5. package/src/command-activity.ts +75 -0
  6. package/src/commands/admit.ts +106 -0
  7. package/src/commands/check.ts +13 -2
  8. package/src/commands/emit-review-evidence.ts +38 -0
  9. package/src/commands/emit.ts +419 -0
  10. package/src/commands/gate.ts +127 -18
  11. package/src/commands/maintain.ts +203 -0
  12. package/src/commands/managed.ts +478 -0
  13. package/src/commands/prepare.ts +123 -15
  14. package/src/commands/record.ts +100 -8
  15. package/src/commands/resume.ts +53 -0
  16. package/src/commands/review-context.ts +19 -0
  17. package/src/commands/runs.ts +640 -0
  18. package/src/commands/save-context.ts +51 -0
  19. package/src/commands/submit-evidence.ts +46 -7
  20. package/src/commands/verify.ts +173 -5
  21. package/src/declared-checks.ts +50 -0
  22. package/src/index.ts +40 -4
  23. package/src/main.ts +38 -42
  24. package/src/ordinary-context.ts +99 -0
  25. package/src/provider-rails.ts +24 -0
  26. package/src/record-retention.ts +325 -0
  27. package/src/review-evidence.ts +284 -0
  28. package/src/run-archive-commands.ts +164 -0
  29. package/src/run-archive.ts +127 -0
  30. package/src/run-artifact-commands.ts +163 -0
  31. package/src/run-attachments.ts +112 -0
  32. package/src/run-export.ts +164 -0
  33. package/src/run-live.ts +102 -0
  34. package/src/run-projection.ts +396 -0
  35. package/src/run-server.ts +905 -0
  36. package/src/run-surface.ts +334 -0
  37. package/src/run-view-command.ts +77 -0
  38. package/src/run-view-html.ts +138 -0
  39. package/src/run-view-record.ts +119 -0
  40. package/src/run-view.ts +343 -0
@@ -5,27 +5,135 @@
5
5
  * admission. Capturing an unprepared tree (dirty or with untracked files) is a
6
6
  * typed block, not a silent skip.
7
7
  */
8
- import { publishPreparationReceipt } from "@agent-delivery-harness/kernel";
9
- import type { CommandContext, CommandDescriptor, CommandResult } from "../boundary.ts";
8
+ import {
9
+ classifyCandidateDrift,
10
+ computePreparationFingerprint,
11
+ computeDeliverableIdentity,
12
+ evaluatePreparationReceipt,
13
+ createBlocker,
14
+ createExecPort,
15
+ invalidatePreparationReceipt,
16
+ revokePreparationAttempt,
17
+ publishPreparationReceipt,
18
+ type RunPreparationObservation,
19
+ } from "@agent-delivery-harness/kernel";
20
+ import { CliInterruption, type CommandContext, type CommandDescriptor, type CommandResult } from "../boundary.ts";
21
+
22
+ const USAGE = "Usage: delivery-harness prepare [--refresh-record-neutral]";
10
23
 
11
24
  export const prepareCommand: CommandDescriptor = {
12
25
  name: "prepare",
13
26
  sourceId: "delivery-harness.cli.prepare",
14
- summary: "Capture the candidate and publish its preparation receipt.",
27
+ summary: "Run preparation checks; --refresh-record-neutral permits proven artifact-only receipt refresh.",
28
+ // The text prepare has always answered `--help` with, unchanged; the
29
+ // boundary is what prints it now, for every command rather than this one.
30
+ 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
31
  async run(context: CommandContext): Promise<CommandResult> {
32
+ if (context.args.length > 1 || (context.args.length === 1 && context.args[0] !== "--refresh-record-neutral")) {
33
+ return { kind: "usage", message: USAGE };
34
+ }
35
+ const refreshRecordNeutral = context.args[0] === "--refresh-record-neutral";
16
36
  const wiring = await context.wire();
17
- const capture = await wiring.captureCandidate();
18
- if (!capture.ok) {
19
- return { kind: "blocked", blockers: [...capture.blockers] };
37
+ let retainedAttemptId: string | undefined;
38
+ let ownedAttemptId: string | undefined;
39
+ let refreshed = false;
40
+ try {
41
+ let capture: Awaited<ReturnType<typeof wiring.captureCandidate>>;
42
+ // A refresh continues the successful attempt it evaluated. It cannot
43
+ // transfer that success to new ownership after another attempt revokes it.
44
+ let reusableFingerprint: string | undefined;
45
+ let attemptId: string;
46
+ try {
47
+ capture = await wiring.captureCandidate();
48
+ if (capture.ok && refreshRecordNeutral) {
49
+ const previous = await evaluatePreparationReceipt(context.rootDir,
50
+ { config: context.config, candidate: capture.candidate }, { ...wiring.storageOptions, allowValidationEquivalent: true });
51
+ if (previous.prepared && previous.receipt.attemptId !== undefined) {
52
+ reusableFingerprint = previous.receipt.preparationFingerprint;
53
+ retainedAttemptId = previous.receipt.attemptId;
54
+ }
55
+ }
56
+ } finally {
57
+ attemptId = retainedAttemptId ?? await invalidatePreparationReceipt(context.rootDir, context.config, wiring.storageOptions);
58
+ ownedAttemptId = attemptId;
59
+ }
60
+ if (!capture.ok) {
61
+ return { kind: "blocked", blockers: [...capture.blockers] };
62
+ }
63
+ const fingerprint = await computePreparationFingerprint(context.rootDir, context.config, wiring.storageOptions);
64
+ const reusable = reusableFingerprint === fingerprint;
65
+ const preparation: RunPreparationObservation = reusable
66
+ ? { checks: "reused", reason: "validation-equivalent" }
67
+ : { checks: "executed", reason: !refreshRecordNeutral ? "ordinary"
68
+ : retainedAttemptId === undefined ? "receipt-not-reusable" : "preparation-fingerprint-changed" };
69
+ if (!reusable && retainedAttemptId !== undefined) {
70
+ attemptId = await invalidatePreparationReceipt(context.rootDir, context.config, wiring.storageOptions);
71
+ ownedAttemptId = attemptId;
72
+ retainedAttemptId = undefined;
73
+ }
74
+ const validationDigest = await computeDeliverableIdentity({ rootDir: context.rootDir, treeSha: capture.candidate.treeSha,
75
+ config: { ...context.config, computingIdentityVersion: "validation-tree/v1", reviewNeutral: context.config.recordNeutral } });
76
+ if (reusable) context.write("reusing preparation checks: strict validation projection, base, policy and wiring unchanged");
77
+ const exec = createExecPort();
78
+ const env = Object.fromEntries(Object.entries(context.env).filter((entry): entry is [string, string] => entry[1] !== undefined));
79
+ for (const check of reusable ? [] : context.config.preparationCommands ?? []) {
80
+ context.write(`preparing ${check.id}`);
81
+ const result = await exec.run({
82
+ command: check.command[0], args: check.command.slice(1), cwd: context.rootDir,
83
+ env, timeoutMs: check.timeoutMs, maxBuffer: 1024 * 1024,
84
+ ...(context.signal === undefined ? {} : { signal: context.signal }),
85
+ });
86
+ if (result.code !== 0 || context.signal?.aborted) {
87
+ return {
88
+ kind: "blocked",
89
+ blockers: [createBlocker({
90
+ code: "preparation_command_failed", source: { kind: "command", id: "delivery-harness.cli.prepare" },
91
+ summary: `Preparation check ${check.id} failed; no receipt was published.`,
92
+ details: `exit ${result.code}${result.errorCode === undefined ? "" : ` (${result.errorCode})`}\n${`${result.stdout}\n${result.stderr}`.slice(-4000)}`,
93
+ remediations: [{ id: "repair-preparation-check", kind: "manual_action", summary: `Fix ${check.id}, then run prepare again.` }],
94
+ })],
95
+ };
96
+ }
97
+ }
98
+ if (context.signal?.aborted) throw new CliInterruption();
99
+ const finalCapture = await wiring.captureCandidate();
100
+ if (!finalCapture.ok) return { kind: "blocked", blockers: [...finalCapture.blockers] };
101
+ const after = finalCapture.candidate;
102
+ if (classifyCandidateDrift(capture.candidate, after).length > 0 ||
103
+ capture.candidate.headSha !== after.headSha || capture.candidate.mode !== after.mode ||
104
+ fingerprint !== await computePreparationFingerprint(context.rootDir, context.config, wiring.storageOptions)) {
105
+ return {
106
+ kind: "blocked",
107
+ blockers: [createBlocker({
108
+ code: "preparation_candidate_changed", source: { kind: "command", id: "delivery-harness.cli.prepare" },
109
+ summary: "The candidate, base, or preparation wiring changed while checks ran; no receipt was published.",
110
+ remediations: [{ id: "prepare-stable-candidate", kind: "manual_action", summary: "Commit any intended changes and prepare the stable candidate again." }],
111
+ })],
112
+ };
113
+ }
114
+ const published = await publishPreparationReceipt(
115
+ context.rootDir,
116
+ { config: context.config, candidate: capture.candidate, attemptId, validationDigest },
117
+ wiring.storageOptions,
118
+ );
119
+ if (context.signal?.aborted) throw new CliInterruption();
120
+ refreshed = true;
121
+ // The labelled line exists so a reader — an operator or a review round
122
+ // about to bind itself to this candidate — has one unambiguous token to
123
+ // copy. It is the same value the record carries as `treeSha` and the same
124
+ // value `review-context` reports as `candidate tree`.
125
+ return {
126
+ kind: "ok",
127
+ preparation,
128
+ summary: [
129
+ `prepared ${context.config.gateId}: tree ${capture.candidate.treeSha} (${capture.candidate.mode}); receipt ${published.path}`,
130
+ ` treeSha ${capture.candidate.treeSha}`,
131
+ ].join("\n"),
132
+ };
133
+ } finally {
134
+ if (ownedAttemptId !== undefined && !refreshed) {
135
+ await revokePreparationAttempt(context.rootDir, context.config, ownedAttemptId, wiring.storageOptions);
136
+ }
20
137
  }
21
- const published = await publishPreparationReceipt(
22
- context.rootDir,
23
- { config: context.config, candidate: capture.candidate },
24
- wiring.storageOptions,
25
- );
26
- return {
27
- kind: "ok",
28
- summary: `prepared ${context.config.gateId}: tree ${capture.candidate.treeSha} (${capture.candidate.mode}); receipt ${published.path}`,
29
- };
30
138
  },
31
139
  };
@@ -13,30 +13,70 @@
13
13
  */
14
14
  import {
15
15
  buildDeliveryRecord,
16
+ candidateTreeEvidenceReader,
17
+ computePreparationFingerprint, capturePortableEvidenceContext, repositoryEvidenceReader, capturePortableVerificationInputs, verifyDeliveryRecord,
16
18
  deliveryRecordBytes,
17
19
  deliveryRecordPathFor,
18
20
  discoverRecords,
19
- runAdmission,
20
21
  type EvidenceRecord,
22
+ readCompiledRepositoryPolicy,
21
23
  } from "@agent-delivery-harness/kernel";
22
24
  import path from "node:path";
23
25
  import { commandBlocker } from "../boundary.ts";
24
26
  import type { CommandContext, CommandDescriptor, CommandResult } from "../boundary.ts";
27
+ import { oneLine } from "../run-surface.ts";
28
+ import { applyDeliveryRecordRetention } from "../record-retention.ts";
29
+ import { runProviderBackedAdmission } from "./gate.ts";
30
+
31
+ interface RetentionOptions {
32
+ readonly scope: string;
33
+ readonly keepSuperseded: number;
34
+ }
35
+
36
+ function parseRetentionOptions(args: readonly string[]): RetentionOptions | undefined | string {
37
+ if (args.length === 0) return undefined;
38
+ let scope: string | undefined;
39
+ let keepSuperseded: number | undefined;
40
+ for (let index = 0; index < args.length; index += 2) {
41
+ const flag = args[index];
42
+ const value = args[index + 1];
43
+ if (value === undefined) return `${flag ?? "record option"} requires a value`;
44
+ if (flag === "--retention-scope" && scope === undefined) scope = value;
45
+ else if (flag === "--keep-superseded" && keepSuperseded === undefined) {
46
+ if (!/^\d+$/.test(value)) return "--keep-superseded must be an integer from 0 through 100";
47
+ keepSuperseded = Number(value);
48
+ } else return `${oneLine(flag ?? "record option", 64)} is not a valid record retention option`;
49
+ }
50
+ if (scope === undefined || keepSuperseded === undefined) {
51
+ return "--retention-scope and --keep-superseded must be supplied together";
52
+ }
53
+ if (!Number.isSafeInteger(keepSuperseded) || keepSuperseded > 100) {
54
+ return "--keep-superseded must be an integer from 0 through 100";
55
+ }
56
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(scope)) {
57
+ return "--retention-scope must be a plain 1-128 character delivery key";
58
+ }
59
+ return { scope, keepSuperseded };
60
+ }
25
61
 
26
62
  export const recordCommand: CommandDescriptor = {
27
63
  name: "record",
28
64
  sourceId: "delivery-harness.cli.record",
29
65
  summary: "Write the tracked delivery record for an admitted gate.",
66
+ 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.",
30
67
  async run(context: CommandContext): Promise<CommandResult> {
68
+ const observedAt = `${new Date().toISOString().slice(0, 19)}Z`;
69
+ // Arguments before anything is wired, admitted, or written.
70
+ const retention = parseRetentionOptions(context.args);
71
+ if (typeof retention === "string") {
72
+ return { kind: "usage", message: `${retention}.\n${recordCommand.usage}` };
73
+ }
31
74
  const wiring = await context.wire();
32
75
 
33
76
  // The gate is run without a prompt: `record` is not the waiver surface. If a
34
77
  // waiver is needed, the operator runs `gate` first; here a non-admitting gate
35
78
  // is simply a refusal.
36
- const admission = await runAdmission(
37
- { rootDir: context.rootDir, config: context.config, context: context.classifyContext() },
38
- { captureCandidate: wiring.captureCandidate, projectActivation: wiring.projectActivation, ...wiring.storageOptions },
39
- );
79
+ const admission = await runProviderBackedAdmission(context, { allowPrompt: false, includeInjectedLiveResults: false });
40
80
  if (!admission.admitted || admission.decision === undefined) {
41
81
  return { kind: "blocked", blockers: [...admission.blockers] };
42
82
  }
@@ -82,15 +122,67 @@ export const recordCommand: CommandDescriptor = {
82
122
  evidenceRecords.push(...discovery.records);
83
123
  }
84
124
 
85
- const built = buildDeliveryRecord({ config: context.config, decision, evidenceRecords });
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
+ const built = buildDeliveryRecord({ config: context.config, decision, evidenceRecords, context: evidenceContext,
131
+ ...(compiledPolicy === null ? {} : { compiledPolicy, observedAt }) });
86
132
  if (!built.ok) {
87
133
  return { kind: "blocked", blockers: [...built.blockers] };
88
134
  }
89
135
 
136
+ const verificationInputs = await capturePortableVerificationInputs(context.rootDir, context.config, recheck.candidate, built.record);
137
+ const checked = verifyDeliveryRecord(context.config, built.record,
138
+ { deliverableDigest: recheck.candidate.deliverable.digest, identityToken: recheck.candidate.deliverable.identity }, recheck.candidate.base,
139
+ { ...verificationInputs, observedAt, liveResults: admission.observedLiveResults ?? [], executionContext: context.classifyContext() });
140
+ if (!checked.ok) return { kind: "blocked", blockers: [...checked.blockers] };
90
141
  const relativePath = deliveryRecordPathFor(context.config, decision.candidate.deliverable.digest);
91
142
  const absolutePath = path.join(context.rootDir, relativePath);
92
- await context.artifacts.writeTextFile(absolutePath, deliveryRecordBytes(built.record));
143
+ const bytes = deliveryRecordBytes(built.record);
144
+ if (retention === undefined) {
145
+ await context.artifacts.writeTextFile(absolutePath, bytes);
146
+ return { kind: "ok", summary: `recorded ${relativePath}` };
147
+ }
148
+
149
+ const retained = await applyDeliveryRecordRetention({
150
+ rootDir: context.rootDir,
151
+ storageNamespace: context.config.storageNamespace,
152
+ scope: retention.scope,
153
+ keepSuperseded: retention.keepSuperseded,
154
+ recordBasePath: context.config.deliveryRecordPath,
155
+ current: {
156
+ relativePath,
157
+ deliverableDigest: decision.candidate.deliverable.digest,
158
+ bytes,
159
+ },
160
+ writeCurrent: () => context.artifacts.writeTextFile(absolutePath, bytes),
161
+ });
162
+ if (!retained.ok) {
163
+ return {
164
+ kind: "blocked",
165
+ blockers: [
166
+ commandBlocker({
167
+ code: retained.code,
168
+ sourceId: "delivery-harness.cli.record",
169
+ summary: "Tracked delivery-record retention did not complete.",
170
+ details: retained.detail,
171
+ remediations: [
172
+ {
173
+ id: "repair-record-retention",
174
+ kind: "manual_action",
175
+ summary: "Preserve the exact owned record bytes in Git, repair the named ownership conflict, then retry record with the same scope and bound.",
176
+ },
177
+ ],
178
+ }),
179
+ ],
180
+ };
181
+ }
93
182
 
94
- return { kind: "ok", summary: `recorded ${relativePath}` };
183
+ return {
184
+ kind: "ok",
185
+ summary: `recorded ${relativePath}${retained.pruned.length === 0 ? "" : `; pruned Git-preserved ${retained.pruned.join(", ")}`}`,
186
+ };
95
187
  },
96
188
  };
@@ -0,0 +1,53 @@
1
+ import { classifyCandidateDrift, digestCanonical, evaluatePreparationReceipt, runAdmission, type CandidateBinding } from "@agent-delivery-harness/kernel";
2
+ import { installedRelease, policyDigest, reconciliationActions, recoveryBlocker, recoveryRun } from "../ordinary-context.ts";
3
+ import type { CommandDescriptor } from "../boundary.ts";
4
+
5
+ const USAGE = "Usage: delivery-harness resume [--run <run-id>]";
6
+
7
+ export const resumeCommand: CommandDescriptor = {
8
+ name: "resume", sourceId: "delivery-harness.cli.resume",
9
+ summary: "Read saved ordinary context and recheck evidence without executing or replaying work.",
10
+ usage: USAGE,
11
+ async run(context) {
12
+ if (context.args.length !== 0 && (context.args.length !== 2 || context.args[0] !== "--run")) return { kind: "usage", message: USAGE };
13
+ const run = await recoveryRun(context.rootDir, context.args[1]);
14
+ if (!run.ok) return { kind: "blocked", blockers: [run.blocker] };
15
+ const saved = [...run.events].reverse().find(event => event.kind === "context.saved");
16
+ if (!saved) return { kind: "blocked", blockers: [recoveryBlocker("resume_context_missing", "This run has no saved ordinary context. Recover the contract from its work item and save it explicitly.")] };
17
+ const p = saved.payload;
18
+ const actions = reconciliationActions(run.events);
19
+ const blockers = actions.some(action => action.outcome === "unknown" || action.inconsistent)
20
+ ? [recoveryBlocker("resume_action_unreconciled", "An external action has no consistent observed outcome. Reconcile its reference with host tools before any repeat.")] : [];
21
+ const wiring = await context.wire();
22
+ const capture = await wiring.captureCandidate();
23
+ const drift: string[] = [];
24
+ if (p["policyDigest"] !== policyDigest(context)) drift.push("policy_changed");
25
+ try { if (digestCanonical(p["release"]) !== digestCanonical(await installedRelease(context.rootDir))) drift.push("release_changed"); }
26
+ catch { drift.push("release_unreadable"); }
27
+ if (capture.ok) {
28
+ const binding = p["candidateBinding"] as Record<string, string>;
29
+ const expected: CandidateBinding = { treeSha: saved.candidateTreeSha!, deliverable: { digest: binding["deliverableDigest"]!, identity: binding["identity"]! },
30
+ base: { ref: binding["baseRef"]!, tipSha: binding["baseTipSha"]!, mergeBaseSha: binding["mergeBaseSha"]! }, workspaceId: binding["workspaceId"]! };
31
+ drift.push(...classifyCandidateDrift(expected, capture.candidate));
32
+ if (expected.deliverable.identity !== capture.candidate.deliverable.identity) drift.push("identity_changed");
33
+ if (expected.base.ref !== capture.candidate.base.ref) drift.push("base_ref_changed");
34
+ } else blockers.push(...capture.blockers);
35
+ // Reuse the ordinary sensors, with no prompt, live-result injection, or
36
+ // provider invocation. They read receipts/evidence; this journal grants nothing.
37
+ const preparation = capture.ok ? await evaluatePreparationReceipt(context.rootDir, { config: context.config, candidate: capture.candidate }, wiring.storageOptions) : undefined;
38
+ const admission = capture.ok ? await runAdmission({ rootDir: context.rootDir, config: context.config, context: context.classifyContext() },
39
+ { captureCandidate: wiring.captureCandidate, projectActivation: wiring.projectActivation, ...wiring.storageOptions }) : undefined;
40
+ if (capture.ok && admission?.candidate && classifyCandidateDrift(capture.candidate, admission.candidate).length > 0) drift.push("candidate_observation_changed");
41
+ try { if (digestCanonical(p["release"]) !== digestCanonical(await installedRelease(context.rootDir)) && !drift.includes("release_changed")) drift.push("release_changed"); }
42
+ catch { if (!drift.includes("release_unreadable")) drift.push("release_unreadable"); }
43
+ if (admission) blockers.push(...admission.blockers);
44
+ // Raw-tree movement alone is reported, but existing sensors decide whether
45
+ // the change is neutral. No new identity or freshness rule is introduced.
46
+ if (drift.some(item => item !== "raw_tree_changed")) blockers.push(recoveryBlocker("resume_context_stale", "Saved candidate, policy, or release bindings changed; revalidate the contract and affected work before saving fresh context."));
47
+ const reuseAllowed = blockers.length === 0 && admission?.admitted === true;
48
+ context.write(JSON.stringify({ spec: "ordinary-resume/1", runId: run.runId,
49
+ observationOnly: true, automaticReplay: false, savedContext: p, actions, drift,
50
+ preparation: preparation ?? { prepared: false }, admission: admission ?? { admitted: false }, reuseAllowed }));
51
+ return blockers.length > 0 ? { kind: "blocked", blockers } : { kind: "ok" };
52
+ },
53
+ };
@@ -8,12 +8,19 @@
8
8
  import { evaluatePreparationReceipt } from "@agent-delivery-harness/kernel";
9
9
  import { commandBlocker } from "../boundary.ts";
10
10
  import type { CommandContext, CommandDescriptor, CommandResult } from "../boundary.ts";
11
+ import { buildReviewContext, OutcomeError } from "../review-evidence.ts";
12
+
13
+ const USAGE = "Usage: delivery-harness review-context [--json]";
11
14
 
12
15
  export const reviewContextCommand: CommandDescriptor = {
13
16
  name: "review-context",
14
17
  sourceId: "delivery-harness.cli.review-context",
15
18
  summary: "Show the reviewable-change context for the prepared candidate.",
19
+ usage: USAGE,
16
20
  async run(context: CommandContext): Promise<CommandResult> {
21
+ if (context.args.length > 0 && !(context.args.length === 1 && context.args[0] === "--json")) {
22
+ return { kind: "usage", message: `review-context accepts only --json.\n${USAGE}` };
23
+ }
17
24
  const wiring = await context.wire();
18
25
  const capture = await wiring.captureCandidate();
19
26
  if (!capture.ok) {
@@ -49,6 +56,18 @@ export const reviewContextCommand: CommandDescriptor = {
49
56
  return { kind: "blocked", blockers };
50
57
  }
51
58
 
59
+ if (context.args[0] === "--json") {
60
+ try {
61
+ const document = await buildReviewContext(context.rootDir, context.config, capture.candidate, evaluation.receipt);
62
+ return { kind: "ok", summary: JSON.stringify(document, null, 2) };
63
+ } catch (error) {
64
+ if (!(error instanceof OutcomeError)) throw error;
65
+ return { kind: "blocked", blockers: [commandBlocker({
66
+ code: "review_context_unavailable", sourceId: "delivery-harness.cli.review-context", summary: error.message,
67
+ remediations: [{ id: "restore-review-inputs", kind: "manual_action", summary: "Restore the installed workflow release and compiled policy charters before acquiring review." }],
68
+ })] };
69
+ }
70
+ }
52
71
  const projection = await wiring.projectActivation(capture.candidate);
53
72
  const active = projection.relevantLineCount >= context.config.activationThreshold || projection.hasRelevantBinaryChange;
54
73
  return {