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
@@ -0,0 +1,360 @@
1
+ // Batch run state persistence for unattended Initiative batch runs.
2
+ // State lives at .imm/state/batches/<batch_id>.json, written only under the
3
+ // kernel store lock, and is Git-ignored with the rest of .imm/state/.
4
+ import { existsSync, mkdirSync, openSync, closeSync, writeFileSync, renameSync, lstatSync, constants, rmSync } from "node:fs";
5
+ import { randomUUID } from "node:crypto";
6
+ import { dirname, join } from "node:path";
7
+ import { readSecureProjectFile, withKernelStoreLock } from "../kernel/storage";
8
+ import type { BatchPlanChild } from "./types";
9
+
10
+ export type BatchRunState =
11
+ | "prepared"
12
+ | "running"
13
+ | "needs_human"
14
+ | "completed"
15
+ | "budget_stopped"
16
+ | "failed"
17
+ | "rejected";
18
+
19
+ export type BatchChildRunState =
20
+ | "pending"
21
+ | "enrolled"
22
+ | "settled"
23
+ | "committed"
24
+ | "needs_human"
25
+ | "skipped_blocked";
26
+
27
+ export interface BatchChildRun {
28
+ task_id: string;
29
+ slice_id: string;
30
+ blocked_by: string[];
31
+ state: BatchChildRunState;
32
+ /** Terminal reason for needs_human / skipped_blocked / failed children. */
33
+ reason: string | null;
34
+ /** Commit created after the Kernel reported this child done. */
35
+ commit: string | null;
36
+ }
37
+
38
+ export interface BatchRunStateRecord {
39
+ contract: "assurance_kernel/batch_run_state/v1";
40
+ batch_id: string;
41
+ initiative_slug: string;
42
+ plan_digest: string;
43
+ base_head: string;
44
+ /** Dedicated batch branch, e.g. imm/<initiative-slug>. */
45
+ branch?: string;
46
+ /** Timestamp of the literal-user batch confirmation. */
47
+ confirmation_time: string;
48
+ /** Authorization expiry from the batch capability binding. */
49
+ authorization_expires_at: string;
50
+ budget: {
51
+ max_children: number;
52
+ deadline_at: string;
53
+ qa_failure_limit: number;
54
+ };
55
+ batch_state: BatchRunState;
56
+ children: BatchChildRun[];
57
+ /** Consecutive deterministic-QA failures across children in this run. */
58
+ consecutive_qa_failures: number;
59
+ /** Commit heads produced by this batch, in child order. */
60
+ commits: string[];
61
+ created_at: string;
62
+ updated_at: string;
63
+ }
64
+
65
+ const BATCH_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
66
+
67
+ const CHILD_RUN_STATES: ReadonlySet<string> = new Set([
68
+ "pending",
69
+ "enrolled",
70
+ "settled",
71
+ "committed",
72
+ "needs_human",
73
+ "skipped_blocked",
74
+ ]);
75
+
76
+ const BATCH_RUN_STATES: ReadonlySet<string> = new Set([
77
+ "prepared",
78
+ "running",
79
+ "needs_human",
80
+ "completed",
81
+ "budget_stopped",
82
+ "failed",
83
+ "rejected",
84
+ ]);
85
+
86
+ const ISO_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
87
+
88
+ function isCanonicalTimestamp(value: unknown): value is string {
89
+ if (typeof value !== "string" || !ISO_TIMESTAMP_PATTERN.test(value)) return false;
90
+ const milliseconds = Date.parse(value);
91
+ return Number.isFinite(milliseconds) && new Date(milliseconds).toISOString() === value;
92
+ }
93
+
94
+ function validateBatchId(batchId: string): void {
95
+ if (typeof batchId !== "string" || !BATCH_ID_PATTERN.test(batchId))
96
+ throw new Error("batch id is not a safe file identity");
97
+ }
98
+
99
+ function statePath(batchId: string): string {
100
+ validateBatchId(batchId);
101
+ return join(".imm", "state", "batches", `${batchId}.json`);
102
+ }
103
+
104
+ function canonicalBytes(record: BatchRunStateRecord): string {
105
+ return `${JSON.stringify(record, null, 2)}\n`;
106
+ }
107
+
108
+ function validateRecordShape(value: unknown, batchId: string): asserts value is BatchRunStateRecord {
109
+ if (typeof value !== "object" || value === null)
110
+ throw new Error(`batch run state ${batchId} is not an object`);
111
+ const record = value as Record<string, unknown>;
112
+ if (record.contract !== "assurance_kernel/batch_run_state/v1")
113
+ throw new Error(`batch run state ${batchId} has an unknown contract`);
114
+ if (record.batch_id !== batchId)
115
+ throw new Error(`batch run state ${batchId} carries batch_id ${String(record.batch_id)}`);
116
+ if (typeof record.plan_digest !== "string" || !record.plan_digest)
117
+ throw new Error(`batch run state ${batchId} has an invalid plan_digest`);
118
+ if (typeof record.base_head !== "string" || !record.base_head)
119
+ throw new Error(`batch run state ${batchId} has an invalid base_head`);
120
+ if (record.branch !== undefined && (typeof record.branch !== "string" || !record.branch))
121
+ throw new Error(`batch run state ${batchId} has an invalid branch`);
122
+ if (!isCanonicalTimestamp(record.confirmation_time))
123
+ throw new Error(`batch run state ${batchId} has an invalid confirmation_time`);
124
+ if (!isCanonicalTimestamp(record.authorization_expires_at))
125
+ throw new Error(`batch run state ${batchId} has an invalid authorization_expires_at`);
126
+ if (!isCanonicalTimestamp(record.created_at) || !isCanonicalTimestamp(record.updated_at))
127
+ throw new Error(`batch run state ${batchId} has invalid state timestamps`);
128
+ if (!Array.isArray(record.children) || record.children.length === 0)
129
+ throw new Error(`batch run state ${batchId} has no children`);
130
+ if (!BATCH_RUN_STATES.has(String(record.batch_state)))
131
+ throw new Error(`batch run state ${batchId} has an invalid batch_state`);
132
+ if (
133
+ typeof record.consecutive_qa_failures !== "number" ||
134
+ !Number.isInteger(record.consecutive_qa_failures) ||
135
+ record.consecutive_qa_failures < 0
136
+ )
137
+ throw new Error(`batch run state ${batchId} has an invalid consecutive_qa_failures`);
138
+ const budget = record.budget as Record<string, unknown>;
139
+ if (
140
+ typeof record.budget !== "object" ||
141
+ record.budget === null ||
142
+ typeof budget.max_children !== "number" ||
143
+ !Number.isInteger(budget.max_children) ||
144
+ budget.max_children <= 0 ||
145
+ !isCanonicalTimestamp(budget.deadline_at) ||
146
+ typeof budget.qa_failure_limit !== "number" ||
147
+ !Number.isInteger(budget.qa_failure_limit) ||
148
+ budget.qa_failure_limit <= 0
149
+ )
150
+ throw new Error(`batch run state ${batchId} has an invalid budget`);
151
+ if (!Array.isArray(record.commits) || record.commits.some((c) => typeof c !== "string"))
152
+ throw new Error(`batch run state ${batchId} has an invalid commits list`);
153
+ const seenTaskIds = new Set<string>();
154
+ for (const child of record.children) {
155
+ if (
156
+ typeof child !== "object" ||
157
+ child === null ||
158
+ typeof child.task_id !== "string" ||
159
+ !child.task_id ||
160
+ typeof child.slice_id !== "string" ||
161
+ !child.slice_id
162
+ )
163
+ throw new Error(`batch run state ${batchId} has an invalid child entry`);
164
+ if (!CHILD_RUN_STATES.has(String(child.state)))
165
+ throw new Error(`batch run state ${batchId} child ${child.task_id} has an invalid state`);
166
+ if (!Array.isArray(child.blocked_by) || child.blocked_by.some((b: unknown) => typeof b !== "string"))
167
+ throw new Error(`batch run state ${batchId} child ${child.task_id} has an invalid blocked_by`);
168
+ if (
169
+ (child.reason !== null && typeof child.reason !== "string") ||
170
+ (child.commit !== null && typeof child.commit !== "string")
171
+ )
172
+ throw new Error(`batch run state ${batchId} child ${child.task_id} has invalid terminal fields`);
173
+ // State invariants: commit/reason pair coherently with the state.
174
+ if (child.state === "committed" && child.commit === null)
175
+ throw new Error(`batch run state ${batchId} child ${child.task_id} is committed without a commit`);
176
+ if (child.state !== "committed" && child.commit !== null)
177
+ throw new Error(`batch run state ${batchId} child ${child.task_id} has a commit but is not committed`);
178
+ if (
179
+ (child.state === "needs_human" || child.state === "skipped_blocked") &&
180
+ child.reason === null
181
+ )
182
+ throw new Error(`batch run state ${batchId} child ${child.task_id} needs a terminal reason`);
183
+ if (seenTaskIds.has(child.task_id))
184
+ throw new Error(`batch run state ${batchId} has a duplicate child ${child.task_id}`);
185
+ seenTaskIds.add(child.task_id);
186
+ }
187
+ // A committed child must appear in the commits list (review-1).
188
+ for (const child of record.children) {
189
+ if (child.state === "committed" && child.commit !== null && !record.commits.includes(child.commit))
190
+ throw new Error(`batch run state ${batchId} child ${child.task_id} commit is missing from commits`);
191
+ }
192
+ if (
193
+ (record.batch_state === "budget_stopped" ||
194
+ record.batch_state === "failed" ||
195
+ record.batch_state === "rejected") &&
196
+ record.children.some((child) => child.state === "enrolled" || child.state === "settled")
197
+ )
198
+ throw new Error(`batch run state ${batchId} is ${String(record.batch_state)} but a child is still mid-flight`);
199
+ if (
200
+ record.batch_state === "completed" &&
201
+ record.children.some((child) => child.state !== "committed")
202
+ )
203
+ throw new Error(`batch run state ${batchId} is completed but a child is not committed`);
204
+ }
205
+
206
+ export function prepareBatchRunState(input: {
207
+ batch_id: string;
208
+ initiative_slug: string;
209
+ children: BatchPlanChild[];
210
+ plan_digest: string;
211
+ base_head: string;
212
+ branch?: string;
213
+ confirmation_time: string;
214
+ authorization_expires_at: string;
215
+ budget: { max_children: number; deadline_at: string; qa_failure_limit: number };
216
+ now: string;
217
+ }): BatchRunStateRecord {
218
+ validateBatchId(input.batch_id);
219
+ const prepared: BatchRunStateRecord = {
220
+ contract: "assurance_kernel/batch_run_state/v1",
221
+ batch_id: input.batch_id,
222
+ initiative_slug: input.initiative_slug,
223
+ plan_digest: input.plan_digest,
224
+ base_head: input.base_head,
225
+ branch: input.branch ?? `imm/${input.initiative_slug}`,
226
+ confirmation_time: input.confirmation_time,
227
+ authorization_expires_at: input.authorization_expires_at,
228
+ budget: input.budget,
229
+ batch_state: "prepared",
230
+ children: input.children.map((child) => ({
231
+ task_id: child.task_id,
232
+ slice_id: child.slice_id,
233
+ blocked_by: [...child.blocked_by],
234
+ state: "pending",
235
+ reason: null,
236
+ commit: null,
237
+ })),
238
+ consecutive_qa_failures: 0,
239
+ commits: [],
240
+ created_at: input.now,
241
+ updated_at: input.now,
242
+ };
243
+ return prepared;
244
+ }
245
+
246
+ export function readBatchRunState(root: string, batchId: string): BatchRunStateRecord | null {
247
+ const path = statePath(batchId);
248
+ if (!existsSync(join(root, path))) return null;
249
+ const parsed: unknown = JSON.parse(readSecureProjectFile(root, path));
250
+ validateRecordShape(parsed, batchId);
251
+ return parsed;
252
+ }
253
+
254
+ function ensureSecureDirectory(root: string, relative: string): string {
255
+ // review-4: create the directory with lstat verification so a pre-existing
256
+ // symlink cannot redirect state writes outside the project; mirrors the
257
+ // kernel storage boundary.
258
+ const target = join(root, relative);
259
+ const parent = dirname(target);
260
+ if (!existsSync(parent)) mkdirSync(parent, { recursive: true });
261
+ if (existsSync(target)) {
262
+ const stats = lstatSync(target);
263
+ if (!stats.isDirectory()) throw new Error(`${relative} exists but is not a directory`);
264
+ } else {
265
+ mkdirSync(target);
266
+ }
267
+ return target;
268
+ }
269
+
270
+ function writeFileAtomically(root: string, relative: string, bytes: string): void {
271
+ // review-4: no-symlink atomic write; lstat each created directory and the
272
+ // target so writes cannot be redirected by a pre-existing symlink.
273
+ const target = join(root, relative);
274
+ const targetDir = dirname(target);
275
+ const stats = lstatSync(targetDir);
276
+ if (!stats.isDirectory()) throw new Error(`${dirname(relative)} is not a directory`);
277
+ const tempPath = `${target}.${randomUUID()}.tmp`; let fd: number | null = null;
278
+ try {
279
+ fd = openSync(tempPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
280
+ writeFileSync(fd, bytes, "utf8");
281
+ closeSync(fd);
282
+ fd = null;
283
+ renameSync(tempPath, target);
284
+ } finally {
285
+ if (fd !== null) closeSync(fd);
286
+ if (existsSync(tempPath)) { try { rmSync(tempPath); } catch { /* temp already moved */ } }
287
+ }
288
+ }
289
+
290
+ export function writeBatchRunState(
291
+ root: string,
292
+ record: BatchRunStateRecord,
293
+ ): BatchRunStateRecord {
294
+ const path = statePath(record.batch_id);
295
+ validateRecordShape(record, record.batch_id);
296
+ return withKernelStoreLock(root, () => {
297
+ const existing = existsSync(join(root, path)) ? readSecureProjectFile(root, path) : null;
298
+ if (existing !== null && existing === canonicalBytes(record)) return record;
299
+ const stored: BatchRunStateRecord = {
300
+ ...record,
301
+ updated_at: new Date().toISOString(),
302
+ };
303
+ // review-4: secure directory + atomic no-symlink write.
304
+ ensureSecureDirectory(root, join(".imm", "state", "batches"));
305
+ writeFileAtomically(root, path, canonicalBytes(stored));
306
+ return stored;
307
+ });
308
+ }
309
+
310
+ export interface BatchRunReport {
311
+ contract: "assurance_kernel/batch_run_report/v1";
312
+ batch_id: string;
313
+ initiative_slug: string;
314
+ batch_state: BatchRunState;
315
+ children: BatchChildRun[];
316
+ commits: string[];
317
+ reason: string | null;
318
+ /** The single next action for the operator. */
319
+ next_action: string;
320
+ created_at: string;
321
+ }
322
+
323
+ function reportPath(batchId: string): string {
324
+ validateBatchId(batchId);
325
+ return join(".imm", "state", "batches", `${batchId}.report.json`);
326
+ }
327
+
328
+ /** Persist one current stop report per batch. Terminal reports are immutable;
329
+ * a resumable needs_human report may be replaced by the later stop reached
330
+ * after a fresh literal-user confirmation. */
331
+ export function writeBatchRunReport(root: string, report: BatchRunReport): BatchRunReport {
332
+ const relative = reportPath(report.batch_id);
333
+ return withKernelStoreLock(root, () => {
334
+ const path = join(root, relative);
335
+ if (existsSync(path)) {
336
+ const original: unknown = JSON.parse(readSecureProjectFile(root, relative));
337
+ if (
338
+ typeof original !== "object" ||
339
+ original === null ||
340
+ (original as BatchRunReport).contract !== "assurance_kernel/batch_run_report/v1"
341
+ )
342
+ throw new Error(`batch run report ${report.batch_id} has an unknown contract`);
343
+ const prior = original as BatchRunReport;
344
+ if (canonicalReportBytes(prior) === canonicalReportBytes(report)) return prior;
345
+ if (prior.batch_state !== "needs_human") return prior;
346
+ }
347
+ ensureSecureDirectory(root, join(".imm", "state", "batches"));
348
+ writeFileAtomically(root, relative, canonicalReportBytes(report));
349
+ return report;
350
+ });
351
+ }
352
+
353
+ function canonicalReportBytes(report: BatchRunReport): string {
354
+ return `${JSON.stringify(report, null, 2)}\n`;
355
+ }
356
+
357
+ /** All terminal batch states; needs_human is a park, not terminal. */
358
+ export function isTerminalBatchState(state: BatchRunState): boolean {
359
+ return state === "completed" || state === "budget_stopped" || state === "failed" || state === "rejected";
360
+ }
@@ -0,0 +1,60 @@
1
+ import type { GithubInitiativeObservation } from "../github_issue_tracker";
2
+
3
+ export interface BatchPlanBudget {
4
+ max_children: number;
5
+ deadline_at: string;
6
+ qa_failure_limit: number;
7
+ }
8
+
9
+ export interface BatchPlanBudgetInput {
10
+ max_children?: number;
11
+ deadline_at?: string;
12
+ qa_failure_limit?: number;
13
+ }
14
+
15
+ export interface BatchPlanDigestChild {
16
+ task_id: string;
17
+ intent_path: string;
18
+ intent_revision: number;
19
+ intent_content_hash: string;
20
+ blocked_by: string[];
21
+ }
22
+
23
+ export type BatchPlanChildStatus =
24
+ | "enrollable"
25
+ | "already_settled"
26
+ | "already_owned"
27
+ | "needs_human"
28
+ | "blocked";
29
+
30
+ export interface BatchPlanChild {
31
+ task_id: string;
32
+ slice_id: string;
33
+ blocked_by: string[];
34
+ status: BatchPlanChildStatus;
35
+ reason: "critical" | "invalid_intent" | "dependency_unavailable" | null;
36
+ intent_path: string | null;
37
+ intent_revision: number | null;
38
+ intent_content_hash: string | null;
39
+ }
40
+
41
+ export interface BatchPlan {
42
+ contract: "assurance_kernel/batch_plan/v1";
43
+ initiative_slug: string;
44
+ confirmation_time: string;
45
+ tracker_observation: GithubInitiativeObservation;
46
+ children: BatchPlanChild[];
47
+ enrollable: BatchPlanDigestChild[];
48
+ plan_digest: string;
49
+ budget: BatchPlanBudget;
50
+ }
51
+
52
+ export interface ProjectBatchPlanInput {
53
+ confirmation_time: string;
54
+ budget?: BatchPlanBudgetInput;
55
+ }
56
+
57
+ export type InitiativeObservationReader = (
58
+ root: string,
59
+ initiativeSlug: string,
60
+ ) => Promise<GithubInitiativeObservation>;
@@ -22,6 +22,7 @@ references load only under their own condition. Never read the whole contract
22
22
  or all references as an entry prerequisite.
23
23
 
24
24
  - common: [Shared Guards](../../dist/BASELINE.md#shared-guards), [Workflow Activation](../../dist/BASELINE.md#workflow-activation), [Host Confirmation Boundary](../../dist/BASELINE.md#host-confirmation-boundary), [Kernel Canary Routing and Authority](../../dist/imm-loop.md#kernel-canary-routing-and-authority)
25
+ - unattended batch run, or any question about whether `imm-loop` starts one: [Unattended Batch Opt-In](../../dist/imm-loop.md#unattended-batch-opt-in)
25
26
  - steady execution: [Verification and Local Recovery](../../dist/BASELINE.md#verification-and-local-recovery), [Execution Loop](../../dist/imm-loop.md#execution-loop), [Observable Output](../../dist/imm-loop.md#observable-output)
26
27
  - rework, scope expansion, breaking revision, user decision, stop, interruption or unknown state before any action: [Decisions and Recovery](../../dist/imm-loop.md#decisions-and-recovery), [Failure Output](../../dist/imm-loop.md#failure-output); re-read `status`, then the pending obligation
27
28
  - review or post-settlement learning: [Review and Learning](../../dist/imm-loop.md#review-and-learning)