@felixdotgo/aidlc-kit 0.0.1 → 0.1.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.
Files changed (123) hide show
  1. package/DEVELOPMENT.md +1 -1
  2. package/README.md +20 -9
  3. package/dist/assets/.agents/aidlc/conventions.md +8 -8
  4. package/dist/assets/.agents/aidlc/orchestrator.md +15 -10
  5. package/dist/assets/.agents/aidlc/phase-build.md +7 -7
  6. package/dist/assets/.agents/aidlc/phase-clarify.md +3 -3
  7. package/dist/assets/.agents/aidlc/phase-plan.md +6 -6
  8. package/dist/assets/.agents/aidlc/phase-wrap.md +1 -1
  9. package/dist/assets/.agents/aidlc/schemas/manifest.schema.json +1 -1
  10. package/dist/assets/.agents/aidlc/scripts/assignment.mjs +36 -0
  11. package/dist/assets/.agents/aidlc/scripts/context.mjs +2 -0
  12. package/dist/assets/.agents/aidlc/scripts/gate-check.mjs +2 -0
  13. package/dist/assets/.agents/aidlc/scripts/gate-view.mjs +2 -0
  14. package/dist/assets/.agents/aidlc/scripts/lib/assignment-store.mjs +181 -0
  15. package/dist/assets/.agents/aidlc/scripts/lib/authority.mjs +3 -0
  16. package/dist/assets/.agents/aidlc/scripts/lib/context-runtime.mjs +13 -1
  17. package/dist/assets/.agents/aidlc/scripts/lib/feature-store.mjs +70 -0
  18. package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +220 -0
  19. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +183 -0
  20. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +100 -0
  21. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +58 -0
  22. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +320 -0
  23. package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +284 -0
  24. package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +51 -0
  25. package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-artifacts.mjs +63 -0
  26. package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-broker.mjs +53 -0
  27. package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-local-broker.mjs +44 -0
  28. package/dist/assets/.agents/aidlc/scripts/lib/loop-lead-sandbox.mjs +88 -0
  29. package/dist/assets/.agents/aidlc/scripts/lib/loop-lease.mjs +89 -0
  30. package/dist/assets/.agents/aidlc/scripts/lib/loop-local-gate-receipt.mjs +32 -0
  31. package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +162 -0
  32. package/dist/assets/.agents/aidlc/scripts/lib/loop-planner.mjs +13 -0
  33. package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +260 -0
  34. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-local.mjs +131 -0
  35. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +55 -0
  36. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor.mjs +101 -0
  37. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +32 -0
  38. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-snapshot.mjs +66 -0
  39. package/dist/assets/.agents/aidlc/scripts/lib/remote-state-client.mjs +112 -0
  40. package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +41 -6
  41. package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +90 -0
  42. package/dist/assets/.agents/aidlc/scripts/loop-gate.mjs +58 -0
  43. package/dist/assets/.agents/aidlc/scripts/loop.mjs +36 -0
  44. package/dist/assets/.agents/aidlc/scripts/remote-state.mjs +311 -0
  45. package/dist/assets/.agents/aidlc/scripts/render.mjs +2 -0
  46. package/dist/assets/.agents/aidlc/scripts/state.mjs +71 -11
  47. package/dist/assets/.agents/aidlc/scripts/task-next.mjs +2 -0
  48. package/dist/assets/.agents/aidlc/templates/model-contract.md +3 -3
  49. package/dist/assets/.agents/aidlc/templates/workplan.md +2 -2
  50. package/dist/assets/.agents/skills/aidlc-build/SKILL.md +2 -2
  51. package/dist/assets/.agents/skills/aidlc-clarify/SKILL.md +2 -2
  52. package/dist/assets/.agents/skills/aidlc-plan/SKILL.md +2 -2
  53. package/dist/assets/.agents/skills/aidlc-wrap/SKILL.md +1 -1
  54. package/dist/services/aidlc-coordination/.env.fleet.example +11 -0
  55. package/dist/services/aidlc-coordination/compose.yaml +23 -2
  56. package/dist/services/aidlc-coordination/package-lock.json +1 -0
  57. package/dist/services/aidlc-coordination/package.json +2 -1
  58. package/dist/services/aidlc-coordination/src/coordination.mjs +1 -1
  59. package/dist/services/aidlc-coordination/src/fleet-client.mjs +36 -0
  60. package/dist/services/aidlc-coordination/src/fleet-run.mjs +18 -0
  61. package/dist/services/aidlc-coordination/src/fleet.mjs +233 -0
  62. package/dist/services/aidlc-coordination/src/git-gateway.mjs +50 -0
  63. package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +41 -6
  64. package/dist/services/aidlc-coordination/src/repository.mjs +167 -4
  65. package/dist/services/aidlc-coordination/src/server.mjs +53 -9
  66. package/dist/services/aidlc-coordination/test/fleet.test.mjs +351 -0
  67. package/dist/services/aidlc-coordination/test/git-gateway.test.mjs +75 -0
  68. package/dist/services/aidlc-coordination/test/supervisor-fencing.test.mjs +59 -0
  69. package/dist/src/adapters.js +32 -5
  70. package/dist/src/cli.js +146 -70
  71. package/dist/src/context.js +2 -0
  72. package/dist/src/gate.d.ts +1 -0
  73. package/dist/src/gate.js +51 -38
  74. package/dist/src/installer.d.ts +10 -1
  75. package/dist/src/installer.js +157 -37
  76. package/dist/src/managed-content.js +1 -1
  77. package/dist/src/model.d.ts +16 -1
  78. package/dist/src/model.js +1 -1
  79. package/dist/src/profiles.d.ts +9 -1
  80. package/dist/src/profiles.js +37 -3
  81. package/dist/src/project-migration.d.ts +1 -0
  82. package/dist/src/project-migration.js +7 -0
  83. package/dist/src/state/archive.d.ts +17 -0
  84. package/dist/src/state/archive.js +60 -0
  85. package/dist/src/state/items.d.ts +25 -0
  86. package/dist/src/state/items.js +146 -0
  87. package/dist/src/state/lessons.d.ts +7 -0
  88. package/dist/src/state/lessons.js +67 -0
  89. package/dist/src/state/lifecycle.d.ts +5 -0
  90. package/dist/src/state/lifecycle.js +78 -0
  91. package/dist/src/state/lock.d.ts +7 -0
  92. package/dist/src/state/lock.js +124 -0
  93. package/dist/src/state/memory.d.ts +8 -0
  94. package/dist/src/state/memory.js +88 -0
  95. package/dist/src/state/render.d.ts +26 -0
  96. package/dist/src/state/render.js +29 -0
  97. package/dist/src/state/shared.d.ts +29 -0
  98. package/dist/src/state/shared.js +71 -0
  99. package/dist/src/state/store.d.ts +4 -0
  100. package/dist/src/state/store.js +81 -0
  101. package/dist/src/state/transitions.d.ts +21 -0
  102. package/dist/src/state/transitions.js +93 -0
  103. package/dist/src/state/validate.d.ts +4 -0
  104. package/dist/src/state/validate.js +100 -0
  105. package/dist/src/state.d.ts +15 -110
  106. package/dist/src/state.js +15 -764
  107. package/dist/src/tracker.d.ts +7 -0
  108. package/dist/src/tracker.js +3 -0
  109. package/dist/src/upgrade-board.d.ts +5 -0
  110. package/dist/src/upgrade-board.js +89 -0
  111. package/dist/src/upgrade.d.ts +5 -6
  112. package/dist/src/upgrade.js +211 -193
  113. package/dist/src/workflow.d.ts +1 -0
  114. package/dist/src/workflow.js +3 -2
  115. package/docs/README.md +4 -4
  116. package/docs/architecture.md +10 -2
  117. package/docs/command-reference.md +72 -11
  118. package/docs/configuration.md +26 -4
  119. package/docs/coordination-service.md +59 -3
  120. package/docs/development.md +16 -2
  121. package/docs/operating-workflow.md +53 -14
  122. package/docs/testing-and-release.md +53 -10
  123. package/package.json +3 -3
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Git handoff helpers: create lead/worker/isolated worktrees, sync the
3
+ * integration branch, and produce scoped change reports.
4
+ */
5
+ import { createHash, randomUUID } from "node:crypto";
6
+ import { execFileSync } from "node:child_process";
7
+ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync } from "node:fs";
8
+ import { join, resolve } from "node:path";
9
+ import { isLoopProvider } from "./loop-drivers.mjs";
10
+ import { createWorkerSnapshot, inspectWorkerSnapshot } from "./loop-worker-snapshot.mjs";
11
+
12
+ const SHA = /^[0-9a-f]{40,64}$/;
13
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
14
+ const BRANCH = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,240}$/;
15
+
16
+ function git(root, ...args) {
17
+ return execFileSync("git", args, { cwd: root, encoding: "utf8", timeout: 30_000, maxBuffer: 4 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] }).trim();
18
+ }
19
+ function validId(value, label) {
20
+ if (typeof value !== "string" || !ID.test(value)) throw new Error(`Invalid ${label}`);
21
+ return value;
22
+ }
23
+ function validSha(value, label) {
24
+ if (typeof value !== "string" || !SHA.test(value)) throw new Error(`Invalid ${label}`);
25
+ return value;
26
+ }
27
+ function validBranch(value) {
28
+ if (typeof value !== "string" || !BRANCH.test(value) || value.includes("..") || value.includes("//") || value.endsWith("/") || value.split("/").some((part) => part.startsWith("."))) throw new Error("Invalid Git branch");
29
+ return value;
30
+ }
31
+ function validPath(value, label = "path") {
32
+ if (typeof value !== "string" || !value || value.startsWith("/") || value.includes("\\") || value.includes("\0") || value.split("/").some((part, index, parts) => (!part && index !== parts.length - 1) || part === "." || part === "..") || value === ".git" || value.startsWith(".git/")) throw new Error(`Invalid ${label}: ${value}`);
33
+ return value;
34
+ }
35
+ function scopes(input) {
36
+ if (!Array.isArray(input) || input.length === 0) throw new Error("Assignment requires nonempty ownedPaths");
37
+ return [...new Set(input.map((path) => validPath(path, "owned path").replace(/\/+$/, "")))].filter(Boolean).sort();
38
+ }
39
+ function inScope(path, ownedPaths) { return ownedPaths.some((scope) => path === scope || path.startsWith(`${scope}/`)); }
40
+ function changedPaths(root, base, head) {
41
+ const raw = execFileSync("git", ["diff", "--no-renames", "--name-only", "-z", base, head], { cwd: root, encoding: "buffer", timeout: 30_000, maxBuffer: 4 * 1024 * 1024 });
42
+ return raw.toString("utf8").split("\0").filter(Boolean).map((path) => validPath(path, "changed path")).sort();
43
+ }
44
+ function worktreePaths(root) {
45
+ const unstaged = execFileSync("git", ["diff", "--no-ext-diff", "--no-renames", "--name-only", "-z"], { cwd: root, encoding: "buffer", timeout: 30_000, maxBuffer: 4 * 1024 * 1024 });
46
+ const staged = execFileSync("git", ["diff", "--cached", "--no-ext-diff", "--no-renames", "--name-only", "-z", "HEAD"], { cwd: root, encoding: "buffer", timeout: 30_000, maxBuffer: 4 * 1024 * 1024 });
47
+ const untracked = execFileSync("git", ["ls-files", "--others", "--exclude-standard", "-z"], { cwd: root, encoding: "buffer", timeout: 30_000, maxBuffer: 4 * 1024 * 1024 });
48
+ return [...new Set(Buffer.concat([unstaged, staged, untracked]).toString("utf8").split("\0").filter(Boolean).map((path) => validPath(path, "changed path")))].sort();
49
+ }
50
+ function worktreeFingerprint(root, paths) {
51
+ // Hash the index as well as the worktree: a staged edit may differ from both
52
+ // HEAD and the current file, even when the latter has been restored to HEAD.
53
+ const index = execFileSync("git", ["ls-files", "--stage", "-z"], { cwd: root, encoding: "buffer", timeout: 30_000, maxBuffer: 4 * 1024 * 1024 });
54
+ const files = paths.map((path) => {
55
+ try {
56
+ const target = join(root, path);
57
+ const stat = lstatSync(target);
58
+ if (stat.isSymbolicLink()) return [path, "symlink", stat.mode, createHash("sha256").update(readlinkSync(target)).digest("hex")];
59
+ if (!stat.isFile()) throw new Error(`Unsupported changed path type: ${path}`);
60
+ return [path, "file", stat.mode, createHash("sha256").update(readFileSync(target)).digest("hex")];
61
+ } catch (error) {
62
+ if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return [path, "deleted"];
63
+ throw error;
64
+ }
65
+ });
66
+ return createHash("sha256").update(git(root, "rev-parse", "HEAD")).update(index).update(JSON.stringify(files)).digest("hex");
67
+ }
68
+ function checkScope(paths, ownedPaths) {
69
+ const outside = paths.filter((path) => !inScope(path, ownedPaths));
70
+ if (outside.length) throw new Error(`Worker changed paths outside assignment: ${outside.join(", ")}`);
71
+ }
72
+ function remoteRef(remote, branch) { return `refs/remotes/${remote}/${branch}`; }
73
+ function fetchBranch(root, remote, branch) {
74
+ validBranch(branch);
75
+ git(root, "fetch", "--no-tags", remote, `refs/heads/${branch}:${remoteRef(remote, branch)}`);
76
+ return git(root, "rev-parse", remoteRef(remote, branch));
77
+ }
78
+ function ancestor(root, base, head) {
79
+ try { git(root, "merge-base", "--is-ancestor", base, head); return true; } catch { return false; } /* intentional: non-git/non-worktree is treated as not clean */
80
+ }
81
+ function assertClean(root) {
82
+ if (git(root, "status", "--porcelain=v1", "--untracked-files=all")) throw new Error("Git worktree must be clean before handoff or integration");
83
+ }
84
+ function normaliseReport(job) {
85
+ const report = job.report ?? job;
86
+ const commitSha = validSha(report.commitSha, "reported commit SHA");
87
+ const changed = Array.isArray(report.changedPaths) ? [...new Set(report.changedPaths.map((path) => validPath(path, "reported path")))].sort() : null;
88
+ if (!changed) throw new Error("Report lacks changedPaths");
89
+ return { commitSha, changed };
90
+ }
91
+
92
+ /** The coordinator calls this from its protected integration checkout. */
93
+ export function createLeadGitHandoff({ root: inputRoot, integrationBranch, remote = "origin", agent = "codex" }) {
94
+ // --- Validate inputs and resolve the integration checkout ------------------
95
+ const root = resolve(inputRoot);
96
+ validBranch(integrationBranch); validId(remote, "remote");
97
+ if (!isLoopProvider(agent)) throw new Error("Invalid worker agent");
98
+ const current = () => { const branch = git(root, "symbolic-ref", "--short", "HEAD"); if (branch !== integrationBranch) throw new Error(`Expected integration branch ${integrationBranch}, found ${branch}`); return git(root, "rev-parse", "HEAD"); };
99
+ const syncCurrent = () => {
100
+ assertClean(root);
101
+ const localHead = current();
102
+ const remoteHead = fetchBranch(root, remote, integrationBranch);
103
+ if (localHead === remoteHead || ancestor(root, remoteHead, localHead)) return localHead;
104
+ if (!ancestor(root, localHead, remoteHead)) throw new Error("Integration branch diverged from remote; reconcile before dispatch");
105
+ git(root, "merge", "--ff-only", remoteHead);
106
+ return current();
107
+ };
108
+ return {
109
+ async currentCommit() {
110
+ const localHead = syncCurrent();
111
+ const remoteHead = fetchBranch(root, remote, integrationBranch);
112
+ if (localHead !== remoteHead) throw new Error("Integration commit is not published to the remote branch");
113
+ return localHead;
114
+ },
115
+ async prepare({ taskId, item }) {
116
+ validId(taskId, "task ID"); validId(item?.id, "item ID");
117
+ const ownedPaths = scopes(item.ownedPaths);
118
+ const baseCommit = syncCurrent();
119
+ const branch = validBranch(`aidlc/${taskId}/${item.id}/${randomUUID()}`);
120
+ return { baseCommit, branch, ownedPaths, agent: item.agent ?? agent };
121
+ },
122
+ async verify({ job }) {
123
+ try {
124
+ const branch = validBranch(job.branch);
125
+ const baseCommit = validSha(job.baseCommit, "base commit");
126
+ const ownedPaths = scopes(job.ownedPaths);
127
+ const { commitSha, changed } = normaliseReport(job);
128
+ const fetched = fetchBranch(root, remote, branch);
129
+ if (fetched !== commitSha) throw new Error("Reported commit does not match remote worker branch");
130
+ git(root, "cat-file", "-e", `${baseCommit}^{commit}`);
131
+ if (!ancestor(root, baseCommit, commitSha) || baseCommit === commitSha) throw new Error("Worker commit is not descended from assigned base");
132
+ const actualPaths = changedPaths(root, baseCommit, commitSha);
133
+ checkScope(actualPaths, ownedPaths);
134
+ if (JSON.stringify(actualPaths) !== JSON.stringify(changed)) throw new Error("Reported changed paths do not match Git commit");
135
+ const currentHead = syncCurrent();
136
+ if (!ancestor(root, baseCommit, currentHead)) throw new Error("Integration branch no longer contains assigned base");
137
+ return { valid: true, commitSha, baseCommit, branch, changedPaths: actualPaths, ownedPaths, alreadyIntegrated: ancestor(root, commitSha, currentHead) };
138
+ } catch (error) { return { valid: false, reason: error.message }; }
139
+ },
140
+ async recover({ job }) {
141
+ try {
142
+ const branch = validBranch(job.branch);
143
+ const baseCommit = validSha(job.baseCommit, "base commit");
144
+ const ownedPaths = scopes(job.ownedPaths);
145
+ try { git(root, "ls-remote", "--exit-code", remote, `refs/heads/${branch}`); }
146
+ catch (error) {
147
+ if (error?.status === 2) return { recovered: false, retryable: true, reason: "Assigned remote branch has no pushed commit" };
148
+ throw error;
149
+ }
150
+ const commitSha = fetchBranch(root, remote, branch);
151
+ git(root, "cat-file", "-e", `${baseCommit}^{commit}`);
152
+ if (baseCommit === commitSha) return { recovered: false, retryable: true, reason: "Assigned remote branch remains at base commit" };
153
+ if (!ancestor(root, baseCommit, commitSha)) throw new Error("Worker branch is not descended from assigned base");
154
+ const changed = changedPaths(root, baseCommit, commitSha);
155
+ if (!changed.length) throw new Error("Worker branch has no changed paths");
156
+ checkScope(changed, ownedPaths);
157
+ const report = { commitSha, changedPaths: changed };
158
+ const verified = await this.verify({ job: { ...job, report } });
159
+ if (!verified.valid) throw new Error(verified.reason);
160
+ return { recovered: true, report, ...verified };
161
+ } catch (error) { return { recovered: false, reason: error.message }; }
162
+ },
163
+ async integrate({ job }) {
164
+ // A prior verification result is advisory: re-fetch the ref at integration
165
+ // time so a rewritten worker branch or stale report cannot be trusted.
166
+ const verified = await this.verify({ job });
167
+ if (!verified.valid) throw new Error(`Git artifact verification failed: ${verified.reason}`);
168
+ const before = syncCurrent();
169
+ if (ancestor(root, verified.commitSha, before)) {
170
+ try { git(root, "push", remote, `HEAD:refs/heads/${integrationBranch}`); }
171
+ catch (error) { throw new Error(`Integrated locally at ${before}, but remote push failed: ${error.message}`); }
172
+ return { integrated: false, alreadyIntegrated: true, commitSha: verified.commitSha, integrationCommit: before, changedPaths: verified.changedPaths };
173
+ }
174
+ try {
175
+ git(root, "-c", "user.name=AI-DLC lead", "-c", "user.email=aidlc-lead@localhost", "merge", "--no-ff", "--no-edit", verified.commitSha);
176
+ } catch (error) {
177
+ try { git(root, "merge", "--abort"); } catch { /* The original merge error is authoritative. */ }
178
+ throw new Error(`Git integration conflict or failure: ${error.message}`);
179
+ }
180
+ const integrationCommit = current();
181
+ try { git(root, "push", remote, `HEAD:refs/heads/${integrationBranch}`); }
182
+ catch (error) { throw new Error(`Integrated locally at ${integrationCommit}, but remote push failed: ${error.message}`); }
183
+ return { integrated: true, alreadyIntegrated: false, commitSha: verified.commitSha, integrationCommit, changedPaths: verified.changedPaths };
184
+ }
185
+ };
186
+ }
187
+
188
+ /** Workers receive only assignment metadata and never write the integration branch. */
189
+ export function createWorkerGitHandoff({ root: inputRoot, worktreesRoot: inputWorktreesRoot, remote = "origin" }) {
190
+ const root = resolve(inputRoot);
191
+ const worktreesRoot = resolve(inputWorktreesRoot);
192
+ validId(remote, "remote");
193
+ if (worktreesRoot === root || worktreesRoot.startsWith(`${root}/`)) throw new Error("Worker worktree directory must be outside the source checkout");
194
+ return {
195
+ async prepare(job) {
196
+ validId(job.assignmentId, "assignment ID");
197
+ const base = validSha(job.baseCommit, "base commit");
198
+ const branch = validBranch(job.branch);
199
+ scopes(job.ownedPaths);
200
+ mkdirSync(worktreesRoot, { recursive: true });
201
+ const workspace = join(worktreesRoot, job.assignmentId);
202
+ if (existsSync(workspace)) {
203
+ const actualBranch = git(workspace, "symbolic-ref", "--short", "HEAD");
204
+ if (actualBranch !== branch || !ancestor(workspace, base, "HEAD")) throw new Error("Existing worker worktree does not match assignment");
205
+ return { root: workspace, branch };
206
+ }
207
+ git(root, "fetch", "--no-tags", remote, base);
208
+ git(root, "worktree", "add", "-b", branch, workspace, base);
209
+ return { root: workspace, branch };
210
+ },
211
+ async observe({ job, workspace, result }) {
212
+ const changed = worktreePaths(workspace.root);
213
+ checkScope(changed, scopes(job.ownedPaths));
214
+ const done = typeof result?.resultText === "string" && result.resultText.split(/\r?\n/).some((line) => line.trim() === "AI-DLC-ITEM-COMPLETE");
215
+ return { done, fingerprint: worktreeFingerprint(workspace.root, changed) };
216
+ },
217
+ async finish({ job, workspace }) {
218
+ const branch = validBranch(job.branch);
219
+ if (git(workspace.root, "symbolic-ref", "--short", "HEAD") !== branch) throw new Error("Worker switched away from assigned branch");
220
+ const ownedPaths = scopes(job.ownedPaths);
221
+ const dirty = worktreePaths(workspace.root);
222
+ checkScope(dirty, ownedPaths);
223
+ if (dirty.length) {
224
+ git(workspace.root, "add", "-A", "--", ...dirty);
225
+ git(workspace.root, "-c", "user.name=AI-DLC worker", "-c", "user.email=aidlc-worker@localhost", "commit", "-m", `AI-DLC ${validId(job.taskId, "task ID")}/${validId(job.itemId, "item ID")} ${validId(job.assignmentId, "assignment ID")}`);
226
+ }
227
+ const commitSha = git(workspace.root, "rev-parse", "HEAD");
228
+ const base = validSha(job.baseCommit, "base commit");
229
+ if (commitSha === base) throw new Error("Worker produced no commit");
230
+ const changed = changedPaths(workspace.root, base, commitSha);
231
+ checkScope(changed, ownedPaths);
232
+ git(workspace.root, "push", remote, `HEAD:refs/heads/${branch}`);
233
+ return { commitSha, changedPaths: changed };
234
+ },
235
+ async discard({ workspace }) {
236
+ const path = resolve(workspace.root);
237
+ if (!path.startsWith(`${worktreesRoot}/`)) throw new Error("Invalid worker worktree path");
238
+ git(root, "worktree", "remove", "--force", path);
239
+ rmSync(path, { recursive: true, force: true });
240
+ }
241
+ };
242
+ }
243
+
244
+ /** Worker sees only a checked source snapshot. Git and credentials remain on host. */
245
+ export function createIsolatedWorkerGitHandoff(options) {
246
+ const host = createWorkerGitHandoff(options);
247
+ return {
248
+ async prepare(job) {
249
+ const checkout = await host.prepare(job);
250
+ if (worktreePaths(checkout.root).length || git(checkout.root, "rev-parse", "HEAD") !== job.baseCommit) throw new Error("Existing worker checkout requires reconciliation");
251
+ const tracked = execFileSync("git", ["ls-files", "-z"], { cwd: checkout.root, encoding: "utf8", timeout: 30000 }).split("\0").filter((path) => path && path !== ".agents/data" && !path.startsWith(".agents/data/"));
252
+ const snapshot = mkdtempSync(join(resolve(options.worktreesRoot), "snapshot-"));
253
+ try { const baseline = createWorkerSnapshot(checkout.root, snapshot, tracked); return { root: snapshot, checkout, baseline }; }
254
+ catch (error) { rmSync(snapshot, {recursive:true,force:true}); throw error; }
255
+ },
256
+ async observe({job, workspace, result}) {
257
+ const changed = inspectWorkerSnapshot(workspace.root, workspace.baseline, job.ownedPaths);
258
+ const fingerprint = createHash("sha256").update(JSON.stringify(changed.map((path) => { const full=join(workspace.root,path); return existsSync(full) ? [path,createHash("sha256").update(readFileSync(full)).digest("hex"),lstatSync(full).mode] : [path,null]; }))).digest("hex");
259
+ const done = typeof result?.resultText === "string" && result.resultText.split(/\r?\n/).some((line) => line.trim() === "AI-DLC-ITEM-COMPLETE");
260
+ return {done, fingerprint};
261
+ },
262
+ async finish({job, workspace}) {
263
+ const changed=inspectWorkerSnapshot(workspace.root, workspace.baseline, job.ownedPaths);
264
+ if (!changed.length) throw new Error("Worker produced no changes");
265
+ if (worktreePaths(workspace.checkout.root).length || git(workspace.checkout.root,"rev-parse","HEAD") !== job.baseCommit) throw new Error("Host checkout changed during worker run");
266
+ for (const path of changed) {
267
+ const source=join(workspace.root,path), target=join(workspace.checkout.root,path);
268
+ let parent = workspace.checkout.root;
269
+ for (const segment of path.split("/").slice(0,-1)) {
270
+ parent = join(parent,segment);
271
+ try { const info=lstatSync(parent); if (!info.isDirectory() || info.isSymbolicLink()) throw new Error("Unsafe integration directory"); }
272
+ catch(error) { if (error.code !== "ENOENT") throw error; mkdirSync(parent); }
273
+ }
274
+ try { const info=lstatSync(target); if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1) throw new Error("Unsafe integration target"); }
275
+ catch(error) { if (error.code !== "ENOENT") throw error; }
276
+ if (!existsSync(source)) { rmSync(target,{force:true}); continue; }
277
+ copyFileSync(source,target); chmodSync(target,lstatSync(source).mode & 0o777);
278
+ }
279
+ const result=await host.finish({job,workspace:workspace.checkout});
280
+ rmSync(workspace.root,{recursive:true,force:true}); return result;
281
+ },
282
+ async discard({workspace}) { rmSync(workspace.root,{recursive:true,force:true}); return host.discard({workspace:workspace.checkout}); }
283
+ };
284
+ }
@@ -0,0 +1,51 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+
5
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
6
+ const digest = (value) => createHash("sha256").update(value).digest("hex");
7
+
8
+ /** Prepare a durable, idempotent intake without treating it as lifecycle state. */
9
+ export async function prepareLoopIntake({ root, taskId, requirement, authority, createTask, readTask }) {
10
+ if (!ID.test(taskId ?? "")) throw new Error("Invalid task ID");
11
+ if (!["local", "fleet"].includes(authority)) throw new Error("Invalid loop authority");
12
+ if (typeof requirement !== "string" || !requirement.trim() || Buffer.byteLength(requirement) > 8192) throw new Error("Requirement must contain 1–8192 bytes");
13
+ if (typeof createTask !== "function" || typeof readTask !== "function") throw new Error("Intake requires lifecycle callbacks");
14
+ const project = resolve(root), base = join(project, ".agents", "data", "tasks");
15
+ for (const path of [join(project, ".agents"), join(project, ".agents", "data"), base]) {
16
+ if (!existsSync(path)) mkdirSync(path);
17
+ const stat = lstatSync(path);
18
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Unsafe intake directory");
19
+ }
20
+ const directory = join(base, taskId);
21
+ if (!existsSync(directory)) mkdirSync(directory, { mode: 0o700 });
22
+ const stat = lstatSync(directory);
23
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Unsafe task directory");
24
+ const recordPath = join(directory, "loop-intake.json");
25
+ const record = { schemaVersion: 1, taskId, authority, requirementHash: digest(requirement) };
26
+ if (!existsSync(recordPath) && await readTask(taskId)) throw new Error("Existing task has no matching loop intake record");
27
+ try { writeFileSync(recordPath, `${JSON.stringify(record)}\n`, { flag: "wx", mode: 0o600 }); }
28
+ catch (error) {
29
+ if (error?.code !== "EEXIST") throw error;
30
+ const currentStat = lstatSync(recordPath);
31
+ if (!currentStat.isFile() || currentStat.isSymbolicLink()) throw new Error("Unsafe intake record");
32
+ let current; try { current = JSON.parse(readFileSync(recordPath, "utf8")); } catch { throw new Error("Invalid intake record"); }
33
+ if (JSON.stringify(current) !== JSON.stringify(record)) throw new Error("Existing intake has different authority or requirement");
34
+ }
35
+ const requirementPath = join(directory, "requirement.md");
36
+ try { writeFileSync(requirementPath, requirement, { flag: "wx", mode: 0o600 }); }
37
+ catch (error) {
38
+ if (error?.code !== "EEXIST") throw error;
39
+ const existing = lstatSync(requirementPath);
40
+ if (!existing.isFile() || existing.isSymbolicLink() || digest(readFileSync(requirementPath, "utf8")) !== record.requirementHash) throw new Error("Existing requirement does not match intake");
41
+ }
42
+ const title = `Loop task ${taskId}`;
43
+ let task = await readTask(taskId);
44
+ if (!task) {
45
+ try { await createTask({ taskId, title }); }
46
+ catch (error) { task = await readTask(taskId); if (!task || task.title !== title) throw error; }
47
+ task ??= await readTask(taskId);
48
+ }
49
+ if (!task || task.id !== taskId || task.title !== title) throw new Error("Existing task conflicts with intake");
50
+ return { taskId, authority, requirementPath };
51
+ }
@@ -0,0 +1,63 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { closeSync, existsSync, fsyncSync, lstatSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ const hash = (data) => createHash("sha256").update(data).digest("hex");
6
+ const digest = (path) => {
7
+ let stat;
8
+ try { stat = lstatSync(path); } catch (error) { if (error.code === "ENOENT") return null; throw error; }
9
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new Error("Unsafe lead artifact file");
10
+ return hash(readFileSync(path));
11
+ };
12
+ const syncDir = (dir) => { const fd = openSync(dir, "r"); try { fsyncSync(fd); } finally { closeSync(fd); } };
13
+ const hexOrNull = (x) => x === null || (typeof x === "string" && /^[a-f0-9]{64}$/.test(x));
14
+ const safeFile = (path) => { const stat = lstatSync(path); if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new Error("Unsafe lead artifact journal"); };
15
+ function directory(root, id) {
16
+ let cursor = root;
17
+ for (const part of [".agents", "data", "tasks", id]) { cursor = join(cursor, part); const stat = lstatSync(cursor); if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Unsafe lead artifact directory"); }
18
+ return cursor;
19
+ }
20
+ /** Read the expected digest for a phase packet while holding the same lock. */
21
+ export function leadArtifactDigest({ root, taskId, task, name }) {
22
+ if (!["intent", "design", "workplan"].includes(name) || task.artifacts?.[name] !== `.agents/data/tasks/${taskId}/${name}.md`) throw new Error("Invalid canonical artifact path");
23
+ return digest(join(directory(root, taskId), `${name}.md`));
24
+ }
25
+ /** Must be called while holding the canonical state lock and supervisor fence. */
26
+ export async function recoverLeadArtifact({ root, taskId, task, assertOwned }) {
27
+ let dir;
28
+ try { dir = directory(root, taskId); } catch (error) { if (error.code === "ENOENT") return; throw error; }
29
+ const journal = join(dir, ".lead-artifact-journal.json");
30
+ try { safeFile(journal); } catch (error) { if (error.code === "ENOENT") return; throw error; }
31
+ let entry; try { entry = JSON.parse(readFileSync(journal, "utf8")); } catch { throw new Error("Invalid lead artifact journal"); }
32
+ if (!entry || entry.version !== 1 || !["intent", "design", "workplan"].includes(entry.name) || !hexOrNull(entry.before) || typeof entry.after !== "string" || !/^[a-f0-9]{64}$/.test(entry.after) || typeof entry.temp !== "string" || !/^\.lead-artifact-[0-9a-f-]{36}\.tmp$/.test(entry.temp) || task.artifacts?.[entry.name] !== `.agents/data/tasks/${taskId}/${entry.name}.md`) throw new Error("Invalid lead artifact journal");
33
+ const target = join(dir, `${entry.name}.md`), temp = join(dir, entry.temp), current = digest(target);
34
+ if (current !== entry.before && current !== entry.after) throw new Error("Lead artifact journal conflicts with current file; reconcile manually");
35
+ await assertOwned();
36
+ // Once the target contains the after digest the write committed. Otherwise the
37
+ // intent was not committed; discard the temp so the original can be retried.
38
+ try { safeFile(temp); unlinkSync(temp); } catch (error) { if (error.code !== "ENOENT") throw error; }
39
+ unlinkSync(journal); syncDir(dir);
40
+ }
41
+ /** Compare the reviewed artifact digest, stage a durable intent, then rename.
42
+ * Recovery never guesses when the target was changed by another writer. */
43
+ export async function writeLeadArtifact({ root, taskId, task, operation: op, assertOwned }) {
44
+ const dir = directory(root, taskId);
45
+ if (task.artifacts?.[op.name] !== `.agents/data/tasks/${taskId}/${op.name}.md`) throw new Error("Canonical artifact path is not the expected task path");
46
+ await recoverLeadArtifact({ root, taskId, task, assertOwned });
47
+ const target = join(dir, `${op.name}.md`), before = digest(target);
48
+ if (before !== op.expectedDigest) throw new Error("Stale lead artifact digest");
49
+ const after = hash(Buffer.from(op.content)), name = `.lead-artifact-${randomUUID()}.tmp`, temp = join(dir, name), journal = join(dir, ".lead-artifact-journal.json");
50
+ let fd, journaled = false;
51
+ try {
52
+ fd = openSync(temp, "wx", 0o600); writeFileSync(fd, op.content, "utf8"); fsyncSync(fd); closeSync(fd); fd = undefined;
53
+ await assertOwned();
54
+ fd = openSync(journal, "wx", 0o600); writeFileSync(fd, JSON.stringify({ version: 1, name: op.name, before, after, temp: name }), "utf8"); fsyncSync(fd); closeSync(fd); fd = undefined; journaled = true; syncDir(dir);
55
+ await assertOwned();
56
+ if (digest(target) !== before) throw new Error("Lead artifact changed during commit");
57
+ renameSync(temp, target); syncDir(dir);
58
+ await assertOwned(); unlinkSync(journal); syncDir(dir);
59
+ } finally {
60
+ if (fd !== undefined) closeSync(fd);
61
+ if (!journaled && existsSync(temp)) unlinkSync(temp);
62
+ }
63
+ }
@@ -0,0 +1,53 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isAbsolute } from "node:path";
3
+
4
+ const LIMIT = 65536;
5
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
6
+ const validId = (x) => typeof x === "string" && ID.test(x);
7
+ const record = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
8
+ const exact = (x, keys) => record(x) && Object.keys(x).length === keys.length && keys.every((key) => Object.hasOwn(x, key));
9
+ const text = (x, max) => typeof x === "string" && x.trim().length > 0 && Buffer.byteLength(x) <= max && !x.includes("\0");
10
+ const scope = (x) => text(x, 512) && !isAbsolute(x) && !x.includes("\\") && !/[\x00-\x1f]/.test(x) && x.split("/").every((part) => part && part !== "." && part !== ".." && part !== ".git" && part !== ".agents");
11
+ export const leadTaskDigest = (task) => createHash("sha256").update(JSON.stringify(task)).digest("hex");
12
+
13
+ /** Parse an untrusted, bounded model proposal. This schema never admits approval,
14
+ * evidence, execution commands, phase transitions, or arbitrary artifact paths. */
15
+ export function parseLeadProposal(raw) {
16
+ if (typeof raw !== "string" || Buffer.byteLength(raw) > LIMIT) throw new Error("Invalid or oversized lead proposal");
17
+ let p; try { p = JSON.parse(raw); } catch { throw new Error("Invalid lead proposal JSON"); }
18
+ if (!exact(p, ["version", "taskId", "phase", "taskDigest", "operation"]) || p.version !== 1 || !validId(p.taskId) || !["clarify", "plan"].includes(p.phase) || !/^[a-f0-9]{64}$/.test(p.taskDigest)) throw new Error("Invalid lead proposal envelope");
19
+ const op = p.operation;
20
+ if (op?.kind === "artifact") {
21
+ if (!exact(op, ["kind", "name", "content", "expectedDigest"]) || !({ clarify: ["intent"], plan: ["design", "workplan"] }[p.phase]).includes(op.name) || !text(op.content, 60000) || !(op.expectedDigest === null || (typeof op.expectedDigest === "string" && /^[a-f0-9]{64}$/.test(op.expectedDigest)))) throw new Error("Invalid artifact proposal");
22
+ } else if (op?.kind === "decision") {
23
+ if (p.phase !== "plan" || !exact(op, ["kind", "id", "label", "resolution"]) || !validId(op.id) || !text(op.label, 512) || !text(op.resolution, 4096)) throw new Error("Invalid decision proposal");
24
+ } else if (op?.kind === "item") {
25
+ if (p.phase !== "plan" || !exact(op, ["kind", "id", "label", "dependsOn", "ownedPaths"]) || !validId(op.id) || !text(op.label, 512) || !Array.isArray(op.dependsOn) || op.dependsOn.length > 64 || new Set(op.dependsOn).size !== op.dependsOn.length || !op.dependsOn.every((id) => validId(id) && id !== op.id) || !Array.isArray(op.ownedPaths) || !op.ownedPaths.length || op.ownedPaths.length > 64 || new Set(op.ownedPaths).size !== op.ownedPaths.length || !op.ownedPaths.every(scope)) throw new Error("Invalid item proposal");
26
+ } else throw new Error("Lead operation not allowed");
27
+ return p;
28
+ }
29
+
30
+ /** adapter.withTaskLock MUST hold the canonical lock and supervisor fence through
31
+ * read, validation, one apply, and reading nextAction. The adapter is trusted
32
+ * host code; this module does not expose it to the model or container. */
33
+ export async function applyLeadProposal(raw, adapter) {
34
+ const proposal = parseLeadProposal(raw);
35
+ if (typeof adapter?.withTaskLock !== "function") throw new Error("Atomic fenced broker adapter required");
36
+ return adapter.withTaskLock(proposal.taskId, async (tx) => {
37
+ if (typeof tx?.assertOwned !== "function" || typeof tx?.readTask !== "function" || typeof tx?.nextAction !== "function" || typeof tx?.applyOne !== "function") throw new Error("Incomplete fenced broker transaction");
38
+ await tx.assertOwned();
39
+ const task = await tx.readTask();
40
+ if (!task || task.id !== proposal.taskId || task.status !== "active" || task.phase !== proposal.phase || leadTaskDigest(task) !== proposal.taskDigest) throw new Error("Stale lead proposal");
41
+ const action = await tx.nextAction(task);
42
+ if (action?.classification !== "run_phase" || action.phase !== proposal.phase) throw new Error("Task is not actionable in proposed phase");
43
+ if (proposal.operation.kind === "item") {
44
+ const known = new Set(task.tasks.map((item) => item.id));
45
+ if (proposal.operation.dependsOn.some((id) => !known.has(id))) throw new Error("Unknown item dependency");
46
+ if (known.has(proposal.operation.id)) throw new Error("Item already exists");
47
+ }
48
+ await tx.assertOwned();
49
+ await tx.applyOne(proposal.operation);
50
+ await tx.assertOwned();
51
+ return tx.nextAction(await tx.readTask());
52
+ });
53
+ }
@@ -0,0 +1,44 @@
1
+ import { resolve } from "node:path";
2
+ import { acquireStateLock, loadState, nextAction, readyTaskItems, saveState } from "./store.mjs";
3
+ import { requireLocalAuthority } from "./authority.mjs";
4
+ import { recoverLeadArtifact, writeLeadArtifact } from "./loop-lead-artifacts.mjs";
5
+
6
+ /** Create a trusted host-only adapter. assertOwned comes directly from the live
7
+ * supervisor lease and must remain valid throughout the transaction. */
8
+ export function createLocalLeadBrokerAdapter({ root: inputRoot, taskId, assertOwned }) {
9
+ const root = resolve(inputRoot);
10
+ if (typeof assertOwned !== "function" || typeof taskId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(taskId)) throw new Error("Local broker requires a live supervisor fence and task ID");
11
+ return { async withTaskLock(id, run) {
12
+ requireLocalAuthority();
13
+ if (id !== taskId || typeof run !== "function") throw new Error("Broker task mismatch");
14
+ await assertOwned();
15
+ const release = acquireStateLock(root);
16
+ try {
17
+ await assertOwned();
18
+ const state = loadState(root), task = state.tasks[taskId];
19
+ if (!task) throw new Error("Unknown active broker task");
20
+ await recoverLeadArtifact({ root, taskId, task, assertOwned });
21
+ let applied = false;
22
+ return await run({
23
+ assertOwned,
24
+ readTask: async () => task,
25
+ nextAction: async (value) => nextAction(value, root),
26
+ applyOne: async (op) => {
27
+ if (applied) throw new Error("Broker permits only one mutation per transaction");
28
+ applied = true; await assertOwned();
29
+ if (op.kind === "artifact") {
30
+ await writeLeadArtifact({ root, taskId, task, operation: op, assertOwned });
31
+ } else if (op.kind === "decision") {
32
+ if (task.decisions.some((entry) => entry.id === op.id)) throw new Error("Decision already exists; broker cannot overwrite human decisions");
33
+ task.decisions.push({ id: op.id, label: op.label, status: "unresolved", resolution: op.resolution });
34
+ task.updatedAt = new Date().toISOString(); await assertOwned(); saveState(root, state);
35
+ } else if (op.kind === "item") {
36
+ if (task.tasks.some((entry) => entry.id === op.id)) throw new Error("Item already exists");
37
+ task.tasks.push({ id: op.id, label: op.label, status: "todo", dependsOn: op.dependsOn, ownedPaths: op.ownedPaths });
38
+ readyTaskItems(task); task.updatedAt = new Date().toISOString(); await assertOwned(); saveState(root, state);
39
+ } else throw new Error("Broker operation not allowed");
40
+ }
41
+ });
42
+ } finally { release(); }
43
+ } };
44
+ }
@@ -0,0 +1,88 @@
1
+ import { realpathSync, statSync } from "node:fs";
2
+ import { isLoopProvider } from "./loop-drivers.mjs";
3
+ import { OPENCODE_CONTAINER_ERROR, workerDockerInvocation } from "./loop-worker-docker.mjs";
4
+
5
+ /** Invocation only: callers must supply a sanitized, host-owned source snapshot,
6
+ * never the checkout. No host output directory, state, or broker channel is mounted.
7
+ * The bounded provider response carries the proposal back to the trusted host. */
8
+ export function leadDockerInvocation({ snapshot, image, agent, args, credentialEnv, hostEnv, uid, gid, containerName }) {
9
+ const source = realpathSync(snapshot);
10
+ if (!statSync(source).isDirectory()) throw new Error("Lead snapshot must be a directory");
11
+ const invocation = workerDockerInvocation({ workspace: source, image, command: agent, args, credentialEnv, hostEnv, uid, gid, containerName });
12
+ const mount = `type=bind,src=${source},dst=/workspace`;
13
+ const index = invocation.args.indexOf(mount);
14
+ if (index < 0) throw new Error("Missing lead snapshot mount");
15
+ invocation.args[index] = `${mount},readonly`;
16
+ return invocation;
17
+ }
18
+
19
+ /** Create and clean an isolated source-only snapshot for exactly one turn. */
20
+ export async function withLeadSnapshot({ root: inputRoot, snapshotsRoot: inputSnapshotsRoot }, use) {
21
+ const { execFileSync } = await import("node:child_process");
22
+ const { mkdtempSync, mkdirSync, rmSync } = await import("node:fs");
23
+ const { resolve, sep, join } = await import("node:path");
24
+ const { createWorkerSnapshot } = await import("./loop-worker-snapshot.mjs");
25
+ if (typeof use !== "function" || typeof inputRoot !== "string" || typeof inputSnapshotsRoot !== "string") throw new Error("Lead snapshot requires paths and a callback");
26
+ const root = realpathSync(inputRoot);
27
+ const requested = resolve(inputSnapshotsRoot);
28
+ if (requested === root || requested.startsWith(`${root}${sep}`) || root.startsWith(`${requested}${sep}`)) throw new Error("Lead snapshots root must be outside the project");
29
+ mkdirSync(requested, { recursive: true, mode: 0o700 });
30
+ const parent = realpathSync(requested);
31
+ if (parent === root || parent.startsWith(`${root}${sep}`) || root.startsWith(`${parent}${sep}`)) throw new Error("Lead snapshots root resolves inside or above the project");
32
+ const snapshot = mkdtempSync(join(parent, "aidlc-lead-"));
33
+ try {
34
+ const output = execFileSync("git", ["ls-files", "-z"], { cwd: root, encoding: "utf8", timeout: 30000, maxBuffer: 16 * 1024 * 1024 });
35
+ const paths = output.split("\0").filter((path) => path && path !== ".agents/data" && !path.startsWith(".agents/data/"));
36
+ createWorkerSnapshot(root, snapshot, paths);
37
+ return await use(snapshot);
38
+ } finally { rmSync(snapshot, { recursive: true, force: true }); }
39
+ }
40
+
41
+ /** Execute one lead proposal in a separate read-only container. No broker or
42
+ * lifecycle capability is passed to the container. Caller applies the returned
43
+ * proposal separately under its fence and canonical lock. */
44
+ export async function runIsolatedLeadTurn({ root, snapshotsRoot, image, agent, prompt, assertOwned, timeoutMs = 600000, signal }) {
45
+ const { execFileSync, spawn } = await import("node:child_process");
46
+ const { randomUUID } = await import("node:crypto");
47
+ const { runAgentTurn } = await import("./loop-drivers.mjs");
48
+ const { parseLeadProposal } = await import("./loop-lead-broker.mjs");
49
+ if (agent === "opencode") throw new Error(OPENCODE_CONTAINER_ERROR);
50
+ if (process.platform !== "linux" || !isLoopProvider(agent) || typeof prompt !== "string" || !prompt || Buffer.byteLength(prompt) > 262144 || typeof assertOwned !== "function") throw new Error("Invalid isolated lead configuration");
51
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || (signal !== undefined && !(signal instanceof AbortSignal))) throw new Error("Invalid isolated lead timeout or signal");
52
+ const uid = process.getuid?.(), gid = process.getgid?.();
53
+ if (!uid || !gid) throw new Error("Isolated lead requires a non-root Linux host user");
54
+ const credentialEnv = agent === "codex" ? ["OPENAI_API_KEY"] : ["ANTHROPIC_API_KEY"];
55
+ const env = { PATH: process.env.PATH ?? "/usr/bin:/bin" };
56
+ const check = (...args) => execFileSync("docker", args, { env, stdio: "ignore", timeout: 30000 });
57
+ await assertOwned();
58
+ return withLeadSnapshot({ root, snapshotsRoot }, async (snapshot) => {
59
+ // Validation builds an invocation before any agent or task mutation starts.
60
+ leadDockerInvocation({ snapshot, image, agent, args: [], credentialEnv, hostEnv: process.env, uid, gid });
61
+ try { check("info"); check("image", "inspect", image); check("run", "--rm", "--network", "none", "--read-only", "--user", `${uid}:${gid}`, "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--entrypoint", agent, image, "--version"); }
62
+ catch { throw new Error("Docker engine, lead image, or provider CLI preflight failed"); }
63
+ const name = `aidlc-lead-${randomUUID()}`, controller = new AbortController();
64
+ let lost;
65
+ const abort = () => controller.abort();
66
+ signal?.addEventListener("abort", abort, { once: true });
67
+ if (signal?.aborted) controller.abort();
68
+ const timer = setInterval(() => { Promise.resolve().then(assertOwned).catch((error) => { lost = error; controller.abort(); }); }, 1000);
69
+ try {
70
+ await assertOwned();
71
+ const result = await runAgentTurn({ agent, root: "/workspace", prompt, timeoutMs, signal: controller.signal, spawnProcess: (command, args, options) => {
72
+ const invocation = leadDockerInvocation({ snapshot, image, agent: command, args, credentialEnv, hostEnv: process.env, uid, gid, containerName: name });
73
+ return spawn(invocation.command, invocation.args, { ...options, cwd: snapshot, env: invocation.env, shell: false });
74
+ } });
75
+ if (lost || controller.signal.aborted) throw lost ?? new Error("Lead turn aborted");
76
+ await assertOwned();
77
+ return parseLeadProposal(result.resultText);
78
+ } finally {
79
+ clearInterval(timer); signal?.removeEventListener("abort", abort);
80
+ try { check("rm", "-f", name); }
81
+ catch {
82
+ try { check("info"); } catch { throw new Error("Lead container cleanup uncertain: Docker unavailable"); }
83
+ try { check("inspect", name); throw new Error("Lead container cleanup uncertain: container remains"); }
84
+ catch (error) { if (error.message === "Lead container cleanup uncertain: container remains") throw error; }
85
+ }
86
+ }
87
+ });
88
+ }