immune-brain 5.3.0 → 5.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/README.zh-CN.md +4 -4
- package/package.json +2 -1
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-enroll.ts +54 -70
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +163 -28
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +71 -10
- package/plugins/immune-brain/.pi-extension/pi-canary-tool-failure.ts +1 -1
- package/plugins/immune-brain/.pi-extension/pi-run-continuation.ts +161 -0
- package/plugins/immune-brain/agents/immune-brain-reviewer.md +26 -2
- package/plugins/immune-brain/dist/BASELINE.md +13 -1
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +16130 -12734
- package/plugins/immune-brain/dist/docs/reference/code-quality-guard.md +3 -1
- package/plugins/immune-brain/dist/docs/reference/immune-brain-config.md +41 -1
- package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +1 -1
- package/plugins/immune-brain/dist/imm-planner.md +57 -16
- package/plugins/immune-brain/dist/imm-run-batch.md +398 -0
- package/plugins/immune-brain/dist/imm-run.md +89 -344
- package/plugins/immune-brain/dist/role-prompts/code-review.md +26 -2
- package/plugins/immune-brain/dist/role-prompts/executor.md +35 -10
- package/plugins/immune-brain/hooks/hooks.json +21 -1
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +203 -13
- package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +2 -1
- package/plugins/immune-brain/runtime/assurance/enrollment_git_base.ts +99 -14
- package/plugins/immune-brain/runtime/assurance/phase_entry.ts +1 -7
- package/plugins/immune-brain/runtime/assurance/qa.ts +126 -43
- package/plugins/immune-brain/runtime/assurance/qa_findings.ts +8 -0
- package/plugins/immune-brain/runtime/assurance/review_evidence.ts +48 -0
- package/plugins/immune-brain/runtime/assurance/run_continuation.ts +284 -0
- package/plugins/immune-brain/runtime/assurance/verdict_authority.ts +46 -4
- package/plugins/immune-brain/runtime/assurance/verification.ts +83 -35
- package/plugins/immune-brain/runtime/assurance/workflow_autocommit.ts +222 -0
- package/plugins/immune-brain/runtime/claude/interaction.ts +11 -2
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +199 -48
- package/plugins/immune-brain/runtime/claude/lane_guard.ts +8 -15
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +105 -9
- package/plugins/immune-brain/runtime/claude/run_continuation.ts +274 -0
- package/plugins/immune-brain/runtime/commands/kernel.ts +41 -103
- package/plugins/immune-brain/runtime/github_issue_tracker.ts +60 -2
- package/plugins/immune-brain/runtime/kernel/application.ts +4 -1
- package/plugins/immune-brain/runtime/kernel/assurance_projection.ts +103 -20
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +57 -7
- package/plugins/immune-brain/runtime/kernel/completion.ts +28 -4
- package/plugins/immune-brain/runtime/kernel/enrollment.ts +48 -8
- package/plugins/immune-brain/runtime/kernel/intent.ts +169 -104
- package/plugins/immune-brain/runtime/kernel/reducer.ts +136 -6
- package/plugins/immune-brain/runtime/kernel/risk_floor.ts +286 -0
- package/plugins/immune-brain/runtime/kernel/storage_layout_migration.ts +83 -1
- package/plugins/immune-brain/runtime/kernel/storage_paths.ts +1 -1
- package/plugins/immune-brain/runtime/kernel/types.ts +49 -3
- package/plugins/immune-brain/runtime/kernel/validation.ts +147 -7
- package/plugins/immune-brain/runtime/loop_contract.ts +5 -0
- package/plugins/immune-brain/runtime/plan_core.ts +15 -8
- package/plugins/immune-brain/runtime/plugin_source_skew.ts +58 -0
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +26 -2
- package/plugins/immune-brain/runtime/prompts/executor.md +35 -10
- package/plugins/immune-brain/runtime/unattended/batch_delegation.ts +5 -3
- package/plugins/immune-brain/runtime/unattended/batch_disposition.ts +40 -10
- package/plugins/immune-brain/runtime/unattended/batch_final_verification.ts +57 -3
- package/plugins/immune-brain/runtime/unattended/batch_git.ts +424 -66
- package/plugins/immune-brain/runtime/unattended/batch_integration.ts +2 -1
- package/plugins/immune-brain/runtime/unattended/batch_kernel_port.ts +13 -0
- package/plugins/immune-brain/runtime/unattended/batch_lanes.ts +43 -24
- package/plugins/immune-brain/runtime/unattended/batch_plan.ts +6 -2
- package/plugins/immune-brain/runtime/unattended/batch_preflight.ts +102 -41
- package/plugins/immune-brain/runtime/unattended/batch_reasons.ts +9 -3
- package/plugins/immune-brain/runtime/unattended/batch_reconfirmation.ts +9 -9
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +90 -32
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +102 -4
- package/plugins/immune-brain/runtime/unattended/parent_delivery_consolidation.ts +248 -0
- package/plugins/immune-brain/runtime/workspace_scope.ts +126 -18
- package/plugins/immune-brain/skills/BASELINE.md +13 -1
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +5 -0
- package/plugins/immune-brain/skills/imm-run/SKILL.md +12 -8
|
@@ -318,7 +318,8 @@ export async function verifyCandidateDescriptors(input: {
|
|
|
318
318
|
};
|
|
319
319
|
let verdict;
|
|
320
320
|
try {
|
|
321
|
-
|
|
321
|
+
// The candidate is a merge-tree commit that is never checked out.
|
|
322
|
+
verdict = await runDeterministicQa(snapshot, descriptors, { signal: input.signal, delivery: "materialized" });
|
|
322
323
|
} catch (error) {
|
|
323
324
|
if (error instanceof VerificationAbortedError) throw error;
|
|
324
325
|
throw new BatchIntegrationError(
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// Kernel-facing member; a Host supplies only advanceTask, its own progression
|
|
4
4
|
// seam. Git operations are not Kernel-port members: the runner drives them
|
|
5
5
|
// through BatchRunnerGitPort alone.
|
|
6
|
+
import { observeParentCompletion } from "./batch_git";
|
|
7
|
+
import type { InitiativeObservation } from "./types";
|
|
6
8
|
import { projectAssurance } from "../kernel/assurance_projection";
|
|
7
9
|
import { deriveChildEnrollment, type BatchAuthorizationBinding, type BatchAuthorityRegistry } from "../kernel/batch_authority";
|
|
8
10
|
import { enrollTask } from "../kernel/enrollment";
|
|
@@ -16,6 +18,7 @@ import { batchQaFailureFacts, type BatchChildAdvanceResult, type BatchRunnerKern
|
|
|
16
18
|
export interface BatchKernelPortInput {
|
|
17
19
|
/** Worktree root the batch runs in. */
|
|
18
20
|
root: string;
|
|
21
|
+
parent?: InitiativeObservation;
|
|
19
22
|
/** Enrollment authority registry shared with the single Enrollment entry (D3). */
|
|
20
23
|
enrollmentRegistry: EnrollmentAuthorityRegistry;
|
|
21
24
|
/** The batch's registry, derived capability and validated binding. */
|
|
@@ -53,6 +56,16 @@ export function createBatchKernelPort(input: BatchKernelPortInput): BatchRunnerK
|
|
|
53
56
|
const deriveEnrollment = input.deps?.deriveChildEnrollment ?? deriveChildEnrollment;
|
|
54
57
|
const enrollKernelTask = input.deps?.enrollTask ?? enrollTask;
|
|
55
58
|
const base: BatchRunnerKernelPort = {
|
|
59
|
+
canStartTask: async taskId => {
|
|
60
|
+
if (!input.parent) return true;
|
|
61
|
+
const task = input.parent.tasks.find(child => child.task_id === taskId);
|
|
62
|
+
if (!task) throw new Error("authorized child absent from confirmed Parent topology");
|
|
63
|
+
const authorizedIds = new Set(input.registry.children(input.capability).map(child => child.task_id));
|
|
64
|
+
const external = task.blocked_by.filter(id => !authorizedIds.has(id));
|
|
65
|
+
if (!external.length) return true;
|
|
66
|
+
const observation = await observeParentCompletion(input.root, input.parent.initiative_id, external);
|
|
67
|
+
return observation.missing_task_ids.length === 0;
|
|
68
|
+
},
|
|
56
69
|
enrollTask: async ({ root, task_id, batch }) => {
|
|
57
70
|
const now = new Date().toISOString();
|
|
58
71
|
const derived = deriveEnrollment(root, batch.registry, {
|
|
@@ -12,6 +12,7 @@ import { isAbsolute, join } from "node:path";
|
|
|
12
12
|
import { computeBatchPlanDigest } from "../kernel/batch_authority";
|
|
13
13
|
import { readTaskRecordRaw } from "../kernel/storage";
|
|
14
14
|
import { readTaskIntent } from "../kernel/intent";
|
|
15
|
+
import { applyScopeRiskFloor, readRiskFloor } from "../kernel/risk_floor";
|
|
15
16
|
import { readTaskTombstone } from "../kernel/backend_claim";
|
|
16
17
|
import {
|
|
17
18
|
readRuntimeContracts,
|
|
@@ -228,14 +229,19 @@ export function laneRuntimeContractRefusal(root: string, executorRuntime?: strin
|
|
|
228
229
|
return `${RUNTIME_CONTRACT_MISMATCH}: coordinator runtime ${runningRuntimeSource()} and Lane runtime ${lane.path} disagree (${differences.join("; ")}); load the same runtime on both sides before starting the batch`;
|
|
229
230
|
}
|
|
230
231
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
232
|
+
function laneUnreadableReason(error: string, taskId: string, runId: string | null | undefined): string {
|
|
233
|
+
const category = /contract must equal|unknown field|schema version .* incompatible|unknown contract/i.test(error)
|
|
234
|
+
? LANE_CONTRACT_MISMATCH : "batch_lane_unreadable";
|
|
235
|
+
// Persist identity and stage, never arbitrary parser/IO exception bytes.
|
|
236
|
+
return `${category}: ${JSON.stringify({ stage: "project_task", task_id: taskId, run_id: runId ?? null })}`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function readLaneProjection(input: StartBatchInput, path: string, taskId: string) {
|
|
240
|
+
try {
|
|
241
|
+
return await input.kernel.projectTask(path, taskId);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
return { error: error instanceof Error ? error.message : String(error) } as Awaited<ReturnType<StartBatchInput["kernel"]["projectTask"]>>;
|
|
244
|
+
}
|
|
239
245
|
}
|
|
240
246
|
|
|
241
247
|
/** The branch the Lane for a child must sit on. */
|
|
@@ -393,7 +399,7 @@ function laneGitOf(input: StartBatchInput): BatchLaneGitPort {
|
|
|
393
399
|
return (input.git as (BatchRunnerGitPort & { lane?: BatchLaneGitPort }) | undefined)?.lane ?? createDefaultLaneGitPort();
|
|
394
400
|
}
|
|
395
401
|
|
|
396
|
-
function serialGitOf(input: StartBatchInput): Pick<BatchRunnerGitPort, "preflight" | "commitChild" | "lookupBatchCommit"> {
|
|
402
|
+
function serialGitOf(input: StartBatchInput): Pick<BatchRunnerGitPort, "preflight" | "commitChild" | "lookupBatchCommit" | "retainLaneEvidence"> {
|
|
397
403
|
return input.git ?? defaultSerialGit();
|
|
398
404
|
}
|
|
399
405
|
|
|
@@ -791,9 +797,11 @@ export async function runLaneBatch(
|
|
|
791
797
|
if (!child.lane?.run_id)
|
|
792
798
|
return laneReport(record, "parked Lane has no recorded run identity; inspect it without re-enrolling", LANE_NEEDS_HUMAN_NEXT_ACTION);
|
|
793
799
|
const facts = lanes.inspectLane(input.root, child.lane.path);
|
|
794
|
-
const fresh = await input
|
|
800
|
+
const fresh = await readLaneProjection(input, child.lane.path, child.task_id);
|
|
801
|
+
if (fresh.error !== null)
|
|
802
|
+
return laneReport(record, laneUnreadableReason(fresh.error, child.task_id, child.lane.run_id), LANE_NEEDS_HUMAN_NEXT_ACTION);
|
|
795
803
|
if (!facts.exists || !facts.same_repository || !facts.is_worktree_root || facts.branch !== child.lane.branch ||
|
|
796
|
-
fresh.
|
|
804
|
+
fresh.projection.run_id !== child.lane.run_id ||
|
|
797
805
|
fresh.projection.open_user_decision_count > 0 || fresh.projection.replan_required_ids.length > 0)
|
|
798
806
|
return laneReport(record, "parked Lane identity or decision is unresolved", LANE_NEEDS_HUMAN_NEXT_ACTION);
|
|
799
807
|
const state = fresh.projection.lifecycle === "done" && fresh.projection.completion_ready ?
|
|
@@ -876,6 +884,8 @@ export async function runLaneBatch(
|
|
|
876
884
|
lane_commit: child.lane.lane_commit,
|
|
877
885
|
});
|
|
878
886
|
if (!found) continue;
|
|
887
|
+
await git.retainLaneEvidence?.({ root: input.root, taskId: child.task_id, batchId: record.batch_id,
|
|
888
|
+
branch: record.branch, lane: { ...child.lane, lane_commit: child.lane.lane_commit, integrated_commit: found } });
|
|
879
889
|
record.children = record.children.map((c) => (c.task_id === child.task_id ? { ...c, state: "integrated", commit: found } : c));
|
|
880
890
|
record.commits = [...record.commits, found];
|
|
881
891
|
persist();
|
|
@@ -958,14 +968,17 @@ export async function runLaneBatch(
|
|
|
958
968
|
const authorized = readTaskIntent(input.root, request.task_id, planChild?.intent_path ?? undefined).intent;
|
|
959
969
|
const current = readTaskRecordRaw(child.lane.path, request.task_id).record?.intent_snapshot;
|
|
960
970
|
if (!current) throw new Error(`the Lane has no TaskRecord for ${request.task_id}`);
|
|
961
|
-
|
|
971
|
+
// The Lane's own declaration decides the floor of the revision it applies.
|
|
972
|
+
const floor = readRiskFloor(child.lane.path);
|
|
973
|
+
const nextIntent = applyScopeRiskFloor(request.next_intent, floor);
|
|
974
|
+
const outOfBounds = delegatedRevisionRefusal(authorized, current, nextIntent, floor);
|
|
962
975
|
if (outOfBounds) refuseRevision(outOfBounds);
|
|
963
976
|
else {
|
|
964
977
|
const confirmationRef = delegatedConfirmationRef(record.batch_id, grant);
|
|
965
978
|
const applied = await applyDelegatedIntentRevision({
|
|
966
979
|
lane_root: child.lane.path,
|
|
967
980
|
task_id: request.task_id,
|
|
968
|
-
next_intent:
|
|
981
|
+
next_intent: nextIntent,
|
|
969
982
|
confirmation_ref: confirmationRef,
|
|
970
983
|
now: input.now,
|
|
971
984
|
});
|
|
@@ -998,8 +1011,9 @@ export async function runLaneBatch(
|
|
|
998
1011
|
task_id: taskId,
|
|
999
1012
|
batch: { registry: input.registry, capability: input.capability, binding: { batch_id: record.batch_id, expected_head: lane.base_head } },
|
|
1000
1013
|
});
|
|
1001
|
-
const fresh = await input
|
|
1002
|
-
if (fresh.error !== null
|
|
1014
|
+
const fresh = await readLaneProjection(input, lane.path, taskId);
|
|
1015
|
+
if (fresh.error !== null) throw new Error(laneUnreadableReason(fresh.error, taskId, lane.run_id));
|
|
1016
|
+
if (!fresh.projection.run_id || fresh.claim?.task_id !== taskId)
|
|
1003
1017
|
throw new Error("enrolled Lane has no observable owned run");
|
|
1004
1018
|
record.children = record.children.map((c) => (c.task_id === taskId ? { ...c, state: "enrolled", reason: null, lane: { ...lane, run_id: fresh.projection.run_id } } : c));
|
|
1005
1019
|
persist();
|
|
@@ -1019,14 +1033,9 @@ export async function runLaneBatch(
|
|
|
1019
1033
|
// Nothing is inferred from terminal text; only Kernel facts move a child.
|
|
1020
1034
|
for (const child of [...record.children]) {
|
|
1021
1035
|
if ((child.state !== "lane_admitted" && child.state !== "enrolled") || !child.lane) continue;
|
|
1022
|
-
|
|
1023
|
-
try {
|
|
1024
|
-
fresh = await input.kernel.projectTask(child.lane.path, child.task_id);
|
|
1025
|
-
} catch (error) {
|
|
1026
|
-
fresh = { error: error instanceof Error ? error.message : String(error) } as unknown as typeof fresh;
|
|
1027
|
-
}
|
|
1036
|
+
const fresh = await readLaneProjection(input, child.lane.path, child.task_id);
|
|
1028
1037
|
if (fresh.error !== null) {
|
|
1029
|
-
parkChild(child.task_id, laneUnreadableReason(
|
|
1038
|
+
parkChild(child.task_id, laneUnreadableReason(fresh.error, child.task_id, child.lane.run_id));
|
|
1030
1039
|
continue;
|
|
1031
1040
|
}
|
|
1032
1041
|
if (child.lane.run_id && fresh.projection.run_id !== child.lane.run_id) {
|
|
@@ -1167,6 +1176,8 @@ export async function runLaneBatch(
|
|
|
1167
1176
|
.filter((c) => c.state === "integrated" && c.task_id !== child.task_id)
|
|
1168
1177
|
.map((c) => ({ task_id: c.task_id, intent_path: intentPathOf(c.task_id), commit: c.commit })),
|
|
1169
1178
|
});
|
|
1179
|
+
await git.retainLaneEvidence?.({ root: input.root, taskId: child.task_id, batchId: record.batch_id,
|
|
1180
|
+
branch: record.branch, lane: { ...child.lane, lane_commit: child.lane.lane_commit, integrated_commit: commit } });
|
|
1170
1181
|
record.children = record.children.map((c) => (c.task_id === child.task_id ? { ...c, state: "integrated", commit, reason: null } : c));
|
|
1171
1182
|
record.commits = [...record.commits, commit];
|
|
1172
1183
|
persist();
|
|
@@ -1199,6 +1210,10 @@ export async function runLaneBatch(
|
|
|
1199
1210
|
refusals.push({ ...offer, reason: "batch_lane_unknown_child" });
|
|
1200
1211
|
continue;
|
|
1201
1212
|
}
|
|
1213
|
+
if (input.kernel.canStartTask && !await input.kernel.canStartTask(offer.task_id)) {
|
|
1214
|
+
refusals.push({ ...offer, reason: "Parent prerequisite is not integrated" });
|
|
1215
|
+
continue;
|
|
1216
|
+
}
|
|
1202
1217
|
const head = expectedBatchHead(record);
|
|
1203
1218
|
const facts = lanes.inspectLane(input.root, offer.path);
|
|
1204
1219
|
const reason = decideLaneAdmission({
|
|
@@ -1227,6 +1242,7 @@ export async function runLaneBatch(
|
|
|
1227
1242
|
// completion report is written. A failure is reported, never rolled back.
|
|
1228
1243
|
const final = record.final_verification?.length ? await runFinalVerification(input.root, record.final_verification) : undefined;
|
|
1229
1244
|
record.batch_state = "completed";
|
|
1245
|
+
if (final) record.final_verification_result = final;
|
|
1230
1246
|
persist();
|
|
1231
1247
|
return finalizeLane(
|
|
1232
1248
|
input.root,
|
|
@@ -1256,8 +1272,11 @@ export async function runLaneBatch(
|
|
|
1256
1272
|
});
|
|
1257
1273
|
continue;
|
|
1258
1274
|
}
|
|
1259
|
-
const fresh = await input
|
|
1260
|
-
if (fresh.error !== null)
|
|
1275
|
+
const fresh = await readLaneProjection(input, child.lane.path, child.task_id);
|
|
1276
|
+
if (fresh.error !== null) {
|
|
1277
|
+
parkChild(child.task_id, laneUnreadableReason(fresh.error, child.task_id, child.lane.run_id));
|
|
1278
|
+
continue;
|
|
1279
|
+
}
|
|
1261
1280
|
handoffs.push({
|
|
1262
1281
|
role: "executor",
|
|
1263
1282
|
task_id: child.task_id,
|
|
@@ -5,6 +5,7 @@ import { hasLocalInitiative, observeLocalInitiative } from "../local_initiative"
|
|
|
5
5
|
import { readTaskTombstone } from "../kernel/backend_claim";
|
|
6
6
|
import {
|
|
7
7
|
observeTaskIntent,
|
|
8
|
+
observeEnrollmentTaskIntent,
|
|
8
9
|
TaskIntentObservationError,
|
|
9
10
|
} from "../kernel/intent";
|
|
10
11
|
import { readTaskRecordRaw } from "../kernel/storage";
|
|
@@ -155,6 +156,7 @@ export async function projectBatchPlan(
|
|
|
155
156
|
initiativeSlug: string,
|
|
156
157
|
input: ProjectBatchPlanInput,
|
|
157
158
|
readInitiative: InitiativeObservationReader = observeInitiative,
|
|
159
|
+
preparing = false,
|
|
158
160
|
): Promise<BatchPlan> {
|
|
159
161
|
if (!ID_PATTERN.test(initiativeSlug)) throw new Error("initiative_slug is invalid");
|
|
160
162
|
if (!input || typeof input !== "object") throw new Error("batch plan input is required");
|
|
@@ -180,7 +182,9 @@ export async function projectBatchPlan(
|
|
|
180
182
|
}
|
|
181
183
|
const intentPath = `docs/plans/${task.task_id}.intent.json`;
|
|
182
184
|
try {
|
|
183
|
-
const read =
|
|
185
|
+
const read = preparing
|
|
186
|
+
? observeEnrollmentTaskIntent(root, task.task_id)
|
|
187
|
+
: observeTaskIntent(root, task.task_id, intentPath);
|
|
184
188
|
if (read.intent.risk === "critical") {
|
|
185
189
|
children.push({
|
|
186
190
|
...child,
|
|
@@ -224,7 +228,7 @@ export async function projectBatchPlan(
|
|
|
224
228
|
if (child.status !== "enrollable") continue;
|
|
225
229
|
if (directDependencies.get(child.task_id)!.some((id) => {
|
|
226
230
|
const status = childById.get(id)!.status;
|
|
227
|
-
return status === "already_owned" || status === "needs_human" || status === "blocked";
|
|
231
|
+
return status === "already_owned" || (status === "needs_human" && childById.get(id)!.reason !== "critical") || status === "blocked";
|
|
228
232
|
})) {
|
|
229
233
|
child.status = "blocked";
|
|
230
234
|
child.reason = "dependency_unavailable";
|
|
@@ -19,9 +19,10 @@ import { LITERAL_USER_ACTOR_ID } from "../kernel/actor_identity";
|
|
|
19
19
|
import { readBackendClaim } from "../kernel/backend_claim";
|
|
20
20
|
import { computeBatchPlanDigest, type BatchAuthorizationBinding } from "../kernel/batch_authority";
|
|
21
21
|
import { readGitHead } from "../kernel/pi_canary_prepare";
|
|
22
|
-
import { readTaskIntent } from "../kernel/intent";
|
|
23
|
-
import { readTaskRecordRaw, readSettledTaskEvidence, readWorkspaceStateRaw, enrollmentEventIdFor, reconcileKernelAuthority } from "../kernel/storage";
|
|
22
|
+
import { readTaskIntent, observeEnrollmentTaskIntent } from "../kernel/intent";
|
|
23
|
+
import { readTaskRecordRaw, readSettledTaskEvidence, readWorkspaceStateRaw, enrollmentEventIdFor, reconcileKernelAuthority, withKernelStoreLock } from "../kernel/storage";
|
|
24
24
|
import type { TaskRecord } from "../kernel/types";
|
|
25
|
+
import { commitWorkflowPaths } from "../assurance/workflow_autocommit";
|
|
25
26
|
import { pathMatchesScope } from "../workspace_scope";
|
|
26
27
|
import { captureBatchReconfirmation, retainReconfirmation, ownUnpersistedBatchHead, type ReconfirmationSnapshot } from "./batch_reconfirmation";
|
|
27
28
|
import { projectBatchPlan } from "./batch_plan";
|
|
@@ -419,6 +420,7 @@ type PlanSurfaceOutcome =
|
|
|
419
420
|
* and the post-confirmation drift check so both read one implementation.
|
|
420
421
|
*/
|
|
421
422
|
async function projectPlanSurface(input: {
|
|
423
|
+
preparing?: boolean;
|
|
422
424
|
root: string;
|
|
423
425
|
initiative_slug: string;
|
|
424
426
|
is_resuming: boolean;
|
|
@@ -500,7 +502,7 @@ async function projectPlanSurface(input: {
|
|
|
500
502
|
} else {
|
|
501
503
|
let plan;
|
|
502
504
|
try {
|
|
503
|
-
plan = await projectBatchPlan(root, initiativeSlug, { confirmation_time: now }, input.readInitiative);
|
|
505
|
+
plan = await projectBatchPlan(root, initiativeSlug, { confirmation_time: now }, input.readInitiative, input.preparing);
|
|
504
506
|
} catch (err) {
|
|
505
507
|
const msg = err instanceof Error ? err.message : String(err);
|
|
506
508
|
if (msg.includes("has no enrollable children"))
|
|
@@ -528,7 +530,9 @@ async function projectPlanSurface(input: {
|
|
|
528
530
|
for (const c of plan.children.filter((item) => item.status === "enrollable")) {
|
|
529
531
|
let childRisk = "material";
|
|
530
532
|
try {
|
|
531
|
-
childRisk =
|
|
533
|
+
childRisk = (input.preparing
|
|
534
|
+
? observeEnrollmentTaskIntent(root, c.task_id)
|
|
535
|
+
: readTaskIntent(root, c.task_id, c.intent_path ?? undefined)).intent.risk;
|
|
532
536
|
} catch {
|
|
533
537
|
// fallback
|
|
534
538
|
}
|
|
@@ -555,8 +559,62 @@ async function projectPlanSurface(input: {
|
|
|
555
559
|
};
|
|
556
560
|
}
|
|
557
561
|
|
|
558
|
-
export async function projectBatchPreflight(
|
|
559
|
-
options
|
|
562
|
+
export async function projectBatchPreflight(options: BatchPreflightOptions): Promise<BatchPreflightOutcome> {
|
|
563
|
+
return projectPreflight(options, false);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Host preparation only: commit fresh-batch preconditions before capturing the binding. */
|
|
567
|
+
export async function prepareBatchPreflight(
|
|
568
|
+
options: BatchPreflightOptions & { signal?: AbortSignal; reuseOnly?: boolean },
|
|
569
|
+
): Promise<BatchPreflightOutcome & { precondition_commit?: string }> {
|
|
570
|
+
options.signal?.throwIfAborted();
|
|
571
|
+
const projected = await projectPreflight(options, !options.reuseOnly);
|
|
572
|
+
if (!projected.ok || projected.projection.is_resuming || options.reuseOnly) return projected;
|
|
573
|
+
const { root, initiative_slug: slug } = options;
|
|
574
|
+
const initialEntries = porcelainEntries(root);
|
|
575
|
+
if (!initialEntries) return reject("git_status_unreadable");
|
|
576
|
+
if (initialEntries.length === 0) return projected;
|
|
577
|
+
let commit: string | undefined;
|
|
578
|
+
try {
|
|
579
|
+
const refusal = withKernelStoreLock(root, () => {
|
|
580
|
+
options.signal?.throwIfAborted();
|
|
581
|
+
const claim = readActiveClaimTaskId(root);
|
|
582
|
+
if (claim) return reject("claim_already_active", claim);
|
|
583
|
+
if (findExistingActiveBatch(root, slug)) return reject("plan_changed");
|
|
584
|
+
if (readGitHead(root) !== projected.projection.base_head) return reject("head_moved");
|
|
585
|
+
const scopes: string[] = [];
|
|
586
|
+
for (const child of projected.projection.recovery_children) {
|
|
587
|
+
const observed = observeEnrollmentTaskIntent(root, child.task_id);
|
|
588
|
+
if (observed.content_hash !== child.intent_content_hash) return reject("plan_changed");
|
|
589
|
+
scopes.push(...observed.intent.scope_hint);
|
|
590
|
+
}
|
|
591
|
+
const entries = porcelainEntries(root);
|
|
592
|
+
if (!entries) return reject("git_status_unreadable");
|
|
593
|
+
const paths = entries.map(({ path }) => path).filter((path) =>
|
|
594
|
+
[".imm/audit/", "docs/plans/", "docs/specs/"].some((prefix) => path.startsWith(prefix))
|
|
595
|
+
|| scopes.some((scope) => pathMatchesScope(path, scope)),
|
|
596
|
+
);
|
|
597
|
+
const result = commitWorkflowPaths(root, {
|
|
598
|
+
paths, subject: `chore(imm): commit batch preconditions for ${slug}`, signal: options.signal,
|
|
599
|
+
});
|
|
600
|
+
if (result.committed) commit = result.commit;
|
|
601
|
+
return null;
|
|
602
|
+
});
|
|
603
|
+
if (refusal) return refusal;
|
|
604
|
+
if (!commit) {
|
|
605
|
+
const remaining = porcelainEntries(root);
|
|
606
|
+
if (!remaining) return reject("git_status_unreadable");
|
|
607
|
+
return remaining.length ? reject("working_tree_dirty", remaining.map(({ path }) => path).join(", ")) : projected;
|
|
608
|
+
}
|
|
609
|
+
return { ...await projectBatchPreflight(options), precondition_commit: commit };
|
|
610
|
+
} catch (error) {
|
|
611
|
+
return { ...reject("precondition_commit_failed", error instanceof Error ? error.message : String(error)),
|
|
612
|
+
...(commit ? { precondition_commit: commit } : {}) };
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
async function projectPreflight(
|
|
617
|
+
options: BatchPreflightOptions, allowNewDirt: boolean,
|
|
560
618
|
): Promise<BatchPreflightOutcome> {
|
|
561
619
|
const { root, initiative_slug: initiativeSlug, readInitiative } = options;
|
|
562
620
|
if (!INITIATIVE_SLUG_PATTERN.test(initiativeSlug))
|
|
@@ -609,49 +667,52 @@ export async function projectBatchPreflight(
|
|
|
609
667
|
else if (plan.kind === "unprovable") unprovableLeak = `${plan.path}: ${plan.detail}`;
|
|
610
668
|
}
|
|
611
669
|
if (statusEntries.length > 0) {
|
|
612
|
-
if (!isResuming)
|
|
613
|
-
return reject("working_tree_dirty");
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
670
|
+
if (!isResuming && !allowNewDirt)
|
|
671
|
+
return reject("working_tree_dirty", statusEntries.map(({ path }) => path).join(", "));
|
|
672
|
+
if (isResuming) {
|
|
673
|
+
// Kernel projections accept staged in-flight work inside the active child's
|
|
674
|
+
// authorized scope, and reject unstaged/untracked bytes or out-of-scope paths.
|
|
675
|
+
// `settled` belongs here: Kernel settlement happens before the batch commits
|
|
676
|
+
// the child, and settlement clears the live state record, so a crash in that
|
|
677
|
+
// window resumes into a settled child whose staged work is legitimate.
|
|
678
|
+
const inFlightChild = existingBatch!.children.find(
|
|
679
|
+
(c) => c.state === "enrolled" || c.state === "needs_human" || c.state === "settled",
|
|
680
|
+
);
|
|
681
|
+
let authorizedScope: string[] = [];
|
|
682
|
+
if (inFlightChild) {
|
|
683
|
+
authorizedScope = authorizedScopeOf(root, inFlightChild.task_id, inFlightChild.state);
|
|
684
|
+
if (authorizedScope.length === 0)
|
|
685
|
+
return reject("authorized_scope_underivable");
|
|
686
|
+
}
|
|
687
|
+
const dirtyBytes = statusEntries.some(({ code }) => code === "??" || code[1] !== " ");
|
|
688
|
+
if (dirtyBytes)
|
|
689
|
+
return reject("working_tree_unstaged", unprovableLeak);
|
|
690
|
+
let outsideScope = false;
|
|
691
|
+
for (const { path } of statusEntries) {
|
|
692
|
+
if (path.startsWith(".imm/") || path.startsWith("docs/plans/") || path.startsWith("docs/specs/")) continue;
|
|
693
|
+
// Scope entries may be exact files, directories, or globs; delegate the
|
|
694
|
+
// boundary matching to the Kernel's own helper instead of exact includes.
|
|
695
|
+
let matched = false;
|
|
696
|
+
for (const scopePath of authorizedScope) {
|
|
697
|
+
if (pathMatchesScope(path, scopePath)) {
|
|
698
|
+
matched = true;
|
|
699
|
+
break;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
if (!matched) {
|
|
703
|
+
outsideScope = true;
|
|
640
704
|
break;
|
|
641
705
|
}
|
|
642
706
|
}
|
|
643
|
-
if (
|
|
644
|
-
|
|
645
|
-
break;
|
|
646
|
-
}
|
|
707
|
+
if (outsideScope)
|
|
708
|
+
return reject("working_tree_out_of_scope");
|
|
647
709
|
}
|
|
648
|
-
if (outsideScope)
|
|
649
|
-
return reject("working_tree_out_of_scope");
|
|
650
710
|
}
|
|
651
711
|
|
|
652
712
|
// 3. Project or reconstruct the plan (pre-confirmation).
|
|
653
713
|
const now = options.now ?? new Date().toISOString();
|
|
654
714
|
const planSurface = await projectPlanSurface({
|
|
715
|
+
preparing: allowNewDirt && !isResuming,
|
|
655
716
|
root,
|
|
656
717
|
initiative_slug: initiativeSlug,
|
|
657
718
|
is_resuming: isResuming,
|
|
@@ -665,7 +726,7 @@ export async function projectBatchPreflight(
|
|
|
665
726
|
if (activeRecord && !isLaneBatchRecord(activeRecord) && activeRecord.plan_digest !== planSurface.surface.plan_digest) {
|
|
666
727
|
try { reconfirmation = await captureBatchReconfirmation(root, activeRecord, planSurface.surface.recovery_children); }
|
|
667
728
|
catch { return reject("plan_projection_failed", activeRecord.commits.length
|
|
668
|
-
? `batch plan changed after ${activeRecord.commits.length} recorded child commit(s). The old authorization cannot execute the revised plan. Preserve the batch record, report, commit evidence and audit; inspect each remaining child's Kernel run before an explicit imm-run handoff. Do not re-enroll settled children or manufacture batch trailers. To close the parked record explicitly, dispose it with the retire_stale_batch Tool for this Initiative; a child
|
|
729
|
+
? `batch plan changed after ${activeRecord.commits.length} recorded child commit(s). The old authorization cannot execute the revised plan. Preserve the batch record, report, commit evidence and audit; inspect each remaining child's Kernel run before an explicit imm-run handoff. Do not re-enroll settled children or manufacture batch trailers. To close the parked record explicitly, dispose it with the retire_stale_batch Tool for this Initiative; a child whose Kernel run is still live must settle first, while one that already ended outside the batch does not block the disposition. This rejection neither terminates the old batch nor authorizes the revised plan.`
|
|
669
730
|
: "batch plan reconfirmation is not eligible"); }
|
|
670
731
|
}
|
|
671
732
|
|
|
@@ -34,6 +34,7 @@ export type BatchReasonKey =
|
|
|
34
34
|
| "repository_became_unreadable"
|
|
35
35
|
| "head_moved"
|
|
36
36
|
| "cancelled_before_execution"
|
|
37
|
+
| "precondition_commit_failed"
|
|
37
38
|
| "batch_run_rejected"
|
|
38
39
|
| "stale_batch_absent"
|
|
39
40
|
| "stale_batch_in_flight";
|
|
@@ -83,8 +84,8 @@ export const BATCH_REASONS: Readonly<Record<BatchReasonKey, BatchReasonSpec>> =
|
|
|
83
84
|
},
|
|
84
85
|
working_tree_dirty: {
|
|
85
86
|
state: "rejected",
|
|
86
|
-
reason:
|
|
87
|
-
recovery_action: "
|
|
87
|
+
reason: (detail: string) => `branch preflight failed: working tree is dirty${detail ? `: ${detail}` : ""}`,
|
|
88
|
+
recovery_action: "resolve the listed remaining paths outside the batch committable set, then retry in the current Host",
|
|
88
89
|
},
|
|
89
90
|
authorized_scope_underivable: {
|
|
90
91
|
state: "rejected",
|
|
@@ -167,6 +168,11 @@ export const BATCH_REASONS: Readonly<Record<BatchReasonKey, BatchReasonSpec>> =
|
|
|
167
168
|
reason: "user cancelled before batch execution",
|
|
168
169
|
recovery_action: "wait for a fresh literal-user request",
|
|
169
170
|
},
|
|
171
|
+
precondition_commit_failed: {
|
|
172
|
+
state: "rejected",
|
|
173
|
+
reason: (detail: string) => `batch precondition commit failed: ${detail}`,
|
|
174
|
+
recovery_action: "repair the reported Git precondition failure and retry the batch in the current Host",
|
|
175
|
+
},
|
|
170
176
|
batch_run_rejected: {
|
|
171
177
|
state: "rejected",
|
|
172
178
|
// The runner owns the specific reason; the fallback is this entry's own text.
|
|
@@ -181,7 +187,7 @@ export const BATCH_REASONS: Readonly<Record<BatchReasonKey, BatchReasonSpec>> =
|
|
|
181
187
|
stale_batch_in_flight: {
|
|
182
188
|
state: "blocked",
|
|
183
189
|
reason: (detail: string) => `a batch child is still mid-flight, so the record cannot be disposed: ${detail}`,
|
|
184
|
-
recovery_action: "settle or stop the in-flight child through its own task, then dispose the batch in the current Host",
|
|
190
|
+
recovery_action: "settle or stop the in-flight child through its own task, then dispose the batch in the current Host; a child whose Kernel run already ended no longer blocks the disposition",
|
|
185
191
|
},
|
|
186
192
|
});
|
|
187
193
|
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// Read-only eligibility for a first, zero-commit plan reconfirmation.
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
3
|
import { computeBatchPlanDigest, type BatchPlanChild as DigestChild } from "../kernel/batch_authority";
|
|
4
|
-
import { canonicalIntentHash,
|
|
4
|
+
import { canonicalIntentHash, parseProjectTaskIntent, readTaskIntent } from "../kernel/intent";
|
|
5
5
|
import { projectAssurance } from "../kernel/assurance_projection";
|
|
6
6
|
import { readAuditTaskPair, readSecureProjectBytes, readSettledRunEvidence, enrollmentEventIdFor, readWorkspaceStateRaw, type SettledRunEvidence } from "../kernel/storage";
|
|
7
7
|
import { auditRunDirPath, auditRunRecordPath, auditRunTerminalProofPath, batchCommitEvidencePath, batchStatePath } from "../kernel/storage_paths";
|
|
8
8
|
import { taskDeliveryIdentity, taskCommitRevisionIdentity, pathMatchesScope } from "../workspace_scope";
|
|
9
9
|
import { projectTask } from "../kernel/completion";
|
|
10
|
+
import { readRiskFloor } from "../kernel/risk_floor";
|
|
10
11
|
import { isTerminalBatchState, type BatchRunStateRecord } from "./batch_state";
|
|
11
12
|
import type { BatchPlanChild } from "./types";
|
|
12
13
|
|
|
@@ -54,7 +55,7 @@ export function ownUnpersistedBatchHead(root: string, record: BatchRunStateRecor
|
|
|
54
55
|
if (!paths.length || paths.some(path => !path.startsWith(`${auditRunDirPath(task, run)}/`) &&
|
|
55
56
|
!pair.record.intent_snapshot.scope_hint.some(s => pathMatchesScope(path, s)))) return false;
|
|
56
57
|
const identity = taskCommitRevisionIdentity(root, pair.record.intent_snapshot.scope_hint, pair.record.git_base_head, head);
|
|
57
|
-
if (!projectTask(pair.record.intent_snapshot, pair.record, identity.diff_hash, pair.record.intent_ref.content_hash, identity.changed_paths).complete) return false;
|
|
58
|
+
if (!projectTask(pair.record.intent_snapshot, pair.record, identity.diff_hash, pair.record.intent_ref.content_hash, identity.changed_paths, readRiskFloor(root)).complete) return false;
|
|
58
59
|
return git(root, ["rev-parse", "HEAD"]).trim() === head;
|
|
59
60
|
} catch { return false; }
|
|
60
61
|
}
|
|
@@ -111,12 +112,12 @@ export async function captureBatchReconfirmation(root: string, record: BatchRunS
|
|
|
111
112
|
const previous = record.children[index]!;
|
|
112
113
|
if (!id.test(child.task_id) || child.task_id !== previous.task_id || child.slice_id !== previous.slice_id ||
|
|
113
114
|
JSON.stringify(child.blocked_by) !== JSON.stringify(previous.blocked_by) || child.intent_path !== `docs/plans/${child.task_id}.intent.json`) refuse();
|
|
114
|
-
const old =
|
|
115
|
+
const old = parseProjectTaskIntent(root, JSON.parse(git(root, ["show", `${record.base_head}:${child.intent_path}`])));
|
|
115
116
|
if (old.task_id !== child.task_id) refuse();
|
|
116
117
|
const oldHash = canonicalIntentHash(old);
|
|
117
118
|
oldChildren.push({ task_id: child.task_id, blocked_by: [...child.blocked_by], intent_path: child.intent_path, intent_revision: old.revision, intent_content_hash: oldHash });
|
|
118
119
|
const currentBytes = capture(child.intent_path);
|
|
119
|
-
const current =
|
|
120
|
+
const current = parseProjectTaskIntent(root, JSON.parse(decode(currentBytes)));
|
|
120
121
|
const currentHash = canonicalIntentHash(current);
|
|
121
122
|
const tracked = readTaskIntent(root, child.task_id, child.intent_path);
|
|
122
123
|
if (currentHash !== tracked.content_hash || currentHash !== child.intent_content_hash || current.revision !== child.intent_revision) refuse();
|
|
@@ -140,12 +141,11 @@ export async function captureBatchReconfirmation(root: string, record: BatchRunS
|
|
|
140
141
|
const capturedProof = JSON.parse(decode(capture(proofPath)));
|
|
141
142
|
if (!matchesLocalProof(settled.proof_json, capturedProof) || !matchesLocalProof(settled.proof_json, pair.proof)) refuse();
|
|
142
143
|
const identity = taskDeliveryIdentity(root, r);
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
});
|
|
144
|
+
// Settlement status is historical; reconfirmation must also bind the current delivery.
|
|
145
|
+
if (!projectTask(r.intent_snapshot, r, identity.diff_hash, currentHash, identity.changed_paths, readRiskFloor(root)).complete) refuse();
|
|
146
|
+
const projection = await projectAssurance(root, child.task_id);
|
|
147
147
|
if (projection.error || projection.claim || projection.projection.run_id !== run || projection.projection.lifecycle !== "done" ||
|
|
148
|
-
!projection.projection.completion_ready || projection.projection.intent_content_hash !== currentHash ||
|
|
148
|
+
!projection.projection.completion_ready || projection.projection.intent_content_hash !== currentHash ||
|
|
149
149
|
!projection.projection.fresh_approval_kinds.includes("qa") ||
|
|
150
150
|
(projection.projection.risk !== "routine" && !projection.projection.fresh_approval_kinds.includes("review"))) refuse();
|
|
151
151
|
scope = r.intent_snapshot.scope_hint;
|