immune-brain 3.5.0 → 3.6.1
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 -3
- package/README.zh-CN.md +3 -3
- 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 +5 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +3 -96
- package/plugins/immune-brain/.pi-extension/pi-canary-interaction.ts +20 -5
- package/plugins/immune-brain/.pi-extension/pi-canary-native-review.ts +2 -2
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +141 -59
- package/plugins/immune-brain/dist/docs/reference/immune-brain-config.md +17 -8
- package/plugins/immune-brain/dist/imm-loop.md +143 -150
- package/plugins/immune-brain/dist/imm-planner.md +71 -77
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +58 -6
- package/plugins/immune-brain/runtime/assurance/qa.ts +85 -0
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +20 -51
- package/plugins/immune-brain/runtime/kernel/intent.ts +44 -3
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
|
@@ -4,7 +4,6 @@ import { execFileSync } from "node:child_process";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import {
|
|
6
6
|
AssuranceCoordinator,
|
|
7
|
-
snapshotDigest,
|
|
8
7
|
type AssuranceCoordinatorPorts,
|
|
9
8
|
type AssuranceSubmitReviewResult,
|
|
10
9
|
type AssuranceVerdict,
|
|
@@ -15,8 +14,6 @@ import {
|
|
|
15
14
|
assertRunnerCompatible,
|
|
16
15
|
findingsDigest,
|
|
17
16
|
resolveBunRunner,
|
|
18
|
-
runFixedVerification,
|
|
19
|
-
VerificationAbortedError,
|
|
20
17
|
type FrozenRunner,
|
|
21
18
|
type VerificationDescriptor,
|
|
22
19
|
} from "../assurance/verification";
|
|
@@ -29,7 +26,7 @@ import {
|
|
|
29
26
|
import { parseVerificationDescriptor } from "../verification_descriptor";
|
|
30
27
|
import { projectAssurance, type AssuranceProjectionResult } from "../kernel/assurance_projection";
|
|
31
28
|
import type { TaskRecord } from "../kernel/types";
|
|
32
|
-
import { readTaskRecord } from "../kernel/storage";
|
|
29
|
+
import { readTaskRecord, readTaskRecordRaw } from "../kernel/storage";
|
|
33
30
|
import { canonicalIntentHash, parseTaskIntentV1, readTaskIntent } from "../kernel/intent";
|
|
34
31
|
import { capabilityActionFor, createCanaryApplication } from "../kernel/canary_application";
|
|
35
32
|
import {
|
|
@@ -45,7 +42,7 @@ import {
|
|
|
45
42
|
import { enrollCanaryTask, runEnrollmentRehearsal } from "../kernel/enrollment";
|
|
46
43
|
import { reconcileKernelAuthority, repairKernelAuthority } from "../kernel/storage";
|
|
47
44
|
import { preparePiCanary, revalidatePiCanary } from "../kernel/pi_canary_prepare";
|
|
48
|
-
import {
|
|
45
|
+
import { runDeterministicQa } from "../assurance/qa";
|
|
49
46
|
import { taskDiffIdentity, taskRevisionIdentity } from "../workspace_scope";
|
|
50
47
|
import {
|
|
51
48
|
confirmationRef,
|
|
@@ -73,6 +70,19 @@ export function diffHashOf(root: string, record: TaskRecord): string {
|
|
|
73
70
|
return diffSnapshotOf(root, record).diff_hash;
|
|
74
71
|
}
|
|
75
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Read the TaskIntent through the TaskRecord's `intent_ref.path`.
|
|
75
|
+
*
|
|
76
|
+
* `freeze_artifacts` relocates the sidecar from `docs/plans/<task-id>.intent.json`
|
|
77
|
+
* into `docs/plans/archive/`, so every post-freeze read — QA settlement included —
|
|
78
|
+
* must follow the record instead of the pre-freeze default path. The Pi adapter
|
|
79
|
+
* resolves the same way in its own runtime stub; both Hosts must stay in step.
|
|
80
|
+
*/
|
|
81
|
+
function readTaskIntentForRecord(root: string, taskId: string) {
|
|
82
|
+
const currentPath = readTaskRecordRaw(root, taskId).record?.intent_ref?.path;
|
|
83
|
+
return readTaskIntent(root, taskId, currentPath);
|
|
84
|
+
}
|
|
85
|
+
|
|
76
86
|
function extractVerdictJson(input: unknown): Record<string, unknown> | null {
|
|
77
87
|
if (typeof input === "string") {
|
|
78
88
|
const cleaned = input.split("\n").map((line) => line.trim()).filter((line) => line.startsWith("{") && line.endsWith("}")).join("");
|
|
@@ -129,47 +139,6 @@ function assertProjectionBinding(before: AssuranceProjectionResult, after: Assur
|
|
|
129
139
|
}
|
|
130
140
|
}
|
|
131
141
|
|
|
132
|
-
export async function runDeterministicQa(
|
|
133
|
-
snapshot: SnapshotDescriptor,
|
|
134
|
-
descriptors: Map<string, VerificationDescriptor>,
|
|
135
|
-
runner: FrozenRunner,
|
|
136
|
-
options: { signal?: AbortSignal; onProgress?: (progress: { index: number; total: number; acceptance_id: string; phase: "running" | "passed" | "failed"; elapsed_ms: number }) => void } = {},
|
|
137
|
-
): Promise<AssuranceVerdict> {
|
|
138
|
-
if (snapshot.role !== "qa") throw new Error("deterministic QA requires qa role");
|
|
139
|
-
if (options.signal?.aborted) throw new VerificationAbortedError();
|
|
140
|
-
const findings: NonNullable<AssuranceVerdict["findings"]> = [];
|
|
141
|
-
for (const [offset, item] of snapshot.acceptance.entries()) {
|
|
142
|
-
if (options.signal?.aborted) throw new VerificationAbortedError();
|
|
143
|
-
const descriptor = descriptors.get(item.id);
|
|
144
|
-
if (!descriptor) throw new Error(`verification descriptor missing for ${item.id}`);
|
|
145
|
-
const startedAt = Date.now();
|
|
146
|
-
options.onProgress?.({ index: offset + 1, total: snapshot.acceptance.length, acceptance_id: item.id, phase: "running", elapsed_ms: 0 });
|
|
147
|
-
const result = await runFixedVerification(snapshot.root, descriptor, runner, { signal: options.signal });
|
|
148
|
-
const failed = result.exit_code !== 0 || result.timed_out;
|
|
149
|
-
options.onProgress?.({ index: offset + 1, total: snapshot.acceptance.length, acceptance_id: item.id, phase: failed ? "failed" : "passed", elapsed_ms: Date.now() - startedAt });
|
|
150
|
-
if (failed) {
|
|
151
|
-
findings.push({
|
|
152
|
-
id: qaFindingId(item.id, snapshotDigest(snapshot)),
|
|
153
|
-
kind: "blocking",
|
|
154
|
-
acceptance_id: item.id,
|
|
155
|
-
summary: `verification failed (exit ${result.exit_code}${result.timed_out ? ", timed out" : ""})`,
|
|
156
|
-
findings_digest: "",
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
if (findings.length > 0) {
|
|
161
|
-
return { contract: "assurance_kernel/assurance_verdict/v2", role: "qa", task_id: snapshot.task_id, snapshot_digest: snapshotDigest(snapshot), decision: "rework", findings };
|
|
162
|
-
}
|
|
163
|
-
return {
|
|
164
|
-
contract: "assurance_kernel/assurance_verdict/v2",
|
|
165
|
-
role: "qa",
|
|
166
|
-
task_id: snapshot.task_id,
|
|
167
|
-
snapshot_digest: snapshotDigest(snapshot),
|
|
168
|
-
decision: "pass",
|
|
169
|
-
approval: { kind: "qa", authority_role: "qa", summary: `all ${snapshot.acceptance.length} fixed verification descriptor(s) passed` },
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
|
|
173
142
|
function qaOutcomes(record: { attestations: Array<{ kind: string; acceptance_results: Array<{ acceptance_id: string; status: "passed" | "failed" | "blocked"; summary: string }> }> }) {
|
|
174
143
|
return Object.fromEntries(
|
|
175
144
|
record.attestations.filter((item) => item.kind === "qa").flatMap((item) => item.acceptance_results)
|
|
@@ -379,7 +348,7 @@ export class ClaudeRuntime {
|
|
|
379
348
|
host: this.host,
|
|
380
349
|
projectTask: (root, taskId) => projectAssurance(root, taskId, diffSnapshotOf),
|
|
381
350
|
readTaskRecord: (root, taskId) => readTaskRecord(root, taskId),
|
|
382
|
-
readTaskIntent: (root, taskId) =>
|
|
351
|
+
readTaskIntent: (root, taskId) => readTaskIntentForRecord(root, taskId),
|
|
383
352
|
frozenRunner: async () => resolveBunRunner(),
|
|
384
353
|
buildAssurance: (root, taskId, role, projection, runner) => buildAssuranceSnapshot(root, taskId, role, projection, runner),
|
|
385
354
|
ensureReviewRevision: async (root, taskId, projection) => {
|
|
@@ -442,7 +411,7 @@ export class ClaudeRuntime {
|
|
|
442
411
|
async enroll(taskId: string, meta: ToolMeta) {
|
|
443
412
|
const now = new Date().toISOString();
|
|
444
413
|
const preparation = await preparePiCanary(this.cwd, { task_id: taskId, now });
|
|
445
|
-
const intent = await
|
|
414
|
+
const intent = await readTaskIntentForRecord(this.cwd, taskId);
|
|
446
415
|
const gate = await this.gate("enroll", { ...meta, taskId }, {
|
|
447
416
|
risk: intent.intent.risk,
|
|
448
417
|
intentRevision: preparation.intent?.revision,
|
|
@@ -520,7 +489,7 @@ export class ClaudeRuntime {
|
|
|
520
489
|
throw new Error(readiness.blocked ?? "no unique host-derived authorization operation");
|
|
521
490
|
}
|
|
522
491
|
}
|
|
523
|
-
const priorIntent = await
|
|
492
|
+
const priorIntent = await readTaskIntentForRecord(this.cwd, taskId);
|
|
524
493
|
const now = new Date().toISOString();
|
|
525
494
|
const actorId = "user";
|
|
526
495
|
const nextIntent = extra.next_intent ? await parseTaskIntentV1(extra.next_intent) : undefined;
|
|
@@ -658,7 +627,7 @@ export class ClaudeRuntime {
|
|
|
658
627
|
},
|
|
659
628
|
): Promise<void> {
|
|
660
629
|
const { registry, app } = await this.authority();
|
|
661
|
-
const priorIntentToken = (await
|
|
630
|
+
const priorIntentToken = (await readTaskIntentForRecord(ctx.cwd, input.taskId)).token;
|
|
662
631
|
const now = new Date().toISOString();
|
|
663
632
|
const commitAndApply = async <T>(apply: () => Promise<T>): Promise<T> => {
|
|
664
633
|
this.coordinator.commitInvocation(input.invocation as never);
|
|
@@ -743,7 +712,7 @@ export class ClaudeRuntime {
|
|
|
743
712
|
const operation = input.operation.op === "revise_intent"
|
|
744
713
|
? { ...input.operation, next_intent: await parseTaskIntentV1(input.operation.next_intent) }
|
|
745
714
|
: input.operation;
|
|
746
|
-
const priorIntent = await
|
|
715
|
+
const priorIntent = await readTaskIntentForRecord(ctx.cwd, input.taskId);
|
|
747
716
|
const sidecar = join(ctx.cwd, priorIntent.intent_ref.path);
|
|
748
717
|
const priorBytes = operation.op === "revise_intent" ? readFileSync(sidecar) : null;
|
|
749
718
|
try {
|
|
@@ -43,6 +43,12 @@ export const INTENT_SIDECAR_RELATIVE_PREFIX = "docs/plans/";
|
|
|
43
43
|
export const RISK_FLOOR_SCOPE_PREFIXES = [
|
|
44
44
|
"plugins/immune-brain/runtime/kernel",
|
|
45
45
|
"plugins/immune-brain/runtime/authority_commit_receipts.ts",
|
|
46
|
+
"plugins/immune-brain/runtime/assurance",
|
|
47
|
+
"plugins/immune-brain/runtime/claude/interaction.ts",
|
|
48
|
+
"plugins/immune-brain/runtime/claude/capability.ts",
|
|
49
|
+
"plugins/immune-brain/runtime/claude/review_host.ts",
|
|
50
|
+
"plugins/immune-brain/runtime/claude/kernel_ports.ts",
|
|
51
|
+
"plugins/immune-brain/runtime/claude/mcp_server.ts",
|
|
46
52
|
"plugins/immune-brain/.pi-extension",
|
|
47
53
|
] as const;
|
|
48
54
|
|
|
@@ -50,8 +56,7 @@ export const RISK_FLOOR_SCOPE_PREFIXES = [
|
|
|
50
56
|
// separate from the glob-aware scope_hint policy above: the two inputs have
|
|
51
57
|
// different contracts and docs-only scope remains routine today.
|
|
52
58
|
export const CHANGED_PATH_RISK_FLOOR_PREFIXES = [
|
|
53
|
-
|
|
54
|
-
"plugins/immune-brain/.pi-extension",
|
|
59
|
+
...RISK_FLOOR_SCOPE_PREFIXES,
|
|
55
60
|
"docs/specs",
|
|
56
61
|
"docs/plans",
|
|
57
62
|
] as const;
|
|
@@ -458,6 +463,35 @@ function resolveCanonicalRoot(root: string): string {
|
|
|
458
463
|
return realpathSync(resolved);
|
|
459
464
|
}
|
|
460
465
|
|
|
466
|
+
// Resolve a path-less read to the sidecar that actually exists.
|
|
467
|
+
//
|
|
468
|
+
// The active path stays authoritative whenever it is present: that is the
|
|
469
|
+
// pre-freeze layout every path-less caller assumes, and a leftover archived
|
|
470
|
+
// sidecar from an earlier task reusing the same id must never shadow it. Only
|
|
471
|
+
// when the active path is gone — the post-`freeze_artifacts` layout — does the
|
|
472
|
+
// archive answer, which is exactly the case that used to fail with a raw ENOENT.
|
|
473
|
+
function resolveSidecarPath(
|
|
474
|
+
canonicalRoot: string,
|
|
475
|
+
activePath: string,
|
|
476
|
+
archivedPath: string,
|
|
477
|
+
): string {
|
|
478
|
+
if (sidecarPresent(canonicalRoot, activePath)) return activePath;
|
|
479
|
+
if (sidecarPresent(canonicalRoot, archivedPath)) return archivedPath;
|
|
480
|
+
return activePath;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Existence only. A symlink counts as present so `collectPathIdentities` still
|
|
484
|
+
// rejects it as a symlink; reporting it as missing would turn a security
|
|
485
|
+
// rejection into a lookup failure.
|
|
486
|
+
function sidecarPresent(canonicalRoot: string, relativePath: string): boolean {
|
|
487
|
+
try {
|
|
488
|
+
lstatSync(join(canonicalRoot, relativePath));
|
|
489
|
+
return true;
|
|
490
|
+
} catch {
|
|
491
|
+
return false;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
461
495
|
function collectPathIdentities(
|
|
462
496
|
canonicalRoot: string,
|
|
463
497
|
relativePath: string,
|
|
@@ -499,12 +533,19 @@ export function readTaskIntent(
|
|
|
499
533
|
const canonicalRoot = resolveCanonicalRoot(root);
|
|
500
534
|
const activePath = `${INTENT_SIDECAR_RELATIVE_PREFIX}${taskId}.intent.json`;
|
|
501
535
|
const archivedPath = `${INTENT_SIDECAR_RELATIVE_PREFIX}archive/${taskId}.intent.json`;
|
|
502
|
-
|
|
536
|
+
// `freeze_artifacts` relocates the sidecar from the active path to the archive
|
|
537
|
+
// path, so the caller's TaskRecord `intent_ref.path` is the authority. When no
|
|
538
|
+
// path is requested, resolve the single sidecar that exists rather than
|
|
539
|
+
// assuming the pre-freeze layout; a missing or ambiguous sidecar is a stable
|
|
540
|
+
// contract failure, not a raw `lstat` ENOENT.
|
|
541
|
+
const sidecarPath = requestedPath ?? resolveSidecarPath(canonicalRoot, activePath, archivedPath);
|
|
503
542
|
if (sidecarPath !== activePath && sidecarPath !== archivedPath)
|
|
504
543
|
throw new Error("intent sidecar path is not the active or archived task path");
|
|
505
544
|
const target = join(canonicalRoot, sidecarPath);
|
|
506
545
|
if (!target.startsWith(canonicalRoot + sep))
|
|
507
546
|
throw new Error("intent sidecar escapes project root");
|
|
547
|
+
if (!sidecarPresent(canonicalRoot, sidecarPath))
|
|
548
|
+
throw new Error(`TaskIntent sidecar is missing at ${sidecarPath}`);
|
|
508
549
|
|
|
509
550
|
const pathIdentities = collectPathIdentities(canonicalRoot, sidecarPath);
|
|
510
551
|
const fileIdentity = pathIdentities[pathIdentities.length - 1];
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by scripts/plugin_versioning.ts from the root package.json.
|
|
2
|
-
export const PLUGIN_VERSION = "3.
|
|
2
|
+
export const PLUGIN_VERSION = "3.6.1" as const;
|