immune-brain 2.8.3 → 3.0.2

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 (66) hide show
  1. package/.claude-plugin/marketplace.json +16 -0
  2. package/README.md +2 -2
  3. package/README.zh-CN.md +2 -2
  4. package/package.json +9 -2
  5. package/plugins/immune-brain/.claude-plugin/plugin.json +8 -0
  6. package/plugins/immune-brain/.mcp.json +8 -0
  7. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +354 -64
  8. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +74 -706
  9. package/plugins/immune-brain/.pi-extension/pi-canary-invocations.ts +1 -90
  10. package/plugins/immune-brain/.pi-extension/pi-canary-native-review.ts +13 -160
  11. package/plugins/immune-brain/.pi-extension/pi-canary-qa-findings.ts +1 -50
  12. package/plugins/immune-brain/.pi-extension/pi-canary-review-bundle.ts +1 -262
  13. package/plugins/immune-brain/.pi-extension/pi-canary-tool-failure.ts +3 -2
  14. package/plugins/immune-brain/.pi-extension/pi-canary-verification.ts +8 -229
  15. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +23 -5
  16. package/plugins/immune-brain/agents/immune-brain-reviewer.md +11 -0
  17. package/plugins/immune-brain/dist/claude/mcp-server.mjs +7514 -0
  18. package/plugins/immune-brain/dist/docs/reference/code-quality-guard.md +58 -0
  19. package/plugins/immune-brain/dist/docs/reference/immune-brain-config.md +1 -1
  20. package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +1 -1
  21. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +19 -13
  22. package/plugins/immune-brain/dist/imm-loop.md +6 -7
  23. package/plugins/immune-brain/dist/imm-planner.md +1 -1
  24. package/plugins/immune-brain/dist/imm-pr-fix.md +9 -0
  25. package/plugins/immune-brain/dist/role-prompts/code-review.md +33 -13
  26. package/plugins/immune-brain/dist/role-prompts/executor.md +14 -0
  27. package/plugins/immune-brain/dist/role-prompts/pr-fix.md +9 -0
  28. package/plugins/immune-brain/dist/role-prompts/test-fixer.md +7 -0
  29. package/plugins/immune-brain/hooks/hooks.json +55 -0
  30. package/plugins/immune-brain/runtime/assurance/coordinator.ts +836 -0
  31. package/plugins/immune-brain/runtime/assurance/enrollment.ts +6 -0
  32. package/plugins/immune-brain/runtime/assurance/host_port.ts +18 -0
  33. package/plugins/immune-brain/runtime/assurance/invocations.ts +90 -0
  34. package/plugins/immune-brain/runtime/assurance/qa_findings.ts +50 -0
  35. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +596 -0
  36. package/plugins/immune-brain/runtime/assurance/verification.ts +233 -0
  37. package/plugins/immune-brain/runtime/claude/capability.ts +67 -0
  38. package/plugins/immune-brain/runtime/claude/interaction.ts +70 -0
  39. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +789 -0
  40. package/plugins/immune-brain/runtime/claude/mcp_server.ts +363 -0
  41. package/plugins/immune-brain/runtime/claude/review_host.ts +645 -0
  42. package/plugins/immune-brain/runtime/commands/kernel.ts +221 -3
  43. package/plugins/immune-brain/runtime/github_issue_tracker.ts +2 -2
  44. package/plugins/immune-brain/runtime/kernel/application.ts +8 -5
  45. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +24 -13
  46. package/plugins/immune-brain/runtime/kernel/authority_port.ts +78 -115
  47. package/plugins/immune-brain/runtime/kernel/canary_application.ts +6 -4
  48. package/plugins/immune-brain/runtime/kernel/capability_registry.ts +89 -0
  49. package/plugins/immune-brain/runtime/kernel/completion.ts +64 -6
  50. package/plugins/immune-brain/runtime/kernel/enrollment.ts +189 -100
  51. package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +37 -80
  52. package/plugins/immune-brain/runtime/kernel/intent.ts +24 -0
  53. package/plugins/immune-brain/runtime/kernel/pi_canary_prepare.ts +31 -0
  54. package/plugins/immune-brain/runtime/kernel/reducer.ts +36 -13
  55. package/plugins/immune-brain/runtime/kernel/storage.ts +16 -16
  56. package/plugins/immune-brain/runtime/kernel/types.ts +32 -2
  57. package/plugins/immune-brain/runtime/kernel/validation.ts +107 -16
  58. package/plugins/immune-brain/runtime/loop_contract.ts +17 -2
  59. package/plugins/immune-brain/runtime/prompts/code-review.md +33 -13
  60. package/plugins/immune-brain/runtime/prompts/executor.md +14 -0
  61. package/plugins/immune-brain/runtime/prompts/pr-fix.md +9 -0
  62. package/plugins/immune-brain/runtime/prompts/test-fixer.md +7 -0
  63. package/plugins/immune-brain/runtime/v4_runtime.ts +5 -2
  64. package/plugins/immune-brain/runtime/workspace_scope.ts +191 -5
  65. package/plugins/immune-brain/skills/imm-loop/SKILL.md +3 -4
  66. package/plugins/immune-brain/skills/imm-planner/SKILL.md +2 -0
@@ -0,0 +1,596 @@
1
+ // Host-captured immutable review evidence.
2
+
3
+ import { execFileSync } from "node:child_process";
4
+ import { createHash } from "node:crypto";
5
+ import {
6
+ mkdtempSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ statSync,
10
+ readFileSync,
11
+ realpathSync,
12
+ chmodSync,
13
+ } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+ import {
17
+ captureGitTaskSnapshot,
18
+ captureGitTaskRevisionSnapshot,
19
+ pathMatchesScope,
20
+ taskDiffHash,
21
+ type GitTaskIndexEntry,
22
+ type GitTaskRevisionSnapshot,
23
+ } from "../workspace_scope";
24
+
25
+ const MAX_REVIEW_BUNDLE_BYTES = 2 * 1024 * 1024;
26
+
27
+ export interface ReviewOutcome {
28
+ status: "passed" | "failed" | "blocked";
29
+ summary: string;
30
+ }
31
+
32
+ export interface ReviewNeighborhoodFile {
33
+ mode: "100644" | "100755" | "120000";
34
+ oid: string;
35
+ base_mode: "100644" | "100755" | "120000";
36
+ base_oid: string;
37
+ fingerprint: string;
38
+ current_content: string;
39
+ }
40
+
41
+ export interface ReviewBundle {
42
+ contract: "assurance_kernel/review_bundle/v4";
43
+ root: string;
44
+ head: string;
45
+ scope: string[];
46
+ diff_hash: string;
47
+ dirty_files: Record<string, GitTaskIndexEntry & {
48
+ fingerprint: string;
49
+ current_content: string | null;
50
+ }>;
51
+ /** Present on newly captured bundles; optional only for legacy in-memory test fixtures. */
52
+ neighborhood_files?: Record<string, ReviewNeighborhoodFile>;
53
+ /** Explicit provenance for every bundled path. */
54
+ path_provenance?: Record<string, "diff" | "neighborhood">;
55
+ outcomes: Record<string, ReviewOutcome>;
56
+ bundle_digest: string;
57
+ }
58
+
59
+ function bundleDigest(bundle: Omit<ReviewBundle, "bundle_digest">): string {
60
+ return `sha256:${createHash("sha256").update(JSON.stringify(bundle)).digest("hex")}`;
61
+ }
62
+
63
+ const reviewUtf8 = new TextDecoder("utf-8", { fatal: true });
64
+
65
+ function readIndexBlob(
66
+ root: string,
67
+ path: string,
68
+ entry: Pick<GitTaskIndexEntry, "oid">,
69
+ ): string | null {
70
+ if (!entry.oid) return null;
71
+ const type = execFileSync("git", ["cat-file", "-t", entry.oid], {
72
+ cwd: root,
73
+ encoding: "utf8",
74
+ stdio: ["ignore", "pipe", "ignore"],
75
+ maxBuffer: 16,
76
+ timeout: 10_000,
77
+ }).trim();
78
+ if (type !== "blob") throw new Error(`index object is not a blob for ${path}`);
79
+ const sizeText = execFileSync("git", ["cat-file", "-s", entry.oid], {
80
+ cwd: root,
81
+ encoding: "utf8",
82
+ stdio: ["ignore", "pipe", "ignore"],
83
+ maxBuffer: 64,
84
+ timeout: 10_000,
85
+ }).trim();
86
+ const size = Number(sizeText);
87
+ if (!Number.isSafeInteger(size) || size < 0 || size > MAX_REVIEW_BUNDLE_BYTES)
88
+ throw new Error(`review file exceeds bounded size: ${path}`);
89
+ const bytes = execFileSync("git", ["cat-file", "blob", entry.oid], {
90
+ cwd: root,
91
+ encoding: "buffer",
92
+ stdio: ["ignore", "pipe", "ignore"],
93
+ maxBuffer: MAX_REVIEW_BUNDLE_BYTES + 1,
94
+ timeout: 10_000,
95
+ }) as Buffer;
96
+ if (bytes.length !== size) throw new Error(`index blob size changed during capture: ${path}`);
97
+ let content: string;
98
+ try {
99
+ content = reviewUtf8.decode(bytes);
100
+ } catch {
101
+ throw new Error(`review file is not valid UTF-8: ${path}`);
102
+ }
103
+ if (!Buffer.from(content, "utf8").equals(bytes))
104
+ throw new Error(`review file does not round-trip through UTF-8: ${path}`);
105
+ return content;
106
+ }
107
+
108
+ const GIT_OBJECT_ID = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
109
+ const REVIEW_MODES = new Set(["100644", "100755", "120000"] as const);
110
+
111
+ function nullRecords(bytes: Buffer): Buffer[] {
112
+ if (bytes.length === 0) return [];
113
+ if (bytes[bytes.length - 1] !== 0) throw new Error("git index listing is not NUL-terminated");
114
+ const records: Buffer[] = [];
115
+ let start = 0;
116
+ for (let index = 0; index < bytes.length; index += 1) {
117
+ if (bytes[index] !== 0) continue;
118
+ if (index === start) throw new Error("git index listing contains an empty record");
119
+ records.push(bytes.subarray(start, index));
120
+ start = index + 1;
121
+ }
122
+ return records;
123
+ }
124
+
125
+ function scopedNeighborhoodFiles(
126
+ root: string,
127
+ scope: string[],
128
+ dirtyPaths: Set<string>,
129
+ ): Record<string, ReviewNeighborhoodFile> {
130
+ const listing = execFileSync("git", ["ls-files", "--stage", "-z"], {
131
+ cwd: root,
132
+ encoding: "buffer",
133
+ stdio: ["ignore", "pipe", "ignore"],
134
+ maxBuffer: 32 * 1024 * 1024,
135
+ timeout: 10_000,
136
+ }) as Buffer;
137
+ const entries: Array<[string, ReviewNeighborhoodFile]> = [];
138
+ for (const record of nullRecords(listing)) {
139
+ const tab = record.indexOf(9);
140
+ if (tab < 0) throw new Error("git index entry is malformed");
141
+ const [mode, oid, stage] = record.subarray(0, tab).toString("ascii").split(" ");
142
+ let path: string;
143
+ try {
144
+ path = reviewUtf8.decode(record.subarray(tab + 1));
145
+ } catch {
146
+ throw new Error("git index path is not valid UTF-8");
147
+ }
148
+ if (!Buffer.from(path, "utf8").equals(record.subarray(tab + 1)))
149
+ throw new Error("git index path does not round-trip through UTF-8");
150
+ if (!scope.some((scopePath) => pathMatchesScope(path, scopePath)) || dirtyPaths.has(path)) continue;
151
+ if (!REVIEW_MODES.has(mode as "100644" | "100755" | "120000"))
152
+ throw new Error(`review neighborhood file has unsupported mode: ${path}`);
153
+ if (!GIT_OBJECT_ID.test(oid ?? "") || /^0+$/.test(oid ?? "") || stage !== "0")
154
+ throw new Error(`review neighborhood file has invalid index identity: ${path}`);
155
+ const content = readIndexBlob(root, path, { oid });
156
+ if (content === null) throw new Error(`review neighborhood file is missing index content: ${path}`);
157
+ entries.push([path, {
158
+ mode: mode as ReviewNeighborhoodFile["mode"],
159
+ oid,
160
+ base_mode: mode as ReviewNeighborhoodFile["base_mode"],
161
+ base_oid: oid,
162
+ fingerprint: `index:${mode}:${oid}`,
163
+ current_content: content,
164
+ }]);
165
+ }
166
+ entries.sort(([left], [right]) => left.localeCompare(right));
167
+ return Object.fromEntries(entries);
168
+ }
169
+
170
+ export function captureReviewBundle(
171
+ root: string,
172
+ scopeHint: unknown,
173
+ expectedDiffHash: string,
174
+ outcomes: Record<string, ReviewOutcome>,
175
+ ): ReviewBundle {
176
+ const before = captureGitTaskSnapshot(root, scopeHint);
177
+ if (taskDiffHash(root, before.scope) !== expectedDiffHash)
178
+ throw new Error("review task snapshot does not match assurance snapshot");
179
+ const dirtyFiles = Object.fromEntries(
180
+ Object.entries(before.staged_files).map(([path, entry]) => [path, {
181
+ ...entry,
182
+ fingerprint: `index:${entry.mode ?? "missing"}:${entry.oid ?? "missing"}`,
183
+ current_content: readIndexBlob(before.repository_root, path, entry),
184
+ }]),
185
+ );
186
+ const neighborhoodFiles = scopedNeighborhoodFiles(
187
+ before.repository_root,
188
+ before.scope,
189
+ new Set(Object.keys(dirtyFiles)),
190
+ );
191
+ const pathProvenance = Object.fromEntries([
192
+ ...Object.keys(dirtyFiles).map((path) => [path, "diff"] as const),
193
+ ...Object.keys(neighborhoodFiles).map((path) => [path, "neighborhood"] as const),
194
+ ].sort(([left], [right]) => left.localeCompare(right)));
195
+ const after = captureGitTaskSnapshot(root, before.scope);
196
+ if (
197
+ JSON.stringify(after) !== JSON.stringify(before) ||
198
+ taskDiffHash(root, before.scope) !== expectedDiffHash
199
+ ) {
200
+ throw new Error("task snapshot changed while capturing immutable review bundle");
201
+ }
202
+ const unsigned = {
203
+ contract: "assurance_kernel/review_bundle/v4" as const,
204
+ root: before.repository_root,
205
+ head: before.head,
206
+ scope: before.scope,
207
+ diff_hash: expectedDiffHash,
208
+ dirty_files: dirtyFiles,
209
+ neighborhood_files: neighborhoodFiles,
210
+ path_provenance: pathProvenance,
211
+ // Defensive copies so later caller mutation cannot alter the frozen record.
212
+ outcomes: Object.fromEntries(
213
+ Object.entries(outcomes).map(([id, outcome]) => [id, { ...outcome }]),
214
+ ),
215
+ };
216
+ if (Buffer.byteLength(JSON.stringify(unsigned)) > MAX_REVIEW_BUNDLE_BYTES) {
217
+ throw new Error("immutable review bundle exceeds bounded output limit");
218
+ }
219
+ return { ...unsigned, bundle_digest: bundleDigest(unsigned) };
220
+ }
221
+
222
+ export function verifyReviewBundle(bundle: ReviewBundle): void {
223
+ const { bundle_digest, ...unsigned } = bundle;
224
+ if (bundle_digest !== bundleDigest(unsigned)) throw new Error("immutable review bundle digest mismatch");
225
+ }
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // TaskRecord v4 transport: the reviewed bytes are an immutable synthetic Git
229
+ // revision, so the host envelope carries metadata only and source size, encoding,
230
+ // and binary content can never block Review.
231
+ // ---------------------------------------------------------------------------
232
+
233
+ const GIT_COMMIT_ID = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
234
+ const REVIEW_TASK_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
235
+ const REVISION_DIFF_HASH = /^sha256:[a-f0-9]{64}$/;
236
+ export const REVIEW_REF_NAMESPACE = "refs/immune-brain/reviews";
237
+ const SNAPSHOT_IDENTITY = {
238
+ name: "Immune-Brain Assurance",
239
+ email: "assurance@immune-brain.local",
240
+ date: "1970-01-01T00:00:00 +0000",
241
+ };
242
+
243
+ export interface ReviewRevision {
244
+ contract: "assurance_kernel/review_revision/v1";
245
+ base_head: string;
246
+ review_tree: string;
247
+ review_commit: string;
248
+ review_ref: string;
249
+ diff_hash: string;
250
+ manifest_digest?: string;
251
+ }
252
+
253
+ export interface ReviewManifestV5 {
254
+ contract: "assurance_kernel/review_manifest/v5";
255
+ task_id: string;
256
+ intent_revision: number;
257
+ intent_content_hash: string;
258
+ scope: string[];
259
+ base_head: string;
260
+ review_tree: string;
261
+ review_commit: string;
262
+ review_ref: string;
263
+ changed_paths: Record<string, GitTaskIndexEntry>;
264
+ diff_hash: string;
265
+ outcomes: Record<string, ReviewOutcome>;
266
+ record_revision: string;
267
+ workspace_revision: string;
268
+ lifecycle: string;
269
+ artifact_state: string;
270
+ risk: string;
271
+ manifest_digest: string;
272
+ }
273
+
274
+ function manifestDigest(manifest: Omit<ReviewManifestV5, "manifest_digest">): string {
275
+ return `sha256:${createHash("sha256").update(JSON.stringify(manifest)).digest("hex")}`;
276
+ }
277
+
278
+ function gitEvidenceBytes(root: string, args: string[], extraEnv: Record<string, string> = {}): Buffer {
279
+ return execFileSync("git", args, {
280
+ cwd: root,
281
+ encoding: "buffer",
282
+ stdio: ["ignore", "pipe", "pipe"],
283
+ maxBuffer: 16 * 1024 * 1024,
284
+ timeout: 30_000,
285
+ env: { ...process.env, ...extraEnv },
286
+ }) as Buffer;
287
+ }
288
+
289
+ function gitEvidence(root: string, args: string[], extraEnv: Record<string, string> = {}): string {
290
+ return gitEvidenceBytes(root, args, extraEnv).toString("utf8").trim();
291
+ }
292
+
293
+ function decodeNullPaths(bytes: Buffer, label: string): string[] {
294
+ if (bytes.length === 0) return [];
295
+ if (bytes[bytes.length - 1] !== 0) throw new Error(`${label} is not NUL-terminated`);
296
+ const paths: string[] = [];
297
+ let start = 0;
298
+ for (let index = 0; index < bytes.length; index += 1) {
299
+ if (bytes[index] !== 0) continue;
300
+ if (index === start) throw new Error(`${label} contains an empty path`);
301
+ const raw = bytes.subarray(start, index);
302
+ let path: string;
303
+ try {
304
+ path = reviewUtf8.decode(raw);
305
+ } catch {
306
+ throw new Error(`${label} contains invalid UTF-8 path bytes`);
307
+ }
308
+ if (!Buffer.from(path, "utf8").equals(raw)) throw new Error(`${label} path does not round-trip through UTF-8`);
309
+ paths.push(path);
310
+ start = index + 1;
311
+ }
312
+ return paths;
313
+ }
314
+
315
+ function reviewRefTaskSegment(taskId: string): string {
316
+ if (!REVIEW_TASK_ID.test(taskId)) throw new Error("review task id has invalid identity");
317
+ if (!taskId.includes("..") && !taskId.endsWith(".")) return taskId;
318
+ return `_${Buffer.from(taskId, "utf8").toString("base64url")}`;
319
+ }
320
+
321
+ function taskIdFromReviewRefSegment(segment: string): string {
322
+ if (!segment.startsWith("_")) {
323
+ if (reviewRefTaskSegment(segment) !== segment) throw new Error("review ref task segment has invalid identity");
324
+ return segment;
325
+ }
326
+ const taskId = Buffer.from(segment.slice(1), "base64url").toString("utf8");
327
+ if (reviewRefTaskSegment(taskId) !== segment) throw new Error("review ref task segment has invalid identity");
328
+ return taskId;
329
+ }
330
+
331
+ function reviewRef(taskId: string, reviewCommit: string): string {
332
+ const taskSegment = reviewRefTaskSegment(taskId);
333
+ if (!GIT_COMMIT_ID.test(reviewCommit)) throw new Error("review commit has invalid identity");
334
+ return `${REVIEW_REF_NAMESPACE}/${taskSegment}/${reviewCommit}`;
335
+ }
336
+
337
+ /** The scoped delta of one captured revision, as base->tree paths. */
338
+ function revisionDelta(snapshot: GitTaskRevisionSnapshot): string[] {
339
+ return Object.keys(snapshot.changed_paths).sort(compareRevisionPaths);
340
+ }
341
+
342
+ function compareRevisionPaths(left: string, right: string): number {
343
+ return left < right ? -1 : left > right ? 1 : 0;
344
+ }
345
+
346
+ /**
347
+ * Publish the task-scoped synthetic commit for one already-captured revision
348
+ * snapshot. The ref name embeds the commit id, so an existing ref can only ever
349
+ * point at that same immutable commit; a non-resolving or mismatched target
350
+ * fails loudly and is never overwritten.
351
+ */
352
+ export function publishReviewRevision(
353
+ root: string,
354
+ snapshot: GitTaskRevisionSnapshot,
355
+ diffHash: string,
356
+ taskId: string,
357
+ ): ReviewRevision {
358
+ if (snapshot.base_head !== snapshot.base_head.toLowerCase() || !GIT_COMMIT_ID.test(snapshot.base_head))
359
+ throw new Error("review revision base has invalid identity");
360
+ if (!REVISION_DIFF_HASH.test(diffHash)) throw new Error("review revision diff hash has invalid identity");
361
+ const indexDirectory = mkdtempSync(join(tmpdir(), "imm-review-index-"));
362
+ try {
363
+ const indexFile = join(indexDirectory, "index");
364
+ const env = { GIT_INDEX_FILE: indexFile };
365
+ gitEvidence(root, ["read-tree", snapshot.base_tree], env);
366
+ for (const [path, entry] of Object.entries(snapshot.changed_paths)) {
367
+ if (entry.oid && entry.mode)
368
+ gitEvidence(root, ["update-index", "--add", "--cacheinfo", `${entry.mode},${entry.oid},${path}`], env);
369
+ else gitEvidence(root, ["update-index", "--force-remove", "--", path], env);
370
+ }
371
+ const reviewTree = gitEvidence(root, ["write-tree"], env);
372
+ if (!GIT_COMMIT_ID.test(reviewTree)) throw new Error("review synthetic tree write failed");
373
+ const message = `Immune-Brain review snapshot task=${taskId} base=${snapshot.base_head} diff=${diffHash}`;
374
+ const reviewCommit = gitEvidence(
375
+ root,
376
+ ["commit-tree", reviewTree, "-p", snapshot.base_head, "-m", message],
377
+ {
378
+ ...env,
379
+ GIT_AUTHOR_NAME: SNAPSHOT_IDENTITY.name,
380
+ GIT_AUTHOR_EMAIL: SNAPSHOT_IDENTITY.email,
381
+ GIT_AUTHOR_DATE: SNAPSHOT_IDENTITY.date,
382
+ GIT_COMMITTER_NAME: SNAPSHOT_IDENTITY.name,
383
+ GIT_COMMITTER_EMAIL: SNAPSHOT_IDENTITY.email,
384
+ GIT_COMMITTER_DATE: SNAPSHOT_IDENTITY.date,
385
+ GPG_PROGRAM: "",
386
+ },
387
+ );
388
+ // Determinism proof: the synthetic commit's tree-to-tree delta against the
389
+ // Enrollment base must be exactly the captured scoped delta, nothing more.
390
+ if (!GIT_COMMIT_ID.test(reviewCommit)) throw new Error("review synthetic commit write failed");
391
+ const ref = reviewRef(taskId, reviewCommit);
392
+ const expected = revisionDelta(snapshot);
393
+ const actual = decodeNullPaths(
394
+ gitEvidenceBytes(root, ["diff", "--no-renames", "--name-only", "-z", snapshot.base_head, reviewCommit]),
395
+ "review synthetic commit paths",
396
+ ).sort(compareRevisionPaths);
397
+ if (JSON.stringify(actual) !== JSON.stringify(expected))
398
+ throw new Error(
399
+ `review synthetic commit delta mismatch: expected ${expected.length} paths, got ${actual.length}`,
400
+ );
401
+ const existing = (() => {
402
+ try {
403
+ return gitEvidence(root, ["rev-parse", "--verify", ref]);
404
+ } catch {
405
+ return null;
406
+ }
407
+ })();
408
+ if (existing !== null && existing !== reviewCommit)
409
+ throw new Error(`review ref ${ref} resolves to ${existing}, not ${reviewCommit}`);
410
+ if (existing === null) gitEvidence(root, ["update-ref", ref, reviewCommit, ""]);
411
+ return {
412
+ contract: "assurance_kernel/review_revision/v1",
413
+ base_head: snapshot.base_head,
414
+ review_tree: reviewTree,
415
+ review_commit: reviewCommit,
416
+ review_ref: ref,
417
+ diff_hash: diffHash,
418
+ };
419
+ } finally {
420
+ rmSync(indexDirectory, { recursive: true, force: true });
421
+ }
422
+ }
423
+
424
+ function publishInput(
425
+ root: string,
426
+ input: {
427
+ taskId: string;
428
+ baseHead: string;
429
+ scopeHint: unknown;
430
+ expectedDiffHash: string;
431
+ },
432
+ ): { snapshot: GitTaskRevisionSnapshot; revision: ReviewRevision } {
433
+ if (typeof input.baseHead !== "string" || !GIT_COMMIT_ID.test(input.baseHead))
434
+ throw new Error("review requires a TaskRecord v4 git_base_head");
435
+ if (!REVISION_DIFF_HASH.test(input.expectedDiffHash))
436
+ throw new Error("review task revision hash has invalid identity");
437
+ const snapshot = captureGitTaskRevisionSnapshot(root, input.scopeHint, input.baseHead);
438
+ const recomputed = `sha256:${createHash("sha256").update(JSON.stringify(snapshot)).digest("hex")}`;
439
+ if (recomputed !== input.expectedDiffHash)
440
+ throw new Error("review task revision does not match assurance snapshot");
441
+ return { snapshot, revision: publishReviewRevision(snapshot.repository_root, snapshot, recomputed, input.taskId) };
442
+ }
443
+
444
+ export function captureReviewManifest(
445
+ root: string,
446
+ input: {
447
+ taskId: string;
448
+ baseHead: string;
449
+ scopeHint: unknown;
450
+ expectedDiffHash: string;
451
+ intentRevision: number;
452
+ intentContentHash: string;
453
+ recordRevision: string;
454
+ workspaceRevision: string;
455
+ lifecycle: string;
456
+ artifactState: string;
457
+ risk: string;
458
+ outcomes: Record<string, ReviewOutcome>;
459
+ },
460
+ ): ReviewManifestV5 {
461
+ const { snapshot, revision } = publishInput(root, input);
462
+ const unsigned = {
463
+ contract: "assurance_kernel/review_manifest/v5" as const,
464
+ task_id: input.taskId,
465
+ intent_revision: input.intentRevision,
466
+ intent_content_hash: input.intentContentHash,
467
+ scope: snapshot.scope,
468
+ base_head: revision.base_head,
469
+ review_tree: revision.review_tree,
470
+ review_commit: revision.review_commit,
471
+ review_ref: revision.review_ref,
472
+ changed_paths: snapshot.changed_paths,
473
+ diff_hash: revision.diff_hash,
474
+ outcomes: Object.fromEntries(
475
+ Object.entries(input.outcomes).map(([id, outcome]) => [id, { ...outcome }]),
476
+ ),
477
+ record_revision: input.recordRevision,
478
+ workspace_revision: input.workspaceRevision,
479
+ lifecycle: input.lifecycle,
480
+ artifact_state: input.artifactState,
481
+ risk: input.risk,
482
+ };
483
+ const manifest = { ...unsigned, manifest_digest: manifestDigest(unsigned) };
484
+ if (Buffer.byteLength(JSON.stringify(manifest)) > MAX_REVIEW_BUNDLE_BYTES)
485
+ throw new Error("immutable review manifest metadata exceeds bounded output limit");
486
+ return manifest;
487
+ }
488
+
489
+ /** Publish and return the exact revision a v4 task must use. */
490
+ export function ensureReviewRevision(
491
+ root: string,
492
+ input: { taskId: string; baseHead: string; scopeHint: unknown; expectedDiffHash: string },
493
+ ): ReviewRevision {
494
+ return publishInput(root, input).revision;
495
+ }
496
+
497
+ export function listReviewRefs(root: string): Array<{ ref: string; commit: string; taskId: string }> {
498
+ const output = gitEvidence(root, ["for-each-ref", "--format=%(refname) %(objectname)", `${REVIEW_REF_NAMESPACE}/`]);
499
+ const refs: Array<{ ref: string; commit: string; taskId: string }> = [];
500
+ for (const line of output.split("\n").filter(Boolean)) {
501
+ const [ref, commit] = line.split(" ");
502
+ const segment = ref?.slice(REVIEW_REF_NAMESPACE.length + 1).split("/")[0] ?? "";
503
+ let taskId: string;
504
+ try {
505
+ taskId = taskIdFromReviewRefSegment(segment);
506
+ } catch {
507
+ continue;
508
+ }
509
+ if (!ref || !commit || !taskId) continue;
510
+ refs.push({ ref, commit, taskId });
511
+ }
512
+ return refs;
513
+ }
514
+
515
+ /**
516
+ * Remove refs with no live TaskRecord owner. A ref is evidence transport, never
517
+ * workflow authority, so this is idempotent and its failure is non-fatal.
518
+ */
519
+ export function reconcileReviewRefs(
520
+ root: string,
521
+ liveTaskIds: Set<string>,
522
+ ): { removed: string[]; failed: string[] } {
523
+ const removed: string[] = [];
524
+ const failed: string[] = [];
525
+ for (const entry of listReviewRefs(root)) {
526
+ if (liveTaskIds.has(entry.taskId)) continue;
527
+ try {
528
+ gitEvidence(root, ["update-ref", "-d", entry.ref, entry.commit]);
529
+ removed.push(entry.ref);
530
+ } catch {
531
+ failed.push(entry.ref);
532
+ }
533
+ }
534
+ return { removed, failed };
535
+ }
536
+
537
+ export function deleteReviewRef(root: string, revision: ReviewRevision): void {
538
+ const parts = revision.review_ref.split("/");
539
+ let validRef = false;
540
+ if (
541
+ parts.length === 5 &&
542
+ parts[0] === "refs" &&
543
+ parts[1] === "immune-brain" &&
544
+ parts[2] === "reviews" &&
545
+ parts[4] === revision.review_commit
546
+ ) {
547
+ try {
548
+ taskIdFromReviewRefSegment(parts[3] ?? "");
549
+ validRef = true;
550
+ } catch {
551
+ validRef = false;
552
+ }
553
+ }
554
+ if (!validRef || !GIT_COMMIT_ID.test(revision.review_commit))
555
+ throw new Error("review ref has invalid identity");
556
+ gitEvidence(root, ["update-ref", "-d", revision.review_ref, revision.review_commit]);
557
+ }
558
+
559
+ export function writeNativeReviewEvidence(payload: unknown): { path: string; remove(): void } {
560
+ const rawDirectory = mkdtempSync(join(tmpdir(), "imm-canary-native-review-"));
561
+ try {
562
+ const directory = realpathSync(rawDirectory);
563
+ chmodSync(directory, 0o755);
564
+ const path = join(directory, "evidence.json");
565
+ writeFileSync(path, JSON.stringify(payload), { encoding: "utf8", mode: 0o644, flag: "wx" });
566
+ // Fail-closed artifact check: the immutable evidence must be readable
567
+ // before a reserved review is dispatched.
568
+ assertReviewArtifact(path);
569
+ return {
570
+ path,
571
+ remove: () => rmSync(directory, { recursive: true, force: true }),
572
+ };
573
+ } catch (error) {
574
+ rmSync(rawDirectory, { recursive: true, force: true });
575
+ throw error;
576
+ }
577
+ }
578
+
579
+ /**
580
+ * Fail-closed artifact check: the immutable evidence file must exist and be
581
+ * readable before a reserved review is dispatched. Missing or unreadable
582
+ * artifacts throw so dispatch writes zero authority and exposes an explicit
583
+ * re-reserve path.
584
+ */
585
+ export function assertReviewArtifact(path: string): void {
586
+ const targetPath = realpathSync(path);
587
+ let stat;
588
+ try {
589
+ stat = statSync(targetPath);
590
+ } catch {
591
+ throw new Error(`review evidence artifact is missing or empty: ${path}`);
592
+ }
593
+ if (!stat.isFile() || stat.size === 0) throw new Error(`review evidence artifact is missing or empty: ${path}`);
594
+ const read = readFileSync(targetPath, { encoding: "utf8" });
595
+ if (read.trim().length === 0) throw new Error(`review evidence artifact is empty: ${path}`);
596
+ }