immune-brain 3.6.6 → 3.6.8
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 +110 -51
- package/README.zh-CN.md +153 -49
- package/package.json +2 -1
- package/plugins/immune-brain/.claude-plugin/plugin.json +1 -1
- package/plugins/immune-brain/.pi-extension/imm-canary-work.ts +29 -19
- package/plugins/immune-brain/.pi-extension/imm-unattended-batch.ts +932 -0
- package/plugins/immune-brain/.pi-extension/package.json +3 -2
- package/plugins/immune-brain/.pi-extension/pi-canary-interaction.ts +149 -4
- package/plugins/immune-brain/.pi-extension/runtime-stub.ts +200 -2
- package/plugins/immune-brain/dist/claude/mcp-server.mjs +3404 -227
- package/plugins/immune-brain/dist/imm-loop.md +25 -0
- package/plugins/immune-brain/dist/role-prompts/code-review.md +9 -1
- package/plugins/immune-brain/runtime/assurance/coordinator.ts +101 -3
- package/plugins/immune-brain/runtime/claude/interaction.ts +16 -3
- package/plugins/immune-brain/runtime/claude/kernel_ports.ts +840 -17
- package/plugins/immune-brain/runtime/claude/mcp_server.ts +72 -10
- package/plugins/immune-brain/runtime/kernel/canary_application.ts +9 -0
- package/plugins/immune-brain/runtime/kernel/completion.ts +19 -1
- package/plugins/immune-brain/runtime/kernel/reducer.ts +162 -11
- package/plugins/immune-brain/runtime/kernel/refutation.ts +82 -0
- package/plugins/immune-brain/runtime/kernel/types.ts +34 -1
- package/plugins/immune-brain/runtime/kernel/validation.ts +202 -9
- package/plugins/immune-brain/runtime/plugin_version.ts +1 -1
- package/plugins/immune-brain/runtime/prompts/code-review.md +9 -1
- package/plugins/immune-brain/runtime/unattended/batch_git.ts +775 -0
- package/plugins/immune-brain/runtime/unattended/batch_plan.ts +203 -0
- package/plugins/immune-brain/runtime/unattended/batch_runner.ts +1224 -0
- package/plugins/immune-brain/runtime/unattended/batch_state.ts +360 -0
- package/plugins/immune-brain/runtime/unattended/types.ts +60 -0
- package/plugins/immune-brain/skills/imm-loop/SKILL.md +1 -0
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
// Git branch preflight and per-child scope-bounded commit for unattended batch runs.
|
|
2
|
+
// Defined by docs/specs/unattended-initiative-batch-run.spec.md section 3.5.
|
|
3
|
+
// Invariant: The runtime never creates, switches, or deletes a Git worktree,
|
|
4
|
+
// never pushes a ref, and never opens or updates a pull request.
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import {
|
|
8
|
+
constants,
|
|
9
|
+
closeSync,
|
|
10
|
+
existsSync,
|
|
11
|
+
lstatSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
openSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
realpathSync,
|
|
16
|
+
renameSync,
|
|
17
|
+
rmSync,
|
|
18
|
+
writeFileSync,
|
|
19
|
+
} from "node:fs";
|
|
20
|
+
import { dirname, join } from "node:path";
|
|
21
|
+
import { readAuditTaskPair, readSecureProjectFile } from "../kernel/storage";
|
|
22
|
+
import { pathMatchesScope } from "../workspace_scope";
|
|
23
|
+
|
|
24
|
+
export type BatchGitPreflightRejectReason =
|
|
25
|
+
| "batch_branch_exists"
|
|
26
|
+
| "dirty_working_tree"
|
|
27
|
+
| "uncommitted_head"
|
|
28
|
+
| "not_a_git_repository"
|
|
29
|
+
| "not_repository_root"
|
|
30
|
+
| string;
|
|
31
|
+
|
|
32
|
+
export type BatchGitPreflightResult =
|
|
33
|
+
| { ok: true; branch: string }
|
|
34
|
+
| { ok: false; reason: BatchGitPreflightRejectReason; message: string };
|
|
35
|
+
|
|
36
|
+
export interface BatchRunnerGitPort {
|
|
37
|
+
preflight(input: {
|
|
38
|
+
root: string;
|
|
39
|
+
initiative_slug: string;
|
|
40
|
+
base_head: string;
|
|
41
|
+
}): Promise<BatchGitPreflightResult> | BatchGitPreflightResult;
|
|
42
|
+
commitChild(
|
|
43
|
+
root: string,
|
|
44
|
+
taskId: string,
|
|
45
|
+
batchId: string,
|
|
46
|
+
head: string,
|
|
47
|
+
branch?: string,
|
|
48
|
+
intentPath?: string,
|
|
49
|
+
): Promise<{ commit: string }>;
|
|
50
|
+
lookupBatchCommit(
|
|
51
|
+
root: string,
|
|
52
|
+
taskId: string,
|
|
53
|
+
batchId: string,
|
|
54
|
+
expectedHead?: string,
|
|
55
|
+
branch?: string,
|
|
56
|
+
): Promise<{ commit: string } | null>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const DEFAULT_GIT_ENV = {
|
|
60
|
+
...process.env,
|
|
61
|
+
GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME || "Immune-Brain Batch",
|
|
62
|
+
GIT_AUTHOR_EMAIL: process.env.GIT_AUTHOR_EMAIL || "immune-brain@local",
|
|
63
|
+
GIT_COMMITTER_NAME: process.env.GIT_COMMITTER_NAME || "Immune-Brain Batch",
|
|
64
|
+
GIT_COMMITTER_EMAIL: process.env.GIT_COMMITTER_EMAIL || "immune-brain@local",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Preflight before any child is enrolled: requires clean working tree,
|
|
69
|
+
* committed HEAD, and absence of refs/heads/imm/<initiative-slug>.
|
|
70
|
+
* Verifies root is the top-level repository root (Finding 5).
|
|
71
|
+
* Explicitly passes --untracked-files=all so repo config cannot bypass dirty check (Finding 2).
|
|
72
|
+
* Disables Git hooks during checkout (Finding 2).
|
|
73
|
+
* Rejection leaves zero writes, zero batch state files, and the original branch.
|
|
74
|
+
* Passing preflight creates and switches to imm/<initiative-slug> from base_head.
|
|
75
|
+
*/
|
|
76
|
+
export function runBatchGitPreflight(input: {
|
|
77
|
+
root: string;
|
|
78
|
+
initiative_slug: string;
|
|
79
|
+
base_head: string;
|
|
80
|
+
}): BatchGitPreflightResult {
|
|
81
|
+
const { root, initiative_slug: initiativeSlug, base_head: baseHead } = input;
|
|
82
|
+
const branch = `imm/${initiativeSlug}`;
|
|
83
|
+
|
|
84
|
+
// 0. Verify root is a Git repository and is the top-level repository root (Finding 5)
|
|
85
|
+
const toplevelResult = spawnSync("git", ["-C", root, "rev-parse", "--show-toplevel"], {
|
|
86
|
+
encoding: "utf8",
|
|
87
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
88
|
+
});
|
|
89
|
+
if (toplevelResult.status !== 0 || !toplevelResult.stdout.trim()) {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
reason: "not_a_git_repository",
|
|
93
|
+
message: "root must be a Git repository with a committed HEAD",
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
let realToplevel: string;
|
|
97
|
+
let realRoot: string;
|
|
98
|
+
try {
|
|
99
|
+
realToplevel = realpathSync(toplevelResult.stdout.trim());
|
|
100
|
+
realRoot = realpathSync(root);
|
|
101
|
+
} catch {
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
reason: "not_repository_root",
|
|
105
|
+
message: "failed to resolve repository root",
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (realToplevel !== realRoot) {
|
|
109
|
+
return {
|
|
110
|
+
ok: false,
|
|
111
|
+
reason: "not_repository_root",
|
|
112
|
+
message: "batch root must be the top-level repository root, not a subdirectory",
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 1. Clean working tree check: explicitly pass --untracked-files=all so status.showUntrackedFiles=no is overridden (Finding 2)
|
|
117
|
+
// Also reject unsupported index flags (assume-unchanged/skip-worktree) that hide modifications (Finding 2)
|
|
118
|
+
const flaggedPreflight = getUnsupportedIndexFlags(root);
|
|
119
|
+
if (flaggedPreflight.length > 0) {
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
reason: "dirty_working_tree",
|
|
123
|
+
message: `unsupported index flags (assume-unchanged/skip-worktree) detected: ${flaggedPreflight.join(", ")}`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const statusResult = spawnSync("git", ["-C", root, "status", "--porcelain", "--untracked-files=all", "--ignore-submodules=none"], {
|
|
128
|
+
encoding: "utf8",
|
|
129
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
130
|
+
});
|
|
131
|
+
if (statusResult.status !== 0) {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
reason: "dirty_working_tree",
|
|
135
|
+
message: statusResult.stderr?.trim() || "failed to inspect working tree status",
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
if (statusResult.stdout.trim().length > 0) {
|
|
139
|
+
return {
|
|
140
|
+
ok: false,
|
|
141
|
+
reason: "dirty_working_tree",
|
|
142
|
+
message: "working tree is dirty before batch preflight",
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// 2. Committed HEAD check
|
|
147
|
+
const headResult = spawnSync("git", ["-C", root, "rev-parse", "--verify", "HEAD^{commit}"], {
|
|
148
|
+
encoding: "utf8",
|
|
149
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
150
|
+
});
|
|
151
|
+
if (headResult.status !== 0 || !headResult.stdout.trim()) {
|
|
152
|
+
return {
|
|
153
|
+
ok: false,
|
|
154
|
+
reason: "uncommitted_head",
|
|
155
|
+
message: "HEAD is uncommitted or not a valid commit",
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 3. Absence of refs/heads/imm/<initiative-slug>
|
|
160
|
+
const branchCheck = spawnSync(
|
|
161
|
+
"git",
|
|
162
|
+
["-C", root, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
|
|
163
|
+
{ stdio: ["ignore", "ignore", "ignore"] },
|
|
164
|
+
);
|
|
165
|
+
if (branchCheck.status === 0) {
|
|
166
|
+
return {
|
|
167
|
+
ok: false,
|
|
168
|
+
reason: "batch_branch_exists",
|
|
169
|
+
message: `branch refs/heads/${branch} already exists`,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const originalBranchResult = spawnSync("git", ["-C", root, "symbolic-ref", "--short", "HEAD"], {
|
|
174
|
+
encoding: "utf8",
|
|
175
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
176
|
+
});
|
|
177
|
+
const originalBranch = originalBranchResult.stdout.trim();
|
|
178
|
+
|
|
179
|
+
// 4. Create and switch to imm/<initiative-slug> from base_head with hooks explicitly disabled (Finding 2)
|
|
180
|
+
const checkoutResult = spawnSync(
|
|
181
|
+
"git",
|
|
182
|
+
["-C", root, "-c", "core.hooksPath=/dev/null", "checkout", "-b", branch, baseHead],
|
|
183
|
+
{
|
|
184
|
+
encoding: "utf8",
|
|
185
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
186
|
+
env: DEFAULT_GIT_ENV,
|
|
187
|
+
},
|
|
188
|
+
);
|
|
189
|
+
if (checkoutResult.status !== 0) {
|
|
190
|
+
const stderr = checkoutResult.stderr?.trim() || "";
|
|
191
|
+
// Check if checkout failed because branch already exists concurrently (Finding 2)
|
|
192
|
+
const branchExists =
|
|
193
|
+
stderr.includes("already exists") ||
|
|
194
|
+
spawnSync("git", ["-C", root, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`]).status === 0;
|
|
195
|
+
|
|
196
|
+
// Restore original branch checkout if HEAD was switched
|
|
197
|
+
const currentBranchCheck = spawnSync("git", ["-C", root, "symbolic-ref", "--short", "HEAD"], {
|
|
198
|
+
encoding: "utf8",
|
|
199
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
200
|
+
}).stdout.trim();
|
|
201
|
+
if (currentBranchCheck === branch && originalBranch && originalBranch !== branch) {
|
|
202
|
+
spawnSync("git", ["-C", root, "-c", "core.hooksPath=/dev/null", "checkout", originalBranch], {
|
|
203
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
// Do not unconditionally delete branch with branch -D; preserve concurrent external refs (Finding 2)
|
|
207
|
+
|
|
208
|
+
if (branchExists) {
|
|
209
|
+
return {
|
|
210
|
+
ok: false,
|
|
211
|
+
reason: "batch_branch_exists",
|
|
212
|
+
message: `branch refs/heads/${branch} already exists`,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
ok: false,
|
|
217
|
+
reason: "branch_creation_failed",
|
|
218
|
+
message: stderr || `failed to checkout -b ${branch} ${baseHead}`,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return { ok: true, branch };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function hasBoundaryWhitespace(path: string): boolean {
|
|
226
|
+
return path.split("/").some((segment) => segment.trim() !== segment || segment.length === 0);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function getUnsupportedIndexFlags(root: string): string[] {
|
|
230
|
+
const result = spawnSync("git", ["-C", root, "ls-files", "-v", "-z", "--"], {
|
|
231
|
+
encoding: "utf8",
|
|
232
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
233
|
+
});
|
|
234
|
+
if (result.status !== 0) {
|
|
235
|
+
throw new Error("failed to inspect index flags via git ls-files -v");
|
|
236
|
+
}
|
|
237
|
+
const entries = result.stdout.split("\0").filter((e) => e.length > 0);
|
|
238
|
+
const flagged: string[] = [];
|
|
239
|
+
for (const entry of entries) {
|
|
240
|
+
const tag = entry[0];
|
|
241
|
+
// Reject h (assume-unchanged), S (skip-worktree), and s (both flags combined) (Finding 1)
|
|
242
|
+
if (tag === "h" || tag === "S" || tag === "s") {
|
|
243
|
+
flagged.push(entry.slice(2));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return flagged;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function isPathAllowedForChild(path: string, taskId: string, scopeHint: string[]): boolean {
|
|
250
|
+
// Finding 4: reject segment-level leading/trailing whitespace
|
|
251
|
+
if (hasBoundaryWhitespace(path)) return false;
|
|
252
|
+
// Finding 2: on POSIX, literal backslashes are valid filename characters and must not
|
|
253
|
+
// be normalized into path separators; reject non-canonical backslash paths before check
|
|
254
|
+
if (path.includes("\\")) return false;
|
|
255
|
+
|
|
256
|
+
const normalized = path.replace(/^\.\//, "");
|
|
257
|
+
if (normalized === `.imm/audit/${taskId}` || normalized.startsWith(`.imm/audit/${taskId}/`)) {
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
return scopeHint.some((scopePath) => {
|
|
261
|
+
if (scopePath.includes("*") || scopePath.includes("?")) {
|
|
262
|
+
return pathMatchesScope(normalized, scopePath);
|
|
263
|
+
}
|
|
264
|
+
return normalized === scopePath || normalized.startsWith(`${scopePath}/`);
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function getChangedProjectPaths(root: string): string[] {
|
|
269
|
+
const tracked = spawnSync("git", ["-C", root, "diff-index", "--name-only", "-z", "--ignore-submodules=none", "HEAD", "--"], {
|
|
270
|
+
encoding: "utf8",
|
|
271
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
272
|
+
});
|
|
273
|
+
if (tracked.status !== 0) throw new Error("failed to inspect tracked diff vs HEAD");
|
|
274
|
+
const untracked = spawnSync("git", ["-C", root, "ls-files", "--others", "--exclude-standard", "-z", "--"], {
|
|
275
|
+
encoding: "utf8",
|
|
276
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
277
|
+
});
|
|
278
|
+
if (untracked.status !== 0) throw new Error("failed to inspect untracked files");
|
|
279
|
+
|
|
280
|
+
// Finding 4: do NOT trim NUL-delimited paths; preserve exact path bytes
|
|
281
|
+
const splitZ = (s: string) => s.split("\0").filter((p) => p.length > 0);
|
|
282
|
+
return [...new Set([...splitZ(tracked.stdout), ...splitZ(untracked.stdout)])];
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function getStagedProjectPaths(root: string): string[] {
|
|
286
|
+
const staged = spawnSync("git", ["-C", root, "diff-index", "--cached", "--name-only", "-z", "--ignore-submodules=none", "HEAD", "--"], {
|
|
287
|
+
encoding: "utf8",
|
|
288
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
289
|
+
});
|
|
290
|
+
if (staged.status !== 0) throw new Error("failed to inspect staged diff vs HEAD");
|
|
291
|
+
return staged.stdout.split("\0").filter((p) => p.length > 0);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function getUnstagedProjectPaths(root: string): string[] {
|
|
295
|
+
const diffFiles = spawnSync("git", ["-C", root, "diff-files", "--name-only", "-z", "--ignore-submodules=none", "--"], {
|
|
296
|
+
encoding: "utf8",
|
|
297
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
298
|
+
});
|
|
299
|
+
if (diffFiles.status !== 0) throw new Error("failed to inspect unstaged tracked changes");
|
|
300
|
+
const untracked = spawnSync("git", ["-C", root, "ls-files", "--others", "--exclude-standard", "-z", "--"], {
|
|
301
|
+
encoding: "utf8",
|
|
302
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
303
|
+
});
|
|
304
|
+
if (untracked.status !== 0) throw new Error("failed to inspect untracked files");
|
|
305
|
+
|
|
306
|
+
const splitZ = (s: string) => s.split("\0").filter((p) => p.length > 0);
|
|
307
|
+
return [...new Set([...splitZ(diffFiles.stdout), ...splitZ(untracked.stdout)])];
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function getCommittedDeltaPaths(root: string): string[] {
|
|
311
|
+
const delta = spawnSync(
|
|
312
|
+
"git",
|
|
313
|
+
["-C", root, "diff-tree", "--no-commit-id", "--name-only", "-z", "-r", "HEAD~1", "HEAD"],
|
|
314
|
+
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
|
|
315
|
+
);
|
|
316
|
+
if (delta.status !== 0) throw new Error("failed to inspect committed tree delta");
|
|
317
|
+
return delta.stdout.split("\0").filter((p) => p.length > 0);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function commitEvidencePath(batchId: string, taskId: string): string {
|
|
321
|
+
return join(".imm", "state", "batches", "commits", `${batchId}-${taskId}.json`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function ensureSecureDirectory(root: string, relativePath: string): string {
|
|
325
|
+
const segments = relativePath.split("/").filter(Boolean);
|
|
326
|
+
let current = root;
|
|
327
|
+
for (const segment of segments) {
|
|
328
|
+
current = join(current, segment);
|
|
329
|
+
if (existsSync(current)) {
|
|
330
|
+
const stats = lstatSync(current);
|
|
331
|
+
if (stats.isSymbolicLink() || !stats.isDirectory()) {
|
|
332
|
+
throw new Error(`${segment} exists but is not a real directory`);
|
|
333
|
+
}
|
|
334
|
+
} else {
|
|
335
|
+
mkdirSync(current);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return current;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function writeFileAtomically(root: string, relativePath: string, bytes: string): void {
|
|
342
|
+
const target = join(root, relativePath);
|
|
343
|
+
const targetDir = dirname(target);
|
|
344
|
+
ensureSecureDirectory(root, dirname(relativePath));
|
|
345
|
+
const stats = lstatSync(targetDir);
|
|
346
|
+
if (stats.isSymbolicLink() || !stats.isDirectory()) {
|
|
347
|
+
throw new Error(`${dirname(relativePath)} is not a real directory`);
|
|
348
|
+
}
|
|
349
|
+
if (existsSync(target)) {
|
|
350
|
+
const targetStats = lstatSync(target);
|
|
351
|
+
if (targetStats.isSymbolicLink()) {
|
|
352
|
+
throw new Error(`${relativePath} is a symlink`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const tempPath = `${target}.${randomUUID()}.tmp`;
|
|
356
|
+
let fd: number | null = null;
|
|
357
|
+
try {
|
|
358
|
+
fd = openSync(tempPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
|
|
359
|
+
writeFileSync(fd, bytes, "utf8");
|
|
360
|
+
closeSync(fd);
|
|
361
|
+
fd = null;
|
|
362
|
+
renameSync(tempPath, target);
|
|
363
|
+
} finally {
|
|
364
|
+
if (fd !== null) closeSync(fd);
|
|
365
|
+
if (existsSync(tempPath)) {
|
|
366
|
+
try {
|
|
367
|
+
rmSync(tempPath);
|
|
368
|
+
} catch {
|
|
369
|
+
/* temp already moved */
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function writeBatchCommitEvidence(
|
|
376
|
+
root: string,
|
|
377
|
+
evidence: { batchId: string; taskId: string; commit: string; parentHead: string },
|
|
378
|
+
): void {
|
|
379
|
+
const path = commitEvidencePath(evidence.batchId, evidence.taskId);
|
|
380
|
+
const payload = {
|
|
381
|
+
contract: "assurance_kernel/batch_commit_evidence/v1",
|
|
382
|
+
batch_id: evidence.batchId,
|
|
383
|
+
task_id: evidence.taskId,
|
|
384
|
+
commit: evidence.commit,
|
|
385
|
+
parent_head: evidence.parentHead,
|
|
386
|
+
created_at: new Date().toISOString(),
|
|
387
|
+
};
|
|
388
|
+
writeFileAtomically(root, path, `${JSON.stringify(payload, null, 2)}\n`);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function readBatchCommitEvidence(
|
|
392
|
+
root: string,
|
|
393
|
+
batchId: string,
|
|
394
|
+
taskId: string,
|
|
395
|
+
): { commit: string; parent_head: string } | null {
|
|
396
|
+
const path = commitEvidencePath(batchId, taskId);
|
|
397
|
+
const fullPath = join(root, path);
|
|
398
|
+
if (!existsSync(fullPath)) return null;
|
|
399
|
+
try {
|
|
400
|
+
const content = readSecureProjectFile(root, path);
|
|
401
|
+
const parsed = JSON.parse(content) as {
|
|
402
|
+
contract?: unknown;
|
|
403
|
+
batch_id?: unknown;
|
|
404
|
+
task_id?: unknown;
|
|
405
|
+
commit?: unknown;
|
|
406
|
+
parent_head?: unknown;
|
|
407
|
+
};
|
|
408
|
+
if (
|
|
409
|
+
parsed.contract === "assurance_kernel/batch_commit_evidence/v1" &&
|
|
410
|
+
parsed.batch_id === batchId &&
|
|
411
|
+
parsed.task_id === taskId &&
|
|
412
|
+
typeof parsed.commit === "string" &&
|
|
413
|
+
typeof parsed.parent_head === "string"
|
|
414
|
+
) {
|
|
415
|
+
return { commit: parsed.commit, parent_head: parsed.parent_head };
|
|
416
|
+
}
|
|
417
|
+
} catch {
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
return null;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Scope-bounded commit after Kernel reports a child done.
|
|
425
|
+
* Reads authorized scope and goal from verified terminal TaskRecord.intent_snapshot (Finding 1).
|
|
426
|
+
* Full audit pair validation with identity, contract, and hash checks (Finding 2).
|
|
427
|
+
* Staging includes exactly the changed paths inside that child's TaskIntent scope
|
|
428
|
+
* envelope plus .imm/audit/<task-id>/**. If any change exists outside that set,
|
|
429
|
+
* nothing is committed and an error with message "dirty_outside_scope" is thrown.
|
|
430
|
+
* Advances expected_head lineage. Disables all Git hooks (Finding 1).
|
|
431
|
+
* Preserves files on failure without destructive rollback (Finding 1).
|
|
432
|
+
* Records durable commit evidence for tamper-proof crash recovery (Finding 1).
|
|
433
|
+
*/
|
|
434
|
+
export async function commitBatchChild(input: {
|
|
435
|
+
root: string;
|
|
436
|
+
taskId: string;
|
|
437
|
+
batchId: string;
|
|
438
|
+
expectedHead: string;
|
|
439
|
+
branch?: string;
|
|
440
|
+
intentPath?: string;
|
|
441
|
+
}): Promise<{ commit: string }> {
|
|
442
|
+
const { root, taskId, batchId, expectedHead, branch: expectedBranch } = input;
|
|
443
|
+
|
|
444
|
+
// 1. Verify Kernel reports that child done via immutable terminal audit pair (Finding 2)
|
|
445
|
+
const auditPair = readAuditTaskPair(root, taskId);
|
|
446
|
+
if (!auditPair) {
|
|
447
|
+
throw new Error(`cannot commit child ${taskId}: task is not settled done (audit pair missing)`);
|
|
448
|
+
}
|
|
449
|
+
const lifecycle = "lifecycle" in auditPair.record ? auditPair.record.lifecycle : auditPair.record.phase;
|
|
450
|
+
if (lifecycle !== "done") {
|
|
451
|
+
throw new Error(`cannot commit child ${taskId}: task is not settled done (lifecycle is ${lifecycle})`);
|
|
452
|
+
}
|
|
453
|
+
if (auditPair.proof.terminal_lifecycle !== "done") {
|
|
454
|
+
throw new Error(`cannot commit child ${taskId}: terminal proof lifecycle is not done`);
|
|
455
|
+
}
|
|
456
|
+
if (auditPair.record.task_id !== taskId || auditPair.proof.task_id !== taskId) {
|
|
457
|
+
throw new Error(`cannot commit child ${taskId}: audit task id mismatch`);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// 2. Read scope_hint and goal from the verified terminal TaskRecord.intent_snapshot (Finding 1)
|
|
461
|
+
const intentSnapshot = auditPair.record.intent_snapshot;
|
|
462
|
+
const goal = typeof intentSnapshot.goal === "string" ? intentSnapshot.goal : "";
|
|
463
|
+
const scopeHint = Array.isArray(intentSnapshot.scope_hint)
|
|
464
|
+
? intentSnapshot.scope_hint.filter((s): s is string => typeof s === "string")
|
|
465
|
+
: [];
|
|
466
|
+
|
|
467
|
+
// 3. Verify symbolic HEAD branch and HEAD lineage before commit (Review Round 7 Finding 1)
|
|
468
|
+
if (expectedBranch !== undefined) {
|
|
469
|
+
const currentBranchResult = spawnSync("git", ["-C", root, "symbolic-ref", "--short", "HEAD"], {
|
|
470
|
+
encoding: "utf8",
|
|
471
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
472
|
+
});
|
|
473
|
+
const currentBranch = currentBranchResult.stdout.trim();
|
|
474
|
+
if (currentBranchResult.status !== 0 || currentBranch !== expectedBranch) {
|
|
475
|
+
throw new Error(
|
|
476
|
+
`batch_head_lineage_broken: current branch ${currentBranch} does not match expected branch ${expectedBranch}`,
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const currentHeadResult = spawnSync("git", ["-C", root, "rev-parse", "HEAD"], {
|
|
482
|
+
encoding: "utf8",
|
|
483
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
484
|
+
});
|
|
485
|
+
const currentHead = currentHeadResult.stdout.trim();
|
|
486
|
+
if (currentHeadResult.status !== 0 || !currentHead || currentHead !== expectedHead) {
|
|
487
|
+
throw new Error(
|
|
488
|
+
`batch_head_lineage_broken: current HEAD ${currentHead} does not match expected_head ${expectedHead}`,
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// 3b. Reject unsupported index flags before scope inspection (Finding 2)
|
|
493
|
+
const flaggedCommit = getUnsupportedIndexFlags(root);
|
|
494
|
+
if (flaggedCommit.length > 0) {
|
|
495
|
+
throw new Error("dirty_outside_scope");
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// 4. Identify changed paths and verify they are strictly within scope envelope + .imm/audit/<task-id>/** (Finding 2 & 4)
|
|
499
|
+
const changedPaths = getChangedProjectPaths(root);
|
|
500
|
+
const outsideScope = changedPaths.filter((p) => !isPathAllowedForChild(p, taskId, scopeHint));
|
|
501
|
+
if (outsideScope.length > 0) {
|
|
502
|
+
// Nothing is committed
|
|
503
|
+
throw new Error("dirty_outside_scope");
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// 5. Stage only paths needing an update, preserving already-staged deletions/renames (Finding 2)
|
|
507
|
+
const pathsToStage = getUnstagedProjectPaths(root);
|
|
508
|
+
if (pathsToStage.length > 0) {
|
|
509
|
+
const addResult = spawnSync("git", ["-C", root, "--literal-pathspecs", "add", "-A", "--", ...pathsToStage], {
|
|
510
|
+
encoding: "utf8",
|
|
511
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
512
|
+
});
|
|
513
|
+
if (addResult.status !== 0) {
|
|
514
|
+
throw new Error(`failed to stage changed paths: ${addResult.stderr?.trim() || "git add failed"}`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// 6. review round 16: git add cannot capture submodule-internal changes in
|
|
519
|
+
// the parent repository; reject residual unstaged work so a child's
|
|
520
|
+
// implementation is never left uncommitted while the batch reports success.
|
|
521
|
+
const residualUnstaged = getUnstagedProjectPaths(root);
|
|
522
|
+
if (residualUnstaged.length > 0) {
|
|
523
|
+
throw new Error(
|
|
524
|
+
`residual unstaged changes cannot be captured by the parent repository: ${residualUnstaged.join(", ")}`,
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// 5b. Verify actual staged path set before commit (Finding 4)
|
|
529
|
+
const staged = getStagedProjectPaths(root);
|
|
530
|
+
const stagedOutside = staged.filter((p) => !isPathAllowedForChild(p, taskId, scopeHint));
|
|
531
|
+
if (stagedOutside.length > 0) {
|
|
532
|
+
spawnSync("git", ["-C", root, "reset", "--quiet"], { stdio: ["ignore", "ignore", "ignore"] });
|
|
533
|
+
throw new Error("dirty_outside_scope");
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// 6. Commit with structured message, Immune-Brain-Batch trailer, and all hooks disabled (Finding 1)
|
|
537
|
+
const goalFirstLine = goal.trim().split(/\r?\n/)[0]?.trim() || taskId;
|
|
538
|
+
const commitMessage = `imm(${taskId}): ${goalFirstLine}\n\nImmune-Brain-Batch: ${batchId}\n`;
|
|
539
|
+
|
|
540
|
+
const commitResult = spawnSync(
|
|
541
|
+
"git",
|
|
542
|
+
["-C", root, "-c", "core.hooksPath=/dev/null", "commit", "--no-verify", "-F", "-"],
|
|
543
|
+
{
|
|
544
|
+
input: commitMessage,
|
|
545
|
+
encoding: "utf8",
|
|
546
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
547
|
+
env: DEFAULT_GIT_ENV,
|
|
548
|
+
},
|
|
549
|
+
);
|
|
550
|
+
if (commitResult.status !== 0) {
|
|
551
|
+
throw new Error(`commit failed for child ${taskId}: ${commitResult.stderr?.trim() || "git commit failed"}`);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// 7. Verify new HEAD and verify single parent equals expectedHead (Finding 3)
|
|
555
|
+
const newHeadResult = spawnSync("git", ["-C", root, "rev-parse", "HEAD"], {
|
|
556
|
+
encoding: "utf8",
|
|
557
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
558
|
+
});
|
|
559
|
+
const newHead = newHeadResult.stdout.trim();
|
|
560
|
+
if (newHeadResult.status !== 0 || !newHead || newHead === expectedHead) {
|
|
561
|
+
throw new Error("commit_failed");
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const parentsResult = spawnSync("git", ["-C", root, "rev-parse", "HEAD^@"], {
|
|
565
|
+
encoding: "utf8",
|
|
566
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
567
|
+
});
|
|
568
|
+
const parents = parentsResult.stdout.trim().split(/\s+/).filter(Boolean);
|
|
569
|
+
if (parents.length !== 1 || parents[0] !== expectedHead) {
|
|
570
|
+
throw new Error(
|
|
571
|
+
`batch_head_lineage_broken: commit parent ${parents.join(",")} does not match expected_head ${expectedHead}`,
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// 8. Post-commit verification: verify committed tree delta contains strictly authorized paths.
|
|
576
|
+
// Preserves files on disk on failure without destructive rollback (Finding 1)
|
|
577
|
+
const committedDelta = getCommittedDeltaPaths(root);
|
|
578
|
+
const deltaOutside = committedDelta.filter((p) => !isPathAllowedForChild(p, taskId, scopeHint));
|
|
579
|
+
if (deltaOutside.length > 0) {
|
|
580
|
+
throw new Error("dirty_outside_scope");
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// 9. Persist durable commit evidence before returning (Finding 1)
|
|
584
|
+
writeBatchCommitEvidence(root, { batchId, taskId, commit: newHead, parentHead: expectedHead });
|
|
585
|
+
|
|
586
|
+
return { commit: newHead };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Look up an existing batch commit for a child to support idempotent crash recovery.
|
|
591
|
+
* Requires durable batch commit evidence persisted by commitBatchChild (Finding 1).
|
|
592
|
+
* Verifies current HEAD equals the commit and its single parent equals expectedHead (Finding 3).
|
|
593
|
+
* Verifies terminal audit, batch author identity, and commit tree delta within scope (Finding 3).
|
|
594
|
+
* Throws on git lookup error rather than treating non-zero as missing (Finding 3).
|
|
595
|
+
*/
|
|
596
|
+
export async function lookupBatchCommit(input: {
|
|
597
|
+
root: string;
|
|
598
|
+
taskId: string;
|
|
599
|
+
batchId: string;
|
|
600
|
+
expectedHead?: string;
|
|
601
|
+
branch?: string;
|
|
602
|
+
}): Promise<{ commit: string } | null> {
|
|
603
|
+
const { root, taskId, batchId, expectedHead, branch: expectedBranch } = input;
|
|
604
|
+
const FORMAT = "%H%x00%(trailers:key=Immune-Brain-Batch,valueonly)%x00%an%x00%ae%x00%s";
|
|
605
|
+
const subjectPrefix = `imm(${taskId}):`;
|
|
606
|
+
const parseEntry = (entry: string) => {
|
|
607
|
+
const parts = entry.split("\0");
|
|
608
|
+
return {
|
|
609
|
+
commit: parts[0]?.trim() ?? "",
|
|
610
|
+
trailer: parts[1]?.trim() ?? "",
|
|
611
|
+
authorName: parts[2]?.trim() ?? "",
|
|
612
|
+
// review round 13: the marker must sit on the subject prefix, not
|
|
613
|
+
// merely anywhere in the message body.
|
|
614
|
+
subject: parts[4]?.trim() ?? "",
|
|
615
|
+
};
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
// review round 14: resolve the recorded commit directly from durable
|
|
619
|
+
// evidence so a later commit whose goal text mentions the marker can never
|
|
620
|
+
// shadow it, independent of grep dialect config. The fixed-strings scan
|
|
621
|
+
// below remains only as a discovery fallback for evidence-less commits.
|
|
622
|
+
const evidence = readBatchCommitEvidence(root, batchId, taskId);
|
|
623
|
+
type Candidate = { commit: string; trailer: string; authorName: string; subject: string };
|
|
624
|
+
let candidates: Candidate[] = [];
|
|
625
|
+
let evidenceBacked = false;
|
|
626
|
+
if (evidence && evidence.commit) {
|
|
627
|
+
const direct = spawnSync(
|
|
628
|
+
"git",
|
|
629
|
+
["-C", root, "log", "-n", "1", `--format=${FORMAT}`, evidence.commit, "--"],
|
|
630
|
+
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
|
|
631
|
+
);
|
|
632
|
+
if (direct.status !== 0) {
|
|
633
|
+
throw new Error(`failed to inspect batch commit for ${taskId}: ${direct.stderr?.trim() || "git log failed"}`);
|
|
634
|
+
}
|
|
635
|
+
if (direct.stdout?.trim()) {
|
|
636
|
+
const parsed = parseEntry(direct.stdout);
|
|
637
|
+
if (parsed.commit === evidence.commit) {
|
|
638
|
+
candidates = [parsed];
|
|
639
|
+
evidenceBacked = true;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
if (!candidates.length) {
|
|
644
|
+
// --fixed-strings overrides grep.patternType config (e.g.
|
|
645
|
+
// grep.extendedRegexp=true); bounded scan, fully validated below.
|
|
646
|
+
const result = spawnSync(
|
|
647
|
+
"git",
|
|
648
|
+
[
|
|
649
|
+
"-C",
|
|
650
|
+
root,
|
|
651
|
+
"log",
|
|
652
|
+
"--fixed-strings",
|
|
653
|
+
`--grep=${subjectPrefix}`,
|
|
654
|
+
"-n",
|
|
655
|
+
"20",
|
|
656
|
+
`--format=%x1e${FORMAT}`,
|
|
657
|
+
],
|
|
658
|
+
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
|
|
659
|
+
);
|
|
660
|
+
if (result.status !== 0) {
|
|
661
|
+
throw new Error(`failed to lookup batch commit for ${taskId}: ${result.stderr?.trim() || "git log failed"}`);
|
|
662
|
+
}
|
|
663
|
+
candidates = (result.stdout ?? "")
|
|
664
|
+
.split("\x1e")
|
|
665
|
+
.filter((entry) => entry.trim())
|
|
666
|
+
.map(parseEntry);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
let match: Candidate | null = null;
|
|
670
|
+
for (const candidate of candidates) {
|
|
671
|
+
if (!candidate.commit || candidate.trailer !== batchId) continue;
|
|
672
|
+
if (!candidate.subject.startsWith(subjectPrefix)) continue;
|
|
673
|
+
match = candidate;
|
|
674
|
+
break;
|
|
675
|
+
}
|
|
676
|
+
if (!match) return null;
|
|
677
|
+
const commit = match.commit;
|
|
678
|
+
|
|
679
|
+
// Finding 1: Require durable commit evidence persisted by commitBatchChild
|
|
680
|
+
// Rejects forged in-scope external commits created during interruption
|
|
681
|
+
if (!evidenceBacked) {
|
|
682
|
+
const evidenceForMatch = readBatchCommitEvidence(root, batchId, taskId);
|
|
683
|
+
if (!evidenceForMatch || evidenceForMatch.commit !== commit) {
|
|
684
|
+
throw new Error(
|
|
685
|
+
`batch_head_lineage_broken: commit ${commit} lacks durable batch runner production evidence`,
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Verify author identity matches batch runner
|
|
691
|
+
if (match.authorName && match.authorName !== DEFAULT_GIT_ENV.GIT_AUTHOR_NAME) {
|
|
692
|
+
throw new Error(
|
|
693
|
+
`batch_head_lineage_broken: adopted commit author ${match.authorName} does not match batch authority ${DEFAULT_GIT_ENV.GIT_AUTHOR_NAME}`,
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Verify symbolic branch matches expected branch (Review Round 7 Finding 1)
|
|
698
|
+
if (expectedBranch !== undefined) {
|
|
699
|
+
const currentBranchResult = spawnSync("git", ["-C", root, "symbolic-ref", "--short", "HEAD"], {
|
|
700
|
+
encoding: "utf8",
|
|
701
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
702
|
+
});
|
|
703
|
+
const currentBranch = currentBranchResult.stdout.trim();
|
|
704
|
+
if (currentBranchResult.status !== 0 || currentBranch !== expectedBranch) {
|
|
705
|
+
throw new Error(
|
|
706
|
+
`batch_head_lineage_broken: current branch ${currentBranch} does not match expected branch ${expectedBranch}`,
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Finding 3: Verify terminal audit, expectedHead, single parent, and commit tree delta within authorized scope
|
|
712
|
+
if (expectedHead !== undefined) {
|
|
713
|
+
const currentHeadResult = spawnSync("git", ["-C", root, "rev-parse", "HEAD"], {
|
|
714
|
+
encoding: "utf8",
|
|
715
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
716
|
+
});
|
|
717
|
+
const currentHead = currentHeadResult.stdout.trim();
|
|
718
|
+
if (currentHead !== commit) {
|
|
719
|
+
throw new Error(`batch_head_lineage_broken: current HEAD ${currentHead} diverged from adopted commit ${commit}`);
|
|
720
|
+
}
|
|
721
|
+
const parentsResult = spawnSync("git", ["-C", root, "rev-parse", `${commit}^@`], {
|
|
722
|
+
encoding: "utf8",
|
|
723
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
724
|
+
});
|
|
725
|
+
const parents = parentsResult.stdout.trim().split(/\s+/).filter(Boolean);
|
|
726
|
+
if (parents.length !== 1 || parents[0] !== expectedHead) {
|
|
727
|
+
throw new Error(
|
|
728
|
+
`batch_head_lineage_broken: adopted commit parent ${parents.join(",")} does not match expected_head ${expectedHead}`,
|
|
729
|
+
);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Verify terminal audit exists and is settled done
|
|
733
|
+
const auditPair = readAuditTaskPair(root, taskId);
|
|
734
|
+
if (!auditPair) {
|
|
735
|
+
throw new Error(`batch_head_lineage_broken: adopted commit lacks terminal audit pair for ${taskId}`);
|
|
736
|
+
}
|
|
737
|
+
const lifecycle = "lifecycle" in auditPair.record ? auditPair.record.lifecycle : auditPair.record.phase;
|
|
738
|
+
if (lifecycle !== "done") {
|
|
739
|
+
throw new Error(`batch_head_lineage_broken: adopted commit task lifecycle is not done: ${lifecycle}`);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// Verify commit tree delta is strictly within authorized scope_hint
|
|
743
|
+
const scopeHint = Array.isArray(auditPair.record.intent_snapshot.scope_hint)
|
|
744
|
+
? auditPair.record.intent_snapshot.scope_hint.filter((s): s is string => typeof s === "string")
|
|
745
|
+
: [];
|
|
746
|
+
const deltaResult = spawnSync(
|
|
747
|
+
"git",
|
|
748
|
+
["-C", root, "diff-tree", "--no-commit-id", "--name-only", "-z", "-r", expectedHead, commit],
|
|
749
|
+
{ encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] },
|
|
750
|
+
);
|
|
751
|
+
if (deltaResult.status !== 0) {
|
|
752
|
+
throw new Error("batch_head_lineage_broken: failed to inspect adopted commit delta");
|
|
753
|
+
}
|
|
754
|
+
const changedInCommit = deltaResult.stdout.split("\0").filter((p) => p.length > 0);
|
|
755
|
+
const outside = changedInCommit.filter((p) => !isPathAllowedForChild(p, taskId, scopeHint));
|
|
756
|
+
if (outside.length > 0) {
|
|
757
|
+
throw new Error(
|
|
758
|
+
`batch_head_lineage_broken: adopted commit contains out-of-scope changes: ${outside.join(", ")}`,
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
return { commit };
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/** Default Git port using the real repository Git operations. */
|
|
767
|
+
export function createDefaultBatchGitPort(): BatchRunnerGitPort {
|
|
768
|
+
return {
|
|
769
|
+
preflight: runBatchGitPreflight,
|
|
770
|
+
commitChild: (root, taskId, batchId, head, branch, intentPath) =>
|
|
771
|
+
commitBatchChild({ root, taskId, batchId, expectedHead: head, branch, intentPath }),
|
|
772
|
+
lookupBatchCommit: (root, taskId, batchId, expectedHead, branch) =>
|
|
773
|
+
lookupBatchCommit({ root, taskId, batchId, expectedHead, branch }),
|
|
774
|
+
};
|
|
775
|
+
}
|