immune-brain 2.8.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.
- package/README.md +239 -0
- package/README.zh-CN.md +239 -0
- package/package.json +84 -0
- package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +666 -0
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +1622 -0
- package/plugins/immune-brain/.pi-extension/package.json +11 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +711 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-assurance.ts +154 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-interaction.ts +349 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-invocations.ts +90 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-native-review.ts +214 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-qa-findings.ts +50 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-review-bundle.ts +262 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-tool-failure.ts +28 -0
- package/plugins/immune-brain/.pi-extension/pi-canary-verification.ts +237 -0
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +414 -0
- package/plugins/immune-brain/.pi-extension/tsconfig.json +20 -0
- package/plugins/immune-brain/bin/imm-kernel +4 -0
- package/plugins/immune-brain/bin/imm-plan +4 -0
- package/plugins/immune-brain/bin/imm-pr-diag +230 -0
- package/plugins/immune-brain/bin/imm-retire-stale-wrapper +4 -0
- package/plugins/immune-brain/bin/imm-retired +4 -0
- package/plugins/immune-brain/bin/imm-tracker +4 -0
- package/plugins/immune-brain/dist/BASELINE.md +138 -0
- package/plugins/immune-brain/dist/docs/reference/HANDOFF-template.md +122 -0
- package/plugins/immune-brain/dist/docs/reference/design-contract-audit-rubric.md +149 -0
- package/plugins/immune-brain/dist/docs/reference/design-contract-review-checklist.md +55 -0
- package/plugins/immune-brain/dist/docs/reference/i18n-review-checklist.md +110 -0
- package/plugins/immune-brain/dist/docs/reference/immune-brain-config.md +52 -0
- package/plugins/immune-brain/dist/docs/reference/planning-artifact-retention.md +94 -0
- package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +44 -0
- package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +105 -0
- package/plugins/immune-brain/dist/docs/reference/ux-heuristic-checklist.md +131 -0
- package/plugins/immune-brain/dist/imm-brainstorm.md +140 -0
- package/plugins/immune-brain/dist/imm-doc-prune.md +137 -0
- package/plugins/immune-brain/dist/imm-loop.md +158 -0
- package/plugins/immune-brain/dist/imm-planner.md +387 -0
- package/plugins/immune-brain/dist/imm-pr-fix.md +71 -0
- package/plugins/immune-brain/dist/registry.yaml +49 -0
- package/plugins/immune-brain/dist/role-prompts/advisory-reviewer.md +16 -0
- package/plugins/immune-brain/dist/role-prompts/arch-explorer.md +14 -0
- package/plugins/immune-brain/dist/role-prompts/code-review.md +15 -0
- package/plugins/immune-brain/dist/role-prompts/compounder.md +20 -0
- package/plugins/immune-brain/dist/role-prompts/executor.md +13 -0
- package/plugins/immune-brain/dist/role-prompts/pr-fix.md +81 -0
- package/plugins/immune-brain/dist/role-prompts/qa.md +23 -0
- package/plugins/immune-brain/dist/role-prompts/test-fixer.md +3 -0
- package/plugins/immune-brain/dist/role-prompts/ui-review.md +14 -0
- package/plugins/immune-brain/runtime/authority_commit_receipts.ts +716 -0
- package/plugins/immune-brain/runtime/canonical_json.ts +19 -0
- package/plugins/immune-brain/runtime/commands/kernel.ts +1160 -0
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +1009 -0
- package/plugins/immune-brain/runtime/kernel/application.ts +300 -0
- package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +284 -0
- package/plugins/immune-brain/runtime/kernel/authority_port.ts +208 -0
- package/plugins/immune-brain/runtime/kernel/automatic_observations.ts +451 -0
- package/plugins/immune-brain/runtime/kernel/backend_claim.ts +197 -0
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +507 -0
- package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +73 -0
- package/plugins/immune-brain/runtime/kernel/completion.ts +160 -0
- package/plugins/immune-brain/runtime/kernel/enrollment.ts +194 -0
- package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +123 -0
- package/plugins/immune-brain/runtime/kernel/index.ts +29 -0
- package/plugins/immune-brain/runtime/kernel/intent.ts +563 -0
- package/plugins/immune-brain/runtime/kernel/intent_token_registry.ts +80 -0
- package/plugins/immune-brain/runtime/kernel/legacy.ts +299 -0
- package/plugins/immune-brain/runtime/kernel/legacy_audit.ts +153 -0
- package/plugins/immune-brain/runtime/kernel/observation.ts +395 -0
- package/plugins/immune-brain/runtime/kernel/pi_canary_prepare.ts +169 -0
- package/plugins/immune-brain/runtime/kernel/readiness.ts +282 -0
- package/plugins/immune-brain/runtime/kernel/readiness_evidence.ts +132 -0
- package/plugins/immune-brain/runtime/kernel/reducer.ts +624 -0
- package/plugins/immune-brain/runtime/kernel/storage.ts +1780 -0
- package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +791 -0
- package/plugins/immune-brain/runtime/kernel/storage_paths.ts +492 -0
- package/plugins/immune-brain/runtime/kernel/types.ts +295 -0
- package/plugins/immune-brain/runtime/kernel/validation.ts +963 -0
- package/plugins/immune-brain/runtime/loop_contract.ts +362 -0
- package/plugins/immune-brain/runtime/managed_task_routing_policy.ts +462 -0
- package/plugins/immune-brain/runtime/plan_core.ts +1053 -0
- package/plugins/immune-brain/runtime/prompts/advisory-reviewer.md +16 -0
- package/plugins/immune-brain/runtime/prompts/arch-explorer.md +14 -0
- package/plugins/immune-brain/runtime/prompts/code-review.md +15 -0
- package/plugins/immune-brain/runtime/prompts/compounder.md +20 -0
- package/plugins/immune-brain/runtime/prompts/executor.md +13 -0
- package/plugins/immune-brain/runtime/prompts/pr-fix.md +81 -0
- package/plugins/immune-brain/runtime/prompts/qa.md +23 -0
- package/plugins/immune-brain/runtime/prompts/test-fixer.md +3 -0
- package/plugins/immune-brain/runtime/prompts/ui-review.md +14 -0
- package/plugins/immune-brain/runtime/role_prompt_bridge.ts +160 -0
- package/plugins/immune-brain/runtime/v4_runtime.ts +295 -0
- package/plugins/immune-brain/runtime/verification_descriptor.ts +162 -0
- package/plugins/immune-brain/runtime/workspace_scope.ts +623 -0
- package/plugins/immune-brain/skills/.ignore +1 -0
- package/plugins/immune-brain/skills/BASELINE.md +138 -0
- package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +66 -0
- package/plugins/immune-brain/skills/imm-doc-prune/SKILL.md +11 -0
- package/plugins/immune-brain/skills/imm-loop/SKILL.md +52 -0
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +221 -0
- package/plugins/immune-brain/skills/imm-pr-fix/SKILL.md +10 -0
- package/plugins/immune-brain/skills/registry.yaml +49 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
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
|
+
export interface ReservedAgentParams {
|
|
26
|
+
subagent_type: "Review";
|
|
27
|
+
description: string;
|
|
28
|
+
prompt: string;
|
|
29
|
+
inherit_context: false;
|
|
30
|
+
isolated: true;
|
|
31
|
+
isolation: "worktree";
|
|
32
|
+
run_in_background: false;
|
|
33
|
+
max_turns: number;
|
|
34
|
+
resume: "";
|
|
35
|
+
schedule: "";
|
|
36
|
+
}
|
|
37
|
+
|
|
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
|
+
export function reservedAgentDescription(taskId: string, operationId: string): string {
|
|
52
|
+
return `Review ${shortId(taskId)} ${shortId(operationId)}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function semanticNeighborhoodReviewPrompt(prompt: string): string {
|
|
56
|
+
return [
|
|
57
|
+
prompt,
|
|
58
|
+
`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.`,
|
|
59
|
+
`For settlement-class changes, enumerate every terminal, cancellation, timeout, and race path present across dirty_files and neighborhood_files before deciding the verdict; report findings for every affected path. Reference a bundle path at the start of each finding summary (for example, "path/to/file.ts: ...") so verdict v2 can identify neighborhood context without live repository reads.`,
|
|
60
|
+
].join("\n");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function reservedAgentParams(input: {
|
|
64
|
+
taskId: string;
|
|
65
|
+
operationId: string;
|
|
66
|
+
prompt: string;
|
|
67
|
+
max_turns?: number;
|
|
68
|
+
}): ReservedAgentParams {
|
|
69
|
+
return {
|
|
70
|
+
subagent_type: "Review",
|
|
71
|
+
description: reservedAgentDescription(input.taskId, input.operationId),
|
|
72
|
+
prompt: semanticNeighborhoodReviewPrompt(input.prompt),
|
|
73
|
+
inherit_context: false,
|
|
74
|
+
isolated: true,
|
|
75
|
+
isolation: "worktree",
|
|
76
|
+
run_in_background: false,
|
|
77
|
+
max_turns: input.max_turns ?? 16,
|
|
78
|
+
resume: "",
|
|
79
|
+
schedule: "",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
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
|
+
export function promptDigest(prompt: string): string {
|
|
150
|
+
return `sha256:${createHash("sha256").update(prompt).digest("hex")}`;
|
|
151
|
+
}
|
|
152
|
+
|
|
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
|
+
function shortId(value: string): string {
|
|
209
|
+
return value.replace(/[^A-Za-z0-9]/g, "").slice(-8) || "review";
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
213
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
214
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface ToolFailureV1 {
|
|
2
|
+
contract: "immune_brain/tool_failure/v1";
|
|
3
|
+
tool: "imm_canary_enrollment" | "imm_kernel_canary";
|
|
4
|
+
task_id: string;
|
|
5
|
+
operation: string;
|
|
6
|
+
state: "blocked" | "failed" | "authority_conflict" | "settlement_unknown";
|
|
7
|
+
code: string;
|
|
8
|
+
message: string;
|
|
9
|
+
next_action: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function throwToolFailure(
|
|
13
|
+
failure: Omit<ToolFailureV1, "contract">,
|
|
14
|
+
): never {
|
|
15
|
+
throw new Error(JSON.stringify({
|
|
16
|
+
contract: "immune_brain/tool_failure/v1",
|
|
17
|
+
...failure,
|
|
18
|
+
} satisfies ToolFailureV1));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isToolFailureState(
|
|
22
|
+
state: unknown,
|
|
23
|
+
): state is ToolFailureV1["state"] {
|
|
24
|
+
return state === "blocked"
|
|
25
|
+
|| state === "failed"
|
|
26
|
+
|| state === "authority_conflict"
|
|
27
|
+
|| state === "settlement_unknown";
|
|
28
|
+
}
|