@felixdotgo/aidlc-kit 0.1.1 → 0.2.0

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 (134) hide show
  1. package/DEVELOPMENT.md +14 -0
  2. package/README.md +11 -13
  3. package/dist/assets/.agents/aidlc/docker/Dockerfile.claude +7 -0
  4. package/dist/assets/.agents/aidlc/docker/Dockerfile.codex +9 -0
  5. package/dist/assets/.agents/aidlc/docker/Dockerfile.opencode +8 -0
  6. package/dist/assets/.agents/aidlc/orchestrator.md +1 -1
  7. package/dist/assets/.agents/aidlc/phase-clarify.md +1 -1
  8. package/dist/assets/.agents/aidlc/phase-plan.md +2 -2
  9. package/dist/assets/.agents/aidlc/schemas/config.schema.json +2 -1
  10. package/dist/assets/.agents/aidlc/scripts/lib/loop-authority.mjs +245 -0
  11. package/dist/assets/.agents/aidlc/scripts/lib/loop-commit-message.mjs +45 -0
  12. package/dist/assets/.agents/aidlc/scripts/lib/loop-daemon.mjs +232 -0
  13. package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +15 -3
  14. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +86 -28
  15. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +7 -0
  16. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +22 -6
  17. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +40 -15
  18. package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +14 -6
  19. package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +23 -5
  20. package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +42 -15
  21. package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +75 -5
  22. package/dist/assets/.agents/aidlc/scripts/lib/loop-stream.mjs +60 -0
  23. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +142 -5
  24. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor.mjs +9 -1
  25. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +31 -5
  26. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-image.mjs +64 -0
  27. package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +57 -2
  28. package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +85 -73
  29. package/dist/assets/.agents/aidlc/scripts/loop-gate.mjs +19 -7
  30. package/dist/assets/.agents/aidlc/scripts/loop.mjs +2 -1
  31. package/dist/assets/.agents/aidlc/templates/design.md +9 -0
  32. package/dist/assets/.agents/aidlc/templates/intent.md +4 -0
  33. package/dist/assets/.agents/aidlc/templates/workplan.md +1 -1
  34. package/dist/assets/.agents/skills/aidlc-plan/SKILL.md +1 -1
  35. package/dist/assets/ui/bulma.min.css +3 -0
  36. package/dist/assets/ui/dashboard.css +414 -0
  37. package/dist/services/aidlc-coordination/Dockerfile +11 -3
  38. package/dist/services/aidlc-coordination/compose.yaml +12 -0
  39. package/dist/services/aidlc-coordination/package-lock.json +3 -1
  40. package/dist/services/aidlc-coordination/package.json +9 -4
  41. package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +57 -2
  42. package/dist/services/aidlc-coordination/src/repository.mjs +66 -0
  43. package/dist/services/aidlc-coordination/src/server.mjs +9 -3
  44. package/dist/services/aidlc-coordination/test/fleet.test.mjs +21 -0
  45. package/dist/services/aidlc-coordination/test/server-health-auth.test.mjs +67 -0
  46. package/dist/src/adapters.d.ts +0 -3
  47. package/dist/src/adapters.js +3 -5
  48. package/dist/src/cli/argv.d.ts +12 -0
  49. package/dist/src/cli/argv.js +62 -0
  50. package/dist/src/cli/commands.d.ts +8 -0
  51. package/dist/src/cli/commands.js +129 -0
  52. package/dist/src/cli/options.d.ts +25 -0
  53. package/dist/src/cli/options.js +32 -0
  54. package/dist/src/cli/prompt.d.ts +9 -0
  55. package/dist/src/cli/prompt.js +66 -0
  56. package/dist/src/cli.js +25 -191
  57. package/dist/src/gate.js +3 -2
  58. package/dist/src/installer.d.ts +14 -7
  59. package/dist/src/installer.js +38 -141
  60. package/dist/src/legacy.js +1 -0
  61. package/dist/src/managed-content.d.ts +7 -0
  62. package/dist/src/managed-content.js +15 -4
  63. package/dist/src/managed-planning.d.ts +65 -0
  64. package/dist/src/managed-planning.js +219 -0
  65. package/dist/src/mcp-setup.js +3 -1
  66. package/dist/src/model/config-contracts.d.ts +65 -0
  67. package/dist/src/model/config-contracts.js +1 -0
  68. package/dist/src/model/install-contracts.d.ts +29 -0
  69. package/dist/src/model/install-contracts.js +1 -0
  70. package/dist/src/model/lifecycle-enums.d.ts +13 -0
  71. package/dist/src/model/lifecycle-enums.js +1 -0
  72. package/dist/src/model/loop.d.ts +20 -0
  73. package/dist/src/model/loop.js +1 -0
  74. package/dist/src/model/manifest.d.ts +16 -0
  75. package/dist/src/model/manifest.js +1 -0
  76. package/dist/src/model/records.d.ts +138 -0
  77. package/dist/src/model/records.js +1 -0
  78. package/dist/src/model.d.ts +11 -264
  79. package/dist/src/model.js +1 -1
  80. package/dist/src/profiles.d.ts +2 -0
  81. package/dist/src/profiles.js +53 -9
  82. package/dist/src/state/archive-record.d.ts +9 -0
  83. package/dist/src/state/archive-record.js +26 -0
  84. package/dist/src/state/commands.d.ts +18 -0
  85. package/dist/src/state/commands.js +27 -0
  86. package/dist/src/state/domain.d.ts +9 -0
  87. package/dist/src/state/domain.js +9 -0
  88. package/dist/src/state/guards.d.ts +7 -0
  89. package/dist/src/state/guards.js +17 -0
  90. package/dist/src/state/io.d.ts +10 -0
  91. package/dist/src/state/io.js +35 -0
  92. package/dist/src/state/items.d.ts +3 -4
  93. package/dist/src/state/items.js +58 -57
  94. package/dist/src/state/lessons.js +14 -7
  95. package/dist/src/state/lifecycle.js +2 -2
  96. package/dist/src/state/lock.js +7 -6
  97. package/dist/src/state/memory.js +24 -12
  98. package/dist/src/state/{archive.js → migrate.js} +7 -7
  99. package/dist/src/state/partition.d.ts +15 -0
  100. package/dist/src/state/partition.js +90 -0
  101. package/dist/src/state/paths.d.ts +11 -0
  102. package/dist/src/state/paths.js +12 -0
  103. package/dist/src/state/render.js +9 -6
  104. package/dist/src/state/store.js +9 -15
  105. package/dist/src/state/transitions.js +9 -7
  106. package/dist/src/state/validate.js +50 -7
  107. package/dist/src/state.d.ts +16 -11
  108. package/dist/src/state.js +16 -11
  109. package/dist/src/task-ui.d.ts +54 -0
  110. package/dist/src/task-ui.js +1570 -0
  111. package/dist/src/upgrade-board.js +10 -10
  112. package/dist/src/upgrade.d.ts +0 -1
  113. package/dist/src/upgrade.js +19 -83
  114. package/dist/src/workflow.js +3 -3
  115. package/docs/README.md +6 -2
  116. package/docs/architecture.md +9 -8
  117. package/docs/command-reference.md +81 -20
  118. package/docs/configuration.md +28 -8
  119. package/docs/coordination-service.md +18 -6
  120. package/docs/design-decisions.md +83 -0
  121. package/docs/development.md +27 -2
  122. package/docs/loop-local-run.md +495 -0
  123. package/docs/loop-runbook.md +235 -0
  124. package/docs/operating-workflow.md +2 -2
  125. package/docs/testing-and-release.md +5 -1
  126. package/docs/troubleshooting.md +125 -0
  127. package/package.json +4 -1
  128. package/dist/src/ledger.d.ts +0 -22
  129. package/dist/src/ledger.js +0 -119
  130. package/dist/src/state/shared.d.ts +0 -29
  131. package/dist/src/state/shared.js +0 -71
  132. package/dist/src/tracker.d.ts +0 -36
  133. package/dist/src/tracker.js +0 -44
  134. /package/dist/src/state/{archive.d.ts → migrate.d.ts} +0 -0
@@ -1,19 +1,27 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { execFileSync } from "node:child_process";
3
+ import { tmpdir } from "node:os";
4
+ import { join, resolve } from "node:path";
2
5
  import { requireLocalAuthority } from "./authority.mjs";
3
6
  import { runAgentTurn, isLoopProvider, LOOP_PROVIDER_LIST } from "./loop-drivers.mjs";
4
7
  import { readLoopRecord, runLoop, validateRoot } from "./loop-runtime.mjs";
5
8
  import { runSupervisor } from "./loop-supervisor.mjs";
6
9
  import { createLocalSupervisorAdapter } from "./loop-supervisor-local.mjs";
10
+ import { pathsOverlap, readyTaskItems, selectBuildBatch } from "./store.mjs";
11
+ import { runLocalWorkerBatch } from "./loop-local-workers.mjs";
12
+ import { reconcileLocalAssignments } from "./assignment-store.mjs";
13
+ import { ensureWorkerImage, defaultWorkerImageTag } from "./loop-worker-image.mjs";
14
+ import { workerCredentialKeys } from "./loop-worker-docker.mjs";
7
15
 
8
16
  const digest = (value) => createHash("sha256").update(JSON.stringify(value)).digest("hex");
9
17
 
10
18
  /**
11
19
  * Bind the canonical local store, distinct supervisor lease and the existing
12
20
  * leased runner. The caller owns process supervision and human gate approval.
13
- * Abort stops polling or the next turn; the current runLoop call is bounded by
14
- * its existing timeouts and is not cancelled by this wrapper.
21
+ * `signal` stops polling and asks runLoop to stop at its next turn boundary;
22
+ * `cancelSignal` also cancels the in-flight agent turn. Both end as `stopped`.
15
23
  */
16
- export function createLocalLoopSupervisor({ root: rootInput, taskId, agent, limits, driver = runAgentTurn, preflight = true, pollMs, signal, onEvent = () => {}, runner = runLoop } = {}) {
24
+ export function createLocalLoopSupervisor({ root: rootInput, taskId, agent, limits, driver = runAgentTurn, preflight = true, pollMs, signal, cancelSignal, untilStop = false, onEvent = () => {}, runner = runLoop } = {}) {
17
25
  requireLocalAuthority();
18
26
  const root = validateRoot(rootInput);
19
27
  if (!isLoopProvider(agent)) throw new Error(`local lead agent must be ${LOOP_PROVIDER_LIST}`);
@@ -27,7 +35,7 @@ export function createLocalLoopSupervisor({ root: rootInput, taskId, agent, limi
27
35
  if (mode === "resume" && !record) throw new Error("Local loop record missing; use start");
28
36
  if (record && record.agent !== agent) throw new Error("Resume must use the recorded lead agent");
29
37
  let nextMode = mode;
30
- return runSupervisor({ taskId, authority: "local", ...adapter, pollMs, signal, onEvent,
38
+ return runSupervisor({ taskId, authority: "local", ...adapter, pollMs, signal, untilStop, onEvent,
31
39
  runTurn: async ({ precondition }) => {
32
40
  if (signal?.aborted) return { status: "stopped", taskId };
33
41
  requireLocalAuthority();
@@ -37,7 +45,136 @@ export function createLocalLoopSupervisor({ root: rootInput, taskId, agent, limi
37
45
  if (snapshot.task.status !== "active" || snapshot.nextAction.classification !== "run_phase") return { status: "blocked", taskId, reason: "Canonical state changed before runner acquired lease" };
38
46
  expected = { taskId, phase: snapshot.task.phase, taskHash: digest(snapshot.task), evidenceHash: digest(snapshot.task.evidence), evidenceCount: snapshot.task.evidence.length };
39
47
  }
40
- const result = await runner({ root, taskId, agent, mode: nextMode, limits, driver, preflight, precondition: expected, onEvent });
48
+ const result = await runner({ root, taskId, agent, mode: nextMode, limits, driver, preflight, precondition: expected, onEvent, signal, cancelSignal });
49
+ nextMode = "resume";
50
+ return result;
51
+ }
52
+ });
53
+ };
54
+ return {
55
+ start: () => execute("run"),
56
+ resume: () => execute("resume"),
57
+ async status() {
58
+ requireLocalAuthority();
59
+ const snapshot = await adapter.readTask({ taskId });
60
+ return { taskId, authority: "local", record: getRecord(), nextAction: snapshot.nextAction };
61
+ }
62
+ };
63
+ }
64
+
65
+
66
+ const itemPrompt = (root, taskId, itemId) => {
67
+ const packet = JSON.parse(execFileSync(process.execPath, [join(root, ".agents/aidlc/scripts/context.mjs"), taskId, "--phase", "build", "--item", itemId, "--format", "json", "--root", root], { cwd: root, encoding: "utf8", timeout: 30000, maxBuffer: 2 * 1024 * 1024 }));
68
+ if (typeof packet?.content !== "string" || !packet.content.trim()) throw new Error(`Context packet for ${itemId} was empty or invalid`);
69
+ return `${packet.content}\n\nOnly modify the assigned owned paths. Do not modify AI-DLC lifecycle state or approve gates. End with a Conventional Commits message on its own line as AI-DLC-COMMIT: type(scope): subject (for example AI-DLC-COMMIT: docs(ui): add web UI improvement proposal), followed by AI-DLC-ITEM-COMPLETE on its own line.`;
70
+ };
71
+
72
+ const ACTIVE_ASSIGNMENT_STATES = new Set(["leased", "reported", "uncertain"]);
73
+ const UNRESOLVED_ASSIGNMENT_STATES = new Set(["reported", "uncertain"]);
74
+
75
+ /**
76
+ * Pure batch planner for local build dispatch. Selects at most one item per
77
+ * worker from the dependency-ready items, skipping (deferring) items whose
78
+ * owned paths overlap each other or an active assignment, and items whose own
79
+ * prior assignment is unresolved. Overlap never throws: an overlapping item
80
+ * simply waits for a later batch. Throws only for a plan error (ready item
81
+ * without owned paths) or when every ready item is blocked by an active
82
+ * assignment, naming those assignments so a human can inspect them.
83
+ */
84
+ export function planLocalBuildBatch({ taskId, task, workers, assignments = [] }) {
85
+ const ready = readyTaskItems(task).ready;
86
+ if (!ready.length) return { jobs: [], deferred: [] };
87
+ const providers = Object.entries(workers ?? {});
88
+ if (!providers.length) throw new Error("Loop requires a configured lead and worker providers");
89
+ const byId = new Map(task.tasks.map((item) => [item.id, item]));
90
+ for (const itemId of ready) {
91
+ const item = byId.get(itemId);
92
+ if (!item || !Array.isArray(item.ownedPaths) || !item.ownedPaths.length) throw new Error(`Build item ${itemId} requires an owned path scope for Docker dispatch`);
93
+ }
94
+ const active = assignments.filter((entry) => ACTIVE_ASSIGNMENT_STATES.has(entry.state));
95
+ const busyPaths = active.flatMap((entry) => entry.ownedPaths ?? []);
96
+ const unresolvedItems = [...new Set(active.filter((entry) => UNRESOLVED_ASSIGNMENT_STATES.has(entry.state) && byId.get(entry.itemId)?.status !== "done").map((entry) => entry.itemId))];
97
+ const { selected, deferred } = selectBuildBatch(task, { capacity: providers.length, busyPaths, exclude: unresolvedItems });
98
+ if (!selected.length) {
99
+ // Every ready item is held back by an assignment; never auto-release it.
100
+ const blocking = active.filter((entry) => ready.some((itemId) => entry.itemId === itemId || pathsOverlap(byId.get(itemId).ownedPaths, entry.ownedPaths ?? [])));
101
+ const details = blocking.map((entry) => `${entry.state === "leased" ? "Active" : "Unresolved"} assignment ${entry.id} (${entry.state}) for ${entry.itemId} holds ${(entry.ownedPaths ?? []).join(", ")}`).join("; ");
102
+ throw new Error(`${details || `No ready build item of ${taskId} could be dispatched`}; inspect it, then run node .agents/aidlc/scripts/assignment.mjs reconcile ${taskId} or node .agents/aidlc/scripts/assignment.mjs release ${taskId} <assignment-id>`);
103
+ }
104
+ const jobs = selected.map((itemId, index) => {
105
+ const [workerId, agent] = providers[index];
106
+ return { itemId, workerId, agent, ownedPaths: [...byId.get(itemId).ownedPaths] };
107
+ });
108
+ return { jobs, deferred };
109
+ }
110
+
111
+ const markItemDone = (root, taskId, itemId) => {
112
+ const envelope = JSON.parse(execFileSync(process.execPath, [join(root, ".agents/aidlc/scripts/state.mjs"), "task", "item", taskId, itemId, "--status", "done", "--root", root], { cwd: root, encoding: "utf8", timeout: 30000, maxBuffer: 2 * 1024 * 1024 }));
113
+ if (envelope?.ok !== true) throw new Error(`Failed to mark item ${itemId} done`);
114
+ };
115
+
116
+ /** Fan dependency-ready build items out to Docker-isolated workers, then record
117
+ * each integrated item as done. No worker ever marks canonical progress itself.
118
+ * Overlapping or excess ready items are deferred to a later batch, not failed.
119
+ * The trailing hooks exist for tests; production uses the defaults. */
120
+ export async function dispatchLocalBuild({ root, taskId, task, workers, images = {}, credentials, worktreesRoot, onEvent = () => {}, cancelSignal, runBatch = runLocalWorkerBatch, ensureImage = ensureWorkerImage, prompt = itemPrompt, markDone = markItemDone }) {
121
+ // Reconcile completed items first so their owned paths are released for the
122
+ // next batch: an integrated item's assignment stays `reported` (still active)
123
+ // until reconciled, and overlapping owned paths would otherwise block the next
124
+ // claim even though the item is already done.
125
+ const { assignments } = reconcileLocalAssignments(root, taskId);
126
+ const { jobs: planned, deferred } = planLocalBuildBatch({ taskId, task, workers, assignments });
127
+ if (!planned.length) return;
128
+ for (const { id, reason } of deferred) await onEvent({ type: "build_deferred", taskId, itemId: id, reason });
129
+ const jobs = planned.map((job) => ({ ...job, prompt: prompt(root, taskId, job.itemId) }));
130
+ // Auto-build any missing per-provider image before dispatch so the user never
131
+ // has to build the worker image by hand.
132
+ const resolvedImages = {};
133
+ for (const provider of new Set(jobs.map((job) => job.agent))) {
134
+ resolvedImages[provider] = ensureImage({ root, provider, image: images[provider] ?? defaultWorkerImageTag(provider), onEvent });
135
+ }
136
+ const completed = await runBatch({ root, taskId, jobs, worktreesRoot, docker: { images: resolvedImages, credentials }, signal: cancelSignal });
137
+ for (const result of completed) markDone(root, taskId, result.itemId);
138
+ }
139
+
140
+ /**
141
+ * Local end-to-end supervisor: host-executed lead (Codex, Claude Code, or
142
+ * OpenCode) for clarify/plan/wrap, and Docker-isolated workers for build items.
143
+ * Reuses the local supervisor adapter + runSupervisor and the leased runLoop
144
+ * with a build dispatcher wired to runLocalWorkerBatch. Workers only ever
145
+ * receive environment credentials; the host lead and the trusted terminal own
146
+ * gates and lifecycle state.
147
+ */
148
+ export function createIsolatedLocalSupervisor({ root: rootInput, taskId, agent, workers = {}, limits, docker, worktreesRoot, workerImages = {}, pollMs, signal, cancelSignal, untilStop = false, onEvent = () => {}, runner = runLoop, driver = runAgentTurn, preflight = true } = {}) {
149
+ requireLocalAuthority();
150
+ const root = validateRoot(rootInput);
151
+ if (!isLoopProvider(agent)) throw new Error(`local lead agent must be ${LOOP_PROVIDER_LIST}`);
152
+ if (!workers || typeof workers !== "object" || Array.isArray(workers) || !Object.keys(workers).length) throw new Error("Loop requires a configured lead and worker providers");
153
+ for (const [id, provider] of Object.entries(workers)) if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(id) || !isLoopProvider(provider)) throw new Error(`invalid local worker: ${id}`);
154
+ const adapter = createLocalSupervisorAdapter({ root, taskId });
155
+ const getRecord = () => readLoopRecord(root, taskId);
156
+ const images = Object.fromEntries([...new Set(Object.values(workers))].map((provider) => [provider, docker?.images?.[provider] ?? docker?.image ?? workerImages?.[provider] ?? process.env.AIDLC_WORKER_DOCKER_IMAGE ?? defaultWorkerImageTag(provider)]));
157
+ const credentials = docker?.credentials ?? Object.fromEntries([...new Set(Object.values(workers))].map((provider) => [provider, workerCredentialKeys(provider)]));
158
+ const worktrees = resolve(worktreesRoot ?? process.env.AIDLC_LOCAL_WORKTREES_ROOT ?? join(tmpdir(), "aidlc-local-worktrees"));
159
+ const execute = async (mode) => {
160
+ requireLocalAuthority();
161
+ const record = getRecord();
162
+ if (mode === "run" && record) throw new Error("Local loop record already exists; use resume");
163
+ if (mode === "resume" && !record) throw new Error("Local loop record missing; use start");
164
+ if (record && record.agent !== agent) throw new Error("Resume must use the recorded lead agent");
165
+ let nextMode = mode;
166
+ return runSupervisor({ taskId, authority: "local", ...adapter, pollMs, signal, untilStop, onEvent,
167
+ runTurn: async ({ precondition }) => {
168
+ if (signal?.aborted) return { status: "stopped", taskId };
169
+ requireLocalAuthority();
170
+ let expected = precondition;
171
+ if (!expected) {
172
+ const snapshot = await adapter.readTask({ taskId });
173
+ if (snapshot.task.status !== "active" || snapshot.nextAction.classification !== "run_phase") return { status: "blocked", taskId, reason: "Canonical state changed before runner acquired lease" };
174
+ expected = { taskId, phase: snapshot.task.phase, taskHash: digest(snapshot.task), evidenceHash: digest(snapshot.task.evidence), evidenceCount: snapshot.task.evidence.length };
175
+ }
176
+ const dispatchBuild = async ({ root, taskId, task }) => dispatchLocalBuild({ root, taskId, task, workers, images, credentials, worktreesRoot: worktrees, onEvent, cancelSignal });
177
+ const result = await runner({ root, taskId, agent, mode: nextMode, limits, driver, dispatchBuild, preflight, precondition: expected, onEvent, signal, cancelSignal });
41
178
  nextMode = "resume";
42
179
  return result;
43
180
  }
@@ -37,6 +37,8 @@ export async function checkGateCheckpoint({ checkpoint, snapshot, verifyFleetApp
37
37
  }
38
38
  const appended = task.evidence.slice(checkpoint.evidenceCount);
39
39
  const approvals = appended.filter((entry) => entry.kind === "approval" && entry.gate === checkpoint.gate && entry.result === "pass" && typeof entry.source === "string" && entry.source.trim());
40
+ const cancelled = appended.some((entry) => entry.kind === "diagnostic" && entry.result === "pass" && typeof entry.source === "string" && entry.source.trim() && typeof entry.detail === "string" && entry.detail.startsWith(`Cancelled gate wait at ${checkpoint.gate}: `));
41
+ if (task.phase === checkpoint.phase && task.gate === checkpoint.gate && task.status === "active" && nextAction.classification === "run_phase" && approvals.length === 0 && cancelled) return { status: "rejected", precondition: { taskId: checkpoint.taskId, gate: checkpoint.gate, phase: task.phase, taskHash: hash(task), evidenceCount: task.evidence.length, evidenceHash: hash(task.evidence) } };
40
42
  if (task.phase !== TARGET[checkpoint.gate] || task.status !== "active" || nextAction.classification !== "run_phase" || approvals.length !== 1) return { status: "blocked", reason: "gate changed without its matching approval" };
41
43
  if (checkpoint.authority === "local" && verifyLocalApproval && !await verifyLocalApproval({ checkpoint, snapshot })) return { status: "blocked", reason: "local approval lacks the matching host receipt" };
42
44
  if (checkpoint.authority === "fleet") {
@@ -57,7 +59,7 @@ export async function checkGateCheckpoint({ checkpoint, snapshot, verifyFleetApp
57
59
  * renewable exclusive supervisor lease that does not block human gate approval.
58
60
  * No callback is allowed to approve a human gate.
59
61
  */
60
- export async function runSupervisor({ taskId, authority, acquireSupervisor, readTask, runTurn, loadCheckpoint, saveCheckpoint, clearCheckpoint, verifyFleetApproval, verifyLocalApproval, pollMs = 5000, signal, onEvent = () => {} }) {
62
+ export async function runSupervisor({ taskId, authority, acquireSupervisor, readTask, runTurn, loadCheckpoint, saveCheckpoint, clearCheckpoint, verifyFleetApproval, verifyLocalApproval, pollMs = 5000, signal, untilStop = false, onEvent = () => {} }) {
61
63
  validId(taskId, "task ID");
62
64
  if (!["local", "fleet"].includes(authority)) throw new Error("invalid authority");
63
65
  for (const [name, value] of Object.entries({ acquireSupervisor, readTask, runTurn, loadCheckpoint, saveCheckpoint, clearCheckpoint, onEvent })) required(value, name);
@@ -74,9 +76,14 @@ export async function runSupervisor({ taskId, authority, acquireSupervisor, read
74
76
  const verdict = await checkGateCheckpoint({ checkpoint, snapshot, verifyFleetApproval, verifyLocalApproval });
75
77
  if (verdict.status === "blocked") return { status: "blocked", taskId, reason: verdict.reason };
76
78
  if (verdict.status === "wait") {
79
+ if (untilStop) return { status: "gate", taskId, gate: checkpoint.gate };
77
80
  try { await sleep(pollMs, undefined, { signal }); } catch (error) { if (error?.name !== "AbortError") throw error; }
78
81
  continue;
79
82
  }
83
+ if (verdict.status === "rejected") {
84
+ await clearCheckpoint({ taskId, authority, checkpoint });
85
+ checkpoint = null;
86
+ }
80
87
  precondition = verdict.precondition;
81
88
  } else if (snapshot.nextAction.classification !== "run_phase") {
82
89
  return { status: snapshot.nextAction.classification === "await_user" ? "blocked" : snapshot.nextAction.classification, taskId, reason: snapshot.nextAction.classification === "await_user" ? "gate checkpoint missing; inspect the gate before resuming" : snapshot.nextAction.reason };
@@ -94,6 +101,7 @@ export async function runSupervisor({ taskId, authority, acquireSupervisor, read
94
101
  await ownership.assertOwned();
95
102
  await saveCheckpoint({ taskId, authority, checkpoint: next, previous: checkpoint });
96
103
  checkpoint = next;
104
+ if (untilStop) return { status: "gate", taskId, gate: next.gate, ...(next.snapshotId ? { snapshotId: next.snapshotId } : {}), gateView: result.gateView };
97
105
  await onEvent({ type: "gate", taskId, gate: next.gate, ...(next.snapshotId ? { snapshotId: next.snapshotId } : {}), gateView: result.gateView });
98
106
  }
99
107
  return { status: "stopped", taskId };
@@ -2,20 +2,45 @@ import { lstatSync, realpathSync } from 'node:fs';
2
2
  import { sep } from 'node:path';
3
3
  import { isLoopProvider } from './loop-drivers.mjs';
4
4
 
5
- /** Container workers receive credentials as environment keys only. OpenCode
6
- * authenticates from a file-based data store, so it fails closed here. */
5
+ /** Container workers receive credentials as environment keys only. OpenCode can
6
+ * authenticate from environment variables too, so the worker path redirects its
7
+ * XDG data/config roots into the worker's ephemeral HOME instead of failing.
8
+ * The isolated LEAD container still rejects OpenCode (it needs a file-based
9
+ * session store that host-brokered proposals do not use); that check lives in
10
+ * the lead sandbox, not here. */
7
11
  export const OPENCODE_CONTAINER_ERROR = 'OpenCode workers require file-based credentials and are not available in isolated containers yet';
8
12
 
13
+ /** OpenCode's data/config roots, redirected into the worker's tmpfs HOME so the
14
+ * container never reads the host ~/.local/share/opencode or ~/.config/opencode. */
15
+ const OPENCODE_XDG_ENV = [
16
+ 'XDG_DATA_HOME=/home/worker/.local/share',
17
+ 'XDG_CONFIG_HOME=/home/worker/.config',
18
+ 'XDG_CACHE_HOME=/home/worker/.cache',
19
+ 'XDG_STATE_HOME=/home/worker/.local/state',
20
+ 'OPENCODE_DB=/home/worker/.local/share/opencode/opencode.db'
21
+ ];
22
+
23
+ /** Environment credential keys a containerized worker of `agent` receives.
24
+ * OpenCode authenticates from the environment like codex/claude; its key
25
+ * follows the configured model's provider (opencode-go → OPENCODE_API_KEY). */
26
+ export function workerCredentialKeys(agent, env = process.env) {
27
+ if (agent === 'codex') return ['OPENAI_API_KEY'];
28
+ if (agent === 'claude') return ['ANTHROPIC_API_KEY'];
29
+ const model = env?.AIDLC_OPENCODE_MODEL ?? '';
30
+ if (model.startsWith('openai/')) return ['OPENAI_API_KEY'];
31
+ if (model.startsWith('anthropic/')) return ['ANTHROPIC_API_KEY'];
32
+ return ['OPENCODE_API_KEY'];
33
+ }
34
+
9
35
  /** Build a Docker invocation. This function never accepts a shell command or credentials in arguments. */
10
36
  export function workerDockerInvocation({ workspace, image, command, args, credentialEnv, hostEnv, uid, gid, containerName, sessionHome }) {
11
37
  const root = realpathSync(workspace);
12
38
  if (/[\x00-\x1f,]/.test(root)) throw new Error('Invalid Docker workspace path');
13
39
  if (typeof image !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,255}$/.test(image)) throw new Error('Explicit Docker image is required');
14
- if (command === 'opencode') throw new Error(OPENCODE_CONTAINER_ERROR);
15
40
  if (!isLoopProvider(command) || !Array.isArray(args) || args.some((arg) => typeof arg !== 'string')) throw new Error('Invalid worker provider command');
16
41
  if (!Number.isSafeInteger(uid) || uid < 1 || !Number.isSafeInteger(gid) || gid < 1) throw new Error('Non-root worker uid and gid are required');
17
42
  if (!hostEnv || typeof hostEnv !== 'object' || !Array.isArray(credentialEnv) || !credentialEnv.length || credentialEnv.some((key) => !/^[A-Z][A-Z0-9_]*$/.test(key) || typeof hostEnv[key] !== 'string' || !hostEnv[key])) throw new Error('Explicit worker provider credentials are required');
18
- const allowed = command === 'codex' ? ['OPENAI_API_KEY'] : ['ANTHROPIC_API_KEY'];
43
+ const allowed = command === 'codex' ? ['OPENAI_API_KEY'] : command === 'claude' ? ['ANTHROPIC_API_KEY'] : ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'OPENCODE_API_KEY'];
19
44
  if (credentialEnv.some((key) => !allowed.includes(key)) || new Set(credentialEnv).size !== credentialEnv.length) throw new Error('Unsupported worker credential environment');
20
45
  if (containerName !== undefined && (typeof containerName !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(containerName))) throw new Error('Invalid container name');
21
46
  let homeMount;
@@ -28,5 +53,6 @@ export function workerDockerInvocation({ workspace, image, command, args, creden
28
53
  }
29
54
  const env = { PATH: hostEnv.PATH ?? '/usr/bin:/bin' };
30
55
  for (const key of credentialEnv) env[key] = hostEnv[key];
31
- return { command: 'docker', args: ['run', '--rm', '--interactive', ...(containerName ? ['--name', containerName] : []), '--read-only', '--user', `${uid}:${gid}`, '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--pids-limit', '256', '--memory', '2g', '--cpus', '2', '--tmpfs', '/tmp:rw,nosuid,nodev,size=256m', ...(homeMount ? ['--mount', homeMount] : ['--tmpfs', `/home/worker:rw,nosuid,nodev,size=128m,uid=${uid},gid=${gid}`]), '--env', 'HOME=/home/worker', '--workdir', '/workspace', '--mount', `type=bind,src=${root},dst=/workspace`, ...credentialEnv.flatMap((key) => ['--env', key]), '--entrypoint', command, image, ...args], env };
56
+ const opencodeEnv = command === 'opencode' ? [...OPENCODE_XDG_ENV, 'OPENCODE_CONFIG_CONTENT={}'].flatMap((entry) => ['--env', entry]) : [];
57
+ return { command: 'docker', args: ['run', '--rm', '--interactive', ...(containerName ? ['--name', containerName] : []), '--read-only', '--user', `${uid}:${gid}`, '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--pids-limit', '256', '--memory', '2g', '--cpus', '2', '--tmpfs', '/tmp:rw,nosuid,nodev,size=256m', ...(homeMount ? ['--mount', homeMount] : ['--tmpfs', `/home/worker:rw,nosuid,nodev,size=128m,uid=${uid},gid=${gid}`]), '--env', 'HOME=/home/worker', '--workdir', '/workspace', '--mount', `type=bind,src=${root},dst=/workspace`, ...credentialEnv.flatMap((key) => ['--env', key]), ...opencodeEnv, '--entrypoint', command, image, ...args], env };
32
58
  }
@@ -0,0 +1,64 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { isLoopProvider } from "./loop-drivers.mjs";
5
+
6
+ /** Image tag must satisfy the same shape the Docker worker invocation accepts. */
7
+ const IMAGE_TAG = /^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,255}$/;
8
+
9
+ /** Deterministic default tag for a provider: `aidlc-worker-<provider>:local`. */
10
+ export const defaultWorkerImageTag = (provider) => `aidlc-worker-${provider}:local`;
11
+
12
+ /** Validate an explicit image tag the way `workerDockerInvocation` will. */
13
+ export const validWorkerImageTag = (tag) => typeof tag === "string" && IMAGE_TAG.test(tag);
14
+
15
+ /** Resolve the shipped Dockerfile for a provider inside an installed project. */
16
+ export const workerDockerfilePath = (root, provider) => join(root, ".agents", "aidlc", "docker", `Dockerfile.${provider}`);
17
+
18
+ const dockerEnv = (env = process.env) => ({ PATH: env.PATH ?? "/usr/bin:/bin" });
19
+
20
+ /** True when a local image with that tag already exists in the Docker daemon. */
21
+ export function dockerImageExists(image, env = process.env) {
22
+ try {
23
+ execFileSync("docker", ["image", "inspect", "--format", "{{.Id}}", image], { env: dockerEnv(env), encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 30000 });
24
+ return true;
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ /** Build a worker image from the shipped Dockerfile. Streams progress to the
31
+ * terminal (stdio inherit) and returns the built tag. Never accepts a shell. */
32
+ export function buildWorkerImage({ root, provider, image, env = process.env, onEvent = () => {} }) {
33
+ if (!isLoopProvider(provider)) throw new Error(`provider must be codex, claude, or opencode`);
34
+ if (!validWorkerImageTag(image)) throw new Error("Explicit Docker image is required");
35
+ const dockerfile = workerDockerfilePath(root, provider);
36
+ if (!existsSync(dockerfile)) throw new Error(`Worker Dockerfile is missing: ${dockerfile}`);
37
+ onEvent({ type: "worker_image_build", provider, image });
38
+ execFileSync("docker", ["build", "-f", dockerfile, "-t", image, join(root, ".agents", "aidlc", "docker")], { env: dockerEnv(env), stdio: "inherit" });
39
+ return image;
40
+ }
41
+
42
+ /** Build the image only when it is missing; otherwise return the existing tag. */
43
+ export function ensureWorkerImage({ root, provider, image, env = process.env, onEvent = () => {} }) {
44
+ if (dockerImageExists(image, env)) return image;
45
+ return buildWorkerImage({ root, provider, image, env, onEvent });
46
+ }
47
+
48
+ /** Persist the built tag into `config.loop.workerImage[provider]` so the local
49
+ * supervisor can reuse it without a manual `export`. No-op when the project
50
+ * config does not exist yet; credentials are never persisted. */
51
+ export function recordWorkerImage({ root, provider, image }) {
52
+ if (!isLoopProvider(provider) || !validWorkerImageTag(image)) throw new Error("Cannot record an invalid worker image");
53
+ const configPath = join(root, ".agents", "config.json");
54
+ if (!existsSync(configPath)) return;
55
+ const config = JSON.parse(readFileSync(configPath, "utf8"));
56
+ if (!config || typeof config !== "object" || Array.isArray(config)) return;
57
+ const loop = (config.loop && typeof config.loop === "object" && !Array.isArray(config.loop)) ? config.loop : {};
58
+ const workerImage = (loop.workerImage && typeof loop.workerImage === "object" && !Array.isArray(loop.workerImage)) ? loop.workerImage : {};
59
+ if (workerImage[provider] === image) return;
60
+ workerImage[provider] = image;
61
+ loop.workerImage = workerImage;
62
+ config.loop = loop;
63
+ writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
64
+ }
@@ -307,6 +307,60 @@ export const readyTaskItems = (task) => {
307
307
  return { ready, blocked };
308
308
  };
309
309
 
310
+ /* Parallel work partition: owned paths overlap by path segment (`a` overlaps `a/b`, not `ab`).
311
+ * Two open items can run concurrently unless one is a direct or transitive dependency of the other. */
312
+ const ownedPathKey = (path) => String(path).replace(/\/+$/, "");
313
+ export const pathOverlaps = (left, right) => { const a = ownedPathKey(left), b = ownedPathKey(right); return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`); };
314
+ export const pathsOverlap = (left = [], right = []) => left.some((a) => right.some((b) => pathOverlaps(a, b)));
315
+ const itemAncestors = (task) => {
316
+ const byId = new Map(task.tasks.map((item) => [item.id, item])); const memo = new Map();
317
+ const walk = (id, trail = new Set()) => { if (memo.has(id)) return memo.get(id); const found = new Set(); if (trail.has(id)) return found; trail.add(id); for (const dep of byId.get(id)?.dependsOn ?? []) { if (!byId.has(dep)) continue; found.add(dep); for (const deeper of walk(dep, trail)) found.add(deeper); } trail.delete(id); memo.set(id, found); return found; };
318
+ return (id) => walk(id);
319
+ };
320
+ export const concurrentItemPairs = (task) => {
321
+ const open = task.tasks.filter((item) => !["done", "deferred"].includes(item.status)); const ancestors = itemAncestors(task); const pairs = [];
322
+ for (let i = 0; i < open.length; i += 1) for (let j = i + 1; j < open.length; j += 1) { const a = open[i], b = open[j]; if (!ancestors(a.id).has(b.id) && !ancestors(b.id).has(a.id)) pairs.push([a, b]); }
323
+ return pairs;
324
+ };
325
+ /** Approve-plan partition check. `strict` (enabled loop) makes findings ERRORs; otherwise WARNs. */
326
+ export const partitionDiagnostics = (task, { strict = true } = {}) => {
327
+ const level = strict ? "ERROR" : "WARN"; const diagnostics = [];
328
+ const unscoped = task.tasks.filter((item) => !["done", "deferred"].includes(item.status) && !(Array.isArray(item.ownedPaths) && item.ownedPaths.length));
329
+ if (unscoped.length) diagnostics.push({ level, code: "PARTITION_SCOPE", message: `Build items need owned paths (--owns) for parallel workers: ${unscoped.map((item) => item.id).join(", ")}` });
330
+ for (const [a, b] of concurrentItemPairs(task)) {
331
+ const shared = (a.ownedPaths ?? []).filter((path) => (b.ownedPaths ?? []).some((other) => pathOverlaps(path, other)));
332
+ if (shared.length) diagnostics.push({ level, code: "PARTITION_OVERLAP", message: `Items ${a.id} and ${b.id} can run concurrently but both own ${shared.join(", ")}; split the paths or add --depends-on` });
333
+ }
334
+ return diagnostics;
335
+ };
336
+ /** The partition check is strict when the loop is enabled: `loop.mode` local/fleet in the project config, or
337
+ * running inside a fleet-enabled coordination service. A missing config means the default `off`; an
338
+ * unreadable or corrupt config fails closed (strict). */
339
+ export const loopPartitionStrict = (root) => {
340
+ if (process.env.MCP_FLEET_ENABLED === "1") return true;
341
+ if (root === undefined) return false;
342
+ const path = join(resolve(root), ".agents", "config.json");
343
+ if (!existsSync(path)) return false;
344
+ try { const mode = JSON.parse(readFileSync(path, "utf8"))?.loop?.mode; return mode === "local" || mode === "fleet"; }
345
+ catch { return true; }
346
+ };
347
+ /** Pick the next dispatchable batch: dependency-ready items in plan order, at most `capacity`,
348
+ * skipping items that overlap each other, `busyPaths`, or are listed in `exclude`. Never throws for overlap. */
349
+ export const selectBuildBatch = (task, { capacity = Infinity, busyPaths = [], exclude = [] } = {}) => {
350
+ const byId = new Map(task.tasks.map((item) => [item.id, item])); const selected = []; const deferred = []; const taken = [];
351
+ for (const id of readyTaskItems(task).ready) {
352
+ const owned = byId.get(id)?.ownedPaths ?? [];
353
+ if (exclude.includes(id)) { deferred.push({ id, reason: "unresolved assignment" }); continue; }
354
+ if (!owned.length) { deferred.push({ id, reason: "no owned paths" }); continue; }
355
+ if (pathsOverlap(owned, busyPaths)) { deferred.push({ id, reason: "busy path held by an active assignment" }); continue; }
356
+ const clash = taken.find((entry) => pathsOverlap(owned, entry.owned));
357
+ if (clash) { deferred.push({ id, reason: `overlaps ${clash.id}` }); continue; }
358
+ if (selected.length >= capacity) { deferred.push({ id, reason: "capacity" }); continue; }
359
+ selected.push(id); taken.push({ id, owned });
360
+ }
361
+ return { selected, deferred };
362
+ };
363
+
310
364
  export const nextAction = (task, root) => {
311
365
  const rootSuffix = root === undefined ? "" : ` --root ${JSON.stringify(resolve(root))}`;
312
366
  const script = (name) => root === undefined ? `node .agents/aidlc/scripts/${name}` : `node ${JSON.stringify(join(resolve(root), ".agents/aidlc/scripts", name))}`;
@@ -357,13 +411,14 @@ export const searchLessons = (root, state, query, areas = [], limit = 5) => { co
357
411
 
358
412
  export const listTaskSummaries = (state, options = {}) => { const active = Object.values(state.tasks).map((task) => ({ id: task.id, title: task.title, phase: task.phase, gate: task.gate, status: task.status, risk: task.risk, areas: task.areas, updatedAt: task.updatedAt, source: "active" })); const archived = options.includeArchive ? Object.values(state.archive ?? {}).map((task) => ({ id: task.id, title: task.title, phase: task.phase, gate: task.gate, status: task.status, risk: task.risk, areas: task.areas, updatedAt: task.updatedAt, source: "archive" })) : []; const queryTokens = tokens(options.query ?? ""); const statusSet = new Set(options.statuses ?? []); const filtered = [...active, ...archived].filter((item) => (!statusSet.size || statusSet.has(item.status)) && (!queryTokens.length || queryTokens.every((token) => tokens(`${item.id} ${item.title} ${item.areas.join(" ")}`).includes(token)))).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt) || a.id.localeCompare(b.id)); const cursor = Math.max(0, options.cursor ?? 0); const limit = Math.max(1, Math.min(options.limit ?? 20, 100)); const items = filtered.slice(cursor, cursor + limit); return { items, nextCursor: cursor + items.length < filtered.length ? String(cursor + items.length) : null, total: filtered.length }; };
359
413
 
360
- export const renderWorkplan = (task) => { const decisions = task.decisions.length ? task.decisions.map((item) => `- [${item.status === "approved" ? "x" : " "}] ${item.id} — ${item.label}${item.resolution ? ` — ${item.resolution}` : ""}`).join("\n") : "- None"; const tasks = task.tasks.length ? task.tasks.map((item) => `- [${item.status === "done" ? "x" : item.status === "in_progress" ? "~" : " "}] ${item.id} — ${item.label}`).join("\n") : "- None"; const lifecycle = [`- Status: \`${task.status}\``, task.handoff ? `- Handoff: \`${task.handoff.kind}\` — ${task.handoff.reason}` : "", task.closure ? `- Closure: ${task.closure.reason}` : "", task.predecessorTaskId ? `- Predecessor: \`${task.predecessorTaskId}\`` : "", task.successorTaskId ? `- Successor: \`${task.successorTaskId}\`` : "", task.lessonDisposition ? `- Lessons: \`${task.lessonDisposition.status}\`` : ""].filter(Boolean).join("\n"); return `# Workplan — ${task.title} (\`${task.id}\`)\n\n> Generated from canonical JSON state.\n\n## Lifecycle\n${lifecycle}\n\n## 🧩 Decisions (Approve plan — before implementation)\n${decisions}\n\n## 🧩 Tasks (Review implementation — work items)\n${tasks}\n`; };
414
+ const workplanScope = (item) => { const parts = [item.ownedPaths?.length ? `owns: ${item.ownedPaths.join(", ")}` : "", item.dependsOn?.length ? `after: ${item.dependsOn.join(", ")}` : ""].filter(Boolean); return parts.length ? ` — ${parts.join(" · ")}` : ""; };
415
+ export const renderWorkplan = (task) => { const decisions = task.decisions.length ? task.decisions.map((item) => `- [${item.status === "approved" ? "x" : " "}] ${item.id} — ${item.label}${item.resolution ? ` — ${item.resolution}` : ""}`).join("\n") : "- None"; const tasks = task.tasks.length ? task.tasks.map((item) => `- [${item.status === "done" ? "x" : item.status === "in_progress" ? "~" : " "}] ${item.id} — ${item.label}${workplanScope(item)}`).join("\n") : "- None"; const lifecycle = [`- Status: \`${task.status}\``, task.handoff ? `- Handoff: \`${task.handoff.kind}\` — ${task.handoff.reason}` : "", task.closure ? `- Closure: ${task.closure.reason}` : "", task.predecessorTaskId ? `- Predecessor: \`${task.predecessorTaskId}\`` : "", task.successorTaskId ? `- Successor: \`${task.successorTaskId}\`` : "", task.lessonDisposition ? `- Lessons: \`${task.lessonDisposition.status}\`` : ""].filter(Boolean).join("\n"); return `# Workplan — ${task.title} (\`${task.id}\`)\n\n> Generated from canonical JSON state.\n\n## Lifecycle\n${lifecycle}\n\n## 🧩 Decisions (Approve plan — before implementation)\n${decisions}\n\n## 🧩 Tasks (Review implementation — work items)\n${tasks}\n`; };
361
416
  export const renderViews = (root, state = loadState(root), selected = Object.values(state.tasks)) => { for (const task of selected) { if (!task.artifacts.workplan) continue; assertNoSymlinkPath(root, task.artifacts.workplan); writeAtomic(join(resolve(root), task.artifacts.workplan), renderWorkplan(task)); } };
362
417
 
363
418
  const safeArtifactTarget = (root, path) => { assertNoSymlinkPath(root, path); return join(resolve(root), path); };
364
419
  const artifactContent = (root, path) => { if (!path) return undefined; try { const target = safeArtifactTarget(root, path); return existsSync(target) ? readFileSync(target, "utf8") : undefined; } catch { return undefined; } /* intentional: unreadable/unsafe artifact is treated as absent */ };
365
420
  const artifactDiagnostics = (root, task, names) => names.flatMap((name) => { const path = task.artifacts[name]; if (!path) return [{ level: "ERROR", code: "ARTIFACT_REFERENCE", message: `${name} artifact is not referenced` }]; try { return existsSync(safeArtifactTarget(root, path)) ? [] : [{ level: "ERROR", code: "ARTIFACT_MISSING", message: `${path} does not exist` }]; } catch (error) { return [{ level: "ERROR", code: "ARTIFACT_UNSAFE", message: error instanceof Error ? error.message : String(error) }]; } });
366
- export const checkGate = (root, state, taskId, gate) => { const task = state.tasks[taskId]; if (!task) return [{ level: "ERROR", code: "TASK_UNKNOWN", message: `Unknown active task: ${taskId}` }]; const diagnostics = []; if (task.gate !== gate) diagnostics.push({ level: "ERROR", code: "GATE_STATE", message: `Task is at ${task.gate}, not ${gate}` }); if (["closed", "superseded"].includes(task.status)) diagnostics.push({ level: "ERROR", code: "TASK_TERMINAL", message: `Terminal task cannot approve ${gate}` }); if (task.handoff) diagnostics.push({ level: "ERROR", code: "TASK_HANDOFF", message: `Task has unresolved handoff: ${task.handoff.reason}` }); if (task.id !== taskId) diagnostics.push({ level: "ERROR", code: "TASK_ID", message: "Task key and id differ" }); if (!task.title.trim() || !task.areas.length) diagnostics.push({ level: "ERROR", code: "TASK_FIELDS", message: "Task title and affected areas are required" }); if (gate === "G0_confirm") { diagnostics.push(...artifactDiagnostics(root, task, ["intent"])); const content = artifactContent(root, task.artifacts.intent); if (content !== undefined) for (const heading of ["## 📋 Problem", "## 🗺️ Affected areas", "## 💭 Assumptions", "## ❓ Open questions", "## 🎯 Scope"]) if (!content.includes(heading)) diagnostics.push({ level: "ERROR", code: "INTENT_HEADING", message: `Intent is missing ${heading}` }); } if (gate === "G1_review") { diagnostics.push(...artifactDiagnostics(root, task, ["intent", "design", "workplan"])); diagnostics.push(...transitionDiagnostics(task, "build").filter((item) => item.code === "UNRESOLVED_DECISION")); const content = artifactContent(root, task.artifacts.design); if (content !== undefined) for (const heading of ["## 🧩 Solution per affected area", "## 📌 Spec traceability", "## 🔗 Cross-service contracts", "## ⚠️ Risks / edge cases"]) if (!content.includes(heading)) diagnostics.push({ level: "ERROR", code: "DESIGN_HEADING", message: `Design is missing ${heading}` }); } if (gate === "G2_codereview") { diagnostics.push(...artifactDiagnostics(root, task, ["intent", "design", "workplan"])); if (task.tasks.some((item) => !["done", "deferred"].includes(item.status))) diagnostics.push({ level: "ERROR", code: "TASKS_OPEN", message: "Build tasks remain open" }); for (const area of task.areas) if (!hasAreaVerification(task, area)) diagnostics.push({ level: "ERROR", code: "VERIFY_EVIDENCE", message: `Latest post-G1 verification evidence must pass for affected area: ${area}` }); if (!hasReview(task)) diagnostics.push({ level: "ERROR", code: "REVIEW_EVIDENCE", message: "Latest post-G1 code-review evidence must pass" }); const bounds = repairBounds(task); if (!isLegacyG2Wait(task) && (bounds.verifyExhausted || bounds.reviewExhausted)) diagnostics.push({ level: "ERROR", code: "REPAIR_BOUND", message: "Repair bound exhausted; record a durable handoff instead of presenting G2" }); } if (!diagnostics.length) diagnostics.push({ level: "INFO", code: "GATE_OK", message: `${gate} checks passed for ${taskId}` }); return diagnostics; };
421
+ export const checkGate = (root, state, taskId, gate) => { const task = state.tasks[taskId]; if (!task) return [{ level: "ERROR", code: "TASK_UNKNOWN", message: `Unknown active task: ${taskId}` }]; const diagnostics = []; if (task.gate !== gate) diagnostics.push({ level: "ERROR", code: "GATE_STATE", message: `Task is at ${task.gate}, not ${gate}` }); if (["closed", "superseded"].includes(task.status)) diagnostics.push({ level: "ERROR", code: "TASK_TERMINAL", message: `Terminal task cannot approve ${gate}` }); if (task.handoff) diagnostics.push({ level: "ERROR", code: "TASK_HANDOFF", message: `Task has unresolved handoff: ${task.handoff.reason}` }); if (task.id !== taskId) diagnostics.push({ level: "ERROR", code: "TASK_ID", message: "Task key and id differ" }); if (!task.title.trim() || !task.areas.length) diagnostics.push({ level: "ERROR", code: "TASK_FIELDS", message: "Task title and affected areas are required" }); if (gate === "G0_confirm") { diagnostics.push(...artifactDiagnostics(root, task, ["intent"])); const content = artifactContent(root, task.artifacts.intent); if (content !== undefined) for (const heading of ["## 📋 Problem", "## 🗺️ Affected areas", "## 💭 Assumptions", "## ❓ Open questions", "## 🎯 Scope"]) if (!content.includes(heading)) diagnostics.push({ level: "ERROR", code: "INTENT_HEADING", message: `Intent is missing ${heading}` }); } if (gate === "G1_review") { diagnostics.push(...artifactDiagnostics(root, task, ["intent", "design", "workplan"])); diagnostics.push(...transitionDiagnostics(task, "build").filter((item) => item.code === "UNRESOLVED_DECISION")); const content = artifactContent(root, task.artifacts.design); if (content !== undefined) for (const heading of ["## 🧩 Solution per affected area", "## 📌 Spec traceability", "## 🔗 Cross-service contracts", "## ⚠️ Risks / edge cases"]) if (!content.includes(heading)) diagnostics.push({ level: "ERROR", code: "DESIGN_HEADING", message: `Design is missing ${heading}` }); diagnostics.push(...partitionDiagnostics(task, { strict: loopPartitionStrict(root) })); } if (gate === "G2_codereview") { diagnostics.push(...artifactDiagnostics(root, task, ["intent", "design", "workplan"])); if (task.tasks.some((item) => !["done", "deferred"].includes(item.status))) diagnostics.push({ level: "ERROR", code: "TASKS_OPEN", message: "Build tasks remain open" }); for (const area of task.areas) if (!hasAreaVerification(task, area)) diagnostics.push({ level: "ERROR", code: "VERIFY_EVIDENCE", message: `Latest post-G1 verification evidence must pass for affected area: ${area}` }); if (!hasReview(task)) diagnostics.push({ level: "ERROR", code: "REVIEW_EVIDENCE", message: "Latest post-G1 code-review evidence must pass" }); const bounds = repairBounds(task); if (!isLegacyG2Wait(task) && (bounds.verifyExhausted || bounds.reviewExhausted)) diagnostics.push({ level: "ERROR", code: "REPAIR_BOUND", message: "Repair bound exhausted; record a durable handoff instead of presenting G2" }); } if (!diagnostics.some((item) => item.level === "ERROR")) diagnostics.push({ level: "INFO", code: "GATE_OK", message: `${gate} checks passed for ${taskId}` }); return diagnostics; };
367
422
  const gateTarget = { G0_confirm: "plan", G1_review: "build", G2_codereview: "wrap" };
368
423
  export const approveAndAdvance = (root, state, taskId, gate, source, recordedAt = new Date().toISOString()) => { const task = state.tasks[taskId]; if (!task) throw new Error(`Unknown active task: ${taskId}`); if (["closed", "superseded"].includes(task.status) || task.handoff) throw new Error(`Terminal or handed-off task cannot approve ${gate}`); const target = gateTarget[gate]; if (!target) throw new Error(`Gate ${gate} cannot be approved`); if (task.phase === target) return { task, nextAction: nextAction(task, root), idempotent: true }; if (task.gate !== gate || task.status !== "blocked_on_user") throw new Error(`Task must be blocked_on_user at ${gate}`); const errors = checkGate(root, state, taskId, gate).filter((item) => item.level === "ERROR"); if (errors.length) throw new Error(errors.map((item) => item.message).join("; ")); task.evidence.push({ kind: "approval", gate, result: "pass", source, recordedAt }); transitionTask(state, taskId, target); return { task, nextAction: nextAction(task, root), idempotent: false }; };
369
424
  export const formatDiagnostics = (diagnostics) => diagnostics.map((item) => `${item.level} ${item.code}: ${item.message}`).join("\n");