immune-brain 3.6.0 → 3.6.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "immune-brain",
3
- "version": "3.6.0",
3
+ "version": "3.6.2",
4
4
  "description": "Immune-Brain agent skill system",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "immune-brain",
3
- "version": "3.6.0",
3
+ "version": "3.6.2",
4
4
  "description": "Immune-Brain Claude Code Host: native Enrollment, QA, Review, and Kernel settlement.",
5
5
  "author": {
6
6
  "name": "Immune-Brain Team"
@@ -381,7 +381,11 @@ async function executeForegroundEnrollment(
381
381
  taskIntent = await readTaskIntent(root, taskId);
382
382
  } catch (error) {
383
383
  const message = errorMessage(error);
384
- if (/not Git-tracked|ENOENT|no such file/i.test(message))
384
+ // `readTaskIntent` reports an absent sidecar semantically now, so the
385
+ // "missing" classification must recognise that wording as well as the raw
386
+ // filesystem errors; otherwise a missing file is misreported as a schema
387
+ // defect and the operator is told to repair fields that do not exist.
388
+ if (/not Git-tracked|ENOENT|no such file|sidecar is missing/i.test(message))
385
389
  return terminal(action, taskId, "blocked", stage, "A Git-tracked TaskIntent is required for Kernel enrollment", "author and stage the canonical TaskIntent");
386
390
  return terminal(action, taskId, "blocked", stage, `TaskIntent validation failed before rehearsal: ${message}`, "repair the reported TaskIntent schema errors");
387
391
  }
@@ -42,7 +42,7 @@ function probeHost(env = process.env, platform = process.platform, hostVersion)
42
42
  }
43
43
 
44
44
  // plugins/immune-brain/runtime/plugin_version.ts
45
- var PLUGIN_VERSION = "3.6.0";
45
+ var PLUGIN_VERSION = "3.6.2";
46
46
 
47
47
  // plugins/immune-brain/runtime/claude/interaction.ts
48
48
  import { createHash, randomUUID } from "node:crypto";
@@ -1001,13 +1001,20 @@ function roleSpec(role) {
1001
1001
  throw new Error(`unknown internal role: ${String(role)}`);
1002
1002
  return spec;
1003
1003
  }
1004
+ function rolePromptSearchDirs(moduleDir) {
1005
+ return [
1006
+ join3(moduleDir, "..", "dist", "role-prompts"),
1007
+ join3(moduleDir, "..", "role-prompts")
1008
+ ];
1009
+ }
1004
1010
  function loadRolePrompt(role) {
1005
1011
  const spec = roleSpec(role);
1006
- const path = join3(RUNTIME_DIR, "..", "dist", "role-prompts", spec.file);
1007
- if (!existsSync(path)) {
1008
- throw new Error(`internal role prompt is not packaged: ${role}`);
1012
+ for (const dir of rolePromptSearchDirs(RUNTIME_DIR)) {
1013
+ const path = join3(dir, spec.file);
1014
+ if (existsSync(path))
1015
+ return readFileSync(path, "utf8");
1009
1016
  }
1010
- return readFileSync(path, "utf8");
1017
+ throw new Error(`internal role prompt is not packaged: ${role}`);
1011
1018
  }
1012
1019
  function buildRoleDelegationPacket(input) {
1013
1020
  const spec = roleSpec(input.role);
@@ -1289,6 +1296,7 @@ class AssuranceCoordinator {
1289
1296
  this.rejectedReviewOperations.delete(taskId);
1290
1297
  let authorityCommitted = false;
1291
1298
  let authorityBoundaryStarted = false;
1299
+ let boundaryBaseline = null;
1292
1300
  let reviewPreparationStarted = false;
1293
1301
  let phase = "preparing";
1294
1302
  const operationLive = () => this.sessionActive && this.sessionGeneration === operationGeneration && this.activeOperations.get(taskId) === operationId && !operationController.signal.aborted;
@@ -1340,6 +1348,7 @@ class AssuranceCoordinator {
1340
1348
  if (aborted())
1341
1349
  return this.cancelled("qa", operationId, "host cancellation before artifact freeze");
1342
1350
  progress("freezing_artifacts", "Freezing planning artifacts for deterministic assurance");
1351
+ boundaryBaseline = projection.projection.record_revision;
1343
1352
  const freeze = this.ports.applyOrdinaryOperation(ctx, { taskId, operation: { op: "freeze_artifacts", actor_id: "executor" } });
1344
1353
  authorityBoundaryStarted = true;
1345
1354
  await freeze;
@@ -1349,13 +1358,17 @@ class AssuranceCoordinator {
1349
1358
  if (projection.error || projection.projection.lifecycle !== "active" || projection.projection.artifact_state !== "frozen")
1350
1359
  return this.unknownAfterCommit(taskId, "qa", operationId, projection.error ?? "artifact freeze did not settle");
1351
1360
  authorityBoundaryStarted = false;
1361
+ boundaryBaseline = null;
1352
1362
  }
1353
1363
  if (projection.projection.next_obligation === "complete") {
1354
1364
  progress("completing", "Completing the routine task after deterministic QA");
1365
+ const completionBaseline = projection.projection.record_revision;
1355
1366
  try {
1356
1367
  await this.ports.applyOrdinaryOperation(ctx, { taskId, operation: { op: "complete", actor_id: "kernel-assurance" } });
1357
1368
  return { state: "completed" };
1358
1369
  } catch (error) {
1370
+ if (await this.mutationProvablyRejected(ctx, taskId, completionBaseline))
1371
+ return { state: "failed", operation: "qa", operation_id: operationId, reason: `${phase}: ${boundedAssuranceError(error)}` };
1359
1372
  return this.unknownAfterCommit(taskId, "qa", operationId, boundedAssuranceError(error));
1360
1373
  }
1361
1374
  }
@@ -1527,8 +1540,12 @@ class AssuranceCoordinator {
1527
1540
  const reason = aborted() || error instanceof VerificationAbortedError ? `${phase}: host cancellation` : `${phase}: ${boundedAssuranceError(error)}`;
1528
1541
  return this.reviewPreparationFailed(taskId, operationId, reason);
1529
1542
  }
1530
- if (authorityCommitted || authorityBoundaryStarted)
1543
+ if (authorityCommitted || authorityBoundaryStarted) {
1544
+ const cancelling = aborted() || error instanceof VerificationAbortedError;
1545
+ if (!authorityCommitted && boundaryBaseline !== null && !cancelling && await this.mutationProvablyRejected(ctx, taskId, boundaryBaseline))
1546
+ return { state: "failed", operation: "qa", operation_id: operationId, reason: `${phase}: ${boundedAssuranceError(error)}` };
1531
1547
  return this.unknownAfterCommit(taskId, "qa", operationId, `${phase}: ${boundedAssuranceError(error)}`);
1548
+ }
1532
1549
  if (aborted() || error instanceof VerificationAbortedError)
1533
1550
  return this.cancelled("qa", operationId, `${phase}: host cancellation`);
1534
1551
  return { state: "failed", operation: "qa", operation_id: operationId, reason: `${phase}: ${boundedAssuranceError(error)}` };
@@ -1665,6 +1682,14 @@ class AssuranceCoordinator {
1665
1682
  this.rejectedReviewOperations.delete(taskId);
1666
1683
  return { state: "review_preparation_failed", operation: "review", operation_id: operationId, reason };
1667
1684
  }
1685
+ async mutationProvablyRejected(ctx, taskId, baselineRevision) {
1686
+ try {
1687
+ const fresh = await this.ports.projectTask(ctx.cwd, taskId);
1688
+ return !fresh.error && fresh.projection.record_revision === baselineRevision;
1689
+ } catch {
1690
+ return false;
1691
+ }
1692
+ }
1668
1693
  unknownAfterCommit(taskId, operation, operationId, reason) {
1669
1694
  this.unknownOperations.set(taskId, { operation, operationId, reason });
1670
1695
  return { state: "settlement_unknown", operation, operation_id: operationId, reason };
@@ -2916,6 +2941,21 @@ function resolveCanonicalRoot(root) {
2916
2941
  throw new Error("project root must be a real directory, not a symlink");
2917
2942
  return realpathSync4(resolved);
2918
2943
  }
2944
+ function resolveSidecarPath(canonicalRoot, activePath, archivedPath) {
2945
+ if (sidecarPresent(canonicalRoot, activePath))
2946
+ return activePath;
2947
+ if (sidecarPresent(canonicalRoot, archivedPath))
2948
+ return archivedPath;
2949
+ return activePath;
2950
+ }
2951
+ function sidecarPresent(canonicalRoot, relativePath) {
2952
+ try {
2953
+ lstatSync4(join6(canonicalRoot, relativePath));
2954
+ return true;
2955
+ } catch {
2956
+ return false;
2957
+ }
2958
+ }
2919
2959
  function collectPathIdentities(canonicalRoot, relativePath) {
2920
2960
  const identities = [];
2921
2961
  let current = canonicalRoot;
@@ -2943,12 +2983,14 @@ function readTaskIntent(root, taskId, requestedPath) {
2943
2983
  const canonicalRoot = resolveCanonicalRoot(root);
2944
2984
  const activePath = `${INTENT_SIDECAR_RELATIVE_PREFIX}${taskId}.intent.json`;
2945
2985
  const archivedPath = `${INTENT_SIDECAR_RELATIVE_PREFIX}archive/${taskId}.intent.json`;
2946
- const sidecarPath = requestedPath ?? activePath;
2986
+ const sidecarPath = requestedPath ?? resolveSidecarPath(canonicalRoot, activePath, archivedPath);
2947
2987
  if (sidecarPath !== activePath && sidecarPath !== archivedPath)
2948
2988
  throw new Error("intent sidecar path is not the active or archived task path");
2949
2989
  const target = join6(canonicalRoot, sidecarPath);
2950
2990
  if (!target.startsWith(canonicalRoot + sep3))
2951
2991
  throw new Error("intent sidecar escapes project root");
2992
+ if (!sidecarPresent(canonicalRoot, sidecarPath))
2993
+ throw new Error(`TaskIntent sidecar is missing at ${sidecarPath}`);
2952
2994
  const pathIdentities = collectPathIdentities(canonicalRoot, sidecarPath);
2953
2995
  const fileIdentity = pathIdentities[pathIdentities.length - 1];
2954
2996
  try {
@@ -6472,6 +6514,10 @@ function diffSnapshotOf(root, record) {
6472
6514
  function diffHashOf(root, record) {
6473
6515
  return diffSnapshotOf(root, record).diff_hash;
6474
6516
  }
6517
+ function readTaskIntentForRecord(root, taskId) {
6518
+ const currentPath = readTaskRecordRaw(root, taskId).record?.intent_ref?.path;
6519
+ return readTaskIntent(root, taskId, currentPath);
6520
+ }
6475
6521
  function extractVerdictJson(input) {
6476
6522
  if (typeof input === "string") {
6477
6523
  const cleaned = input.split(`
@@ -6674,7 +6720,7 @@ class ClaudeRuntime {
6674
6720
  host: this.host,
6675
6721
  projectTask: (root, taskId) => projectAssurance(root, taskId, diffSnapshotOf),
6676
6722
  readTaskRecord: (root, taskId) => readTaskRecord(root, taskId),
6677
- readTaskIntent: (root, taskId) => readTaskIntent(root, taskId),
6723
+ readTaskIntent: (root, taskId) => readTaskIntentForRecord(root, taskId),
6678
6724
  frozenRunner: async () => resolveBunRunner(),
6679
6725
  buildAssurance: (root, taskId, role, projection, runner) => buildAssuranceSnapshot(root, taskId, role, projection, runner),
6680
6726
  ensureReviewRevision: async (root, taskId, projection) => {
@@ -6735,7 +6781,7 @@ class ClaudeRuntime {
6735
6781
  async enroll(taskId, meta) {
6736
6782
  const now = new Date().toISOString();
6737
6783
  const preparation = await preparePiCanary(this.cwd, { task_id: taskId, now });
6738
- const intent = await readTaskIntent(this.cwd, taskId);
6784
+ const intent = await readTaskIntentForRecord(this.cwd, taskId);
6739
6785
  const gate = await this.gate("enroll", { ...meta, taskId }, {
6740
6786
  risk: intent.intent.risk,
6741
6787
  intentRevision: preparation.intent?.revision,
@@ -6810,7 +6856,7 @@ class ClaudeRuntime {
6810
6856
  throw new Error(readiness.blocked ?? "no unique host-derived authorization operation");
6811
6857
  }
6812
6858
  }
6813
- const priorIntent = await readTaskIntent(this.cwd, taskId);
6859
+ const priorIntent = await readTaskIntentForRecord(this.cwd, taskId);
6814
6860
  const now = new Date().toISOString();
6815
6861
  const actorId = "user";
6816
6862
  const nextIntent = extra.next_intent ? await parseTaskIntentV1(extra.next_intent) : undefined;
@@ -6937,7 +6983,7 @@ class ClaudeRuntime {
6937
6983
  }
6938
6984
  async applyVerdict(ctx, input) {
6939
6985
  const { registry, app } = await this.authority();
6940
- const priorIntentToken = (await readTaskIntent(ctx.cwd, input.taskId)).token;
6986
+ const priorIntentToken = (await readTaskIntentForRecord(ctx.cwd, input.taskId)).token;
6941
6987
  const now = new Date().toISOString();
6942
6988
  const commitAndApply = async (apply) => {
6943
6989
  this.coordinator.commitInvocation(input.invocation);
@@ -7019,7 +7065,7 @@ class ClaudeRuntime {
7019
7065
  async executeOrdinary(ctx, input) {
7020
7066
  const { app } = await this.authority();
7021
7067
  const operation = input.operation.op === "revise_intent" ? { ...input.operation, next_intent: await parseTaskIntentV1(input.operation.next_intent) } : input.operation;
7022
- const priorIntent = await readTaskIntent(ctx.cwd, input.taskId);
7068
+ const priorIntent = await readTaskIntentForRecord(ctx.cwd, input.taskId);
7023
7069
  const sidecar = join7(ctx.cwd, priorIntent.intent_ref.path);
7024
7070
  const priorBytes = operation.op === "revise_intent" ? readFileSync7(sidecar) : null;
7025
7071
  try {
@@ -15,6 +15,15 @@ Host's `imm_kernel_canary` `status` first and verify the exact active backend
15
15
  claim, TaskIntent, and TaskRecord. Invalid or contradictory projections fail
16
16
  closed. A candidate TaskIntent is not Enrollment authority.
17
17
 
18
+ Before the first Enrollment of a candidate TaskIntent, confirm the Planner
19
+ returned `tracker_associated` for its Initiative. `tracker_projection_failed` or
20
+ `awaiting_user_initiative_confirmation` blocks that Enrollment until the same
21
+ complete carrier batch succeeds; report the stable carrier reason and its exact
22
+ retry action instead of enrolling. A carrier command the Host refused, cancelled,
23
+ or never ran is not a completed batch, and a later `imm-loop` entry does not
24
+ clear it. This pre-Enrollment gate is distinct from the post-settlement tracker
25
+ projection below, which never blocks the Loop.
26
+
18
27
  TaskIntent defines the goal, acceptance, and `scope_hint`; TaskRecord and the
19
28
  Kernel projection own lifecycle, artifact state, freshness, and next obligation.
20
29
  Conversation memory, GitHub Issues, and `CONTEXT.md` never override them.
@@ -470,6 +470,10 @@ export class AssuranceCoordinator {
470
470
  this.rejectedReviewOperations.delete(taskId);
471
471
  let authorityCommitted = false;
472
472
  let authorityBoundaryStarted = false;
473
+ // Record revision observed immediately before an in-flight ordinary
474
+ // mutation, so a rejected precondition can be proven to have written
475
+ // nothing instead of being reported as an unknown settlement.
476
+ let boundaryBaseline: string | null = null;
473
477
  let reviewPreparationStarted = false;
474
478
  let phase = "preparing";
475
479
  const operationLive = () => this.sessionActive
@@ -519,6 +523,7 @@ export class AssuranceCoordinator {
519
523
  return { state: "blocked", reason: `Kernel requires ${projection.projection.next_obligation}` };
520
524
  if (aborted()) return this.cancelled("qa", operationId, "host cancellation before artifact freeze");
521
525
  progress("freezing_artifacts", "Freezing planning artifacts for deterministic assurance");
526
+ boundaryBaseline = projection.projection.record_revision;
522
527
  const freeze = this.ports.applyOrdinaryOperation(ctx, { taskId, operation: { op: "freeze_artifacts", actor_id: "executor" } });
523
528
  authorityBoundaryStarted = true;
524
529
  await freeze;
@@ -530,13 +535,17 @@ export class AssuranceCoordinator {
530
535
  // The freeze outcome is proven by the re-projection above, so later
531
536
  // read-only preparation failures are never a committed-authority mystery.
532
537
  authorityBoundaryStarted = false;
538
+ boundaryBaseline = null;
533
539
  }
534
540
  if (projection.projection.next_obligation === "complete") {
535
541
  progress("completing", "Completing the routine task after deterministic QA");
542
+ const completionBaseline = projection.projection.record_revision;
536
543
  try {
537
544
  await this.ports.applyOrdinaryOperation(ctx, { taskId, operation: { op: "complete", actor_id: "kernel-assurance" } });
538
545
  return { state: "completed" };
539
546
  } catch (error) {
547
+ if (await this.mutationProvablyRejected(ctx, taskId, completionBaseline))
548
+ return { state: "failed", operation: "qa", operation_id: operationId, reason: `${phase}: ${boundedAssuranceError(error)}` };
540
549
  return this.unknownAfterCommit(taskId, "qa", operationId, boundedAssuranceError(error));
541
550
  }
542
551
  }
@@ -701,7 +710,18 @@ export class AssuranceCoordinator {
701
710
  : `${phase}: ${boundedAssuranceError(error)}`;
702
711
  return this.reviewPreparationFailed(taskId, operationId, reason);
703
712
  }
704
- if (authorityCommitted || authorityBoundaryStarted) return this.unknownAfterCommit(taskId, "qa", operationId, `${phase}: ${boundedAssuranceError(error)}`);
713
+ if (authorityCommitted || authorityBoundaryStarted) {
714
+ // An in-flight mutation is genuinely unknown only when the authority
715
+ // state cannot be proven. A Kernel precondition rejection — a missing
716
+ // scope-bound Spec, for example — writes nothing, and reporting it as
717
+ // `settlement_unknown` sends the Loop into a reconciling retry that
718
+ // can never change the outcome.
719
+ const cancelling = aborted() || error instanceof VerificationAbortedError;
720
+ if (!authorityCommitted && boundaryBaseline !== null && !cancelling
721
+ && await this.mutationProvablyRejected(ctx, taskId, boundaryBaseline))
722
+ return { state: "failed", operation: "qa", operation_id: operationId, reason: `${phase}: ${boundedAssuranceError(error)}` };
723
+ return this.unknownAfterCommit(taskId, "qa", operationId, `${phase}: ${boundedAssuranceError(error)}`);
724
+ }
705
725
  if (aborted() || error instanceof VerificationAbortedError) return this.cancelled("qa", operationId, `${phase}: host cancellation`);
706
726
  return { state: "failed", operation: "qa", operation_id: operationId, reason: `${phase}: ${boundedAssuranceError(error)}` };
707
727
  } finally {
@@ -835,6 +855,24 @@ export class AssuranceCoordinator {
835
855
  return { state: "review_preparation_failed", operation: "review", operation_id: operationId, reason };
836
856
  }
837
857
 
858
+ /**
859
+ * Prove that a failed ordinary mutation wrote nothing.
860
+ *
861
+ * Re-reads the projection once and compares the record revision with the one
862
+ * observed immediately before the mutation. An unchanged revision means the
863
+ * Kernel rejected a precondition, which is a deterministic failure the caller
864
+ * must act on — not an unknown settlement to reconcile. Any read failure or
865
+ * drift stays unknown, so the conservative answer is the default.
866
+ */
867
+ private async mutationProvablyRejected(ctx: HostContext, taskId: string, baselineRevision: string): Promise<boolean> {
868
+ try {
869
+ const fresh = await this.ports.projectTask(ctx.cwd, taskId);
870
+ return !fresh.error && fresh.projection.record_revision === baselineRevision;
871
+ } catch {
872
+ return false;
873
+ }
874
+ }
875
+
838
876
  private unknownAfterCommit(taskId: string, operation: "qa" | "review", operationId: string, reason: string): AssuranceAdvanceResult {
839
877
  this.unknownOperations.set(taskId, { operation, operationId, reason });
840
878
  return { state: "settlement_unknown", operation, operation_id: operationId, reason };
@@ -26,7 +26,7 @@ import {
26
26
  import { parseVerificationDescriptor } from "../verification_descriptor";
27
27
  import { projectAssurance, type AssuranceProjectionResult } from "../kernel/assurance_projection";
28
28
  import type { TaskRecord } from "../kernel/types";
29
- import { readTaskRecord } from "../kernel/storage";
29
+ import { readTaskRecord, readTaskRecordRaw } from "../kernel/storage";
30
30
  import { canonicalIntentHash, parseTaskIntentV1, readTaskIntent } from "../kernel/intent";
31
31
  import { capabilityActionFor, createCanaryApplication } from "../kernel/canary_application";
32
32
  import {
@@ -70,6 +70,19 @@ export function diffHashOf(root: string, record: TaskRecord): string {
70
70
  return diffSnapshotOf(root, record).diff_hash;
71
71
  }
72
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
+
73
86
  function extractVerdictJson(input: unknown): Record<string, unknown> | null {
74
87
  if (typeof input === "string") {
75
88
  const cleaned = input.split("\n").map((line) => line.trim()).filter((line) => line.startsWith("{") && line.endsWith("}")).join("");
@@ -335,7 +348,7 @@ export class ClaudeRuntime {
335
348
  host: this.host,
336
349
  projectTask: (root, taskId) => projectAssurance(root, taskId, diffSnapshotOf),
337
350
  readTaskRecord: (root, taskId) => readTaskRecord(root, taskId),
338
- readTaskIntent: (root, taskId) => readTaskIntent(root, taskId),
351
+ readTaskIntent: (root, taskId) => readTaskIntentForRecord(root, taskId),
339
352
  frozenRunner: async () => resolveBunRunner(),
340
353
  buildAssurance: (root, taskId, role, projection, runner) => buildAssuranceSnapshot(root, taskId, role, projection, runner),
341
354
  ensureReviewRevision: async (root, taskId, projection) => {
@@ -398,7 +411,7 @@ export class ClaudeRuntime {
398
411
  async enroll(taskId: string, meta: ToolMeta) {
399
412
  const now = new Date().toISOString();
400
413
  const preparation = await preparePiCanary(this.cwd, { task_id: taskId, now });
401
- const intent = await readTaskIntent(this.cwd, taskId);
414
+ const intent = await readTaskIntentForRecord(this.cwd, taskId);
402
415
  const gate = await this.gate("enroll", { ...meta, taskId }, {
403
416
  risk: intent.intent.risk,
404
417
  intentRevision: preparation.intent?.revision,
@@ -476,7 +489,7 @@ export class ClaudeRuntime {
476
489
  throw new Error(readiness.blocked ?? "no unique host-derived authorization operation");
477
490
  }
478
491
  }
479
- const priorIntent = await readTaskIntent(this.cwd, taskId);
492
+ const priorIntent = await readTaskIntentForRecord(this.cwd, taskId);
480
493
  const now = new Date().toISOString();
481
494
  const actorId = "user";
482
495
  const nextIntent = extra.next_intent ? await parseTaskIntentV1(extra.next_intent) : undefined;
@@ -614,7 +627,7 @@ export class ClaudeRuntime {
614
627
  },
615
628
  ): Promise<void> {
616
629
  const { registry, app } = await this.authority();
617
- const priorIntentToken = (await readTaskIntent(ctx.cwd, input.taskId)).token;
630
+ const priorIntentToken = (await readTaskIntentForRecord(ctx.cwd, input.taskId)).token;
618
631
  const now = new Date().toISOString();
619
632
  const commitAndApply = async <T>(apply: () => Promise<T>): Promise<T> => {
620
633
  this.coordinator.commitInvocation(input.invocation as never);
@@ -699,7 +712,7 @@ export class ClaudeRuntime {
699
712
  const operation = input.operation.op === "revise_intent"
700
713
  ? { ...input.operation, next_intent: await parseTaskIntentV1(input.operation.next_intent) }
701
714
  : input.operation;
702
- const priorIntent = await readTaskIntent(ctx.cwd, input.taskId);
715
+ const priorIntent = await readTaskIntentForRecord(ctx.cwd, input.taskId);
703
716
  const sidecar = join(ctx.cwd, priorIntent.intent_ref.path);
704
717
  const priorBytes = operation.op === "revise_intent" ? readFileSync(sidecar) : null;
705
718
  try {
@@ -463,6 +463,35 @@ function resolveCanonicalRoot(root: string): string {
463
463
  return realpathSync(resolved);
464
464
  }
465
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
+
466
495
  function collectPathIdentities(
467
496
  canonicalRoot: string,
468
497
  relativePath: string,
@@ -504,12 +533,19 @@ export function readTaskIntent(
504
533
  const canonicalRoot = resolveCanonicalRoot(root);
505
534
  const activePath = `${INTENT_SIDECAR_RELATIVE_PREFIX}${taskId}.intent.json`;
506
535
  const archivedPath = `${INTENT_SIDECAR_RELATIVE_PREFIX}archive/${taskId}.intent.json`;
507
- 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);
508
542
  if (sidecarPath !== activePath && sidecarPath !== archivedPath)
509
543
  throw new Error("intent sidecar path is not the active or archived task path");
510
544
  const target = join(canonicalRoot, sidecarPath);
511
545
  if (!target.startsWith(canonicalRoot + sep))
512
546
  throw new Error("intent sidecar escapes project root");
547
+ if (!sidecarPresent(canonicalRoot, sidecarPath))
548
+ throw new Error(`TaskIntent sidecar is missing at ${sidecarPath}`);
513
549
 
514
550
  const pathIdentities = collectPathIdentities(canonicalRoot, sidecarPath);
515
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.6.0" as const;
2
+ export const PLUGIN_VERSION = "3.6.2" as const;
@@ -108,17 +108,33 @@ function roleSpec(role: InternalRole): RolePromptSpec {
108
108
  return spec;
109
109
  }
110
110
 
111
+ /**
112
+ * Directories that can hold the packaged role prompts, most specific first.
113
+ *
114
+ * From source this module sits in `runtime/`, a sibling of `dist/`. The Claude
115
+ * Code Host instead ships a bundle at `dist/claude/mcp-server.mjs`, where the
116
+ * same relative walk lands on a `dist/dist/` that never exists while the prompts
117
+ * sit one level up. Every test runs from source, so the shipped Host could not
118
+ * load a single internal role prompt and no test noticed.
119
+ */
120
+ export function rolePromptSearchDirs(moduleDir: string): string[] {
121
+ return [
122
+ join(moduleDir, "..", "dist", "role-prompts"),
123
+ join(moduleDir, "..", "role-prompts"),
124
+ ];
125
+ }
126
+
111
127
  /**
112
128
  * Read the packaged prompt so the runtime follows the bytes shipped to a
113
129
  * consumer. The canonical source is synced into this dist-local directory.
114
130
  */
115
131
  export function loadRolePrompt(role: InternalRole): string {
116
132
  const spec = roleSpec(role);
117
- const path = join(RUNTIME_DIR, "..", "dist", "role-prompts", spec.file);
118
- if (!existsSync(path)) {
119
- throw new Error(`internal role prompt is not packaged: ${role}`);
133
+ for (const dir of rolePromptSearchDirs(RUNTIME_DIR)) {
134
+ const path = join(dir, spec.file);
135
+ if (existsSync(path)) return readFileSync(path, "utf8");
120
136
  }
121
- return readFileSync(path, "utf8");
137
+ throw new Error(`internal role prompt is not packaged: ${role}`);
122
138
  }
123
139
 
124
140
  export function buildRoleDelegationPacket(input: {