immune-brain 3.3.0 → 3.5.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.
@@ -45,23 +45,17 @@ export type ClaudeHookEvent =
45
45
  taskId?: string;
46
46
  operationId?: string;
47
47
  }
48
- | { type: "SessionEnd"; sessionId: string }
49
- | { type: "ElicitationResult"; sessionId: string; toolCallId: string; decision: "accept" | "deny" | "cancel" }
50
- | { type: "ElicitationConsumed"; sessionId: string; toolCallId: string };
48
+ | { type: "SessionEnd"; sessionId: string };
51
49
 
52
50
  export interface HookEventLog {
53
51
  append(event: ClaudeHookEvent): boolean;
54
52
  list(sessionId?: string): ClaudeHookEvent[];
55
53
  sessions(): string[];
56
54
  clear(sessionId?: string): void;
57
- /** Persists consumed identity durably; true only after the record is verified. */
58
- consumeElicitation(sessionId: string, toolCallId: string): boolean;
59
- consumedKeys(): string[];
60
55
  }
61
56
 
62
57
  export class MemoryHookEventLog implements HookEventLog {
63
58
  private readonly events: ClaudeHookEvent[] = [];
64
- private readonly consumed: string[] = [];
65
59
  append(event: ClaudeHookEvent): boolean { this.events.push(event); return true; }
66
60
  list(sessionId?: string): ClaudeHookEvent[] {
67
61
  return sessionId ? this.events.filter((event) => event.sessionId === sessionId) : [...this.events];
@@ -78,17 +72,9 @@ export class MemoryHookEventLog implements HookEventLog {
78
72
  if (this.events[i].sessionId === sessionId) this.events.splice(i, 1);
79
73
  }
80
74
  }
81
- consumeElicitation(sessionId: string, toolCallId: string): boolean {
82
- this.consumed.push(`${sessionId}\0${toolCallId}`);
83
- this.events.push({ type: "ElicitationConsumed", sessionId, toolCallId });
84
- return true;
85
- }
86
- consumedKeys(): string[] { return [...this.consumed]; }
87
75
  }
88
76
 
89
77
  const CACHE_DIR = "immune-brain-claude";
90
- const CONSUMED_FILE = "consumed.jsonl";
91
- const CONSUMED_DIR = "consumed";
92
78
 
93
79
  function sessionHash(sessionId: string): string {
94
80
  return createHash("sha256").update(sessionId).digest("hex");
@@ -120,25 +106,6 @@ function ensurePrivateDir(dir: string): boolean {
120
106
  }
121
107
  }
122
108
 
123
- function claimAtomicKey(dir: string, keyName: string): boolean {
124
- const claimsDir = join(dir, CONSUMED_DIR);
125
- if (!ensurePrivateDir(claimsDir)) return false;
126
- const claimPath = join(claimsDir, `${keyName}.claim`);
127
- const flags = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW | constants.O_NONBLOCK;
128
- let fd: number;
129
- try {
130
- fd = openSync(claimPath, flags, 0o600);
131
- } catch (error) {
132
- return false;
133
- }
134
- try {
135
- const stat = fstatSync(fd);
136
- if (!stat.isFile() || !ownedByUs(stat) || (stat.mode & 0o777) !== 0o600) return false;
137
- return true;
138
- } finally {
139
- closeSync(fd);
140
- }
141
- }
142
109
  function appendPrivate(path: string, dir: string, line: string): boolean {
143
110
  if (!ensurePrivateDir(dir)) return false;
144
111
  const flags = constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | constants.O_NOFOLLOW | constants.O_NONBLOCK;
@@ -185,7 +152,7 @@ export class FileHookEventLog implements HookEventLog {
185
152
  if (!ensurePrivateDir(dir)) return [];
186
153
  try {
187
154
  return readdirSync(dir)
188
- .filter((name) => name.endsWith(".jsonl") && name !== CONSUMED_FILE)
155
+ .filter((name) => name.endsWith(".jsonl"))
189
156
  .flatMap((name) => this.readFile(join(dir, name)));
190
157
  } catch {
191
158
  return [];
@@ -218,34 +185,6 @@ export class FileHookEventLog implements HookEventLog {
218
185
  rmSync(path, { force: true });
219
186
  } catch { /* cleanup cannot create authority */ }
220
187
  }
221
- consumeElicitation(sessionId: string, toolCallId: string): boolean {
222
- // The durable consumed identity is claimed atomically (O_EXCL per-key)
223
- // and committed to consumed.jsonl BEFORE any session evidence is touched:
224
- // concurrent consumers cannot double-claim the same identity.
225
- const dir = cacheDir(this.root);
226
- const claimKey = createHash("sha256").update(`${sessionId}\0${toolCallId}`).digest("hex");
227
- const keyClaimed = claimAtomicKey(dir, claimKey);
228
- if (!keyClaimed) return false;
229
- if (!appendPrivate(join(dir, CONSUMED_FILE), dir, `${JSON.stringify({ sessionId, toolCallId })}\n`)) {
230
- // Roll back the claim file if the durable record append failed
231
- try { rmSync(join(dir, CONSUMED_DIR, `${claimKey}.claim`), { force: true }); } catch { /* ignore */ }
232
- return false;
233
- }
234
- if (!this.consumedKeys().includes(`${sessionId}\0${toolCallId}`)) return false;
235
- return this.append({ type: "ElicitationConsumed", sessionId, toolCallId });
236
- }
237
- consumedKeys(): string[] {
238
- if (!ensurePrivateDir(cacheDir(this.root))) return [];
239
- const text = readPrivate(join(cacheDir(this.root), CONSUMED_FILE));
240
- if (!text) return [];
241
- try {
242
- return text.split("\n").filter(Boolean)
243
- .map((line) => JSON.parse(line) as { sessionId: string; toolCallId: string })
244
- .map((item) => `${item.sessionId}\0${item.toolCallId}`);
245
- } catch {
246
- return [];
247
- }
248
- }
249
188
  }
250
189
 
251
190
  interface PendingReview {
@@ -287,11 +226,8 @@ export class ClaudeReviewHost implements AssuranceHostPort {
287
226
  readonly host = "claude-code" as const;
288
227
  private readonly pending = new Map<string, PendingReview>();
289
228
  private readonly appliedBySession = new Map<string, number>();
290
- private readonly consumedElicitations = new Set<string>();
291
229
  constructor(private readonly log: HookEventLog = new MemoryHookEventLog()) {}
292
230
 
293
- private readonly confirmations = new Map<string, "accept" | "deny" | "cancel">();
294
-
295
231
  prepareReview(request: ReviewRequest): HostReviewReservation {
296
232
  const initialCursors = new Map<string, number>();
297
233
  const sessionCursors = new Map<string, number>();
@@ -322,7 +258,6 @@ export class ClaudeReviewHost implements AssuranceHostPort {
322
258
  }
323
259
 
324
260
  private drain(): void {
325
- for (const key of this.log.consumedKeys()) this.consumedElicitations.add(key);
326
261
  for (const sessionId of this.log.sessions()) {
327
262
  const events = this.log.list(sessionId);
328
263
  let start = this.appliedBySession.get(sessionId) ?? 0;
@@ -330,34 +265,10 @@ export class ClaudeReviewHost implements AssuranceHostPort {
330
265
  let ended = false;
331
266
  for (let i = start; i < events.length; i++) {
332
267
  const event = events[i];
333
- if (event.type === "ElicitationResult") {
334
- const key = `${event.sessionId}\0${event.toolCallId}`;
335
- if (this.consumedElicitations.has(key)) continue;
336
- if (this.confirmations.has(key)) {
337
- if (this.log.consumeElicitation(event.sessionId, event.toolCallId)) {
338
- this.confirmations.delete(key);
339
- this.consumedElicitations.add(key);
340
- }
341
- continue;
342
- }
343
- this.confirmations.set(key, event.decision);
344
- continue;
345
- }
346
- if (event.type === "ElicitationConsumed") {
347
- const key = `${event.sessionId}\0${event.toolCallId}`;
348
- this.consumedElicitations.add(key);
349
- this.confirmations.delete(key);
350
- continue;
351
- }
352
268
  if (event.type === "SessionEnd") {
353
269
  this.log.clear(event.sessionId);
354
270
  ended = true;
355
271
  this.appliedBySession.delete(event.sessionId);
356
- // A confirmation cached before the session ended must never
357
- // authorize a later privileged call.
358
- for (const key of this.confirmations.keys()) {
359
- if (key.startsWith(`${event.sessionId}\0`)) this.confirmations.delete(key);
360
- }
361
272
  for (const [id, state] of this.pending) {
362
273
  if (state.startEvent?.sessionId === event.sessionId || state.postEvent?.sessionId === event.sessionId || state.stopEvent?.sessionId === event.sessionId) {
363
274
  this.pending.delete(id);
@@ -394,38 +305,6 @@ export class ClaudeReviewHost implements AssuranceHostPort {
394
305
  }
395
306
  }
396
307
 
397
- peekConfirmation(sessionId: string, toolCallId: string): boolean {
398
- this.drain();
399
- return this.confirmations.has(`${sessionId}\0${toolCallId}`);
400
- }
401
-
402
- takeConfirmation(sessionId: string, toolCallId: string): "accept" | "deny" | "cancel" | undefined {
403
- this.drain();
404
- const key = `${sessionId}\0${toolCallId}`;
405
- const decision = this.confirmations.get(key);
406
- if (!decision) return undefined;
407
- if (!this.log.consumeElicitation(sessionId, toolCallId)) {
408
- this.confirmations.delete(key);
409
- return undefined;
410
- }
411
- this.confirmations.delete(key);
412
- this.consumedElicitations.add(key);
413
- this.drain();
414
- return decision;
415
- }
416
-
417
- sessionOfElicitation(toolCallId: string): string | undefined {
418
- this.drain();
419
- const sessions: string[] = [];
420
- for (const key of this.confirmations.keys()) {
421
- const sep = key.lastIndexOf("\0");
422
- if (sep >= 0 && key.slice(sep + 1) === toolCallId) sessions.push(key.slice(0, sep));
423
- }
424
- // Ambiguous correlation across sessions fails closed.
425
- if (sessions.length !== 1) return undefined;
426
- return sessions[0];
427
- }
428
-
429
308
  private applyReviewEvent(event: ClaudeHookEvent, state: PendingReview): void {
430
309
  if (state.error) return;
431
310
  if (event.type === "SubagentStart") {
@@ -543,10 +422,6 @@ export function parseHookStdin(raw: string): ClaudeHookEvent | null {
543
422
  try { payload = JSON.parse(raw) as Record<string, unknown>; }
544
423
  catch { return null; }
545
424
  const hookType = String(payload.hook_event_name ?? payload.type ?? "");
546
- // ElicitationResult is native authority evidence: it must carry the Host's
547
- // own session_id verbatim. Alias or environment fallbacks are never
548
- // accepted for this event; anything else is malformed and fails closed.
549
- if (hookType === "ElicitationResult" && (typeof payload.session_id !== "string" || !payload.session_id)) return null;
550
425
  const sessionId = String(payload.session_id ?? payload.sessionId ?? process.env.CLAUDE_SESSION_ID ?? "");
551
426
  if (!sessionId) return null;
552
427
  const agent = String(payload.agent_type ?? payload.agent ?? payload.subagent_type ?? "");
@@ -630,16 +505,5 @@ export function parseHookStdin(raw: string): ClaudeHookEvent | null {
630
505
  };
631
506
  }
632
507
  if (hookType === "SessionEnd") return { type: "SessionEnd", sessionId };
633
- if (hookType === "ElicitationResult") {
634
- const rawToolCallId = payload.tool_use_id ?? payload.toolCallId;
635
- if (typeof rawToolCallId !== "string" || !rawToolCallId) return null;
636
- const toolCallId = rawToolCallId;
637
- const raw = payload.decision ?? payload.result ?? payload.action;
638
- // Only the exact MCP elicitation decision strings are native evidence;
639
- // booleans and aliases like "yes" are malformed and fail closed.
640
- const decision = raw === "accept" ? "accept" : raw === "deny" ? "deny" : raw === "cancel" ? "cancel" : null;
641
- if (!toolCallId || !decision) return null;
642
- return { type: "ElicitationResult", sessionId, toolCallId, decision };
643
- }
644
508
  return null;
645
509
  }
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/plugin_versioning.ts from the root package.json.
2
- export const PLUGIN_VERSION = "3.3.0" as const;
2
+ export const PLUGIN_VERSION = "3.5.0" as const;
@@ -59,7 +59,7 @@ Require exact host confirmation only for privileged effects:
59
59
  override; and
60
60
  - external writes whose target or impact cannot be safely reversed locally.
61
61
 
62
- Routine Managed enrollment uses one host confirmation bound to the TaskIntent content hash at the Planner's final `ctx.ui.custom` gate. Enrollment validates intent, Git ownership, scope, workspace claim, and final authority preconditions without executing acceptance descriptors; deterministic QA executes them after implementation. The routine task proceeds from that single confirmation through enrollment, execution, and QA without a second human stop. Do not request confirmation for local in-scope edits, local verification, ordinary Direct rework, scoped diff review, or completion reporting. Managed evidence, QA, Review, and completion authority remain governed by their Managed contracts; R2 does not weaken them.
62
+ Routine Managed enrollment uses one current-Host native confirmation bound to the TaskIntent content hash after Planner validation. Explicit Plan-only requests stop with candidate artifacts and do not invoke Enrollment; execution-bearing requests open the native gate directly without chat pre-confirmation. Enrollment validates intent, Git ownership, scope, workspace claim, and final authority preconditions without executing acceptance descriptors; deterministic QA executes them after implementation. The routine task proceeds from that single confirmation through enrollment, execution, and QA without a second human stop. Do not request confirmation for local in-scope edits, local verification, ordinary Direct rework, scoped diff review, or completion reporting. Managed evidence, QA, Review, and completion authority remain governed by their Managed contracts; R2 does not weaken them. Managed native-authority failures fail closed with one stable reason and exactly one same-Host recovery action; never offer a Pi, Direct Path, cross-Host/worktree, unmanaged, or automatic-retry fallback.
63
63
 
64
64
  ## Parallel Read-Only Dispatch
65
65
 
@@ -64,3 +64,7 @@ collect completed child outputs, and feed them to
64
64
  background work, mutate state, or own final Spec/Plan authority.
65
65
  Agreement becomes framing evidence, Disagreement becomes decision criteria or
66
66
  `BR-Q-*`, and strong-model blockers become risks or verification requirements.
67
+
68
+ When framing discusses later execution, describe Enrollment only as the current
69
+ Host's native gate. Never recommend another Host, worktree, or unmanaged
70
+ implementation as a fallback for a failed Managed authority interaction.
@@ -7,3 +7,7 @@ description: Use to run an enrolled TaskIntent to completion through Kernel-gove
7
7
 
8
8
  Load [`../../dist/imm-loop.md`](../../dist/imm-loop.md), then follow that
9
9
  canonical contract in the current host conversation.
10
+
11
+ All Managed authority gates use the current Host's native interaction. A failed
12
+ gate stays fail-closed and reports one same-Host recovery action; never suggest
13
+ another Host, worktree, or unmanaged implementation as a fallback.
@@ -7,3 +7,9 @@ description: Use to create or revise a spec and TaskIntent from requirements; ow
7
7
 
8
8
  Load [`../../dist/imm-planner.md`](../../dist/imm-planner.md), then follow that
9
9
  canonical contract. `mode: page_design` selects its page-design branch.
10
+
11
+ Plan-only requests stop after candidate Spec/TaskIntent validation. Requests that
12
+ include execution invoke the current Host's native Enrollment gate directly,
13
+ without chat pre-confirmation. Native-gate failure stays fail-closed in that Host:
14
+ report its reason and one retry action only; never suggest another Host, worktree,
15
+ or unmanaged implementation as a fallback.