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
@@ -1,90 +1 @@
1
- // P2B2 task-scoped invocation registry. NOT part of the Kernel runtime graph.
2
- // One linear state transition per task: `open -> committed | cancelled`.
3
- // Concurrent assure/authorize operations for the same task are rejected;
4
- // timeout/cancel wins `open -> cancelled` first; a successful continuation
5
- // must win `open -> committed` immediately before mint/apply; application
6
- // failure leaves the invocation closed so retry requires a new invocation.
7
- // No memory/file cross-transaction atomicity is claimed.
8
-
9
- export type InvocationState = "open" | "committed" | "cancelled";
10
-
11
- export interface InvocationToken {
12
- readonly task_id: string;
13
- readonly nonce: string;
14
- }
15
-
16
- export interface InvocationRegistry {
17
- /** Open a new invocation for one task; rejects if one is already open. */
18
- open(taskId: string): InvocationToken;
19
- /**
20
- * Linearization point: wins `open -> committed` for exactly this token.
21
- * Throws for a foreign token or a token whose invocation already ended.
22
- * Only the winner may mint/apply a capability.
23
- */
24
- commit(token: InvocationToken): void;
25
- /** Wins `open -> cancelled` (timeout/cancel/abort path). */
26
- cancel(token: InvocationToken): void;
27
- /** Inspect the current state of the caller's token. */
28
- stateOf(token: InvocationToken): InvocationState;
29
- /** Whether any invocation for the task is currently open. */
30
- isOpen(taskId: string): boolean;
31
- /** Internal task state (for tests). */
32
- states(): Record<string, InvocationState>;
33
- }
34
-
35
- export function createInvocationRegistry(): InvocationRegistry {
36
- const states = new Map<string, { token: InvocationToken; state: InvocationState }>();
37
-
38
- function tokenOf(taskId: string, nonce: string): InvocationToken {
39
- return Object.freeze({ task_id: taskId, nonce });
40
- }
41
-
42
- function entryOf(token: InvocationToken): { token: InvocationToken; state: InvocationState } {
43
- const entry = states.get(token.task_id);
44
- if (!entry || entry.token.nonce !== token.nonce)
45
- throw new Error("invocation token is not recognized for this task");
46
- return entry;
47
- }
48
-
49
- return {
50
- open(taskId: string): InvocationToken {
51
- if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(taskId))
52
- throw new Error("task id is not a safe file identity");
53
- const existing = states.get(taskId);
54
- if (existing && existing.state === "open") {
55
- throw new Error(
56
- `task ${taskId} already has an open invocation; concurrent assure/authorize is rejected`,
57
- );
58
- }
59
- // A closed (committed/cancelled) invocation is replaced by a fresh
60
- // one: retry requires a new invocation.
61
- const token = tokenOf(taskId, `${taskId}:${Date.now()}:${Math.random().toString(36).slice(2, 10)}`);
62
- states.set(taskId, { token, state: "open" });
63
- return token;
64
- },
65
- commit(token: InvocationToken): void {
66
- const entry = entryOf(token);
67
- if (entry.state !== "open")
68
- throw new Error(
69
- `invocation for task ${token.task_id} is already ${entry.state}; a new invocation is required`,
70
- );
71
- entry.state = "committed";
72
- },
73
- cancel(token: InvocationToken): void {
74
- const entry = entryOf(token);
75
- if (entry.state === "open") entry.state = "cancelled";
76
- // Cancel on an already-closed invocation is a no-op (idempotent).
77
- },
78
- stateOf(token: InvocationToken): InvocationState {
79
- return entryOf(token).state;
80
- },
81
- isOpen(taskId: string): boolean {
82
- return states.get(taskId)?.state === "open";
83
- },
84
- states(): Record<string, InvocationState> {
85
- const out: Record<string, InvocationState> = {};
86
- for (const [taskId, entry] of states) out[taskId] = entry.state;
87
- return out;
88
- },
89
- };
90
- }
1
+ export * from "../runtime/assurance/invocations";
@@ -1,31 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
2
 
3
- export const STANDARD_AGENT_TOOL = "Agent";
4
-
5
- export interface NativeReviewResult {
6
- agentId: string;
7
- result: string;
8
- status: string;
9
- durationMs?: number;
10
- tokens?: { input: number; output: number; total: number };
11
- }
12
-
13
- const NATIVE_REVIEW_FAILURE_STATUSES = new Set([
14
- "failed",
15
- "error",
16
- "cancelled",
17
- "stopped",
18
- "terminated",
19
- ]);
20
-
21
- export function nativeReviewResultIsFailure(result: NativeReviewResult): boolean {
22
- return NATIVE_REVIEW_FAILURE_STATUSES.has(result.status);
23
- }
24
-
25
3
  export interface ReservedAgentParams {
26
4
  subagent_type: "Review";
27
5
  description: string;
28
6
  prompt: string;
7
+ name: "";
8
+ model: "";
9
+ thinking: "";
29
10
  inherit_context: false;
30
11
  isolated: true;
31
12
  isolation: "worktree";
@@ -35,24 +16,18 @@ export interface ReservedAgentParams {
35
16
  schedule: "";
36
17
  }
37
18
 
38
- export interface ToolExecutionEndLike {
39
- toolName?: string;
40
- toolCallId?: string;
41
- args?: unknown;
42
- input?: unknown;
43
- result?: unknown;
44
- content?: unknown;
45
- details?: unknown;
46
- isError?: boolean;
47
- }
48
-
49
- export interface ToolResultLike extends ToolExecutionEndLike {}
50
-
51
19
  export function reservedAgentDescription(taskId: string, operationId: string): string {
52
20
  return `Review ${shortId(taskId)} ${shortId(operationId)}`;
53
21
  }
54
22
 
55
23
  export function semanticNeighborhoodReviewPrompt(prompt: string): string {
24
+ if (prompt.includes("assurance_kernel/review_manifest/v5")) {
25
+ return [
26
+ prompt,
27
+ `For every changed_paths entry, inspect the immutable Git objects with git diff and git show against the supplied base_head and review_commit. Verify the review_commit parent and tree before analyzing it; do not read live worktree files or expect source bytes in the manifest.`,
28
+ `Unchanged files are outside mutation authority. Read an unchanged path only when directly required by an acceptance assertion, a changed caller, or the same state machine, and cite the path and reason in the finding. Do not enumerate neighborhood files or explore the repository broadly.`,
29
+ ].join("\n");
30
+ }
56
31
  return [
57
32
  prompt,
58
33
  `For every neighborhood_files entry, verify git rev-parse HEAD:<path> equals base_oid, then analyze current_content exclusively from the immutable bundle. path_provenance is authoritative: diff marks task changes and neighborhood marks unchanged same-state-machine context selected only from scope_hint.`,
@@ -70,6 +45,9 @@ export function reservedAgentParams(input: {
70
45
  subagent_type: "Review",
71
46
  description: reservedAgentDescription(input.taskId, input.operationId),
72
47
  prompt: semanticNeighborhoodReviewPrompt(input.prompt),
48
+ name: "",
49
+ model: "",
50
+ thinking: "",
73
51
  inherit_context: false,
74
52
  isolated: true,
75
53
  isolation: "worktree",
@@ -80,135 +58,10 @@ export function reservedAgentParams(input: {
80
58
  };
81
59
  }
82
60
 
83
- export function matchesReservedAgentArgs(
84
- args: unknown,
85
- params: ReservedAgentParams,
86
- ): boolean {
87
- if (!isRecord(args)) return false;
88
- const expectedKeys = Object.entries(params)
89
- .filter(([, value]) => value !== undefined)
90
- .map(([key]) => key);
91
- // Host serializes Agent input, may omit falsy inherit_context, and resolves
92
- // execution metadata. name/model/thinking are host-owned and not Review
93
- // authority identity: strip them, then require the exact reserved field set.
94
- const normalizedArgs: Record<string, unknown> = { ...(args as Record<string, unknown>) };
95
- delete normalizedArgs["name"];
96
- delete normalizedArgs["model"];
97
- delete normalizedArgs["thinking"];
98
- if (!Object.hasOwn(normalizedArgs, "inherit_context") && (params as unknown as Record<string, unknown>)["inherit_context"] === false) {
99
- normalizedArgs["inherit_context"] = false;
100
- }
101
- if (
102
- Object.keys(normalizedArgs).length !== expectedKeys.length ||
103
- expectedKeys.some((key) => !Object.hasOwn(normalizedArgs, key))
104
- ) return false;
105
- return (
106
- normalizedArgs.subagent_type === params.subagent_type &&
107
- normalizedArgs.description === params.description &&
108
- normalizedArgs.prompt === params.prompt &&
109
- normalizedArgs.inherit_context === params.inherit_context &&
110
- normalizedArgs.isolated === params.isolated &&
111
- normalizedArgs.isolation === params.isolation &&
112
- normalizedArgs.run_in_background === params.run_in_background &&
113
- normalizedArgs.max_turns === params.max_turns &&
114
- normalizedArgs.resume === params.resume &&
115
- normalizedArgs.schedule === params.schedule
116
- );
117
- }
118
-
119
- export function parseForegroundAgentResult(
120
- event: ToolResultLike,
121
- fallbackAgentId: string,
122
- ): NativeReviewResult {
123
- if (event.toolName !== STANDARD_AGENT_TOOL || event.isError)
124
- throw new Error("foreground Agent result is unavailable");
125
- const details = isRecord(event.details)
126
- ? event.details
127
- : toolDetails(event.result);
128
- const status = stringField(details, "status") ?? "completed";
129
- if (NATIVE_REVIEW_FAILURE_STATUSES.has(status)) {
130
- throw new Error(stringField(details, "error") ?? `foreground Agent ${status}`);
131
- }
132
- if (!["completed", "steered", "wrapped_up"].includes(status))
133
- throw new Error(`foreground Agent returned unsupported status: ${status}`);
134
- const content = event.content ?? event.result;
135
- const result = toolText(content);
136
- if (!result.trim()) throw new Error("foreground Agent returned no review result");
137
- const agentId = stringField(details, "agentId")
138
- ?? stringField(details, "agent_id")
139
- ?? `foreground-${fallbackAgentId}`;
140
- const durationMs = parseDurationMs(result);
141
- return {
142
- agentId,
143
- result,
144
- status,
145
- ...(durationMs === undefined ? {} : { durationMs }),
146
- };
147
- }
148
-
149
61
  export function promptDigest(prompt: string): string {
150
62
  return `sha256:${createHash("sha256").update(prompt).digest("hex")}`;
151
63
  }
152
64
 
153
- export function toolResultText(result: unknown): string {
154
- return toolText(result);
155
- }
156
-
157
- export function toolResultDetails(result: unknown): Record<string, unknown> | null {
158
- return toolDetails(result);
159
- }
160
-
161
- function toolDetails(result: unknown): Record<string, unknown> | null {
162
- if (!isRecord(result)) return null;
163
- if (isRecord(result.details)) return result.details;
164
- if (Array.isArray(result.content)) {
165
- for (const item of result.content) {
166
- if (isRecord(item) && isRecord(item.details)) return item.details;
167
- }
168
- }
169
- return result;
170
- }
171
-
172
- function toolText(result: unknown): string {
173
- if (typeof result === "string") return result;
174
- if (Array.isArray(result)) {
175
- return result
176
- .map((item) => (isRecord(item) && typeof item.text === "string" ? item.text : ""))
177
- .filter(Boolean)
178
- .join("\n");
179
- }
180
- if (!isRecord(result)) return "";
181
- if (typeof result.text === "string") return result.text;
182
- if (Array.isArray(result.content)) {
183
- return result.content
184
- .map((item) => (isRecord(item) && typeof item.text === "string" ? item.text : ""))
185
- .filter(Boolean)
186
- .join("\n");
187
- }
188
- return "";
189
- }
190
-
191
- function parseDurationMs(text: string): number | undefined {
192
- const match = text.match(/Duration:\s+([0-9.]+)\s*(ms|s|m)?/i);
193
- if (!match) return undefined;
194
- const amount = Number(match[1]);
195
- if (!Number.isFinite(amount)) return undefined;
196
- switch ((match[2] ?? "ms").toLowerCase()) {
197
- case "s": return Math.round(amount * 1000);
198
- case "m": return Math.round(amount * 60_000);
199
- default: return Math.round(amount);
200
- }
201
- }
202
-
203
- function stringField(record: Record<string, unknown> | null, key: string): string | null {
204
- const value = record?.[key];
205
- return typeof value === "string" && value.length > 0 ? value : null;
206
- }
207
-
208
65
  function shortId(value: string): string {
209
66
  return value.replace(/[^A-Za-z0-9]/g, "").slice(-8) || "review";
210
67
  }
211
-
212
- function isRecord(value: unknown): value is Record<string, unknown> {
213
- return typeof value === "object" && value !== null && !Array.isArray(value);
214
- }
@@ -1,50 +1 @@
1
- import { randomUUID } from "node:crypto";
2
-
3
- /**
4
- * QA finding ids must stay globally unique for the lifetime of a task: the
5
- * Kernel reducer rejects any finding id that was ever recorded, including
6
- * resolved ones. Scoping each id to the snapshot digest plus a per-invocation
7
- * suffix guarantees repeated rework attempts (even on an identical snapshot)
8
- * never collide, while keeping the acceptance id traceable in the id itself.
9
- */
10
-
11
- /** Digest-scoped id for a per-acceptance QA rework finding. */
12
- export function qaFindingId(acceptanceId: string, snapshotDigest: string): string {
13
- return `qa-${acceptanceId}-${attemptRef(snapshotDigest)}`;
14
- }
15
-
16
- /** Digest-scoped id for an evidence-freshness QA rework finding. */
17
- export function qaEvidenceFreshnessId(snapshotDigest: string): string {
18
- const digest16 = snapshotDigest.slice("sha256:".length, "sha256:".length + 16);
19
- return `qa-evidence-freshness-${digest16}-${attemptRef(snapshotDigest)}`;
20
- }
21
-
22
- function attemptRef(snapshotDigest: string): string {
23
- const digest8 = snapshotDigest.slice("sha256:".length, "sha256:".length + 8);
24
- return `${digest8}-${randomUUID().slice(0, 6)}`;
25
- }
26
-
27
- /**
28
- * Preserve the original QA verdict details (which acceptance failed and why)
29
- * when applying an authority verdict fails. The raw reducer error alone masks
30
- * the failure reason that matters; append the verdict findings when present.
31
- */
32
- export function describeQaFailure(
33
- boundedBase: string,
34
- findings?: Array<{ id: string; summary: string }>,
35
- ): string {
36
- if (!findings?.length) return boundedBase;
37
- const detail = findings.map((finding) => `${finding.id}: ${finding.summary}`).join(" | ");
38
- const marker = "; verdict: ";
39
- const totalBudget = 300;
40
- // Reserve room for at least the first 160 chars of verdict detail.
41
- const baseBudget = Math.max(0, totalBudget - marker.length - Math.min(detail.length, 160));
42
- const trimmedBase =
43
- boundedBase.length <= baseBudget
44
- ? boundedBase
45
- : `${boundedBase.slice(0, Math.max(0, baseBudget - 1))}…`;
46
- const detailBudget = Math.max(0, totalBudget - trimmedBase.length - marker.length);
47
- const bounded =
48
- detail.length <= detailBudget ? detail : `${detail.slice(0, Math.max(0, detailBudget - 3))}...`;
49
- return `${trimmedBase}${marker}${bounded}`;
50
- }
1
+ export * from "../runtime/assurance/qa_findings";
@@ -1,262 +1 @@
1
- // Host-captured immutable review evidence for native Pi subagents.
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
- pathMatchesScope,
19
- taskDiffHash,
20
- type GitTaskIndexEntry,
21
- } from "../runtime/workspace_scope";
22
-
23
- const MAX_REVIEW_BUNDLE_BYTES = 2 * 1024 * 1024;
24
-
25
- export interface ReviewOutcome {
26
- status: "passed" | "failed" | "blocked";
27
- summary: string;
28
- }
29
-
30
- export interface ReviewNeighborhoodFile {
31
- mode: "100644" | "100755" | "120000";
32
- oid: string;
33
- base_mode: "100644" | "100755" | "120000";
34
- base_oid: string;
35
- fingerprint: string;
36
- current_content: string;
37
- }
38
-
39
- export interface ReviewBundle {
40
- contract: "assurance_kernel/review_bundle/v4";
41
- root: string;
42
- head: string;
43
- scope: string[];
44
- diff_hash: string;
45
- dirty_files: Record<string, GitTaskIndexEntry & {
46
- fingerprint: string;
47
- current_content: string | null;
48
- }>;
49
- /** Present on newly captured bundles; optional only for legacy in-memory test fixtures. */
50
- neighborhood_files?: Record<string, ReviewNeighborhoodFile>;
51
- /** Explicit provenance for every bundled path. */
52
- path_provenance?: Record<string, "diff" | "neighborhood">;
53
- outcomes: Record<string, ReviewOutcome>;
54
- bundle_digest: string;
55
- }
56
-
57
- function bundleDigest(bundle: Omit<ReviewBundle, "bundle_digest">): string {
58
- return `sha256:${createHash("sha256").update(JSON.stringify(bundle)).digest("hex")}`;
59
- }
60
-
61
- const reviewUtf8 = new TextDecoder("utf-8", { fatal: true });
62
-
63
- function readIndexBlob(
64
- root: string,
65
- path: string,
66
- entry: Pick<GitTaskIndexEntry, "oid">,
67
- ): string | null {
68
- if (!entry.oid) return null;
69
- const type = execFileSync("git", ["cat-file", "-t", entry.oid], {
70
- cwd: root,
71
- encoding: "utf8",
72
- stdio: ["ignore", "pipe", "ignore"],
73
- maxBuffer: 16,
74
- timeout: 10_000,
75
- }).trim();
76
- if (type !== "blob") throw new Error(`index object is not a blob for ${path}`);
77
- const sizeText = execFileSync("git", ["cat-file", "-s", entry.oid], {
78
- cwd: root,
79
- encoding: "utf8",
80
- stdio: ["ignore", "pipe", "ignore"],
81
- maxBuffer: 64,
82
- timeout: 10_000,
83
- }).trim();
84
- const size = Number(sizeText);
85
- if (!Number.isSafeInteger(size) || size < 0 || size > MAX_REVIEW_BUNDLE_BYTES)
86
- throw new Error(`review file exceeds bounded size: ${path}`);
87
- const bytes = execFileSync("git", ["cat-file", "blob", entry.oid], {
88
- cwd: root,
89
- encoding: "buffer",
90
- stdio: ["ignore", "pipe", "ignore"],
91
- maxBuffer: MAX_REVIEW_BUNDLE_BYTES + 1,
92
- timeout: 10_000,
93
- }) as Buffer;
94
- if (bytes.length !== size) throw new Error(`index blob size changed during capture: ${path}`);
95
- let content: string;
96
- try {
97
- content = reviewUtf8.decode(bytes);
98
- } catch {
99
- throw new Error(`review file is not valid UTF-8: ${path}`);
100
- }
101
- if (!Buffer.from(content, "utf8").equals(bytes))
102
- throw new Error(`review file does not round-trip through UTF-8: ${path}`);
103
- return content;
104
- }
105
-
106
- const GIT_OBJECT_ID = /^[a-f0-9]{40,64}$/;
107
- const REVIEW_MODES = new Set(["100644", "100755", "120000"] as const);
108
-
109
- function nullRecords(bytes: Buffer): Buffer[] {
110
- if (bytes.length === 0) return [];
111
- if (bytes[bytes.length - 1] !== 0) throw new Error("git index listing is not NUL-terminated");
112
- const records: Buffer[] = [];
113
- let start = 0;
114
- for (let index = 0; index < bytes.length; index += 1) {
115
- if (bytes[index] !== 0) continue;
116
- if (index === start) throw new Error("git index listing contains an empty record");
117
- records.push(bytes.subarray(start, index));
118
- start = index + 1;
119
- }
120
- return records;
121
- }
122
-
123
- function scopedNeighborhoodFiles(
124
- root: string,
125
- scope: string[],
126
- dirtyPaths: Set<string>,
127
- ): Record<string, ReviewNeighborhoodFile> {
128
- const listing = execFileSync("git", ["ls-files", "--stage", "-z"], {
129
- cwd: root,
130
- encoding: "buffer",
131
- stdio: ["ignore", "pipe", "ignore"],
132
- maxBuffer: 32 * 1024 * 1024,
133
- timeout: 10_000,
134
- }) as Buffer;
135
- const entries: Array<[string, ReviewNeighborhoodFile]> = [];
136
- for (const record of nullRecords(listing)) {
137
- const tab = record.indexOf(9);
138
- if (tab < 0) throw new Error("git index entry is malformed");
139
- const [mode, oid, stage] = record.subarray(0, tab).toString("ascii").split(" ");
140
- let path: string;
141
- try {
142
- path = reviewUtf8.decode(record.subarray(tab + 1));
143
- } catch {
144
- throw new Error("git index path is not valid UTF-8");
145
- }
146
- if (!Buffer.from(path, "utf8").equals(record.subarray(tab + 1)))
147
- throw new Error("git index path does not round-trip through UTF-8");
148
- if (!scope.some((scopePath) => pathMatchesScope(path, scopePath)) || dirtyPaths.has(path)) continue;
149
- if (!REVIEW_MODES.has(mode as "100644" | "100755" | "120000"))
150
- throw new Error(`review neighborhood file has unsupported mode: ${path}`);
151
- if (!GIT_OBJECT_ID.test(oid ?? "") || /^0+$/.test(oid ?? "") || stage !== "0")
152
- throw new Error(`review neighborhood file has invalid index identity: ${path}`);
153
- const content = readIndexBlob(root, path, { oid });
154
- if (content === null) throw new Error(`review neighborhood file is missing index content: ${path}`);
155
- entries.push([path, {
156
- mode: mode as ReviewNeighborhoodFile["mode"],
157
- oid,
158
- base_mode: mode as ReviewNeighborhoodFile["base_mode"],
159
- base_oid: oid,
160
- fingerprint: `index:${mode}:${oid}`,
161
- current_content: content,
162
- }]);
163
- }
164
- entries.sort(([left], [right]) => left.localeCompare(right));
165
- return Object.fromEntries(entries);
166
- }
167
-
168
- export function captureReviewBundle(
169
- root: string,
170
- scopeHint: unknown,
171
- expectedDiffHash: string,
172
- outcomes: Record<string, ReviewOutcome>,
173
- ): ReviewBundle {
174
- const before = captureGitTaskSnapshot(root, scopeHint);
175
- if (taskDiffHash(root, before.scope) !== expectedDiffHash)
176
- throw new Error("review task snapshot does not match assurance snapshot");
177
- const dirtyFiles = Object.fromEntries(
178
- Object.entries(before.staged_files).map(([path, entry]) => [path, {
179
- ...entry,
180
- fingerprint: `index:${entry.mode ?? "missing"}:${entry.oid ?? "missing"}`,
181
- current_content: readIndexBlob(before.repository_root, path, entry),
182
- }]),
183
- );
184
- const neighborhoodFiles = scopedNeighborhoodFiles(
185
- before.repository_root,
186
- before.scope,
187
- new Set(Object.keys(dirtyFiles)),
188
- );
189
- const pathProvenance = Object.fromEntries([
190
- ...Object.keys(dirtyFiles).map((path) => [path, "diff"] as const),
191
- ...Object.keys(neighborhoodFiles).map((path) => [path, "neighborhood"] as const),
192
- ].sort(([left], [right]) => left.localeCompare(right)));
193
- const after = captureGitTaskSnapshot(root, before.scope);
194
- if (
195
- JSON.stringify(after) !== JSON.stringify(before) ||
196
- taskDiffHash(root, before.scope) !== expectedDiffHash
197
- ) {
198
- throw new Error("task snapshot changed while capturing immutable review bundle");
199
- }
200
- const unsigned = {
201
- contract: "assurance_kernel/review_bundle/v4" as const,
202
- root: before.repository_root,
203
- head: before.head,
204
- scope: before.scope,
205
- diff_hash: expectedDiffHash,
206
- dirty_files: dirtyFiles,
207
- neighborhood_files: neighborhoodFiles,
208
- path_provenance: pathProvenance,
209
- // Defensive copies so later caller mutation cannot alter the frozen record.
210
- outcomes: Object.fromEntries(
211
- Object.entries(outcomes).map(([id, outcome]) => [id, { ...outcome }]),
212
- ),
213
- };
214
- if (Buffer.byteLength(JSON.stringify(unsigned)) > MAX_REVIEW_BUNDLE_BYTES) {
215
- throw new Error("immutable review bundle exceeds bounded output limit");
216
- }
217
- return { ...unsigned, bundle_digest: bundleDigest(unsigned) };
218
- }
219
-
220
- export function verifyReviewBundle(bundle: ReviewBundle): void {
221
- const { bundle_digest, ...unsigned } = bundle;
222
- if (bundle_digest !== bundleDigest(unsigned)) throw new Error("immutable review bundle digest mismatch");
223
- }
224
-
225
- export function writeNativeReviewEvidence(payload: unknown): { path: string; remove(): void } {
226
- const rawDirectory = mkdtempSync(join(tmpdir(), "imm-canary-native-review-"));
227
- try {
228
- const directory = realpathSync(rawDirectory);
229
- chmodSync(directory, 0o755);
230
- const path = join(directory, "evidence.json");
231
- writeFileSync(path, JSON.stringify(payload), { encoding: "utf8", mode: 0o644, flag: "wx" });
232
- // Fail-closed artifact check: the immutable evidence must be readable
233
- // before a reserved review is dispatched.
234
- assertReviewArtifact(path);
235
- return {
236
- path,
237
- remove: () => rmSync(directory, { recursive: true, force: true }),
238
- };
239
- } catch (error) {
240
- rmSync(rawDirectory, { recursive: true, force: true });
241
- throw error;
242
- }
243
- }
244
-
245
- /**
246
- * Fail-closed artifact check: the immutable evidence file must exist and be
247
- * readable before a reserved review is dispatched. Missing or unreadable
248
- * artifacts throw so dispatch writes zero authority and exposes an explicit
249
- * re-reserve path.
250
- */
251
- export function assertReviewArtifact(path: string): void {
252
- const targetPath = realpathSync(path);
253
- let stat;
254
- try {
255
- stat = statSync(targetPath);
256
- } catch {
257
- throw new Error(`review evidence artifact is missing or empty: ${path}`);
258
- }
259
- if (!stat.isFile() || stat.size === 0) throw new Error(`review evidence artifact is missing or empty: ${path}`);
260
- const read = readFileSync(targetPath, { encoding: "utf8" });
261
- if (read.trim().length === 0) throw new Error(`review evidence artifact is empty: ${path}`);
262
- }
1
+ export * from "../runtime/assurance/review_evidence";
@@ -3,7 +3,7 @@ export interface ToolFailureV1 {
3
3
  tool: "imm_canary_enrollment" | "imm_kernel_canary";
4
4
  task_id: string;
5
5
  operation: string;
6
- state: "blocked" | "failed" | "authority_conflict" | "settlement_unknown";
6
+ state: "blocked" | "failed" | "authority_conflict" | "settlement_unknown" | "review_preparation_failed";
7
7
  code: string;
8
8
  message: string;
9
9
  next_action: string;
@@ -24,5 +24,6 @@ export function isToolFailureState(
24
24
  return state === "blocked"
25
25
  || state === "failed"
26
26
  || state === "authority_conflict"
27
- || state === "settlement_unknown";
27
+ || state === "settlement_unknown"
28
+ || state === "review_preparation_failed";
28
29
  }