immune-brain 4.5.0 → 4.6.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.
- package/README.md +3 -2
- package/package.json +1 -1
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +0 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +47 -2
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +23 -18
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +0 -3
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +679 -762
- package/plugins/immune-brain/dist/imm-loop.md +11 -4
- package/plugins/immune-brain/dist/imm-planner.md +37 -12
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +55 -2
- package/plugins/immune-brain/runtime/assurance/review_evidence.ts +4 -0
- package/plugins/immune-brain/runtime/claude/interaction.ts +1 -2
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +73 -27
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +3 -4
- package/plugins/immune-brain/runtime/claude/review_host.ts +15 -0
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +738 -677
- package/plugins/immune-brain/runtime/kernel/application.ts +2 -5
- package/plugins/immune-brain/runtime/kernel/authority_port.ts +5 -14
- package/plugins/immune-brain/runtime/kernel/batch_authority.ts +30 -49
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +1 -2
- package/plugins/immune-brain/runtime/kernel/canary_eligibility.ts +0 -1
- package/plugins/immune-brain/runtime/kernel/capability_registry.ts +7 -9
- package/plugins/immune-brain/runtime/kernel/enrollment.ts +21 -12
- package/plugins/immune-brain/runtime/kernel/enrollment_authority.ts +5 -11
- package/plugins/immune-brain/runtime/kernel/types.ts +2 -1
- package/plugins/immune-brain/runtime/kernel/validation.ts +3 -1
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/unattended/batch_git.ts +9 -1
- package/plugins/immune-brain/runtime/unattended/batch_plan.ts +3 -9
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +120 -31
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +0 -6
- package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +6 -7
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +72 -119
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +36 -14
- package/plugins/immune-brain/runtime/unattended/types.ts +0 -2
- package/plugins/immune-brain/runtime/workspace_scope.ts +44 -8
- package/plugins/immune-brain/runtime/unattended/confirmation_deadline.ts +0 -33
package/README.md
CHANGED
|
@@ -301,7 +301,8 @@ When an Initiative has several ready children, you can run them as one serial ba
|
|
|
301
301
|
- **Entry is explicit:** the Host's privileged `start_unattended_batch` tool, taking the Initiative slug. Nothing batch-related exists until it is called — without it, `imm-loop` behaves exactly like per-task enrollment and creates no batch state, branch, or authorization.
|
|
302
302
|
- **One confirmation, one digest:** the native gate (Pi TUI dialog or Claude MCP elicitation) shows the ordered child list and the shared plan digest; that single literal-user act is the whole Batch Authorization.
|
|
303
303
|
- **Per-child authority survives:** every child is still enrolled, frozen, QA'd, reviewed, and settled by the Kernel on its own `TaskRecord`. The batch is the scope of one authorization, never a new authority layer.
|
|
304
|
-
- **
|
|
304
|
+
- **Closeout is automatic:** when a child reaches `done` in the foreground, the same call commits it and enrolls the next child, or marks the batch `completed`, with no new gate. A parked or stopped child is never committed for you, and a failed continuation is reported beside the result with `start_unattended_batch` as the retry. A fast-forward commit you add to the batch branch is adopted; any other HEAD movement still stops the run.
|
|
305
|
+
- **Bounds:** only published, non-`critical` children run, serially on a dedicated batch branch. The run parks when a child needs a human decision or a budget, authorization, or commit failure stops it; the budget is a child count and a QA failure limit, and nothing expires with time, and dependents of a blocked child are skipped rather than reordered. The runner never pushes, opens PRs, resolves user decisions, or creates, switches, or deletes Git worktrees.
|
|
305
306
|
|
|
306
307
|
---
|
|
307
308
|
|
|
@@ -361,7 +362,7 @@ docs/specs/ # Living specs (updated in place)
|
|
|
361
362
|
|
|
362
363
|
**A review finding stopped blocking — why?** It was refuted: fresh deterministic QA evidence shows the acceptance it names passes. The refutation is bound to that exact evidence, so the finding blocks again the moment the evidence goes stale for the current revision, intent hash, or diff.
|
|
363
364
|
|
|
364
|
-
**Can it run a whole Initiative without me?** Only as far as you authorize. Confirm `start_unattended_batch` with the Initiative slug and the runner works through the published, non-`critical` children serially on one batch branch — parking as soon as a child needs a human decision or the run hits a budget,
|
|
365
|
+
**Can it run a whole Initiative without me?** Only as far as you authorize. Confirm `start_unattended_batch` with the Initiative slug and the runner works through the published, non-`critical` children serially on one batch branch — parking as soon as a child needs a human decision or the run hits a budget, authorization, or commit failure. A parked run waits for you indefinitely: neither the confirmation dialog nor the authorization it grants times out. It never pushes, opens PRs, or settles user decisions for you.
|
|
365
366
|
|
|
366
367
|
**Can I switch between hosts (e.g. plan in Claude Code, code in Pi)?** Yes. Immune-Brain's contracts and state live entirely on disk in the repository, decoupled from conversation sessions. You can leverage Claude Code for deep architectural thinking and Spec planning, then switch to Pi to run `imm-loop` for code execution and deterministic QA. Interrupted tasks can be resumed in either host at any time.
|
|
367
368
|
|
package/package.json
CHANGED
|
@@ -557,7 +557,6 @@ async function executeForegroundEnrollment(
|
|
|
557
557
|
preparation_digest: preparation.digest,
|
|
558
558
|
actor_id: LITERAL_USER_ACTOR_ID,
|
|
559
559
|
confirmation_ref: `pi-confirm-${createHash("sha256").update(`${taskId}\0${now}\0${nonce}`).digest("hex").slice(0, 16)}`,
|
|
560
|
-
expires_at: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
|
|
561
560
|
nonce,
|
|
562
561
|
};
|
|
563
562
|
const capability = registry.issue(binding);
|
|
@@ -21,6 +21,8 @@ import { join, resolve } from "node:path";
|
|
|
21
21
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
22
22
|
import { Type } from "typebox";
|
|
23
23
|
import { PLUGIN_VERSION } from "../runtime/plugin_version";
|
|
24
|
+
import { findResumableBatchSlugForTask } from "../runtime/unattended/batch_preflight";
|
|
25
|
+
import { executePiUnattendedBatch } from "./imm-unattended-batch";
|
|
24
26
|
import {
|
|
25
27
|
parseVerificationDescriptor,
|
|
26
28
|
type VerificationDescriptor,
|
|
@@ -581,7 +583,11 @@ export default function (
|
|
|
581
583
|
: action.op === "request_stop"
|
|
582
584
|
? await authorizeExactOperation(taskId, "stop", { ...ctx, signal: signal && ctx.signal ? AbortSignal.any([signal, ctx.signal]) : signal ?? ctx.signal })
|
|
583
585
|
: await requestAuthorization(taskId, ctx);
|
|
584
|
-
const enriched = await
|
|
586
|
+
const enriched = await closeOutBatchChild(
|
|
587
|
+
ctx,
|
|
588
|
+
taskId,
|
|
589
|
+
await enrichAssuranceResult(ctx, taskId, result as unknown as Record<string, unknown>),
|
|
590
|
+
);
|
|
585
591
|
presentTaskRailResult(ctx, taskId, enriched);
|
|
586
592
|
throwIfCanaryToolFailure(taskId, action.op, enriched);
|
|
587
593
|
return toolResult(JSON.stringify(enriched, null, 2), enriched);
|
|
@@ -1648,7 +1654,6 @@ async function mintCapability(
|
|
|
1648
1654
|
confirmation_ref: `pi-confirm-${createHash("sha256").update(
|
|
1649
1655
|
`${input.task_id}\0${now}\0${input.intent_revision}\0${input.intent_content_hash}\0${input.diff_hash}`,
|
|
1650
1656
|
).digest("hex").slice(0, 16)}`,
|
|
1651
|
-
expires_at: new Date(Date.now() + 10 * 60 * 1000).toISOString(),
|
|
1652
1657
|
findings_digest:
|
|
1653
1658
|
input.action_kind === "request_rework"
|
|
1654
1659
|
? await findingsDigestV2(input.findings as never[])
|
|
@@ -1732,6 +1737,46 @@ async function reconcileRefsQuietly(root: string): Promise<void> {
|
|
|
1732
1737
|
}
|
|
1733
1738
|
}
|
|
1734
1739
|
|
|
1740
|
+
/**
|
|
1741
|
+
* Gate-free closeout: a batch child that reached Kernel `done` in the foreground
|
|
1742
|
+
* re-enters the batch in the same call, reusing its authorization. Only `done`
|
|
1743
|
+
* continues; stopped, parked and Review-pending children end where they do
|
|
1744
|
+
* today. The continuation is transport: the Kernel result is returned
|
|
1745
|
+
* unchanged, and any failure is reported beside it with one retry action.
|
|
1746
|
+
*/
|
|
1747
|
+
async function closeOutBatchChild(
|
|
1748
|
+
ctx: ExtensionContext,
|
|
1749
|
+
taskId: string,
|
|
1750
|
+
enriched: Record<string, unknown>,
|
|
1751
|
+
): Promise<Record<string, unknown>> {
|
|
1752
|
+
const taskState = enriched.task_state as { lifecycle?: unknown } | undefined;
|
|
1753
|
+
if (taskState?.lifecycle !== "done") return enriched;
|
|
1754
|
+
let slug: string | null;
|
|
1755
|
+
try {
|
|
1756
|
+
slug = findResumableBatchSlugForTask(ctx.cwd, taskId);
|
|
1757
|
+
} catch {
|
|
1758
|
+
return enriched;
|
|
1759
|
+
}
|
|
1760
|
+
if (!slug) return enriched;
|
|
1761
|
+
try {
|
|
1762
|
+
// The batch commit takes exactly the scope envelope plus this task's own
|
|
1763
|
+
// audit evidence and refuses unstaged changes: stage only that evidence.
|
|
1764
|
+
if (existsSync(join(ctx.cwd, ".imm", "audit", taskId)))
|
|
1765
|
+
execFileSync("git", ["-C", ctx.cwd, "add", "--", `.imm/audit/${taskId}`], { stdio: "ignore" });
|
|
1766
|
+
const batch = await executePiUnattendedBatch({ root: ctx.cwd, initiativeSlug: slug, reuseOnly: true, signal: ctx.signal });
|
|
1767
|
+
return { ...enriched, batch };
|
|
1768
|
+
} catch (error) {
|
|
1769
|
+
return {
|
|
1770
|
+
...enriched,
|
|
1771
|
+
batch: {
|
|
1772
|
+
state: "rejected",
|
|
1773
|
+
reason: `batch continuation failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
1774
|
+
recovery_action: "call start_unattended_batch with the same Initiative to continue the batch",
|
|
1775
|
+
},
|
|
1776
|
+
};
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1735
1780
|
async function enrichAssuranceResult(
|
|
1736
1781
|
ctx: ExtensionContext,
|
|
1737
1782
|
taskId: string,
|
|
@@ -19,7 +19,6 @@ import {
|
|
|
19
19
|
type InitiativeObservationReader,
|
|
20
20
|
} from "./runtime-stub";
|
|
21
21
|
import { batchReason } from "../runtime/unattended/batch_reasons";
|
|
22
|
-
import { startConfirmationDeadline } from "../runtime/unattended/confirmation_deadline";
|
|
23
22
|
import {
|
|
24
23
|
authorizeBatch,
|
|
25
24
|
projectBatchPreflight,
|
|
@@ -69,12 +68,16 @@ export interface PiBatchExecutionOptions {
|
|
|
69
68
|
root: string;
|
|
70
69
|
initiativeSlug: string;
|
|
71
70
|
interactive?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Gate-free continuation after a foreground child: never opens the
|
|
73
|
+
* confirmation, and an authorization that cannot be reused is reported as a
|
|
74
|
+
* blocked result with the one action that reopens the gate.
|
|
75
|
+
*/
|
|
76
|
+
reuseOnly?: boolean;
|
|
72
77
|
signal?: AbortSignal;
|
|
73
78
|
batchKernel?: Partial<BatchRunnerKernelPort>;
|
|
74
79
|
batchGit?: BatchRunnerGitPort;
|
|
75
80
|
readInitiative?: InitiativeObservationReader;
|
|
76
|
-
/** Environment the confirmation deadline reads; production passes process.env. */
|
|
77
|
-
env?: Record<string, string | undefined>;
|
|
78
81
|
confirmBatch?: (details: {
|
|
79
82
|
title: string;
|
|
80
83
|
summary: string;
|
|
@@ -107,9 +110,10 @@ export async function executePiUnattendedBatch(
|
|
|
107
110
|
options: PiBatchExecutionOptions,
|
|
108
111
|
): Promise<PiBatchExecutionResult> {
|
|
109
112
|
const { root, initiativeSlug, signal } = options;
|
|
113
|
+
const reuseOnly = options.reuseOnly === true;
|
|
110
114
|
const interactive = options.interactive ?? true;
|
|
111
115
|
|
|
112
|
-
if (!interactive) return { state: "rejected", ...nonInteractiveRefusal() };
|
|
116
|
+
if (!reuseOnly && !interactive) return { state: "rejected", ...nonInteractiveRefusal() };
|
|
113
117
|
|
|
114
118
|
// 1. Host-independent batch preflight: claim ownership, branch availability,
|
|
115
119
|
// working-tree cleanliness against the authorized scope, recovery children,
|
|
@@ -132,7 +136,7 @@ export async function executePiUnattendedBatch(
|
|
|
132
136
|
const planDigest = preflight.projection.plan_digest;
|
|
133
137
|
const recoveryChildren = preflight.projection.recovery_children;
|
|
134
138
|
|
|
135
|
-
// 4. Literal-user gate plus the shared reuse
|
|
139
|
+
// 4. Literal-user gate plus the shared reuse decision, the post-gate
|
|
136
140
|
// claim/drift cascade, and the Batch Authorization binding. The Host supplies
|
|
137
141
|
// only its gate, its confirmation reference, and its binding nonce.
|
|
138
142
|
const authorization = await authorizeBatch<PiBatchExecutionResult>({
|
|
@@ -143,36 +147,40 @@ export async function executePiUnattendedBatch(
|
|
|
143
147
|
readInitiative: options.readInitiative,
|
|
144
148
|
nonce: randomUUID(),
|
|
145
149
|
gate: async (facts) => {
|
|
150
|
+
if (reuseOnly)
|
|
151
|
+
return {
|
|
152
|
+
kind: "host_rejection",
|
|
153
|
+
value: {
|
|
154
|
+
state: "blocked",
|
|
155
|
+
reason: `batch authorization cannot be reused (${facts.reuse_blockers.join(", ")})`,
|
|
156
|
+
recovery_action: "call start_unattended_batch with the same Initiative to confirm a fresh authorization",
|
|
157
|
+
},
|
|
158
|
+
};
|
|
146
159
|
// review-2: fail closed with zero writes when confirmation port is missing
|
|
147
160
|
if (!options.confirmBatch) {
|
|
148
161
|
return { kind: "host_rejection", value: batchReason("confirmation_port_unavailable") };
|
|
149
162
|
}
|
|
150
163
|
const confirmDetails = {
|
|
151
164
|
title: `Authorize Unattended Batch: ${facts.initiative_slug}`,
|
|
152
|
-
summary: `Initiative: ${facts.initiative_slug}\nBatch branch: ${facts.batch_branch}\nPlan digest: ${facts.plan_digest}\nBudget: max_children=${facts.budget.max_children},
|
|
165
|
+
summary: `Initiative: ${facts.initiative_slug}\nBatch branch: ${facts.batch_branch}\nPlan digest: ${facts.plan_digest}\nBudget: max_children=${facts.budget.max_children}, qa_failure_limit=${facts.budget.qa_failure_limit}`,
|
|
153
166
|
details: `Ordered children (${facts.children.length}):\n${facts.children.map((child) => ` - ${child.task_id} (${child.slice_id}) [risk: ${child.risk}] [status: ${child.status === "already_settled" ? "completed" : "pending execution"}]`).join("\n")}${facts.excluded.length > 0 ? `\n\nExcluded children:\n${facts.excluded.map((child) => ` - ${child.task_id} (${child.slice_id}): ${child.reason}`).join("\n")}` : ""}${facts.reuse_blockers.length > 0 ? `\n\nRe-confirmation required: ${facts.reuse_blockers.join(", ")}.\nRecovery: confirm to issue a fresh authorization bound to the current plan and HEAD.` : ""}`,
|
|
154
167
|
planDigest: facts.plan_digest,
|
|
155
168
|
signal,
|
|
156
169
|
};
|
|
157
170
|
|
|
158
|
-
// The
|
|
159
|
-
//
|
|
160
|
-
const deadline = startConfirmationDeadline({ env: options.env ?? process.env, signal });
|
|
171
|
+
// The gate settles only on the literal user's answer or the caller's
|
|
172
|
+
// cancellation signal.
|
|
161
173
|
let decision: "accept" | "decline" | "cancel";
|
|
162
174
|
try {
|
|
163
|
-
decision = await options.confirmBatch(
|
|
175
|
+
decision = await options.confirmBatch(confirmDetails);
|
|
164
176
|
} catch (err) {
|
|
165
|
-
if (deadline.timedOut()) return { kind: "host_rejection", value: batchReason("confirmation_timed_out") };
|
|
166
177
|
if (signal?.aborted) return { kind: "host_rejection", value: batchReason("confirmation_cancelled") };
|
|
167
178
|
return {
|
|
168
179
|
kind: "host_rejection",
|
|
169
180
|
value: batchReason("confirmation_failed", err instanceof Error ? err.message : String(err)),
|
|
170
181
|
};
|
|
171
|
-
} finally {
|
|
172
|
-
deadline.clear();
|
|
173
182
|
}
|
|
174
183
|
|
|
175
|
-
if (deadline.timedOut()) return { kind: "host_rejection", value: batchReason("confirmation_timed_out") };
|
|
176
184
|
if (decision === "cancel" || signal?.aborted) {
|
|
177
185
|
return { kind: "host_rejection", value: batchReason("confirmation_cancelled") };
|
|
178
186
|
}
|
|
@@ -195,7 +203,7 @@ export async function executePiUnattendedBatch(
|
|
|
195
203
|
});
|
|
196
204
|
if (authorization.outcome === "host_rejection") return authorization.value;
|
|
197
205
|
if (authorization.outcome === "rejected") return authorization.rejection;
|
|
198
|
-
const { binding, batch_id: batchId
|
|
206
|
+
const { binding, batch_id: batchId } = authorization;
|
|
199
207
|
|
|
200
208
|
// 6. Issue Batch Authorization through Kernel registry and startBatch
|
|
201
209
|
const batchRegistry: BatchAuthorityRegistry = await createBatchAuthorityRegistry();
|
|
@@ -205,8 +213,6 @@ export async function executePiUnattendedBatch(
|
|
|
205
213
|
if (signal?.aborted) return batchReason("cancelled_before_execution");
|
|
206
214
|
|
|
207
215
|
now = new Date().toISOString();
|
|
208
|
-
if (Date.parse(expiresAt) <= Date.parse(now))
|
|
209
|
-
return batchReason("confirmation_failed", "confirmed batch deadline expired before authorization issuance");
|
|
210
216
|
const capability = batchRegistry.issue(binding, recoveryChildren as any, now);
|
|
211
217
|
|
|
212
218
|
let activeReviewDispatch: { operation_id: string; agent_params: Record<string, unknown> } | null = null;
|
|
@@ -300,7 +306,6 @@ export async function executePiUnattendedBatch(
|
|
|
300
306
|
plan_digest: planDigest,
|
|
301
307
|
base_head: binding.base_head,
|
|
302
308
|
confirmation_time: now,
|
|
303
|
-
authorization_expires_at: expiresAt,
|
|
304
309
|
budget,
|
|
305
310
|
now,
|
|
306
311
|
kernel: kernelPort,
|
|
@@ -21,7 +21,6 @@ export interface EnrollmentCapabilityBinding {
|
|
|
21
21
|
preparation_digest: string;
|
|
22
22
|
actor_id: string;
|
|
23
23
|
confirmation_ref: string;
|
|
24
|
-
expires_at: string;
|
|
25
24
|
nonce: string;
|
|
26
25
|
}
|
|
27
26
|
export interface EnrollmentAuthorityRegistry {
|
|
@@ -36,7 +35,6 @@ export interface CanaryWaiver {
|
|
|
36
35
|
reason: string;
|
|
37
36
|
actor: string;
|
|
38
37
|
confirmation_ref: string;
|
|
39
|
-
expires_at: string;
|
|
40
38
|
nonce: string;
|
|
41
39
|
}
|
|
42
40
|
export interface PiCanaryPrepareInput {
|
|
@@ -66,7 +64,6 @@ export interface CapabilityBindingV2 {
|
|
|
66
64
|
diff_hash: string;
|
|
67
65
|
actor_id: string;
|
|
68
66
|
confirmation_ref: string;
|
|
69
|
-
expires_at: string;
|
|
70
67
|
findings_digest: string | null;
|
|
71
68
|
}
|
|
72
69
|
export interface MutationAuthorityRegistry {
|