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 +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/dist/claude/mcp-server.mjs +47 -8
- package/plugins/immune-brain/dist/imm-loop.md +9 -0
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +39 -1
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +19 -6
- package/plugins/immune-brain/runtime/kernel/intent.ts +37 -1
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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) =>
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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) =>
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
2
|
+
export const PLUGIN_VERSION = "3.6.1" as const;
|