immune-brain 4.5.0 → 5.0.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 (79) hide show
  1. package/README.md +157 -39
  2. package/README.zh-CN.md +160 -40
  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 +42 -45
  6. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +189 -476
  7. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +77 -102
  8. package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +7 -2
  9. package/plugins/immune-brain/.pi-extension/tsconfig.json +2 -2
  10. package/plugins/immune-brain/agents/immune-brain-advisory-reviewer.md +23 -0
  11. package/plugins/immune-brain/agents/immune-brain-qa.md +42 -0
  12. package/plugins/immune-brain/agents/immune-brain-reviewer.md +90 -1
  13. package/plugins/immune-brain/agents/immune-brain-ui-review.md +21 -0
  14. package/plugins/immune-brain/dist/BASELINE.md +4 -4
  15. package/plugins/immune-brain/dist/claude/mcp-server.mjs +9924 -9654
  16. package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +3 -3
  17. package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +30 -1
  18. package/plugins/immune-brain/dist/imm-brainstorm.md +1 -1
  19. package/plugins/immune-brain/dist/imm-doc-prune.md +2 -2
  20. package/plugins/immune-brain/dist/{imm-agent-doc-maintain.md → imm-doc-slim.md} +9 -9
  21. package/plugins/immune-brain/dist/imm-planner.md +60 -20
  22. package/plugins/immune-brain/dist/imm-pr-fix.md +1 -1
  23. package/plugins/immune-brain/dist/{imm-review-retro.md → imm-retro.md} +26 -13
  24. package/plugins/immune-brain/dist/{imm-loop.md → imm-run.md} +44 -11
  25. package/plugins/immune-brain/dist/registry.yaml +9 -9
  26. package/plugins/immune-brain/dist/role-prompts/code-review.md +7 -0
  27. package/plugins/immune-brain/dist/role-prompts/executor.md +35 -2
  28. package/plugins/immune-brain/runtime/assurance/coordinator.ts +138 -69
  29. package/plugins/immune-brain/runtime/assurance/host_port.ts +20 -0
  30. package/plugins/immune-brain/runtime/assurance/qa.ts +2 -2
  31. package/plugins/immune-brain/runtime/assurance/review_evidence.ts +4 -0
  32. package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +347 -0
  33. package/plugins/immune-brain/runtime/claude/interaction.ts +1 -2
  34. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +135 -396
  35. package/plugins/immune-brain/runtime/claude/mcp_server.ts +5 -4
  36. package/plugins/immune-brain/runtime/claude/review_host.ts +63 -16
  37. package/plugins/immune-brain/runtime/claude/role_agents.ts +83 -0
  38. package/plugins/immune-brain/runtime/commands/kernel.ts +3 -6
  39. package/plugins/immune-brain/runtime/github_issue_tracker.ts +738 -677
  40. package/plugins/immune-brain/runtime/kernel/application.ts +7 -7
  41. package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +15 -8
  42. package/plugins/immune-brain/runtime/kernel/authority_port.ts +5 -14
  43. package/plugins/immune-brain/runtime/kernel/batch_authority.ts +30 -49
  44. package/plugins/immune-brain/runtime/kernel/canary_application.ts +6 -5
  45. package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +0 -1
  46. package/plugins/immune-brain/runtime/kernel/capability_registry.ts +7 -9
  47. package/plugins/immune-brain/runtime/kernel/enrollment.ts +170 -17
  48. package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +5 -11
  49. package/plugins/immune-brain/runtime/kernel/index.ts +3 -1
  50. package/plugins/immune-brain/runtime/kernel/storage.ts +170 -11
  51. package/plugins/immune-brain/runtime/kernel/storage_paths.ts +13 -0
  52. package/plugins/immune-brain/runtime/kernel/types.ts +2 -1
  53. package/plugins/immune-brain/runtime/kernel/validation.ts +3 -1
  54. package/plugins/immune-brain/runtime/loop_contract.ts +22 -22
  55. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  56. package/plugins/immune-brain/runtime/prompts/code-review.md +7 -0
  57. package/plugins/immune-brain/runtime/prompts/executor.md +35 -2
  58. package/plugins/immune-brain/runtime/role_prompt_bridge.ts +27 -0
  59. package/plugins/immune-brain/runtime/unattended/batch_git.ts +66 -28
  60. package/plugins/immune-brain/runtime/unattended/batch_kernel_port.ts +106 -0
  61. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +3 -9
  62. package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +132 -45
  63. package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +0 -6
  64. package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +40 -36
  65. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +98 -209
  66. package/plugins/immune-brain/runtime/unattended/batch_state.ts +36 -14
  67. package/plugins/immune-brain/runtime/unattended/types.ts +0 -2
  68. package/plugins/immune-brain/runtime/workspace_scope.ts +78 -8
  69. package/plugins/immune-brain/skills/BASELINE.md +4 -4
  70. package/plugins/immune-brain/skills/imm-doc-prune/SKILL.md +1 -1
  71. package/plugins/immune-brain/skills/{imm-agent-doc-maintain → imm-doc-slim}/SKILL.md +9 -9
  72. package/plugins/immune-brain/skills/imm-planner/SKILL.md +4 -2
  73. package/plugins/immune-brain/skills/imm-pr-fix/SKILL.md +1 -1
  74. package/plugins/immune-brain/skills/{imm-review-retro → imm-retro}/SKILL.md +7 -7
  75. package/plugins/immune-brain/skills/{imm-review-retro → imm-retro}/scripts/review_retro.ts +46 -21
  76. package/plugins/immune-brain/skills/{imm-loop → imm-run}/SKILL.md +7 -7
  77. package/plugins/immune-brain/skills/registry.yaml +9 -9
  78. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +0 -584
  79. package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +0 -33
@@ -74,6 +74,7 @@ export interface McpRuntimeOptions {
74
74
  cwd?: string;
75
75
  env?: Record<string, string | undefined>;
76
76
  ports?: AssuranceCoordinatorPorts;
77
+ authorityOverrides?: Partial<import("../assurance/verdict_authority").VerdictAuthority>;
77
78
  host?: ClaudeReviewHost;
78
79
  interactive?: boolean;
79
80
  requestConfirmation?: NativeConfirmationPort;
@@ -89,6 +90,7 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
89
90
  env: options.env ?? process.env,
90
91
  host,
91
92
  ports: options.ports,
93
+ authorityOverrides: options.authorityOverrides,
92
94
  interactive: options.interactive,
93
95
  requestConfirmation: options.requestConfirmation,
94
96
  batchKernel: options.batchKernel,
@@ -160,10 +162,10 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
160
162
  signal: meta.signal,
161
163
  };
162
164
  if (name === "enroll") return runtime.enroll(taskId, toolMeta);
163
- if (name === "advance_assurance") return runtime.advance(taskId, toolMeta.signal);
165
+ if (name === "advance_assurance") return runtime.advance(taskId, toolMeta.signal, toolMeta);
164
166
  if (name === "submit_review") {
165
167
  if (!Object.hasOwn(args, "verdict")) throw new Error("verdict is required");
166
- return runtime.submitReview(taskId, args.verdict);
168
+ return runtime.submitReview(taskId, args.verdict, toolMeta);
167
169
  }
168
170
  if (name === "revise_intent") {
169
171
  if (!Object.hasOwn(args, "next_intent")) throw new Error("next_intent is required");
@@ -369,8 +371,7 @@ export function elicitationParams(input: NativeConfirmationInput) {
369
371
  input.planDigest ? `Plan digest: ${input.planDigest}` : null,
370
372
  b?.children ? `Ordered children (${b.children.length}):\n${b.children.map((c) => ` - ${c.task_id} (${c.slice_id}) [risk: ${c.risk ?? "unknown"}]`).join("\n")}` : null,
371
373
  b?.excluded && b.excluded.length > 0 ? `Excluded children (${b.excluded.length}):\n${b.excluded.map((e) => ` - ${e.task_id} (${e.slice_id}): ${e.reason}`).join("\n")}` : null,
372
- b?.budget ? `Budget: max_children=${b.budget.max_children}, deadline_at=${b.budget.deadline_at}, qa_failure_limit=${b.budget.qa_failure_limit}` : null,
373
- b?.expires_at ? `Expires at: ${b.expires_at}` : null,
374
+ b?.budget ? `Budget: max_children=${b.budget.max_children}, qa_failure_limit=${b.budget.qa_failure_limit}` : null,
374
375
  ].filter(Boolean);
375
376
  return {
376
377
  mode: "form",
@@ -149,9 +149,9 @@ export type AsyncAgentLaunch = { agentId: string; outputFile: string };
149
149
  /**
150
150
  * Recognise the launch receipt this Claude Code build returns for `Agent`.
151
151
  *
152
- * Every `Agent` call here runs asynchronously — `run_in_background: false` in
153
- * the dispatch envelope is not honoured, and there is no synchronous mode. The
154
- * tool result is therefore
152
+ * Every `Agent` call here runs asynchronously: this Host has no synchronous
153
+ * mode and no background switch, so the dispatch envelope carries nothing that
154
+ * would ask for one. The tool result is therefore
155
155
  * `{"isAsync":true,"status":"async_launched","agentId":…,"outputFile":…}`:
156
156
  * proof that a subagent started, never its answer. Treating it as the verdict
157
157
  * would settle Review on a receipt for starting the reviewer, so the launch
@@ -169,12 +169,21 @@ export function parseAsyncAgentLaunch(result: string): AsyncAgentLaunch | null {
169
169
  return { agentId, outputFile };
170
170
  }
171
171
 
172
+ const HANDBACK_TOOL = "SubagentHandback";
173
+
172
174
  /**
173
175
  * Extract the reviewer's terminal message from its own transcript.
174
176
  *
175
177
  * Each record carries the `agentId` that wrote it, so the caller's independently
176
178
  * observed id is matched per record rather than trusted for the file as a whole;
177
179
  * a transcript that interleaves another agent cannot contribute its text.
180
+ *
181
+ * A subagent ends its run either with an assistant text message or, on Hosts
182
+ * that hand the report back through a tool, with a `SubagentHandback` call whose
183
+ * `input.message` is that same report and which leaves no text block behind.
184
+ * Both are the reviewer's own record in its own transcript, so whichever the
185
+ * observed agent wrote last is its result — across records and across the
186
+ * ordered content blocks of a single record.
178
187
  */
179
188
  export function readAgentTranscriptResult(transcript: string, agentId: string): string | null {
180
189
  let last: string | null = null;
@@ -192,6 +201,12 @@ export function readAgentTranscriptResult(transcript: string, agentId: string):
192
201
  if (!block || typeof block !== "object") continue;
193
202
  const part = block as Record<string, unknown>;
194
203
  if (part.type === "text" && typeof part.text === "string") text += part.text;
204
+ if (part.type === "tool_use" && part.name === HANDBACK_TOOL && part.input && typeof part.input === "object") {
205
+ const report = (part.input as Record<string, unknown>).message;
206
+ // Blocks are ordered, so text before the report is a preamble and
207
+ // only text written after it can supersede it.
208
+ if (typeof report === "string" && report.trim()) { last = report; text = ""; }
209
+ }
195
210
  }
196
211
  if (text.trim()) last = text;
197
212
  }
@@ -262,6 +277,12 @@ export class FileHookEventLog implements HookEventLog {
262
277
 
263
278
  interface PendingReview {
264
279
  request: ReviewRequest;
280
+ /**
281
+ * The prompt the Host asked the Parent to dispatch, marker line included.
282
+ * Hook evidence carries Parent-authored text, so this is the only string a
283
+ * dispatched prompt is ever compared against.
284
+ */
285
+ dispatchPrompt: string;
265
286
  initialCursors: Map<string, number>;
266
287
  sessionCursors: Map<string, number>;
267
288
  startEvent?: Extract<ClaudeHookEvent, { type: "SubagentStart" }>;
@@ -271,18 +292,39 @@ interface PendingReview {
271
292
  error?: string;
272
293
  }
273
294
 
295
+ /**
296
+ * The reserved dispatch prompt: the reservation marker line followed by the
297
+ * per-dispatch snapshot facts, byte for byte.
298
+ *
299
+ * One place builds it and one place compares against it, so the marker format
300
+ * cannot drift from what the binding rule accepts. The static instructions are
301
+ * deliberately absent: the dispatched reviewer definition carries them, and they
302
+ * are generated from one source by the plugin build.
303
+ */
304
+ function reservedDispatchPrompt(
305
+ request: Pick<ReviewRequest, "operationId" | "taskId" | "snapshotPrompt">,
306
+ ): string {
307
+ return `<!-- immune-brain:operation_id=${request.operationId} task_id=${request.taskId} -->\n${request.snapshotPrompt}`;
308
+ }
309
+
310
+ /**
311
+ * Whether hook evidence carrying a prompt belongs to this reservation.
312
+ *
313
+ * The prompt is Parent-authored text, so the marker is not an acceptance path:
314
+ * a dispatch that keeps the matching `operation_id` and `task_id` marker but
315
+ * appends, truncates or rewrites the body does not bind and cannot settle. Only
316
+ * the exact reserved dispatch prompt does. Evidence with no prompt at all is
317
+ * judged by the identifiers the hook itself supplies.
318
+ */
319
+ function bindsPrompt(prompt: string | undefined, pending: PendingReview): boolean {
320
+ return prompt === undefined || prompt === pending.dispatchPrompt;
321
+ }
322
+
274
323
  function bindsStart(event: Extract<ClaudeHookEvent, { type: "SubagentStart" }>, pending: PendingReview): boolean {
275
324
  if (event.taskId && event.taskId !== pending.request.taskId) return false;
276
- if (event.prompt !== undefined) {
277
- const match = /<!-- immune-brain:operation_id=([^\s]+)\s+task_id=([^\s]+)\s+-->/.exec(event.prompt);
278
- if (match) {
279
- if (match[1] !== pending.request.operationId || match[2] !== pending.request.taskId) return false;
280
- } else if (event.prompt !== pending.request.prompt) {
281
- return false;
282
- }
283
- }
325
+ if (!bindsPrompt(event.prompt, pending)) return false;
284
326
  if (event.operationId) return event.operationId === pending.request.operationId;
285
- return event.prompt !== undefined && event.prompt === pending.request.prompt;
327
+ return event.prompt !== undefined;
286
328
  }
287
329
 
288
330
  export class ClaudeReviewHost implements AssuranceHostPort {
@@ -306,17 +348,19 @@ export class ClaudeReviewHost implements AssuranceHostPort {
306
348
  }
307
349
  this.pending.set(request.operationId, {
308
350
  request,
351
+ dispatchPrompt: reservedDispatchPrompt(request),
309
352
  initialCursors,
310
353
  sessionCursors,
311
354
  consumed: false,
312
355
  });
313
356
  return {
314
357
  id: request.operationId,
358
+ // Only parameters the Claude `Agent` tool accepts. The Host has no turn
359
+ // cap and no background switch, so `request.maxTurns` (which the Pi port
360
+ // forwards natively) has no receiver here and is not projected.
315
361
  dispatch: {
316
362
  name: CLAUDE_REVIEWER_AGENT,
317
- prompt: `<!-- immune-brain:operation_id=${request.operationId} task_id=${request.taskId} -->\n${request.prompt}`,
318
- max_turns: request.maxTurns,
319
- run_in_background: false,
363
+ prompt: reservedDispatchPrompt(request),
320
364
  },
321
365
  };
322
366
  }
@@ -419,6 +463,9 @@ export class ClaudeReviewHost implements AssuranceHostPort {
419
463
  if (event.toolName !== AGENT_TOOL) return;
420
464
  if (!event.agentId || !event.operationId || event.operationId !== state.request.operationId) return;
421
465
  if ("taskId" in event && event.taskId && event.taskId !== state.request.taskId) return;
466
+ // The `Agent` tool input is where this Host actually reports the
467
+ // dispatched prompt, so it is bound by the same exactness rule.
468
+ if (!bindsPrompt(event.prompt, state)) return;
422
469
  if (state.startEvent && event.sessionId !== state.startEvent.sessionId) return;
423
470
  if (state.startEvent && event.agentId !== state.startEvent.agentId) return;
424
471
  if (state.stopEvent && event.sessionId !== state.stopEvent.sessionId) return;
@@ -443,7 +490,7 @@ export class ClaudeReviewHost implements AssuranceHostPort {
443
490
  }
444
491
  if (state.stopEvent) {
445
492
  if (state.stopEvent === event) return;
446
- state.error = "duplicate SubagentStop observed for review reservation";
493
+ state.error = "duplicate SubagentStop observed for review reservation: a reviewer was continued after it finished; a reviewer cannot be continued and a fresh reviewer must be dispatched";
447
494
  return;
448
495
  }
449
496
  state.stopEvent = event;
@@ -0,0 +1,83 @@
1
+ import type { InternalRole } from "../role_prompt_bridge";
2
+
3
+ /**
4
+ * Claude plugin agent definitions for the read-only internal roles.
5
+ *
6
+ * A native definition is the strictest boundary the Host can express for these
7
+ * roles: `Read, Grep, Glob` grants no write, shell or dispatch tool, while the
8
+ * Pi mapping leaves the same boundary as prompt text. `INTERNAL_ROLE_PROMPTS`
9
+ * declares `no tools` or `read-only tools` for every role here; a native
10
+ * definition cannot express an empty allowlist without inheriting every tool,
11
+ * so read-only-without-shell is what ships.
12
+ *
13
+ * The bodies are generated from the matching `runtime/prompts/` source by
14
+ * `scripts/build-claude-plugin.ts`, never maintained by hand.
15
+ */
16
+ export interface ClaudeReadonlyRoleAgent {
17
+ /** The plugin-qualified agent type the Claude `Agent` tool accepts. */
18
+ readonly agent: string;
19
+ /** Repository-relative definition the plugin build generates. */
20
+ readonly definition: string;
21
+ /** The role prompt source the definition body is generated from. */
22
+ readonly prompt: string;
23
+ /** Frontmatter `description`: when the Host may select this agent. */
24
+ readonly description: string;
25
+ }
26
+
27
+ export const CLAUDE_READONLY_ROLE_AGENTS = {
28
+ qa: {
29
+ agent: "immune-brain:immune-brain-qa",
30
+ definition: "plugins/immune-brain/agents/immune-brain-qa.md",
31
+ prompt: "plugins/immune-brain/runtime/prompts/qa.md",
32
+ description:
33
+ "Immune-Brain QA authority. Judges recorded execution evidence against one active target and returns one decision.",
34
+ },
35
+ "ui-review": {
36
+ agent: "immune-brain:immune-brain-ui-review",
37
+ definition: "plugins/immune-brain/agents/immune-brain-ui-review.md",
38
+ prompt: "plugins/immune-brain/runtime/prompts/ui-review.md",
39
+ description:
40
+ "Immune-Brain UI Review authority. Read-only review of a bounded UI change surface and its evidence.",
41
+ },
42
+ "advisory-reviewer": {
43
+ agent: "immune-brain:immune-brain-advisory-reviewer",
44
+ definition: "plugins/immune-brain/agents/immune-brain-advisory-reviewer.md",
45
+ prompt: "plugins/immune-brain/runtime/prompts/advisory-reviewer.md",
46
+ description:
47
+ "Immune-Brain advisory reviewer for one explicit lens. Read-only advice; synthesis stays with the coordinating Parent.",
48
+ },
49
+ } as const satisfies Record<string, ClaudeReadonlyRoleAgent>;
50
+
51
+ /** The read-only roles that ship a generated native definition. */
52
+ export type ClaudeReadonlyRole = keyof typeof CLAUDE_READONLY_ROLE_AGENTS;
53
+
54
+ export const CLAUDE_READONLY_ROLES = Object.keys(
55
+ CLAUDE_READONLY_ROLE_AGENTS,
56
+ ) as ClaudeReadonlyRole[];
57
+
58
+ /** The tool allowlist every generated read-only definition declares. */
59
+ export const CLAUDE_READONLY_TOOLS = ["Read", "Grep", "Glob"] as const;
60
+
61
+ /**
62
+ * The read-only internal roles that ship no definition because the Host already
63
+ * provides the agent. `arch-explorer` is architecture discovery: Claude's own
64
+ * `Explore` agent is the read-only research agent, and shipping a second
65
+ * definition would fork a boundary the Host owns.
66
+ */
67
+ export const CLAUDE_HOST_PROVIDED_ROLE_AGENTS = {
68
+ "arch-explorer": { agent: "Explore" },
69
+ } as const satisfies Record<string, { agent: string }>;
70
+
71
+ /**
72
+ * Roles that are not dispatched as native Claude agents.
73
+ *
74
+ * `code-review` ships its own definition with a wider allowlist (it must run
75
+ * read-only Git commands), so it is not part of this table. The remaining roles
76
+ * write: `executor`, `test-fixer` and `pr-fix` mutate the workspace, and
77
+ * `compounder` writes learning storage. A write-capable role stays bounded by
78
+ * its dispatch envelope and the Parent, not by an agent definition.
79
+ */
80
+ export const CLAUDE_NATIVE_AGENT_ROLES = new Set<InternalRole>([
81
+ ...CLAUDE_READONLY_ROLES,
82
+ ...(Object.keys(CLAUDE_HOST_PROVIDED_ROLE_AGENTS) as InternalRole[]),
83
+ ]);
@@ -29,7 +29,7 @@ import {
29
29
  import { inspectSpecBinding } from "../kernel/spec_binding";
30
30
  import { projectTask, resolveProjectedRisk } from "../kernel/completion";
31
31
  import { deriveAssuranceAuthorization } from "../kernel/assurance_projection";
32
- import { taskDiffIdentity, taskRevisionIdentity } from "../workspace_scope";
32
+ import { taskDeliveryIdentity } from "../workspace_scope";
33
33
  import type { TaskIntentV1, TaskRisk } from "../kernel/types";
34
34
  import {
35
35
  canonicalDescriptorBytes,
@@ -273,10 +273,7 @@ function runInspect(root: string): KernelExecution {
273
273
  const workspaceState = readWorkspaceStateRaw(root);
274
274
  let identity;
275
275
  try {
276
- identity =
277
- record.contract === "assurance_kernel/task_record/v4"
278
- ? taskRevisionIdentity(root, intent.scope_hint, record.git_base_head, intent.task_id)
279
- : taskDiffIdentity(root, intent.scope_hint, intent.task_id);
276
+ identity = taskDeliveryIdentity(root, record);
280
277
  } catch (error) {
281
278
  return sourceFailure("inspect", error);
282
279
  }
@@ -571,7 +568,7 @@ function runIntentAuthor(args: string[], root: string): KernelExecution {
571
568
  "rejected",
572
569
  "kernel_owner_active",
573
570
  null,
574
- "Route to imm-loop instead of authoring a new draft.",
571
+ "Route to imm-run instead of authoring a new draft.",
575
572
  ),
576
573
  };
577
574
  if (owner.v3)