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 +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 +58 -12
- 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/plugins/immune-brain/runtime/role_prompt_bridge.ts +20 -4
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.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
|
|
1007
|
-
|
|
1008
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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.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
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
137
|
+
throw new Error(`internal role prompt is not packaged: ${role}`);
|
|
122
138
|
}
|
|
123
139
|
|
|
124
140
|
export function buildRoleDelegationPacket(input: {
|