immune-brain 3.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "immune-brain",
3
- "version": "3.6.0",
3
+ "version": "3.6.1",
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.1",
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.1";
46
46
 
47
47
  // plugins/immune-brain/runtime/claude/interaction.ts
48
48
  import { createHash, randomUUID } from "node:crypto";
@@ -1289,6 +1289,7 @@ class AssuranceCoordinator {
1289
1289
  this.rejectedReviewOperations.delete(taskId);
1290
1290
  let authorityCommitted = false;
1291
1291
  let authorityBoundaryStarted = false;
1292
+ let boundaryBaseline = null;
1292
1293
  let reviewPreparationStarted = false;
1293
1294
  let phase = "preparing";
1294
1295
  const operationLive = () => this.sessionActive && this.sessionGeneration === operationGeneration && this.activeOperations.get(taskId) === operationId && !operationController.signal.aborted;
@@ -1340,6 +1341,7 @@ class AssuranceCoordinator {
1340
1341
  if (aborted())
1341
1342
  return this.cancelled("qa", operationId, "host cancellation before artifact freeze");
1342
1343
  progress("freezing_artifacts", "Freezing planning artifacts for deterministic assurance");
1344
+ boundaryBaseline = projection.projection.record_revision;
1343
1345
  const freeze = this.ports.applyOrdinaryOperation(ctx, { taskId, operation: { op: "freeze_artifacts", actor_id: "executor" } });
1344
1346
  authorityBoundaryStarted = true;
1345
1347
  await freeze;
@@ -1349,13 +1351,17 @@ class AssuranceCoordinator {
1349
1351
  if (projection.error || projection.projection.lifecycle !== "active" || projection.projection.artifact_state !== "frozen")
1350
1352
  return this.unknownAfterCommit(taskId, "qa", operationId, projection.error ?? "artifact freeze did not settle");
1351
1353
  authorityBoundaryStarted = false;
1354
+ boundaryBaseline = null;
1352
1355
  }
1353
1356
  if (projection.projection.next_obligation === "complete") {
1354
1357
  progress("completing", "Completing the routine task after deterministic QA");
1358
+ const completionBaseline = projection.projection.record_revision;
1355
1359
  try {
1356
1360
  await this.ports.applyOrdinaryOperation(ctx, { taskId, operation: { op: "complete", actor_id: "kernel-assurance" } });
1357
1361
  return { state: "completed" };
1358
1362
  } catch (error) {
1363
+ if (await this.mutationProvablyRejected(ctx, taskId, completionBaseline))
1364
+ return { state: "failed", operation: "qa", operation_id: operationId, reason: `${phase}: ${boundedAssuranceError(error)}` };
1359
1365
  return this.unknownAfterCommit(taskId, "qa", operationId, boundedAssuranceError(error));
1360
1366
  }
1361
1367
  }
@@ -1527,8 +1533,12 @@ class AssuranceCoordinator {
1527
1533
  const reason = aborted() || error instanceof VerificationAbortedError ? `${phase}: host cancellation` : `${phase}: ${boundedAssuranceError(error)}`;
1528
1534
  return this.reviewPreparationFailed(taskId, operationId, reason);
1529
1535
  }
1530
- if (authorityCommitted || authorityBoundaryStarted)
1536
+ if (authorityCommitted || authorityBoundaryStarted) {
1537
+ const cancelling = aborted() || error instanceof VerificationAbortedError;
1538
+ if (!authorityCommitted && boundaryBaseline !== null && !cancelling && await this.mutationProvablyRejected(ctx, taskId, boundaryBaseline))
1539
+ return { state: "failed", operation: "qa", operation_id: operationId, reason: `${phase}: ${boundedAssuranceError(error)}` };
1531
1540
  return this.unknownAfterCommit(taskId, "qa", operationId, `${phase}: ${boundedAssuranceError(error)}`);
1541
+ }
1532
1542
  if (aborted() || error instanceof VerificationAbortedError)
1533
1543
  return this.cancelled("qa", operationId, `${phase}: host cancellation`);
1534
1544
  return { state: "failed", operation: "qa", operation_id: operationId, reason: `${phase}: ${boundedAssuranceError(error)}` };
@@ -1665,6 +1675,14 @@ class AssuranceCoordinator {
1665
1675
  this.rejectedReviewOperations.delete(taskId);
1666
1676
  return { state: "review_preparation_failed", operation: "review", operation_id: operationId, reason };
1667
1677
  }
1678
+ async mutationProvablyRejected(ctx, taskId, baselineRevision) {
1679
+ try {
1680
+ const fresh = await this.ports.projectTask(ctx.cwd, taskId);
1681
+ return !fresh.error && fresh.projection.record_revision === baselineRevision;
1682
+ } catch {
1683
+ return false;
1684
+ }
1685
+ }
1668
1686
  unknownAfterCommit(taskId, operation, operationId, reason) {
1669
1687
  this.unknownOperations.set(taskId, { operation, operationId, reason });
1670
1688
  return { state: "settlement_unknown", operation, operation_id: operationId, reason };
@@ -2916,6 +2934,21 @@ function resolveCanonicalRoot(root) {
2916
2934
  throw new Error("project root must be a real directory, not a symlink");
2917
2935
  return realpathSync4(resolved);
2918
2936
  }
2937
+ function resolveSidecarPath(canonicalRoot, activePath, archivedPath) {
2938
+ if (sidecarPresent(canonicalRoot, activePath))
2939
+ return activePath;
2940
+ if (sidecarPresent(canonicalRoot, archivedPath))
2941
+ return archivedPath;
2942
+ return activePath;
2943
+ }
2944
+ function sidecarPresent(canonicalRoot, relativePath) {
2945
+ try {
2946
+ lstatSync4(join6(canonicalRoot, relativePath));
2947
+ return true;
2948
+ } catch {
2949
+ return false;
2950
+ }
2951
+ }
2919
2952
  function collectPathIdentities(canonicalRoot, relativePath) {
2920
2953
  const identities = [];
2921
2954
  let current = canonicalRoot;
@@ -2943,12 +2976,14 @@ function readTaskIntent(root, taskId, requestedPath) {
2943
2976
  const canonicalRoot = resolveCanonicalRoot(root);
2944
2977
  const activePath = `${INTENT_SIDECAR_RELATIVE_PREFIX}${taskId}.intent.json`;
2945
2978
  const archivedPath = `${INTENT_SIDECAR_RELATIVE_PREFIX}archive/${taskId}.intent.json`;
2946
- const sidecarPath = requestedPath ?? activePath;
2979
+ const sidecarPath = requestedPath ?? resolveSidecarPath(canonicalRoot, activePath, archivedPath);
2947
2980
  if (sidecarPath !== activePath && sidecarPath !== archivedPath)
2948
2981
  throw new Error("intent sidecar path is not the active or archived task path");
2949
2982
  const target = join6(canonicalRoot, sidecarPath);
2950
2983
  if (!target.startsWith(canonicalRoot + sep3))
2951
2984
  throw new Error("intent sidecar escapes project root");
2985
+ if (!sidecarPresent(canonicalRoot, sidecarPath))
2986
+ throw new Error(`TaskIntent sidecar is missing at ${sidecarPath}`);
2952
2987
  const pathIdentities = collectPathIdentities(canonicalRoot, sidecarPath);
2953
2988
  const fileIdentity = pathIdentities[pathIdentities.length - 1];
2954
2989
  try {
@@ -6472,6 +6507,10 @@ function diffSnapshotOf(root, record) {
6472
6507
  function diffHashOf(root, record) {
6473
6508
  return diffSnapshotOf(root, record).diff_hash;
6474
6509
  }
6510
+ function readTaskIntentForRecord(root, taskId) {
6511
+ const currentPath = readTaskRecordRaw(root, taskId).record?.intent_ref?.path;
6512
+ return readTaskIntent(root, taskId, currentPath);
6513
+ }
6475
6514
  function extractVerdictJson(input) {
6476
6515
  if (typeof input === "string") {
6477
6516
  const cleaned = input.split(`
@@ -6674,7 +6713,7 @@ class ClaudeRuntime {
6674
6713
  host: this.host,
6675
6714
  projectTask: (root, taskId) => projectAssurance(root, taskId, diffSnapshotOf),
6676
6715
  readTaskRecord: (root, taskId) => readTaskRecord(root, taskId),
6677
- readTaskIntent: (root, taskId) => readTaskIntent(root, taskId),
6716
+ readTaskIntent: (root, taskId) => readTaskIntentForRecord(root, taskId),
6678
6717
  frozenRunner: async () => resolveBunRunner(),
6679
6718
  buildAssurance: (root, taskId, role, projection, runner) => buildAssuranceSnapshot(root, taskId, role, projection, runner),
6680
6719
  ensureReviewRevision: async (root, taskId, projection) => {
@@ -6735,7 +6774,7 @@ class ClaudeRuntime {
6735
6774
  async enroll(taskId, meta) {
6736
6775
  const now = new Date().toISOString();
6737
6776
  const preparation = await preparePiCanary(this.cwd, { task_id: taskId, now });
6738
- const intent = await readTaskIntent(this.cwd, taskId);
6777
+ const intent = await readTaskIntentForRecord(this.cwd, taskId);
6739
6778
  const gate = await this.gate("enroll", { ...meta, taskId }, {
6740
6779
  risk: intent.intent.risk,
6741
6780
  intentRevision: preparation.intent?.revision,
@@ -6810,7 +6849,7 @@ class ClaudeRuntime {
6810
6849
  throw new Error(readiness.blocked ?? "no unique host-derived authorization operation");
6811
6850
  }
6812
6851
  }
6813
- const priorIntent = await readTaskIntent(this.cwd, taskId);
6852
+ const priorIntent = await readTaskIntentForRecord(this.cwd, taskId);
6814
6853
  const now = new Date().toISOString();
6815
6854
  const actorId = "user";
6816
6855
  const nextIntent = extra.next_intent ? await parseTaskIntentV1(extra.next_intent) : undefined;
@@ -6937,7 +6976,7 @@ class ClaudeRuntime {
6937
6976
  }
6938
6977
  async applyVerdict(ctx, input) {
6939
6978
  const { registry, app } = await this.authority();
6940
- const priorIntentToken = (await readTaskIntent(ctx.cwd, input.taskId)).token;
6979
+ const priorIntentToken = (await readTaskIntentForRecord(ctx.cwd, input.taskId)).token;
6941
6980
  const now = new Date().toISOString();
6942
6981
  const commitAndApply = async (apply) => {
6943
6982
  this.coordinator.commitInvocation(input.invocation);
@@ -7019,7 +7058,7 @@ class ClaudeRuntime {
7019
7058
  async executeOrdinary(ctx, input) {
7020
7059
  const { app } = await this.authority();
7021
7060
  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);
7061
+ const priorIntent = await readTaskIntentForRecord(ctx.cwd, input.taskId);
7023
7062
  const sidecar = join7(ctx.cwd, priorIntent.intent_ref.path);
7024
7063
  const priorBytes = operation.op === "revise_intent" ? readFileSync7(sidecar) : null;
7025
7064
  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.1" as const;