@felixdotgo/aidlc-kit 0.2.0 → 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 (109) hide show
  1. package/DEVELOPMENT.md +13 -26
  2. package/README.md +35 -39
  3. package/dist/assets/.agents/aidlc/orchestrator.md +1 -1
  4. package/dist/assets/.agents/aidlc/schemas/state.schema.json +2 -0
  5. package/dist/assets/.agents/aidlc/scripts/lib/assignment-store.mjs +94 -48
  6. package/dist/assets/.agents/aidlc/scripts/lib/context-runtime.mjs +5 -4
  7. package/dist/assets/.agents/aidlc/scripts/lib/loop-authority.mjs +21 -5
  8. package/dist/assets/.agents/aidlc/scripts/lib/loop-daemon.mjs +52 -78
  9. package/dist/assets/.agents/aidlc/scripts/lib/loop-drivers.mjs +88 -14
  10. package/dist/assets/.agents/aidlc/scripts/lib/loop-env.mjs +279 -0
  11. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-cli.mjs +20 -5
  12. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-remote.mjs +10 -1
  13. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet-supervisor.mjs +2 -1
  14. package/dist/assets/.agents/aidlc/scripts/lib/loop-fleet.mjs +3 -2
  15. package/dist/assets/.agents/aidlc/scripts/lib/loop-git.mjs +9 -3
  16. package/dist/assets/.agents/aidlc/scripts/lib/loop-holder.mjs +376 -0
  17. package/dist/assets/.agents/aidlc/scripts/lib/loop-intake.mjs +11 -3
  18. package/dist/assets/.agents/aidlc/scripts/lib/loop-lease.mjs +67 -61
  19. package/dist/assets/.agents/aidlc/scripts/lib/loop-leases.mjs +268 -0
  20. package/dist/assets/.agents/aidlc/scripts/lib/loop-local-workers.mjs +78 -17
  21. package/dist/assets/.agents/aidlc/scripts/lib/loop-runtime.mjs +42 -10
  22. package/dist/assets/.agents/aidlc/scripts/lib/loop-stream.mjs +66 -9
  23. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-local.mjs +16 -56
  24. package/dist/assets/.agents/aidlc/scripts/lib/loop-supervisor-run-local.mjs +12 -6
  25. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-docker.mjs +14 -2
  26. package/dist/assets/.agents/aidlc/scripts/lib/loop-worker-log.mjs +158 -0
  27. package/dist/assets/.agents/aidlc/scripts/lib/repo-head.mjs +22 -0
  28. package/dist/assets/.agents/aidlc/scripts/lib/store.mjs +11 -0
  29. package/dist/assets/.agents/aidlc/scripts/lib/task-runtime-migrate.mjs +75 -0
  30. package/dist/assets/.agents/aidlc/scripts/lib/task-runtime.mjs +97 -0
  31. package/dist/assets/.agents/aidlc/scripts/loop-entry.mjs +87 -4
  32. package/dist/assets/.agents/aidlc/scripts/loop.cmd +101 -0
  33. package/dist/assets/.agents/aidlc/scripts/loop.sh +71 -0
  34. package/dist/assets/.agents/aidlc/scripts/remote-state.mjs +10 -4
  35. package/dist/assets/.agents/aidlc/scripts/state.mjs +16 -8
  36. package/dist/assets/ui/changes.js +83 -0
  37. package/dist/assets/ui/common.js +664 -0
  38. package/dist/assets/ui/dashboard.css +281 -59
  39. package/dist/assets/ui/dashboard.js +77 -0
  40. package/dist/assets/ui/detail.js +159 -0
  41. package/dist/assets/ui/live-log.js +332 -0
  42. package/dist/services/aidlc-coordination/package.json +1 -1
  43. package/dist/services/aidlc-coordination/src/lifecycle-core.mjs +11 -0
  44. package/dist/src/cli/argv.js +10 -4
  45. package/dist/src/cli/commands.js +52 -22
  46. package/dist/src/cli/loop-launcher.d.ts +47 -0
  47. package/dist/src/cli/loop-launcher.js +322 -0
  48. package/dist/src/cli.js +3 -0
  49. package/dist/src/installer.js +4 -2
  50. package/dist/src/model/records.d.ts +4 -0
  51. package/dist/src/state/validate.js +5 -0
  52. package/dist/src/task-ui.d.ts +31 -52
  53. package/dist/src/task-ui.js +25 -1562
  54. package/dist/src/ui/assets.d.ts +14 -0
  55. package/dist/src/ui/assets.js +45 -0
  56. package/dist/src/ui/changes.d.ts +83 -0
  57. package/dist/src/ui/changes.js +498 -0
  58. package/dist/src/ui/containers.d.ts +71 -0
  59. package/dist/src/ui/containers.js +214 -0
  60. package/dist/src/ui/daemon.d.ts +75 -0
  61. package/dist/src/ui/daemon.js +232 -0
  62. package/dist/src/ui/data.d.ts +42 -0
  63. package/dist/src/ui/data.js +89 -0
  64. package/dist/src/ui/docs.d.ts +13 -0
  65. package/dist/src/ui/docs.js +39 -0
  66. package/dist/src/ui/files.d.ts +3 -0
  67. package/dist/src/ui/files.js +20 -0
  68. package/dist/src/ui/http.d.ts +14 -0
  69. package/dist/src/ui/http.js +80 -0
  70. package/dist/src/ui/live.d.ts +5 -0
  71. package/dist/src/ui/live.js +43 -0
  72. package/dist/src/ui/markdown.d.ts +2 -0
  73. package/dist/src/ui/markdown.js +127 -0
  74. package/dist/src/ui/options.d.ts +48 -0
  75. package/dist/src/ui/options.js +81 -0
  76. package/dist/src/ui/security.d.ts +34 -0
  77. package/dist/src/ui/security.js +38 -0
  78. package/dist/src/ui/server.d.ts +39 -0
  79. package/dist/src/ui/server.js +347 -0
  80. package/dist/src/ui/sse.d.ts +14 -0
  81. package/dist/src/ui/sse.js +281 -0
  82. package/dist/src/ui/views/board.d.ts +7 -0
  83. package/dist/src/ui/views/board.js +131 -0
  84. package/dist/src/ui/views/changes.d.ts +19 -0
  85. package/dist/src/ui/views/changes.js +154 -0
  86. package/dist/src/ui/views/components.d.ts +56 -0
  87. package/dist/src/ui/views/components.js +113 -0
  88. package/dist/src/ui/views/docs.d.ts +15 -0
  89. package/dist/src/ui/views/docs.js +64 -0
  90. package/dist/src/ui/views/layout.d.ts +44 -0
  91. package/dist/src/ui/views/layout.js +233 -0
  92. package/dist/src/ui/views/task.d.ts +25 -0
  93. package/dist/src/ui/views/task.js +320 -0
  94. package/dist/src/upgrade.js +5 -2
  95. package/dist/src/workflow.d.ts +4 -0
  96. package/dist/src/workflow.js +33 -1
  97. package/docs/README.md +48 -33
  98. package/docs/architecture.md +101 -38
  99. package/docs/command-reference.md +269 -93
  100. package/docs/configuration.md +62 -42
  101. package/docs/coordination-service.md +85 -36
  102. package/docs/design-decisions.md +35 -31
  103. package/docs/development.md +111 -61
  104. package/docs/loop-local-run.md +135 -402
  105. package/docs/loop-runbook.md +84 -105
  106. package/docs/operating-workflow.md +55 -42
  107. package/docs/testing-and-release.md +70 -51
  108. package/docs/troubleshooting.md +165 -69
  109. package/package.json +1 -1
@@ -1,27 +1,84 @@
1
- import { appendFileSync, mkdirSync, renameSync, statSync } from "node:fs";
2
- import { dirname } from "node:path";
1
+ import { appendFileSync, lstatSync, mkdirSync, renameSync, statSync } from "node:fs";
2
+ import { basename, dirname, resolve } from "node:path";
3
+ import { isTaskId, taskRuntimePath } from "./task-runtime.mjs";
4
+ import { migrateTaskRuntime } from "./task-runtime-migrate.mjs";
3
5
 
4
6
  /** The event-log sidecar rotates to `<path>.1` (one generation kept) once it
5
7
  * reaches this size, so a 24/7 daemon cannot grow it without bound. */
6
8
  export const EVENT_LOG_MAX_BYTES = 5 * 1024 * 1024;
9
+ /** Per-task history files (`.agents/data/tasks/<taskId>/runtime/events.jsonl`)
10
+ * rotate the same way at a smaller bound, since one exists for every task. */
11
+ export const TASK_EVENT_LOG_MAX_BYTES = 1024 * 1024;
7
12
 
8
13
  let preparedDirectory;
14
+ const migratedTasks = new Set();
9
15
 
10
- /** Append one structured event to the optional UI event-log sidecar (JSONL).
11
- * Each record gets an `at` timestamp; the directory is created on demand. */
16
+ const rotate = (path, limit) => {
17
+ try { if (statSync(path).size >= limit) renameSync(path, `${path}.1`); }
18
+ catch (error) { if (error?.code !== "ENOENT") throw error; }
19
+ };
20
+
21
+ /** True when `path` is missing or a real (non-symlink) entry of the expected kind. */
22
+ const safeEntry = (path, directory) => {
23
+ try {
24
+ const stat = lstatSync(path);
25
+ return !stat.isSymbolicLink() && (directory ? stat.isDirectory() : stat.isFile());
26
+ } catch (error) { if (error?.code === "ENOENT") return true; throw error; }
27
+ };
28
+
29
+ /** Path of the per-task history file (`.agents/data/tasks/<taskId>/runtime/events.jsonl`),
30
+ * or null when `root` is not a string or the id is not a canonical task id. */
31
+ export const taskEventLogPath = (root, taskId) => {
32
+ if (typeof root !== "string" || !root || !isTaskId(taskId)) return null;
33
+ try { return taskRuntimePath(root, taskId, "events.jsonl"); } catch { return null; }
34
+ };
35
+
36
+ /** The project root a global event sidecar belongs to: the sidecar is always
37
+ * `<root>/.agents/data/loop/<file>`; any other location has no task history. */
38
+ export const loopRootFromEventLog = (logPath) => {
39
+ if (typeof logPath !== "string" || !logPath) return null;
40
+ const loopDir = dirname(resolve(logPath));
41
+ const data = dirname(loopDir), agents = dirname(data);
42
+ return basename(loopDir) === "loop" && basename(data) === "data" && basename(agents) === ".agents" ? dirname(agents) : null;
43
+ };
44
+
45
+ /** Append one line to a task's history. Refuses symlinked (or non-regular)
46
+ * directories and files so a planted link cannot redirect the write. A
47
+ * pre-runtime `.agents/data/loop/events/<taskId>.jsonl` is moved under
48
+ * `runtime/` before the first append of this process. */
49
+ function appendTaskEventLog(root, taskId, line) {
50
+ const path = taskEventLogPath(root, taskId);
51
+ if (!path) return;
52
+ if (!migratedTasks.has(path)) { migratedTasks.add(path); migrateTaskRuntime(root, taskId, { names: ["events.jsonl", "events.jsonl.1"] }); }
53
+ const directory = dirname(path);
54
+ if (!safeEntry(dirname(directory), true) || !safeEntry(directory, true)) return;
55
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
56
+ if (!safeEntry(directory, true) || !safeEntry(path, false) || !safeEntry(`${path}.1`, false)) return;
57
+ rotate(path, TASK_EVENT_LOG_MAX_BYTES);
58
+ appendFileSync(path, line);
59
+ }
60
+
61
+ /** Append one structured event to the optional UI event-log sidecar (JSONL)
62
+ * and, when it carries a valid `taskId`, to that task's history file. Each
63
+ * record gets an `at` timestamp; directories are created on demand. */
12
64
  function appendEventLog(event) {
13
65
  const path = process.env.AIDLC_LOOP_EVENT_LOG;
14
66
  if (!path) return;
67
+ let line;
15
68
  try {
16
69
  const directory = dirname(path);
17
70
  if (preparedDirectory !== directory) { mkdirSync(directory, { recursive: true }); preparedDirectory = directory; }
18
- try { if (statSync(path).size >= EVENT_LOG_MAX_BYTES) renameSync(path, `${path}.1`); }
19
- catch (error) { if (error?.code !== "ENOENT") throw error; }
71
+ rotate(path, EVENT_LOG_MAX_BYTES);
20
72
  const record = event && typeof event === "object" && !Array.isArray(event) && !("at" in event) ? { at: new Date().toISOString(), ...event } : event;
21
- appendFileSync(path, `${JSON.stringify(record)}\n`);
73
+ line = `${JSON.stringify(record)}\n`;
74
+ appendFileSync(path, line);
22
75
  } catch { /* best-effort: never break the loop on a log write failure */ }
76
+ try { if (line && typeof event?.taskId === "string") appendTaskEventLog(loopRootFromEventLog(path), event.taskId, line); }
77
+ catch { /* best-effort, independently of the global log */ }
23
78
  }
24
79
 
80
+ const withSummary = (event, separator) => typeof event.summary === "string" && event.summary ? `${separator}${event.summary}` : "";
81
+
25
82
  /** Render foreground loop progress to stderr without polluting stdout JSON. */
26
83
  export function streamLog(event) {
27
84
  appendEventLog(event);
@@ -37,10 +94,10 @@ export function streamLog(event) {
37
94
  process.stderr.write(`${event.text ?? ""}\n`);
38
95
  break;
39
96
  case "agent_tool":
40
- process.stderr.write(` › tool: ${event.tool ?? "?"}\n`);
97
+ process.stderr.write(` › tool: ${event.tool ?? "?"}${withSummary(event, " · ")}\n`);
41
98
  break;
42
99
  case "lifecycle_command":
43
- process.stderr.write(` › command ${event.status ?? "?"} (exit ${event.exitCode ?? "?"})\n`);
100
+ process.stderr.write(` › command ${event.status ?? "?"} (exit ${event.exitCode ?? "?"})${withSummary(event, ": ")}\n`);
44
101
  break;
45
102
  case "daemon_turn_error":
46
103
  process.stderr.write(` ! ${event.taskId ?? "?"}: ${event.message ?? "error"} (retry in ${Math.round(Number(event.retryInMs ?? 0) / 1000)}s)\n`);
@@ -1,61 +1,28 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
- import { assertLoopPath } from "./loop-lease.mjs";
4
+ import { acquireTaskLease, assertLoopPath } from "./loop-lease.mjs";
5
5
  import { validateRoot } from "./loop-runtime.mjs";
6
6
  import { acquireStateLock, loadTask, nextAction } from "./store.mjs";
7
7
  import { recoverLeadArtifact } from "./loop-lead-artifacts.mjs";
8
8
 
9
9
  const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
10
10
  const checkAuthority = (authority) => { if (authority !== "local") throw new Error("local supervisor adapter requires local authority"); };
11
- const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (error) { return error?.code !== "ESRCH"; } };
12
- const wait = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
13
-
14
- function readLease(path, taskId) {
15
- try {
16
- const lease = JSON.parse(readFileSync(path, "utf8"));
17
- if (lease?.taskId !== taskId || !Number.isSafeInteger(lease.pid) || lease.pid < 1 || typeof lease.token !== "string" || !lease.token) throw new Error("invalid fields");
18
- return lease;
19
- } catch (error) { if (error?.code === "ENOENT") return null; throw new Error(`Corrupt supervisor lease for ${taskId}: ${error.message}`); }
20
- }
21
-
22
- function writeExclusive(path, value) {
23
- const fd = openSync(path, "wx", 0o600);
24
- try { writeFileSync(fd, `${JSON.stringify(value)}\n`); fsyncSync(fd); } finally { closeSync(fd); }
25
- }
26
-
27
- /** Local durable checkpoint + separate PID-fenced supervisor lock.
28
- * The lock uses a distinct task key from the runner's lease so humans can approve.
11
+ /** Local durable checkpoint + the task lease (`loop/<task>.lease`).
12
+ * The supervisor holds the lease with activity "waiting" between runs, so a
13
+ * human can approve gates; the runLoop it drives shares the same handle and
14
+ * flips activity to "turn" while a run executes (see loop-lease.mjs).
29
15
  * This adapter intentionally does not implement runTurn or perform approvals.
30
16
  */
31
- export function createLocalSupervisorAdapter({ root: rootInput, taskId }) {
17
+ export function createLocalSupervisorAdapter({ root: rootInput, taskId, onWarning }) {
32
18
  const root = validateRoot(rootInput);
33
19
  const checkpointPath = assertLoopPath(root, taskId, ".supervisor.json");
34
- const leasePath = assertLoopPath(root, taskId, ".supervisor.lease");
35
- const guardPath = assertLoopPath(root, taskId, ".supervisor.guard");
36
- let token;
37
- const withGuard = (action) => {
38
- assertLoopPath(root, taskId, ".supervisor.guard");
39
- mkdirSync(dirname(guardPath), { recursive: true });
40
- const guard = { taskId, pid: process.pid, token: randomUUID() }, deadline = Date.now() + 5000;
41
- for (;;) {
42
- try { writeExclusive(guardPath, guard); break; }
43
- catch (error) {
44
- if (error?.code !== "EEXIST") throw error;
45
- const existing = readLease(guardPath, taskId);
46
- if (!existing) continue;
47
- if (!alive(existing.pid)) { if (readLease(guardPath, taskId)?.token === existing.token) { try { unlinkSync(guardPath); } catch (e) { if (e?.code !== "ENOENT") throw e; } } continue; }
48
- if (Date.now() >= deadline) throw new Error(`Timed out waiting for supervisor guard: ${taskId}`);
49
- wait(10);
50
- }
51
- }
52
- try { return action(); } finally { if (readLease(guardPath, taskId)?.token === guard.token) unlinkSync(guardPath); }
53
- };
20
+ let lease;
54
21
  const checkScope = (input) => { checkAuthority(input?.authority); if (input?.taskId !== taskId) throw new Error("supervisor task mismatch"); };
55
22
  const assertOwned = async () => {
56
- assertLoopPath(root, taskId, ".supervisor.lease");
57
- const current = readLease(leasePath, taskId);
58
- if (!token || !current || current.token !== token || current.pid !== process.pid) throw new Error("local supervisor ownership lost");
23
+ if (!lease) throw new Error("local supervisor ownership lost: the task lease is not held");
24
+ try { lease.assertOwned(); }
25
+ catch (error) { throw new Error(`local supervisor ownership lost: ${error.message}`); }
59
26
  };
60
27
  const readCheckpoint = () => {
61
28
  assertLoopPath(root, taskId, ".supervisor.json");
@@ -72,27 +39,20 @@ export function createLocalSupervisorAdapter({ root: rootInput, taskId }) {
72
39
  return {
73
40
  async acquireSupervisor(input) {
74
41
  checkScope(input);
75
- if (token) throw new Error("local supervisor adapter is already acquired");
42
+ if (lease) throw new Error("local supervisor adapter is already acquired");
76
43
  if (input.signal?.aborted) throw new Error("supervisor already aborted");
77
- const newToken = randomUUID();
78
- withGuard(() => {
79
- assertLoopPath(root, taskId, ".supervisor.lease");
80
- const existing = readLease(leasePath, taskId);
81
- if (existing && alive(existing.pid)) throw new Error(`Task ${taskId} already has a live supervisor (pid ${existing.pid})`);
82
- if (existing && readLease(leasePath, taskId)?.token === existing.token) unlinkSync(leasePath);
83
- writeExclusive(leasePath, { taskId, pid: process.pid, token: newToken });
84
- });
85
- token = newToken;
86
- return { assertOwned, release: async () => { if (token !== newToken) return; withGuard(() => { if (readLease(leasePath, taskId)?.token === newToken) unlinkSync(leasePath); }); token = undefined; } };
44
+ const handle = acquireTaskLease(root, taskId, { activity: "waiting", label: "supervisor", purpose: "local loop supervisor", onWarning });
45
+ lease = handle;
46
+ return { assertOwned, release: async () => { if (lease !== handle) return; try { handle.release(); } finally { lease = undefined; } } };
87
47
  },
88
48
  async readTask(input) {
89
49
  checkScope({ ...input, authority: "local" });
90
50
  const release = acquireStateLock(root);
91
51
  try {
92
- if (token) await assertOwned();
52
+ if (lease) await assertOwned();
93
53
  const task = loadTask(root, taskId);
94
54
  if (!task || task.id !== taskId) throw new Error("invalid local canonical task snapshot");
95
- if (token) {
55
+ if (lease) {
96
56
  await recoverLeadArtifact({ root, taskId, task, assertOwned });
97
57
  await assertOwned();
98
58
  } else {
@@ -9,7 +9,7 @@ import { runSupervisor } from "./loop-supervisor.mjs";
9
9
  import { createLocalSupervisorAdapter } from "./loop-supervisor-local.mjs";
10
10
  import { pathsOverlap, readyTaskItems, selectBuildBatch } from "./store.mjs";
11
11
  import { runLocalWorkerBatch } from "./loop-local-workers.mjs";
12
- import { reconcileLocalAssignments } from "./assignment-store.mjs";
12
+ import { ASSIGNMENT_STATE_LABELS, effectiveAssignmentState, reconcileLocalAssignments } from "./assignment-store.mjs";
13
13
  import { ensureWorkerImage, defaultWorkerImageTag } from "./loop-worker-image.mjs";
14
14
  import { workerCredentialKeys } from "./loop-worker-docker.mjs";
15
15
 
@@ -26,7 +26,7 @@ export function createLocalLoopSupervisor({ root: rootInput, taskId, agent, limi
26
26
  const root = validateRoot(rootInput);
27
27
  if (!isLoopProvider(agent)) throw new Error(`local lead agent must be ${LOOP_PROVIDER_LIST}`);
28
28
  if (typeof driver !== "function" || typeof runner !== "function" || typeof onEvent !== "function") throw new Error("invalid local supervisor callbacks");
29
- const adapter = createLocalSupervisorAdapter({ root, taskId });
29
+ const adapter = createLocalSupervisorAdapter({ root, taskId, onWarning: onEvent });
30
30
  const getRecord = () => readLoopRecord(root, taskId);
31
31
  const execute = async (mode) => {
32
32
  requireLocalAuthority();
@@ -98,8 +98,14 @@ export function planLocalBuildBatch({ taskId, task, workers, assignments = [] })
98
98
  if (!selected.length) {
99
99
  // Every ready item is held back by an assignment; never auto-release it.
100
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>`);
101
+ const now = Date.now();
102
+ const details = blocking.map((entry) => {
103
+ const state = entry.leaseExpiresAt ? effectiveAssignmentState(entry, now) : entry.state;
104
+ const claim = [entry.workerId ? `worker ${entry.workerId}` : null, `${ASSIGNMENT_STATE_LABELS[state] ?? state}`, entry.leaseExpiresAt ? `claim expires ${entry.leaseExpiresAt}` : null].filter(Boolean).join(", ");
105
+ return `${entry.state === "leased" ? "Active" : "Unresolved"} assignment ${entry.id} (${entry.state}) for ${entry.itemId} holds ${(entry.ownedPaths ?? []).join(", ")} [${claim}]`;
106
+ }).join("; ");
107
+ const releaseTarget = blocking.length === 1 ? blocking[0].id : "<assignment-id>";
108
+ throw new Error(`${details || `No ready build item of ${taskId} could be dispatched`}; inspect it (node .agents/aidlc/scripts/assignment.mjs show ${taskId}), then run node .agents/aidlc/scripts/assignment.mjs reconcile ${taskId} once the item is done, or node .agents/aidlc/scripts/assignment.mjs release ${taskId} ${releaseTarget} to drop the claim (discards the worker's unintegrated result)`);
103
109
  }
104
110
  const jobs = selected.map((itemId, index) => {
105
111
  const [workerId, agent] = providers[index];
@@ -133,7 +139,7 @@ export async function dispatchLocalBuild({ root, taskId, task, workers, images =
133
139
  for (const provider of new Set(jobs.map((job) => job.agent))) {
134
140
  resolvedImages[provider] = ensureImage({ root, provider, image: images[provider] ?? defaultWorkerImageTag(provider), onEvent });
135
141
  }
136
- const completed = await runBatch({ root, taskId, jobs, worktreesRoot, docker: { images: resolvedImages, credentials }, signal: cancelSignal });
142
+ const completed = await runBatch({ root, taskId, jobs, worktreesRoot, docker: { images: resolvedImages, credentials }, onEvent, signal: cancelSignal });
137
143
  for (const result of completed) markDone(root, taskId, result.itemId);
138
144
  }
139
145
 
@@ -151,7 +157,7 @@ export function createIsolatedLocalSupervisor({ root: rootInput, taskId, agent,
151
157
  if (!isLoopProvider(agent)) throw new Error(`local lead agent must be ${LOOP_PROVIDER_LIST}`);
152
158
  if (!workers || typeof workers !== "object" || Array.isArray(workers) || !Object.keys(workers).length) throw new Error("Loop requires a configured lead and worker providers");
153
159
  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 });
160
+ const adapter = createLocalSupervisorAdapter({ root, taskId, onWarning: onEvent });
155
161
  const getRecord = () => readLoopRecord(root, taskId);
156
162
  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
163
  const credentials = docker?.credentials ?? Object.fromEntries([...new Set(Object.values(workers))].map((provider) => [provider, workerCredentialKeys(provider)]));
@@ -10,6 +10,8 @@ import { isLoopProvider } from './loop-drivers.mjs';
10
10
  * the lead sandbox, not here. */
11
11
  export const OPENCODE_CONTAINER_ERROR = 'OpenCode workers require file-based credentials and are not available in isolated containers yet';
12
12
 
13
+ const LABEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
14
+
13
15
  /** OpenCode's data/config roots, redirected into the worker's tmpfs HOME so the
14
16
  * container never reads the host ~/.local/share/opencode or ~/.config/opencode. */
15
17
  const OPENCODE_XDG_ENV = [
@@ -33,7 +35,7 @@ export function workerCredentialKeys(agent, env = process.env) {
33
35
  }
34
36
 
35
37
  /** Build a Docker invocation. This function never accepts a shell command or credentials in arguments. */
36
- export function workerDockerInvocation({ workspace, image, command, args, credentialEnv, hostEnv, uid, gid, containerName, sessionHome }) {
38
+ export function workerDockerInvocation({ workspace, image, command, args, credentialEnv, hostEnv, uid, gid, containerName, sessionHome, labels }) {
37
39
  const root = realpathSync(workspace);
38
40
  if (/[\x00-\x1f,]/.test(root)) throw new Error('Invalid Docker workspace path');
39
41
  if (typeof image !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9._/:@-]{0,255}$/.test(image)) throw new Error('Explicit Docker image is required');
@@ -43,6 +45,16 @@ export function workerDockerInvocation({ workspace, image, command, args, creden
43
45
  const allowed = command === 'codex' ? ['OPENAI_API_KEY'] : command === 'claude' ? ['ANTHROPIC_API_KEY'] : ['OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'OPENCODE_API_KEY'];
44
46
  if (credentialEnv.some((key) => !allowed.includes(key)) || new Set(credentialEnv).size !== credentialEnv.length) throw new Error('Unsupported worker credential environment');
45
47
  if (containerName !== undefined && (typeof containerName !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(containerName))) throw new Error('Invalid container name');
48
+ // Optional `--label key=value` pairs (aidlc.task/item/worker/assignment) so the
49
+ // dashboard can `docker ps --filter label=`; keys and values are plain identifiers.
50
+ const labelArgs = [];
51
+ if (labels !== undefined) {
52
+ if (!labels || typeof labels !== 'object' || Array.isArray(labels)) throw new Error('Invalid container labels');
53
+ for (const [key, value] of Object.entries(labels)) {
54
+ if (!LABEL_PATTERN.test(key) || typeof value !== 'string' || !LABEL_PATTERN.test(value)) throw new Error(`Invalid container label: ${key}`);
55
+ labelArgs.push('--label', `${key}=${value}`);
56
+ }
57
+ }
46
58
  let homeMount;
47
59
  if (sessionHome !== undefined) {
48
60
  const info = lstatSync(sessionHome);
@@ -54,5 +66,5 @@ export function workerDockerInvocation({ workspace, image, command, args, creden
54
66
  const env = { PATH: hostEnv.PATH ?? '/usr/bin:/bin' };
55
67
  for (const key of credentialEnv) env[key] = hostEnv[key];
56
68
  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 };
69
+ return { command: 'docker', args: ['run', '--rm', '--interactive', ...(containerName ? ['--name', containerName] : []), ...labelArgs, '--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 };
58
70
  }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Per-task record of worker containers and a raw tee of their output.
3
+ *
4
+ * Both the local loop (`loop-local-workers.mjs`) and fleet workers
5
+ * (`loop-fleet-cli.mjs`) write the same structure under the task's own data
6
+ * directory, so the dashboard lists containers identically in every mode:
7
+ *
8
+ * .agents/data/tasks/<taskId>/runtime/workers/index.json one entry per container (turn)
9
+ * .agents/data/tasks/<taskId>/runtime/workers/<assignmentId>.log raw stdout+stderr, all turns
10
+ *
11
+ * The log is capped at WORKER_LOG_MAX_BYTES and rotates once to `.log.1`
12
+ * (overwritten), the same rule as the per-task event log. Writes never throw
13
+ * into the caller: the first failure is reported through `onError`, later ones
14
+ * are swallowed. The `runtime/` directory is operational and never committed.
15
+ */
16
+ import { closeSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
17
+ import { hostname } from "node:os";
18
+ import { join, resolve } from "node:path";
19
+ import { taskRuntimeDir } from "./task-runtime.mjs";
20
+
21
+ export const WORKER_LOG_MAX_BYTES = 5 * 1024 * 1024;
22
+ export const WORKER_INDEX_MAX_ENTRIES = 500;
23
+ const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
24
+ const MODES = new Set(["local", "fleet"]);
25
+ const safeId = (value, label) => { if (typeof value !== "string" || !ID_PATTERN.test(value)) throw new Error(`Invalid ${label}`); return value; };
26
+ const iso = () => new Date().toISOString();
27
+
28
+ /** True when `path` is missing or a real (non-symlink) entry. */
29
+ const refuseSymlink = (path) => {
30
+ try { if (lstatSync(path).isSymbolicLink()) throw new Error(`Worker log path crosses symlink: ${path}`); }
31
+ catch (error) { if (error?.code !== "ENOENT") throw error; }
32
+ };
33
+
34
+ /** `.agents/data/tasks/<taskId>/runtime/workers`, refusing symlinked components. */
35
+ export function workerLogDirectory(root, taskId) {
36
+ const path = join(taskRuntimeDir(root, safeId(taskId, "task id")), "workers");
37
+ refuseSymlink(path);
38
+ return path;
39
+ }
40
+ export const workerIndexPath = (root, taskId) => join(workerLogDirectory(root, taskId), "index.json");
41
+ export function workerLogPath(root, taskId, assignmentId, generation = 0) {
42
+ safeId(assignmentId, "assignment id");
43
+ const path = join(workerLogDirectory(root, taskId), `${assignmentId}.log${generation ? `.${generation}` : ""}`);
44
+ refuseSymlink(path);
45
+ return path;
46
+ }
47
+
48
+ const validEntry = (entry) => entry && typeof entry === "object" && !Array.isArray(entry) && typeof entry.name === "string" && typeof entry.assignmentId === "string" && typeof entry.startedAt === "string";
49
+
50
+ /** Read a task's container index; a missing or corrupt file reads as empty. */
51
+ export function readWorkerIndex(root, taskId) {
52
+ let raw;
53
+ try { raw = readFileSync(workerIndexPath(root, taskId), "utf8"); }
54
+ catch (error) { if (error?.code === "ENOENT") return []; throw error; }
55
+ try { const value = JSON.parse(raw); return Array.isArray(value) ? value.filter(validEntry) : []; }
56
+ catch { return []; }
57
+ }
58
+
59
+ function writeWorkerIndex(directory, entries) {
60
+ const path = join(directory, "index.json"), temp = join(directory, `index.json.${process.pid}.${Date.now().toString(36)}.tmp`);
61
+ const trimmed = entries.length > WORKER_INDEX_MAX_ENTRIES ? entries.slice(entries.length - WORKER_INDEX_MAX_ENTRIES) : entries;
62
+ const fd = openSync(temp, "w", 0o644);
63
+ try { writeSync(fd, `${JSON.stringify(trimmed, null, 2)}\n`); fsyncSync(fd); }
64
+ finally { closeSync(fd); }
65
+ try { renameSync(temp, path); }
66
+ catch (error) { try { unlinkSync(temp); } catch { /* best effort */ } throw error; }
67
+ }
68
+
69
+ /**
70
+ * Open the container record and raw log for one assignment.
71
+ * Returns `{ turnStart(containerName, turn), tee(child), turnEnd(exitCode), close() }`.
72
+ * `turnStart` appends `--- turn <N> container <name> <ISO> ---` and adds an index
73
+ * entry; `tee` appends every stdout/stderr chunk unmodified; `turnEnd` appends a
74
+ * trailer and records `endedAt`/`exitCode`; `close` ends a dangling turn
75
+ * (exitCode null) and releases the file descriptor. Methods never throw.
76
+ */
77
+ export function openWorkerLog({ root, taskId, assignmentId, itemId, workerId, agent, mode, onError, maxBytes = WORKER_LOG_MAX_BYTES } = {}) {
78
+ if (typeof root !== "string" || !root) throw new Error("Worker log root is required");
79
+ safeId(taskId, "task id"); safeId(assignmentId, "assignment id"); safeId(itemId, "item id"); safeId(workerId, "worker id"); safeId(agent, "agent");
80
+ if (!MODES.has(mode)) throw new Error("Worker log mode must be local or fleet");
81
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new Error("Invalid worker log size cap");
82
+ const base = resolve(root), host = hostname(), logName = `${assignmentId}.log`;
83
+ let fd = null, size = 0, reported = false, current = null, closed = false;
84
+ const report = (error) => {
85
+ if (reported) return;
86
+ reported = true;
87
+ if (typeof onError === "function") { try { onError(error instanceof Error ? error : new Error(String(error))); } catch { /* diagnostics never throw */ } }
88
+ };
89
+ const guard = (fn) => { try { fn(); } catch (error) { report(error); } };
90
+ const directory = () => { const path = workerLogDirectory(base, taskId); mkdirSync(path, { recursive: true }); refuseSymlink(path); return path; };
91
+ const open = () => {
92
+ if (fd !== null) return;
93
+ const path = workerLogPath(base, taskId, assignmentId);
94
+ directory();
95
+ fd = openSync(path, "a", 0o644);
96
+ try { size = lstatSync(path).size; } catch { size = 0; }
97
+ };
98
+ const write = (chunk) => {
99
+ if (closed) return;
100
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8");
101
+ if (!bytes.length) return;
102
+ open();
103
+ if (size > 0 && size + bytes.length > maxBytes) {
104
+ closeSync(fd); fd = null;
105
+ const path = workerLogPath(base, taskId, assignmentId);
106
+ refuseSymlink(workerLogPath(base, taskId, assignmentId, 1));
107
+ renameSync(path, `${path}.1`);
108
+ open();
109
+ }
110
+ let offset = 0;
111
+ while (offset < bytes.length) offset += writeSync(fd, bytes, offset, bytes.length - offset);
112
+ size += bytes.length;
113
+ };
114
+ const updateIndex = (mutate) => {
115
+ const dir = directory();
116
+ const entries = readWorkerIndex(base, taskId);
117
+ writeWorkerIndex(dir, mutate(entries));
118
+ };
119
+ const finish = (exitCode) => {
120
+ if (!current) return;
121
+ const entry = current; current = null;
122
+ const endedAt = iso(), code = Number.isSafeInteger(exitCode) ? exitCode : null;
123
+ try { write(`--- end turn ${entry.turn} container ${entry.name} exit ${code === null ? "signal" : code} ${endedAt} ---\n`); }
124
+ catch (error) { report(error); }
125
+ updateIndex((entries) => {
126
+ const index = entries.findIndex((candidate) => candidate.name === entry.name && candidate.assignmentId === assignmentId && candidate.turn === entry.turn);
127
+ const record = { ...(index === -1 ? entry.record : entries[index]), endedAt, exitCode: code };
128
+ if (index === -1) entries.push(record); else entries[index] = record;
129
+ return entries;
130
+ });
131
+ };
132
+ return {
133
+ turnStart(containerName, turn) {
134
+ guard(() => {
135
+ if (closed) throw new Error("Worker log is closed");
136
+ safeId(containerName, "container name");
137
+ if (!Number.isSafeInteger(turn) || turn < 0) throw new Error("Invalid worker turn");
138
+ if (current) finish(null);
139
+ const startedAt = iso();
140
+ const record = { name: containerName, assignmentId, itemId, workerId, agent, mode, host, turn, startedAt, log: logName };
141
+ current = { name: containerName, turn, record };
142
+ write(`--- turn ${turn} container ${containerName} ${startedAt} ---\n`);
143
+ updateIndex((entries) => { entries.push(record); return entries; });
144
+ });
145
+ },
146
+ tee(child) {
147
+ const listener = (chunk) => guard(() => write(chunk));
148
+ for (const stream of [child?.stdout, child?.stderr]) { if (stream && typeof stream.on === "function") stream.on("data", listener); }
149
+ return child;
150
+ },
151
+ turnEnd(exitCode) { guard(() => finish(exitCode)); },
152
+ close() {
153
+ guard(() => finish(null));
154
+ closed = true;
155
+ if (fd !== null) { const handle = fd; fd = null; try { closeSync(handle); } catch (error) { report(error); } }
156
+ }
157
+ };
158
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Resolve the repository HEAD commit for lifecycle wrappers (state.mjs, remote-state.mjs).
3
+ * The lifecycle core never runs git; wrappers call this and then store.mjs#recordTaskCommit.
4
+ */
5
+ import { execFileSync } from "node:child_process";
6
+
7
+ const shaPattern = /^[0-9a-f]{40,64}$/;
8
+
9
+ /**
10
+ * Returns the full HEAD commit SHA of the git repository at `root`, or `undefined` when git is
11
+ * unavailable, `root` is not inside a repository, or the repository has no commits. Never throws.
12
+ */
13
+ export const headCommit = (root, { timeout = 5000 } = {}) => {
14
+ if (typeof root !== "string" || !root.trim()) return undefined;
15
+ try {
16
+ const output = execFileSync("git", ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], { cwd: root, shell: false, timeout, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8", maxBuffer: 4096 });
17
+ const sha = String(output).trim().toLowerCase();
18
+ return shaPattern.test(sha) ? sha : undefined;
19
+ } catch {
20
+ return undefined;
21
+ }
22
+ };
@@ -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}`);
@@ -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
+ }