immune-brain 5.1.1 → 5.2.0
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/README.md +13 -6
- package/README.zh-CN.md +12 -5
- 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 +14 -2
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +93 -0
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +81 -13
- package/plugins/immune-brain/agents/immune-brain-reviewer.md +20 -0
- package/plugins/immune-brain/dist/BASELINE.md +1 -1
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +8573 -7449
- package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +1 -1
- package/plugins/immune-brain/dist/imm-brainstorm.md +6 -6
- package/plugins/immune-brain/dist/imm-doc-prune.md +2 -1
- package/plugins/immune-brain/dist/imm-planner.md +63 -37
- package/plugins/immune-brain/dist/imm-run.md +137 -12
- package/plugins/immune-brain/dist/imm-test-prune.md +233 -0
- package/plugins/immune-brain/dist/registry.yaml +9 -0
- package/plugins/immune-brain/dist/role-prompts/code-review.md +20 -0
- package/plugins/immune-brain/dist/role-prompts/executor.md +15 -0
- package/plugins/immune-brain/hooks/hooks.json +31 -1
- package/plugins/immune-brain/runtime/assurance/active_task_notice.ts +42 -0
- package/plugins/immune-brain/runtime/assurance/alias_file.ts +24 -0
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +9 -0
- package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +4 -1
- package/plugins/immune-brain/runtime/assurance/phase_entry.ts +143 -0
- package/plugins/immune-brain/runtime/claude/interaction.ts +10 -1
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +32 -6
- package/plugins/immune-brain/runtime/claude/lane_guard.ts +210 -0
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +85 -7
- package/plugins/immune-brain/runtime/claude/review_host.ts +6 -6
- package/plugins/immune-brain/runtime/commands/kernel.ts +54 -1
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +235 -7
- package/plugins/immune-brain/runtime/kernel/runtime_contracts.json +7 -0
- package/plugins/immune-brain/runtime/kernel/runtime_contracts.ts +75 -0
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +20 -0
- package/plugins/immune-brain/runtime/prompts/executor.md +15 -0
- package/plugins/immune-brain/runtime/unattended/batch_delegation.ts +160 -0
- package/plugins/immune-brain/runtime/unattended/batch_final_verification.ts +72 -0
- package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +396 -23
- package/plugins/immune-brain/runtime/unattended/batch_leak_restore.ts +131 -0
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +25 -4
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +3 -2
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +42 -0
- package/plugins/immune-brain/runtime/unattended/batch_schedule.ts +40 -0
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +155 -0
- package/plugins/immune-brain/runtime/unattended/lane_workspace.ts +58 -0
- package/plugins/immune-brain/skills/BASELINE.md +1 -1
- package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +3 -2
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +8 -6
- package/plugins/immune-brain/skills/imm-run/SKILL.md +2 -1
- package/plugins/immune-brain/skills/imm-test-prune/SKILL.md +28 -0
- package/plugins/immune-brain/skills/registry.yaml +9 -0
|
@@ -55,9 +55,11 @@ import {
|
|
|
55
55
|
startBatch,
|
|
56
56
|
type BatchRunnerKernelPort,
|
|
57
57
|
type BatchRunReport,
|
|
58
|
+
type BatchTrackerPort,
|
|
58
59
|
} from "../unattended/batch_runner";
|
|
59
60
|
import { createBatchKernelPort } from "../unattended/batch_kernel_port";
|
|
60
|
-
import type {
|
|
61
|
+
import type { LaneRevisionRequest } from "../unattended/batch_delegation";
|
|
62
|
+
import { createBatchTrackerPort, laneRuntimeContractRefusal, type LaneInstructionRequest, type LaneOffer } from "../unattended/batch_lanes";
|
|
61
63
|
import { retireStaleBatch } from "../unattended/batch_disposition";
|
|
62
64
|
import type { BatchLaneRunReport } from "../unattended/batch_state";
|
|
63
65
|
import {
|
|
@@ -231,6 +233,7 @@ export interface ClaudeRuntimeOptions {
|
|
|
231
233
|
requestConfirmation?: NativeConfirmationPort;
|
|
232
234
|
batchKernel?: Partial<BatchRunnerKernelPort>;
|
|
233
235
|
batchGit?: BatchRunnerGitPort;
|
|
236
|
+
batchTracker?: BatchTrackerPort;
|
|
234
237
|
readInitiative?: InitiativeObservationReader;
|
|
235
238
|
}
|
|
236
239
|
|
|
@@ -268,6 +271,7 @@ export class ClaudeRuntime {
|
|
|
268
271
|
private app: ReturnType<typeof createCanaryApplication> | null = null;
|
|
269
272
|
private readonly batchKernel?: Partial<BatchRunnerKernelPort>;
|
|
270
273
|
private readonly batchGit?: BatchRunnerGitPort;
|
|
274
|
+
private readonly batchTracker?: BatchTrackerPort;
|
|
271
275
|
private readonly readInitiative?: InitiativeObservationReader;
|
|
272
276
|
|
|
273
277
|
constructor(options: ClaudeRuntimeOptions) {
|
|
@@ -277,6 +281,7 @@ export class ClaudeRuntime {
|
|
|
277
281
|
this.requestConfirmation = options.requestConfirmation;
|
|
278
282
|
this.batchKernel = options.batchKernel;
|
|
279
283
|
this.batchGit = options.batchGit;
|
|
284
|
+
this.batchTracker = options.batchTracker;
|
|
280
285
|
this.readInitiative = options.readInitiative;
|
|
281
286
|
this.host = options.host ?? new ClaudeReviewHost(new FileHookEventLog());
|
|
282
287
|
this.coordinator = new AssuranceCoordinator({
|
|
@@ -741,7 +746,7 @@ export class ClaudeRuntime {
|
|
|
741
746
|
async startUnattendedBatch(
|
|
742
747
|
initiativeSlug: string,
|
|
743
748
|
meta: ToolMeta,
|
|
744
|
-
options: { reuseOnly?: boolean; max_parallel?: number; lane_offers?: LaneOffer[] } = {},
|
|
749
|
+
options: { reuseOnly?: boolean; max_parallel?: number; lane_offers?: LaneOffer[]; final_verification?: string[]; lane_instruction?: LaneInstructionRequest; lane_revision?: LaneRevisionRequest } = {},
|
|
745
750
|
): Promise<ClaudeBatchStartResult> {
|
|
746
751
|
throwIfCancelled(meta.signal);
|
|
747
752
|
const reuseOnly = options.reuseOnly === true;
|
|
@@ -779,6 +784,12 @@ export class ClaudeRuntime {
|
|
|
779
784
|
const budget = preflight.projection.budget;
|
|
780
785
|
const planDigest = preflight.projection.plan_digest;
|
|
781
786
|
const recoveryChildren = preflight.projection.recovery_children;
|
|
787
|
+
// A new lane batch whose Lane runtime cannot read the coordinator's Kernel
|
|
788
|
+
// contracts is refused before the gate opens; a match adds nothing.
|
|
789
|
+
if (options.max_parallel !== undefined && !isResuming) {
|
|
790
|
+
const contractRefusal = laneRuntimeContractRefusal(this.cwd);
|
|
791
|
+
if (contractRefusal) return batchReason("batch_run_rejected", contractRefusal);
|
|
792
|
+
}
|
|
782
793
|
// 5. Literal-user gate plus the shared reuse decision, the post-gate
|
|
783
794
|
// claim/drift cascade, and the Batch Authorization binding. The Host supplies
|
|
784
795
|
// only its gate, its confirmation reference, and its binding nonce.
|
|
@@ -803,7 +814,7 @@ export class ClaudeRuntime {
|
|
|
803
814
|
};
|
|
804
815
|
// The gate settles only on the literal user's answer or the caller's
|
|
805
816
|
// cancellation signal.
|
|
806
|
-
let confirmationResult: { decision: NativeDecision; requestId: string };
|
|
817
|
+
let confirmationResult: { decision: NativeDecision; requestId: string; delegateRevisions?: boolean };
|
|
807
818
|
try {
|
|
808
819
|
confirmationResult = await this.requestConfirmation!({
|
|
809
820
|
operation: "start_unattended_batch",
|
|
@@ -830,6 +841,8 @@ export class ClaudeRuntime {
|
|
|
830
841
|
max_parallel: options.max_parallel,
|
|
831
842
|
parallel_groups: facts.parallel_groups,
|
|
832
843
|
serialized: facts.scope_conflicts,
|
|
844
|
+
...(options.final_verification ? { final_verification: options.final_verification } : {}),
|
|
845
|
+
offer_revision_delegation: true,
|
|
833
846
|
},
|
|
834
847
|
}
|
|
835
848
|
: {}),
|
|
@@ -874,7 +887,11 @@ export class ClaudeRuntime {
|
|
|
874
887
|
return { kind: "host_rejection", value: batchReason("confirmation_cancelled") };
|
|
875
888
|
if (confirmationResult.decision !== "accept")
|
|
876
889
|
return { kind: "host_rejection", value: batchReason("confirmation_no_decision") };
|
|
877
|
-
return {
|
|
890
|
+
return {
|
|
891
|
+
kind: "confirmed",
|
|
892
|
+
request_id: confirmationResult.requestId,
|
|
893
|
+
...(options.max_parallel !== undefined && confirmationResult.delegateRevisions ? { delegate_revisions: true } : {}),
|
|
894
|
+
};
|
|
878
895
|
},
|
|
879
896
|
// A reused authorization keeps this invocation's Kernel binding without a new
|
|
880
897
|
// literal-user act, so its confirmation reference names the resumed batch
|
|
@@ -937,7 +954,16 @@ export class ClaudeRuntime {
|
|
|
937
954
|
now,
|
|
938
955
|
kernel: kernelPort,
|
|
939
956
|
git: this.batchGit,
|
|
940
|
-
...(options.max_parallel !== undefined
|
|
957
|
+
...(options.max_parallel !== undefined
|
|
958
|
+
? {
|
|
959
|
+
max_parallel: options.max_parallel,
|
|
960
|
+
tracker: this.batchTracker ?? createBatchTrackerPort(this.cwd, initiativeSlug),
|
|
961
|
+
...(options.final_verification ? { final_verification: options.final_verification } : {}),
|
|
962
|
+
...(options.lane_instruction ? { lane_instruction: options.lane_instruction } : {}),
|
|
963
|
+
...(options.lane_revision ? { lane_revision: options.lane_revision } : {}),
|
|
964
|
+
...(authorization.revision_delegation !== undefined ? { revision_delegation: authorization.revision_delegation } : {}),
|
|
965
|
+
}
|
|
966
|
+
: {}),
|
|
941
967
|
...(options.lane_offers !== undefined ? { lane_offers: options.lane_offers } : {}),
|
|
942
968
|
});
|
|
943
969
|
|
|
@@ -969,7 +995,7 @@ export class ClaudeRuntime {
|
|
|
969
995
|
initiative_slug: initiativeSlug,
|
|
970
996
|
now: new Date().toISOString(),
|
|
971
997
|
gate: async (facts) => {
|
|
972
|
-
let confirmationResult: { decision: NativeDecision; requestId: string };
|
|
998
|
+
let confirmationResult: { decision: NativeDecision; requestId: string; delegateRevisions?: boolean };
|
|
973
999
|
try {
|
|
974
1000
|
confirmationResult = await this.requestConfirmation!({
|
|
975
1001
|
operation: "retire_stale_batch",
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Claude Code hook decisions that are not Review observation. PreToolUse guard for batch Lanes: an Edit, Write, MultiEdit or
|
|
2
|
+
// NotebookEdit whose target resolves outside the Lane is denied before it
|
|
3
|
+
// runs, the same rule the Pi extension applies to `edit`/`write`. Bash is not
|
|
4
|
+
// intercepted. Outside a Lane the hook answers nothing and the tool runs as
|
|
5
|
+
// configured.
|
|
6
|
+
//
|
|
7
|
+
// Phase entry gate: UserPromptSubmit records which literal prompt entered
|
|
8
|
+
// planning or execution; PreToolUse denies `enroll`, `start_unattended_batch`
|
|
9
|
+
// and the three phase Skills without a matching entry, and otherwise leaves an
|
|
10
|
+
// admission the tool handler consumes by tool-use id.
|
|
11
|
+
import { lstatSync, rmSync } from "node:fs";
|
|
12
|
+
import { homedir, tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { laneWriteRefusal } from "../unattended/lane_workspace";
|
|
15
|
+
import { activeTaskNotice } from "../assurance/active_task_notice";
|
|
16
|
+
import { readAliasFile } from "../assurance/alias_file";
|
|
17
|
+
import {
|
|
18
|
+
admitPhaseOperation,
|
|
19
|
+
NO_PHASE_ENTRY,
|
|
20
|
+
packagedPhaseSkill,
|
|
21
|
+
phaseEntryLogLine,
|
|
22
|
+
phaseRefusalMessage,
|
|
23
|
+
PHASE_ENTRY_RECOVERY,
|
|
24
|
+
recognizePhaseEntry,
|
|
25
|
+
reducePhaseEntryLog,
|
|
26
|
+
RUN_ENTRY_REQUIRED,
|
|
27
|
+
type AliasReader,
|
|
28
|
+
type PhaseEntryState,
|
|
29
|
+
type PhaseOperation,
|
|
30
|
+
} from "../assurance/phase_entry";
|
|
31
|
+
import { readActiveClaimTaskId } from "../unattended/batch_preflight";
|
|
32
|
+
import { appendPrivate, cacheDir, ensurePrivateDir, ownedByUs, readPrivate, sessionHash } from "./review_host";
|
|
33
|
+
|
|
34
|
+
const FILE_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
35
|
+
const GATED_MCP_TOOL = /^mcp__.*immune-brain.*__(enroll|start_unattended_batch)$/;
|
|
36
|
+
|
|
37
|
+
export type GatedTool = "enroll" | "start_unattended_batch";
|
|
38
|
+
|
|
39
|
+
/** Where the phase entry gate keeps its private files and finds user aliases. */
|
|
40
|
+
export interface PhaseEntryLocations {
|
|
41
|
+
/** Parent of the private cache directory. Defaults to the OS temp directory. */
|
|
42
|
+
root?: string;
|
|
43
|
+
/** The user's home, for `~/.claude/commands`. */
|
|
44
|
+
home?: string;
|
|
45
|
+
/** The private store's append and read. Default to the real file operations. */
|
|
46
|
+
append?: typeof appendPrivate;
|
|
47
|
+
read?: typeof readPrivate;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function denyPreToolUse(reason: string): string {
|
|
51
|
+
return JSON.stringify({
|
|
52
|
+
hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The hook's stdout for one PreToolUse payload, or null to stay silent. */
|
|
57
|
+
export function laneGuardHookOutput(payload: Record<string, unknown>): string | null {
|
|
58
|
+
if (payload.hook_event_name !== "PreToolUse" || !FILE_TOOLS.has(String(payload.tool_name))) return null;
|
|
59
|
+
const input = (payload.tool_input ?? {}) as { file_path?: unknown; notebook_path?: unknown };
|
|
60
|
+
const cwd = typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd();
|
|
61
|
+
const refusal = laneWriteRefusal(cwd, input.file_path ?? input.notebook_path);
|
|
62
|
+
if (!refusal) return null;
|
|
63
|
+
return denyPreToolUse(refusal);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* UserPromptSubmit: when the workspace has an active Managed task, add the
|
|
68
|
+
* advisory notice as context. It never resumes the task.
|
|
69
|
+
*/
|
|
70
|
+
export async function activeTaskHookOutput(payload: Record<string, unknown>): Promise<string | null> {
|
|
71
|
+
if (payload.hook_event_name !== "UserPromptSubmit") return null;
|
|
72
|
+
const cwd = typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd();
|
|
73
|
+
const notice = await activeTaskNotice(cwd);
|
|
74
|
+
if (!notice) return null;
|
|
75
|
+
return JSON.stringify({ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: notice } });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function entryLogPath(sessionId: string, root: string): string {
|
|
79
|
+
return join(cacheDir(root), `${sessionHash(sessionId)}.entry`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function admissionPath(toolUseId: string, root: string): string {
|
|
83
|
+
return join(cacheDir(root), `${sessionHash(toolUseId)}.admit`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function sessionIdOf(payload: Record<string, unknown>): string {
|
|
87
|
+
return typeof payload.session_id === "string" ? payload.session_id : "";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function cwdOf(payload: Record<string, unknown>): string {
|
|
91
|
+
return typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Project aliases first, then the user's, as Claude Code resolves commands. */
|
|
95
|
+
function claudeAliasReader(cwd: string, home: string): AliasReader {
|
|
96
|
+
return (name) => {
|
|
97
|
+
for (const dir of [join(cwd, ".claude", "commands"), join(home, ".claude", "commands")]) {
|
|
98
|
+
const body = readAliasFile(join(dir, `${name}.md`));
|
|
99
|
+
if (body !== null) return body;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The session's entry state: none recorded yet, or null when the store is unreadable. */
|
|
106
|
+
export function readPhaseEntryState(sessionId: string, root = tmpdir(), read: typeof readPrivate = readPrivate): PhaseEntryState | null {
|
|
107
|
+
if (!sessionId) return null;
|
|
108
|
+
const path = entryLogPath(sessionId, root);
|
|
109
|
+
try {
|
|
110
|
+
lstatSync(path);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT" ? NO_PHASE_ENTRY : null;
|
|
113
|
+
}
|
|
114
|
+
if (!ensurePrivateDir(cacheDir(root))) return null;
|
|
115
|
+
let text: string | undefined;
|
|
116
|
+
try { text = read(path); } catch { return null; }
|
|
117
|
+
return text === undefined ? null : reducePhaseEntryLog(text);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Removes one of our private files. Never follows a link or touches another owner's file. */
|
|
121
|
+
function dropPrivate(path: string): void {
|
|
122
|
+
try {
|
|
123
|
+
const stat = lstatSync(path);
|
|
124
|
+
if (ownedByUs(stat) && stat.isFile()) rmSync(path, { force: true });
|
|
125
|
+
} catch { /* already gone, or not ours to remove */ }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* UserPromptSubmit: record whether this literal prompt entered a phase. Every
|
|
130
|
+
* prompt is recorded so "the latest prompt" is decidable later. A prompt that
|
|
131
|
+
* could not be recorded drops the log: an earlier entry must not outlive a
|
|
132
|
+
* prompt the store never saw, so the session is back to no entry.
|
|
133
|
+
*/
|
|
134
|
+
export function recordPhaseEntryPrompt(payload: Record<string, unknown>, locations: PhaseEntryLocations = {}): boolean {
|
|
135
|
+
if (payload.hook_event_name !== "UserPromptSubmit") return false;
|
|
136
|
+
const sessionId = sessionIdOf(payload);
|
|
137
|
+
if (!sessionId) return false;
|
|
138
|
+
const root = locations.root ?? tmpdir();
|
|
139
|
+
const prompt = typeof payload.prompt === "string" ? payload.prompt : "";
|
|
140
|
+
const entry = recognizePhaseEntry(prompt, claudeAliasReader(cwdOf(payload), locations.home ?? homedir()));
|
|
141
|
+
const path = entryLogPath(sessionId, root);
|
|
142
|
+
const recorded = (locations.append ?? appendPrivate)(path, cacheDir(root), phaseEntryLogLine(entry));
|
|
143
|
+
if (!recorded) dropPrivate(path);
|
|
144
|
+
return recorded;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function gatedOperation(payload: Record<string, unknown>): PhaseOperation | null {
|
|
148
|
+
const toolName = String(payload.tool_name ?? "");
|
|
149
|
+
if (toolName === "Skill") {
|
|
150
|
+
const input = (payload.tool_input ?? {}) as { skill?: unknown };
|
|
151
|
+
const skill = typeof input.skill === "string" ? packagedPhaseSkill(input.skill) : null;
|
|
152
|
+
return skill ? { kind: "skill", skill } : null;
|
|
153
|
+
}
|
|
154
|
+
const mcp = GATED_MCP_TOOL.exec(toolName);
|
|
155
|
+
return mcp ? { kind: "tool", name: mcp[1] as GatedTool } : null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* PreToolUse for a gated tool: deny without a matching entry; for the two MCP
|
|
160
|
+
* tools, otherwise write the admission the handler consumes. Any other tool
|
|
161
|
+
* answers null and runs as configured.
|
|
162
|
+
*/
|
|
163
|
+
export function phaseEntryGateHookOutput(payload: Record<string, unknown>, locations: PhaseEntryLocations = {}): string | null {
|
|
164
|
+
if (payload.hook_event_name !== "PreToolUse") return null;
|
|
165
|
+
const operation = gatedOperation(payload);
|
|
166
|
+
if (!operation) return null;
|
|
167
|
+
const root = locations.root ?? tmpdir();
|
|
168
|
+
let state: PhaseEntryState | null = null;
|
|
169
|
+
try { state = readPhaseEntryState(sessionIdOf(payload), root, locations.read); } catch { /* an unreadable store refuses */ }
|
|
170
|
+
let activeClaim = false;
|
|
171
|
+
try { activeClaim = readActiveClaimTaskId(cwdOf(payload)) !== null; } catch { /* unreadable authority is no claim */ }
|
|
172
|
+
const admission = admitPhaseOperation(operation, state, { activeClaim });
|
|
173
|
+
if (!admission.ok) return denyPreToolUse(phaseRefusalMessage(admission));
|
|
174
|
+
if (operation.kind === "skill") return null;
|
|
175
|
+
const toolUseId = typeof payload.tool_use_id === "string" ? payload.tool_use_id : "";
|
|
176
|
+
let recorded = false;
|
|
177
|
+
try {
|
|
178
|
+
recorded = toolUseId !== ""
|
|
179
|
+
&& (locations.append ?? appendPrivate)(admissionPath(toolUseId, root), cacheDir(root), `${JSON.stringify({ v: 1, operation: operation.name })}\n`);
|
|
180
|
+
} catch { /* an admission that could not be written refuses */ }
|
|
181
|
+
if (recorded) return null;
|
|
182
|
+
return denyPreToolUse(
|
|
183
|
+
`${RUN_ENTRY_REQUIRED}: the admission for this ${operation.name} call could not be recorded; recovery: ${PHASE_ENTRY_RECOVERY[RUN_ENTRY_REQUIRED]}`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Tool side: take the admission the hook wrote for this exact call. One use;
|
|
189
|
+
* absent, unreadable or written for another operation all refuse. The hook may
|
|
190
|
+
* run more than once for one call (two enabled copies of the plugin), so
|
|
191
|
+
* repeated identical admissions count as one.
|
|
192
|
+
*/
|
|
193
|
+
export function consumePhaseAdmission(toolUseId: string, operation: GatedTool, root = tmpdir()): boolean {
|
|
194
|
+
if (!toolUseId || !ensurePrivateDir(cacheDir(root))) return false;
|
|
195
|
+
const path = admissionPath(toolUseId, root);
|
|
196
|
+
let text: string | undefined;
|
|
197
|
+
try { text = readPrivate(path); } catch { return false; }
|
|
198
|
+
if (text === undefined) return false;
|
|
199
|
+
dropPrivate(path);
|
|
200
|
+
const lines = text.split("\n").filter(Boolean);
|
|
201
|
+
if (lines.length === 0) return false;
|
|
202
|
+
return lines.every((line) => {
|
|
203
|
+
try {
|
|
204
|
+
const record = JSON.parse(line) as { v?: unknown; operation?: unknown } | null;
|
|
205
|
+
return record?.v === 1 && record.operation === operation;
|
|
206
|
+
} catch {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
}
|
|
@@ -12,8 +12,11 @@ import {
|
|
|
12
12
|
type NativeConfirmationPort,
|
|
13
13
|
} from "./interaction";
|
|
14
14
|
import { ClaudeReviewHost, FileHookEventLog, parseHookStdin } from "./review_host";
|
|
15
|
+
import { activeTaskHookOutput, consumePhaseAdmission, laneGuardHookOutput, phaseEntryGateHookOutput, recordPhaseEntryPrompt, type GatedTool } from "./lane_guard";
|
|
15
16
|
import { ClaudeRuntime, type ToolMeta } from "./kernel_ports";
|
|
16
|
-
import { parseLaneOffers, parseMaxParallel, resolveLaneParallel } from "../unattended/batch_lanes";
|
|
17
|
+
import { parseLaneInstruction, parseLaneOffers, parseMaxParallel, resolveLaneParallel } from "../unattended/batch_lanes";
|
|
18
|
+
import { parseFinalVerification } from "../unattended/batch_final_verification";
|
|
19
|
+
import { parseLaneRevision, REVISION_DELEGATION_TEXT } from "../unattended/batch_delegation";
|
|
17
20
|
import type { AssuranceCoordinatorPorts } from "../assurance/coordinator";
|
|
18
21
|
|
|
19
22
|
export const MCP_PROTOCOL_VERSION = "2025-06-18";
|
|
@@ -56,6 +59,24 @@ export function listMcpTools() {
|
|
|
56
59
|
additionalProperties: false,
|
|
57
60
|
},
|
|
58
61
|
},
|
|
62
|
+
final_verification: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 8 },
|
|
63
|
+
lane_instruction: {
|
|
64
|
+
type: "object",
|
|
65
|
+
properties: {
|
|
66
|
+
task_id: { type: "string" },
|
|
67
|
+
kind: { type: "string", enum: ["instruction", "correction"] },
|
|
68
|
+
text: { type: "string" },
|
|
69
|
+
session_state: { type: "string", enum: ["idle", "working", "blocked", "done"] },
|
|
70
|
+
},
|
|
71
|
+
required: ["task_id", "text", "session_state"],
|
|
72
|
+
additionalProperties: false,
|
|
73
|
+
},
|
|
74
|
+
lane_revision: {
|
|
75
|
+
type: "object",
|
|
76
|
+
properties: { task_id: { type: "string" }, next_intent: { type: "object" } },
|
|
77
|
+
required: ["task_id", "next_intent"],
|
|
78
|
+
additionalProperties: false,
|
|
79
|
+
},
|
|
59
80
|
}
|
|
60
81
|
: {}),
|
|
61
82
|
}
|
|
@@ -97,6 +118,11 @@ export interface McpRuntimeOptions {
|
|
|
97
118
|
batchKernel?: Partial<import("../unattended/batch_runner").BatchRunnerKernelPort>;
|
|
98
119
|
batchGit?: import("../unattended/batch_git").BatchRunnerGitPort;
|
|
99
120
|
readInitiative?: import("../unattended/types").InitiativeObservationReader;
|
|
121
|
+
/**
|
|
122
|
+
* Takes the admission the PreToolUse hook wrote for one `enroll` or
|
|
123
|
+
* `start_unattended_batch` call. Defaults to the private file store.
|
|
124
|
+
*/
|
|
125
|
+
consumePhaseAdmission?: (toolCallId: string, operation: GatedTool) => boolean;
|
|
100
126
|
}
|
|
101
127
|
|
|
102
128
|
export function createMcpRuntime(options: McpRuntimeOptions = {}) {
|
|
@@ -118,6 +144,14 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
|
|
|
118
144
|
let negotiatedVersion: string | undefined;
|
|
119
145
|
let negotiatedInteractive = false;
|
|
120
146
|
const connectionId = randomUUID();
|
|
147
|
+
const consumeAdmission = options.consumePhaseAdmission ?? ((toolCallId: string, operation: GatedTool) => consumePhaseAdmission(toolCallId, operation));
|
|
148
|
+
// The phase entry gate, before any preparation or native dialog. The hook
|
|
149
|
+
// decided on the session's literal entry; no admission for this exact call
|
|
150
|
+
// means no hook saw it, which refuses.
|
|
151
|
+
const requireRunEntry = (operation: GatedTool, toolCallId: string) => {
|
|
152
|
+
if (consumeAdmission(toolCallId, operation)) return;
|
|
153
|
+
throw new NativeAuthorityError("run_entry_required", `${operation} needs a literal-user imm-run entry in this session`);
|
|
154
|
+
};
|
|
121
155
|
return {
|
|
122
156
|
runtime,
|
|
123
157
|
host,
|
|
@@ -143,6 +177,7 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
|
|
|
143
177
|
}
|
|
144
178
|
const probe = probeHost(options.env ?? process.env, process.platform, negotiatedVersion);
|
|
145
179
|
if (!probe.ok) throw new NativeAuthorityError("unsupported_host", probe.reason);
|
|
180
|
+
requireRunEntry(name, meta.toolCallId ?? `call-${name}`);
|
|
146
181
|
const toolMeta: ToolMeta = {
|
|
147
182
|
sessionId: meta.sessionId ?? connectionId,
|
|
148
183
|
toolCallId: meta.toolCallId ?? `call-${name}`,
|
|
@@ -153,10 +188,16 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
|
|
|
153
188
|
};
|
|
154
189
|
const laneOffers = parseLaneOffers(args.lane_offers);
|
|
155
190
|
// A lane_offers-only call resumes the recorded lane batch; refused before any gate otherwise.
|
|
156
|
-
const
|
|
191
|
+
const laneInstruction = parseLaneInstruction(args.lane_instruction);
|
|
192
|
+
const laneRevision = parseLaneRevision(args.lane_revision);
|
|
193
|
+
const maxParallel = resolveLaneParallel(options.cwd ?? process.cwd(), initiativeSlug, parseMaxParallel(args.max_parallel), laneOffers, laneInstruction ?? laneRevision);
|
|
194
|
+
const finalVerification = parseFinalVerification(args.final_verification);
|
|
157
195
|
return runtime.startUnattendedBatch(initiativeSlug, toolMeta, {
|
|
158
196
|
...(maxParallel !== undefined ? { max_parallel: maxParallel } : {}),
|
|
159
197
|
...(laneOffers !== undefined ? { lane_offers: laneOffers } : {}),
|
|
198
|
+
...(finalVerification !== undefined ? { final_verification: finalVerification } : {}),
|
|
199
|
+
...(laneInstruction !== undefined ? { lane_instruction: laneInstruction } : {}),
|
|
200
|
+
...(laneRevision !== undefined ? { lane_revision: laneRevision } : {}),
|
|
160
201
|
});
|
|
161
202
|
}
|
|
162
203
|
if (name === "retire_stale_batch") {
|
|
@@ -203,7 +244,10 @@ export function createMcpRuntime(options: McpRuntimeOptions = {}) {
|
|
|
203
244
|
interactive: meta.interactive ?? options.interactive ?? negotiatedInteractive,
|
|
204
245
|
signal: meta.signal,
|
|
205
246
|
};
|
|
206
|
-
if (name === "enroll")
|
|
247
|
+
if (name === "enroll") {
|
|
248
|
+
requireRunEntry(name, meta.toolCallId ?? `call-${name}`);
|
|
249
|
+
return runtime.enroll(taskId, toolMeta);
|
|
250
|
+
}
|
|
207
251
|
if (name === "advance_assurance") return runtime.advance(taskId, toolMeta.signal, toolMeta);
|
|
208
252
|
if (name === "submit_review") {
|
|
209
253
|
// ADR 0017: an absent verdict is the receipt-bound submission.
|
|
@@ -445,11 +489,25 @@ export function elicitationParams(input: NativeConfirmationInput) {
|
|
|
445
489
|
b?.lane_mode ? `Lane mode: max_parallel=${b.lane_mode.max_parallel}` : null,
|
|
446
490
|
b?.lane_mode ? `Parallel groups (${b.lane_mode.parallel_groups.length}):\n${b.lane_mode.parallel_groups.map((group) => ` - ${group.join(", ")}`).join("\n")}` : null,
|
|
447
491
|
b?.lane_mode && b.lane_mode.serialized.length > 0 ? `Serialized by overlapping scope (${b.lane_mode.serialized.length}):\n${b.lane_mode.serialized.map((c) => ` - ${c.task_id} after ${c.overlaps_with.join(", ")}`).join("\n")}` : null,
|
|
492
|
+
b?.lane_mode?.final_verification ? `Final verification: ${b.lane_mode.final_verification.join("; ")}` : null,
|
|
493
|
+
b?.lane_mode?.offer_revision_delegation ? REVISION_DELEGATION_TEXT : null,
|
|
448
494
|
].filter(Boolean);
|
|
449
495
|
return {
|
|
450
496
|
mode: "form",
|
|
451
497
|
message: `Authorize this unattended batch run for ${input.initiativeSlug}?\n\n${details.join("\n")}`,
|
|
452
|
-
requestedSchema:
|
|
498
|
+
requestedSchema: b?.lane_mode?.offer_revision_delegation
|
|
499
|
+
? {
|
|
500
|
+
type: "object",
|
|
501
|
+
properties: {
|
|
502
|
+
delegate_in_lane_revisions: {
|
|
503
|
+
type: "boolean",
|
|
504
|
+
title: "Delegate in-envelope Lane Intent revisions to the batch coordinator",
|
|
505
|
+
description: REVISION_DELEGATION_TEXT,
|
|
506
|
+
default: false,
|
|
507
|
+
},
|
|
508
|
+
},
|
|
509
|
+
}
|
|
510
|
+
: { type: "object", properties: {} },
|
|
453
511
|
};
|
|
454
512
|
}
|
|
455
513
|
const details = [
|
|
@@ -527,13 +585,19 @@ export async function serveStdio(options: {
|
|
|
527
585
|
if (action !== "accept" && action !== "decline" && action !== "cancel") {
|
|
528
586
|
throw new NativeAuthorityError("correlation_missing", "MCP elicitation returned an invalid action");
|
|
529
587
|
}
|
|
588
|
+
let delegateRevisions = false;
|
|
530
589
|
if (action === "accept") {
|
|
531
590
|
const content = (result as { content?: unknown }).content;
|
|
532
|
-
|
|
591
|
+
const offered = input.operation === "start_unattended_batch" && input.batchDetails?.lane_mode?.offer_revision_delegation === true;
|
|
592
|
+
const keys = typeof content === "object" && content !== null && !Array.isArray(content) ? Object.keys(content) : null;
|
|
593
|
+
const valid = keys !== null && (keys.length === 0 ||
|
|
594
|
+
(offered && keys.length === 1 && keys[0] === "delegate_in_lane_revisions" && typeof (content as Record<string, unknown>).delegate_in_lane_revisions === "boolean"));
|
|
595
|
+
if (!valid) {
|
|
533
596
|
throw new NativeAuthorityError("correlation_missing", "MCP elicitation accept content did not match the requested schema");
|
|
534
597
|
}
|
|
598
|
+
delegateRevisions = offered && (content as Record<string, unknown>).delegate_in_lane_revisions === true;
|
|
535
599
|
}
|
|
536
|
-
return { decision: action, requestId };
|
|
600
|
+
return { decision: action, requestId, ...(delegateRevisions ? { delegateRevisions: true } : {}) };
|
|
537
601
|
} finally {
|
|
538
602
|
pending.delete(requestId);
|
|
539
603
|
if (abortListener) input.signal?.removeEventListener("abort", abortListener);
|
|
@@ -686,7 +750,21 @@ async function runHook(): Promise<void> {
|
|
|
686
750
|
const lines: string[] = [];
|
|
687
751
|
const rl = createInterface({ input: stdin });
|
|
688
752
|
for await (const line of rl) lines.push(line);
|
|
689
|
-
const
|
|
753
|
+
const raw = lines.join("\n");
|
|
754
|
+
let payload: Record<string, unknown> | null = null;
|
|
755
|
+
try { payload = JSON.parse(raw) as Record<string, unknown>; } catch { payload = null; }
|
|
756
|
+
if (payload?.hook_event_name === "PreToolUse") {
|
|
757
|
+
const decision = laneGuardHookOutput(payload) ?? phaseEntryGateHookOutput(payload);
|
|
758
|
+
if (decision) process.stdout.write(`${decision}\n`);
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
if (payload?.hook_event_name === "UserPromptSubmit") {
|
|
762
|
+
recordPhaseEntryPrompt(payload);
|
|
763
|
+
const notice = await activeTaskHookOutput(payload);
|
|
764
|
+
if (notice) process.stdout.write(`${notice}\n`);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
const event = parseHookStdin(raw);
|
|
690
768
|
if (!event) return;
|
|
691
769
|
new FileHookEventLog().append(event);
|
|
692
770
|
}
|
|
@@ -76,21 +76,21 @@ export class MemoryHookEventLog implements HookEventLog {
|
|
|
76
76
|
|
|
77
77
|
const CACHE_DIR = "immune-brain-claude";
|
|
78
78
|
|
|
79
|
-
function sessionHash(sessionId: string): string {
|
|
79
|
+
export function sessionHash(sessionId: string): string {
|
|
80
80
|
return createHash("sha256").update(sessionId).digest("hex");
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
function cacheDir(root: string): string {
|
|
83
|
+
export function cacheDir(root: string): string {
|
|
84
84
|
return join(root, CACHE_DIR);
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
function ownedByUs(stat: { uid: number; isSymbolicLink(): boolean }): boolean {
|
|
87
|
+
export function ownedByUs(stat: { uid: number; isSymbolicLink(): boolean }): boolean {
|
|
88
88
|
if (stat.isSymbolicLink()) return false;
|
|
89
89
|
const uid = process.getuid?.();
|
|
90
90
|
return uid === undefined || stat.uid === uid;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
function ensurePrivateDir(dir: string): boolean {
|
|
93
|
+
export function ensurePrivateDir(dir: string): boolean {
|
|
94
94
|
try {
|
|
95
95
|
const existing = lstatSync(dir);
|
|
96
96
|
return existing.isDirectory() && ownedByUs(existing) && (existing.mode & 0o777) === 0o700;
|
|
@@ -106,7 +106,7 @@ function ensurePrivateDir(dir: string): boolean {
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
function appendPrivate(path: string, dir: string, line: string): boolean {
|
|
109
|
+
export function appendPrivate(path: string, dir: string, line: string): boolean {
|
|
110
110
|
if (!ensurePrivateDir(dir)) return false;
|
|
111
111
|
const flags = constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | constants.O_NOFOLLOW | constants.O_NONBLOCK;
|
|
112
112
|
let fd: number;
|
|
@@ -122,7 +122,7 @@ function appendPrivate(path: string, dir: string, line: string): boolean {
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
function readPrivate(path: string, maxBytes = Number.POSITIVE_INFINITY): string | undefined {
|
|
125
|
+
export function readPrivate(path: string, maxBytes = Number.POSITIVE_INFINITY): string | undefined {
|
|
126
126
|
let fd: number;
|
|
127
127
|
try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); } catch { return; }
|
|
128
128
|
try {
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from "node:fs";
|
|
9
9
|
import { execFileSync } from "node:child_process";
|
|
10
10
|
import { resolve } from "node:path";
|
|
11
|
+
import { projectScopeOverlap } from "../unattended/batch_schedule";
|
|
11
12
|
import process from "node:process";
|
|
12
13
|
import { fileURLToPath } from "node:url";
|
|
13
14
|
import {
|
|
@@ -858,6 +859,57 @@ function runIntentAuthor(args: string[], root: string): KernelExecution {
|
|
|
858
859
|
* TaskRecord, claim, Ledger, Git index, or session write. Returns a stable
|
|
859
860
|
* bounded projection.
|
|
860
861
|
*/
|
|
862
|
+
/**
|
|
863
|
+
* Read-only: compare the `scope_hint` of two or more TaskIntents of one
|
|
864
|
+
* Initiative and report every overlapping entry pair plus the start waves the
|
|
865
|
+
* Batch Plan would use. Optional `--blocked-by a=b,c` edges order the waves the
|
|
866
|
+
* way the Initiative's dependencies do. Writes nothing.
|
|
867
|
+
*/
|
|
868
|
+
function runIntentOverlap(args: string[], root: string): KernelExecution {
|
|
869
|
+
const usage = "Run imm-kernel intent overlap <path> <path>... [--blocked-by <task-id>=<task-id>,...] --json.";
|
|
870
|
+
const reject = (message: string): KernelExecution => ({
|
|
871
|
+
result: errorResult("invalid_command", message, 2),
|
|
872
|
+
journal: journalFor("intent", null, "rejected", "invalid_command", null, usage),
|
|
873
|
+
});
|
|
874
|
+
const paths: string[] = [];
|
|
875
|
+
const blockedBy = new Map<string, string[]>();
|
|
876
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
877
|
+
const arg = args[index]!;
|
|
878
|
+
if (arg === "--json") continue;
|
|
879
|
+
if (arg === "--blocked-by") {
|
|
880
|
+
const edge = args[++index] ?? "";
|
|
881
|
+
const match = /^([A-Za-z0-9][A-Za-z0-9._-]*)=([A-Za-z0-9._,-]+)$/.exec(edge);
|
|
882
|
+
if (!match) return reject(`invalid --blocked-by edge: ${edge}`);
|
|
883
|
+
blockedBy.set(match[1]!, match[2]!.split(",").filter(Boolean));
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
if (arg.startsWith("-")) return reject(`unsupported intent overlap option: ${arg}`);
|
|
887
|
+
paths.push(arg);
|
|
888
|
+
}
|
|
889
|
+
if (paths.length < 2) return reject("imm-kernel intent overlap requires at least two paths");
|
|
890
|
+
const children: Array<{ task_id: string; blocked_by: string[]; scope_hint: string[] }> = [];
|
|
891
|
+
for (const path of paths) {
|
|
892
|
+
let intent: { task_id?: unknown; scope_hint?: unknown };
|
|
893
|
+
try {
|
|
894
|
+
intent = JSON.parse(readFileSync(resolve(root, path), "utf8"));
|
|
895
|
+
} catch (error) {
|
|
896
|
+
return { result: errorResult("source_invalid", `cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`, 1), journal: journalFor("intent", null, "rejected", "source_read_failed", null, usage) };
|
|
897
|
+
}
|
|
898
|
+
if (typeof intent.task_id !== "string" || !Array.isArray(intent.scope_hint) || intent.scope_hint.some((entry) => typeof entry !== "string"))
|
|
899
|
+
return { result: errorResult("source_invalid", `${path} has no task_id and string scope_hint`, 1), journal: journalFor("intent", null, "rejected", "source_invalid", null, usage) };
|
|
900
|
+
children.push({ task_id: intent.task_id, blocked_by: [], scope_hint: intent.scope_hint as string[] });
|
|
901
|
+
}
|
|
902
|
+
const known = new Set(children.map((child) => child.task_id));
|
|
903
|
+
for (const child of children) child.blocked_by = (blockedBy.get(child.task_id) ?? []).filter((id) => known.has(id));
|
|
904
|
+
let report: ReturnType<typeof projectScopeOverlap>;
|
|
905
|
+
try {
|
|
906
|
+
report = projectScopeOverlap(children);
|
|
907
|
+
} catch (error) {
|
|
908
|
+
return reject(error instanceof Error ? error.message : String(error));
|
|
909
|
+
}
|
|
910
|
+
return { result: jsonResult(report, 0), journal: journalFor("intent", null, "ok", "command_ok", null, null) };
|
|
911
|
+
}
|
|
912
|
+
|
|
861
913
|
function runIntentValidate(args: string[], root: string): KernelExecution {
|
|
862
914
|
const nonFlags = args.filter((arg) => !arg.startsWith("-"));
|
|
863
915
|
if (nonFlags.length !== 1)
|
|
@@ -1173,10 +1225,11 @@ function executeKernelCommand(args: string[], root: string): KernelExecution {
|
|
|
1173
1225
|
const sub = args[1] ?? "";
|
|
1174
1226
|
if (sub === "author") return runIntentAuthor(args.slice(2), root);
|
|
1175
1227
|
if (sub === "validate") return runIntentValidate(args.slice(2), root);
|
|
1228
|
+
if (sub === "overlap") return runIntentOverlap(args.slice(2), root);
|
|
1176
1229
|
return {
|
|
1177
1230
|
result: errorResult(
|
|
1178
1231
|
"invalid_command",
|
|
1179
|
-
"usage: imm-kernel intent author <path> --stdin --json | validate <path> --json",
|
|
1232
|
+
"usage: imm-kernel intent author <path> --stdin --json | validate <path> --json | overlap <path> <path>... [--blocked-by <task-id>=<task-id>,...] --json",
|
|
1180
1233
|
2,
|
|
1181
1234
|
),
|
|
1182
1235
|
journal: journalFor(
|