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.
@@ -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 { qaFindingId } from "../assurance/qa_findings";
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) => 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 readTaskIntent(this.cwd, taskId);
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 readTaskIntent(this.cwd, taskId);
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 readTaskIntent(ctx.cwd, input.taskId)).token;
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 readTaskIntent(ctx.cwd, input.taskId);
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
- "plugins/immune-brain/runtime/kernel",
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
- const sidecarPath = requestedPath ?? activePath;
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.5.0" as const;
2
+ export const PLUGIN_VERSION = "3.6.1" as const;