immune-brain 5.1.0 → 5.1.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-work.ts +37 -5
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +230 -10
- package/plugins/immune-brain/.pi-extension/pi-canary-assurance-progression.ts +66 -3
- package/plugins/immune-brain/agents/immune-brain-reviewer.md +26 -1
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +8712 -7410
- package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +1 -1
- package/plugins/immune-brain/dist/docs/reference/subagent-dispatch-protocol.md +8 -6
- package/plugins/immune-brain/dist/imm-brainstorm.md +4 -4
- package/plugins/immune-brain/dist/imm-planner.md +41 -5
- package/plugins/immune-brain/dist/imm-run.md +123 -9
- package/plugins/immune-brain/dist/role-prompts/code-review.md +25 -0
- package/plugins/immune-brain/dist/role-prompts/executor.md +7 -0
- package/plugins/immune-brain/hooks/hooks.json +22 -1
- package/plugins/immune-brain/runtime/assurance/active_task_notice.ts +42 -0
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +31 -4
- package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +4 -1
- package/plugins/immune-brain/runtime/assurance/review_mediation.ts +107 -0
- package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +3 -0
- package/plugins/immune-brain/runtime/claude/interaction.ts +15 -0
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +120 -59
- package/plugins/immune-brain/runtime/claude/lane_guard.ts +33 -0
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +142 -31
- 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/assurance_projection.ts +4 -1
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +9 -2
- package/plugins/immune-brain/runtime/kernel/completion.ts +24 -1
- package/plugins/immune-brain/runtime/kernel/reducer.ts +15 -2
- 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/kernel/spec_binding.ts +6 -0
- package/plugins/immune-brain/runtime/kernel/storage.ts +20 -4
- package/plugins/immune-brain/runtime/kernel/storage_paths.ts +33 -3
- package/plugins/immune-brain/runtime/kernel/types.ts +7 -0
- package/plugins/immune-brain/runtime/kernel/validation.ts +13 -2
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +25 -0
- package/plugins/immune-brain/runtime/prompts/executor.md +7 -0
- package/plugins/immune-brain/runtime/role_prompt_bridge.ts +1 -1
- package/plugins/immune-brain/runtime/unattended/batch_delegation.ts +160 -0
- package/plugins/immune-brain/runtime/unattended/batch_disposition.ts +222 -0
- package/plugins/immune-brain/runtime/unattended/batch_final_verification.ts +72 -0
- package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +447 -37
- package/plugins/immune-brain/runtime/unattended/batch_leak_restore.ts +131 -0
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +33 -16
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +16 -3
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +44 -0
- package/plugins/immune-brain/runtime/unattended/batch_schedule.ts +40 -0
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +199 -6
- package/plugins/immune-brain/runtime/unattended/lane_workspace.ts +58 -0
- package/plugins/immune-brain/skills/imm-brainstorm/SKILL.md +1 -1
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +3 -1
|
@@ -54,8 +54,11 @@ function isolatedGitEnv(extra: Record<string, string> = {}): NodeJS.ProcessEnv {
|
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// `maintenance.auto=false`: fetch otherwise starts `maintenance run --auto`,
|
|
58
|
+
// which newer Git detaches; the detached child still owns
|
|
59
|
+
// objects/maintenance.lock after fetch returns and mutates the sealed .git.
|
|
57
60
|
function git(cwd: string, args: string[], extra: Record<string, string> = {}): string {
|
|
58
|
-
return execFileSync("git", args, {
|
|
61
|
+
return execFileSync("git", ["-c", "maintenance.auto=false", ...args], {
|
|
59
62
|
cwd,
|
|
60
63
|
encoding: "utf8",
|
|
61
64
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Host-neutral Review submission mediation (ADR 0017).
|
|
2
|
+
//
|
|
3
|
+
// Both host adapters resolve the verdict source before the shared submit path:
|
|
4
|
+
// the Parent's relayed bytes when provided, or the host-observed reviewer
|
|
5
|
+
// receipt when the verdict is omitted. The Parent keeps exclusive trigger
|
|
6
|
+
// authority; only the transcription step is optional. The coordinator itself
|
|
7
|
+
// stays single-verdict-input and never knows where the bytes came from.
|
|
8
|
+
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import type { AssuranceCoordinator, AssuranceSubmitReviewResult, HostContext } from "./coordinator";
|
|
11
|
+
|
|
12
|
+
export function extractVerdictJson(input: unknown): Record<string, unknown> | null {
|
|
13
|
+
if (typeof input === "string") {
|
|
14
|
+
// Agent transport prefixes a status line to the strict JSON verdict.
|
|
15
|
+
const first = input.indexOf("{");
|
|
16
|
+
const last = input.lastIndexOf("}");
|
|
17
|
+
const cleaned = first >= 0 && last >= first ? input.slice(first, last + 1) : "";
|
|
18
|
+
if (!cleaned) return null;
|
|
19
|
+
try { return JSON.parse(cleaned) as Record<string, unknown>; } catch { return null; }
|
|
20
|
+
}
|
|
21
|
+
if (typeof input === "object" && input !== null && !Array.isArray(input)) return input as Record<string, unknown>;
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function verdictFingerprint(raw: Record<string, unknown>): string {
|
|
26
|
+
return JSON.stringify({
|
|
27
|
+
contract: raw.contract ?? null,
|
|
28
|
+
role: raw.role ?? null,
|
|
29
|
+
task_id: raw.task_id ?? null,
|
|
30
|
+
snapshot_digest: raw.snapshot_digest ?? null,
|
|
31
|
+
decision: raw.decision ?? null,
|
|
32
|
+
approval: raw.approval ?? null,
|
|
33
|
+
findings: raw.findings ?? null,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** sha256 over the reviewer's own result bytes, bound into the review attestation. */
|
|
38
|
+
export function digestOfReviewerBytes(bytes: string): string {
|
|
39
|
+
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The host-observation shape both adapters supply; mirrors ClaudeReviewHost's. */
|
|
43
|
+
export type ReviewObservation =
|
|
44
|
+
| { ok: true; receipt: { actorId: string; result: string } }
|
|
45
|
+
| { ok: false; release: boolean; reason: string };
|
|
46
|
+
|
|
47
|
+
const RELEASED_REVIEW_RECOVERY =
|
|
48
|
+
"Call advance_assurance to obtain a new Review reservation, then dispatch one fresh reviewer with the returned envelope unchanged";
|
|
49
|
+
const RETAINED_REVIEW_RECOVERY =
|
|
50
|
+
"Wait for the dispatched reviewer to finish, then call submit_review again with its verdict; do not dispatch or continue another reviewer";
|
|
51
|
+
const MISMATCH_REVIEW_RECOVERY =
|
|
52
|
+
"Resubmit without a verdict to apply the observed reviewer receipt, or resubmit the reviewer's verdict exactly as the reviewer returned it";
|
|
53
|
+
|
|
54
|
+
function withReviewRecovery(
|
|
55
|
+
result: AssuranceSubmitReviewResult,
|
|
56
|
+
recovery_action: string,
|
|
57
|
+
): AssuranceSubmitReviewResult {
|
|
58
|
+
if (result.state !== "blocked" || result.code === "verdict_invalid") return result;
|
|
59
|
+
return { ...result, recovery_action };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function submitMediatedReview(
|
|
63
|
+
coordinator: AssuranceCoordinator,
|
|
64
|
+
ctx: HostContext,
|
|
65
|
+
taskId: string,
|
|
66
|
+
verdictInput: unknown,
|
|
67
|
+
inspect: () => ReviewObservation,
|
|
68
|
+
): Promise<AssuranceSubmitReviewResult> {
|
|
69
|
+
// Preserve uncertain authority settlement before inspecting a released receipt.
|
|
70
|
+
if (coordinator.active(taskId)?.state === "settlement_unknown") {
|
|
71
|
+
return coordinator.submitReview(taskId, ctx, verdictInput);
|
|
72
|
+
}
|
|
73
|
+
const observed = inspect();
|
|
74
|
+
if (!observed.ok) {
|
|
75
|
+
if (observed.release) return withReviewRecovery(coordinator.abandonReview(taskId, observed.reason), RELEASED_REVIEW_RECOVERY);
|
|
76
|
+
return { state: "blocked", reason: observed.reason, recovery_action: RETAINED_REVIEW_RECOVERY };
|
|
77
|
+
}
|
|
78
|
+
const receiptBytes = observed.receipt.result;
|
|
79
|
+
const options = {
|
|
80
|
+
receiptRecoveryAction: RETAINED_REVIEW_RECOVERY,
|
|
81
|
+
reviewer_verdict_sha256: digestOfReviewerBytes(receiptBytes),
|
|
82
|
+
validateReceipt: () => {
|
|
83
|
+
const current = inspect();
|
|
84
|
+
if (!current.ok) return current.reason;
|
|
85
|
+
return current.receipt.actorId === observed.receipt.actorId && current.receipt.result === receiptBytes
|
|
86
|
+
? null : "reviewer receipt changed during submission";
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
if (verdictInput === undefined) {
|
|
90
|
+
// ADR 0017: the host-observed reviewer bytes are the submission.
|
|
91
|
+
if (!coordinator.isReviewVerdictValid(taskId, receiptBytes)) {
|
|
92
|
+
return withReviewRecovery(coordinator.abandonReview(taskId, "reviewer receipt is not a valid verdict"), RELEASED_REVIEW_RECOVERY);
|
|
93
|
+
}
|
|
94
|
+
return coordinator.submitReview(taskId, ctx, receiptBytes, options);
|
|
95
|
+
}
|
|
96
|
+
const parentValid = coordinator.isReviewVerdictValid(taskId, verdictInput);
|
|
97
|
+
if (!parentValid) return coordinator.submitReview(taskId, ctx, verdictInput);
|
|
98
|
+
if (!coordinator.isReviewVerdictValid(taskId, receiptBytes)) {
|
|
99
|
+
return withReviewRecovery(coordinator.abandonReview(taskId, "reviewer receipt is not a valid verdict"), RELEASED_REVIEW_RECOVERY);
|
|
100
|
+
}
|
|
101
|
+
const parentJson = extractVerdictJson(verdictInput);
|
|
102
|
+
const receiptJson = extractVerdictJson(receiptBytes);
|
|
103
|
+
if (!parentJson || !receiptJson || verdictFingerprint(parentJson) !== verdictFingerprint(receiptJson)) {
|
|
104
|
+
return { state: "blocked", reason: "parent verdict does not match reviewer receipt", recovery_action: MISMATCH_REVIEW_RECOVERY };
|
|
105
|
+
}
|
|
106
|
+
return coordinator.submitReview(taskId, ctx, verdictInput, options);
|
|
107
|
+
}
|
|
@@ -272,6 +272,8 @@ export interface VerdictApplicationInput {
|
|
|
272
272
|
verdict: AssuranceVerdict;
|
|
273
273
|
invocation: InvocationToken;
|
|
274
274
|
actorId: string;
|
|
275
|
+
/** ADR 0017: sha256 over the reviewer's own result bytes, bound only on review attestations. */
|
|
276
|
+
reviewer_verdict_sha256?: string;
|
|
275
277
|
hooks?: { beforeCommit?: () => Promise<void>; onCommit?: () => void; afterCommit?: () => Promise<void> };
|
|
276
278
|
}
|
|
277
279
|
export interface VerdictAuthority {
|
|
@@ -310,6 +312,7 @@ export function createVerdictAuthority(options: {
|
|
|
310
312
|
diff_hash: snapshot.diff_hash, actor_id: actorId, summary: verdict.approval!.summary,
|
|
311
313
|
...(snapshot.role === "review" && snapshot.review_revision ? { review_revision: snapshot.review_revision } : {}),
|
|
312
314
|
...(snapshot.role === "review" && advisories.length ? { advisory_findings: advisories } : {}),
|
|
315
|
+
...(snapshot.role === "review" && input.reviewer_verdict_sha256 ? { reviewer_verdict_sha256: input.reviewer_verdict_sha256 } : {}),
|
|
313
316
|
} : undefined;
|
|
314
317
|
const op = verdict.decision === "rework" ? "request_rework" : "record_approval";
|
|
315
318
|
const payload = approval ? { approval } : { findings };
|
|
@@ -6,6 +6,7 @@ export const PRIVILEGED_OPERATIONS = [
|
|
|
6
6
|
"approve_breaking_intent_revision",
|
|
7
7
|
"stop",
|
|
8
8
|
"start_unattended_batch",
|
|
9
|
+
"retire_stale_batch",
|
|
9
10
|
] as const;
|
|
10
11
|
|
|
11
12
|
export type PrivilegedOperation = (typeof PRIVILEGED_OPERATIONS)[number];
|
|
@@ -87,14 +88,28 @@ export interface NativeConfirmationInput {
|
|
|
87
88
|
max_parallel: number;
|
|
88
89
|
parallel_groups: string[][];
|
|
89
90
|
serialized: Array<{ task_id: string; overlaps_with: string[] }>;
|
|
91
|
+
final_verification?: string[];
|
|
92
|
+
/** ADR 0018: the gate offers the opt-in delegation of in-envelope Lane Intent revisions. */
|
|
93
|
+
offer_revision_delegation?: boolean;
|
|
90
94
|
};
|
|
91
95
|
};
|
|
96
|
+
/** Present only for a batch disposition: the record the user is asked to retire. */
|
|
97
|
+
batchDisposition?: {
|
|
98
|
+
batch_id: string;
|
|
99
|
+
batch_state: string;
|
|
100
|
+
batch_branch: string;
|
|
101
|
+
plan_digest: string;
|
|
102
|
+
recorded_commits: number;
|
|
103
|
+
children: Array<{ task_id: string; slice_id: string; state: string; commit: string | null; lane_branch: string | null }>;
|
|
104
|
+
};
|
|
92
105
|
signal?: AbortSignal;
|
|
93
106
|
}
|
|
94
107
|
|
|
95
108
|
export interface NativeConfirmationResult {
|
|
96
109
|
decision: NativeDecision;
|
|
97
110
|
requestId: string;
|
|
111
|
+
/** ADR 0018: true only when the user ticked the delegation in a lane batch gate. */
|
|
112
|
+
delegateRevisions?: boolean;
|
|
98
113
|
}
|
|
99
114
|
|
|
100
115
|
export type NativeConfirmationPort = (input: NativeConfirmationInput) => Promise<NativeConfirmationResult>;
|
|
@@ -55,9 +55,13 @@ 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";
|
|
63
|
+
import { retireStaleBatch } from "../unattended/batch_disposition";
|
|
64
|
+
import type { BatchLaneRunReport } from "../unattended/batch_state";
|
|
61
65
|
import {
|
|
62
66
|
createBatchAuthorityRegistry,
|
|
63
67
|
} from "../kernel/batch_authority";
|
|
@@ -107,42 +111,11 @@ function readTaskIntentForRecord(root: string, taskId: string) {
|
|
|
107
111
|
return readTaskIntent(root, taskId, currentPath);
|
|
108
112
|
}
|
|
109
113
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
if (typeof input === "object" && input !== null && !Array.isArray(input)) return input as Record<string, unknown>;
|
|
117
|
-
return null;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function verdictFingerprint(raw: Record<string, unknown>): string {
|
|
121
|
-
return JSON.stringify({
|
|
122
|
-
contract: raw.contract ?? null,
|
|
123
|
-
role: raw.role ?? null,
|
|
124
|
-
task_id: raw.task_id ?? null,
|
|
125
|
-
snapshot_digest: raw.snapshot_digest ?? null,
|
|
126
|
-
decision: raw.decision ?? null,
|
|
127
|
-
approval: raw.approval ?? null,
|
|
128
|
-
findings: raw.findings ?? null,
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const RELEASED_REVIEW_RECOVERY =
|
|
133
|
-
"Call advance_assurance to obtain a new Review reservation, then dispatch one fresh reviewer with the returned envelope unchanged";
|
|
134
|
-
const RETAINED_REVIEW_RECOVERY =
|
|
135
|
-
"Wait for the dispatched reviewer to finish, then call submit_review again with its verdict; do not dispatch or continue another reviewer";
|
|
136
|
-
const MISMATCH_REVIEW_RECOVERY =
|
|
137
|
-
"Resubmit the reviewer's verdict exactly as the reviewer returned it";
|
|
138
|
-
|
|
139
|
-
function withReviewRecovery(
|
|
140
|
-
result: AssuranceSubmitReviewResult,
|
|
141
|
-
recovery_action: string,
|
|
142
|
-
): AssuranceSubmitReviewResult {
|
|
143
|
-
if (result.state !== "blocked" || result.code === "verdict_invalid") return result;
|
|
144
|
-
return { ...result, recovery_action };
|
|
145
|
-
}
|
|
114
|
+
import {
|
|
115
|
+
extractVerdictJson,
|
|
116
|
+
submitMediatedReview,
|
|
117
|
+
} from "../assurance/review_mediation";
|
|
118
|
+
export { extractVerdictJson };
|
|
146
119
|
|
|
147
120
|
export async function submitClaudeReview(
|
|
148
121
|
host: ClaudeReviewHost,
|
|
@@ -151,24 +124,8 @@ export async function submitClaudeReview(
|
|
|
151
124
|
taskId: string,
|
|
152
125
|
verdictInput: unknown,
|
|
153
126
|
): Promise<AssuranceSubmitReviewResult> {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if (!observed.ok) {
|
|
157
|
-
if (observed.release) return withReviewRecovery(coordinator.abandonReview(taskId, observed.reason), RELEASED_REVIEW_RECOVERY);
|
|
158
|
-
return { state: "blocked", reason: observed.reason, recovery_action: RETAINED_REVIEW_RECOVERY };
|
|
159
|
-
}
|
|
160
|
-
const parentValid = coordinator.isReviewVerdictValid(taskId, verdictInput);
|
|
161
|
-
if (!parentValid) return coordinator.submitReview(taskId, ctx, verdictInput);
|
|
162
|
-
const receiptValid = coordinator.isReviewVerdictValid(taskId, observed.receipt.result);
|
|
163
|
-
if (!receiptValid) {
|
|
164
|
-
return withReviewRecovery(coordinator.abandonReview(taskId, "reviewer receipt is not a valid verdict"), RELEASED_REVIEW_RECOVERY);
|
|
165
|
-
}
|
|
166
|
-
const parentJson = extractVerdictJson(verdictInput)!;
|
|
167
|
-
const receiptJson = extractVerdictJson(observed.receipt.result)!;
|
|
168
|
-
if (verdictFingerprint(parentJson) !== verdictFingerprint(receiptJson)) {
|
|
169
|
-
return { state: "blocked", reason: "parent verdict does not match reviewer receipt", recovery_action: MISMATCH_REVIEW_RECOVERY };
|
|
170
|
-
}
|
|
171
|
-
return coordinator.submitReview(taskId, ctx, verdictInput);
|
|
127
|
+
// ADR 0017: an omitted verdict applies the hook-observed reviewer receipt.
|
|
128
|
+
return submitMediatedReview(coordinator, ctx, taskId, verdictInput, () => host.inspectReviewForTask(taskId));
|
|
172
129
|
}
|
|
173
130
|
|
|
174
131
|
/**
|
|
@@ -276,6 +233,7 @@ export interface ClaudeRuntimeOptions {
|
|
|
276
233
|
requestConfirmation?: NativeConfirmationPort;
|
|
277
234
|
batchKernel?: Partial<BatchRunnerKernelPort>;
|
|
278
235
|
batchGit?: BatchRunnerGitPort;
|
|
236
|
+
batchTracker?: BatchTrackerPort;
|
|
279
237
|
readInitiative?: InitiativeObservationReader;
|
|
280
238
|
}
|
|
281
239
|
|
|
@@ -285,6 +243,7 @@ export interface ClaudeRuntimeOptions {
|
|
|
285
243
|
*/
|
|
286
244
|
type ClaudeBatchStartResult =
|
|
287
245
|
| { state: "started"; batch_id: string; report: BatchRunReport }
|
|
246
|
+
| { state: "retired"; batch_id: string; report: BatchRunReport | BatchLaneRunReport }
|
|
288
247
|
| { state: "rejected"; reason: string; recovery_action: string }
|
|
289
248
|
| { state: "cancelled"; reason: string; recovery_action: string }
|
|
290
249
|
| { state: "blocked"; reason: string; recovery_action: string };
|
|
@@ -312,6 +271,7 @@ export class ClaudeRuntime {
|
|
|
312
271
|
private app: ReturnType<typeof createCanaryApplication> | null = null;
|
|
313
272
|
private readonly batchKernel?: Partial<BatchRunnerKernelPort>;
|
|
314
273
|
private readonly batchGit?: BatchRunnerGitPort;
|
|
274
|
+
private readonly batchTracker?: BatchTrackerPort;
|
|
315
275
|
private readonly readInitiative?: InitiativeObservationReader;
|
|
316
276
|
|
|
317
277
|
constructor(options: ClaudeRuntimeOptions) {
|
|
@@ -321,6 +281,7 @@ export class ClaudeRuntime {
|
|
|
321
281
|
this.requestConfirmation = options.requestConfirmation;
|
|
322
282
|
this.batchKernel = options.batchKernel;
|
|
323
283
|
this.batchGit = options.batchGit;
|
|
284
|
+
this.batchTracker = options.batchTracker;
|
|
324
285
|
this.readInitiative = options.readInitiative;
|
|
325
286
|
this.host = options.host ?? new ClaudeReviewHost(new FileHookEventLog());
|
|
326
287
|
this.coordinator = new AssuranceCoordinator({
|
|
@@ -785,7 +746,7 @@ export class ClaudeRuntime {
|
|
|
785
746
|
async startUnattendedBatch(
|
|
786
747
|
initiativeSlug: string,
|
|
787
748
|
meta: ToolMeta,
|
|
788
|
-
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 } = {},
|
|
789
750
|
): Promise<ClaudeBatchStartResult> {
|
|
790
751
|
throwIfCancelled(meta.signal);
|
|
791
752
|
const reuseOnly = options.reuseOnly === true;
|
|
@@ -823,6 +784,12 @@ export class ClaudeRuntime {
|
|
|
823
784
|
const budget = preflight.projection.budget;
|
|
824
785
|
const planDigest = preflight.projection.plan_digest;
|
|
825
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
|
+
}
|
|
826
793
|
// 5. Literal-user gate plus the shared reuse decision, the post-gate
|
|
827
794
|
// claim/drift cascade, and the Batch Authorization binding. The Host supplies
|
|
828
795
|
// only its gate, its confirmation reference, and its binding nonce.
|
|
@@ -847,7 +814,7 @@ export class ClaudeRuntime {
|
|
|
847
814
|
};
|
|
848
815
|
// The gate settles only on the literal user's answer or the caller's
|
|
849
816
|
// cancellation signal.
|
|
850
|
-
let confirmationResult: { decision: NativeDecision; requestId: string };
|
|
817
|
+
let confirmationResult: { decision: NativeDecision; requestId: string; delegateRevisions?: boolean };
|
|
851
818
|
try {
|
|
852
819
|
confirmationResult = await this.requestConfirmation!({
|
|
853
820
|
operation: "start_unattended_batch",
|
|
@@ -874,6 +841,8 @@ export class ClaudeRuntime {
|
|
|
874
841
|
max_parallel: options.max_parallel,
|
|
875
842
|
parallel_groups: facts.parallel_groups,
|
|
876
843
|
serialized: facts.scope_conflicts,
|
|
844
|
+
...(options.final_verification ? { final_verification: options.final_verification } : {}),
|
|
845
|
+
offer_revision_delegation: true,
|
|
877
846
|
},
|
|
878
847
|
}
|
|
879
848
|
: {}),
|
|
@@ -918,7 +887,11 @@ export class ClaudeRuntime {
|
|
|
918
887
|
return { kind: "host_rejection", value: batchReason("confirmation_cancelled") };
|
|
919
888
|
if (confirmationResult.decision !== "accept")
|
|
920
889
|
return { kind: "host_rejection", value: batchReason("confirmation_no_decision") };
|
|
921
|
-
return {
|
|
890
|
+
return {
|
|
891
|
+
kind: "confirmed",
|
|
892
|
+
request_id: confirmationResult.requestId,
|
|
893
|
+
...(options.max_parallel !== undefined && confirmationResult.delegateRevisions ? { delegate_revisions: true } : {}),
|
|
894
|
+
};
|
|
922
895
|
},
|
|
923
896
|
// A reused authorization keeps this invocation's Kernel binding without a new
|
|
924
897
|
// literal-user act, so its confirmation reference names the resumed batch
|
|
@@ -981,7 +954,16 @@ export class ClaudeRuntime {
|
|
|
981
954
|
now,
|
|
982
955
|
kernel: kernelPort,
|
|
983
956
|
git: this.batchGit,
|
|
984
|
-
...(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
|
+
: {}),
|
|
985
967
|
...(options.lane_offers !== undefined ? { lane_offers: options.lane_offers } : {}),
|
|
986
968
|
});
|
|
987
969
|
|
|
@@ -994,6 +976,85 @@ export class ClaudeRuntime {
|
|
|
994
976
|
report,
|
|
995
977
|
};
|
|
996
978
|
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* The batch disposition. One explicit literal-user decision retires a record
|
|
982
|
+
* the plan moved past: the terminal superseded state and its report preserve
|
|
983
|
+
* every child state, recorded commit and the batch branch, and no child is
|
|
984
|
+
* re-enrolled, handed off, or credited with delivery it never had.
|
|
985
|
+
*/
|
|
986
|
+
async retireStaleBatch(initiativeSlug: string, meta: ToolMeta): Promise<ClaudeBatchStartResult> {
|
|
987
|
+
throwIfCancelled(meta.signal);
|
|
988
|
+
const probe = probeHost(this.env, process.platform, this.hostVersion);
|
|
989
|
+
if (!probe.ok) throw new NativeAuthorityError("unsupported_host", probe.reason);
|
|
990
|
+
// The disposition always opens a gate, so it needs a confirmation port.
|
|
991
|
+
if (!this.requestConfirmation)
|
|
992
|
+
throw new NativeAuthorityError("interaction_not_opened", batchReason("confirmation_port_unavailable").reason);
|
|
993
|
+
const outcome = await retireStaleBatch<ClaudeBatchStartResult>({
|
|
994
|
+
root: this.cwd,
|
|
995
|
+
initiative_slug: initiativeSlug,
|
|
996
|
+
now: new Date().toISOString(),
|
|
997
|
+
gate: async (facts) => {
|
|
998
|
+
let confirmationResult: { decision: NativeDecision; requestId: string; delegateRevisions?: boolean };
|
|
999
|
+
try {
|
|
1000
|
+
confirmationResult = await this.requestConfirmation!({
|
|
1001
|
+
operation: "retire_stale_batch",
|
|
1002
|
+
initiativeSlug,
|
|
1003
|
+
toolCallId: meta.toolCallId,
|
|
1004
|
+
batchDisposition: {
|
|
1005
|
+
batch_id: facts.batch_id,
|
|
1006
|
+
batch_state: facts.batch_state,
|
|
1007
|
+
batch_branch: facts.branch,
|
|
1008
|
+
plan_digest: facts.plan_digest,
|
|
1009
|
+
recorded_commits: facts.recorded_commits.length,
|
|
1010
|
+
children: facts.children.map((child) => ({
|
|
1011
|
+
task_id: child.task_id,
|
|
1012
|
+
slice_id: child.slice_id,
|
|
1013
|
+
state: child.state,
|
|
1014
|
+
commit: child.commit,
|
|
1015
|
+
lane_branch: child.lane_branch,
|
|
1016
|
+
})),
|
|
1017
|
+
},
|
|
1018
|
+
signal: meta.signal,
|
|
1019
|
+
});
|
|
1020
|
+
} catch (err) {
|
|
1021
|
+
if (meta.signal?.aborted)
|
|
1022
|
+
return { kind: "host_rejection", value: batchReason("confirmation_cancelled") };
|
|
1023
|
+
if (err instanceof NativeAuthorityError) {
|
|
1024
|
+
if (err.reasonCode === "unsupported_host") throw err;
|
|
1025
|
+
if (err.reasonCode === "user_cancelled") {
|
|
1026
|
+
return {
|
|
1027
|
+
kind: "host_rejection",
|
|
1028
|
+
value: { state: "cancelled", reason: err.message, recovery_action: err.recoveryAction },
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
return {
|
|
1032
|
+
kind: "host_rejection",
|
|
1033
|
+
value: { state: "rejected", reason: err.message, recovery_action: err.recoveryAction },
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
return {
|
|
1037
|
+
kind: "host_rejection",
|
|
1038
|
+
value: batchReason("confirmation_failed", err instanceof Error ? err.message : String(err)),
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
if (confirmationResult.decision === "cancel" && meta.signal?.aborted)
|
|
1042
|
+
return { kind: "host_rejection", value: batchReason("confirmation_cancelled") };
|
|
1043
|
+
if (meta.signal?.aborted)
|
|
1044
|
+
return { kind: "host_rejection", value: batchReason("cancelled_before_execution") };
|
|
1045
|
+
if (confirmationResult.decision === "decline")
|
|
1046
|
+
return { kind: "host_rejection", value: batchReason("confirmation_declined") };
|
|
1047
|
+
if (confirmationResult.decision === "cancel")
|
|
1048
|
+
return { kind: "host_rejection", value: batchReason("confirmation_cancelled") };
|
|
1049
|
+
if (confirmationResult.decision !== "accept")
|
|
1050
|
+
return { kind: "host_rejection", value: batchReason("confirmation_no_decision") };
|
|
1051
|
+
return { kind: "confirmed", request_id: confirmationResult.requestId };
|
|
1052
|
+
},
|
|
1053
|
+
});
|
|
1054
|
+
if (outcome.outcome === "host_rejection") return outcome.value;
|
|
1055
|
+
if (outcome.outcome === "rejected") return outcome.rejection;
|
|
1056
|
+
return { state: "retired", batch_id: outcome.batch_id, report: outcome.report };
|
|
1057
|
+
}
|
|
997
1058
|
}
|
|
998
1059
|
|
|
999
1060
|
export interface ToolMeta {
|
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
import { laneWriteRefusal } from "../unattended/lane_workspace";
|
|
7
|
+
import { activeTaskNotice } from "../assurance/active_task_notice";
|
|
8
|
+
|
|
9
|
+
const FILE_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
10
|
+
|
|
11
|
+
/** The hook's stdout for one PreToolUse payload, or null to stay silent. */
|
|
12
|
+
export function laneGuardHookOutput(payload: Record<string, unknown>): string | null {
|
|
13
|
+
if (payload.hook_event_name !== "PreToolUse" || !FILE_TOOLS.has(String(payload.tool_name))) return null;
|
|
14
|
+
const input = (payload.tool_input ?? {}) as { file_path?: unknown; notebook_path?: unknown };
|
|
15
|
+
const cwd = typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd();
|
|
16
|
+
const refusal = laneWriteRefusal(cwd, input.file_path ?? input.notebook_path);
|
|
17
|
+
if (!refusal) return null;
|
|
18
|
+
return JSON.stringify({
|
|
19
|
+
hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: refusal },
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* UserPromptSubmit: when the workspace has an active Managed task, add the
|
|
25
|
+
* advisory notice as context. It never resumes the task.
|
|
26
|
+
*/
|
|
27
|
+
export async function activeTaskHookOutput(payload: Record<string, unknown>): Promise<string | null> {
|
|
28
|
+
if (payload.hook_event_name !== "UserPromptSubmit") return null;
|
|
29
|
+
const cwd = typeof payload.cwd === "string" && payload.cwd ? payload.cwd : process.cwd();
|
|
30
|
+
const notice = await activeTaskNotice(cwd);
|
|
31
|
+
if (!notice) return null;
|
|
32
|
+
return JSON.stringify({ hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: notice } });
|
|
33
|
+
}
|