immune-brain 5.1.1 → 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 +20 -0
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +66 -11
- package/plugins/immune-brain/agents/immune-brain-reviewer.md +15 -0
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +8256 -7417
- package/plugins/immune-brain/dist/docs/reference/planning-quality-gate.md +1 -1
- package/plugins/immune-brain/dist/imm-brainstorm.md +4 -4
- package/plugins/immune-brain/dist/imm-planner.md +37 -4
- package/plugins/immune-brain/dist/imm-run.md +123 -9
- package/plugins/immune-brain/dist/role-prompts/code-review.md +15 -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 +9 -0
- package/plugins/immune-brain/runtime/assurance/delivery_workspace.ts +4 -1
- package/plugins/immune-brain/runtime/claude/interaction.ts +5 -0
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +32 -6
- package/plugins/immune-brain/runtime/claude/lane_guard.ts +33 -0
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +66 -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 +15 -0
- package/plugins/immune-brain/runtime/prompts/executor.md +7 -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/imm-brainstorm/SKILL.md +1 -1
- package/plugins/immune-brain/skills/imm-planner/SKILL.md +3 -1
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Coordinator-side recovery for a Lane Executor's writes that landed in the
|
|
2
|
+
// coordinator checkout instead of its Lane. On a lane-batch re-entry with a
|
|
3
|
+
// dirty coordinator working tree, every dirty path must be provably a Lane's:
|
|
4
|
+
// that Lane changed the path (its bytes differ from the Lane's base) and the
|
|
5
|
+
// coordinator's bytes equal the Lane's, in its worktree or a commit on its
|
|
6
|
+
// branch. Planning is read-only, so the batch preflight can run it before any
|
|
7
|
+
// gate; only the runner, after authorization, backs the paths up, restores
|
|
8
|
+
// them to HEAD and records the restore. Anything else (a staged change, a
|
|
9
|
+
// deletion, bytes no Lane wrote) leaves every file untouched, so a user's own
|
|
10
|
+
// uncommitted work is never restored.
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { copyFileSync, existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import type { BatchLaneRunStateRecord } from "./batch_state";
|
|
15
|
+
|
|
16
|
+
export interface LaneLeakRestore {
|
|
17
|
+
at: string;
|
|
18
|
+
/** Repository-relative directory holding the backed-up bytes and `restore.json`. */
|
|
19
|
+
backup: string;
|
|
20
|
+
paths: Array<{ path: string; kind: "modified" | "untracked"; lane_task_id: string }>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function git(root: string, args: string[]): { status: number | null; stdout: string } {
|
|
24
|
+
const result = spawnSync("git", ["-C", root, ...args], {
|
|
25
|
+
encoding: "utf8",
|
|
26
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
27
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
|
|
28
|
+
});
|
|
29
|
+
return { status: result.status, stdout: result.stdout ?? "" };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function blobOfFile(root: string, file: string): string | null {
|
|
33
|
+
const hashed = git(root, ["hash-object", "--no-filters", "--", file]);
|
|
34
|
+
return hashed.status === 0 ? hashed.stdout.trim() : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function blobAt(root: string, commit: string, path: string): string | null {
|
|
38
|
+
const blob = git(root, ["rev-parse", "--verify", "--quiet", `${commit}:${path}`]);
|
|
39
|
+
return blob.status === 0 ? blob.stdout.trim() : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The Lane of this batch that wrote `blob` at `path`, or null. A Lane only
|
|
44
|
+
* counts when its bytes for the path differ from the Lane's own base: a Lane
|
|
45
|
+
* that never touched the path still holds its base bytes, and matching those
|
|
46
|
+
* would mistake a user's revert for a Lane write.
|
|
47
|
+
*/
|
|
48
|
+
function laneOrigin(root: string, record: BatchLaneRunStateRecord, path: string, blob: string): string | null {
|
|
49
|
+
for (const child of record.children) {
|
|
50
|
+
const lane = child.lane;
|
|
51
|
+
if (!lane) continue;
|
|
52
|
+
if (blobAt(root, lane.base_head, path) === blob) continue;
|
|
53
|
+
if (existsSync(join(lane.path, path)) && blobOfFile(lane.path, join(lane.path, path)) === blob) return child.task_id;
|
|
54
|
+
const commits = git(root, ["rev-list", `${lane.base_head}..${lane.branch}`]);
|
|
55
|
+
if (commits.status !== 0) continue;
|
|
56
|
+
for (const commit of commits.stdout.split("\n").filter(Boolean))
|
|
57
|
+
if (blobAt(root, commit, path) === blob) return child.task_id;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type LeakRestorePlan =
|
|
63
|
+
| { kind: "clean" }
|
|
64
|
+
| { kind: "provable"; paths: LaneLeakRestore["paths"] }
|
|
65
|
+
| { kind: "unprovable"; path: string; detail: string };
|
|
66
|
+
|
|
67
|
+
/** Read-only: attribute every dirty path to a Lane of this batch, or name the first that cannot be. */
|
|
68
|
+
export function planLaneLeakRestore(
|
|
69
|
+
root: string,
|
|
70
|
+
record: BatchLaneRunStateRecord,
|
|
71
|
+
entries: Array<{ code: string; path: string }>,
|
|
72
|
+
): LeakRestorePlan {
|
|
73
|
+
if (entries.length === 0) return { kind: "clean" };
|
|
74
|
+
const planned: LaneLeakRestore["paths"] = [];
|
|
75
|
+
for (const { code, path } of entries) {
|
|
76
|
+
if (path.startsWith(".imm/")) return { kind: "unprovable", path, detail: "Immune-Brain state is never restored" };
|
|
77
|
+
const kind = code === "??" ? "untracked" : code === " M" ? "modified" : null;
|
|
78
|
+
if (!kind) return { kind: "unprovable", path, detail: `status ${code.trim() || code} is not a Lane write` };
|
|
79
|
+
const blob = blobOfFile(root, join(root, path));
|
|
80
|
+
if (!blob) return { kind: "unprovable", path, detail: "its bytes cannot be read" };
|
|
81
|
+
const origin = laneOrigin(root, record, path, blob);
|
|
82
|
+
if (!origin) return { kind: "unprovable", path, detail: "its bytes match no Lane of this batch" };
|
|
83
|
+
planned.push({ path, kind, lane_task_id: origin });
|
|
84
|
+
}
|
|
85
|
+
return { kind: "provable", paths: planned };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Back up and restore an already planned, fully provable set. Called only by
|
|
90
|
+
* the runner after the batch is authorized; it re-plans against the current
|
|
91
|
+
* status first, so a change since the preflight is never restored blind.
|
|
92
|
+
*/
|
|
93
|
+
export function restoreProvableLaneLeaks(
|
|
94
|
+
root: string,
|
|
95
|
+
record: BatchLaneRunStateRecord,
|
|
96
|
+
entries: Array<{ code: string; path: string }>,
|
|
97
|
+
now: string,
|
|
98
|
+
): { kind: "clean" } | { kind: "restored"; restore: LaneLeakRestore } | { kind: "unprovable"; path: string; detail: string } {
|
|
99
|
+
const plan = planLaneLeakRestore(root, record, entries);
|
|
100
|
+
if (plan.kind !== "provable") return plan;
|
|
101
|
+
const planned = plan.paths;
|
|
102
|
+
const stamp = now.replace(/[^0-9TZ]/g, "");
|
|
103
|
+
const backup = join(".imm", "state", "batches", "restores", record.batch_id, stamp);
|
|
104
|
+
const backupRoot = join(root, backup);
|
|
105
|
+
mkdirSync(join(backupRoot, "files"), { recursive: true });
|
|
106
|
+
const tracked = planned.filter((p) => p.kind === "modified").map((p) => p.path);
|
|
107
|
+
if (tracked.length > 0) {
|
|
108
|
+
const diff = git(root, ["diff", "--binary", "HEAD", "--", ...tracked]);
|
|
109
|
+
writeFileSync(join(backupRoot, "restore.patch"), diff.stdout);
|
|
110
|
+
}
|
|
111
|
+
// Back every modified path up before changing any of them; an untracked
|
|
112
|
+
// path is moved into the backup below, so it keeps exactly one copy.
|
|
113
|
+
for (const { path, kind } of planned) {
|
|
114
|
+
mkdirSync(dirname(join(backupRoot, "files", path)), { recursive: true });
|
|
115
|
+
if (kind === "modified") copyFileSync(join(root, path), join(backupRoot, "files", path));
|
|
116
|
+
}
|
|
117
|
+
const restore: LaneLeakRestore = { at: now, backup, paths: planned };
|
|
118
|
+
writeFileSync(join(backupRoot, "restore.json"), `${JSON.stringify({
|
|
119
|
+
...restore,
|
|
120
|
+
undo: "copy each file under files/ back to the same repository path",
|
|
121
|
+
}, null, 2)}\n`);
|
|
122
|
+
for (const { path, kind } of planned) {
|
|
123
|
+
if (kind === "untracked") {
|
|
124
|
+
renameSync(join(root, path), join(backupRoot, "files", path));
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const restored = git(root, ["restore", "--source=HEAD", "--worktree", "--", path]);
|
|
128
|
+
if (restored.status !== 0) throw new Error(`failed to restore ${path} from HEAD; its bytes are backed up under ${backup}`);
|
|
129
|
+
}
|
|
130
|
+
return { kind: "restored", restore };
|
|
131
|
+
}
|
|
@@ -27,6 +27,7 @@ import { captureBatchReconfirmation, retainReconfirmation, ownUnpersistedBatchHe
|
|
|
27
27
|
import { projectBatchPlan } from "./batch_plan";
|
|
28
28
|
import { batchReason, type BatchReasonKey } from "./batch_reasons";
|
|
29
29
|
import { BATCH_RUN_STATES, isLaneBatchRecord, isTerminalBatchState, readAnyBatchRunState, type AnyBatchRunStateRecord, type BatchRunStateRecord } from "./batch_state";
|
|
30
|
+
import { planLaneLeakRestore } from "./batch_leak_restore";
|
|
30
31
|
import type {
|
|
31
32
|
BatchPlanBudget,
|
|
32
33
|
BatchPlanChild,
|
|
@@ -374,7 +375,7 @@ function authorizedScopeOf(root: string, taskId: string, state: string): string[
|
|
|
374
375
|
return scope;
|
|
375
376
|
}
|
|
376
377
|
|
|
377
|
-
function porcelainEntries(root: string): Array<{ code: string; path: string }> | null {
|
|
378
|
+
export function porcelainEntries(root: string): Array<{ code: string; path: string }> | null {
|
|
378
379
|
// review-batch-resume-porcelain-leading-space: parse the NUL-delimited v1
|
|
379
380
|
// format. Trimming the whole output first shifted the fixed status columns of
|
|
380
381
|
// an unstaged modification (" M path") and silently mis-scoped the path.
|
|
@@ -594,9 +595,19 @@ export async function projectBatchPreflight(
|
|
|
594
595
|
if (branchExists.status === 0 && !existingBatch)
|
|
595
596
|
return reject("branch_already_exists", batchBranch);
|
|
596
597
|
|
|
597
|
-
|
|
598
|
+
let statusEntries = porcelainEntries(root);
|
|
598
599
|
if (statusEntries === null)
|
|
599
600
|
return reject("git_status_unreadable");
|
|
601
|
+
// A lane batch keeps its delivery in Lanes, so a dirty coordinator is a leak.
|
|
602
|
+
// This read-only check only attributes it: when every dirty path is provably
|
|
603
|
+
// a Lane's write, the runner restores them after the gate; any change of
|
|
604
|
+
// unknown origin is refused below and nothing is touched.
|
|
605
|
+
let unprovableLeak = "";
|
|
606
|
+
if (statusEntries.length > 0 && activeRecord && isLaneBatchRecord(activeRecord)) {
|
|
607
|
+
const plan = planLaneLeakRestore(root, activeRecord, statusEntries);
|
|
608
|
+
if (plan.kind === "provable") statusEntries = [];
|
|
609
|
+
else if (plan.kind === "unprovable") unprovableLeak = `${plan.path}: ${plan.detail}`;
|
|
610
|
+
}
|
|
600
611
|
if (statusEntries.length > 0) {
|
|
601
612
|
if (!isResuming)
|
|
602
613
|
return reject("working_tree_dirty");
|
|
@@ -616,7 +627,7 @@ export async function projectBatchPreflight(
|
|
|
616
627
|
}
|
|
617
628
|
const dirtyBytes = statusEntries.some(({ code }) => code === "??" || code[1] !== " ");
|
|
618
629
|
if (dirtyBytes)
|
|
619
|
-
return reject("working_tree_unstaged");
|
|
630
|
+
return reject("working_tree_unstaged", unprovableLeak);
|
|
620
631
|
let outsideScope = false;
|
|
621
632
|
for (const { path } of statusEntries) {
|
|
622
633
|
if (path.startsWith(".imm/") || path.startsWith("docs/plans/") || path.startsWith("docs/specs/")) continue;
|
|
@@ -748,7 +759,12 @@ export interface BatchConfirmationFacts {
|
|
|
748
759
|
* own failure-envelope shape.
|
|
749
760
|
*/
|
|
750
761
|
export type BatchGateDecision<HostRejection> =
|
|
751
|
-
| {
|
|
762
|
+
| {
|
|
763
|
+
kind: "confirmed";
|
|
764
|
+
request_id: string;
|
|
765
|
+
/** ADR 0018: the user also delegated in-envelope Lane Intent revisions (lane mode only). */
|
|
766
|
+
delegate_revisions?: boolean;
|
|
767
|
+
}
|
|
752
768
|
| { kind: "host_rejection"; value: HostRejection };
|
|
753
769
|
|
|
754
770
|
export type BatchAuthorizationOutcome<HostRejection> =
|
|
@@ -758,6 +774,8 @@ export type BatchAuthorizationOutcome<HostRejection> =
|
|
|
758
774
|
reuse_authorization: boolean;
|
|
759
775
|
reuse_blockers: string[];
|
|
760
776
|
binding: BatchAuthorizationBinding;
|
|
777
|
+
/** The gate's delegation answer; absent when the authorization was reused and no gate opened. */
|
|
778
|
+
revision_delegation?: boolean;
|
|
761
779
|
}
|
|
762
780
|
| { outcome: "rejected"; rejection: BatchPreflightRejection }
|
|
763
781
|
| { outcome: "host_rejection"; value: HostRejection };
|
|
@@ -848,10 +866,12 @@ export async function authorizeBatch<HostRejection>(
|
|
|
848
866
|
};
|
|
849
867
|
|
|
850
868
|
let requestId: string | null = null;
|
|
869
|
+
let revisionDelegation: boolean | undefined;
|
|
851
870
|
if (!reuseAuthorization) {
|
|
852
871
|
const decision = await options.gate(facts);
|
|
853
872
|
if (decision.kind === "host_rejection") return { outcome: "host_rejection", value: decision.value };
|
|
854
873
|
requestId = decision.request_id;
|
|
874
|
+
revisionDelegation = decision.delegate_revisions === true;
|
|
855
875
|
}
|
|
856
876
|
|
|
857
877
|
// Post-gate cascade: the drift projection reports the live claim it read
|
|
@@ -916,5 +936,6 @@ export async function authorizeBatch<HostRejection>(
|
|
|
916
936
|
reuse_authorization: reuseAuthorization,
|
|
917
937
|
reuse_blockers: reuseBlockers,
|
|
918
938
|
binding,
|
|
939
|
+
...(revisionDelegation !== undefined ? { revision_delegation: revisionDelegation } : {}),
|
|
919
940
|
};
|
|
920
941
|
}
|
|
@@ -93,8 +93,9 @@ export const BATCH_REASONS: Readonly<Record<BatchReasonKey, BatchReasonSpec>> =
|
|
|
93
93
|
},
|
|
94
94
|
working_tree_unstaged: {
|
|
95
95
|
state: "rejected",
|
|
96
|
-
reason:
|
|
97
|
-
|
|
96
|
+
reason: (detail: string) =>
|
|
97
|
+
`branch preflight failed: working tree has unstaged or untracked changes${detail ? ` (not restored: ${detail})` : ""}`,
|
|
98
|
+
recovery_action: "establish where each unstaged or untracked change came from before staging anything; move changes that are not the active child's verified work out of the working tree, then retry in the current Host",
|
|
98
99
|
},
|
|
99
100
|
working_tree_out_of_scope: {
|
|
100
101
|
state: "rejected",
|
|
@@ -108,6 +108,48 @@ export interface StartBatchInput {
|
|
|
108
108
|
max_parallel?: number;
|
|
109
109
|
/** Present only in lane mode: Lanes someone else created, offered to the batch. */
|
|
110
110
|
lane_offers?: LaneOffer[];
|
|
111
|
+
/**
|
|
112
|
+
* Lane mode only: the opted-in tracker projection the coordinator runs after
|
|
113
|
+
* integrating a child. A Lane settlement writes nothing to the tracker, so
|
|
114
|
+
* this is where its Child Issue is closed, exactly once. Absent, nothing is
|
|
115
|
+
* projected.
|
|
116
|
+
*/
|
|
117
|
+
tracker?: BatchTrackerPort;
|
|
118
|
+
/**
|
|
119
|
+
* Lane mode only: the plugin or runtime directory the Lane Executor Host
|
|
120
|
+
* loads. Absent, the repository's own plugin source is compared when the
|
|
121
|
+
* repository is the Immune-Brain package; otherwise nothing is compared.
|
|
122
|
+
*/
|
|
123
|
+
executor_runtime?: string;
|
|
124
|
+
/**
|
|
125
|
+
* Lane mode only: the project's full verification commands, recorded when a
|
|
126
|
+
* new batch starts and run on the batch branch once every child is
|
|
127
|
+
* integrated. Ignored on a resume; the recorded list stands.
|
|
128
|
+
*/
|
|
129
|
+
final_verification?: string[];
|
|
130
|
+
/**
|
|
131
|
+
* Lane mode only: one text instruction the Parent wants to send a Lane
|
|
132
|
+
* session. The tick records it and answers whether it may be sent; it is
|
|
133
|
+
* never sent to a blocked or working session.
|
|
134
|
+
*/
|
|
135
|
+
lane_instruction?: import("./batch_lanes").LaneInstructionRequest;
|
|
136
|
+
/**
|
|
137
|
+
* Lane mode only, ADR 0018: the literal user's answer, in this call's own
|
|
138
|
+
* batch confirmation, on delegating in-envelope Lane Intent revisions to the
|
|
139
|
+
* coordinator. Absent when no gate opened (a reused authorization): the
|
|
140
|
+
* recorded grant stands.
|
|
141
|
+
*/
|
|
142
|
+
revision_delegation?: boolean;
|
|
143
|
+
/** Lane mode only, ADR 0018: one breaking Intent revision the coordinator approves under the grant. */
|
|
144
|
+
lane_revision?: import("./batch_delegation").LaneRevisionRequest;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The one tracker effect a lane batch may cause: closing an integrated child's Issue. */
|
|
148
|
+
export interface BatchTrackerPort {
|
|
149
|
+
markTerminal(
|
|
150
|
+
root: string,
|
|
151
|
+
input: { task_id: string; phase: "done"; terminal_event_id: string },
|
|
152
|
+
): Promise<{ status: string; message: string }>;
|
|
111
153
|
}
|
|
112
154
|
|
|
113
155
|
/** One git seam: the injected BatchRunnerGitPort, or the default adapter over
|
|
@@ -83,6 +83,46 @@ export function scopesOverlap(left: readonly string[], right: readonly string[])
|
|
|
83
83
|
});
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* The entry pairs that make two scope lists overlap, in list order: the
|
|
88
|
+
* machine evidence behind `scopesOverlap`, so a plan can name the shared paths
|
|
89
|
+
* instead of asserting disjointness by hand. An empty list on either side
|
|
90
|
+
* overlaps everything and is reported as the pair `["", entry]`.
|
|
91
|
+
*/
|
|
92
|
+
export function overlappingScopeEntries(left: readonly string[], right: readonly string[]): Array<[string, string]> {
|
|
93
|
+
if (!left.length || !right.length) return [[left[0] ?? "", right[0] ?? ""]];
|
|
94
|
+
const pairs: Array<[string, string]> = [];
|
|
95
|
+
for (const entry of left) {
|
|
96
|
+
const prefix = scopePrefix(entry);
|
|
97
|
+
for (const other of right) if (prefixOverlaps(prefix, scopePrefix(other))) pairs.push([entry, other]);
|
|
98
|
+
}
|
|
99
|
+
return pairs;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface ScopeOverlapReport {
|
|
103
|
+
contract: "assurance_kernel/intent_scope_overlap/v1";
|
|
104
|
+
overlaps: Array<{ task_ids: [string, string]; entries: Array<[string, string]> }>;
|
|
105
|
+
parallel_groups: BatchPlanParallelGroups;
|
|
106
|
+
scope_conflicts: BatchPlanScopeConflict[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Compare the `scope_hint` of every pair of Intents of one Initiative. The
|
|
111
|
+
* groups use the same projection the Batch Plan uses, so the Planner's review
|
|
112
|
+
* table and the batch confirmation agree on what runs together.
|
|
113
|
+
*/
|
|
114
|
+
export function projectScopeOverlap(children: readonly PlanScheduleChild[]): ScopeOverlapReport {
|
|
115
|
+
const overlaps: ScopeOverlapReport["overlaps"] = [];
|
|
116
|
+
for (let i = 0; i < children.length; i += 1)
|
|
117
|
+
for (let j = i + 1; j < children.length; j += 1) {
|
|
118
|
+
const entries = scopesOverlap(children[i]!.scope_hint, children[j]!.scope_hint)
|
|
119
|
+
? overlappingScopeEntries(children[i]!.scope_hint, children[j]!.scope_hint)
|
|
120
|
+
: [];
|
|
121
|
+
if (entries.length) overlaps.push({ task_ids: [children[i]!.task_id, children[j]!.task_id], entries });
|
|
122
|
+
}
|
|
123
|
+
return { contract: "assurance_kernel/intent_scope_overlap/v1", overlaps, ...projectParallelGroups(children) };
|
|
124
|
+
}
|
|
125
|
+
|
|
86
126
|
function positiveLimit(maxParallel: number): number {
|
|
87
127
|
if (maxParallel === Number.POSITIVE_INFINITY) return maxParallel;
|
|
88
128
|
if (!Number.isSafeInteger(maxParallel) || maxParallel <= 0)
|
|
@@ -109,6 +109,28 @@ export interface BatchLaneChildRun {
|
|
|
109
109
|
commit: string | null;
|
|
110
110
|
lane: BatchLaneBinding | null;
|
|
111
111
|
qa_failures: number;
|
|
112
|
+
/**
|
|
113
|
+
* The Child Issue was closed by the coordinator after this child's commit was
|
|
114
|
+
* integrated. Absent until then; a Lane settlement never closes it.
|
|
115
|
+
*/
|
|
116
|
+
tracker_closed?: boolean;
|
|
117
|
+
/** Coordinator corrections already spent on this child (at most two). */
|
|
118
|
+
corrections?: number;
|
|
119
|
+
/**
|
|
120
|
+
* Set when the child exhausted its rework budget and waits for one
|
|
121
|
+
* design-level coordinator correction instead of parking; cleared when the
|
|
122
|
+
* correction is recorded.
|
|
123
|
+
*/
|
|
124
|
+
correction_due?: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface LaneIntervention {
|
|
128
|
+
at: string;
|
|
129
|
+
task_id: string;
|
|
130
|
+
/** `instruction` is an ordinary correction; `correction` is a design-level correction after an exhausted budget. */
|
|
131
|
+
kind: "instruction" | "correction";
|
|
132
|
+
text: string;
|
|
133
|
+
text_digest: string;
|
|
112
134
|
}
|
|
113
135
|
|
|
114
136
|
/**
|
|
@@ -133,6 +155,30 @@ export interface BatchLaneRunStateRecord {
|
|
|
133
155
|
/** Batch-branch commits produced by integration, in integration order. */
|
|
134
156
|
commits: string[];
|
|
135
157
|
adopted_heads?: Array<{ from: string; to: string }>;
|
|
158
|
+
/**
|
|
159
|
+
* Lane writes that leaked into the coordinator checkout and were restored on
|
|
160
|
+
* re-entry, each with the backup that undoes it. Absent until one happens.
|
|
161
|
+
*/
|
|
162
|
+
restores?: import("./batch_leak_restore").LaneLeakRestore[];
|
|
163
|
+
/**
|
|
164
|
+
* Every text instruction the coordinator sent a Lane session, recorded before
|
|
165
|
+
* it was sent, so coordinator intervention is told apart from the Executor's
|
|
166
|
+
* own behavior afterwards.
|
|
167
|
+
*/
|
|
168
|
+
interventions?: LaneIntervention[];
|
|
169
|
+
/** Commands run on the batch branch once every child is integrated; fixed when the batch starts. */
|
|
170
|
+
final_verification?: string[];
|
|
171
|
+
/**
|
|
172
|
+
* ADR 0018: the literal user's grant, in this batch's confirmation, to let the
|
|
173
|
+
* coordinator approve in-envelope Lane Intent revisions. Absent: not granted.
|
|
174
|
+
*/
|
|
175
|
+
revision_delegation?: import("./batch_delegation").RevisionDelegationGrant;
|
|
176
|
+
/** Every revision applied under that grant. */
|
|
177
|
+
delegated_revisions?: import("./batch_delegation").DelegatedRevisionRecord[];
|
|
178
|
+
/** Each child's TaskIntent identity behind `plan_digest`; resealed only for a delegated revision. */
|
|
179
|
+
intent_identities?: Record<string, { intent_path: string; intent_revision: number; intent_content_hash: string }>;
|
|
180
|
+
/** Each `plan_digest` reseal at integration, with the digest before and after. */
|
|
181
|
+
reseals?: Array<{ at: string; task_id: string; from_digest: string; to_digest: string; intent_revision: number; intent_content_hash: string }>;
|
|
136
182
|
created_at: string;
|
|
137
183
|
updated_at: string;
|
|
138
184
|
}
|
|
@@ -523,6 +569,66 @@ function validateLaneRecordShape(value: unknown, batchId: string): asserts value
|
|
|
523
569
|
))
|
|
524
570
|
)
|
|
525
571
|
throw new Error(`batch run state ${batchId} has an invalid adopted_heads list`);
|
|
572
|
+
if (
|
|
573
|
+
record.restores !== undefined &&
|
|
574
|
+
(!Array.isArray(record.restores) ||
|
|
575
|
+
record.restores.some(
|
|
576
|
+
(r: unknown) =>
|
|
577
|
+
typeof r !== "object" || r === null ||
|
|
578
|
+
typeof (r as { backup?: unknown }).backup !== "string" ||
|
|
579
|
+
!Array.isArray((r as { paths?: unknown }).paths),
|
|
580
|
+
))
|
|
581
|
+
)
|
|
582
|
+
throw new Error(`batch run state ${batchId} has an invalid restores list`);
|
|
583
|
+
if (
|
|
584
|
+
record.final_verification !== undefined &&
|
|
585
|
+
(!Array.isArray(record.final_verification) ||
|
|
586
|
+
record.final_verification.length === 0 ||
|
|
587
|
+
record.final_verification.some((c: unknown) => typeof c !== "string" || !c))
|
|
588
|
+
)
|
|
589
|
+
throw new Error(`batch run state ${batchId} has an invalid final_verification list`);
|
|
590
|
+
if (
|
|
591
|
+
record.interventions !== undefined &&
|
|
592
|
+
(!Array.isArray(record.interventions) ||
|
|
593
|
+
record.interventions.some(
|
|
594
|
+
(i: unknown) =>
|
|
595
|
+
typeof i !== "object" || i === null ||
|
|
596
|
+
typeof (i as LaneIntervention).task_id !== "string" ||
|
|
597
|
+
((i as LaneIntervention).kind !== "instruction" && (i as LaneIntervention).kind !== "correction") ||
|
|
598
|
+
typeof (i as LaneIntervention).text !== "string" ||
|
|
599
|
+
typeof (i as LaneIntervention).text_digest !== "string",
|
|
600
|
+
))
|
|
601
|
+
)
|
|
602
|
+
throw new Error(`batch run state ${batchId} has an invalid interventions list`);
|
|
603
|
+
// ADR 0018 fields carry authority effects, so a malformed one fails closed.
|
|
604
|
+
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
605
|
+
const isHash = (v: unknown) => typeof v === "string" && /^sha256:[0-9a-f]{64}$/.test(v);
|
|
606
|
+
const isRevision = (v: unknown) => typeof v === "number" && Number.isSafeInteger(v) && v > 0;
|
|
607
|
+
if (record.revision_delegation !== undefined &&
|
|
608
|
+
(!isObject(record.revision_delegation) || Object.keys(record.revision_delegation).join() !== "confirmation_time" ||
|
|
609
|
+
!isCanonicalTimestamp(record.revision_delegation.confirmation_time)))
|
|
610
|
+
throw new Error(`batch run state ${batchId} has an invalid revision_delegation`);
|
|
611
|
+
if (record.delegated_revisions !== undefined &&
|
|
612
|
+
(!Array.isArray(record.delegated_revisions) || record.delegated_revisions.some((r: unknown) =>
|
|
613
|
+
!isObject(r) || typeof r.task_id !== "string" || !isRevision(r.from_revision) || !isRevision(r.to_revision) ||
|
|
614
|
+
(r.to_revision as number) <= (r.from_revision as number) || !isHash(r.intent_content_hash) ||
|
|
615
|
+
typeof r.confirmation_ref !== "string" || !r.confirmation_ref.startsWith(`delegated-batch:${batchId}@`) ||
|
|
616
|
+
!isCanonicalTimestamp(r.at))))
|
|
617
|
+
throw new Error(`batch run state ${batchId} has an invalid delegated_revisions list`);
|
|
618
|
+
if (record.delegated_revisions?.length && !record.revision_delegation)
|
|
619
|
+
throw new Error(`batch run state ${batchId} records delegated revisions without a delegation grant`);
|
|
620
|
+
if (record.intent_identities !== undefined &&
|
|
621
|
+
(!isObject(record.intent_identities) || Object.values(record.intent_identities).some((i: unknown) =>
|
|
622
|
+
!isObject(i) || typeof i.intent_path !== "string" || !i.intent_path || !isRevision(i.intent_revision) || typeof i.intent_content_hash !== "string" || !i.intent_content_hash)))
|
|
623
|
+
throw new Error(`batch run state ${batchId} has an invalid intent_identities map`);
|
|
624
|
+
if (record.reseals !== undefined &&
|
|
625
|
+
(!Array.isArray(record.reseals) || record.reseals.some((r: unknown) =>
|
|
626
|
+
!isObject(r) || typeof r.task_id !== "string" || typeof r.from_digest !== "string" || typeof r.to_digest !== "string" ||
|
|
627
|
+
!isRevision(r.intent_revision) || !isHash(r.intent_content_hash) ||
|
|
628
|
+
!(record.delegated_revisions as Array<{ task_id: string; intent_content_hash: string }> | undefined)?.some((d) => d.task_id === r.task_id && d.intent_content_hash === r.intent_content_hash))))
|
|
629
|
+
throw new Error(`batch run state ${batchId} has an invalid reseals list`);
|
|
630
|
+
if (record.reseals?.length && record.reseals[record.reseals.length - 1].to_digest !== record.plan_digest)
|
|
631
|
+
throw new Error(`batch run state ${batchId} plan_digest does not match its last reseal`);
|
|
526
632
|
const seenTaskIds = new Set<string>();
|
|
527
633
|
for (const child of record.children) {
|
|
528
634
|
if (
|
|
@@ -545,6 +651,14 @@ function validateLaneRecordShape(value: unknown, batchId: string): asserts value
|
|
|
545
651
|
throw new Error(`batch run state ${batchId} child ${child.task_id} has invalid terminal fields`);
|
|
546
652
|
if (typeof child.qa_failures !== "number" || !Number.isInteger(child.qa_failures) || child.qa_failures < 0)
|
|
547
653
|
throw new Error(`batch run state ${batchId} child ${child.task_id} has an invalid qa_failures`);
|
|
654
|
+
if (child.corrections !== undefined && (!Number.isInteger(child.corrections) || child.corrections < 0 || child.corrections > 2))
|
|
655
|
+
throw new Error(`batch run state ${batchId} child ${child.task_id} has an invalid corrections count`);
|
|
656
|
+
if (child.correction_due !== undefined && (typeof child.correction_due !== "string" || !child.correction_due || child.state !== "enrolled"))
|
|
657
|
+
throw new Error(`batch run state ${batchId} child ${child.task_id} has an invalid correction_due`);
|
|
658
|
+
if (child.tracker_closed !== undefined && child.tracker_closed !== true)
|
|
659
|
+
throw new Error(`batch run state ${batchId} child ${child.task_id} has an invalid tracker_closed`);
|
|
660
|
+
if (child.tracker_closed === true && child.state !== "integrated" && child.state !== "released")
|
|
661
|
+
throw new Error(`batch run state ${batchId} child ${child.task_id} closed its tracker Issue before integration`);
|
|
548
662
|
const lane = child.lane as BatchLaneBinding | null | undefined;
|
|
549
663
|
if (lane !== null) {
|
|
550
664
|
if (
|
|
@@ -612,6 +726,8 @@ export function prepareBatchLaneRunState(input: {
|
|
|
612
726
|
budget: { max_children: number; qa_failure_limit: number };
|
|
613
727
|
max_parallel: number;
|
|
614
728
|
now: string;
|
|
729
|
+
final_verification?: string[];
|
|
730
|
+
revision_delegation?: import("./batch_delegation").RevisionDelegationGrant;
|
|
615
731
|
}): BatchLaneRunStateRecord {
|
|
616
732
|
validateBatchId(input.batch_id);
|
|
617
733
|
return {
|
|
@@ -636,6 +752,13 @@ export function prepareBatchLaneRunState(input: {
|
|
|
636
752
|
qa_failures: 0,
|
|
637
753
|
})),
|
|
638
754
|
commits: [],
|
|
755
|
+
...(input.final_verification?.length ? { final_verification: [...input.final_verification] } : {}),
|
|
756
|
+
...(input.revision_delegation ? { revision_delegation: { ...input.revision_delegation } } : {}),
|
|
757
|
+
intent_identities: Object.fromEntries(
|
|
758
|
+
input.children
|
|
759
|
+
.filter((child) => child.intent_path !== null && child.intent_revision !== null && child.intent_content_hash !== null)
|
|
760
|
+
.map((child) => [child.task_id, { intent_path: child.intent_path!, intent_revision: child.intent_revision!, intent_content_hash: child.intent_content_hash! }]),
|
|
761
|
+
),
|
|
639
762
|
created_at: input.now,
|
|
640
763
|
updated_at: input.now,
|
|
641
764
|
};
|
|
@@ -725,6 +848,23 @@ export interface BatchLaneRunReport {
|
|
|
725
848
|
handoffs: BatchLaneHandoff[];
|
|
726
849
|
/** Offers refused at admission; a refused offer wrote nothing. */
|
|
727
850
|
lane_refusals?: Array<{ task_id: string; path: string; reason: string }>;
|
|
851
|
+
/**
|
|
852
|
+
* Child Issue closures this tick attempted after integration. A failure is a
|
|
853
|
+
* tracker observation beside the batch result, retried by the next tick.
|
|
854
|
+
*/
|
|
855
|
+
tracker_observations?: Array<{ task_id: string; status: string; message: string }>;
|
|
856
|
+
/** The completion run of the recorded final verification commands; `passed: false` marks the batch not passed. */
|
|
857
|
+
final_verification?: import("./batch_final_verification").FinalVerificationReport;
|
|
858
|
+
/** Coordinator instructions sent to Lane sessions (see BatchLaneRunStateRecord.interventions). */
|
|
859
|
+
interventions?: LaneIntervention[];
|
|
860
|
+
/** This tick's decision on the coordinator's `lane_revision` (ADR 0018). */
|
|
861
|
+
lane_revision?: { task_id: string; accepted: boolean; reason: string | null; fallback?: string };
|
|
862
|
+
delegated_revisions?: import("./batch_delegation").DelegatedRevisionRecord[];
|
|
863
|
+
reseals?: BatchLaneRunStateRecord["reseals"];
|
|
864
|
+
/** This tick's decision on the Parent's `lane_instruction`: send the text only when accepted. */
|
|
865
|
+
lane_instruction?: { task_id: string; kind: LaneIntervention["kind"]; accepted: boolean; reason: string | null };
|
|
866
|
+
/** Restored Lane leaks with their backup location (see BatchLaneRunStateRecord.restores). */
|
|
867
|
+
restores?: import("./batch_leak_restore").LaneLeakRestore[];
|
|
728
868
|
next_action: string;
|
|
729
869
|
created_at: string;
|
|
730
870
|
}
|
|
@@ -739,6 +879,21 @@ export type BatchLaneHandoff =
|
|
|
739
879
|
executor_hosts: readonly string[];
|
|
740
880
|
}
|
|
741
881
|
| { role: "lane-steward"; action: "release"; task_id: string; lane_branch: string }
|
|
882
|
+
| {
|
|
883
|
+
/**
|
|
884
|
+
* The child exhausted its rework budget. The Parent reads the findings of
|
|
885
|
+
* every round from the Lane's Kernel state, decides whether they share one
|
|
886
|
+
* root cause, starts a new Lane session and sends one design-level
|
|
887
|
+
* correction as `lane_instruction` kind `correction`.
|
|
888
|
+
*/
|
|
889
|
+
role: "coordinator";
|
|
890
|
+
action: "correct";
|
|
891
|
+
task_id: string;
|
|
892
|
+
reason: string;
|
|
893
|
+
corrections_used: number;
|
|
894
|
+
corrections_left: number;
|
|
895
|
+
lane_path: string;
|
|
896
|
+
}
|
|
742
897
|
| {
|
|
743
898
|
role: "executor";
|
|
744
899
|
task_id: string;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Read-only identification of a Lane worktree from its checked-out branch.
|
|
2
|
+
// A Lane sits on `imm-lane/<initiative-slug>/<task-id>` (ADR 0013), so the
|
|
3
|
+
// settling Host can tell a Lane settlement from a coordinator or single-task
|
|
4
|
+
// settlement without reading any batch state.
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { realpathSync } from "node:fs";
|
|
7
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
8
|
+
|
|
9
|
+
const LANE_BRANCH = /^imm-lane\/[^/]+\/([A-Za-z0-9][A-Za-z0-9._-]{0,127})$/;
|
|
10
|
+
|
|
11
|
+
/** The task id the Lane at `root` was cut for, or null when `root` is not a Lane. */
|
|
12
|
+
export function laneTaskOfWorkspace(root: string): string | null {
|
|
13
|
+
const branch = spawnSync("git", ["-C", root, "symbolic-ref", "--quiet", "--short", "HEAD"], {
|
|
14
|
+
encoding: "utf8",
|
|
15
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
16
|
+
});
|
|
17
|
+
if (branch.status !== 0) return null;
|
|
18
|
+
return LANE_BRANCH.exec((branch.stdout ?? "").trim())?.[1] ?? null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** True when `root` is the Lane of `taskId`: its tracker close waits for integration. */
|
|
22
|
+
export function isLaneWorkspaceForTask(root: string, taskId: string): boolean {
|
|
23
|
+
return laneTaskOfWorkspace(root) === taskId;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function realpathOfNearest(path: string): string {
|
|
27
|
+
let current = path;
|
|
28
|
+
const suffix: string[] = [];
|
|
29
|
+
for (;;) {
|
|
30
|
+
try {
|
|
31
|
+
return join(realpathSync(current), ...suffix.reverse());
|
|
32
|
+
} catch {
|
|
33
|
+
const parent = dirname(current);
|
|
34
|
+
if (parent === current) return path;
|
|
35
|
+
suffix.push(basename(current));
|
|
36
|
+
current = parent;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Refusal text for an `edit`/`write` a Lane session aims outside its Lane, or
|
|
43
|
+
* null when `cwd` is not a Lane or the target is inside it. The target is
|
|
44
|
+
* resolved against `cwd` and through symlinks of its nearest existing
|
|
45
|
+
* ancestor. Only file-editing tools are checked: a shell command is not
|
|
46
|
+
* intercepted, and the contract says so.
|
|
47
|
+
*/
|
|
48
|
+
export function laneWriteRefusal(cwd: string, target: unknown): string | null {
|
|
49
|
+
if (typeof target !== "string" || !target) return null;
|
|
50
|
+
if (laneTaskOfWorkspace(cwd) === null) return null;
|
|
51
|
+
const top = spawnSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
52
|
+
if (top.status !== 0) return null;
|
|
53
|
+
const laneRoot = realpathOfNearest((top.stdout ?? "").trim());
|
|
54
|
+
const resolved = realpathOfNearest(isAbsolute(target) ? target : resolve(cwd, target));
|
|
55
|
+
const inside = relative(laneRoot, resolved);
|
|
56
|
+
if (inside === "" || (inside !== ".." && !inside.startsWith(`..${sep}`) && !isAbsolute(inside))) return null;
|
|
57
|
+
return `Lane write refused: ${resolved} is outside this Lane. In a batch Lane every edit and write stays under ${laneRoot}; use the same repository-relative path inside the Lane.`;
|
|
58
|
+
}
|
|
@@ -32,4 +32,4 @@ worktree, or unmanaged implementation.
|
|
|
32
32
|
Every sourced branch closes as resolved, explicitly excluded, explicitly
|
|
33
33
|
deferred, or dependent-blocked with its blocking node named, including for a
|
|
34
34
|
clear zero-question request; a confirmed outcome is never conserved as a
|
|
35
|
-
smaller deliverable.
|
|
35
|
+
smaller deliverable, and a confirmed user scenario is a `BR-SCN-*` item with Actor, Given, When, Then, written in English, whose Then is a result the actor observes directly; framing without a user-observable change gets no invented scenario.
|
|
@@ -16,7 +16,9 @@ grants execution authority — only the native Enrollment gate can. Every Spec d
|
|
|
16
16
|
user-owned one with no source is removed or returned for clarification. Before
|
|
17
17
|
handoff it maps every upstream item and required phase exactly once and reports
|
|
18
18
|
each Slice result plus the remaining gap; a plan that conserves a smaller
|
|
19
|
-
deliverable than the confirmed outcome fails that check instead of passing
|
|
19
|
+
deliverable than the confirmed outcome fails that check instead of passing, and a
|
|
20
|
+
Spec carries user scenarios mapped one way to acceptance, or records `no
|
|
21
|
+
user-visible change` with a reason.
|
|
20
22
|
|
|
21
23
|
Section routes - load a section's instructions only when its branch applies.
|
|
22
24
|
Read each linked heading body up to the next heading; nested sections and
|