immune-brain 5.1.1 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +13 -6
  2. package/README.zh-CN.md +12 -5
  3. package/package.json +1 -1
  4. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  5. package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +14 -2
  6. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +93 -0
  7. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +81 -13
  8. package/plugins/immune-brain/agents/immune-brain-reviewer.md +20 -0
  9. package/plugins/immune-brain/dist/BASELINE.md +1 -1
  10. package/plugins/immune-brain/dist/claude/mcp-server.mjs +8573 -7449
  11. package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +1 -1
  12. package/plugins/immune-brain/dist/imm-brainstorm.md +6 -6
  13. package/plugins/immune-brain/dist/imm-doc-prune.md +2 -1
  14. package/plugins/immune-brain/dist/imm-planner.md +63 -37
  15. package/plugins/immune-brain/dist/imm-run.md +137 -12
  16. package/plugins/immune-brain/dist/imm-test-prune.md +233 -0
  17. package/plugins/immune-brain/dist/registry.yaml +9 -0
  18. package/plugins/immune-brain/dist/role-prompts/code-review.md +20 -0
  19. package/plugins/immune-brain/dist/role-prompts/executor.md +15 -0
  20. package/plugins/immune-brain/hooks/hooks.json +31 -1
  21. package/plugins/immune-brain/runtime/assurance/active_task_notice.ts +42 -0
  22. package/plugins/immune-brain/runtime/assurance/alias_file.ts +24 -0
  23. package/plugins/immune-brain/runtime/assurance/coordinator.ts +9 -0
  24. package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +4 -1
  25. package/plugins/immune-brain/runtime/assurance/phase_entry.ts +143 -0
  26. package/plugins/immune-brain/runtime/claude/interaction.ts +10 -1
  27. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +32 -6
  28. package/plugins/immune-brain/runtime/claude/lane_guard.ts +210 -0
  29. package/plugins/immune-brain/runtime/claude/mcp_server.ts +85 -7
  30. package/plugins/immune-brain/runtime/claude/review_host.ts +6 -6
  31. package/plugins/immune-brain/runtime/commands/kernel.ts +54 -1
  32. package/plugins/immune-brain/runtime/github_issue_tracker.ts +235 -7
  33. package/plugins/immune-brain/runtime/kernel/runtime_contracts.json +7 -0
  34. package/plugins/immune-brain/runtime/kernel/runtime_contracts.ts +75 -0
  35. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  36. package/plugins/immune-brain/runtime/prompts/code-review.md +20 -0
  37. package/plugins/immune-brain/runtime/prompts/executor.md +15 -0
  38. package/plugins/immune-brain/runtime/unattended/batch_delegation.ts +160 -0
  39. package/plugins/immune-brain/runtime/unattended/batch_final_verification.ts +72 -0
  40. package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +396 -23
  41. package/plugins/immune-brain/runtime/unattended/batch_leak_restore.ts +131 -0
  42. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +25 -4
  43. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +3 -2
  44. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +42 -0
  45. package/plugins/immune-brain/runtime/unattended/batch_schedule.ts +40 -0
  46. package/plugins/immune-brain/runtime/unattended/batch_state.ts +155 -0
  47. package/plugins/immune-brain/runtime/unattended/lane_workspace.ts +58 -0
  48. package/plugins/immune-brain/skills/BASELINE.md +1 -1
  49. package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +3 -2
  50. package/plugins/immune-brain/skills/imm-planner/SKILL.md +8 -6
  51. package/plugins/immune-brain/skills/imm-run/SKILL.md +2 -1
  52. package/plugins/immune-brain/skills/imm-test-prune/SKILL.md +28 -0
  53. package/plugins/immune-brain/skills/registry.yaml +9 -0
@@ -55,9 +55,11 @@ import {
55
55
  startBatch,
56
56
  type BatchRunnerKernelPort,
57
57
  type BatchRunReport,
58
+ type BatchTrackerPort,
58
59
  } from "../unattended/batch_runner";
59
60
  import { createBatchKernelPort } from "../unattended/batch_kernel_port";
60
- import type { LaneOffer } from "../unattended/batch_lanes";
61
+ import type { LaneRevisionRequest } from "../unattended/batch_delegation";
62
+ import { createBatchTrackerPort, laneRuntimeContractRefusal, type LaneInstructionRequest, type LaneOffer } from "../unattended/batch_lanes";
61
63
  import { retireStaleBatch } from "../unattended/batch_disposition";
62
64
  import type { BatchLaneRunReport } from "../unattended/batch_state";
63
65
  import {
@@ -231,6 +233,7 @@ export interface ClaudeRuntimeOptions {
231
233
  requestConfirmation?: NativeConfirmationPort;
232
234
  batchKernel?: Partial<BatchRunnerKernelPort>;
233
235
  batchGit?: BatchRunnerGitPort;
236
+ batchTracker?: BatchTrackerPort;
234
237
  readInitiative?: InitiativeObservationReader;
235
238
  }
236
239
 
@@ -268,6 +271,7 @@ export class ClaudeRuntime {
268
271
  private app: ReturnType<typeof createCanaryApplication> | null = null;
269
272
  private readonly batchKernel?: Partial<BatchRunnerKernelPort>;
270
273
  private readonly batchGit?: BatchRunnerGitPort;
274
+ private readonly batchTracker?: BatchTrackerPort;
271
275
  private readonly readInitiative?: InitiativeObservationReader;
272
276
 
273
277
  constructor(options: ClaudeRuntimeOptions) {
@@ -277,6 +281,7 @@ export class ClaudeRuntime {
277
281
  this.requestConfirmation = options.requestConfirmation;
278
282
  this.batchKernel = options.batchKernel;
279
283
  this.batchGit = options.batchGit;
284
+ this.batchTracker = options.batchTracker;
280
285
  this.readInitiative = options.readInitiative;
281
286
  this.host = options.host ?? new ClaudeReviewHost(new FileHookEventLog());
282
287
  this.coordinator = new AssuranceCoordinator({
@@ -741,7 +746,7 @@ export class ClaudeRuntime {
741
746
  async startUnattendedBatch(
742
747
  initiativeSlug: string,
743
748
  meta: ToolMeta,
744
- options: { reuseOnly?: boolean; max_parallel?: number; lane_offers?: LaneOffer[] } = {},
749
+ options: { reuseOnly?: boolean; max_parallel?: number; lane_offers?: LaneOffer[]; final_verification?: string[]; lane_instruction?: LaneInstructionRequest; lane_revision?: LaneRevisionRequest } = {},
745
750
  ): Promise<ClaudeBatchStartResult> {
746
751
  throwIfCancelled(meta.signal);
747
752
  const reuseOnly = options.reuseOnly === true;
@@ -779,6 +784,12 @@ export class ClaudeRuntime {
779
784
  const budget = preflight.projection.budget;
780
785
  const planDigest = preflight.projection.plan_digest;
781
786
  const recoveryChildren = preflight.projection.recovery_children;
787
+ // A new lane batch whose Lane runtime cannot read the coordinator's Kernel
788
+ // contracts is refused before the gate opens; a match adds nothing.
789
+ if (options.max_parallel !== undefined && !isResuming) {
790
+ const contractRefusal = laneRuntimeContractRefusal(this.cwd);
791
+ if (contractRefusal) return batchReason("batch_run_rejected", contractRefusal);
792
+ }
782
793
  // 5. Literal-user gate plus the shared reuse decision, the post-gate
783
794
  // claim/drift cascade, and the Batch Authorization binding. The Host supplies
784
795
  // only its gate, its confirmation reference, and its binding nonce.
@@ -803,7 +814,7 @@ export class ClaudeRuntime {
803
814
  };
804
815
  // The gate settles only on the literal user's answer or the caller's
805
816
  // cancellation signal.
806
- let confirmationResult: { decision: NativeDecision; requestId: string };
817
+ let confirmationResult: { decision: NativeDecision; requestId: string; delegateRevisions?: boolean };
807
818
  try {
808
819
  confirmationResult = await this.requestConfirmation!({
809
820
  operation: "start_unattended_batch",
@@ -830,6 +841,8 @@ export class ClaudeRuntime {
830
841
  max_parallel: options.max_parallel,
831
842
  parallel_groups: facts.parallel_groups,
832
843
  serialized: facts.scope_conflicts,
844
+ ...(options.final_verification ? { final_verification: options.final_verification } : {}),
845
+ offer_revision_delegation: true,
833
846
  },
834
847
  }
835
848
  : {}),
@@ -874,7 +887,11 @@ export class ClaudeRuntime {
874
887
  return { kind: "host_rejection", value: batchReason("confirmation_cancelled") };
875
888
  if (confirmationResult.decision !== "accept")
876
889
  return { kind: "host_rejection", value: batchReason("confirmation_no_decision") };
877
- return { kind: "confirmed", request_id: confirmationResult.requestId };
890
+ return {
891
+ kind: "confirmed",
892
+ request_id: confirmationResult.requestId,
893
+ ...(options.max_parallel !== undefined && confirmationResult.delegateRevisions ? { delegate_revisions: true } : {}),
894
+ };
878
895
  },
879
896
  // A reused authorization keeps this invocation's Kernel binding without a new
880
897
  // literal-user act, so its confirmation reference names the resumed batch
@@ -937,7 +954,16 @@ export class ClaudeRuntime {
937
954
  now,
938
955
  kernel: kernelPort,
939
956
  git: this.batchGit,
940
- ...(options.max_parallel !== undefined ? { max_parallel: options.max_parallel } : {}),
957
+ ...(options.max_parallel !== undefined
958
+ ? {
959
+ max_parallel: options.max_parallel,
960
+ tracker: this.batchTracker ?? createBatchTrackerPort(this.cwd, initiativeSlug),
961
+ ...(options.final_verification ? { final_verification: options.final_verification } : {}),
962
+ ...(options.lane_instruction ? { lane_instruction: options.lane_instruction } : {}),
963
+ ...(options.lane_revision ? { lane_revision: options.lane_revision } : {}),
964
+ ...(authorization.revision_delegation !== undefined ? { revision_delegation: authorization.revision_delegation } : {}),
965
+ }
966
+ : {}),
941
967
  ...(options.lane_offers !== undefined ? { lane_offers: options.lane_offers } : {}),
942
968
  });
943
969
 
@@ -969,7 +995,7 @@ export class ClaudeRuntime {
969
995
  initiative_slug: initiativeSlug,
970
996
  now: new Date().toISOString(),
971
997
  gate: async (facts) => {
972
- let confirmationResult: { decision: NativeDecision; requestId: string };
998
+ let confirmationResult: { decision: NativeDecision; requestId: string; delegateRevisions?: boolean };
973
999
  try {
974
1000
  confirmationResult = await this.requestConfirmation!({
975
1001
  operation: "retire_stale_batch",
@@ -0,0 +1,210 @@
1
+ // Claude Code hook decisions that are not Review observation. PreToolUse guard for batch Lanes: an Edit, Write, MultiEdit or
2
+ // NotebookEdit whose target resolves outside the Lane is denied before it
3
+ // runs, the same rule the Pi extension applies to `edit`/`write`. Bash is not
4
+ // intercepted. Outside a Lane the hook answers nothing and the tool runs as
5
+ // configured.
6
+ //
7
+ // Phase entry gate: UserPromptSubmit records which literal prompt entered
8
+ // planning or execution; PreToolUse denies `enroll`, `start_unattended_batch`
9
+ // and the three phase Skills without a matching entry, and otherwise leaves an
10
+ // admission the tool handler consumes by tool-use id.
11
+ import { lstatSync, rmSync } from "node:fs";
12
+ import { homedir, tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { laneWriteRefusal } from "../unattended/lane_workspace";
15
+ import { activeTaskNotice } from "../assurance/active_task_notice";
16
+ import { readAliasFile } from "../assurance/alias_file";
17
+ import {
18
+ admitPhaseOperation,
19
+ NO_PHASE_ENTRY,
20
+ packagedPhaseSkill,
21
+ phaseEntryLogLine,
22
+ phaseRefusalMessage,
23
+ PHASE_ENTRY_RECOVERY,
24
+ recognizePhaseEntry,
25
+ reducePhaseEntryLog,
26
+ RUN_ENTRY_REQUIRED,
27
+ type AliasReader,
28
+ type PhaseEntryState,
29
+ type PhaseOperation,
30
+ } from "../assurance/phase_entry";
31
+ import { readActiveClaimTaskId } from "../unattended/batch_preflight";
32
+ import { appendPrivate, cacheDir, ensurePrivateDir, ownedByUs, readPrivate, sessionHash } from "./review_host";
33
+
34
+ const FILE_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
35
+ const GATED_MCP_TOOL = /^mcp__.*immune-brain.*__(enroll|start_unattended_batch)$/;
36
+
37
+ export type GatedTool = "enroll" | "start_unattended_batch";
38
+
39
+ /** Where the phase entry gate keeps its private files and finds user aliases. */
40
+ export interface PhaseEntryLocations {
41
+ /** Parent of the private cache directory. Defaults to the OS temp directory. */
42
+ root?: string;
43
+ /** The user's home, for `~/.claude/commands`. */
44
+ home?: string;
45
+ /** The private store's append and read. Default to the real file operations. */
46
+ append?: typeof appendPrivate;
47
+ read?: typeof readPrivate;
48
+ }
49
+
50
+ function denyPreToolUse(reason: string): string {
51
+ return JSON.stringify({
52
+ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason },
53
+ });
54
+ }
55
+
56
+ /** The hook's stdout for one PreToolUse payload, or null to stay silent. */
57
+ export function laneGuardHookOutput(payload: Record<string, unknown>): string | null {
58
+ if (payload.hook_event_name !== "PreToolUse" || !FILE_TOOLS.has(String(payload.tool_name))) return null;
59
+ const input = (payload.tool_input ?? {}) as { file_path?: unknown; notebook_path?: unknown };
60
+ const cwd = typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd();
61
+ const refusal = laneWriteRefusal(cwd, input.file_path ?? input.notebook_path);
62
+ if (!refusal) return null;
63
+ return denyPreToolUse(refusal);
64
+ }
65
+
66
+ /**
67
+ * UserPromptSubmit: when the workspace has an active Managed task, add the
68
+ * advisory notice as context. It never resumes the task.
69
+ */
70
+ export async function activeTaskHookOutput(payload: Record<string, unknown>): Promise<string | null> {
71
+ if (payload.hook_event_name !== "UserPromptSubmit") return null;
72
+ const cwd = typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd();
73
+ const notice = await activeTaskNotice(cwd);
74
+ if (!notice) return null;
75
+ return JSON.stringify({ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: notice } });
76
+ }
77
+
78
+ function entryLogPath(sessionId: string, root: string): string {
79
+ return join(cacheDir(root), `${sessionHash(sessionId)}.entry`);
80
+ }
81
+
82
+ function admissionPath(toolUseId: string, root: string): string {
83
+ return join(cacheDir(root), `${sessionHash(toolUseId)}.admit`);
84
+ }
85
+
86
+ function sessionIdOf(payload: Record<string, unknown>): string {
87
+ return typeof payload.session_id === "string" ? payload.session_id : "";
88
+ }
89
+
90
+ function cwdOf(payload: Record<string, unknown>): string {
91
+ return typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd();
92
+ }
93
+
94
+ /** Project aliases first, then the user's, as Claude Code resolves commands. */
95
+ function claudeAliasReader(cwd: string, home: string): AliasReader {
96
+ return (name) => {
97
+ for (const dir of [join(cwd, ".claude", "commands"), join(home, ".claude", "commands")]) {
98
+ const body = readAliasFile(join(dir, `${name}.md`));
99
+ if (body !== null) return body;
100
+ }
101
+ return null;
102
+ };
103
+ }
104
+
105
+ /** The session's entry state: none recorded yet, or null when the store is unreadable. */
106
+ export function readPhaseEntryState(sessionId: string, root = tmpdir(), read: typeof readPrivate = readPrivate): PhaseEntryState | null {
107
+ if (!sessionId) return null;
108
+ const path = entryLogPath(sessionId, root);
109
+ try {
110
+ lstatSync(path);
111
+ } catch (error) {
112
+ return (error as NodeJS.ErrnoException).code === "ENOENT" ? NO_PHASE_ENTRY : null;
113
+ }
114
+ if (!ensurePrivateDir(cacheDir(root))) return null;
115
+ let text: string | undefined;
116
+ try { text = read(path); } catch { return null; }
117
+ return text === undefined ? null : reducePhaseEntryLog(text);
118
+ }
119
+
120
+ /** Removes one of our private files. Never follows a link or touches another owner's file. */
121
+ function dropPrivate(path: string): void {
122
+ try {
123
+ const stat = lstatSync(path);
124
+ if (ownedByUs(stat) && stat.isFile()) rmSync(path, { force: true });
125
+ } catch { /* already gone, or not ours to remove */ }
126
+ }
127
+
128
+ /**
129
+ * UserPromptSubmit: record whether this literal prompt entered a phase. Every
130
+ * prompt is recorded so "the latest prompt" is decidable later. A prompt that
131
+ * could not be recorded drops the log: an earlier entry must not outlive a
132
+ * prompt the store never saw, so the session is back to no entry.
133
+ */
134
+ export function recordPhaseEntryPrompt(payload: Record<string, unknown>, locations: PhaseEntryLocations = {}): boolean {
135
+ if (payload.hook_event_name !== "UserPromptSubmit") return false;
136
+ const sessionId = sessionIdOf(payload);
137
+ if (!sessionId) return false;
138
+ const root = locations.root ?? tmpdir();
139
+ const prompt = typeof payload.prompt === "string" ? payload.prompt : "";
140
+ const entry = recognizePhaseEntry(prompt, claudeAliasReader(cwdOf(payload), locations.home ?? homedir()));
141
+ const path = entryLogPath(sessionId, root);
142
+ const recorded = (locations.append ?? appendPrivate)(path, cacheDir(root), phaseEntryLogLine(entry));
143
+ if (!recorded) dropPrivate(path);
144
+ return recorded;
145
+ }
146
+
147
+ function gatedOperation(payload: Record<string, unknown>): PhaseOperation | null {
148
+ const toolName = String(payload.tool_name ?? "");
149
+ if (toolName === "Skill") {
150
+ const input = (payload.tool_input ?? {}) as { skill?: unknown };
151
+ const skill = typeof input.skill === "string" ? packagedPhaseSkill(input.skill) : null;
152
+ return skill ? { kind: "skill", skill } : null;
153
+ }
154
+ const mcp = GATED_MCP_TOOL.exec(toolName);
155
+ return mcp ? { kind: "tool", name: mcp[1] as GatedTool } : null;
156
+ }
157
+
158
+ /**
159
+ * PreToolUse for a gated tool: deny without a matching entry; for the two MCP
160
+ * tools, otherwise write the admission the handler consumes. Any other tool
161
+ * answers null and runs as configured.
162
+ */
163
+ export function phaseEntryGateHookOutput(payload: Record<string, unknown>, locations: PhaseEntryLocations = {}): string | null {
164
+ if (payload.hook_event_name !== "PreToolUse") return null;
165
+ const operation = gatedOperation(payload);
166
+ if (!operation) return null;
167
+ const root = locations.root ?? tmpdir();
168
+ let state: PhaseEntryState | null = null;
169
+ try { state = readPhaseEntryState(sessionIdOf(payload), root, locations.read); } catch { /* an unreadable store refuses */ }
170
+ let activeClaim = false;
171
+ try { activeClaim = readActiveClaimTaskId(cwdOf(payload)) !== null; } catch { /* unreadable authority is no claim */ }
172
+ const admission = admitPhaseOperation(operation, state, { activeClaim });
173
+ if (!admission.ok) return denyPreToolUse(phaseRefusalMessage(admission));
174
+ if (operation.kind === "skill") return null;
175
+ const toolUseId = typeof payload.tool_use_id === "string" ? payload.tool_use_id : "";
176
+ let recorded = false;
177
+ try {
178
+ recorded = toolUseId !== ""
179
+ && (locations.append ?? appendPrivate)(admissionPath(toolUseId, root), cacheDir(root), `${JSON.stringify({ v: 1, operation: operation.name })}\n`);
180
+ } catch { /* an admission that could not be written refuses */ }
181
+ if (recorded) return null;
182
+ return denyPreToolUse(
183
+ `${RUN_ENTRY_REQUIRED}: the admission for this ${operation.name} call could not be recorded; recovery: ${PHASE_ENTRY_RECOVERY[RUN_ENTRY_REQUIRED]}`,
184
+ );
185
+ }
186
+
187
+ /**
188
+ * Tool side: take the admission the hook wrote for this exact call. One use;
189
+ * absent, unreadable or written for another operation all refuse. The hook may
190
+ * run more than once for one call (two enabled copies of the plugin), so
191
+ * repeated identical admissions count as one.
192
+ */
193
+ export function consumePhaseAdmission(toolUseId: string, operation: GatedTool, root = tmpdir()): boolean {
194
+ if (!toolUseId || !ensurePrivateDir(cacheDir(root))) return false;
195
+ const path = admissionPath(toolUseId, root);
196
+ let text: string | undefined;
197
+ try { text = readPrivate(path); } catch { return false; }
198
+ if (text === undefined) return false;
199
+ dropPrivate(path);
200
+ const lines = text.split("\n").filter(Boolean);
201
+ if (lines.length === 0) return false;
202
+ return lines.every((line) => {
203
+ try {
204
+ const record = JSON.parse(line) as { v?: unknown; operation?: unknown } | null;
205
+ return record?.v === 1 && record.operation === operation;
206
+ } catch {
207
+ return false;
208
+ }
209
+ });
210
+ }
@@ -12,8 +12,11 @@ import {
12
12
  type NativeConfirmationPort,
13
13
  } from "./interaction";
14
14
  import { ClaudeReviewHost, FileHookEventLog, parseHookStdin } from "./review_host";
15
+ import { activeTaskHookOutput, consumePhaseAdmission, laneGuardHookOutput, phaseEntryGateHookOutput, recordPhaseEntryPrompt, type GatedTool } from "./lane_guard";
15
16
  import { ClaudeRuntime, type ToolMeta } from "./kernel_ports";
16
- import { parseLaneOffers, parseMaxParallel, resolveLaneParallel } from "../unattended/batch_lanes";
17
+ import { parseLaneInstruction, parseLaneOffers, parseMaxParallel, resolveLaneParallel } from "../unattended/batch_lanes";
18
+ import { parseFinalVerification } from "../unattended/batch_final_verification";
19
+ import { parseLaneRevision, REVISION_DELEGATION_TEXT } from "../unattended/batch_delegation";
17
20
  import type { AssuranceCoordinatorPorts } from "../assurance/coordinator";
18
21
 
19
22
  export const MCP_PROTOCOL_VERSION = "2025-06-18";
@@ -56,6 +59,24 @@ export function listMcpTools() {
56
59
  additionalProperties: false,
57
60
  },
58
61
  },
62
+ final_verification: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 8 },
63
+ lane_instruction: {
64
+ type: "object",
65
+ properties: {
66
+ task_id: { type: "string" },
67
+ kind: { type: "string", enum: ["instruction", "correction"] },
68
+ text: { type: "string" },
69
+ session_state: { type: "string", enum: ["idle", "working", "blocked", "done"] },
70
+ },
71
+ required: ["task_id", "text", "session_state"],
72
+ additionalProperties: false,
73
+ },
74
+ lane_revision: {
75
+ type: "object",
76
+ properties: { task_id: { type: "string" }, next_intent: { type: "object" } },
77
+ required: ["task_id", "next_intent"],
78
+ additionalProperties: false,
79
+ },
59
80
  }
60
81
  : {}),
61
82
  }
@@ -97,6 +118,11 @@ export interface McpRuntimeOptions {
97
118
  batchKernel?: Partial<import("../unattended/batch_runner").BatchRunnerKernelPort>;
98
119
  batchGit?: import("../unattended/batch_git").BatchRunnerGitPort;
99
120
  readInitiative?: import("../unattended/types").InitiativeObservationReader;
121
+ /**
122
+ * Takes the admission the PreToolUse hook wrote for one `enroll` or
123
+ * `start_unattended_batch` call. Defaults to the private file store.
124
+ */
125
+ consumePhaseAdmission?: (toolCallId: string, operation: GatedTool) => boolean;
100
126
  }
101
127
 
102
128
  export function createMcpRuntime(options: McpRuntimeOptions = {}) {
@@ -118,6 +144,14 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
118
144
  let negotiatedVersion: string | undefined;
119
145
  let negotiatedInteractive = false;
120
146
  const connectionId = randomUUID();
147
+ const consumeAdmission = options.consumePhaseAdmission ?? ((toolCallId: string, operation: GatedTool) => consumePhaseAdmission(toolCallId, operation));
148
+ // The phase entry gate, before any preparation or native dialog. The hook
149
+ // decided on the session's literal entry; no admission for this exact call
150
+ // means no hook saw it, which refuses.
151
+ const requireRunEntry = (operation: GatedTool, toolCallId: string) => {
152
+ if (consumeAdmission(toolCallId, operation)) return;
153
+ throw new NativeAuthorityError("run_entry_required", `${operation} needs a literal-user imm-run entry in this session`);
154
+ };
121
155
  return {
122
156
  runtime,
123
157
  host,
@@ -143,6 +177,7 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
143
177
  }
144
178
  const probe = probeHost(options.env ?? process.env, process.platform, negotiatedVersion);
145
179
  if (!probe.ok) throw new NativeAuthorityError("unsupported_host", probe.reason);
180
+ requireRunEntry(name, meta.toolCallId ?? `call-${name}`);
146
181
  const toolMeta: ToolMeta = {
147
182
  sessionId: meta.sessionId ?? connectionId,
148
183
  toolCallId: meta.toolCallId ?? `call-${name}`,
@@ -153,10 +188,16 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
153
188
  };
154
189
  const laneOffers = parseLaneOffers(args.lane_offers);
155
190
  // A lane_offers-only call resumes the recorded lane batch; refused before any gate otherwise.
156
- const maxParallel = resolveLaneParallel(options.cwd ?? process.cwd(), initiativeSlug, parseMaxParallel(args.max_parallel), laneOffers);
191
+ const laneInstruction = parseLaneInstruction(args.lane_instruction);
192
+ const laneRevision = parseLaneRevision(args.lane_revision);
193
+ const maxParallel = resolveLaneParallel(options.cwd ?? process.cwd(), initiativeSlug, parseMaxParallel(args.max_parallel), laneOffers, laneInstruction ?? laneRevision);
194
+ const finalVerification = parseFinalVerification(args.final_verification);
157
195
  return runtime.startUnattendedBatch(initiativeSlug, toolMeta, {
158
196
  ...(maxParallel !== undefined ? { max_parallel: maxParallel } : {}),
159
197
  ...(laneOffers !== undefined ? { lane_offers: laneOffers } : {}),
198
+ ...(finalVerification !== undefined ? { final_verification: finalVerification } : {}),
199
+ ...(laneInstruction !== undefined ? { lane_instruction: laneInstruction } : {}),
200
+ ...(laneRevision !== undefined ? { lane_revision: laneRevision } : {}),
160
201
  });
161
202
  }
162
203
  if (name === "retire_stale_batch") {
@@ -203,7 +244,10 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
203
244
  interactive: meta.interactive ?? options.interactive ?? negotiatedInteractive,
204
245
  signal: meta.signal,
205
246
  };
206
- if (name === "enroll") return runtime.enroll(taskId, toolMeta);
247
+ if (name === "enroll") {
248
+ requireRunEntry(name, meta.toolCallId ?? `call-${name}`);
249
+ return runtime.enroll(taskId, toolMeta);
250
+ }
207
251
  if (name === "advance_assurance") return runtime.advance(taskId, toolMeta.signal, toolMeta);
208
252
  if (name === "submit_review") {
209
253
  // ADR 0017: an absent verdict is the receipt-bound submission.
@@ -445,11 +489,25 @@ export function elicitationParams(input: NativeConfirmationInput) {
445
489
  b?.lane_mode ? `Lane mode: max_parallel=${b.lane_mode.max_parallel}` : null,
446
490
  b?.lane_mode ? `Parallel groups (${b.lane_mode.parallel_groups.length}):\n${b.lane_mode.parallel_groups.map((group) => ` - ${group.join(", ")}`).join("\n")}` : null,
447
491
  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?.lane_mode?.final_verification ? `Final verification: ${b.lane_mode.final_verification.join("; ")}` : null,
493
+ b?.lane_mode?.offer_revision_delegation ? REVISION_DELEGATION_TEXT : null,
448
494
  ].filter(Boolean);
449
495
  return {
450
496
  mode: "form",
451
497
  message: `Authorize this unattended batch run for ${input.initiativeSlug}?\n\n${details.join("\n")}`,
452
- requestedSchema: { type: "object", properties: {} },
498
+ requestedSchema: b?.lane_mode?.offer_revision_delegation
499
+ ? {
500
+ type: "object",
501
+ properties: {
502
+ delegate_in_lane_revisions: {
503
+ type: "boolean",
504
+ title: "Delegate in-envelope Lane Intent revisions to the batch coordinator",
505
+ description: REVISION_DELEGATION_TEXT,
506
+ default: false,
507
+ },
508
+ },
509
+ }
510
+ : { type: "object", properties: {} },
453
511
  };
454
512
  }
455
513
  const details = [
@@ -527,13 +585,19 @@ export async function serveStdio(options: {
527
585
  if (action !== "accept" && action !== "decline" && action !== "cancel") {
528
586
  throw new NativeAuthorityError("correlation_missing", "MCP elicitation returned an invalid action");
529
587
  }
588
+ let delegateRevisions = false;
530
589
  if (action === "accept") {
531
590
  const content = (result as { content?: unknown }).content;
532
- if (typeof content !== "object" || content === null || Array.isArray(content) || Object.keys(content).length !== 0) {
591
+ const offered = input.operation === "start_unattended_batch" && input.batchDetails?.lane_mode?.offer_revision_delegation === true;
592
+ const keys = typeof content === "object" && content !== null && !Array.isArray(content) ? Object.keys(content) : null;
593
+ const valid = keys !== null && (keys.length === 0 ||
594
+ (offered && keys.length === 1 && keys[0] === "delegate_in_lane_revisions" && typeof (content as Record<string, unknown>).delegate_in_lane_revisions === "boolean"));
595
+ if (!valid) {
533
596
  throw new NativeAuthorityError("correlation_missing", "MCP elicitation accept content did not match the requested schema");
534
597
  }
598
+ delegateRevisions = offered && (content as Record<string, unknown>).delegate_in_lane_revisions === true;
535
599
  }
536
- return { decision: action, requestId };
600
+ return { decision: action, requestId, ...(delegateRevisions ? { delegateRevisions: true } : {}) };
537
601
  } finally {
538
602
  pending.delete(requestId);
539
603
  if (abortListener) input.signal?.removeEventListener("abort", abortListener);
@@ -686,7 +750,21 @@ async function runHook(): Promise<void> {
686
750
  const lines: string[] = [];
687
751
  const rl = createInterface({ input: stdin });
688
752
  for await (const line of rl) lines.push(line);
689
- const event = parseHookStdin(lines.join("\n"));
753
+ const raw = lines.join("\n");
754
+ let payload: Record<string, unknown> | null = null;
755
+ try { payload = JSON.parse(raw) as Record<string, unknown>; } catch { payload = null; }
756
+ if (payload?.hook_event_name === "PreToolUse") {
757
+ const decision = laneGuardHookOutput(payload) ?? phaseEntryGateHookOutput(payload);
758
+ if (decision) process.stdout.write(`${decision}\n`);
759
+ return;
760
+ }
761
+ if (payload?.hook_event_name === "UserPromptSubmit") {
762
+ recordPhaseEntryPrompt(payload);
763
+ const notice = await activeTaskHookOutput(payload);
764
+ if (notice) process.stdout.write(`${notice}\n`);
765
+ return;
766
+ }
767
+ const event = parseHookStdin(raw);
690
768
  if (!event) return;
691
769
  new FileHookEventLog().append(event);
692
770
  }
@@ -76,21 +76,21 @@ export class MemoryHookEventLog implements HookEventLog {
76
76
 
77
77
  const CACHE_DIR = "immune-brain-claude";
78
78
 
79
- function sessionHash(sessionId: string): string {
79
+ export function sessionHash(sessionId: string): string {
80
80
  return createHash("sha256").update(sessionId).digest("hex");
81
81
  }
82
82
 
83
- function cacheDir(root: string): string {
83
+ export function cacheDir(root: string): string {
84
84
  return join(root, CACHE_DIR);
85
85
  }
86
86
 
87
- function ownedByUs(stat: { uid: number; isSymbolicLink(): boolean }): boolean {
87
+ export function ownedByUs(stat: { uid: number; isSymbolicLink(): boolean }): boolean {
88
88
  if (stat.isSymbolicLink()) return false;
89
89
  const uid = process.getuid?.();
90
90
  return uid === undefined || stat.uid === uid;
91
91
  }
92
92
 
93
- function ensurePrivateDir(dir: string): boolean {
93
+ export function ensurePrivateDir(dir: string): boolean {
94
94
  try {
95
95
  const existing = lstatSync(dir);
96
96
  return existing.isDirectory() && ownedByUs(existing) && (existing.mode & 0o777) === 0o700;
@@ -106,7 +106,7 @@ function ensurePrivateDir(dir: string): boolean {
106
106
  }
107
107
  }
108
108
 
109
- function appendPrivate(path: string, dir: string, line: string): boolean {
109
+ export function appendPrivate(path: string, dir: string, line: string): boolean {
110
110
  if (!ensurePrivateDir(dir)) return false;
111
111
  const flags = constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | constants.O_NOFOLLOW | constants.O_NONBLOCK;
112
112
  let fd: number;
@@ -122,7 +122,7 @@ function appendPrivate(path: string, dir: string, line: string): boolean {
122
122
  }
123
123
  }
124
124
 
125
- function readPrivate(path: string, maxBytes = Number.POSITIVE_INFINITY): string | undefined {
125
+ export function readPrivate(path: string, maxBytes = Number.POSITIVE_INFINITY): string | undefined {
126
126
  let fd: number;
127
127
  try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); } catch { return; }
128
128
  try {
@@ -8,6 +8,7 @@ import {
8
8
  } from "node:fs";
9
9
  import { execFileSync } from "node:child_process";
10
10
  import { resolve } from "node:path";
11
+ import { projectScopeOverlap } from "../unattended/batch_schedule";
11
12
  import process from "node:process";
12
13
  import { fileURLToPath } from "node:url";
13
14
  import {
@@ -858,6 +859,57 @@ function runIntentAuthor(args: string[], root: string): KernelExecution {
858
859
  * TaskRecord, claim, Ledger, Git index, or session write. Returns a stable
859
860
  * bounded projection.
860
861
  */
862
+ /**
863
+ * Read-only: compare the `scope_hint` of two or more TaskIntents of one
864
+ * Initiative and report every overlapping entry pair plus the start waves the
865
+ * Batch Plan would use. Optional `--blocked-by a=b,c` edges order the waves the
866
+ * way the Initiative's dependencies do. Writes nothing.
867
+ */
868
+ function runIntentOverlap(args: string[], root: string): KernelExecution {
869
+ const usage = "Run imm-kernel intent overlap <path> <path>... [--blocked-by <task-id>=<task-id>,...] --json.";
870
+ const reject = (message: string): KernelExecution => ({
871
+ result: errorResult("invalid_command", message, 2),
872
+ journal: journalFor("intent", null, "rejected", "invalid_command", null, usage),
873
+ });
874
+ const paths: string[] = [];
875
+ const blockedBy = new Map<string, string[]>();
876
+ for (let index = 0; index < args.length; index += 1) {
877
+ const arg = args[index]!;
878
+ if (arg === "--json") continue;
879
+ if (arg === "--blocked-by") {
880
+ const edge = args[++index] ?? "";
881
+ const match = /^([A-Za-z0-9][A-Za-z0-9._-]*)=([A-Za-z0-9._,-]+)$/.exec(edge);
882
+ if (!match) return reject(`invalid --blocked-by edge: ${edge}`);
883
+ blockedBy.set(match[1]!, match[2]!.split(",").filter(Boolean));
884
+ continue;
885
+ }
886
+ if (arg.startsWith("-")) return reject(`unsupported intent overlap option: ${arg}`);
887
+ paths.push(arg);
888
+ }
889
+ if (paths.length < 2) return reject("imm-kernel intent overlap requires at least two paths");
890
+ const children: Array<{ task_id: string; blocked_by: string[]; scope_hint: string[] }> = [];
891
+ for (const path of paths) {
892
+ let intent: { task_id?: unknown; scope_hint?: unknown };
893
+ try {
894
+ intent = JSON.parse(readFileSync(resolve(root, path), "utf8"));
895
+ } catch (error) {
896
+ return { result: errorResult("source_invalid", `cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`, 1), journal: journalFor("intent", null, "rejected", "source_read_failed", null, usage) };
897
+ }
898
+ if (typeof intent.task_id !== "string" || !Array.isArray(intent.scope_hint) || intent.scope_hint.some((entry) => typeof entry !== "string"))
899
+ return { result: errorResult("source_invalid", `${path} has no task_id and string scope_hint`, 1), journal: journalFor("intent", null, "rejected", "source_invalid", null, usage) };
900
+ children.push({ task_id: intent.task_id, blocked_by: [], scope_hint: intent.scope_hint as string[] });
901
+ }
902
+ const known = new Set(children.map((child) => child.task_id));
903
+ for (const child of children) child.blocked_by = (blockedBy.get(child.task_id) ?? []).filter((id) => known.has(id));
904
+ let report: ReturnType<typeof projectScopeOverlap>;
905
+ try {
906
+ report = projectScopeOverlap(children);
907
+ } catch (error) {
908
+ return reject(error instanceof Error ? error.message : String(error));
909
+ }
910
+ return { result: jsonResult(report, 0), journal: journalFor("intent", null, "ok", "command_ok", null, null) };
911
+ }
912
+
861
913
  function runIntentValidate(args: string[], root: string): KernelExecution {
862
914
  const nonFlags = args.filter((arg) => !arg.startsWith("-"));
863
915
  if (nonFlags.length !== 1)
@@ -1173,10 +1225,11 @@ function executeKernelCommand(args: string[], root: string): KernelExecution {
1173
1225
  const sub = args[1] ?? "";
1174
1226
  if (sub === "author") return runIntentAuthor(args.slice(2), root);
1175
1227
  if (sub === "validate") return runIntentValidate(args.slice(2), root);
1228
+ if (sub === "overlap") return runIntentOverlap(args.slice(2), root);
1176
1229
  return {
1177
1230
  result: errorResult(
1178
1231
  "invalid_command",
1179
- "usage: imm-kernel intent author <path> --stdin --json | validate <path> --json",
1232
+ "usage: imm-kernel intent author <path> --stdin --json | validate <path> --json | overlap <path> <path>... [--blocked-by <task-id>=<task-id>,...] --json",
1180
1233
  2,
1181
1234
  ),
1182
1235
  journal: journalFor(