@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
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Zero-config environment for the local loop daemon.
3
+ *
4
+ * Merges three sources, highest priority first:
5
+ * 1. the invoking shell (`process.env`) — never overridden;
6
+ * 2. the AIDLC env file (`AIDLC_ENV_FILE`, default `.agents/loop.env`), a minimal dotenv;
7
+ * 3. values derived from the configured providers' own config files:
8
+ * - `AIDLC_LOOP_EVENT_LOG` → `.agents/data/loop/ui-events.jsonl` (dashboard live log);
9
+ * - `AIDLC_OPENCODE_MODEL` → `model` of the OpenCode config (project > OPENCODE_CONFIG > global);
10
+ * - worker credentials → OpenCode `auth.json`, Codex `$CODEX_HOME/auth.json`
11
+ * (`OPENAI_API_KEY`), Claude Code settings `env.ANTHROPIC_API_KEY` (local > project > user).
12
+ *
13
+ * Only providers listed in `loop.agents` are inspected. Secret values are never printed,
14
+ * logged, written to disk, or returned in a report; the report names their source only.
15
+ */
16
+ import { spawnSync } from "node:child_process";
17
+ import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { delimiter, isAbsolute, join, relative, resolve, sep } from "node:path";
20
+ import { isLoopProvider, OPENCODE_MODEL } from "./loop-drivers.mjs";
21
+ import { workerCredentialKeys } from "./loop-worker-docker.mjs";
22
+
23
+ export const DEFAULT_ENV_FILE = ".agents/loop.env";
24
+ const KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
25
+ const MAX_FILE_BYTES = 1024 * 1024;
26
+ /** The only values a report may show; every other variable (any env-file key) is "set"/"missing". */
27
+ const DISPLAY_KEYS = new Set(["AIDLC_OPENCODE_MODEL", "AIDLC_LOOP_EVENT_LOG", "AIDLC_WORKER_DOCKER_IMAGE", "AIDLC_LOCAL_WORKTREES_ROOT", "AIDLC_ENV_FILE"]);
28
+
29
+ export const isSecretKey = (key) => !DISPLAY_KEYS.has(key);
30
+
31
+ /** Minimal dotenv: `KEY=VALUE`, optional `export `, `#` comments, single/double quotes. No expansion. */
32
+ export function parseEnvFile(text, label = "env file") {
33
+ const values = {};
34
+ const lines = String(text).replace(/^/, "").split(/\r?\n/);
35
+ lines.forEach((raw, index) => {
36
+ const line = raw.trim();
37
+ if (!line || line.startsWith("#")) return;
38
+ const match = /^(?:export\s+)?([^=\s]+)\s*=\s*(.*)$/.exec(line);
39
+ if (!match || !KEY.test(match[1])) throw new Error(`${label}:${index + 1}: expected KEY=VALUE`);
40
+ const [, key, rest] = match;
41
+ let value = rest;
42
+ const quote = value[0];
43
+ if (quote === '"' || quote === "'") {
44
+ const end = value.indexOf(quote, 1);
45
+ if (end < 0) throw new Error(`${label}:${index + 1}: unterminated ${quote === '"' ? "double" : "single"} quote`);
46
+ const tail = value.slice(end + 1).trim();
47
+ if (tail && !tail.startsWith("#")) throw new Error(`${label}:${index + 1}: unexpected text after the closing quote`);
48
+ value = value.slice(1, end);
49
+ if (quote === '"') value = value.replace(/\\n/g, "\n").replace(/\\"/g, '"');
50
+ } else {
51
+ value = value.replace(/\s+#.*$/, "").trim();
52
+ }
53
+ values[key] = value;
54
+ });
55
+ return values;
56
+ }
57
+
58
+ /** The env file to load: `AIDLC_ENV_FILE` (relative to root) or the default; `explicit` marks a user choice. */
59
+ export function resolveEnvFile(root, env = process.env) {
60
+ const chosen = typeof env.AIDLC_ENV_FILE === "string" && env.AIDLC_ENV_FILE.trim() ? env.AIDLC_ENV_FILE.trim() : "";
61
+ return { path: resolve(root, chosen || DEFAULT_ENV_FILE), explicit: Boolean(chosen) };
62
+ }
63
+
64
+ const realOrSelf = (path) => { try { return realpathSync(path); } catch { return resolve(path); } };
65
+ const within = (base, target) => { const rel = relative(base, target); return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); };
66
+
67
+ /**
68
+ * Reads a small regular file (symlinks are followed). With `confine`, a symlinked file must
69
+ * resolve inside the project or the home directory, both compared by their real paths so a
70
+ * project reached through a symlink (macOS /tmp, ~/code -> /mnt/...) still works.
71
+ */
72
+ function readTrustedFile(path, { root, home }, { confine = false } = {}) {
73
+ if (!existsSync(path)) return undefined;
74
+ const real = realpathSync(path);
75
+ if (confine && lstatSync(path).isSymbolicLink() && !within(realOrSelf(root), real) && !(home && within(realOrSelf(home), real))) throw new Error(`${path} links outside the project and home directory`);
76
+ const stat = statSync(real);
77
+ if (!stat.isFile()) throw new Error(`${path} is not a regular file`);
78
+ if (stat.size > MAX_FILE_BYTES) throw new Error(`${path} is larger than 1 MiB`);
79
+ return readFileSync(real, "utf8");
80
+ }
81
+
82
+ /** JSON with `//` and block comments and trailing commas (OpenCode `.jsonc`). Strings are kept intact. */
83
+ export function parseJsonc(text) {
84
+ let out = "";
85
+ for (let i = 0; i < text.length; i += 1) {
86
+ const char = text[i];
87
+ if (char === '"') {
88
+ let j = i + 1;
89
+ while (j < text.length && text[j] !== '"') j += text[j] === "\\" ? 2 : 1;
90
+ out += text.slice(i, j + 1); i = j; continue;
91
+ }
92
+ if (char === "/" && text[i + 1] === "/") { while (i < text.length && text[i] !== "\n") i += 1; out += "\n"; continue; }
93
+ if (char === "/" && text[i + 1] === "*") { const end = text.indexOf("*/", i + 2); i = end < 0 ? text.length : end + 1; continue; }
94
+ out += char;
95
+ }
96
+ return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1"));
97
+ }
98
+
99
+ const readJson = (path, context, { jsonc = false } = {}) => {
100
+ try {
101
+ const text = readTrustedFile(path, context);
102
+ return text === undefined ? undefined : jsonc ? parseJsonc(text) : JSON.parse(text);
103
+ } catch (error) {
104
+ // Never echo a parser message: V8 quotes the source text around the error, which may be a key.
105
+ const reason = error instanceof SyntaxError ? "not valid JSON" : error instanceof Error ? error.message : String(error);
106
+ context.problems.push({ level: "warning", key: "", message: `ignored ${path}: ${reason}` });
107
+ return undefined;
108
+ }
109
+ };
110
+
111
+ /** Unique providers of `loop.agents` (lead first), and the worker subset. */
112
+ export function loopProviders(config) {
113
+ const lead = config?.loop?.agents?.lead;
114
+ const workers = Object.values(config?.loop?.agents?.workers ?? {}).filter(isLoopProvider);
115
+ return { lead: isLoopProvider(lead) ? lead : undefined, workers: [...new Set(workers)], all: [...new Set([...(isLoopProvider(lead) ? [lead] : []), ...workers])] };
116
+ }
117
+
118
+ const xdg = (env, name, home, fallback) => (env[name] && isAbsolute(env[name]) ? env[name] : join(home, fallback));
119
+
120
+ /** OpenCode `model`, following its documented precedence (later wins): global < OPENCODE_CONFIG < project. */
121
+ function opencodeModel(context) {
122
+ const { root, env, home } = context;
123
+ const configDir = join(xdg(env, "XDG_CONFIG_HOME", home, ".config"), "opencode");
124
+ const candidates = [
125
+ join(configDir, "opencode.json"), join(configDir, "opencode.jsonc"),
126
+ ...(env.OPENCODE_CONFIG ? [resolve(root, env.OPENCODE_CONFIG)] : []),
127
+ join(root, "opencode.json"), join(root, "opencode.jsonc"),
128
+ ];
129
+ let found;
130
+ for (const path of candidates) {
131
+ const config = readJson(path, context, { jsonc: true });
132
+ if (typeof config?.model === "string" && config.model.trim()) found = { value: config.model.trim(), source: `derived:${path}` };
133
+ }
134
+ return found;
135
+ }
136
+
137
+ /** Worker credentials a provider's own login/config can supply, as `{ key, value, source }`. */
138
+ function providerCredentials(provider, env, context) {
139
+ const { root, home } = context;
140
+ if (provider === "opencode") {
141
+ const path = join(xdg(env, "XDG_DATA_HOME", home, join(".local", "share")), "opencode", "auth.json");
142
+ const auth = readJson(path, context);
143
+ if (!auth || typeof auth !== "object") return [];
144
+ const prefix = String(env.AIDLC_OPENCODE_MODEL ?? "").split("/")[0];
145
+ const keys = workerCredentialKeys("opencode", env);
146
+ // The OpenCode Zen/Go key only stands in for OPENCODE_API_KEY, never for a provider's own key.
147
+ const ids = keys.includes("OPENCODE_API_KEY") ? [prefix, "opencode", "opencode-go"] : [prefix];
148
+ const entry = ids.map((id) => auth[id]).find((value) => value?.type === "api" && typeof value.key === "string" && value.key);
149
+ return entry ? keys.map((key) => ({ key, value: entry.key, source: `derived:${path}` })) : [];
150
+ }
151
+ if (provider === "codex") {
152
+ const path = join(env.CODEX_HOME && isAbsolute(env.CODEX_HOME) ? env.CODEX_HOME : join(home, ".codex"), "auth.json");
153
+ const auth = readJson(path, context);
154
+ if (typeof auth?.OPENAI_API_KEY === "string" && auth.OPENAI_API_KEY) return [{ key: "OPENAI_API_KEY", value: auth.OPENAI_API_KEY, source: `derived:${path}` }];
155
+ if (auth?.tokens) context.notes.push({ provider, message: "Codex is signed in with ChatGPT; Docker workers need an API key (codex login --with-api-key, or OPENAI_API_KEY in the env file)" });
156
+ return [];
157
+ }
158
+ if (provider === "claude") {
159
+ const userDir = env.CLAUDE_CONFIG_DIR && isAbsolute(env.CLAUDE_CONFIG_DIR) ? env.CLAUDE_CONFIG_DIR : join(home, ".claude");
160
+ let found = [];
161
+ for (const path of [join(userDir, "settings.json"), join(root, ".claude", "settings.json"), join(root, ".claude", "settings.local.json")]) {
162
+ const value = readJson(path, context)?.env?.ANTHROPIC_API_KEY;
163
+ if (typeof value === "string" && value) found = [{ key: "ANTHROPIC_API_KEY", value, source: `derived:${path}` }];
164
+ }
165
+ return found;
166
+ }
167
+ return [];
168
+ }
169
+
170
+ /** True when `command` resolves on PATH (PATHEXT-aware on Windows). */
171
+ export function hasCommand(command, env = process.env) {
172
+ const exts = process.platform === "win32" ? (env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
173
+ return String(env.PATH ?? "").split(delimiter).filter(Boolean).some((dir) => exts.some((ext) => { try { return statSync(join(dir, command + ext)).isFile(); } catch { return false; } }));
174
+ }
175
+
176
+ /** Whether git would ignore the env file; outside a work tree there is nothing to commit. */
177
+ const gitignored = (root, envFile) => {
178
+ const rel = relative(root, envFile);
179
+ if (rel.startsWith("..") || isAbsolute(rel)) return true;
180
+ const result = spawnSync("git", ["check-ignore", "-q", "--", rel.split(sep).join("/")], { cwd: root, stdio: "ignore", timeout: 5000 });
181
+ return result.error ? true : result.status !== 1;
182
+ };
183
+
184
+ /**
185
+ * Resolves the daemon environment. `applied` holds only the keys to add to the
186
+ * process (file + derived); shell keys are left untouched. `problems` with level
187
+ * `error` must stop the daemon before it starts.
188
+ */
189
+ export function loadLoopEnv({ root, config, env = process.env, home = homedir(), commandExists = hasCommand } = {}) {
190
+ root = resolve(root ?? ".");
191
+ const problems = [], notes = [];
192
+ const context = { root, env, home, problems, notes };
193
+ const providers = loopProviders(config);
194
+ const mode = config?.loop?.mode ?? "off";
195
+ const sources = {}, applied = {};
196
+ const merged = { ...env };
197
+ // Anything the shell exports, even an empty value, is never overridden.
198
+ const set = (key, value, source) => { if (merged[key] !== undefined) return false; merged[key] = value; applied[key] = value; sources[key] = source; return true; };
199
+ for (const key of Object.keys(env)) sources[key] = "shell";
200
+
201
+ const envFile = resolveEnvFile(root, env);
202
+ let fileKeys = [];
203
+ try {
204
+ const text = readTrustedFile(envFile.path, context, { confine: true });
205
+ if (text === undefined) {
206
+ if (envFile.explicit) problems.push({ level: "error", key: "AIDLC_ENV_FILE", message: `env file not found: ${envFile.path}` });
207
+ } else {
208
+ const values = parseEnvFile(text, relative(root, envFile.path) || envFile.path);
209
+ fileKeys = Object.keys(values);
210
+ for (const [key, value] of Object.entries(values)) if (value !== "") set(key, value, "file");
211
+ if (!gitignored(root, envFile.path)) problems.push({ level: "warning", key: "", message: `${relative(root, envFile.path)} is not git-ignored; add "loop.env" to .agents/.gitignore so credentials are never committed` });
212
+ }
213
+ } catch (error) {
214
+ problems.push({ level: "error", key: "AIDLC_ENV_FILE", message: error instanceof Error ? error.message : String(error) });
215
+ }
216
+
217
+ if (mode !== "local" && mode !== "fleet") problems.push({ level: "error", key: "", message: 'loop.mode is off: set "loop": { "mode": "local", "agents": { "lead": "<codex|claude|opencode>", "workers": { "worker-a": "<provider>" } } } in .agents/config.json, or run: aidlc-kit init --loop-mode local' });
218
+ else if (!providers.lead || !providers.workers.length) problems.push({ level: "error", key: "", message: 'loop.agents needs a lead and at least one worker provider (codex, claude, or opencode) in .agents/config.json' });
219
+
220
+ set("AIDLC_LOOP_EVENT_LOG", join(root, ".agents", "data", "loop", "ui-events.jsonl"), "derived");
221
+
222
+ const keys = new Set(["AIDLC_LOOP_EVENT_LOG"]);
223
+ if (providers.all.includes("opencode")) {
224
+ keys.add("AIDLC_OPENCODE_MODEL");
225
+ if (!merged.AIDLC_OPENCODE_MODEL) { const found = opencodeModel(context); if (found) set("AIDLC_OPENCODE_MODEL", found.value, found.source); }
226
+ const model = merged.AIDLC_OPENCODE_MODEL;
227
+ if (model === "" && sources.AIDLC_OPENCODE_MODEL === "shell") problems.push({ level: "error", key: "AIDLC_OPENCODE_MODEL", message: "AIDLC_OPENCODE_MODEL is exported empty in the shell, which overrides the env file and opencode.json: unset it or set provider/model" });
228
+ else if (!model) problems.push({ level: "error", key: "AIDLC_OPENCODE_MODEL", message: `opencode needs a model: add AIDLC_OPENCODE_MODEL=<provider/model> to ${relative(root, envFile.path)} or set "model" in opencode.json (list models with: opencode models)` });
229
+ else if (!OPENCODE_MODEL.test(model)) problems.push({ level: "error", key: "AIDLC_OPENCODE_MODEL", message: `AIDLC_OPENCODE_MODEL must look like provider/model[#variant] (from ${sources.AIDLC_OPENCODE_MODEL})` });
230
+ }
231
+
232
+ const workerKeys = new Set();
233
+ for (const provider of providers.workers) {
234
+ const needed = workerCredentialKeys(provider, merged);
235
+ needed.forEach((key) => workerKeys.add(key));
236
+ if (needed.every((key) => merged[key])) { needed.forEach((key) => keys.add(key)); continue; }
237
+ for (const credential of providerCredentials(provider, merged, context)) set(credential.key, credential.value, credential.source);
238
+ for (const key of needed) {
239
+ keys.add(key);
240
+ if (!merged[key]) {
241
+ const note = notes.find((entry) => entry.provider === provider);
242
+ problems.push({ level: "warning", key, message: note ? note.message : `${provider} workers need ${key} for the build phase: add it to ${relative(root, envFile.path)}` });
243
+ }
244
+ }
245
+ }
246
+
247
+ if (mode === "local" && !commandExists("docker", merged)) problems.push({ level: "warning", key: "", message: "docker was not found on PATH; local workers run in Docker during the build phase" });
248
+ for (const key of ["AIDLC_WORKER_DOCKER_IMAGE", "AIDLC_LOCAL_WORKTREES_ROOT", ...fileKeys]) if (merged[key]) keys.add(key);
249
+
250
+ const entries = [...keys].map((key) => {
251
+ const secret = isSecretKey(key);
252
+ const present = Boolean(merged[key]);
253
+ return { key, set: present, source: present ? sources[key] ?? "shell" : null, secret, ...(present && !secret ? { value: merged[key] } : {}) };
254
+ });
255
+ // Worker keys detected from another tool's config reach Docker workers only, never the
256
+ // host lead (a claude/codex lead would otherwise switch from its own login to API-key
257
+ // billing). Env-file keys are explicit, like shell exports, and reach the lead too.
258
+ const leadExclude = [...workerKeys].filter((key) => key in applied && String(sources[key]).startsWith("derived:"));
259
+ if (leadExclude.length) applied.AIDLC_LEAD_ENV_EXCLUDE = leadExclude.join(",");
260
+ return { root, mode, providers: providers.all, envFile: { path: envFile.path, explicit: envFile.explicit, loaded: fileKeys.length > 0 || existsSync(envFile.path) }, entries, problems, applied, ok: !problems.some((problem) => problem.level === "error") };
261
+ }
262
+
263
+ /** A report safe to print: the `applied` values are dropped. */
264
+ export const publicReport = ({ applied: _applied, ...report }) => report;
265
+
266
+ export function formatLoopEnvReport(report) {
267
+ const lines = [];
268
+ lines.push(`Loop environment (${report.mode} mode)`);
269
+ lines.push(` providers : ${report.providers.length ? report.providers.join(", ") : "none configured"}`);
270
+ lines.push(` env file : ${report.envFile.path}${report.envFile.loaded ? "" : " (not found)"}`);
271
+ const width = Math.max(4, ...report.entries.map((entry) => entry.key.length));
272
+ for (const entry of report.entries) {
273
+ const shown = !entry.set ? "missing" : entry.secret ? "set" : entry.value;
274
+ lines.push(` ${entry.set ? "✓" : "✗"} ${entry.key.padEnd(width)} ${shown}${entry.source ? ` [${entry.source}]` : ""}`);
275
+ }
276
+ for (const problem of report.problems) lines.push(` ${problem.level === "error" ? "ERROR" : "WARN "} ${problem.message}`);
277
+ if (report.ok) lines.push(" ready: the daemon can start");
278
+ return lines.join("\n");
279
+ }
@@ -9,6 +9,7 @@ import { runFleetSupervisor } from "./loop-fleet-supervisor.mjs";
9
9
  import { createFleetRemoteRepository } from "./loop-fleet-remote.mjs";
10
10
  import { createLeadGitHandoff, createIsolatedWorkerGitHandoff } from "./loop-git.mjs";
11
11
  import { workerDockerInvocation, workerCredentialKeys } from "./loop-worker-docker.mjs";
12
+ import { openWorkerLog } from "./loop-worker-log.mjs";
12
13
  import { defaultWorkerImageTag, ensureWorkerImage, validWorkerImageTag } from "./loop-worker-image.mjs";
13
14
 
14
15
  const required = (env, name) => { const value = env[name]; if (typeof value !== "string" || !value.trim()) throw new Error(`${name} is required`); return value; };
@@ -133,13 +134,23 @@ export async function runFleetCli(argv, { callTool, env = process.env, out = con
133
134
  if (workerAgent) await preflight(workerAgent);
134
135
  const baseHandoff = createIsolatedWorkerGitHandoff({ root, worktreesRoot: resolve(required(env, "AIDLC_FLEET_WORKTREES_ROOT")), remote });
135
136
  const sessions = new Map();
136
- const cleanupSession = (workspace) => { const current=sessions.get(workspace.root); if(current) { rmSync(current.home,{recursive:true,force:true}); sessions.delete(workspace.root); } };
137
+ const dropSession = (key, current) => { current.log?.close(); rmSync(current.home,{recursive:true,force:true}); sessions.delete(key); };
138
+ const cleanupSession = (workspace) => { const current=sessions.get(workspace.root); if(current) dropSession(workspace.root, current); };
139
+ // Same container record and raw output tee as the local loop, written under
140
+ // this worker checkout's .agents/data/tasks/<task>/runtime/workers (the
141
+ // index entry carries this host's name). Best-effort: reported once, never fatal.
142
+ const openLog = (job) => {
143
+ const diagnostic = (error) => emit({ type: "diagnostic", taskId: job.taskId, itemId: job.itemId, message: `Worker log unavailable for assignment ${job.assignmentId}: ${error instanceof Error ? error.message : String(error)}` });
144
+ try { return openWorkerLog({ root, taskId: job.taskId, assignmentId: job.assignmentId, itemId: job.itemId, workerId, agent: job.agent, mode: "fleet", onError: diagnostic }); }
145
+ catch (error) { diagnostic(error); return null; }
146
+ };
137
147
  const handoff = {
138
148
  ...baseHandoff,
139
149
  async prepare(job) {
140
150
  const workspace=await baseHandoff.prepare(job);
141
151
  const home=mkdtempSync(join(tmpdir(),"aidlc-worker-home-")); chmodSync(home,0o700);
142
- sessions.set(workspace.root,{home,agent:job.agent,assignmentId:job.assignmentId,attempt:job.attempt,epoch:job.epoch,workerId,image,sessionId:null});
152
+ const labels = { "aidlc.task": job.taskId, "aidlc.item": job.itemId, "aidlc.worker": workerId, "aidlc.assignment": job.assignmentId };
153
+ sessions.set(workspace.root,{home,agent:job.agent,assignmentId:job.assignmentId,attempt:job.attempt,epoch:job.epoch,workerId,image,sessionId:null,labels,log:openLog(job),turns:0});
143
154
  return workspace;
144
155
  },
145
156
  async finish(args) { try { return await baseHandoff.finish(args); } finally { cleanupSession(args.workspace); } },
@@ -151,13 +162,17 @@ export async function runFleetCli(argv, { callTool, env = process.env, out = con
151
162
  const credentialEnv = workerCredentialKeys(turn.agent, env);
152
163
  const name = `aidlc-worker-${randomUUID()}`;
153
164
  let started = false;
165
+ session.turns += 1;
166
+ session.log?.turnStart(name, session.turns);
154
167
  try {
155
168
  const result = await runAgentTurn({...turn, root:"/workspace", env, spawnProcess: (command,args,options) => {
156
- const invocation=workerDockerInvocation({workspace:turn.root,image,command,args,credentialEnv,hostEnv:env,uid,gid,sessionHome:session.home});
169
+ const invocation=workerDockerInvocation({workspace:turn.root,image,command,args,credentialEnv,hostEnv:env,uid,gid,sessionHome:session.home,labels:session.labels});
157
170
  const dockerArgs=invocation.args.filter((arg,index) => !(index===1 && arg==="--rm"));
158
171
  dockerArgs.splice(1,0,"--name",name);
159
172
  started = true;
160
- return spawn(invocation.command,dockerArgs,{...options,cwd:turn.root,env:invocation.env});
173
+ const child = spawn(invocation.command,dockerArgs,{...options,cwd:turn.root,env:invocation.env});
174
+ if (session.log) { session.log.tee(child); child.once("close", (code) => session.log.turnEnd(code)); }
175
+ return child;
161
176
  }});
162
177
  session.sessionId=result.sessionId;
163
178
  return result;
@@ -179,7 +194,7 @@ export async function runFleetCli(argv, { callTool, env = process.env, out = con
179
194
  "Complete the scoped item and report remaining risks. The supervisor handles the Git commit and push. End the final answer with a Conventional Commits message on its own line as AI-DLC-COMMIT: type(scope): subject (for example AI-DLC-COMMIT: docs(ui): add web UI improvement proposal), followed by AI-DLC-ITEM-COMPLETE on its own line.",
180
195
  ...(env.AIDLC_FLEET_CODE_ONLY_RETRY === "1" ? ["This assignment is declared code-only: perform no external side effects beyond the supervisor's Git handoff."] : [])
181
196
  ].join("\n"), onEvent: emit }); }
182
- finally { for (const [workspace, entry] of sessions) { rmSync(entry.home,{recursive:true,force:true}); sessions.delete(workspace); } }
197
+ finally { for (const [workspace, entry] of sessions) dropSession(workspace, entry); }
183
198
  emit(result);
184
199
  if (once || result.status === "stopped") return result;
185
200
  await pause(pollMs, signal, cancelSignal);
@@ -1,11 +1,20 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { explainFleetConflict } from "./loop-holder.mjs";
2
3
 
3
4
  const idempotency = () => randomUUID();
4
5
 
5
6
  /** Map the coordination service's versioned MCP tools to the headless runner. */
6
7
  export function createFleetRemoteRepository({ callTool, url, token, workspace, root, workerId }) {
7
8
  if (typeof callTool !== "function" || !url || !token || !workspace || !root) throw new Error("Fleet remote requires MCP callTool, URL, token, workspace, and project root");
8
- const call = (name, args) => callTool({ url, token, name, args: { workspace, ...args } });
9
+ // A FleetConflict keeps its code/current for retry logic; its message gains
10
+ // a lease/claim explanation and the inspect command (original kept in parentheses).
11
+ const call = async (name, args) => {
12
+ try { return await callTool({ url, token, name, args: { workspace, ...args } }); }
13
+ catch (error) {
14
+ if (error?.code === "FLEET_CONFLICT" && typeof error.message === "string") error.message = explainFleetConflict(error.message);
15
+ throw error;
16
+ }
17
+ };
9
18
  let ownerToken;
10
19
  let supervisorToken, supervisorEpoch, supervisorTask;
11
20
  const fleet = () => call("fleet_get", {});
@@ -1,5 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { setTimeout as sleep } from "node:timers/promises";
3
+ import { explainFleetConflict } from "./loop-holder.mjs";
3
4
  import { runFleetCoordinator, validateFleetLimits } from "./loop-fleet.mjs";
4
5
 
5
6
  const taskDigest = (task) => createHash("sha256").update(JSON.stringify(task)).digest("hex");
@@ -65,7 +66,7 @@ export async function runFleetSupervisor({ repository, taskId, limits: inputLimi
65
66
  await onEvent({ type: "gate", taskId, gate: result.gate, snapshotId: result.snapshotId, gateView: result.gateView });
66
67
  if (untilStop) return { status: "gate", taskId, gate: result.gate, snapshotId: result.snapshotId, gateView: result.gateView };
67
68
  }
68
- if (failure) throw new Error(`Supervisor lease renewal failed: ${failure.message}`);
69
+ if (failure) throw new Error(`Supervisor lease renewal failed: ${explainFleetConflict(failure.message)}`);
69
70
  return { status: "stopped", taskId };
70
71
  } finally {
71
72
  clearInterval(timer); await pending;
@@ -5,6 +5,7 @@
5
5
  import { createHash, randomUUID } from "node:crypto";
6
6
  import { readyTaskItems, formatGateView, pathsOverlap } from "./store.mjs";
7
7
  import { isLoopProvider, LOOP_PROVIDER_LIST } from "./loop-drivers.mjs";
8
+ import { explainFleetConflict, LEASES_COMMAND } from "./loop-holder.mjs";
8
9
 
9
10
  export const FLEET_LIMITS = Object.freeze({ maxTurns: 12, turnTimeoutMs: 600_000, maxStallTurns: 2, maxWorkers: 4, leaseMs: 300_000, heartbeatMs: 60_000 });
10
11
  const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
@@ -111,7 +112,7 @@ async function withHeartbeat(renew, intervalMs, work) {
111
112
  try {
112
113
  const result = await work(abort.signal);
113
114
  await pending;
114
- if (failure) throw new Error(`Fleet lease renewal failed: ${failure.message}`);
115
+ if (failure) throw new Error(`Fleet lease renewal failed: ${explainFleetConflict(failure.message)}`);
115
116
  return result;
116
117
  } finally { clearInterval(timer); await pending; }
117
118
  }
@@ -155,7 +156,7 @@ export async function runFleetCoordinator({ repository, taskId, ownerId = random
155
156
  const stopping = () => Boolean(stopSignal?.aborted || cancelSignal?.aborted);
156
157
  const stopped = (reason) => ({ status: "stopped", taskId, reason, revision: snapshot.revision });
157
158
  for (;;) {
158
- if (leaseLost.aborted) throw new Error("Lead lease renewal failed");
159
+ if (leaseLost.aborted) throw new Error(`Lead lease renewal failed: the lead lease (run_claim, lead epoch ${epoch}) for ${taskId} could not be renewed or the supervisor lease was lost; another lead may have taken over or the coordination service is unreachable. Inspect: ${LEASES_COMMAND} ${taskId}`);
159
160
  if (stopping()) return stopped(cancelSignal?.aborted ? "cancel requested" : "stop requested");
160
161
  if (Date.now() - startedAt >= limits.maxTurns * limits.turnTimeoutMs) return { status: "paused", taskId, reason: "coordinator wall-time limit reached" };
161
162
  snapshot = validateSnapshot(await readTask({ taskId }), taskId);
@@ -79,13 +79,19 @@ function fetchBranch(root, remote, branch) {
79
79
  function ancestor(root, base, head) {
80
80
  try { git(root, "merge-base", "--is-ancestor", base, head); return true; } catch { return false; } /* intentional: non-git/non-worktree is treated as not clean */
81
81
  }
82
- // Host-local loop operational files (daemon lock/log, UI event sidecar) live in
83
- // the lead checkout but are never part of a handoff or integration.
82
+ // Host-local loop operational files (daemon lock/log, UI event sidecar) and
83
+ // per-task runtime data (worker container index and logs) live in the lead
84
+ // checkout but are never part of a handoff or integration.
84
85
  const LOOP_OPERATIONAL_PREFIX = ".agents/data/loop/";
86
+ const TASK_RUNTIME_PATTERN = /^\.agents\/data\/tasks\/[^/]+\/runtime\//;
87
+ /** True for repository-relative paths the loop writes operationally
88
+ * (`.agents/data/loop/` and `.agents/data/tasks/<id>/runtime/`); such paths are
89
+ * ignored by clean checks and working-tree change snapshots. */
90
+ export const isLoopOperationalPath = (path) => typeof path === "string" && (path.startsWith(LOOP_OPERATIONAL_PREFIX) || TASK_RUNTIME_PATTERN.test(path));
85
91
  function assertClean(root) {
86
92
  // Not via git(): its trim() would eat the leading status column of the first row.
87
93
  const status = execFileSync("git", ["status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=all"], { cwd: root, encoding: "utf8", timeout: 30_000, maxBuffer: 4 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
88
- const dirty = status.split("\0").filter(Boolean).map((row) => row.slice(3)).filter((path) => !path.startsWith(LOOP_OPERATIONAL_PREFIX));
94
+ const dirty = status.split("\0").filter(Boolean).map((row) => row.slice(3)).filter((path) => !isLoopOperationalPath(path));
89
95
  if (dirty.length) throw new Error("Git worktree must be clean before handoff or integration");
90
96
  }
91
97
  function normaliseReport(job) {