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.
- package/README.md +24 -3
- package/README.zh-CN.md +23 -3
- package/package.json +2 -1
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +29 -19
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +932 -0
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +200 -2
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +3404 -227
- package/plugins/immune-brain/dist/imm-loop.md +22 -0
- package/plugins/immune-brain/dist/role-prompts/code-review.md +9 -1
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +101 -3
- package/plugins/immune-brain/runtime/claude/interaction.ts +16 -3
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +840 -17
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +72 -10
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +9 -0
- package/plugins/immune-brain/runtime/kernel/completion.ts +19 -1
- package/plugins/immune-brain/runtime/kernel/reducer.ts +162 -11
- package/plugins/immune-brain/runtime/kernel/refutation.ts +82 -0
- package/plugins/immune-brain/runtime/kernel/types.ts +34 -1
- package/plugins/immune-brain/runtime/kernel/validation.ts +202 -9
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +9 -1
- package/plugins/immune-brain/runtime/unattended/batch_git.ts +775 -0
- package/plugins/immune-brain/runtime/unattended/batch_plan.ts +203 -0
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +1224 -0
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +360 -0
- package/plugins/immune-brain/runtime/unattended/types.ts +60 -0
- 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`).
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
}
|