@felixdotgo/aidlc-kit 0.1.1 → 0.3.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 (198) hide show
  1. package/DEVELOPMENT.md +14 -13
  2. package/README.md +37 -43
  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 +2 -2
  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/schemas/state.schema.json +2 -0
  11. package/dist/assets/.agents/aidlc/scripts/lib/assignment-store.mjs +94 -48
  12. package/dist/assets/.agents/aidlc/scripts/lib/context-runtime.mjs +5 -4
  13. package/dist/assets/.agents/aidlc/scripts/lib/loop-authority.mjs +261 -0
  14. package/dist/assets/.agents/aidlc/scripts/lib/loop-commit-message.mjs +45 -0
  15. package/dist/assets/.agents/aidlc/scripts/lib/loop-daemon.mjs +206 -0
  16. package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +100 -14
  17. package/dist/assets/.agents/aidlc/scripts/lib/loop-env.mjs +279 -0
  18. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +105 -32
  19. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +17 -1
  20. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +24 -7
  21. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +42 -16
  22. package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +20 -6
  23. package/dist/assets/.agents/aidlc/scripts/lib/loop-holder.mjs +376 -0
  24. package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +33 -7
  25. package/dist/assets/.agents/aidlc/scripts/lib/loop-lease.mjs +67 -61
  26. package/dist/assets/.agents/aidlc/scripts/lib/loop-leases.mjs +268 -0
  27. package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +116 -28
  28. package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +114 -12
  29. package/dist/assets/.agents/aidlc/scripts/lib/loop-stream.mjs +117 -0
  30. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-local.mjs +16 -56
  31. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +149 -6
  32. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor.mjs +9 -1
  33. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +44 -6
  34. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-image.mjs +64 -0
  35. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-log.mjs +158 -0
  36. package/dist/assets/.agents/aidlc/scripts/lib/repo-head.mjs +22 -0
  37. package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +68 -2
  38. package/dist/assets/.agents/aidlc/scripts/lib/task-runtime-migrate.mjs +75 -0
  39. package/dist/assets/.agents/aidlc/scripts/lib/task-runtime.mjs +97 -0
  40. package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +168 -73
  41. package/dist/assets/.agents/aidlc/scripts/loop-gate.mjs +19 -7
  42. package/dist/assets/.agents/aidlc/scripts/loop.cmd +101 -0
  43. package/dist/assets/.agents/aidlc/scripts/loop.mjs +2 -1
  44. package/dist/assets/.agents/aidlc/scripts/loop.sh +71 -0
  45. package/dist/assets/.agents/aidlc/scripts/remote-state.mjs +10 -4
  46. package/dist/assets/.agents/aidlc/scripts/state.mjs +16 -8
  47. package/dist/assets/.agents/aidlc/templates/design.md +9 -0
  48. package/dist/assets/.agents/aidlc/templates/intent.md +4 -0
  49. package/dist/assets/.agents/aidlc/templates/workplan.md +1 -1
  50. package/dist/assets/.agents/skills/aidlc-plan/SKILL.md +1 -1
  51. package/dist/assets/ui/bulma.min.css +3 -0
  52. package/dist/assets/ui/changes.js +83 -0
  53. package/dist/assets/ui/common.js +664 -0
  54. package/dist/assets/ui/dashboard.css +636 -0
  55. package/dist/assets/ui/dashboard.js +77 -0
  56. package/dist/assets/ui/detail.js +159 -0
  57. package/dist/assets/ui/live-log.js +332 -0
  58. package/dist/services/aidlc-coordination/Dockerfile +11 -3
  59. package/dist/services/aidlc-coordination/compose.yaml +12 -0
  60. package/dist/services/aidlc-coordination/package-lock.json +3 -1
  61. package/dist/services/aidlc-coordination/package.json +9 -4
  62. package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +68 -2
  63. package/dist/services/aidlc-coordination/src/repository.mjs +66 -0
  64. package/dist/services/aidlc-coordination/src/server.mjs +9 -3
  65. package/dist/services/aidlc-coordination/test/fleet.test.mjs +21 -0
  66. package/dist/services/aidlc-coordination/test/server-health-auth.test.mjs +67 -0
  67. package/dist/src/adapters.d.ts +0 -3
  68. package/dist/src/adapters.js +3 -5
  69. package/dist/src/cli/argv.d.ts +12 -0
  70. package/dist/src/cli/argv.js +68 -0
  71. package/dist/src/cli/commands.d.ts +8 -0
  72. package/dist/src/cli/commands.js +159 -0
  73. package/dist/src/cli/loop-launcher.d.ts +47 -0
  74. package/dist/src/cli/loop-launcher.js +322 -0
  75. package/dist/src/cli/options.d.ts +25 -0
  76. package/dist/src/cli/options.js +32 -0
  77. package/dist/src/cli/prompt.d.ts +9 -0
  78. package/dist/src/cli/prompt.js +66 -0
  79. package/dist/src/cli.js +28 -191
  80. package/dist/src/gate.js +3 -2
  81. package/dist/src/installer.d.ts +14 -7
  82. package/dist/src/installer.js +42 -143
  83. package/dist/src/legacy.js +1 -0
  84. package/dist/src/managed-content.d.ts +7 -0
  85. package/dist/src/managed-content.js +15 -4
  86. package/dist/src/managed-planning.d.ts +65 -0
  87. package/dist/src/managed-planning.js +219 -0
  88. package/dist/src/mcp-setup.js +3 -1
  89. package/dist/src/model/config-contracts.d.ts +65 -0
  90. package/dist/src/model/config-contracts.js +1 -0
  91. package/dist/src/model/install-contracts.d.ts +29 -0
  92. package/dist/src/model/install-contracts.js +1 -0
  93. package/dist/src/model/lifecycle-enums.d.ts +13 -0
  94. package/dist/src/model/lifecycle-enums.js +1 -0
  95. package/dist/src/model/loop.d.ts +20 -0
  96. package/dist/src/model/loop.js +1 -0
  97. package/dist/src/model/manifest.d.ts +16 -0
  98. package/dist/src/model/manifest.js +1 -0
  99. package/dist/src/model/records.d.ts +142 -0
  100. package/dist/src/model/records.js +1 -0
  101. package/dist/src/model.d.ts +11 -264
  102. package/dist/src/model.js +1 -1
  103. package/dist/src/profiles.d.ts +2 -0
  104. package/dist/src/profiles.js +53 -9
  105. package/dist/src/state/archive-record.d.ts +9 -0
  106. package/dist/src/state/archive-record.js +26 -0
  107. package/dist/src/state/commands.d.ts +18 -0
  108. package/dist/src/state/commands.js +27 -0
  109. package/dist/src/state/domain.d.ts +9 -0
  110. package/dist/src/state/domain.js +9 -0
  111. package/dist/src/state/guards.d.ts +7 -0
  112. package/dist/src/state/guards.js +17 -0
  113. package/dist/src/state/io.d.ts +10 -0
  114. package/dist/src/state/io.js +35 -0
  115. package/dist/src/state/items.d.ts +3 -4
  116. package/dist/src/state/items.js +58 -57
  117. package/dist/src/state/lessons.js +14 -7
  118. package/dist/src/state/lifecycle.js +2 -2
  119. package/dist/src/state/lock.js +7 -6
  120. package/dist/src/state/memory.js +24 -12
  121. package/dist/src/state/{archive.js → migrate.js} +7 -7
  122. package/dist/src/state/partition.d.ts +15 -0
  123. package/dist/src/state/partition.js +90 -0
  124. package/dist/src/state/paths.d.ts +11 -0
  125. package/dist/src/state/paths.js +12 -0
  126. package/dist/src/state/render.js +9 -6
  127. package/dist/src/state/store.js +9 -15
  128. package/dist/src/state/transitions.js +9 -7
  129. package/dist/src/state/validate.js +55 -7
  130. package/dist/src/state.d.ts +16 -11
  131. package/dist/src/state.js +16 -11
  132. package/dist/src/task-ui.d.ts +33 -0
  133. package/dist/src/task-ui.js +33 -0
  134. package/dist/src/ui/assets.d.ts +14 -0
  135. package/dist/src/ui/assets.js +45 -0
  136. package/dist/src/ui/changes.d.ts +83 -0
  137. package/dist/src/ui/changes.js +498 -0
  138. package/dist/src/ui/containers.d.ts +71 -0
  139. package/dist/src/ui/containers.js +214 -0
  140. package/dist/src/ui/daemon.d.ts +75 -0
  141. package/dist/src/ui/daemon.js +232 -0
  142. package/dist/src/ui/data.d.ts +42 -0
  143. package/dist/src/ui/data.js +89 -0
  144. package/dist/src/ui/docs.d.ts +13 -0
  145. package/dist/src/ui/docs.js +39 -0
  146. package/dist/src/ui/files.d.ts +3 -0
  147. package/dist/src/ui/files.js +20 -0
  148. package/dist/src/ui/http.d.ts +14 -0
  149. package/dist/src/ui/http.js +80 -0
  150. package/dist/src/ui/live.d.ts +5 -0
  151. package/dist/src/ui/live.js +43 -0
  152. package/dist/src/ui/markdown.d.ts +2 -0
  153. package/dist/src/ui/markdown.js +127 -0
  154. package/dist/src/ui/options.d.ts +48 -0
  155. package/dist/src/ui/options.js +81 -0
  156. package/dist/src/ui/security.d.ts +34 -0
  157. package/dist/src/ui/security.js +38 -0
  158. package/dist/src/ui/server.d.ts +39 -0
  159. package/dist/src/ui/server.js +347 -0
  160. package/dist/src/ui/sse.d.ts +14 -0
  161. package/dist/src/ui/sse.js +281 -0
  162. package/dist/src/ui/views/board.d.ts +7 -0
  163. package/dist/src/ui/views/board.js +131 -0
  164. package/dist/src/ui/views/changes.d.ts +19 -0
  165. package/dist/src/ui/views/changes.js +154 -0
  166. package/dist/src/ui/views/components.d.ts +56 -0
  167. package/dist/src/ui/views/components.js +113 -0
  168. package/dist/src/ui/views/docs.d.ts +15 -0
  169. package/dist/src/ui/views/docs.js +64 -0
  170. package/dist/src/ui/views/layout.d.ts +44 -0
  171. package/dist/src/ui/views/layout.js +233 -0
  172. package/dist/src/ui/views/task.d.ts +25 -0
  173. package/dist/src/ui/views/task.js +320 -0
  174. package/dist/src/upgrade-board.js +10 -10
  175. package/dist/src/upgrade.d.ts +0 -1
  176. package/dist/src/upgrade.js +24 -85
  177. package/dist/src/workflow.d.ts +4 -0
  178. package/dist/src/workflow.js +36 -4
  179. package/docs/README.md +41 -22
  180. package/docs/architecture.md +101 -37
  181. package/docs/command-reference.md +289 -52
  182. package/docs/configuration.md +70 -30
  183. package/docs/coordination-service.md +91 -30
  184. package/docs/design-decisions.md +87 -0
  185. package/docs/development.md +124 -49
  186. package/docs/loop-local-run.md +228 -0
  187. package/docs/loop-runbook.md +214 -0
  188. package/docs/operating-workflow.md +55 -42
  189. package/docs/testing-and-release.md +72 -49
  190. package/docs/troubleshooting.md +221 -0
  191. package/package.json +4 -1
  192. package/dist/src/ledger.d.ts +0 -22
  193. package/dist/src/ledger.js +0 -119
  194. package/dist/src/state/shared.d.ts +0 -29
  195. package/dist/src/state/shared.js +0 -71
  196. package/dist/src/tracker.d.ts +0 -36
  197. package/dist/src/tracker.js +0 -44
  198. /package/dist/src/state/{archive.d.ts → migrate.d.ts} +0 -0
@@ -12,6 +12,8 @@ const phaseGate = (phase) => phase === "clarify" ? "G0_confirm" : phase === "pla
12
12
  const iso = (value) => typeof value === "string" && Number.isFinite(Date.parse(value)) && new Date(Date.parse(value)).toISOString() === value;
13
13
  const text = (value) => typeof value === "string" && Boolean(value.trim());
14
14
  const idPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
15
+ const commitPattern = /^[0-9a-f]{7,64}$/;
16
+ const commitFields = ["baseCommit", "finalCommit"];
15
17
  const digest = (content) => createHash("sha256").update(content).digest("hex");
16
18
  const archiveRecordPath = (id) => `.agents/data/state/archive/${id}.json`;
17
19
  const taskRecordContent = (task) => `${JSON.stringify(task, null, 2)}\n`;
@@ -74,6 +76,7 @@ const validateTask = (id, task) => {
74
76
  if (task.closure && (!text(task.closure.reason) || !text(task.closure.source) || !iso(task.closure.recordedAt))) throw new Error(`Invalid task closure: ${id}`);
75
77
  if ((task.status === "superseded") !== Boolean(task.successorTaskId) || (task.status === "closed" && task.successorTaskId)) throw new Error(`Invalid task successor: ${id}`);
76
78
  if (task.predecessorTaskId !== undefined && !text(task.predecessorTaskId)) throw new Error(`Invalid task predecessor: ${id}`);
79
+ for (const field of commitFields) if (task[field] !== undefined && !(typeof task[field] === "string" && commitPattern.test(task[field]))) throw new Error(`Invalid task ${field}: ${id}`);
77
80
  for (const path of Object.values(task.artifacts ?? {})) if (path) {
78
81
  const cleaned = normalize(path);
79
82
  if (isAbsolute(cleaned) || cleaned === ".." || cleaned.startsWith(`..${separator}`) || !cleaned.startsWith(`.agents${separator}data${separator}tasks${separator}`)) throw new Error(`Invalid artifact path for ${id}: ${path}`);
@@ -278,6 +281,14 @@ export const transitionTask = (state, id, target) => {
278
281
  task.phase = target; task.gate = phaseGate(target); task.status = target === "done" ? "done" : "active"; delete task.legacyG2Wait; task.updatedAt = new Date().toISOString(); return task;
279
282
  };
280
283
 
284
+ /** Record a git SHA on a task. Wrappers resolve the SHA (lib/repo-head.mjs); the core never runs git. */
285
+ export const recordTaskCommit = (task, field, sha) => {
286
+ if (!task || typeof task !== "object") throw new Error("recordTaskCommit requires a task");
287
+ if (!commitFields.includes(field)) throw new Error(`Unknown task commit field: ${field}`);
288
+ if (typeof sha !== "string" || !commitPattern.test(sha)) throw new Error(`Invalid ${field}: expected a lowercase hex git SHA (7-64 chars)`);
289
+ task[field] = sha; task.updatedAt = new Date().toISOString(); return task;
290
+ };
291
+
281
292
  export const readyTaskItems = (task) => {
282
293
  const byId = new Map(task.tasks.map((item) => [item.id, item]));
283
294
  if (byId.size !== task.tasks.length) throw new Error(`Duplicate execution item: ${task.id}`);
@@ -307,6 +318,60 @@ export const readyTaskItems = (task) => {
307
318
  return { ready, blocked };
308
319
  };
309
320
 
321
+ /* Parallel work partition: owned paths overlap by path segment (`a` overlaps `a/b`, not `ab`).
322
+ * Two open items can run concurrently unless one is a direct or transitive dependency of the other. */
323
+ const ownedPathKey = (path) => String(path).replace(/\/+$/, "");
324
+ export const pathOverlaps = (left, right) => { const a = ownedPathKey(left), b = ownedPathKey(right); return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`); };
325
+ export const pathsOverlap = (left = [], right = []) => left.some((a) => right.some((b) => pathOverlaps(a, b)));
326
+ const itemAncestors = (task) => {
327
+ const byId = new Map(task.tasks.map((item) => [item.id, item])); const memo = new Map();
328
+ 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; };
329
+ return (id) => walk(id);
330
+ };
331
+ export const concurrentItemPairs = (task) => {
332
+ const open = task.tasks.filter((item) => !["done", "deferred"].includes(item.status)); const ancestors = itemAncestors(task); const pairs = [];
333
+ 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]); }
334
+ return pairs;
335
+ };
336
+ /** Approve-plan partition check. `strict` (enabled loop) makes findings ERRORs; otherwise WARNs. */
337
+ export const partitionDiagnostics = (task, { strict = true } = {}) => {
338
+ const level = strict ? "ERROR" : "WARN"; const diagnostics = [];
339
+ const unscoped = task.tasks.filter((item) => !["done", "deferred"].includes(item.status) && !(Array.isArray(item.ownedPaths) && item.ownedPaths.length));
340
+ 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(", ")}` });
341
+ for (const [a, b] of concurrentItemPairs(task)) {
342
+ const shared = (a.ownedPaths ?? []).filter((path) => (b.ownedPaths ?? []).some((other) => pathOverlaps(path, other)));
343
+ 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` });
344
+ }
345
+ return diagnostics;
346
+ };
347
+ /** The partition check is strict when the loop is enabled: `loop.mode` local/fleet in the project config, or
348
+ * running inside a fleet-enabled coordination service. A missing config means the default `off`; an
349
+ * unreadable or corrupt config fails closed (strict). */
350
+ export const loopPartitionStrict = (root) => {
351
+ if (process.env.MCP_FLEET_ENABLED === "1") return true;
352
+ if (root === undefined) return false;
353
+ const path = join(resolve(root), ".agents", "config.json");
354
+ if (!existsSync(path)) return false;
355
+ try { const mode = JSON.parse(readFileSync(path, "utf8"))?.loop?.mode; return mode === "local" || mode === "fleet"; }
356
+ catch { return true; }
357
+ };
358
+ /** Pick the next dispatchable batch: dependency-ready items in plan order, at most `capacity`,
359
+ * skipping items that overlap each other, `busyPaths`, or are listed in `exclude`. Never throws for overlap. */
360
+ export const selectBuildBatch = (task, { capacity = Infinity, busyPaths = [], exclude = [] } = {}) => {
361
+ const byId = new Map(task.tasks.map((item) => [item.id, item])); const selected = []; const deferred = []; const taken = [];
362
+ for (const id of readyTaskItems(task).ready) {
363
+ const owned = byId.get(id)?.ownedPaths ?? [];
364
+ if (exclude.includes(id)) { deferred.push({ id, reason: "unresolved assignment" }); continue; }
365
+ if (!owned.length) { deferred.push({ id, reason: "no owned paths" }); continue; }
366
+ if (pathsOverlap(owned, busyPaths)) { deferred.push({ id, reason: "busy path held by an active assignment" }); continue; }
367
+ const clash = taken.find((entry) => pathsOverlap(owned, entry.owned));
368
+ if (clash) { deferred.push({ id, reason: `overlaps ${clash.id}` }); continue; }
369
+ if (selected.length >= capacity) { deferred.push({ id, reason: "capacity" }); continue; }
370
+ selected.push(id); taken.push({ id, owned });
371
+ }
372
+ return { selected, deferred };
373
+ };
374
+
310
375
  export const nextAction = (task, root) => {
311
376
  const rootSuffix = root === undefined ? "" : ` --root ${JSON.stringify(resolve(root))}`;
312
377
  const script = (name) => root === undefined ? `node .agents/aidlc/scripts/${name}` : `node ${JSON.stringify(join(resolve(root), ".agents/aidlc/scripts", name))}`;
@@ -357,13 +422,14 @@ export const searchLessons = (root, state, query, areas = [], limit = 5) => { co
357
422
 
358
423
  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
424
 
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`; };
425
+ 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(" · ")}` : ""; };
426
+ 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
427
  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
428
 
363
429
  const safeArtifactTarget = (root, path) => { assertNoSymlinkPath(root, path); return join(resolve(root), path); };
364
430
  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
431
  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; };
432
+ 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
433
  const gateTarget = { G0_confirm: "plan", G1_review: "build", G2_codereview: "wrap" };
368
434
  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
435
  export const formatDiagnostics = (diagnostics) => diagnostics.map((item) => `${item.level} ${item.code}: ${item.message}`).join("\n");
@@ -0,0 +1,75 @@
1
+ /**
2
+ * One-time, idempotent move of per-task runtime files from the layout of older
3
+ * aidlc-kit versions (`.agents/data/loop/assignments/<task>.json`,
4
+ * `.agents/data/loop/events/<task>.jsonl`, `.agents/data/loop/<task>.json`,
5
+ * `.agents/data/tasks/<task>/loop-intake.json`, `.../requirement.md`) to
6
+ * `.agents/data/tasks/<task>/runtime/` (see task-runtime.mjs).
7
+ *
8
+ * Rules: a file is renamed only when its destination does not exist (a
9
+ * destination that already exists is reported `skipped` and the source is left
10
+ * for inspection); nothing is ever deleted or overwritten; only regular files
11
+ * move (symlinks are reported `skipped`); a rename across filesystems falls
12
+ * back to copy + unlink. Leases, locks and guards stay where they are: locks
13
+ * are short mutexes that expire on their own, leases belong to
14
+ * `.agents/data/loop/`. Every writer calls `migrateTaskRuntime` for its own
15
+ * file before its first write, the local daemon calls `migrateAllTaskRuntime`
16
+ * once at start, and `loop-entry.mjs migrate-data` runs it on demand.
17
+ */
18
+ import { copyFileSync, lstatSync, readdirSync, renameSync, unlinkSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { ensureTaskRuntimeDir, isRegularFile, isTaskId, legacyTaskRuntimePath, MIGRATED_TASK_RUNTIME_FILES, taskRuntimePath } from "./task-runtime.mjs";
21
+
22
+ const exists = (path) => { try { lstatSync(path); return true; } catch (error) { if (error?.code === "ENOENT") return false; throw error; } };
23
+
24
+ const move = (from, to) => {
25
+ try { renameSync(from, to); }
26
+ catch (error) {
27
+ if (error?.code !== "EXDEV") throw error;
28
+ copyFileSync(from, to);
29
+ unlinkSync(from);
30
+ }
31
+ };
32
+
33
+ /** Move the legacy runtime files of one task. `names` limits the files
34
+ * considered (default: every migrated file). Returns one entry per legacy file
35
+ * found: `{ taskId, name, from, to, action: "moved" | "skipped" | "would-move", reason? }`. */
36
+ export function migrateTaskRuntime(root, taskId, { dryRun = false, names = MIGRATED_TASK_RUNTIME_FILES } = {}) {
37
+ const entries = [];
38
+ for (const name of names) {
39
+ let from, to;
40
+ try { from = legacyTaskRuntimePath(root, taskId, name); if (!exists(from)) continue; to = taskRuntimePath(root, taskId, name); }
41
+ catch (error) { entries.push({ taskId, name, from: from ?? null, to: to ?? null, action: "skipped", reason: error.message }); continue; }
42
+ const entry = { taskId, name, from, to };
43
+ if (!isRegularFile(from)) { entries.push({ ...entry, action: "skipped", reason: "source is not a regular file" }); continue; }
44
+ if (exists(to)) { entries.push({ ...entry, action: "skipped", reason: "destination already exists" }); continue; }
45
+ if (dryRun) { entries.push({ ...entry, action: "would-move" }); continue; }
46
+ try {
47
+ ensureTaskRuntimeDir(root, taskId);
48
+ move(from, to);
49
+ entries.push({ ...entry, action: "moved" });
50
+ } catch (error) { entries.push({ ...entry, action: "skipped", reason: error.message }); }
51
+ }
52
+ return entries;
53
+ }
54
+
55
+ const listDir = (dir) => { try { return readdirSync(dir).sort(); } catch (error) { if (error?.code === "ENOENT") return []; throw error; } };
56
+
57
+ /** Task ids that still have a legacy runtime file somewhere under `.agents/data`. */
58
+ export function legacyTaskRuntimeIds(root) {
59
+ const data = join(root, ".agents", "data");
60
+ const ids = new Set();
61
+ const add = (value) => { if (isTaskId(value)) ids.add(value); };
62
+ for (const name of listDir(join(data, "loop", "assignments"))) { const match = /^(.+)\.json$/.exec(name); if (match) add(match[1]); }
63
+ for (const name of listDir(join(data, "loop", "events"))) { const match = /^(.+)\.jsonl(?:\.1)?$/.exec(name); if (match) add(match[1]); }
64
+ // `<task>.json` run records only: `<task>.supervisor.json` (lead checkpoint), `<task>.gate-receipt.json`, `ui.json` and `cli.json` stay in the loop directory.
65
+ for (const name of listDir(join(data, "loop"))) { const match = /^(.+)\.json$/.exec(name); if (match && !["ui", "cli"].includes(match[1]) && !/\.(supervisor|gate-receipt)$/.test(match[1])) add(match[1]); }
66
+ for (const name of listDir(join(data, "tasks"))) { if (exists(join(data, "tasks", name, "loop-intake.json")) || exists(join(data, "tasks", name, "requirement.md"))) add(name); }
67
+ return [...ids].sort();
68
+ }
69
+
70
+ /** Migrate every task with legacy runtime files. Returns `{ dryRun, entries, moved, skipped }`. */
71
+ export function migrateAllTaskRuntime(root, { dryRun = false } = {}) {
72
+ const entries = [];
73
+ for (const taskId of legacyTaskRuntimeIds(root)) entries.push(...migrateTaskRuntime(root, taskId, { dryRun }));
74
+ return { dryRun, entries, moved: entries.filter((entry) => entry.action === "moved").length, skipped: entries.filter((entry) => entry.action === "skipped").length };
75
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Per-task operational data lives under the task's own directory:
3
+ *
4
+ * .agents/data/tasks/<taskId>/runtime/
5
+ * assignments.json local worker claims (`assignment-store.mjs`)
6
+ * assignments.lock short mutex around one assignments read-modify-write
7
+ * events.jsonl (+ .1) the task's event history (`loop-stream.mjs`)
8
+ * run.json the loop run record (`loop-runtime.mjs`)
9
+ * intake.json the loop intake record (`loop-intake.mjs`)
10
+ * requirement.md the intake requirement text
11
+ * workers/ container index and raw output (`loop-worker-log.mjs`)
12
+ *
13
+ * The task directory root keeps review artifacts only; `.agents/data/loop/`
14
+ * keeps daemon/process state (daemon lock and log, the global event sidecar,
15
+ * task leases). The same layout serves the no-loop workflow, the local loop,
16
+ * and fleet, so every reader finds task data without knowing which mode wrote
17
+ * it. `runtime/` is operational: never committed, handed off, or clean-checked
18
+ * (`isLoopOperationalPath` in loop-git.mjs).
19
+ *
20
+ * Older versions kept these files under `.agents/data/loop/` (assignments,
21
+ * events, `<task>.json` run record) and at the task directory root
22
+ * (`loop-intake.json`, `requirement.md`). `legacyTaskRuntimePath` names those
23
+ * locations so readers fall back to them and `task-runtime-migrate.mjs` moves
24
+ * them once.
25
+ */
26
+ import { lstatSync, mkdirSync } from "node:fs";
27
+ import { join, resolve } from "node:path";
28
+
29
+ const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
30
+
31
+ /** Runtime file names and where an older aidlc-kit kept each one (relative to `.agents/data`). */
32
+ export const TASK_RUNTIME_FILES = Object.freeze({
33
+ "assignments.json": (taskId) => ["loop", "assignments", `${taskId}.json`],
34
+ "assignments.lock": (taskId) => ["loop", "assignments", `${taskId}.lock`],
35
+ "events.jsonl": (taskId) => ["loop", "events", `${taskId}.jsonl`],
36
+ "events.jsonl.1": (taskId) => ["loop", "events", `${taskId}.jsonl.1`],
37
+ "run.json": (taskId) => ["loop", `${taskId}.json`],
38
+ "intake.json": (taskId) => ["tasks", taskId, "loop-intake.json"],
39
+ "requirement.md": (taskId) => ["tasks", taskId, "requirement.md"],
40
+ });
41
+
42
+ /** Runtime files that `task-runtime-migrate.mjs` moves (locks are short-lived and expire on their own). */
43
+ export const MIGRATED_TASK_RUNTIME_FILES = Object.freeze(["assignments.json", "events.jsonl", "events.jsonl.1", "run.json", "intake.json", "requirement.md"]);
44
+
45
+ export const isTaskId = (value) => typeof value === "string" && ID_PATTERN.test(value);
46
+ const safeTaskId = (taskId) => { if (!isTaskId(taskId)) throw new Error(`Invalid task id: ${taskId}`); return taskId; };
47
+ const safeName = (name) => { if (!Object.hasOwn(TASK_RUNTIME_FILES, name)) throw new Error(`Unknown task runtime file: ${name}`); return name; };
48
+
49
+ /** Throw when `path` exists and is a symlink (a planted link must not redirect reads or writes). */
50
+ const refuseSymlink = (path) => {
51
+ try { if (lstatSync(path).isSymbolicLink()) throw new Error(`Task runtime path crosses symlink: ${path}`); }
52
+ catch (error) { if (error?.code !== "ENOENT") throw error; }
53
+ };
54
+
55
+ /** Join `parts` under `root`, refusing any symlinked component. */
56
+ const safeJoin = (root, parts) => {
57
+ let path = resolve(root);
58
+ for (const part of parts) { path = join(path, part); refuseSymlink(path); }
59
+ return path;
60
+ };
61
+
62
+ /** `.agents/data/tasks/<taskId>/runtime`, every component checked for symlinks. */
63
+ export function taskRuntimeDir(root, taskId) {
64
+ return safeJoin(root, [".agents", "data", "tasks", safeTaskId(taskId), "runtime"]);
65
+ }
66
+
67
+ /** The current location of one runtime file (`name` from TASK_RUNTIME_FILES). */
68
+ export function taskRuntimePath(root, taskId, name) {
69
+ return safeJoin(root, [".agents", "data", "tasks", safeTaskId(taskId), "runtime", safeName(name)]);
70
+ }
71
+
72
+ /** Where an older aidlc-kit kept the same runtime file. */
73
+ export function legacyTaskRuntimePath(root, taskId, name) {
74
+ return safeJoin(root, [".agents", "data", ...TASK_RUNTIME_FILES[safeName(name)](safeTaskId(taskId))]);
75
+ }
76
+
77
+ /** True when `path` is a regular (non-symlink) file. */
78
+ export const isRegularFile = (path) => {
79
+ try { const stat = lstatSync(path); return stat.isFile() && !stat.isSymbolicLink(); } catch (error) { if (error?.code === "ENOENT") return false; throw error; }
80
+ };
81
+
82
+ /** Read location of a runtime file: the current path when it exists, else the
83
+ * legacy path when that exists, else the current path (for a clean ENOENT). */
84
+ export function resolveTaskRuntimeRead(root, taskId, name) {
85
+ const current = taskRuntimePath(root, taskId, name);
86
+ if (isRegularFile(current)) return current;
87
+ const legacy = legacyTaskRuntimePath(root, taskId, name);
88
+ return isRegularFile(legacy) ? legacy : current;
89
+ }
90
+
91
+ /** Create the runtime directory (0700, like the task directory an intake creates) and return it. */
92
+ export function ensureTaskRuntimeDir(root, taskId) {
93
+ const directory = taskRuntimeDir(root, taskId);
94
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
95
+ refuseSymlink(directory);
96
+ return directory;
97
+ }
@@ -1,90 +1,185 @@
1
1
  #!/usr/bin/env node
2
- import { execFileSync } from "node:child_process";
3
- import { randomUUID } from "node:crypto";
4
- import { existsSync, readFileSync } from "node:fs";
2
+ /**
3
+ * Loop entry point shared by `aidlc-kit loop`, the web dashboard, and operators.
4
+ *
5
+ * start start the singleton loop daemon for the configured mode (local or fleet)
6
+ * intake create a loop-owned task from a stdin requirement (≤ 8192 bytes) without running it
7
+ * status print the canonical task, loop record/checkpoint, and next action
8
+ * worker-image build and record a provider worker image
9
+ * env report the daemon environment: providers, env file, each variable's
10
+ * source (shell/file/derived) and missing values; secrets are never
11
+ * printed. `env [root] [--json]`; exits 1 when the daemon could not start.
12
+ * leases list loop leases, locks and claims: who holds what, until when,
13
+ * what it blocks, and how to clear it. Works with any loop mode.
14
+ * `leases [task-id] [root] [--json] [--clear-stale]`; --clear-stale
15
+ * removes only stale/old-corrupt lease, lock and guard files.
16
+ * migrate-data move per-task runtime files of older versions (loop assignments,
17
+ * events, run records, intake) under .agents/data/tasks/<id>/runtime/.
18
+ * `migrate-data [root] [--dry-run] [--json]`; idempotent, never
19
+ * overwrites or deletes (the daemon also runs it at start).
20
+ * Dashboard JSON actions (fleet authority; local dashboards read local state in-process):
21
+ * tasks | task <id> | gate <id> | resume-task <id> | approve <id> (source on stdin,
22
+ * reviewer credential only from AIDLC_FLEET_REVIEWER_TOKEN of this process) |
23
+ * feedback <id> (JSON `{ reason, source }` on stdin; keeps the task at its gate
24
+ * and never needs the reviewer credential)
25
+ *
26
+ * Positional arguments: [action, taskId, root, mode, provider, tag, title].
27
+ * Gate approval for local tasks stays on the trusted terminal (`loop-gate.mjs`).
28
+ */
29
+ import { readFileSync } from "node:fs";
5
30
  import { resolve, join } from "node:path";
6
31
  import { createInterface } from "node:readline/promises";
7
32
  import { stdin, stdout } from "node:process";
8
33
  import { requireLocalAuthority } from "./lib/authority.mjs";
9
34
  import { isLoopProvider } from "./lib/loop-drivers.mjs";
10
- import { prepareLoopIntake } from "./lib/loop-intake.mjs";
35
+ import { createLoopAuthority } from "./lib/loop-authority.mjs";
36
+ import { runLoopDaemon } from "./lib/loop-daemon.mjs";
37
+ import { streamLog } from "./lib/loop-stream.mjs";
11
38
  import * as localSupervisor from "./lib/loop-supervisor-run-local.mjs";
12
- import { loadTask } from "./lib/store.mjs";
39
+ import { buildWorkerImage, defaultWorkerImageTag, recordWorkerImage } from "./lib/loop-worker-image.mjs";
40
+ import { formatLeases, runLeases } from "./lib/loop-leases.mjs";
41
+ import { migrateAllTaskRuntime } from "./lib/task-runtime-migrate.mjs";
42
+ import { formatLoopEnvReport, loadLoopEnv, publicReport } from "./lib/loop-env.mjs";
43
+ import { callRemoteTool } from "./lib/remote-state-client.mjs";
13
44
 
14
- const [action, taskArg, rootArg, modeArg] = process.argv.slice(2);
45
+ const [action, taskArg, rootArg, modeArg, providerArg, tagArg, titleArg] = process.argv.slice(2);
15
46
  const root = resolve(rootArg || ".");
16
- const readRequirement = async () => {
17
- if (stdin.isTTY) { const rl = createInterface({ input: stdin, output: stdout }); try { return await rl.question("Requirement: "); } finally { rl.close(); } }
18
- const chunks = []; for await (const chunk of stdin) { chunks.push(chunk); if (chunks.reduce((n, c) => n + c.length, 0) > 8192) throw new Error("Requirement exceeds 8192 bytes"); } return Buffer.concat(chunks).toString("utf8");
47
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
48
+ const ACTIONS = ["start", "resume", "status", "intake", "tasks", "task", "gate", "resume-task", "approve", "feedback"];
49
+ const readStdin = async (prompt) => {
50
+ if (stdin.isTTY) { const rl = createInterface({ input: stdin, output: stdout }); try { return await rl.question(prompt); } finally { rl.close(); } }
51
+ const chunks = []; for await (const chunk of stdin) { chunks.push(chunk); if (chunks.reduce((n, c) => n + c.length, 0) > 8192) throw new Error("Input exceeds 8192 bytes"); } return Buffer.concat(chunks).toString("utf8");
19
52
  };
53
+ const requireTaskId = () => { if (!ID.test(taskArg ?? "")) throw new Error("Valid task ID required"); return taskArg; };
54
+ const print = (value) => console.log(JSON.stringify(value, null, 2));
55
+
56
+ /** `leases [task-id] [root] [--json] [--clear-stale]`; empty positionals are skipped. */
57
+ async function runLeasesAction(argv) {
58
+ const flagSet = new Set(argv.filter((value) => value.startsWith("--")));
59
+ for (const flag of flagSet) if (!["--json", "--clear-stale"].includes(flag)) throw new Error(`Unknown leases option: ${flag}`);
60
+ const [taskValue = "", rootValue = ""] = argv.filter((value) => !value.startsWith("--"));
61
+ if (taskValue && !ID.test(taskValue)) throw new Error("Valid task ID required");
62
+ const leasesRoot = resolve(rootValue || ".");
63
+ let config = null;
64
+ try { config = JSON.parse(readFileSync(join(leasesRoot, ".agents", "config.json"), "utf8")); } catch { config = null; }
65
+ const env = process.env;
66
+ const fleetReader = config?.loop?.mode === "fleet" && env.AIDLC_FLEET_URL && env.AIDLC_FLEET_TOKEN && env.AIDLC_FLEET_WORKSPACE
67
+ ? () => callRemoteTool({ url: env.AIDLC_FLEET_URL, token: env.AIDLC_FLEET_TOKEN }, "fleet_get", { workspace: env.AIDLC_FLEET_WORKSPACE })
68
+ : undefined;
69
+ const report = await runLeases({ root: leasesRoot, taskId: taskValue || undefined, clearStale: flagSet.has("--clear-stale"), fleetReader });
70
+ if (config?.loop?.mode === "fleet" && !fleetReader) report.fleet = { revision: null, entries: [], error: "lead credential not set (AIDLC_FLEET_URL, AIDLC_FLEET_TOKEN, AIDLC_FLEET_WORKSPACE); fleet leases not shown" };
71
+ if (flagSet.has("--json")) print(report);
72
+ else console.log(formatLeases(report));
73
+ }
74
+
75
+ /** `migrate-data [root] [--dry-run] [--json]`: move pre-runtime per-task files under `runtime/`. */
76
+ function runMigrateDataAction(argv) {
77
+ const flagSet = new Set(argv.filter((value) => value.startsWith("--")));
78
+ for (const flag of flagSet) if (!["--json", "--dry-run"].includes(flag)) throw new Error(`Unknown migrate-data option: ${flag}`);
79
+ const [rootValue = ""] = argv.filter((value) => !value.startsWith("--"));
80
+ const migrateRoot = resolve(rootValue || ".");
81
+ const result = migrateAllTaskRuntime(migrateRoot, { dryRun: flagSet.has("--dry-run") });
82
+ if (flagSet.has("--json")) { print({ schemaVersion: 1, root: migrateRoot, ...result }); return; }
83
+ const verb = result.dryRun ? "would move" : "moved";
84
+ if (!result.entries.length) { console.log(`Nothing to migrate: every task runtime file is already under .agents/data/tasks/<id>/runtime/ (${migrateRoot})`); return; }
85
+ for (const entry of result.entries) console.log(`${entry.action === "skipped" ? "skipped" : verb}: ${entry.from} -> ${entry.to}${entry.reason ? ` (${entry.reason})` : ""}`);
86
+ console.log(`${result.dryRun ? "Would move" : "Moved"} ${result.entries.filter((entry) => entry.action !== "skipped").length} file(s), skipped ${result.skipped}.`);
87
+ }
88
+
89
+ /** `env [root] [--json]`: the local daemon environment report (no secret values). */
90
+ function runEnvAction(argv) {
91
+ const flagSet = new Set(argv.filter((value) => value.startsWith("--")));
92
+ for (const flag of flagSet) if (flag !== "--json") throw new Error(`Unknown env option: ${flag}`);
93
+ const [rootValue = ""] = argv.filter((value) => !value.startsWith("--"));
94
+ const envRoot = resolve(rootValue || ".");
95
+ let config = null;
96
+ try { config = JSON.parse(readFileSync(join(envRoot, ".agents", "config.json"), "utf8")); } catch { config = null; }
97
+ const report = publicReport(loadLoopEnv({ root: envRoot, config }));
98
+ if (flagSet.has("--json")) print(report); else console.log(formatLoopEnvReport(report));
99
+ if (!report.ok) process.exitCode = 1;
100
+ }
101
+
20
102
  try {
21
- if (!["start", "resume", "status"].includes(action)) throw new Error("Expected loop start, resume, or status");
22
- const config = JSON.parse(readFileSync(join(root, ".agents", "config.json"), "utf8"));
23
- const mode = modeArg || config.loop?.mode || "off";
24
- if (!["local", "fleet"].includes(mode)) throw new Error("Loop mode is off; configure an enabled loop mode first");
25
- if (mode !== config.loop?.mode) throw new Error("Requested mode does not match configured authority");
26
- const agent = config.loop?.agents?.lead;
27
- const workers = config.loop?.agents?.workers ?? {};
28
- if (!isLoopProvider(agent) || !Object.keys(workers).length || Object.entries(workers).some(([, value]) => !isLoopProvider(value))) throw new Error("Loop requires a configured lead and worker providers");
29
- const taskId = taskArg || (action === "start" ? `loop-${randomUUID()}` : "");
30
- if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(taskId)) throw new Error("Valid task ID required");
31
- if (mode === "fleet") {
32
- const { callRemoteTool } = await import("./lib/remote-state-client.mjs");
33
- const { createFleetRemoteRepository } = await import("./lib/loop-fleet-remote.mjs");
34
- const { runFleetCli } = await import("./lib/loop-fleet-cli.mjs");
35
- const { readIntegrationCommit } = await import("./remote-state.mjs");
36
- const requiredEnv = (name) => { const value = process.env[name]; if (typeof value !== "string" || !value.trim()) throw new Error(`Fleet loop requires ${name} for the configured remote authority`); return value; };
37
- const url = requiredEnv("AIDLC_FLEET_URL"), workspace = requiredEnv("AIDLC_FLEET_WORKSPACE"), token = requiredEnv("AIDLC_FLEET_TOKEN");
38
- const integrationBranch = requiredEnv("AIDLC_FLEET_INTEGRATION_BRANCH");
39
- if (process.env.AIDLC_FLEET_PROJECT_ROOT && resolve(process.env.AIDLC_FLEET_PROJECT_ROOT) !== root) throw new Error("AIDLC_FLEET_PROJECT_ROOT does not match the loop root; refusing to run");
40
- if (process.env.AIDLC_FLEET_WORKER_IDS && process.env.AIDLC_FLEET_WORKER_IDS.split(",").map((value) => value.trim()).filter(Boolean).join(",") !== Object.keys(workers).join(",")) throw new Error("AIDLC_FLEET_WORKER_IDS does not match the configured loop workers");
41
- if (process.env.AIDLC_FLEET_WORKER_PROVIDERS && process.env.AIDLC_FLEET_WORKER_PROVIDERS !== JSON.stringify(workers)) throw new Error("AIDLC_FLEET_WORKER_PROVIDERS does not match the configured loop workers");
42
- const env = { ...process.env, AIDLC_FLEET_PROJECT_ROOT: root, AIDLC_FLEET_WORKER_IDS: Object.keys(workers).join(","), AIDLC_FLEET_WORKER_PROVIDERS: JSON.stringify(workers), AIDLC_FLEET_AGENT: agent };
43
- const remoteState = join(root, ".agents/aidlc/scripts/remote-state.mjs");
44
- if (!existsSync(remoteState)) throw new Error(`Remote lifecycle CLI is missing: ${remoteState}`);
45
- const callTool = ({ url: serviceUrl, token: serviceToken, name, args }) => callRemoteTool({ url: serviceUrl, token: serviceToken }, name, args);
46
- const repository = createFleetRemoteRepository({ callTool, url, token, workspace, root });
47
- if (action === "status") {
48
- let snapshot = null;
49
- try { snapshot = await repository.readTask({ taskId }); } catch (error) { if (!String(error?.message ?? error).includes("task not found")) throw error; }
50
- const checkpoint = await repository.readGateCheckpoint({ taskId });
51
- console.log(JSON.stringify({ taskId, authority: "fleet", task: snapshot?.task ?? null, nextAction: snapshot?.nextAction ?? null, checkpoint }, null, 2));
103
+ if (action === "env") {
104
+ runEnvAction(process.argv.slice(3));
105
+ } else if (action === "leases") {
106
+ await runLeasesAction(process.argv.slice(3));
107
+ } else if (action === "migrate-data") {
108
+ runMigrateDataAction(process.argv.slice(3));
109
+ } else if (action === "worker-image") {
110
+ // Build (or rebuild) a worker image without requiring loop configuration.
111
+ const provider = providerArg || "opencode";
112
+ if (!isLoopProvider(provider)) throw new Error("Expected --provider codex, claude, or opencode");
113
+ const image = tagArg || defaultWorkerImageTag(provider);
114
+ buildWorkerImage({ root, provider, image });
115
+ recordWorkerImage({ root, provider, image });
116
+ console.log(`Built worker image: ${image}`);
117
+ console.log("Recorded worker image; the loop supervisor will use it automatically.");
118
+ } else {
119
+ if (!ACTIONS.includes(action)) throw new Error("Expected loop start, status, intake, env, leases, migrate-data, or worker-image");
120
+ const config = JSON.parse(readFileSync(join(root, ".agents", "config.json"), "utf8"));
121
+ const mode = modeArg || config.loop?.mode || "off";
122
+ if (!["local", "fleet"].includes(mode)) throw new Error("Loop mode is off; configure an enabled loop mode first");
123
+ if (mode !== config.loop?.mode) throw new Error("Requested mode does not match configured authority");
124
+ const agent = config.loop?.agents?.lead;
125
+ const workers = config.loop?.agents?.workers ?? {};
126
+ if (!isLoopProvider(agent) || !Object.keys(workers).length || Object.entries(workers).some(([, value]) => !isLoopProvider(value))) throw new Error("Loop requires a configured lead and worker providers");
127
+ if (action === "resume") throw new Error("loop resume is removed; the daemon auto-resumes actionable tasks — run 'loop start', and re-activate a paused task from the dashboard (resume) or with the task status command");
128
+ if (mode === "local") requireLocalAuthority();
129
+ if (mode === "local" && action === "start") {
130
+ // Zero-config start: shell > AIDLC env file > values derived from the providers' own config.
131
+ const loaded = loadLoopEnv({ root, config });
132
+ for (const problem of loaded.problems) process.stderr.write(`loop: ${problem.level === "error" ? "error" : "warning"}: ${problem.message}\n`);
133
+ if (!loaded.ok) throw new Error("environment is incomplete; fix the errors above (check with: aidlc-kit loop env)");
134
+ Object.assign(process.env, loaded.applied);
135
+ }
136
+ if (mode === "local" && action === "status") {
137
+ const supervisor = await localSupervisor.createIsolatedLocalSupervisor({ root, taskId: requireTaskId(), agent, workers, workerImages: config.loop?.workerImage, onEvent: streamLog });
138
+ print(await supervisor.status());
52
139
  } else {
53
- if (action === "resume") {
54
- const recordPath = join(root, ".agents/data/tasks", taskId, "loop-intake.json");
55
- if (!existsSync(recordPath)) throw new Error("Loop record missing; use start");
56
- const record = JSON.parse(readFileSync(recordPath, "utf8"));
57
- if (record.taskId !== taskId || record.authority !== "fleet") throw new Error("Loop record authority mismatch; use the recorded authority and task");
58
- }
59
- await callRemoteTool({ url, token }, "fleet_get", { workspace });
60
- await readIntegrationCommit({ projectRoot: root, integrationBranch, gitRemote: env.AIDLC_FLEET_GIT_REMOTE ?? "origin" });
140
+ const authority = await createLoopAuthority({ root, config });
61
141
  if (action === "start") {
62
- const requirement = await readRequirement();
63
- const readTask = async (id) => { try { return (await callRemoteTool({ url, token }, "task_get", { workspace, taskId: id, clientProjectRoot: root })).task; } catch { return null; } };
64
- const current = await callRemoteTool({ url, token }, "state_get", { workspace });
65
- const actionable = Object.values(current?.state?.tasks ?? {}).filter((task) => task.id !== taskId && task.status === "active" && !task.handoff);
66
- if (actionable.length) throw new Error(`Task create is blocked while other work is actionable: ${actionable.map((task) => task.id).join(", ")}`);
67
- await prepareLoopIntake({ root, taskId, requirement, authority: "fleet", readTask, createTask: async ({ taskId: id, title }) => {
68
- const claim = await repository.claimLead({ taskId: id, leaseMs: 60000 });
69
- try {
70
- execFileSync(process.execPath, [remoteState, "task", "create", id, "--title", title], { cwd: root, encoding: "utf8", env: { ...env, AIDLC_FLEET_LEAD_EPOCH: String(claim.epoch), AIDLC_FLEET_LEAD_OWNER_TOKEN: repository.leadOwnerToken() } });
71
- } finally { await repository.releaseLead({ taskId: id, epoch: claim.epoch }); }
72
- } });
142
+ // Two-stage shutdown: 1st signal stops after the current turn, 2nd cancels
143
+ // the in-flight agent turn, 3rd exits immediately. Runs end as `stopped`
144
+ // (tasks are not paused) and resume on the next daemon start.
145
+ const stop = new AbortController(), cancel = new AbortController();
146
+ let received = 0;
147
+ const onSignal = () => {
148
+ received += 1;
149
+ if (received === 1) { process.stderr.write("\nloop: stopping after the current turn — send the signal again (Ctrl-C) to cancel it\n"); stop.abort(); }
150
+ else if (received === 2) { process.stderr.write("\nloop: cancelling the in-flight turn — send the signal again to exit immediately\n"); cancel.abort(); }
151
+ else process.exit(130);
152
+ };
153
+ for (const name of ["SIGINT", "SIGTERM"]) process.on(name, onSignal);
154
+ if (typeof authority.preflight === "function") await authority.preflight();
155
+ print(await runLoopDaemon({ root, authority, pollMs: config.loop?.pollMs, signal: stop.signal, cancelSignal: cancel.signal, onEvent: streamLog }));
156
+ } else if (action === "intake") {
157
+ print(await authority.intake({ requirement: await readStdin("Requirement: "), taskId: taskArg || undefined, title: titleArg || undefined }));
158
+ } else if (action === "resume-task") {
159
+ print(await authority.resumeTask(requireTaskId()));
160
+ } else if (mode !== "fleet") {
161
+ throw new Error(`loop ${action} is a fleet dashboard action; local dashboards read canonical local state directly`);
162
+ } else if (action === "status") {
163
+ const taskId = requireTaskId();
164
+ const snapshot = await authority.getTask(taskId);
165
+ print({ taskId, authority: "fleet", task: snapshot.task, nextAction: snapshot.nextAction, checkpoint: await authority.repository.readGateCheckpoint({ taskId }) });
166
+ } else if (action === "tasks") {
167
+ print({ ok: true, authority: "fleet", tasks: await authority.listTasks() });
168
+ } else if (action === "task") {
169
+ print({ ok: true, authority: "fleet", ...(await authority.getTask(requireTaskId())) });
170
+ } else if (action === "gate") {
171
+ print({ ok: true, authority: "fleet", ...(await authority.gate(requireTaskId())) });
172
+ } else if (action === "approve") {
173
+ const taskId = requireTaskId();
174
+ print(await authority.approve({ taskId, source: (await readStdin("Approval source: ")).trim(), reviewerToken: process.env.AIDLC_FLEET_REVIEWER_TOKEN }));
175
+ } else if (action === "feedback") {
176
+ // Human guidance that keeps the task at its current gate; no reviewer credential is involved.
177
+ const taskId = requireTaskId();
178
+ let input; try { input = JSON.parse(await readStdin("Feedback JSON: ")); } catch { throw new Error("Feedback must be a JSON object { reason, source }"); }
179
+ const reason = typeof input?.reason === "string" ? input.reason.trim() : "", source = typeof input?.source === "string" ? input.source.trim() : "";
180
+ if (!input || typeof input !== "object" || Array.isArray(input) || !reason || !source) throw new Error("Feedback must be a JSON object { reason, source } with non-empty strings");
181
+ print(await authority.feedback({ taskId, reason, source }));
73
182
  }
74
- await runFleetCli(["supervisor", taskId], { callTool, env, out: (line) => console.log(line) });
75
183
  }
76
- } else {
77
- requireLocalAuthority();
78
- // The factory contract guarantees that the dispatcher is actually wired into
79
- // the supervisor, not merely present in the package. No host fallback.
80
- if (typeof localSupervisor.createIsolatedLocalSupervisor !== "function") throw new Error("Isolated local worker dispatcher is not integrated; loop execution is unavailable");
81
- const supervisor = await localSupervisor.createIsolatedLocalSupervisor({ root, taskId, agent, workers: config.loop.agents.workers });
82
- if (action === "status") {
83
- console.log(JSON.stringify(await supervisor.status(), null, 2));
84
- } else if (action === "start") {
85
- const requirement = await readRequirement();
86
- await prepareLoopIntake({ root, taskId, requirement, authority: "local", readTask: async (id) => loadTask(root, id), createTask: async ({ taskId: id, title }) => { execFileSync(process.execPath, [join(root, ".agents/aidlc/scripts/state.mjs"), "task", "create", id, "--title", title, "--root", root], { stdio: ["ignore", "pipe", "pipe"] }); } });
87
- console.log(JSON.stringify(await supervisor.start(), null, 2));
88
- } else console.log(JSON.stringify(await supervisor.resume(), null, 2));
89
184
  }
90
185
  } catch (error) { console.error(`loop: ${error.message}`); process.exitCode = 1; }