@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
@@ -9,21 +9,69 @@ import { delimiter, join } from "node:path";
9
9
  const MAX_LINE_BYTES = 1024 * 1024;
10
10
  const MAX_STDERR_BYTES = 4096;
11
11
  const MAX_RESULT_CHARS = 8192;
12
+ const MAX_STREAM_CHARS = 4096;
12
13
  const MAX_DIAGNOSTICS = 16;
14
+ /** Upper bound for the human-readable `summary` carried by live-log events. */
15
+ export const MAX_SUMMARY_CHARS = 240;
13
16
 
14
17
  /** One runtime enumeration for every loop provider. Host gates import this
15
18
  * list; container paths reject providers that need file-based credentials. */
16
19
  export const LOOP_PROVIDERS = Object.freeze(["codex", "claude", "opencode"]);
17
20
  export const LOOP_PROVIDER_LIST = `${LOOP_PROVIDERS.slice(0, -1).join(", ")}, or ${LOOP_PROVIDERS[LOOP_PROVIDERS.length - 1]}`;
18
21
  export const isLoopProvider = (value) => typeof value === "string" && LOOP_PROVIDERS.includes(value);
19
- const OPENCODE_MODEL = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*(?:#[A-Za-z0-9][A-Za-z0-9._-]*)?$/;
22
+ export const OPENCODE_MODEL = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*(?:#[A-Za-z0-9][A-Za-z0-9._-]*)?$/;
20
23
 
21
24
  const record = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : null;
25
+ // Best-effort secret redaction for diagnostics and live-log summaries (commands
26
+ // now reach the event log). Over-redaction is acceptable; leaks are not.
27
+ const SECRET_NAME = String.raw`[A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|PASSWD|PASS|PWD|API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|CREDENTIALS?|AUTH)[A-Z0-9_.-]*`;
22
28
  const redact = (value) => String(value ?? "")
23
- .replace(/\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY))=([^\s]+)/gi, "$1=[REDACTED]")
29
+ // HTTP auth headers first, so the scheme word is not mistaken for the secret.
30
+ .replace(/\b(Authorization\s*:\s*(?:Basic|Bearer|Token|Digest)?\s*)[^\s'"]+/gi, "$1[REDACTED]")
31
+ .replace(/\b(x-api-key|api-key|x-auth-token|private-token|x-access-token)(\s*:\s*)[^\s'"]+/gi, "$1$2[REDACTED]")
32
+ .replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/-]+=*/gi, "$1 [REDACTED]")
33
+ // NAME=value / NAME: value for secret-looking names (env assignments, config, JSON-ish).
34
+ .replace(new RegExp(`\\b(${SECRET_NAME})(\\s*[=:]\\s*)("[^"]*"|'[^']*'|[^\\s'"]+)`, "gi"), "$1$2[REDACTED]")
35
+ // --password value / --token=value style flags.
36
+ .replace(/((?<![\w-])--?(?:password|passwd|pass|pwd|token|secret|api-?key|access-?key|private-?key|auth|credentials?)(?:[= ]+))("[^"]*"|'[^']*'|[^\s'"]+)/gi, "$1[REDACTED]")
37
+ // Credentials embedded in URLs (scheme://user:pass@host).
38
+ .replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi, "$1[REDACTED]@")
39
+ // curl -u user:pass / --user user:pass.
40
+ .replace(/(\s(?:-u|--user)(?:\s+|=))[^\s:]+:[^\s]+/g, "$1[REDACTED]")
41
+ // mysql -pSECRET (password glued to -p).
42
+ .replace(/(\bmysql(?:dump|admin)?\b[^\n]*?\s-p)(\S+)/gi, "$1[REDACTED]")
43
+ // Well-known token formats.
24
44
  .replace(/\b(?:sk|ghp|github_pat)_[A-Za-z0-9_-]+\b/g, "[REDACTED]")
45
+ .replace(/\b(?:sk|gh[pousr]|github_pat|npm|pypi|glpat|hf|xox[abposr])[_-][A-Za-z0-9_-]{8,}/g, "[REDACTED]")
46
+ .replace(/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, "[REDACTED]")
47
+ .replace(/\bAIza[0-9A-Za-z_-]{30,}/g, "[REDACTED]")
48
+ .replace(/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+/g, "[REDACTED]")
49
+ .replace(/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g, "[REDACTED]")
25
50
  .replace(/\s+/g, " ").trim();
26
51
  const diagnostic = (value) => redact(value).slice(0, 512);
52
+ /** Human-readable, secret-redacted, single-line event summary (at most
53
+ * MAX_SUMMARY_CHARS). Returns undefined for empty input so callers can omit the
54
+ * field. Shared by every live-log event producer (lead drivers and workers). */
55
+ const SUMMARY_SCAN_CHARS = 4096;
56
+ export function summarize(value) {
57
+ if (value === undefined || value === null) return undefined;
58
+ // Only the first MAX_SUMMARY_CHARS survive, so redacting a bounded prefix leaks
59
+ // nothing and keeps the regexes linear-time on huge inputs (e.g. base64 blobs).
60
+ const raw = typeof value === "string" ? value : JSON.stringify(value) ?? "";
61
+ const text = redact(raw.slice(0, SUMMARY_SCAN_CHARS));
62
+ if (!text) return undefined;
63
+ return text.length > MAX_SUMMARY_CHARS ? `${text.slice(0, MAX_SUMMARY_CHARS - 1)}…` : text;
64
+ }
65
+ const SUMMARY_INPUT_FIELDS = ["command", "file_path", "filePath", "path", "notebook_path"];
66
+ /** Summary of a tool invocation from its structured input: the shell command or
67
+ * the touched file when present, otherwise the tool name and its input keys
68
+ * (never the values of unknown inputs). */
69
+ export function toolSummary(name, input) {
70
+ const fields = record(input);
71
+ for (const key of SUMMARY_INPUT_FIELDS) if (typeof fields?.[key] === "string" && fields[key].trim()) return summarize(fields[key]);
72
+ const keys = fields ? Object.keys(fields).slice(0, 8) : [];
73
+ return summarize(keys.length ? `${name ?? "tool"} ${JSON.stringify(keys)}` : name);
74
+ }
27
75
  const isId = (value) => typeof value === "string" && value.length > 0 && value.length <= 256 && !/[\s\x00-\x1f]/.test(value);
28
76
  const emit = async (callback, event) => { if (callback) await callback(event); };
29
77
 
@@ -51,8 +99,16 @@ registerAgentAdapter("codex", {
51
99
  if (event.type === "turn.completed") context.complete();
52
100
  if (event.type === "turn.failed" || event.type === "error") throw new Error(`codex reported ${event.type}: ${diagnostic(JSON.stringify(event.error ?? event.message ?? "unknown"))}`);
53
101
  const item = record(event.item);
54
- if (event.type === "item.completed" && item?.type === "agent_message" && typeof item.text === "string") context.result(item.text);
55
- if (event.type === "item.completed" && item?.type === "command_execution") await context.commandEvent(item.exit_code, item.status);
102
+ if (event.type === "item.completed" && item?.type === "agent_message" && typeof item.text === "string") { context.result(item.text); await context.stream(item.text); }
103
+ if (event.type === "item.completed" && item?.type === "command_execution") {
104
+ const command = typeof item.command === "string" ? item.command : undefined;
105
+ await context.tool("shell", summarize(command));
106
+ await context.commandEvent(item.exit_code, item.status, command);
107
+ }
108
+ if (event.type === "item.completed" && item?.type === "file_change" && Array.isArray(item.changes)) {
109
+ const paths = item.changes.map((change) => record(change)?.path).filter((path) => typeof path === "string" && path);
110
+ await context.tool("file_change", summarize(paths.join(", ")));
111
+ }
56
112
  }
57
113
  });
58
114
  registerAgentAdapter("claude", {
@@ -69,10 +125,15 @@ registerAgentAdapter("claude", {
69
125
  const message = record(event.message);
70
126
  if (Array.isArray(message?.content)) for (const block of message.content) {
71
127
  if (!record(block)) continue;
72
- if (block.type === "tool_use" && typeof block.id === "string" && block.name === "Bash") context.toolUses.set(block.id, true);
128
+ if (block.type === "tool_use" && typeof block.name === "string" && block.name) {
129
+ await context.tool(block.name, toolSummary(block.name, block.input));
130
+ // Only shell commands map to lifecycle_command; remember the command for the result.
131
+ if (typeof block.id === "string" && block.name === "Bash") context.toolUses.set(block.id, typeof record(block.input)?.command === "string" ? block.input.command : "");
132
+ }
73
133
  if (block.type === "tool_result" && typeof block.tool_use_id === "string" && context.toolUses.has(block.tool_use_id)) {
134
+ const command = context.toolUses.get(block.tool_use_id);
74
135
  context.toolUses.delete(block.tool_use_id);
75
- await context.commandEvent(block.is_error === true ? 1 : 0, block.is_error === true ? "failed" : "completed");
136
+ await context.commandEvent(block.is_error === true ? 1 : 0, block.is_error === true ? "failed" : "completed", command);
76
137
  }
77
138
  }
78
139
  }
@@ -88,12 +149,18 @@ registerAgentAdapter("opencode", {
88
149
  },
89
150
  parse: async (event, context) => {
90
151
  if (event.sessionID !== undefined) await context.captureId(event.sessionID);
152
+ if (event.type === "error") {
153
+ const info = record(event.error);
154
+ throw new Error(`opencode reported an error: ${diagnostic(info?.message ?? info?.type ?? JSON.stringify(event.error ?? "unknown"))}`);
155
+ }
91
156
  const part = record(event.part);
92
- if (event.type === "text" && typeof part?.text === "string") context.result(part.text);
157
+ if (event.type === "text" && typeof part?.text === "string") { context.result(part.text); await context.stream(part.text); }
158
+ const state = record(part?.state);
159
+ if (event.type === "tool_use" && typeof part?.tool === "string") await context.tool(part.tool, toolSummary(part.tool, state?.input));
93
160
  if (event.type === "tool_use" && part?.tool === "shell") {
94
- const state = record(part.state);
95
161
  const metadata = record(record(state?.metadata)?.metadata);
96
- await context.commandEvent(metadata?.exit, state?.status === "error" ? "failed" : state?.status);
162
+ const command = record(state?.input)?.command;
163
+ await context.commandEvent(metadata?.exit, state?.status === "error" ? "failed" : state?.status, typeof command === "string" ? command : undefined);
97
164
  }
98
165
  if (event.type === "step_finish" && part?.reason === "stop") context.complete();
99
166
  }
@@ -105,6 +172,16 @@ registerAgentAdapter("opencode", {
105
172
  * a checkpoint is durable. Transport and protocol failures reject with a bounded,
106
173
  * redacted error message.
107
174
  */
175
+ /** Host turn environment: drops the worker credentials `loop-env.mjs` added from the env file
176
+ * or provider config (listed in AIDLC_LEAD_ENV_EXCLUDE); shell-exported values stay. */
177
+ export function leadEnv(env) {
178
+ const exclude = String(env.AIDLC_LEAD_ENV_EXCLUDE ?? "").split(",").map((key) => key.trim()).filter(Boolean);
179
+ if (!exclude.length && env.AIDLC_LEAD_ENV_EXCLUDE === undefined) return env;
180
+ const copy = { ...env };
181
+ for (const key of [...exclude, "AIDLC_LEAD_ENV_EXCLUDE"]) delete copy[key];
182
+ return copy;
183
+ }
184
+
108
185
  export async function runAgentTurn({ agent, root, sessionId, prompt, timeoutMs, onSessionId, onEvent, signal, env, spawnProcess = spawn } = {}) {
109
186
  // --- Validate inputs and resolve the adapter -------------------------------
110
187
  const adapter = adapters.get(agent);
@@ -123,7 +200,7 @@ export async function runAgentTurn({ agent, root, sessionId, prompt, timeoutMs,
123
200
  if (typeof spawnProcess !== "function") throw new Error("spawnProcess must be a function");
124
201
  const input = adapter.input?.(prompt);
125
202
  if (input !== undefined && typeof input !== "string") throw new Error("agent adapter input must be a string");
126
- const child = spawnProcess(adapter.command, args, { cwd: root, shell: false, env: { ...(env ?? process.env), PWD: root }, stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"] });
203
+ const child = spawnProcess(adapter.command, args, { cwd: root, shell: false, env: { ...leadEnv(env ?? process.env), PWD: root }, stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"] });
127
204
  if (input !== undefined) {
128
205
  if (!child.stdin) throw new Error("agent transport lacks stdin");
129
206
  child.stdin.on("error", () => {});
@@ -162,18 +239,27 @@ export async function runAgentTurn({ agent, root, sessionId, prompt, timeoutMs,
162
239
  await emit(onSessionId, value);
163
240
  }
164
241
  };
165
- const commandEvent = async (exitCode, status) => {
242
+ const commandEvent = async (exitCode, status, command) => {
166
243
  const safeExitCode = Number.isSafeInteger(exitCode) ? exitCode : null;
167
244
  const safeStatus = ["completed", "failed", "in_progress"].includes(status) ? status : "unknown";
168
- await emit(onEvent, { type: "lifecycle_command", exitCode: safeExitCode, status: safeStatus });
245
+ // The command string is a redacted summary only; command output never leaves the adapter.
246
+ const summary = summarize(command);
247
+ await emit(onEvent, { type: "lifecycle_command", exitCode: safeExitCode, status: safeStatus, ...(summary ? { summary } : {}) });
169
248
  if ((safeExitCode !== null && safeExitCode !== 0) || safeStatus === "failed") addDiagnostic(`commandFailed exitCode=${String(safeExitCode ?? "unknown")} status=${safeStatus}`);
170
249
  };
250
+ const stream = async (text) => {
251
+ if (typeof text === "string" && text.trim()) await emit(onEvent, { type: "agent_text", text: text.slice(0, MAX_STREAM_CHARS) });
252
+ };
253
+ const tool = async (name, summary) => {
254
+ if (typeof name !== "string" || !name) return;
255
+ const safeSummary = typeof summary === "string" ? summarize(summary) : undefined;
256
+ await emit(onEvent, { type: "agent_tool", tool: name, ...(safeSummary ? { summary: safeSummary } : {}) });
257
+ };
171
258
  const parseEvent = async (line) => {
172
259
  let event;
173
260
  try { event = JSON.parse(line); } catch { throw new Error(`${agent} emitted malformed JSONL`); }
174
261
  if (!record(event) || typeof event.type !== "string") throw new Error(`${agent} emitted malformed event`);
175
- await adapter.parse(event, { captureId, commandEvent, toolUses, complete: () => { completed = true; resultSeen = true; }, result: (value) => { resultText = value.slice(0, MAX_RESULT_CHARS); } });
176
- await emit(onEvent, { type: "agent_event", eventType: event.type });
262
+ await adapter.parse(event, { captureId, commandEvent, toolUses, stream, tool, complete: () => { completed = true; resultSeen = true; }, result: (value) => { resultText = value.slice(0, MAX_RESULT_CHARS); } });
177
263
  };
178
264
  const queueLine = (line) => {
179
265
  processing = processing.then(() => parseEvent(line)).catch((error) => fail(error.message));
@@ -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
+ }