immune-brain 3.6.6 → 3.6.7

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 (28) hide show
  1. package/README.md +24 -3
  2. package/README.zh-CN.md +23 -3
  3. package/package.json +2 -1
  4. package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
  5. package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +29 -19
  6. package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +932 -0
  7. package/plugins/immune-brain/.pi-extension/runtime-stub.ts +200 -2
  8. package/plugins/immune-brain/dist/claude/mcp-server.mjs +3404 -227
  9. package/plugins/immune-brain/dist/imm-loop.md +22 -0
  10. package/plugins/immune-brain/dist/role-prompts/code-review.md +9 -1
  11. package/plugins/immune-brain/runtime/assurance/coordinator.ts +101 -3
  12. package/plugins/immune-brain/runtime/claude/interaction.ts +16 -3
  13. package/plugins/immune-brain/runtime/claude/kernel_ports.ts +840 -17
  14. package/plugins/immune-brain/runtime/claude/mcp_server.ts +72 -10
  15. package/plugins/immune-brain/runtime/kernel/canary_application.ts +9 -0
  16. package/plugins/immune-brain/runtime/kernel/completion.ts +19 -1
  17. package/plugins/immune-brain/runtime/kernel/reducer.ts +162 -11
  18. package/plugins/immune-brain/runtime/kernel/refutation.ts +82 -0
  19. package/plugins/immune-brain/runtime/kernel/types.ts +34 -1
  20. package/plugins/immune-brain/runtime/kernel/validation.ts +202 -9
  21. package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
  22. package/plugins/immune-brain/runtime/prompts/code-review.md +9 -1
  23. package/plugins/immune-brain/runtime/unattended/batch_git.ts +775 -0
  24. package/plugins/immune-brain/runtime/unattended/batch_plan.ts +203 -0
  25. package/plugins/immune-brain/runtime/unattended/batch_runner.ts +1224 -0
  26. package/plugins/immune-brain/runtime/unattended/batch_state.ts +360 -0
  27. package/plugins/immune-brain/runtime/unattended/types.ts +60 -0
  28. package/plugins/immune-brain/skills/imm-loop/SKILL.md +1 -0
@@ -83,6 +83,28 @@ suspected external changes. Never repeat a mutation merely to obtain its result.
83
83
  Kernel CAS and freshness checks remain mandatory; reducing Parent reads does
84
84
  not bypass them. Do not poll or create detached jobs.
85
85
 
86
+ ## Unattended Batch Opt-In
87
+
88
+ The only unattended batch entry is the privileged Host tool `start_unattended_batch`
89
+ with its `initiative_slug` parameter. That parameter is the opt-in: absent the call,
90
+ `imm-loop` behavior is byte-identical to per-task Enrollment, and no batch state,
91
+ branch, or Batch Authorization exists. The Standalone Hosts expose the same tool
92
+ name and the same single parameter; it is never a batch of tasks the Host chose.
93
+
94
+ Invoking it authorizes only a user-confirmed batch of already-planned child
95
+ TaskIntents. The Host projects the batch plan from the Initiative's published
96
+ children, excludes every `critical` child, renders the ordered child list, budget,
97
+ and plan digest through its native confirmation, and issues one Kernel Batch
98
+ Authorization for that exact plan. The extension mints no capability and owns no
99
+ batch state transition: the shared `startBatch` driver in
100
+ `runtime/unattended/` owns every batch transition, and each child is still
101
+ enrolled, assured, and settled by the Kernel under an ordinary TaskRecord.
102
+
103
+ Batch execution never pushes a ref, opens or updates a pull request, resolves a
104
+ user decision, or creates, switches, or deletes a Git worktree. Its sole Git
105
+ effect is the batch branch `imm/<initiative-slug>` plus one scope-bounded commit
106
+ per completed child.
107
+
86
108
  ## Decisions and Recovery
87
109
 
88
110
  - Scope expansion returns to Planner's Enrolled Intent Revision route. Planner
@@ -30,6 +30,14 @@ Return exactly one JSON object with the fields required by the Loop review
30
30
  contract: `contract`, `role`, `task_id`, `snapshot_digest`, `decision` (`pass`
31
31
  or `rework`), and for `pass` include `approval` (`kind`, `authority_role`,
32
32
  `summary`), for `rework` include `findings` (`id`, `kind`, `acceptance_id`,
33
- `summary`). Do not invent fields. A passing review has no findings. If the
33
+ `summary`, `evidence`). Every rework finding's `evidence` must carry
34
+ `trigger` (the concrete inputs or state that reach the defect), a non-empty
35
+ `caller_chain` (ordered repository paths or symbols), and `violated`
36
+ (`kind`: `acceptance` or `security_boundary`, `ref`: the acceptance id or
37
+ boundary name). Do not invent fields, and never send an anchor yourself: the
38
+ Kernel derives it as the sha256 of the canonical `{violated.kind,
39
+ violated.ref, caller_chain}`, so an identical claim keeps one stable identity
40
+ across review rounds while a different call chain is a different claim. A
41
+ passing review has no findings. If the
34
42
  checkpoint is `awaiting_user_successor_decision`, stop without dispatch; only
35
43
  a literal user may invoke `--approve-successor`.
@@ -7,6 +7,8 @@
7
7
  // evidence reservation.
8
8
 
9
9
  import { createHash, randomUUID } from "node:crypto";
10
+ import type { FindingEvidence, TaskFinding } from "../kernel/types";
11
+ import { anchorForEvidence } from "../kernel/refutation";
10
12
  import {
11
13
  findingsDigest,
12
14
  type FrozenRunner,
@@ -88,10 +90,40 @@ export interface AssuranceVerdict {
88
90
  kind: "blocking" | "advisory";
89
91
  acceptance_id: string | null;
90
92
  summary: string;
93
+ /**
94
+ * Derived by `parseAssuranceVerdict` for Review-role rework findings. QA
95
+ * findings and pass verdicts never carry it.
96
+ */
97
+ anchor?: string;
98
+ evidence?: FindingEvidence;
99
+ /** Digest this plugin's verdict parser derives for one finding. */
91
100
  findings_digest: string;
92
101
  }>;
93
102
  }
94
103
 
104
+ /**
105
+ * The single Review-verdict → Kernel-finding mapping both Host adapters use.
106
+ * Keeping it here is what makes the two Hosts produce the same
107
+ * `findingsDigestV2` for the same verdict: the reviewer's anchor and evidence
108
+ * are forwarded unchanged into the capability binding and the Kernel action.
109
+ */
110
+ export function reviewReworkFindings(verdict: AssuranceVerdict): TaskFinding[] {
111
+ if (verdict.decision !== "rework" || !verdict.findings?.length)
112
+ throw new Error("review rework findings require a rework verdict");
113
+ return verdict.findings.map((finding) => ({
114
+ id: finding.id,
115
+ kind: finding.kind,
116
+ status: "open" as const,
117
+ acceptance_id: finding.acceptance_id,
118
+ source: "review" as const,
119
+ review_round: null,
120
+ summary: finding.summary,
121
+ anchor: finding.anchor ?? null,
122
+ evidence: finding.evidence ?? null,
123
+ counterevidence: null,
124
+ }));
125
+ }
126
+
95
127
  export interface ReviewRevisionIdentity {
96
128
  contract: "assurance_kernel/review_revision_identity/v1";
97
129
  base_head: string;
@@ -314,12 +346,57 @@ export function buildReviewPrompt(snapshot: SnapshotDescriptor, evidencePath?: s
314
346
  : `Intent revision ${snapshot.intent_revision} (hash ${snapshot.intent_content_hash}), diff ${snapshot.diff_hash}, review bundle ${snapshot.review_bundle_digest}, state ${snapshot.lifecycle}:${snapshot.artifact_state}.`,
315
347
  "Acceptance assertions:", acceptance,
316
348
  "Reserve the final turn for exactly one strict JSON verdict. Reply with ONLY that object, without markdown fences or commentary.",
349
+ `Every rework finding must carry machine-checkable provenance: evidence.trigger (the concrete inputs or state that reach the defect), a non-empty evidence.caller_chain (ordered repository paths or symbols), and evidence.violated {kind: "acceptance"|"security_boundary", ref}. The anchor is derived from that evidence; a finding without it is rejected and the correction must be resubmitted.`,
317
350
  `PASS shape: {"contract":"assurance_kernel/assurance_verdict/v2","role":"review","task_id":"${snapshot.task_id}","snapshot_digest":"${digest}","decision":"pass","approval":{"kind":"review","authority_role":"reviewer","summary":"<one line>"}}`,
318
- `REWORK shape: {"contract":"assurance_kernel/assurance_verdict/v2","role":"review","task_id":"${snapshot.task_id}","snapshot_digest":"${digest}","decision":"rework","findings":[{"id":"review-1","kind":"blocking|advisory","acceptance_id":"<id|null>","summary":"<one line>"}]}`,
351
+ `REWORK shape: {"contract":"assurance_kernel/assurance_verdict/v2","role":"review","task_id":"${snapshot.task_id}","snapshot_digest":"${digest}","decision":"rework","findings":[{"id":"review-1","kind":"blocking|advisory","acceptance_id":"<id|null>","summary":"<one line>","evidence":{"trigger":"<concrete inputs or state>","caller_chain":["<path-or-symbol>"],"violated":{"kind":"acceptance|security_boundary","ref":"<acceptance id or boundary>"}}}]}`,
319
352
  `REWORK verdicts must omit the approval field entirely; do not emit "approval": null.`,
320
353
  ].join("\n");
321
354
  }
322
355
 
356
+ function parseVerdictEvidence(value: unknown, index: number): FindingEvidence {
357
+ if (!value || typeof value !== "object" || Array.isArray(value))
358
+ throw new Error(`finding ${index} evidence is required`);
359
+ const evidence = value as Record<string, unknown>;
360
+ const unknown = Object.keys(evidence).find(
361
+ (key) => !["trigger", "caller_chain", "violated"].includes(key),
362
+ );
363
+ if (unknown) throw new Error(`finding ${index} evidence has unknown field: ${unknown}`);
364
+ if (typeof evidence.trigger !== "string" || !evidence.trigger.trim())
365
+ throw new Error(`finding ${index} evidence.trigger must be a non-empty string`);
366
+ const chain = evidence.caller_chain;
367
+ if (
368
+ !Array.isArray(chain) ||
369
+ chain.length === 0 ||
370
+ chain.some((entry) => typeof entry !== "string" || !entry.trim())
371
+ )
372
+ throw new Error(
373
+ `finding ${index} evidence.caller_chain must be a non-empty list of paths or symbols`,
374
+ );
375
+ const violated = evidence.violated;
376
+ if (!violated || typeof violated !== "object" || Array.isArray(violated))
377
+ throw new Error(`finding ${index} evidence.violated is required`);
378
+ const violatedRecord = violated as Record<string, unknown>;
379
+ const unknownViolated = Object.keys(violatedRecord).find(
380
+ (key) => !["kind", "ref"].includes(key),
381
+ );
382
+ if (unknownViolated)
383
+ throw new Error(`finding ${index} evidence.violated has unknown field: ${unknownViolated}`);
384
+ if (violatedRecord.kind !== "acceptance" && violatedRecord.kind !== "security_boundary")
385
+ throw new Error(
386
+ `finding ${index} evidence.violated.kind must be acceptance or security_boundary`,
387
+ );
388
+ if (typeof violatedRecord.ref !== "string" || !violatedRecord.ref.trim())
389
+ throw new Error(`finding ${index} evidence.violated.ref must be a non-empty string`);
390
+ return {
391
+ trigger: evidence.trigger,
392
+ caller_chain: chain as string[],
393
+ violated: {
394
+ kind: violatedRecord.kind,
395
+ ref: violatedRecord.ref,
396
+ },
397
+ };
398
+ }
399
+
323
400
  export function parseAssuranceVerdict(input: unknown, snapshot: SnapshotDescriptor): AssuranceVerdict {
324
401
  let raw: Record<string, unknown>;
325
402
  if (typeof input === "string") {
@@ -353,12 +430,33 @@ export function parseAssuranceVerdict(input: unknown, snapshot: SnapshotDescript
353
430
  if (raw.approval !== undefined && raw.approval !== null) throw new Error("rework verdict must omit approval");
354
431
  const findings = raw.findings.map((item, index) => {
355
432
  const finding = item as Record<string, unknown>;
356
- const unknownFinding = Object.keys(finding).find((key) => !["id", "kind", "acceptance_id", "summary"].includes(key));
433
+ const allowedFindingKeys =
434
+ snapshot.role === "review"
435
+ ? ["id", "kind", "acceptance_id", "summary", "evidence"]
436
+ : ["id", "kind", "acceptance_id", "summary"];
437
+ const unknownFinding = Object.keys(finding).find(
438
+ (key) => !allowedFindingKeys.includes(key),
439
+ );
357
440
  if (unknownFinding) throw new Error(`finding ${index} has unknown field: ${unknownFinding}`);
358
441
  if (typeof finding.id !== "string" || !finding.id.trim() || (finding.kind !== "blocking" && finding.kind !== "advisory") || (finding.acceptance_id !== null && typeof finding.acceptance_id !== "string") || typeof finding.summary !== "string" || !finding.summary.trim()) throw new Error(`finding ${index} is invalid`);
359
442
  const id = `review-${snapshotDigest(snapshot).slice(7, 19)}-${index + 1}-${finding.id.replace(/[^A-Za-z0-9._-]/g, "-").slice(0, 48)}`;
360
443
  const normalized = { id, kind: finding.kind as "blocking" | "advisory", acceptance_id: finding.acceptance_id as string | null, summary: finding.summary as string };
361
- return { ...normalized, findings_digest: findingsDigest([normalized]) };
444
+ if (snapshot.role !== "review")
445
+ return { ...normalized, findings_digest: findingsDigest([normalized]) };
446
+ // Review findings must carry evidence; the anchor is derived here so a
447
+ // capability minted for one anchor set cannot be spent on another and
448
+ // the reviewer can never assert the anchor directly.
449
+ const evidence = parseVerdictEvidence(finding.evidence, index);
450
+ // The digest that decides refutation inheritance is derived here so the
451
+ // reviewer can never assert the anchor directly; the Kernel recomputes it
452
+ // from the same evidence before admitting the finding.
453
+ const anchor = anchorForEvidence(evidence);
454
+ return {
455
+ ...normalized,
456
+ anchor,
457
+ evidence,
458
+ findings_digest: findingsDigest([normalized]),
459
+ };
362
460
  });
363
461
  return { contract: "assurance_kernel/assurance_verdict/v2", role: snapshot.role, task_id: snapshot.task_id, snapshot_digest: snapshotDigest(snapshot), decision: "rework", findings };
364
462
  }
@@ -5,6 +5,7 @@ export const PRIVILEGED_OPERATIONS = [
5
5
  "request_authorization",
6
6
  "approve_breaking_intent_revision",
7
7
  "stop",
8
+ "start_unattended_batch",
8
9
  ] as const;
9
10
 
10
11
  export type PrivilegedOperation = (typeof PRIVILEGED_OPERATIONS)[number];
@@ -66,12 +67,22 @@ export function evaluateNativeGate(input: NativeGateInput): NativeGateResult {
66
67
 
67
68
  export interface NativeConfirmationInput {
68
69
  operation: PrivilegedOperation;
69
- taskId: string;
70
+ taskId?: string;
71
+ initiativeSlug?: string;
70
72
  toolCallId: string;
71
73
  risk?: string;
72
74
  intentRevision?: number;
73
75
  intentContentHash?: string;
74
76
  bindingDigest?: string;
77
+ planDigest?: string;
78
+ batchDetails?: {
79
+ initiative_slug: string;
80
+ batch_branch: string;
81
+ children: Array<{ task_id: string; slice_id: string; risk?: string }>;
82
+ excluded: Array<{ task_id: string; slice_id: string; reason: string }>;
83
+ budget: { max_children: number; deadline_at: string; qa_failure_limit: number };
84
+ expires_at: string;
85
+ };
75
86
  signal?: AbortSignal;
76
87
  }
77
88
 
@@ -87,13 +98,15 @@ export function confirmationRef(input: {
87
98
  toolCallId: string;
88
99
  requestId: string;
89
100
  operation: string;
90
- taskId: string;
101
+ taskId?: string;
102
+ initiativeSlug?: string;
91
103
  intentRevision?: number;
92
104
  intentContentHash?: string;
93
105
  bindingDigest?: string;
106
+ planDigest?: string;
94
107
  }): string {
95
108
  return `claude-confirm-${createHash("sha256")
96
- .update(`${input.connectionId}\0${input.toolCallId}\0${input.requestId}\0${input.operation}\0${input.taskId}\0${input.intentRevision ?? ""}\0${input.intentContentHash ?? ""}\0${input.bindingDigest ?? ""}`)
109
+ .update(`${input.connectionId}\0${input.toolCallId}\0${input.requestId}\0${input.operation}\0${input.taskId ?? ""}\0${input.initiativeSlug ?? ""}\0${input.intentRevision ?? ""}\0${input.intentContentHash ?? ""}\0${input.bindingDigest ?? ""}\0${input.planDigest ?? ""}`)
97
110
  .digest("hex")
98
111
  .slice(0, 16)}`;
99
112
  }