immune-brain 5.2.0 → 5.5.1
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 +4 -4
- package/README.zh-CN.md +4 -4
- package/package.json +2 -1
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +54 -70
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +163 -28
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +71 -10
- package/plugins/immune-brain/.pi-extension/pi-canary-tool-failure.ts +1 -1
- package/plugins/immune-brain/.pi-extension/pi-run-continuation.ts +161 -0
- package/plugins/immune-brain/agents/immune-brain-reviewer.md +26 -2
- package/plugins/immune-brain/dist/BASELINE.md +17 -1
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +16145 -12745
- package/plugins/immune-brain/dist/docs/reference/code-quality-guard.md +3 -1
- package/plugins/immune-brain/dist/docs/reference/immune-brain-config.md +41 -1
- package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +1 -1
- package/plugins/immune-brain/dist/imm-brainstorm.md +1 -1
- package/plugins/immune-brain/dist/imm-planner.md +63 -19
- package/plugins/immune-brain/dist/imm-run-batch.md +398 -0
- package/plugins/immune-brain/dist/imm-run.md +89 -344
- package/plugins/immune-brain/dist/role-prompts/code-review.md +26 -2
- package/plugins/immune-brain/dist/role-prompts/executor.md +35 -10
- package/plugins/immune-brain/hooks/hooks.json +22 -2
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +203 -13
- package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +2 -1
- package/plugins/immune-brain/runtime/assurance/enrollment_git_base.ts +99 -14
- package/plugins/immune-brain/runtime/assurance/phase_entry.ts +10 -26
- package/plugins/immune-brain/runtime/assurance/qa.ts +126 -43
- package/plugins/immune-brain/runtime/assurance/qa_findings.ts +8 -0
- package/plugins/immune-brain/runtime/assurance/review_evidence.ts +48 -0
- package/plugins/immune-brain/runtime/assurance/run_continuation.ts +284 -0
- package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +46 -4
- package/plugins/immune-brain/runtime/assurance/verification.ts +83 -35
- package/plugins/immune-brain/runtime/assurance/workflow_autocommit.ts +222 -0
- package/plugins/immune-brain/runtime/claude/interaction.ts +11 -2
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +199 -48
- package/plugins/immune-brain/runtime/claude/lane_guard.ts +30 -20
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +105 -9
- package/plugins/immune-brain/runtime/claude/run_continuation.ts +274 -0
- package/plugins/immune-brain/runtime/commands/kernel.ts +41 -103
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +60 -2
- package/plugins/immune-brain/runtime/kernel/application.ts +4 -1
- package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +103 -20
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +57 -7
- package/plugins/immune-brain/runtime/kernel/completion.ts +28 -4
- package/plugins/immune-brain/runtime/kernel/enrollment.ts +48 -8
- package/plugins/immune-brain/runtime/kernel/intent.ts +169 -104
- package/plugins/immune-brain/runtime/kernel/reducer.ts +136 -6
- package/plugins/immune-brain/runtime/kernel/risk_floor.ts +286 -0
- package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +83 -1
- package/plugins/immune-brain/runtime/kernel/storage_paths.ts +1 -1
- package/plugins/immune-brain/runtime/kernel/types.ts +49 -3
- package/plugins/immune-brain/runtime/kernel/validation.ts +147 -7
- package/plugins/immune-brain/runtime/loop_contract.ts +5 -0
- package/plugins/immune-brain/runtime/plan_core.ts +15 -8
- package/plugins/immune-brain/runtime/plugin_source_skew.ts +58 -0
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +26 -2
- package/plugins/immune-brain/runtime/prompts/executor.md +35 -10
- package/plugins/immune-brain/runtime/unattended/batch_delegation.ts +5 -3
- package/plugins/immune-brain/runtime/unattended/batch_disposition.ts +40 -10
- package/plugins/immune-brain/runtime/unattended/batch_final_verification.ts +57 -3
- package/plugins/immune-brain/runtime/unattended/batch_git.ts +424 -66
- package/plugins/immune-brain/runtime/unattended/batch_integration.ts +2 -1
- package/plugins/immune-brain/runtime/unattended/batch_kernel_port.ts +13 -0
- package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +43 -24
- package/plugins/immune-brain/runtime/unattended/batch_plan.ts +6 -2
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +102 -41
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +9 -3
- package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +9 -9
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +90 -32
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +102 -4
- package/plugins/immune-brain/runtime/unattended/parent_delivery_consolidation.ts +248 -0
- package/plugins/immune-brain/runtime/workspace_scope.ts +126 -18
- package/plugins/immune-brain/skills/BASELINE.md +17 -1
- package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +4 -3
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +8 -2
- package/plugins/immune-brain/skills/imm-run/SKILL.md +12 -8
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { readActiveClaimTaskId } from "../unattended/batch_preflight";
|
|
5
|
+
import { isTerminalBatchState, readAnyBatchRunState } from "../unattended/batch_state";
|
|
2
6
|
import { createInterface } from "node:readline";
|
|
3
7
|
import { stdin, stdout } from "node:process";
|
|
4
8
|
import type { Readable, Writable } from "node:stream";
|
|
@@ -13,11 +17,14 @@ import {
|
|
|
13
17
|
} from "./interaction";
|
|
14
18
|
import { ClaudeReviewHost, FileHookEventLog, parseHookStdin } from "./review_host";
|
|
15
19
|
import { activeTaskHookOutput, consumePhaseAdmission, laneGuardHookOutput, phaseEntryGateHookOutput, recordPhaseEntryPrompt, type GatedTool } from "./lane_guard";
|
|
20
|
+
import { readRunToolSession, runContinuationHookOutput } from "./run_continuation";
|
|
16
21
|
import { ClaudeRuntime, type ToolMeta } from "./kernel_ports";
|
|
17
22
|
import { parseLaneInstruction, parseLaneOffers, parseMaxParallel, resolveLaneParallel } from "../unattended/batch_lanes";
|
|
18
23
|
import { parseFinalVerification } from "../unattended/batch_final_verification";
|
|
24
|
+
import { consolidationSchema, parseDeliveryConsolidation } from "../unattended/parent_delivery_consolidation";
|
|
19
25
|
import { parseLaneRevision, REVISION_DELEGATION_TEXT } from "../unattended/batch_delegation";
|
|
20
26
|
import type { AssuranceCoordinatorPorts } from "../assurance/coordinator";
|
|
27
|
+
import { parseReworkChoice, reworkExitLines, type ReworkChoice } from "../kernel/assurance_projection";
|
|
21
28
|
|
|
22
29
|
export const MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
23
30
|
|
|
@@ -59,6 +66,7 @@ export function listMcpTools() {
|
|
|
59
66
|
additionalProperties: false,
|
|
60
67
|
},
|
|
61
68
|
},
|
|
69
|
+
delivery_consolidation: consolidationSchema,
|
|
62
70
|
final_verification: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 8 },
|
|
63
71
|
lane_instruction: {
|
|
64
72
|
type: "object",
|
|
@@ -148,8 +156,12 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
|
|
|
148
156
|
// The phase entry gate, before any preparation or native dialog. The hook
|
|
149
157
|
// decided on the session's literal entry; no admission for this exact call
|
|
150
158
|
// means no hook saw it, which refuses.
|
|
159
|
+
let hookSessionId: string | null = null;
|
|
151
160
|
const requireRunEntry = (operation: GatedTool, toolCallId: string) => {
|
|
152
|
-
|
|
161
|
+
const correlated = readRunToolSession(toolCallId, operation, options.cwd ?? process.cwd());
|
|
162
|
+
if (!options.consumePhaseAdmission && !correlated)
|
|
163
|
+
throw new NativeAuthorityError("run_entry_required", "Enrollment needs exact Hook session and workspace correlation");
|
|
164
|
+
if (consumeAdmission(toolCallId, operation)) { hookSessionId = correlated ?? hookSessionId; return; }
|
|
153
165
|
throw new NativeAuthorityError("run_entry_required", `${operation} needs a literal-user imm-run entry in this session`);
|
|
154
166
|
};
|
|
155
167
|
return {
|
|
@@ -167,6 +179,8 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
|
|
|
167
179
|
},
|
|
168
180
|
sessionInteractive: () => negotiatedInteractive,
|
|
169
181
|
async callTool(name: string, args: Record<string, unknown>, meta: Partial<ToolMeta> = {}) {
|
|
182
|
+
const observedSession = readRunToolSession(meta.toolCallId ?? `call-${name}`, name, options.cwd ?? process.cwd());
|
|
183
|
+
if (observedSession) hookSessionId = observedSession;
|
|
170
184
|
if (name === "start_unattended_batch") {
|
|
171
185
|
const initiativeSlug = String(args.initiative_slug ?? "");
|
|
172
186
|
if (!initiativeSlug) throw new Error("initiative_slug is required");
|
|
@@ -179,7 +193,7 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
|
|
|
179
193
|
if (!probe.ok) throw new NativeAuthorityError("unsupported_host", probe.reason);
|
|
180
194
|
requireRunEntry(name, meta.toolCallId ?? `call-${name}`);
|
|
181
195
|
const toolMeta: ToolMeta = {
|
|
182
|
-
sessionId: meta.sessionId ?? connectionId,
|
|
196
|
+
sessionId: hookSessionId ?? meta.sessionId ?? connectionId,
|
|
183
197
|
toolCallId: meta.toolCallId ?? `call-${name}`,
|
|
184
198
|
taskId: initiativeSlug,
|
|
185
199
|
initiativeSlug,
|
|
@@ -192,10 +206,12 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
|
|
|
192
206
|
const laneRevision = parseLaneRevision(args.lane_revision);
|
|
193
207
|
const maxParallel = resolveLaneParallel(options.cwd ?? process.cwd(), initiativeSlug, parseMaxParallel(args.max_parallel), laneOffers, laneInstruction ?? laneRevision);
|
|
194
208
|
const finalVerification = parseFinalVerification(args.final_verification);
|
|
209
|
+
const deliveryConsolidation = parseDeliveryConsolidation(args.delivery_consolidation);
|
|
195
210
|
return runtime.startUnattendedBatch(initiativeSlug, toolMeta, {
|
|
196
211
|
...(maxParallel !== undefined ? { max_parallel: maxParallel } : {}),
|
|
197
212
|
...(laneOffers !== undefined ? { lane_offers: laneOffers } : {}),
|
|
198
213
|
...(finalVerification !== undefined ? { final_verification: finalVerification } : {}),
|
|
214
|
+
...(deliveryConsolidation !== undefined ? { delivery_consolidation: deliveryConsolidation } : {}),
|
|
199
215
|
...(laneInstruction !== undefined ? { lane_instruction: laneInstruction } : {}),
|
|
200
216
|
...(laneRevision !== undefined ? { lane_revision: laneRevision } : {}),
|
|
201
217
|
});
|
|
@@ -211,7 +227,7 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
|
|
|
211
227
|
const probe = probeHost(options.env ?? process.env, process.platform, negotiatedVersion);
|
|
212
228
|
if (!probe.ok) throw new NativeAuthorityError("unsupported_host", probe.reason);
|
|
213
229
|
const toolMeta: ToolMeta = {
|
|
214
|
-
sessionId: meta.sessionId ?? connectionId,
|
|
230
|
+
sessionId: hookSessionId ?? meta.sessionId ?? connectionId,
|
|
215
231
|
toolCallId: meta.toolCallId ?? `call-${name}`,
|
|
216
232
|
taskId: initiativeSlug,
|
|
217
233
|
initiativeSlug,
|
|
@@ -238,7 +254,7 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
|
|
|
238
254
|
const probe = probeHost(options.env ?? process.env, process.platform, negotiatedVersion);
|
|
239
255
|
if (!probe.ok) throw new NativeAuthorityError("unsupported_host", probe.reason);
|
|
240
256
|
const toolMeta: ToolMeta = {
|
|
241
|
-
sessionId: meta.sessionId ?? connectionId,
|
|
257
|
+
sessionId: hookSessionId ?? meta.sessionId ?? connectionId,
|
|
242
258
|
toolCallId: meta.toolCallId ?? `call-${name}`,
|
|
243
259
|
taskId,
|
|
244
260
|
interactive: meta.interactive ?? options.interactive ?? negotiatedInteractive,
|
|
@@ -462,7 +478,7 @@ export function elicitationParams(input: NativeConfirmationInput) {
|
|
|
462
478
|
? `Children (${d.children.length}):\n${d.children
|
|
463
479
|
.map(
|
|
464
480
|
(child) =>
|
|
465
|
-
` - ${child.task_id} (${child.slice_id}) [state: ${child.state}]${child.commit ? ` [commit: ${child.commit}]` : " [no commit]"}${child.lane_branch ? ` [lane: ${child.lane_branch}]` : ""}`,
|
|
481
|
+
` - ${child.task_id} (${child.slice_id}) [state: ${child.state}]${child.commit ? ` [commit: ${child.commit}]` : " [no commit]"}${child.lane_branch ? ` [lane: ${child.lane_branch}]` : ""}${child.kernel_lifecycle ? ` [Kernel run: ${child.kernel_lifecycle}]` : ""}`,
|
|
466
482
|
)
|
|
467
483
|
.join("\n")}`
|
|
468
484
|
: null,
|
|
@@ -478,6 +494,24 @@ export function elicitationParams(input: NativeConfirmationInput) {
|
|
|
478
494
|
}
|
|
479
495
|
if (input.operation === "start_unattended_batch") {
|
|
480
496
|
const b = input.batchDetails;
|
|
497
|
+
if (b?.parent_final_verification) {
|
|
498
|
+
const lines = [
|
|
499
|
+
`Operation: start_unattended_batch (native Parent final verification; no batch run is started)`,
|
|
500
|
+
`Initiative: ${input.initiativeSlug ?? b.initiative_slug}`,
|
|
501
|
+
input.planDigest ? `Plan digest: ${input.planDigest}` : null,
|
|
502
|
+
`Parent Tasks (${b.children.length}):\n${b.children.map((c) => ` - ${c.task_id} (${c.slice_id})`).join("\n")}`,
|
|
503
|
+
b.parent_final_verification.integrate_task_ids.length > 0
|
|
504
|
+
? `Settled deliveries committed first (${b.parent_final_verification.integrate_task_ids.length}):\n${b.parent_final_verification.integrate_task_ids.map((id) => ` - ${id}`).join("\n")}`
|
|
505
|
+
: null,
|
|
506
|
+
b.parent_final_verification.delivery_consolidation ? `Explicit reviewed delivery consolidation:\n${JSON.stringify(b.parent_final_verification.delivery_consolidation, null, 2)}` : null,
|
|
507
|
+
`Final verification commands run in this workspace outside Host permission:\n${(b.final_verification ?? []).map((command) => ` - ${command}`).join("\n")}`,
|
|
508
|
+
].filter(Boolean);
|
|
509
|
+
return {
|
|
510
|
+
mode: "form",
|
|
511
|
+
message: `Authorize this Parent final verification for ${input.initiativeSlug}?\n\n${lines.join("\n")}`,
|
|
512
|
+
requestedSchema: { type: "object", properties: {} },
|
|
513
|
+
};
|
|
514
|
+
}
|
|
481
515
|
const details = [
|
|
482
516
|
`Operation: start_unattended_batch`,
|
|
483
517
|
`Initiative: ${input.initiativeSlug ?? b?.initiative_slug}`,
|
|
@@ -489,7 +523,7 @@ export function elicitationParams(input: NativeConfirmationInput) {
|
|
|
489
523
|
b?.lane_mode ? `Lane mode: max_parallel=${b.lane_mode.max_parallel}` : null,
|
|
490
524
|
b?.lane_mode ? `Parallel groups (${b.lane_mode.parallel_groups.length}):\n${b.lane_mode.parallel_groups.map((group) => ` - ${group.join(", ")}`).join("\n")}` : null,
|
|
491
525
|
b?.lane_mode && b.lane_mode.serialized.length > 0 ? `Serialized by overlapping scope (${b.lane_mode.serialized.length}):\n${b.lane_mode.serialized.map((c) => ` - ${c.task_id} after ${c.overlaps_with.join(", ")}`).join("\n")}` : null,
|
|
492
|
-
b?.
|
|
526
|
+
b?.final_verification ? `Final verification: ${b.final_verification.join("; ")}` : null,
|
|
493
527
|
b?.lane_mode?.offer_revision_delegation ? REVISION_DELEGATION_TEXT : null,
|
|
494
528
|
].filter(Boolean);
|
|
495
529
|
return {
|
|
@@ -510,6 +544,41 @@ export function elicitationParams(input: NativeConfirmationInput) {
|
|
|
510
544
|
: { type: "object", properties: {} },
|
|
511
545
|
};
|
|
512
546
|
}
|
|
547
|
+
if (input.reworkExits) {
|
|
548
|
+
const exits = input.reworkExits;
|
|
549
|
+
const exitValues = [
|
|
550
|
+
"grant_rounds",
|
|
551
|
+
...(exits.accept_residual_risk ? ["accept_residual_risk"] : []),
|
|
552
|
+
"replan",
|
|
553
|
+
];
|
|
554
|
+
return {
|
|
555
|
+
mode: "form",
|
|
556
|
+
message: `Choose how to resolve this replan boundary for ${input.taskId}.\n\n${[
|
|
557
|
+
`Operation: ${input.operation}`,
|
|
558
|
+
`Task: ${input.taskId}`,
|
|
559
|
+
input.risk ? `Risk: ${input.risk}` : null,
|
|
560
|
+
input.bindingDigest ? `Binding digest: ${input.bindingDigest}` : null,
|
|
561
|
+
...reworkExitLines(exits),
|
|
562
|
+
].filter(Boolean).join("\n")}`,
|
|
563
|
+
requestedSchema: {
|
|
564
|
+
type: "object",
|
|
565
|
+
properties: {
|
|
566
|
+
exit: { type: "string", title: "Exit", enum: exitValues, default: "grant_rounds" },
|
|
567
|
+
rounds: {
|
|
568
|
+
type: "integer",
|
|
569
|
+
title: "Further Review rounds (grant_rounds)",
|
|
570
|
+
minimum: exits.grant_rounds.min,
|
|
571
|
+
maximum: exits.grant_rounds.max,
|
|
572
|
+
default: exits.grant_rounds.min,
|
|
573
|
+
},
|
|
574
|
+
...(exits.accept_residual_risk
|
|
575
|
+
? { reason: { type: "string", title: "Reason for accepting residual risk (accept_residual_risk)" } }
|
|
576
|
+
: {}),
|
|
577
|
+
},
|
|
578
|
+
required: ["exit"],
|
|
579
|
+
},
|
|
580
|
+
};
|
|
581
|
+
}
|
|
513
582
|
const details = [
|
|
514
583
|
`Operation: ${input.operation}`,
|
|
515
584
|
`Task: ${input.taskId}`,
|
|
@@ -586,7 +655,14 @@ export async function serveStdio(options: {
|
|
|
586
655
|
throw new NativeAuthorityError("correlation_missing", "MCP elicitation returned an invalid action");
|
|
587
656
|
}
|
|
588
657
|
let delegateRevisions = false;
|
|
589
|
-
|
|
658
|
+
let reworkChoice: ReworkChoice | undefined;
|
|
659
|
+
if (action === "accept" && input.reworkExits) {
|
|
660
|
+
const parsed = parseReworkChoice(input.reworkExits, (result as { content?: unknown }).content);
|
|
661
|
+
if (!parsed) {
|
|
662
|
+
throw new NativeAuthorityError("correlation_missing", "MCP elicitation accept content did not match the requested exit schema");
|
|
663
|
+
}
|
|
664
|
+
reworkChoice = parsed;
|
|
665
|
+
} else if (action === "accept") {
|
|
590
666
|
const content = (result as { content?: unknown }).content;
|
|
591
667
|
const offered = input.operation === "start_unattended_batch" && input.batchDetails?.lane_mode?.offer_revision_delegation === true;
|
|
592
668
|
const keys = typeof content === "object" && content !== null && !Array.isArray(content) ? Object.keys(content) : null;
|
|
@@ -597,7 +673,12 @@ export async function serveStdio(options: {
|
|
|
597
673
|
}
|
|
598
674
|
delegateRevisions = offered && (content as Record<string, unknown>).delegate_in_lane_revisions === true;
|
|
599
675
|
}
|
|
600
|
-
return {
|
|
676
|
+
return {
|
|
677
|
+
decision: action,
|
|
678
|
+
requestId,
|
|
679
|
+
...(delegateRevisions ? { delegateRevisions: true } : {}),
|
|
680
|
+
...(reworkChoice ? { reworkChoice } : {}),
|
|
681
|
+
};
|
|
601
682
|
} finally {
|
|
602
683
|
pending.delete(requestId);
|
|
603
684
|
if (abortListener) input.signal?.removeEventListener("abort", abortListener);
|
|
@@ -754,18 +835,33 @@ async function runHook(): Promise<void> {
|
|
|
754
835
|
let payload: Record<string, unknown> | null = null;
|
|
755
836
|
try { payload = JSON.parse(raw) as Record<string, unknown>; } catch { payload = null; }
|
|
756
837
|
if (payload?.hook_event_name === "PreToolUse") {
|
|
757
|
-
const decision = laneGuardHookOutput(payload) ?? phaseEntryGateHookOutput(payload);
|
|
838
|
+
const decision = laneGuardHookOutput(payload) ?? phaseEntryGateHookOutput(payload) ?? await runContinuationHookOutput(payload);
|
|
758
839
|
if (decision) process.stdout.write(`${decision}\n`);
|
|
759
840
|
return;
|
|
760
841
|
}
|
|
761
842
|
if (payload?.hook_event_name === "UserPromptSubmit") {
|
|
762
843
|
recordPhaseEntryPrompt(payload);
|
|
844
|
+
await runContinuationHookOutput(payload);
|
|
763
845
|
const notice = await activeTaskHookOutput(payload);
|
|
764
846
|
if (notice) process.stdout.write(`${notice}\n`);
|
|
765
847
|
return;
|
|
766
848
|
}
|
|
849
|
+
if (payload) {
|
|
850
|
+
const continuation = await runContinuationHookOutput(payload);
|
|
851
|
+
if (continuation) process.stdout.write(`${continuation}\n`);
|
|
852
|
+
}
|
|
767
853
|
const event = parseHookStdin(raw);
|
|
768
854
|
if (!event) return;
|
|
855
|
+
const cwd = typeof payload?.cwd === "string" && payload.cwd ? payload.cwd : process.cwd();
|
|
856
|
+
if (!readActiveClaimTaskId(cwd)) {
|
|
857
|
+
const batchesDir = join(cwd, ".imm", "state", "batches");
|
|
858
|
+
const activeBatch = existsSync(batchesDir) && readdirSync(batchesDir).some((file) => {
|
|
859
|
+
if (!file.endsWith(".json") || file.endsWith(".report.json")) return false;
|
|
860
|
+
const batch = readAnyBatchRunState(cwd, file.slice(0, -5));
|
|
861
|
+
return batch !== null && !isTerminalBatchState(batch.batch_state);
|
|
862
|
+
});
|
|
863
|
+
if (!activeBatch) return;
|
|
864
|
+
}
|
|
769
865
|
new FileHookEventLog().append(event);
|
|
770
866
|
}
|
|
771
867
|
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { existsSync, realpathSync, lstatSync, rmSync, renameSync, openSync, fstatSync, readSync, closeSync } from "node:fs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { projectTaskStatus } from "../assurance/coordinator";
|
|
6
|
+
import { readTaskRecordRaw } from "../kernel/storage";
|
|
7
|
+
import { recordParentRepair } from "../unattended/batch_git";
|
|
8
|
+
import { readAnyBatchRunState } from "../unattended/batch_state";
|
|
9
|
+
import { bindRun, decideRunContinuation, observeParentRun, parkFailedCriticalGate, releasesRunBinding, scopedSourceDigest,
|
|
10
|
+
type RunContinuationState, type ReviewContinuationState } from "../assurance/run_continuation";
|
|
11
|
+
import { digestOfReviewerBytes } from "../assurance/review_mediation";
|
|
12
|
+
import { recognizePhaseEntry } from "../assurance/phase_entry";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { readPhaseEntryState, claudeAliasReader } from "./lane_guard";
|
|
15
|
+
import { appendPrivate, cacheDir, ensurePrivateDir, ownedByUs, readPrivate, sessionHash } from "./review_host";
|
|
16
|
+
|
|
17
|
+
const MCP_TOOL = /^mcp__.*immune-brain.*__(\w+)$/;
|
|
18
|
+
const NATIVE_OPERATIONS = new Set(["enroll", "start_unattended_batch", "request_authorization", "approve_breaking_intent_revision", "stop"]);
|
|
19
|
+
const MAX_METADATA_BYTES = 256 * 1024;
|
|
20
|
+
interface Correlation { session_id: string; workspace: string; tool_use_id: string; operation: string; task_id: string | null }
|
|
21
|
+
interface ContinuationMetadata {
|
|
22
|
+
state: RunContinuationState | null;
|
|
23
|
+
sequence: number;
|
|
24
|
+
review: ReviewContinuationState;
|
|
25
|
+
reviewer_prompt: string | null;
|
|
26
|
+
reviewer_call_id: string | null;
|
|
27
|
+
reviewer_agent_id: string | null;
|
|
28
|
+
recovery_action: string | null;
|
|
29
|
+
paused: string | null;
|
|
30
|
+
stop_blocks: number;
|
|
31
|
+
}
|
|
32
|
+
const blank = (): ContinuationMetadata => ({ state: null, sequence: 0, review: "unobserved", reviewer_prompt: null,
|
|
33
|
+
reviewer_call_id: null, reviewer_agent_id: null, recovery_action: null, paused: null, stop_blocks: 0 });
|
|
34
|
+
const statePath = (sessionId: string, root: string) => join(cacheDir(root), `${sessionHash(sessionId)}.continuation`);
|
|
35
|
+
const callPath = (toolId: string, root: string) => join(cacheDir(root), `${sessionHash(toolId)}.continuation-call`);
|
|
36
|
+
function drop(path: string, root: string): void {
|
|
37
|
+
// Nothing to drop means no cache directory either: an idle session must leave the store untouched.
|
|
38
|
+
if (!existsSync(path)) return;
|
|
39
|
+
if (!ensurePrivateDir(cacheDir(root))) return;
|
|
40
|
+
try { const stat = lstatSync(path); if (stat.isFile() && ownedByUs(stat)) rmSync(path); } catch { /* absent */ }
|
|
41
|
+
}
|
|
42
|
+
function readLast(path: string): unknown {
|
|
43
|
+
let stat;
|
|
44
|
+
try { stat = lstatSync(path); } catch (error) {
|
|
45
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
if (!stat.isFile() || !ownedByUs(stat)) throw new Error("unsafe continuation metadata");
|
|
49
|
+
const text = readPrivate(path, MAX_METADATA_BYTES);
|
|
50
|
+
if (text === undefined) throw new Error("continuation metadata could not be read");
|
|
51
|
+
const lines = text.trim().split("\n");
|
|
52
|
+
return JSON.parse(lines.at(-1) ?? "null");
|
|
53
|
+
}
|
|
54
|
+
function readMetadata(sessionId: string, root: string): ContinuationMetadata {
|
|
55
|
+
const value = readLast(statePath(sessionId, root)) as ContinuationMetadata | null;
|
|
56
|
+
if (!value) return blank();
|
|
57
|
+
if (typeof value !== "object" || !Number.isInteger(value.sequence) || value.sequence < 0 ||
|
|
58
|
+
!Number.isInteger(value.stop_blocks) || value.stop_blocks < 0 ||
|
|
59
|
+
!["unobserved", "ready", "running", "returned", "rejected"].includes(value.review) ||
|
|
60
|
+
![value.reviewer_prompt, value.reviewer_call_id, value.reviewer_agent_id, value.recovery_action, value.paused].every(v => v === null || typeof v === "string"))
|
|
61
|
+
throw new Error("invalid continuation metadata");
|
|
62
|
+
if (value.state) {
|
|
63
|
+
const { binding } = value.state;
|
|
64
|
+
if (!binding || binding.session_id !== sessionId || !Number.isInteger(value.state.unchanged_observations) || value.state.unchanged_observations < 0 ||
|
|
65
|
+
!(value.state.progress_digest === null || typeof value.state.progress_digest === "string") ||
|
|
66
|
+
!(value.state.boundary_id === null || typeof value.state.boundary_id === "string")) throw new Error("invalid bound run metadata");
|
|
67
|
+
bindRun(binding);
|
|
68
|
+
}
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
function save(sessionId: string, root: string, value: ContinuationMetadata): void {
|
|
72
|
+
const path = statePath(sessionId, root);
|
|
73
|
+
const temporary = `${path}.${randomUUID()}`;
|
|
74
|
+
if (!appendPrivate(temporary, cacheDir(root), JSON.stringify(value) + "\n")) throw new Error("continuation metadata could not be written");
|
|
75
|
+
try { renameSync(temporary, path); } finally { drop(temporary, root); }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function observeNativeFailure(metadata: ContinuationMetadata, correlation: Correlation, root: string, reason: string): Promise<void> {
|
|
79
|
+
metadata.paused = reason;
|
|
80
|
+
save(correlation.session_id, root, metadata);
|
|
81
|
+
if (correlation.operation === "enroll" && metadata.state && await parkFailedCriticalGate(metadata.state.binding, correlation.task_id ?? undefined)) {
|
|
82
|
+
metadata.paused = null;
|
|
83
|
+
save(correlation.session_id, root, metadata);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Exact PreToolUse correlation; the transport connection ID is never a Hook session. */
|
|
88
|
+
export function readRunToolSession(toolId: string, operation: string, workspace: string, root = tmpdir()): string | null {
|
|
89
|
+
const record = readLast(callPath(toolId, root)) as Correlation | null;
|
|
90
|
+
if (!record || record.operation !== operation || record.tool_use_id !== toolId || record.workspace !== realpathSync(workspace) ||
|
|
91
|
+
typeof record.session_id !== "string" || !record.session_id || !readPhaseEntryState(record.session_id, root)?.run_live) return null;
|
|
92
|
+
return record.session_id;
|
|
93
|
+
}
|
|
94
|
+
export function selectedClaudeParent(toolId: string, workspace: string, slug: string, root = tmpdir()): boolean {
|
|
95
|
+
const call = readLast(callPath(toolId, root)) as Correlation | null;
|
|
96
|
+
if (!call || call.workspace !== realpathSync(workspace) || !["advance_assurance", "submit_review"].includes(call.operation) ||
|
|
97
|
+
!readPhaseEntryState(call.session_id, root)?.run_live) return false;
|
|
98
|
+
const state = readMetadata(call.session_id, root).state;
|
|
99
|
+
return state?.binding.kind === "parent" && state.binding.workspace === call.workspace && state.binding.initiative_slug === slug;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A transcript assistant UUID identifies a Host turn; local callback counters do not. */
|
|
103
|
+
function stopBoundary(payload: Record<string, unknown>): string | null {
|
|
104
|
+
if (typeof payload.transcript_path !== "string" || !payload.transcript_path) return null;
|
|
105
|
+
const fd = openSync(payload.transcript_path, "r");
|
|
106
|
+
try {
|
|
107
|
+
const stat = fstatSync(fd);
|
|
108
|
+
if (!stat.isFile()) throw new Error("Stop transcript must be a regular file");
|
|
109
|
+
const bytes = Buffer.alloc(Math.min(stat.size, 256 * 1024));
|
|
110
|
+
readSync(fd, bytes, 0, bytes.length, stat.size - bytes.length);
|
|
111
|
+
for (const line of bytes.toString("utf8").trim().split("\n").reverse()) {
|
|
112
|
+
let value; try { value = JSON.parse(line); } catch { continue; }
|
|
113
|
+
if (value.type === "assistant" && !value.isSidechain && typeof value.uuid === "string" && value.uuid)
|
|
114
|
+
return digestOfReviewerBytes(`${payload.transcript_path}\n${value.uuid}`);
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
} finally { closeSync(fd); }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function toolResponse(payload: Record<string, unknown>): Record<string, unknown> | null {
|
|
121
|
+
const response = payload.tool_response;
|
|
122
|
+
if (Array.isArray(response)) {
|
|
123
|
+
const text = response.filter(item => item?.type === "text" && typeof item.text === "string").map(item => item.text).join("");
|
|
124
|
+
try { return JSON.parse(text); } catch { return null; }
|
|
125
|
+
}
|
|
126
|
+
if (response && typeof response === "object" && !Array.isArray(response)) {
|
|
127
|
+
const object = response as Record<string, unknown>;
|
|
128
|
+
if (typeof object.state === "string") return object;
|
|
129
|
+
if (Array.isArray(object.content)) {
|
|
130
|
+
const text = object.content.filter(item => item?.type === "text" && typeof item.text === "string").map(item => item.text).join("");
|
|
131
|
+
try { return JSON.parse(text); } catch { return null; }
|
|
132
|
+
}
|
|
133
|
+
return object;
|
|
134
|
+
}
|
|
135
|
+
if (typeof response === "string") { try { return JSON.parse(response); } catch { return null; } }
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
function warning(reason: string): string { return JSON.stringify({ systemMessage: `Immune-Brain run paused: ${reason}` }); }
|
|
139
|
+
|
|
140
|
+
/** Hook metadata only. Every continuation decision re-reads the actual Kernel. */
|
|
141
|
+
export async function runContinuationHookOutput(payload: Record<string, unknown>, root = tmpdir()): Promise<string | null> {
|
|
142
|
+
const sessionId = typeof payload.session_id === "string" ? payload.session_id : "";
|
|
143
|
+
if (!sessionId) return null;
|
|
144
|
+
const workspace = realpathSync(typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd());
|
|
145
|
+
const event = String(payload.hook_event_name ?? "");
|
|
146
|
+
if (event === "SessionEnd") { drop(statePath(sessionId, root), root); return null; }
|
|
147
|
+
if (event === "UserPromptSubmit") {
|
|
148
|
+
const prompt = typeof payload.prompt === "string" ? payload.prompt : "";
|
|
149
|
+
const entry = recognizePhaseEntry(prompt, claudeAliasReader(workspace, homedir()));
|
|
150
|
+
if (releasesRunBinding(prompt, entry === "plan" ? "planner" : null) || entry === "run")
|
|
151
|
+
drop(statePath(sessionId, root), root);
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
const operation = MCP_TOOL.exec(String(payload.tool_name ?? ""))?.[1];
|
|
156
|
+
const toolId = typeof payload.tool_use_id === "string" ? payload.tool_use_id : "";
|
|
157
|
+
if (event === "PreToolUse" && operation && toolId && readPhaseEntryState(sessionId, root)?.run_live) {
|
|
158
|
+
const metadata = readMetadata(sessionId, root);
|
|
159
|
+
if (metadata.paused && NATIVE_OPERATIONS.has(operation)) return JSON.stringify({ hookSpecificOutput: {
|
|
160
|
+
hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: `Run paused: ${metadata.paused}; enter imm-run explicitly before another native gate` } });
|
|
161
|
+
const input = (payload.tool_input ?? {}) as Record<string, unknown>;
|
|
162
|
+
if (operation === "enroll" && metadata.state?.binding.kind === "parent" &&
|
|
163
|
+
metadata.state.binding.blocked_native_tasks?.includes(String(input.task_id))) return JSON.stringify({ hookSpecificOutput: {
|
|
164
|
+
hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "This critical gate is parked; continue independent work or resolve its same-Host recovery and enter imm-run explicitly" } });
|
|
165
|
+
const correlation: Correlation = { session_id: sessionId, workspace, tool_use_id: toolId, operation,
|
|
166
|
+
task_id: typeof input.task_id === "string" ? input.task_id : null };
|
|
167
|
+
if (!appendPrivate(callPath(toolId, root), cacheDir(root), JSON.stringify(correlation) + "\n")) throw new Error("tool correlation could not be recorded");
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
const metadata = readMetadata(sessionId, root);
|
|
171
|
+
if (metadata.paused) return event === "Stop" ? warning(metadata.paused) : null;
|
|
172
|
+
if ((event === "PostToolUse" || event === "PostToolUseFailure") && operation && toolId) {
|
|
173
|
+
const correlated = readLast(callPath(toolId, root)) as Correlation | null;
|
|
174
|
+
if (!correlated || correlated.session_id !== sessionId || correlated.workspace !== workspace || correlated.operation !== operation ||
|
|
175
|
+
readRunToolSession(toolId, operation, workspace, root) !== sessionId) return null;
|
|
176
|
+
drop(callPath(toolId, root), root);
|
|
177
|
+
if (event === "PostToolUseFailure") {
|
|
178
|
+
if (NATIVE_OPERATIONS.has(operation)) await observeNativeFailure(metadata, correlated, root, `${operation} failed or was cancelled; no automatic native retry`);
|
|
179
|
+
else if (operation === "submit_review") { metadata.review = "rejected"; save(sessionId, root, metadata); }
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
const response = toolResponse(payload);
|
|
183
|
+
if (operation === "start_unattended_batch" && response?.state === "native_parent" && typeof response.plan_digest === "string" && typeof response.initiative_slug === "string") {
|
|
184
|
+
if (metadata.state?.binding.kind !== "parent") metadata.state = bindRun({ kind: "parent", workspace, session_id: sessionId,
|
|
185
|
+
batch_id: null, initiative_slug: response.initiative_slug, plan_digest: response.plan_digest,
|
|
186
|
+
final_verification: response.final_verification_commands as string[] | undefined });
|
|
187
|
+
}
|
|
188
|
+
if (operation === "start_unattended_batch" && response?.state === "started" && typeof response.batch_id === "string") {
|
|
189
|
+
const batch = readAnyBatchRunState(workspace, response.batch_id);
|
|
190
|
+
if (!batch) throw new Error("batch receipt has no persisted identity");
|
|
191
|
+
if (metadata.state?.binding.kind !== "parent") metadata.state = bindRun({ kind: "parent", workspace,
|
|
192
|
+
session_id: sessionId, batch_id: batch.batch_id, initiative_slug: batch.initiative_slug, plan_digest: batch.plan_digest });
|
|
193
|
+
}
|
|
194
|
+
if (!metadata.state && correlated.task_id && ["enroll", "status"].includes(operation)) {
|
|
195
|
+
const status = await projectTaskStatus(workspace, correlated.task_id);
|
|
196
|
+
if (!status.error && status.claim?.task_id === correlated.task_id && status.projection.lifecycle === "active" && status.projection.run_id)
|
|
197
|
+
metadata.state = bindRun({ kind: "task", workspace, session_id: sessionId, task_id: correlated.task_id, run_id: status.projection.run_id });
|
|
198
|
+
}
|
|
199
|
+
if (operation === "enroll" && correlated.task_id && metadata.state?.binding.kind === "parent" && !["blocked", "cancelled", "failed", "rejected"].includes(String(response?.state)))
|
|
200
|
+
await recordParentRepair(workspace, metadata.state.binding.batch_id, correlated.task_id, metadata.state.binding.initiative_slug);
|
|
201
|
+
if (metadata.state?.binding.kind === "task" && correlated.task_id !== metadata.state.binding.task_id) return null;
|
|
202
|
+
if (operation === "advance_assurance" && response?.state === "review_ready") {
|
|
203
|
+
const params = response.agent_params as { prompt?: unknown } | undefined;
|
|
204
|
+
if (typeof params?.prompt === "string" && digestOfReviewerBytes(params.prompt) !== metadata.reviewer_prompt) {
|
|
205
|
+
metadata.reviewer_prompt = digestOfReviewerBytes(params.prompt); metadata.reviewer_call_id = null; metadata.reviewer_agent_id = null; metadata.review = "ready";
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (operation === "submit_review") {
|
|
209
|
+
if (["blocked", "rejected", "failed"].includes(String(response?.state))) {
|
|
210
|
+
metadata.review = "rejected";
|
|
211
|
+
const recovery = response?.recovery_action ?? response?.next_action;
|
|
212
|
+
metadata.recovery_action = typeof recovery === "string" ? recovery : null;
|
|
213
|
+
} else { metadata.review = "unobserved"; metadata.reviewer_prompt = null; metadata.reviewer_call_id = null; metadata.recovery_action = null; }
|
|
214
|
+
}
|
|
215
|
+
if (NATIVE_OPERATIONS.has(operation) && ["blocked", "cancelled", "rejected", "failed"].includes(String(response?.state)))
|
|
216
|
+
await observeNativeFailure(metadata, correlated, root, `${operation} did not apply; inspect the same-Host recovery before retrying`);
|
|
217
|
+
if (metadata.state) save(sessionId, root, metadata);
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
if (payload.tool_name === "Agent" && metadata.state && metadata.reviewer_prompt) {
|
|
221
|
+
const input = payload.tool_input as { prompt?: unknown; subagent_type?: unknown } | undefined;
|
|
222
|
+
if (event === "PreToolUse" && typeof input?.prompt === "string" && digestOfReviewerBytes(input.prompt) === metadata.reviewer_prompt && toolId) {
|
|
223
|
+
if (metadata.reviewer_call_id) { metadata.review = "rejected"; }
|
|
224
|
+
else { metadata.reviewer_call_id = toolId; metadata.review = "running"; }
|
|
225
|
+
save(sessionId, root, metadata);
|
|
226
|
+
}
|
|
227
|
+
if ((event === "PostToolUse" || event === "PostToolUseFailure") && toolId === metadata.reviewer_call_id) {
|
|
228
|
+
const response = toolResponse(payload);
|
|
229
|
+
if (response?.isAsync === true || response?.status === "async_launched") {
|
|
230
|
+
metadata.reviewer_agent_id = typeof response.agentId === "string" ? response.agentId : null;
|
|
231
|
+
} else metadata.review = "returned";
|
|
232
|
+
save(sessionId, root, metadata);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (event === "SubagentStop" && metadata.reviewer_agent_id && payload.agent_id === metadata.reviewer_agent_id) {
|
|
236
|
+
metadata.review = "returned"; save(sessionId, root, metadata); return null;
|
|
237
|
+
}
|
|
238
|
+
if (event !== "Stop" || !metadata.state) return null;
|
|
239
|
+
if (!readPhaseEntryState(sessionId, root)?.run_live) { drop(statePath(sessionId, root), root); return null; }
|
|
240
|
+
if (metadata.state.binding.workspace !== workspace) {
|
|
241
|
+
drop(statePath(sessionId, root), root);
|
|
242
|
+
return warning("workspace changed; enter imm-run explicitly in this workspace");
|
|
243
|
+
}
|
|
244
|
+
const boundary = stopBoundary(payload);
|
|
245
|
+
if (!boundary) return null;
|
|
246
|
+
metadata.sequence++;
|
|
247
|
+
const taskId = metadata.state.binding.kind === "task" ? metadata.state.binding.task_id : metadata.state.binding.initiative_slug;
|
|
248
|
+
let observation: Parameters<typeof decideRunContinuation>[1];
|
|
249
|
+
if (metadata.state.binding.kind === "parent") observation = await observeParentRun(metadata.state.binding,
|
|
250
|
+
boundary, metadata.review, metadata.recovery_action ?? undefined);
|
|
251
|
+
else {
|
|
252
|
+
const status = await projectTaskStatus(workspace, taskId);
|
|
253
|
+
const record = readTaskRecordRaw(workspace, taskId);
|
|
254
|
+
observation = { kind: "task", workspace, session_id: sessionId,
|
|
255
|
+
boundary_id: boundary, status,
|
|
256
|
+
source_digest: record.record ? scopedSourceDigest(workspace, record.record.intent_snapshot.scope_hint) : "terminal",
|
|
257
|
+
review: metadata.review, recovery_action: metadata.recovery_action ?? undefined };
|
|
258
|
+
}
|
|
259
|
+
const decision = decideRunContinuation(metadata.state, observation);
|
|
260
|
+
if (decision.action === "unbound" || decision.action === "settled") { drop(statePath(sessionId, root), root); return null; }
|
|
261
|
+
if (decision.action === "wait") { metadata.state = decision.state; save(sessionId, root, metadata); return null; }
|
|
262
|
+
if (decision.state.progress_digest !== metadata.state.progress_digest || payload.stop_hook_active !== true) metadata.stop_blocks = 0;
|
|
263
|
+
metadata.state = decision.state;
|
|
264
|
+
if (decision.action === "pause" || metadata.stop_blocks >= 7) {
|
|
265
|
+
metadata.paused = metadata.stop_blocks >= 7 ? "Claude consecutive Stop boundary reached; resume explicitly in the same Host" : decision.reason;
|
|
266
|
+
save(sessionId, root, metadata); return warning(metadata.paused);
|
|
267
|
+
}
|
|
268
|
+
metadata.stop_blocks++;
|
|
269
|
+
save(sessionId, root, metadata);
|
|
270
|
+
return JSON.stringify({ decision: "block", reason: `Explicit imm-run is bound to ${taskId}; Kernel settlement is incomplete. ${decision.next_action}. Continue the authorized Loop.` });
|
|
271
|
+
} catch (error) {
|
|
272
|
+
return warning(`continuation facts could not be verified: ${error instanceof Error ? error.message : String(error)}`);
|
|
273
|
+
}
|
|
274
|
+
}
|