@agent-delivery-harness/cli 0.2.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/LICENSE +104 -201
  2. package/NOTICE +1 -1
  3. package/package.json +4 -4
  4. package/src/boundary.ts +77 -61
  5. package/src/command-activity.ts +75 -0
  6. package/src/commands/admit.ts +106 -0
  7. package/src/commands/check.ts +13 -2
  8. package/src/commands/emit-review-evidence.ts +38 -0
  9. package/src/commands/emit.ts +80 -12
  10. package/src/commands/gate.ts +33 -8
  11. package/src/commands/maintain.ts +1 -0
  12. package/src/commands/managed.ts +1 -0
  13. package/src/commands/prepare.ts +123 -22
  14. package/src/commands/record.ts +98 -3
  15. package/src/commands/resume.ts +53 -0
  16. package/src/commands/review-context.ts +19 -0
  17. package/src/commands/runs.ts +415 -30
  18. package/src/commands/save-context.ts +51 -0
  19. package/src/commands/submit-evidence.ts +46 -7
  20. package/src/commands/verify.ts +29 -8
  21. package/src/declared-checks.ts +50 -0
  22. package/src/index.ts +14 -1
  23. package/src/main.ts +20 -47
  24. package/src/ordinary-context.ts +99 -0
  25. package/src/provider-rails.ts +23 -704
  26. package/src/record-retention.ts +325 -0
  27. package/src/review-evidence.ts +284 -0
  28. package/src/run-archive-commands.ts +164 -0
  29. package/src/run-archive.ts +127 -0
  30. package/src/run-artifact-commands.ts +163 -0
  31. package/src/run-attachments.ts +112 -0
  32. package/src/run-export.ts +164 -0
  33. package/src/run-live.ts +102 -0
  34. package/src/run-projection.ts +129 -11
  35. package/src/run-server.ts +394 -149
  36. package/src/run-surface.ts +81 -8
  37. package/src/run-view-command.ts +77 -0
  38. package/src/run-view-html.ts +138 -0
  39. package/src/run-view-record.ts +119 -0
  40. package/src/run-view.ts +343 -0
@@ -0,0 +1,325 @@
1
+ /** Opt-in ownership and pruning for tracked per-digest delivery records. */
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { execFile } from "node:child_process";
4
+ import { constants as fsConstants } from "node:fs";
5
+ import { chmod, lstat, mkdir, open, readFile, readdir, rename, unlink } from "node:fs/promises";
6
+ import path from "node:path";
7
+ import { promisify } from "node:util";
8
+ import {
9
+ ProcessLockRefused,
10
+ deriveDeliveryRecordPath,
11
+ resolveRecordStorage,
12
+ sha256Hex,
13
+ withProcessLock,
14
+ } from "@agent-delivery-harness/kernel";
15
+
16
+ const execFileAsync = promisify(execFile);
17
+ const SCOPE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
18
+ const DIGEST = /^[a-f0-9]{64}$/;
19
+ const LEDGER_VERSION = "delivery-record-retention/1";
20
+ const RETENTION_LEAF = "delivery-record-retention";
21
+
22
+ interface Receipt {
23
+ readonly relativePath: string;
24
+ readonly deliverableDigest: string;
25
+ readonly sha256: string;
26
+ }
27
+
28
+ interface Ledger {
29
+ readonly version: typeof LEDGER_VERSION;
30
+ readonly scope: string;
31
+ readonly recordBasePath: string;
32
+ readonly owned: readonly Receipt[];
33
+ readonly pendingPrune: readonly Receipt[];
34
+ }
35
+
36
+ export type RecordRetentionCode =
37
+ | "retention_scope_invalid"
38
+ | "retention_bound_invalid"
39
+ | "retention_ledger_unsafe"
40
+ | "retention_ledger_invalid"
41
+ | "retention_ownership_ambiguous"
42
+ | "retention_path_unowned"
43
+ | "retention_owned_record_changed"
44
+ | "retention_owned_record_unsafe"
45
+ | "retention_history_missing"
46
+ | "retention_write_failed"
47
+ | "retention_cleanup_failed"
48
+ | "retention_lock_unavailable";
49
+
50
+ export type RecordRetentionResult =
51
+ | { readonly ok: true; readonly pruned: readonly string[]; readonly ledgerPath: string }
52
+ | { readonly ok: false; readonly code: RecordRetentionCode; readonly detail: string };
53
+ type RecordRetentionFailure = Extract<RecordRetentionResult, { readonly ok: false }>;
54
+
55
+ export interface ApplyDeliveryRecordRetentionInput {
56
+ readonly rootDir: string;
57
+ readonly storageNamespace: string;
58
+ readonly scope: string;
59
+ readonly keepSuperseded: number;
60
+ readonly recordBasePath: string;
61
+ readonly current: {
62
+ readonly relativePath: string;
63
+ readonly deliverableDigest: string;
64
+ readonly bytes: string;
65
+ };
66
+ readonly writeCurrent: () => Promise<void>;
67
+ }
68
+
69
+ const failure = (code: RecordRetentionCode, detail: string): RecordRetentionFailure => ({ ok: false, code, detail });
70
+ const scopeDigest = (scope: string): string => createHash("sha256").update(scope, "utf8").digest("hex");
71
+
72
+ function isRecord(value: unknown): value is Record<string, unknown> {
73
+ return typeof value === "object" && value !== null && !Array.isArray(value);
74
+ }
75
+
76
+ function receipt(value: unknown, recordBasePath: string): Receipt | undefined {
77
+ if (!isRecord(value) || Object.keys(value).sort().join(",") !== "deliverableDigest,relativePath,sha256") return undefined;
78
+ const relativePath = value["relativePath"];
79
+ const deliverableDigest = value["deliverableDigest"];
80
+ const sha256 = value["sha256"];
81
+ if (typeof relativePath !== "string" || typeof deliverableDigest !== "string" || !DIGEST.test(deliverableDigest) ||
82
+ typeof sha256 !== "string" || !DIGEST.test(sha256) || deriveDeliveryRecordPath(recordBasePath, deliverableDigest) !== relativePath) return undefined;
83
+ return { relativePath, deliverableDigest, sha256 };
84
+ }
85
+
86
+ function parseLedger(value: unknown, expectedName: string): Ledger | undefined {
87
+ if (!isRecord(value) || Object.keys(value).sort().join(",") !== "owned,pendingPrune,recordBasePath,scope,version" ||
88
+ value["version"] !== LEDGER_VERSION || typeof value["scope"] !== "string" || !SCOPE.test(value["scope"]) ||
89
+ typeof value["recordBasePath"] !== "string" || !Array.isArray(value["owned"]) || !Array.isArray(value["pendingPrune"]) ||
90
+ `${scopeDigest(value["scope"])}.json` !== expectedName) return undefined;
91
+ const owned = value["owned"].map((entry) => receipt(entry, value["recordBasePath"] as string));
92
+ const pendingPrune = value["pendingPrune"].map((entry) => receipt(entry, value["recordBasePath"] as string));
93
+ if (owned.some((entry) => entry === undefined) || pendingPrune.some((entry) => entry === undefined)) return undefined;
94
+ const paths = [...owned, ...pendingPrune].map((entry) => entry!.relativePath);
95
+ if (new Set(paths).size !== paths.length) return undefined;
96
+ return {
97
+ version: LEDGER_VERSION,
98
+ scope: value["scope"],
99
+ recordBasePath: value["recordBasePath"],
100
+ owned: owned as Receipt[],
101
+ pendingPrune: pendingPrune as Receipt[],
102
+ };
103
+ }
104
+
105
+ async function readLedgers(storageDir: string): Promise<RecordRetentionFailure | { readonly ok: true; readonly ledgers: Map<string, Ledger> }> {
106
+ const ledgers = new Map<string, Ledger>();
107
+ for (const name of await readdir(storageDir).catch((error: NodeJS.ErrnoException) => {
108
+ if (error.code === "ENOENT") return [] as string[];
109
+ throw error;
110
+ })) {
111
+ if (!name.endsWith(".json")) continue;
112
+ const ledgerPath = path.join(storageDir, name);
113
+ const stats = await lstat(ledgerPath).catch(() => undefined);
114
+ if (stats === undefined || !stats.isFile() || stats.isSymbolicLink() || (stats.mode & 0o077) !== 0) {
115
+ return failure("retention_ledger_unsafe", `${ledgerPath} is not an owner-only regular file`);
116
+ }
117
+ let value: unknown;
118
+ try {
119
+ value = JSON.parse(await readFile(ledgerPath, "utf8"));
120
+ } catch {
121
+ return failure("retention_ledger_invalid", `${ledgerPath} is not valid JSON`);
122
+ }
123
+ const parsed = parseLedger(value, name);
124
+ if (parsed === undefined) return failure("retention_ledger_invalid", `${ledgerPath} has an unsupported or inconsistent shape`);
125
+ ledgers.set(parsed.scope, parsed);
126
+ }
127
+ const owners = new Map<string, string>();
128
+ for (const ledger of ledgers.values()) {
129
+ for (const entry of [...ledger.owned, ...ledger.pendingPrune]) {
130
+ const prior = owners.get(entry.relativePath);
131
+ if (prior !== undefined && prior !== ledger.scope) {
132
+ return failure("retention_ownership_ambiguous", `${entry.relativePath} is claimed by ${prior} and ${ledger.scope}`);
133
+ }
134
+ owners.set(entry.relativePath, ledger.scope);
135
+ }
136
+ }
137
+ return { ok: true, ledgers };
138
+ }
139
+
140
+ async function writeLedger(ledgerPath: string, ledger: Ledger): Promise<void> {
141
+ await mkdir(path.dirname(ledgerPath), { recursive: true, mode: 0o700 });
142
+ await chmod(path.dirname(ledgerPath), 0o700);
143
+ const temporary = `${ledgerPath}.tmp-${randomUUID()}`;
144
+ const handle = await open(temporary, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 0o600);
145
+ try {
146
+ await handle.writeFile(`${JSON.stringify(ledger)}\n`, "utf8");
147
+ await handle.sync();
148
+ await handle.chmod(0o600);
149
+ } finally {
150
+ await handle.close();
151
+ }
152
+ await rename(temporary, ledgerPath);
153
+ }
154
+
155
+ async function verifyWorkingRecord(rootDir: string, entry: Receipt): Promise<RecordRetentionResult | { readonly ok: true }> {
156
+ const absolute = path.join(rootDir, entry.relativePath);
157
+ const stats = await lstat(absolute).catch((error: NodeJS.ErrnoException) => error.code === "ENOENT" ? undefined : Promise.reject(error));
158
+ if (stats === undefined) return failure("retention_owned_record_changed", `${entry.relativePath} is missing`);
159
+ if (!stats.isFile() || stats.isSymbolicLink()) return failure("retention_owned_record_unsafe", `${entry.relativePath} is not a regular file`);
160
+ if (sha256Hex(await readFile(absolute)) !== entry.sha256) {
161
+ return failure("retention_owned_record_changed", `${entry.relativePath} no longer matches its ownership receipt`);
162
+ }
163
+ return { ok: true };
164
+ }
165
+
166
+ async function gitCarriesExactRecord(rootDir: string, entry: Receipt): Promise<boolean> {
167
+ try {
168
+ const { stdout: listing } = await execFileAsync("git", ["ls-tree", "HEAD", "--", entry.relativePath], { cwd: rootDir, encoding: "utf8" });
169
+ if (!listing.startsWith("100644 blob ") || !listing.endsWith(`\t${entry.relativePath}\n`)) return false;
170
+ const { stdout } = await execFileAsync("git", ["show", `HEAD:${entry.relativePath}`], { cwd: rootDir, encoding: "buffer", maxBuffer: 20 * 1024 * 1024 });
171
+ return sha256Hex(stdout) === entry.sha256;
172
+ } catch {
173
+ return false;
174
+ }
175
+ }
176
+
177
+ export async function applyDeliveryRecordRetention(input: ApplyDeliveryRecordRetentionInput): Promise<RecordRetentionResult> {
178
+ if (!SCOPE.test(input.scope)) return failure("retention_scope_invalid", "retention scope must be a plain 1-128 character key");
179
+ if (!Number.isSafeInteger(input.keepSuperseded) || input.keepSuperseded < 0 || input.keepSuperseded > 100) {
180
+ return failure("retention_bound_invalid", "keepSuperseded must be an integer from 0 through 100");
181
+ }
182
+ if (!DIGEST.test(input.current.deliverableDigest) ||
183
+ deriveDeliveryRecordPath(input.recordBasePath, input.current.deliverableDigest) !== input.current.relativePath) {
184
+ return failure("retention_ledger_invalid", "the current path is not the configured per-digest record path");
185
+ }
186
+
187
+ const storage = await resolveRecordStorage(input.rootDir, { storageNamespace: input.storageNamespace, leaf: RETENTION_LEAF });
188
+ const ledgerPath = path.join(storage.storageDir, `${scopeDigest(input.scope)}.json`);
189
+ // Ownership is global to this worktree store. Serializing only one scope
190
+ // would let two scopes reserve the same absent digest path concurrently.
191
+ const lockPath = path.join(storage.storageDir, ".ownership.lock");
192
+ await mkdir(storage.storageDir, { recursive: true, mode: 0o700 });
193
+
194
+ try {
195
+ return await withProcessLock(lockPath, 5000, async () => {
196
+ const loaded = await readLedgers(storage.storageDir);
197
+ if (!loaded.ok) return loaded;
198
+ const existing = loaded.ledgers.get(input.scope);
199
+ if (existing !== undefined && existing.recordBasePath !== input.recordBasePath) {
200
+ return failure("retention_ledger_invalid", `retention scope ${input.scope} was created for a different delivery record path`);
201
+ }
202
+ let ledger: Ledger = existing ?? {
203
+ version: LEDGER_VERSION,
204
+ scope: input.scope,
205
+ recordBasePath: input.recordBasePath,
206
+ owned: [],
207
+ pendingPrune: [],
208
+ };
209
+
210
+ const currentSha = sha256Hex(input.current.bytes);
211
+ const currentReceipt: Receipt = {
212
+ relativePath: input.current.relativePath,
213
+ deliverableDigest: input.current.deliverableDigest,
214
+ sha256: currentSha,
215
+ };
216
+
217
+ // A retried delivery may return to a path whose deletion was durably
218
+ // scheduled by an interrupted later attempt. Cancel that deletion before
219
+ // touching any pending path; the invocation's verified current record wins.
220
+ const returningCurrent = ledger.pendingPrune.find((entry) => entry.relativePath === currentReceipt.relativePath);
221
+ if (returningCurrent !== undefined) {
222
+ if (returningCurrent.sha256 !== currentReceipt.sha256 || returningCurrent.deliverableDigest !== currentReceipt.deliverableDigest) {
223
+ return failure("retention_owned_record_changed", `${currentReceipt.relativePath} conflicts with its pending ownership receipt`);
224
+ }
225
+ ledger = {
226
+ ...ledger,
227
+ owned: [...ledger.owned, returningCurrent],
228
+ pendingPrune: ledger.pendingPrune.filter((entry) => entry.relativePath !== currentReceipt.relativePath),
229
+ };
230
+ await writeLedger(ledgerPath, ledger);
231
+ }
232
+
233
+ // Complete an interrupted, already-proved pruning decision first.
234
+ for (const pending of ledger.pendingPrune) {
235
+ const absolute = path.join(input.rootDir, pending.relativePath);
236
+ const present = await lstat(absolute).then(() => true, (error: NodeJS.ErrnoException) => error.code === "ENOENT" ? false : Promise.reject(error));
237
+ if (present) {
238
+ const verified = await verifyWorkingRecord(input.rootDir, pending);
239
+ if (!verified.ok) return verified;
240
+ if (!await gitCarriesExactRecord(input.rootDir, pending)) {
241
+ return failure("retention_history_missing", `${pending.relativePath} is not preserved byte-for-byte at HEAD`);
242
+ }
243
+ try {
244
+ await unlink(absolute);
245
+ } catch (error) {
246
+ return failure("retention_cleanup_failed", `could not remove ${pending.relativePath}: ${error instanceof Error ? error.message : String(error)}`);
247
+ }
248
+ }
249
+ }
250
+ if (ledger.pendingPrune.length > 0) {
251
+ ledger = { ...ledger, pendingPrune: [] };
252
+ await writeLedger(ledgerPath, ledger);
253
+ }
254
+
255
+ const currentIndex = ledger.owned.findIndex((entry) => entry.relativePath === currentReceipt.relativePath);
256
+ if (currentIndex >= 0 && (ledger.owned[currentIndex]!.sha256 !== currentSha ||
257
+ ledger.owned[currentIndex]!.deliverableDigest !== currentReceipt.deliverableDigest)) {
258
+ return failure("retention_owned_record_changed", `${currentReceipt.relativePath} conflicts with its ownership receipt`);
259
+ }
260
+ for (const other of loaded.ledgers.values()) {
261
+ if (other.scope !== input.scope && [...other.owned, ...other.pendingPrune].some((entry) => entry.relativePath === currentReceipt.relativePath)) {
262
+ return failure("retention_ownership_ambiguous", `${currentReceipt.relativePath} belongs to retention scope ${other.scope}`);
263
+ }
264
+ }
265
+ const currentStats = await lstat(path.join(input.rootDir, currentReceipt.relativePath)).catch((error: NodeJS.ErrnoException) =>
266
+ error.code === "ENOENT" ? undefined : Promise.reject(error));
267
+ if (currentIndex < 0 && currentStats !== undefined) {
268
+ return failure("retention_path_unowned", `${currentReceipt.relativePath} existed before retention scope ${input.scope} owned it`);
269
+ }
270
+
271
+ // Validate every prior live receipt before deciding what this attempt may remove.
272
+ for (const entry of ledger.owned) {
273
+ if (entry.relativePath === currentReceipt.relativePath && currentStats === undefined) continue;
274
+ const verified = await verifyWorkingRecord(input.rootDir, entry);
275
+ if (!verified.ok) return verified;
276
+ }
277
+ const ordered = [
278
+ ...ledger.owned.filter((entry) => entry.relativePath !== currentReceipt.relativePath),
279
+ currentReceipt,
280
+ ];
281
+ const retainCount = input.keepSuperseded + 1;
282
+ const pendingPrune = ordered.slice(0, Math.max(0, ordered.length - retainCount));
283
+ for (const entry of pendingPrune) {
284
+ if (!await gitCarriesExactRecord(input.rootDir, entry)) {
285
+ return failure("retention_history_missing", `${entry.relativePath} is not preserved byte-for-byte at HEAD`);
286
+ }
287
+ }
288
+
289
+ // Reserve only an absent output. A crash after this point can recreate it,
290
+ // but can never reinterpret a pre-existing file as scope-owned.
291
+ ledger = { ...ledger, owned: ordered, pendingPrune: [] };
292
+ await writeLedger(ledgerPath, ledger);
293
+ if (currentStats === undefined) {
294
+ try {
295
+ await input.writeCurrent();
296
+ } catch (error) {
297
+ return failure("retention_write_failed", error instanceof Error ? error.message : String(error));
298
+ }
299
+ }
300
+ const verifiedCurrent = await verifyWorkingRecord(input.rootDir, currentReceipt);
301
+ if (!verifiedCurrent.ok) return verifiedCurrent;
302
+
303
+ if (pendingPrune.length > 0) {
304
+ const kept = ordered.slice(pendingPrune.length);
305
+ ledger = { ...ledger, owned: kept, pendingPrune };
306
+ await writeLedger(ledgerPath, ledger);
307
+ for (const entry of pendingPrune) {
308
+ try {
309
+ await unlink(path.join(input.rootDir, entry.relativePath));
310
+ } catch (error) {
311
+ return failure("retention_cleanup_failed", `could not remove ${entry.relativePath}: ${error instanceof Error ? error.message : String(error)}`);
312
+ }
313
+ }
314
+ ledger = { ...ledger, pendingPrune: [] };
315
+ await writeLedger(ledgerPath, ledger);
316
+ }
317
+ return { ok: true, pruned: pendingPrune.map((entry) => entry.relativePath), ledgerPath };
318
+ });
319
+ } catch (error) {
320
+ if (error instanceof ProcessLockRefused) {
321
+ return failure("retention_lock_unavailable", `retention scope ${input.scope} is already being updated: ${error.reason}`);
322
+ }
323
+ throw error;
324
+ }
325
+ }
@@ -0,0 +1,284 @@
1
+ /**
2
+ * The shipped review-outcome evidence writer. It transcribes concluded host
3
+ * results without running reviewers or upgrading their self-attestation.
4
+ *
5
+ * Capture `delivery-harness review-context --json` before review, then pass
6
+ * that original document to `emit-review-evidence --context <path>` with a
7
+ * review-outcome/1 document on stdin naming its contextDigest. Reviewer names
8
+ * come from the resolved activated charters; for example a named result is
9
+ * `{ "id": "outcome-correctness", "result": "rejected" }`.
10
+ *
11
+ * The raw context and outcome remain digest-bound artifacts. Submission is
12
+ * still the authority on whether the resulting evidence satisfies the gate.
13
+ */
14
+ import { randomUUID } from "node:crypto";
15
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
16
+ import path from "node:path";
17
+
18
+ import {
19
+ resolveReviewCharters,
20
+ validateReviewedContext, parseReviewOutcome, deriveTelemetry, reviewerLists,
21
+ capturePortableEvidenceContext, repositoryEvidenceReader, createArtifactsPort,
22
+ ReviewInputError as OutcomeError,
23
+ BlockedError,
24
+ digestCanonical,
25
+ evaluatePreparationReceipt,
26
+ sha256Hex,
27
+ type CapturedCandidate,
28
+ type PreparationReceipt,
29
+ type HarnessConfig,
30
+ REVIEW_GREEN_1,
31
+ REVIEW_GREEN_2,
32
+ } from "@agent-delivery-harness/kernel";
33
+ import type { CommandContext } from "./boundary.ts";
34
+
35
+ // ── The charters the compiled policy activates ───────────────────────────────
36
+
37
+ /**
38
+ * The compiled policy this repository is judged under, and the installed
39
+ * generation whose archive carries the charters its lenses reference by
40
+ * identity. One activated lens is one reviewer, and the basename of the
41
+ * charter path the archive's manifest declares is the reviewer id the evidence
42
+ * carries — the same two documents `policy-projection-check.ts` compiles the
43
+ * projection from.
44
+ */
45
+ export const COMPILED_SNAPSHOT_FILE = ".agents/policy/compiled-snapshot.json";
46
+ export const INSTALLED_ARCHIVE_DIR = ".agent-skills/current";
47
+
48
+ export const CHARTER_EXTENSION = ".md";
49
+
50
+ /** Review payloads this provider can emit, in preference order. */
51
+ export const REVIEW_PAYLOAD_SPECS = [REVIEW_GREEN_2, REVIEW_GREEN_1] as const;
52
+ export const REVIEW_PAYLOAD_SPEC = REVIEW_GREEN_2;
53
+
54
+ /** The envelope spec the manifest declares. */
55
+ export const ENVELOPE_SPEC = "delivery-evidence/1";
56
+
57
+ /** The outcome document this emitter reads. */
58
+ export const OUTCOME_SPEC = "review-outcome/1";
59
+
60
+ /** This emitter's own version, carried in the manifest's provider triple. */
61
+ export const EMITTER_VERSION = "1.0.0";
62
+
63
+ export type { ResolvedCharter } from "@agent-delivery-harness/kernel";
64
+ export async function resolveActivatedCharters(rootDir: string, config?: Pick<HarnessConfig, "additionalReviewLenses">) {
65
+ return resolveReviewCharters(repositoryEvidenceReader(rootDir, createArtifactsPort()), config);
66
+ }
67
+ export async function resolveReviewerCharters(rootDir: string): Promise<string[]> {
68
+ return (await resolveActivatedCharters(rootDir)).map((charter) => charter.reviewerId).sort();
69
+ }
70
+
71
+ /** Exact review input, retained by the host before acquiring any outcomes. */
72
+ export const REVIEW_CONTEXT_SPEC = "review-context/1";
73
+
74
+ function manifestCandidate(captured: CapturedCandidate) {
75
+ return {
76
+ vcs: captured.vcs,
77
+ treeSha: captured.treeSha,
78
+ headSha: captured.headSha,
79
+ deliverable: { digest: captured.deliverable.digest, identity: captured.deliverable.identity },
80
+ base: { ref: captured.base.ref, tipSha: captured.base.tipSha, mergeBaseSha: captured.base.mergeBaseSha },
81
+ workspaceId: captured.workspaceId,
82
+ };
83
+ }
84
+
85
+ export async function buildReviewContext(
86
+ rootDir: string,
87
+ config: HarnessConfig,
88
+ candidate: CapturedCandidate,
89
+ receipt: PreparationReceipt,
90
+ ) {
91
+ const inputs = await capturePortableEvidenceContext(config, repositoryEvidenceReader(rootDir, createArtifactsPort()), receipt.preparationFingerprint);
92
+ const charters = inputs.reviewerCharters;
93
+ if (charters.length === 0 || inputs.release === null) throw new OutcomeError("the compiled policy activates no review lens or installed release");
94
+ const binding = {
95
+ gate: resolveGateBinding(config),
96
+ candidate: manifestCandidate(candidate),
97
+ preparationFingerprint: inputs.preparationFingerprint,
98
+ configurationDigest: inputs.configurationDigest,
99
+ policyDigest: inputs.policyDigest,
100
+ release: inputs.release,
101
+ workflowGraphSha256: inputs.workflowGraphSha256,
102
+ charters,
103
+ };
104
+ return { spec: REVIEW_CONTEXT_SPEC, digest: digestCanonical(binding), binding };
105
+ }
106
+
107
+ export type ReviewContextDocument = Awaited<ReturnType<typeof buildReviewContext>>;
108
+
109
+ export { validateReviewedContext, parseReviewOutcome, deriveTelemetry, reviewerLists, REVIEWER_RESULTS } from "@agent-delivery-harness/kernel";
110
+ export type { ReviewOutcome, ReviewerOutcome, ReviewerResult } from "@agent-delivery-harness/kernel";
111
+ export { OutcomeError };
112
+ // ── The gate this provider serves ────────────────────────────────────────────
113
+
114
+ export interface GateBinding {
115
+ readonly obligationId: string;
116
+ readonly providerId: string;
117
+ }
118
+
119
+ /**
120
+ * The obligation this manifest answers, and the provider it answers as, taken
121
+ * from the config rather than from a constant — a provider id that has drifted
122
+ * from the gate it serves is rejected as `unknown_provider` at evaluation, long
123
+ * after the review it describes has been paid for.
124
+ */
125
+ export function resolveGateBinding(config: HarnessConfig): GateBinding {
126
+ const obligations = config.obligations.filter((obligation) =>
127
+ REVIEW_PAYLOAD_SPECS.some((payloadSpec) => obligation.acceptedPayloadSpecs.includes(payloadSpec)),
128
+ );
129
+ if (obligations.length !== 1) {
130
+ throw new OutcomeError(
131
+ `the gate declares ${obligations.length} obligations accepting a supported review.green payload; this provider serves exactly one`,
132
+ );
133
+ }
134
+ const obligation = obligations[0]!;
135
+ if (obligation.providers.length !== 1) {
136
+ throw new OutcomeError(
137
+ `obligation ${obligation.id} names ${obligation.providers.length} providers; this provider serves exactly one`,
138
+ );
139
+ }
140
+ return { obligationId: obligation.id, providerId: obligation.providers[0]! };
141
+ }
142
+
143
+ function resolveReviewPayloadSpec(
144
+ config: HarnessConfig,
145
+ obligationId: string,
146
+ ): (typeof REVIEW_PAYLOAD_SPECS)[number] {
147
+ const obligation = config.obligations.find((entry) => entry.id === obligationId);
148
+ const payloadSpec = REVIEW_PAYLOAD_SPECS.find((candidate) => obligation?.acceptedPayloadSpecs.includes(candidate));
149
+ if (payloadSpec === undefined) throw new OutcomeError(`obligation ${obligationId} accepts no supported review.green payload`);
150
+ return payloadSpec;
151
+ }
152
+
153
+ // ── The manifest ─────────────────────────────────────────────────────────────
154
+
155
+ export interface EmitResult {
156
+ readonly manifestPath: string;
157
+ readonly runRoot: string;
158
+ }
159
+
160
+ /**
161
+ * Capture the candidate, allocate the run root, stamp one approval per
162
+ * approving reviewer, and write the manifest. The capture is deliberately the
163
+ * same call the recorder makes at submission (same config, same workspace id,
164
+ * same identity computation): anything else describes a tree the recorder will
165
+ * refuse to recognise.
166
+ */
167
+ export async function emitReviewEvidence(context: CommandContext, original: unknown, document: unknown): Promise<EmitResult> {
168
+ const { rootDir, config } = context;
169
+ const wiring = await context.wire();
170
+ const capture = await wiring.captureCandidate();
171
+ if (!capture.ok) throw new OutcomeError(`the candidate could not be captured: ${capture.code}`);
172
+ const captured = capture.candidate;
173
+ const preparation = await evaluatePreparationReceipt(rootDir, { config, candidate: captured }, wiring.storageOptions);
174
+ if (!preparation.prepared) throw new BlockedError([...preparation.blockers]);
175
+ const current = await buildReviewContext(rootDir, config, captured, preparation.receipt);
176
+ validateReviewedContext(original, current, document);
177
+ const charters = current.binding.charters.map((charter) => charter.reviewerId).sort();
178
+ const outcome = parseReviewOutcome(document, charters);
179
+ const binding = current.binding.gate;
180
+ const payloadSpec = resolveReviewPayloadSpec(config, binding.obligationId);
181
+ const candidate = manifestCandidate(captured);
182
+
183
+ const provider = {
184
+ id: binding.providerId,
185
+ version: EMITTER_VERSION,
186
+ // One emitter run is one evaluated pass over this candidate.
187
+ runId: `r-${randomUUID()}`,
188
+ finalPassId: outcome.finalPassId ?? "pass-1",
189
+ };
190
+ const reviewed = original as ReviewContextDocument;
191
+ const originalRunHistory = outcome.runHistory ?? [{
192
+ preparedTreeSha: reviewed.binding.candidate.treeSha,
193
+ evaluatedInPassId: provider.finalPassId,
194
+ }];
195
+ const finalEntry = originalRunHistory.at(-1)!;
196
+ if (finalEntry["preparedTreeSha"] !== reviewed.binding.candidate.treeSha ||
197
+ finalEntry["evaluatedInPassId"] !== provider.finalPassId) {
198
+ throw new OutcomeError("the supplied final review pass does not name the original reviewed candidate");
199
+ }
200
+ // ENV-9's final tree is a preparation coordinate. A proven neutral reuse
201
+ // projects that coordinate without claiming the reviewers saw a new raw
202
+ // tree, adding a round, or overwriting their original history.
203
+ const runHistory = originalRunHistory.map((entry, index) => index === originalRunHistory.length - 1
204
+ ? { ...entry, preparedTreeSha: captured.treeSha } : entry);
205
+
206
+ const allocation = await context.artifacts.allocateRunRoot({ providerId: provider.id, runId: provider.runId });
207
+ if (!allocation.ok) throw new OutcomeError(`the run root was refused: ${allocation.reason}`);
208
+ const runRoot = allocation.runRoot.path;
209
+
210
+ const lists = reviewerLists(charters, outcome);
211
+ await mkdir(path.join(runRoot, "reviewers"), { recursive: true });
212
+ const artifacts: { path: string; sha256: string; role: string }[] = [];
213
+ for (const [name, value] of [["review-context", original], ["review-outcome", document]] as const) {
214
+ const bytes = `${JSON.stringify(value, null, 2)}\n`;
215
+ await writeFile(path.join(runRoot, `${name}.json`), bytes, "utf8");
216
+ artifacts.push({ path: `${name}.json`, sha256: sha256Hex(bytes), role: name });
217
+ }
218
+ if (digestCanonical(reviewed.binding.candidate) !== digestCanonical(candidate)) {
219
+ const bytes = `${JSON.stringify({
220
+ spec: "review-context-projection/1",
221
+ basis: "unchanged-deliverable-and-review-inputs",
222
+ originalContextDigest: reviewed.digest,
223
+ reviewedCandidate: reviewed.binding.candidate,
224
+ preparedCandidate: candidate,
225
+ originalRunHistory,
226
+ reviewRoundAdded: false,
227
+ }, null, 2)}\n`;
228
+ await writeFile(path.join(runRoot, "review-context-projection.json"), bytes, "utf8");
229
+ artifacts.push({ path: "review-context-projection.json", sha256: sha256Hex(bytes), role: "review-context-projection" });
230
+ }
231
+ for (const reviewerId of lists.approved) {
232
+ // §9.2: the stamp re-states the whole binding, so each approval is
233
+ // independently interpretable in an audit.
234
+ const stamp = `${JSON.stringify(
235
+ {
236
+ schemaVersion: 1,
237
+ reviewerId,
238
+ result: "approved",
239
+ provider: { id: provider.id, runId: provider.runId, finalPassId: provider.finalPassId },
240
+ workspaceId: candidate.workspaceId,
241
+ candidate,
242
+ },
243
+ null,
244
+ 2,
245
+ )}\n`;
246
+ const relativePath = `reviewers/${reviewerId}.json`;
247
+ await writeFile(path.join(runRoot, relativePath), stamp, "utf8");
248
+ artifacts.push({ path: relativePath, sha256: sha256Hex(stamp), role: "reviewer-approval" });
249
+ }
250
+
251
+ const manifest = {
252
+ spec: ENVELOPE_SPEC,
253
+ provider,
254
+ candidate,
255
+ repository: null,
256
+ runHistory,
257
+ artifacts,
258
+ attestation: { level: "self", signatures: [] },
259
+ recordedAt: new Date().toISOString(),
260
+ claims: [
261
+ {
262
+ obligation: binding.obligationId,
263
+ payloadSpec,
264
+ payload: {
265
+ verdict: outcome.verdict,
266
+ finalized: true,
267
+ editedAfterFinalPass: false,
268
+ reviewers: {
269
+ selected: lists.selected,
270
+ completed: lists.completed,
271
+ failed: lists.failed,
272
+ timedOut: lists.timedOut,
273
+ },
274
+ findings: outcome.findings,
275
+ telemetry: { ...deriveTelemetry(outcome.findings, runHistory.length), ...(outcome.cost === undefined ? {} : { cost: outcome.cost }) },
276
+ },
277
+ },
278
+ ],
279
+ };
280
+
281
+ const manifestPath = path.join(runRoot, "manifest.json");
282
+ await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
283
+ return { manifestPath, runRoot };
284
+ }