smol-symphony 0.2.0 → 0.3.1

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 (540) hide show
  1. package/AGENTS.md +41 -22
  2. package/DESIGN.md +494 -273
  3. package/README.md +109 -57
  4. package/SPEC.md +33 -24
  5. package/WORKFLOW.minimal.yaml +34 -0
  6. package/{WORKFLOW.template.md → WORKFLOW.template.yaml} +409 -256
  7. package/WORKFLOW.yaml +487 -0
  8. package/assets/skills/symphony-issues/SKILL.md +136 -0
  9. package/assets/symphony-mise.system.toml +68 -0
  10. package/dist/bin/symphony.js +22 -786
  11. package/dist/bin/symphony.js.map +1 -1
  12. package/dist/core/actions/context.js +109 -0
  13. package/dist/core/actions/context.js.map +1 -0
  14. package/dist/{actions/parsing.js → core/actions/parse.js} +33 -114
  15. package/dist/core/actions/parse.js.map +1 -0
  16. package/dist/core/actions/plan.js +197 -0
  17. package/dist/core/actions/plan.js.map +1 -0
  18. package/dist/core/actions/predicates.js +111 -0
  19. package/dist/core/actions/predicates.js.map +1 -0
  20. package/dist/core/actions/run-fold.js +248 -0
  21. package/dist/core/actions/run-fold.js.map +1 -0
  22. package/dist/core/actions/template.js +118 -0
  23. package/dist/core/actions/template.js.map +1 -0
  24. package/dist/core/cli/args.js +116 -0
  25. package/dist/core/cli/args.js.map +1 -0
  26. package/dist/core/coerce.js +75 -0
  27. package/dist/core/coerce.js.map +1 -0
  28. package/dist/core/credential/account-id.js +20 -0
  29. package/dist/core/credential/account-id.js.map +1 -0
  30. package/dist/core/credential/adapter-config.js +136 -0
  31. package/dist/core/credential/adapter-config.js.map +1 -0
  32. package/dist/core/credential/availability.js +98 -0
  33. package/dist/core/credential/availability.js.map +1 -0
  34. package/dist/core/credential/extract.js +228 -0
  35. package/dist/core/credential/extract.js.map +1 -0
  36. package/dist/core/credential/fake-creds.js +171 -0
  37. package/dist/core/credential/fake-creds.js.map +1 -0
  38. package/dist/core/credential/identity.js +125 -0
  39. package/dist/core/credential/identity.js.map +1 -0
  40. package/dist/core/credential/shape.js +230 -0
  41. package/dist/core/credential/shape.js.map +1 -0
  42. package/dist/core/credential/strings.js +15 -0
  43. package/dist/core/credential/strings.js.map +1 -0
  44. package/dist/core/doctor/checks.js +303 -0
  45. package/dist/core/doctor/checks.js.map +1 -0
  46. package/dist/core/git/result.js +107 -0
  47. package/dist/core/git/result.js.map +1 -0
  48. package/dist/core/http/decisions.js +225 -0
  49. package/dist/core/http/decisions.js.map +1 -0
  50. package/dist/{http.js → core/http/render.js} +472 -738
  51. package/dist/core/http/render.js.map +1 -0
  52. package/dist/{http-handlers.js → core/http/routes.js} +52 -87
  53. package/dist/core/http/routes.js.map +1 -0
  54. package/dist/core/http/views.js +181 -0
  55. package/dist/core/http/views.js.map +1 -0
  56. package/dist/core/image/managed-image.js +95 -0
  57. package/dist/core/image/managed-image.js.map +1 -0
  58. package/dist/core/issue/file.js +149 -0
  59. package/dist/core/issue/file.js.map +1 -0
  60. package/dist/core/issue/parse.js +210 -0
  61. package/dist/core/issue/parse.js.map +1 -0
  62. package/dist/core/mcp/dispatch.js +239 -0
  63. package/dist/core/mcp/dispatch.js.map +1 -0
  64. package/dist/core/mcp/post-move.js +92 -0
  65. package/dist/core/mcp/post-move.js.map +1 -0
  66. package/dist/core/mcp/protocol.js +293 -0
  67. package/dist/core/mcp/protocol.js.map +1 -0
  68. package/dist/core/mcp/url.js +162 -0
  69. package/dist/core/mcp/url.js.map +1 -0
  70. package/dist/core/path.js +63 -0
  71. package/dist/core/path.js.map +1 -0
  72. package/dist/core/reconcile/image-decide.js +48 -0
  73. package/dist/core/reconcile/image-decide.js.map +1 -0
  74. package/dist/core/reconcile/ledger.js +142 -0
  75. package/dist/core/reconcile/ledger.js.map +1 -0
  76. package/dist/core/reconcile/pr-classify.js +62 -0
  77. package/dist/core/reconcile/pr-classify.js.map +1 -0
  78. package/dist/{reconciler → core/reconcile}/pr-decide.js +25 -12
  79. package/dist/core/reconcile/pr-decide.js.map +1 -0
  80. package/dist/core/reconcile/pr-loop.js +161 -0
  81. package/dist/core/reconcile/pr-loop.js.map +1 -0
  82. package/dist/core/reconcile/pr-notes.js +35 -0
  83. package/dist/core/reconcile/pr-notes.js.map +1 -0
  84. package/dist/core/reconcile/vm-decide.js +70 -0
  85. package/dist/core/reconcile/vm-decide.js.map +1 -0
  86. package/dist/core/reconcile/vm-reap.js +207 -0
  87. package/dist/core/reconcile/vm-reap.js.map +1 -0
  88. package/dist/core/reconcile/workspace-decide.js +162 -0
  89. package/dist/core/reconcile/workspace-decide.js.map +1 -0
  90. package/dist/core/runlog/summary.js +231 -0
  91. package/dist/core/runlog/summary.js.map +1 -0
  92. package/dist/core/runner/dispatch-config.js +95 -0
  93. package/dist/core/runner/dispatch-config.js.map +1 -0
  94. package/dist/core/runner/injection.js +61 -0
  95. package/dist/core/runner/injection.js.map +1 -0
  96. package/dist/core/runner/mise.js +210 -0
  97. package/dist/core/runner/mise.js.map +1 -0
  98. package/dist/core/runner/prompt.js +720 -0
  99. package/dist/core/runner/prompt.js.map +1 -0
  100. package/dist/core/runner/turn.js +242 -0
  101. package/dist/core/runner/turn.js.map +1 -0
  102. package/dist/core/runner/vm-plan.js +390 -0
  103. package/dist/core/runner/vm-plan.js.map +1 -0
  104. package/dist/core/schedule/admission.js +123 -0
  105. package/dist/core/schedule/admission.js.map +1 -0
  106. package/dist/core/schedule/circuit-breaker.js +111 -0
  107. package/dist/core/schedule/circuit-breaker.js.map +1 -0
  108. package/dist/core/schedule/eligibility.js +83 -0
  109. package/dist/core/schedule/eligibility.js.map +1 -0
  110. package/dist/core/schedule/reconcile-issue.js +82 -0
  111. package/dist/core/schedule/reconcile-issue.js.map +1 -0
  112. package/dist/core/schedule/retry.js +96 -0
  113. package/dist/core/schedule/retry.js.map +1 -0
  114. package/dist/core/schedule/sleep-cycle.js +133 -0
  115. package/dist/core/schedule/sleep-cycle.js.map +1 -0
  116. package/dist/core/schedule/slots.js +124 -0
  117. package/dist/core/schedule/slots.js.map +1 -0
  118. package/dist/core/schedule/tick.js +553 -0
  119. package/dist/core/schedule/tick.js.map +1 -0
  120. package/dist/core/schedule/token-fold.js +181 -0
  121. package/dist/core/schedule/token-fold.js.map +1 -0
  122. package/dist/core/state-resolve.js +86 -0
  123. package/dist/core/state-resolve.js.map +1 -0
  124. package/dist/core/vm-guards.js +278 -0
  125. package/dist/core/vm-guards.js.map +1 -0
  126. package/dist/core/workflow/derive.js +107 -0
  127. package/dist/core/workflow/derive.js.map +1 -0
  128. package/dist/core/workflow/parse.js +687 -0
  129. package/dist/core/workflow/parse.js.map +1 -0
  130. package/dist/core/workflow/prompt-probe.js +78 -0
  131. package/dist/core/workflow/prompt-probe.js.map +1 -0
  132. package/dist/core/workflow/validate.js +189 -0
  133. package/dist/core/workflow/validate.js.map +1 -0
  134. package/dist/core/workspace-key.js +19 -0
  135. package/dist/core/workspace-key.js.map +1 -0
  136. package/dist/shell/actions-runner.js +356 -0
  137. package/dist/shell/actions-runner.js.map +1 -0
  138. package/dist/shell/adapter/adapter-registry.js +45 -0
  139. package/dist/shell/adapter/adapter-registry.js.map +1 -0
  140. package/dist/shell/adapter/clock-random.js +96 -0
  141. package/dist/shell/adapter/clock-random.js.map +1 -0
  142. package/dist/shell/adapter/gondolin-dispatch-helpers.js +158 -0
  143. package/dist/shell/adapter/gondolin-dispatch-helpers.js.map +1 -0
  144. package/dist/shell/adapter/gondolin-dispatch.js +385 -0
  145. package/dist/shell/adapter/gondolin-dispatch.js.map +1 -0
  146. package/dist/shell/adapter/gondolin-image-converter.js +233 -0
  147. package/dist/shell/adapter/gondolin-image-converter.js.map +1 -0
  148. package/dist/shell/adapter/gondolin-image-fetch.js +180 -0
  149. package/dist/shell/adapter/gondolin-image-fetch.js.map +1 -0
  150. package/dist/shell/adapter/launcher-asset.js +57 -0
  151. package/dist/shell/adapter/launcher-asset.js.map +1 -0
  152. package/dist/shell/adapter/mise-config-asset.js +65 -0
  153. package/dist/shell/adapter/mise-config-asset.js.map +1 -0
  154. package/dist/shell/adapter/workflow-loader.js +304 -0
  155. package/dist/shell/adapter/workflow-loader.js.map +1 -0
  156. package/dist/shell/cli/doctor.js +268 -0
  157. package/dist/shell/cli/doctor.js.map +1 -0
  158. package/dist/shell/effect-interpreter-families.js +314 -0
  159. package/dist/shell/effect-interpreter-families.js.map +1 -0
  160. package/dist/shell/effect-interpreter.js +29 -0
  161. package/dist/shell/effect-interpreter.js.map +1 -0
  162. package/dist/shell/interp/acp-frame.js +137 -0
  163. package/dist/shell/interp/acp-frame.js.map +1 -0
  164. package/dist/shell/interp/acp-ws-conn.js +320 -0
  165. package/dist/shell/interp/acp-ws-conn.js.map +1 -0
  166. package/dist/shell/interp/acp-ws-frames.js +159 -0
  167. package/dist/shell/interp/acp-ws-frames.js.map +1 -0
  168. package/dist/shell/interp/acp-ws.js +197 -0
  169. package/dist/shell/interp/acp-ws.js.map +1 -0
  170. package/dist/shell/interp/acp.js +319 -0
  171. package/dist/shell/interp/acp.js.map +1 -0
  172. package/dist/shell/interp/credential-defaults.js +128 -0
  173. package/dist/shell/interp/credential-defaults.js.map +1 -0
  174. package/dist/shell/interp/credential-hooks.js +149 -0
  175. package/dist/shell/interp/credential-hooks.js.map +1 -0
  176. package/dist/shell/interp/credential-registry.js +226 -0
  177. package/dist/shell/interp/credential-registry.js.map +1 -0
  178. package/dist/shell/interp/credential.js +103 -0
  179. package/dist/shell/interp/credential.js.map +1 -0
  180. package/dist/shell/interp/gh.js +163 -0
  181. package/dist/shell/interp/gh.js.map +1 -0
  182. package/dist/shell/interp/git.js +28 -0
  183. package/dist/shell/interp/git.js.map +1 -0
  184. package/dist/shell/interp/log.js +213 -0
  185. package/dist/shell/interp/log.js.map +1 -0
  186. package/dist/shell/interp/process.js +178 -0
  187. package/dist/shell/interp/process.js.map +1 -0
  188. package/dist/shell/interp/runlog.js +193 -0
  189. package/dist/shell/interp/runlog.js.map +1 -0
  190. package/dist/shell/interp/timer.js +64 -0
  191. package/dist/shell/interp/timer.js.map +1 -0
  192. package/dist/shell/interp/tracker-disk.js +99 -0
  193. package/dist/shell/interp/tracker-disk.js.map +1 -0
  194. package/dist/shell/interp/tracker-parse.js +71 -0
  195. package/dist/shell/interp/tracker-parse.js.map +1 -0
  196. package/dist/shell/interp/tracker-scan.js +238 -0
  197. package/dist/shell/interp/tracker-scan.js.map +1 -0
  198. package/dist/shell/interp/tracker-write.js +91 -0
  199. package/dist/shell/interp/tracker-write.js.map +1 -0
  200. package/dist/shell/interp/tracker.js +41 -0
  201. package/dist/shell/interp/tracker.js.map +1 -0
  202. package/dist/shell/interp/tty.js +48 -0
  203. package/dist/shell/interp/tty.js.map +1 -0
  204. package/dist/shell/interp/vm.js +199 -0
  205. package/dist/shell/interp/vm.js.map +1 -0
  206. package/dist/shell/interp/workspace.js +310 -0
  207. package/dist/shell/interp/workspace.js.map +1 -0
  208. package/dist/shell/main-acp.js +78 -0
  209. package/dist/shell/main-acp.js.map +1 -0
  210. package/dist/shell/main-adapters.js +222 -0
  211. package/dist/shell/main-adapters.js.map +1 -0
  212. package/dist/shell/main-credential.js +122 -0
  213. package/dist/shell/main-credential.js.map +1 -0
  214. package/dist/shell/main-doctor.js +22 -0
  215. package/dist/shell/main-doctor.js.map +1 -0
  216. package/dist/shell/main-entry.js +46 -0
  217. package/dist/shell/main-entry.js.map +1 -0
  218. package/dist/shell/main-http-csrf.js +45 -0
  219. package/dist/shell/main-http-csrf.js.map +1 -0
  220. package/dist/shell/main-http-handler.js +389 -0
  221. package/dist/shell/main-http-handler.js.map +1 -0
  222. package/dist/shell/main-http-mcp.js +122 -0
  223. package/dist/shell/main-http-mcp.js.map +1 -0
  224. package/dist/shell/main-http-views.js +253 -0
  225. package/dist/shell/main-http-views.js.map +1 -0
  226. package/dist/shell/main-http.js +76 -0
  227. package/dist/shell/main-http.js.map +1 -0
  228. package/dist/shell/main-loops.js +130 -0
  229. package/dist/shell/main-loops.js.map +1 -0
  230. package/dist/shell/main-mcp.js +129 -0
  231. package/dist/shell/main-mcp.js.map +1 -0
  232. package/dist/shell/main-orchestrator.js +120 -0
  233. package/dist/shell/main-orchestrator.js.map +1 -0
  234. package/dist/shell/main-preflight.js +43 -0
  235. package/dist/shell/main-preflight.js.map +1 -0
  236. package/dist/shell/main-reconcilers-helpers.js +244 -0
  237. package/dist/shell/main-reconcilers-helpers.js.map +1 -0
  238. package/dist/shell/main-reconcilers-pr.js +148 -0
  239. package/dist/shell/main-reconcilers-pr.js.map +1 -0
  240. package/dist/shell/main-reconcilers.js +225 -0
  241. package/dist/shell/main-reconcilers.js.map +1 -0
  242. package/dist/shell/main-runner.js +355 -0
  243. package/dist/shell/main-runner.js.map +1 -0
  244. package/dist/shell/main-scaffold.js +116 -0
  245. package/dist/shell/main-scaffold.js.map +1 -0
  246. package/dist/shell/main-shutdown.js +115 -0
  247. package/dist/shell/main-shutdown.js.map +1 -0
  248. package/dist/shell/main-startup.js +48 -0
  249. package/dist/shell/main-startup.js.map +1 -0
  250. package/dist/shell/main-substrates.js +43 -0
  251. package/dist/shell/main-substrates.js.map +1 -0
  252. package/dist/shell/main.js +385 -0
  253. package/dist/shell/main.js.map +1 -0
  254. package/dist/shell/orchestrator-feedback.js +69 -0
  255. package/dist/shell/orchestrator-feedback.js.map +1 -0
  256. package/dist/shell/orchestrator-image.js +167 -0
  257. package/dist/shell/orchestrator-image.js.map +1 -0
  258. package/dist/shell/orchestrator-loop.js +468 -0
  259. package/dist/shell/orchestrator-loop.js.map +1 -0
  260. package/dist/shell/orchestrator-reconcile.js +36 -0
  261. package/dist/shell/orchestrator-reconcile.js.map +1 -0
  262. package/dist/shell/reconciler-loop.js +228 -0
  263. package/dist/shell/reconciler-loop.js.map +1 -0
  264. package/dist/shell/runner-loop-turn.js +301 -0
  265. package/dist/shell/runner-loop-turn.js.map +1 -0
  266. package/dist/shell/runner-loop.js +338 -0
  267. package/dist/shell/runner-loop.js.map +1 -0
  268. package/dist/shell/server/http.js +208 -0
  269. package/dist/shell/server/http.js.map +1 -0
  270. package/dist/shell/server/mcp-runtime-effects.js +237 -0
  271. package/dist/shell/server/mcp-runtime-effects.js.map +1 -0
  272. package/dist/shell/server/mcp-runtime.js +99 -0
  273. package/dist/shell/server/mcp-runtime.js.map +1 -0
  274. package/dist/shell/workspace-key.js +14 -0
  275. package/dist/shell/workspace-key.js.map +1 -0
  276. package/dist/types/acp.js +8 -0
  277. package/dist/types/acp.js.map +1 -0
  278. package/dist/types/actions/plan.js +6 -0
  279. package/dist/types/actions/plan.js.map +1 -0
  280. package/dist/types/actions/predicates.js +6 -0
  281. package/dist/types/actions/predicates.js.map +1 -0
  282. package/dist/types/actions/run-fold.js +8 -0
  283. package/dist/types/actions/run-fold.js.map +1 -0
  284. package/dist/types/actions.js +7 -0
  285. package/dist/types/actions.js.map +1 -0
  286. package/dist/types/adapter/clock-random.js +4 -0
  287. package/dist/types/adapter/clock-random.js.map +1 -0
  288. package/dist/types/adapter/gondolin-image-converter.js +5 -0
  289. package/dist/types/adapter/gondolin-image-converter.js.map +1 -0
  290. package/dist/types/adapter/gondolin-image-fetch.js +5 -0
  291. package/dist/types/adapter/gondolin-image-fetch.js.map +1 -0
  292. package/dist/types/adapter/workflow-loader.js +4 -0
  293. package/dist/types/adapter/workflow-loader.js.map +1 -0
  294. package/dist/types/cli/args.js +8 -0
  295. package/dist/types/cli/args.js.map +1 -0
  296. package/dist/types/config.js +8 -0
  297. package/dist/types/config.js.map +1 -0
  298. package/dist/types/credential-interp.js +6 -0
  299. package/dist/types/credential-interp.js.map +1 -0
  300. package/dist/types/credentials.js +10 -0
  301. package/dist/types/credentials.js.map +1 -0
  302. package/dist/types/doctor.js +7 -0
  303. package/dist/types/doctor.js.map +1 -0
  304. package/dist/types/domain.js +7 -0
  305. package/dist/types/domain.js.map +1 -0
  306. package/dist/types/effect.js +15 -0
  307. package/dist/types/effect.js.map +1 -0
  308. package/dist/types/errors.js +39 -0
  309. package/dist/types/errors.js.map +1 -0
  310. package/dist/types/http/decisions.js +6 -0
  311. package/dist/types/http/decisions.js.map +1 -0
  312. package/dist/types/http/render.js +10 -0
  313. package/dist/types/http/render.js.map +1 -0
  314. package/dist/types/http/views.js +6 -0
  315. package/dist/types/http/views.js.map +1 -0
  316. package/dist/types/http.js +9 -0
  317. package/dist/types/http.js.map +1 -0
  318. package/dist/types/image/managed-image.js +7 -0
  319. package/dist/types/image/managed-image.js.map +1 -0
  320. package/dist/types/interp/effect-interpreter.js +8 -0
  321. package/dist/types/interp/effect-interpreter.js.map +1 -0
  322. package/dist/types/interp/tracker.js +7 -0
  323. package/dist/types/interp/tracker.js.map +1 -0
  324. package/dist/types/issue/file.js +6 -0
  325. package/dist/types/issue/file.js.map +1 -0
  326. package/dist/types/issue/parse.js +8 -0
  327. package/dist/types/issue/parse.js.map +1 -0
  328. package/dist/types/main-acp.js +13 -0
  329. package/dist/types/main-acp.js.map +1 -0
  330. package/dist/types/main-adapters.js +5 -0
  331. package/dist/types/main-adapters.js.map +1 -0
  332. package/dist/types/main-credential.js +21 -0
  333. package/dist/types/main-credential.js.map +1 -0
  334. package/dist/types/main-doctor.js +6 -0
  335. package/dist/types/main-doctor.js.map +1 -0
  336. package/dist/types/main-http-handler.js +12 -0
  337. package/dist/types/main-http-handler.js.map +1 -0
  338. package/dist/types/main-http.js +5 -0
  339. package/dist/types/main-http.js.map +1 -0
  340. package/dist/types/main-loops.js +5 -0
  341. package/dist/types/main-loops.js.map +1 -0
  342. package/dist/types/main-mcp.js +12 -0
  343. package/dist/types/main-mcp.js.map +1 -0
  344. package/dist/types/main-orchestrator.js +5 -0
  345. package/dist/types/main-orchestrator.js.map +1 -0
  346. package/dist/types/main-reconcilers.js +11 -0
  347. package/dist/types/main-reconcilers.js.map +1 -0
  348. package/dist/types/main-runner.js +13 -0
  349. package/dist/types/main-runner.js.map +1 -0
  350. package/dist/types/main-startup.js +5 -0
  351. package/dist/types/main-startup.js.map +1 -0
  352. package/dist/types/main-substrates.js +5 -0
  353. package/dist/types/main-substrates.js.map +1 -0
  354. package/dist/types/mcp/dispatch.js +4 -0
  355. package/dist/types/mcp/dispatch.js.map +1 -0
  356. package/dist/types/mcp/post-move.js +7 -0
  357. package/dist/types/mcp/post-move.js.map +1 -0
  358. package/dist/types/mcp.js +9 -0
  359. package/dist/types/mcp.js.map +1 -0
  360. package/dist/types/ports.js +12 -0
  361. package/dist/types/ports.js.map +1 -0
  362. package/dist/types/reconcile/image-decide.js +5 -0
  363. package/dist/types/reconcile/image-decide.js.map +1 -0
  364. package/dist/types/reconcile/ledger.js +7 -0
  365. package/dist/types/reconcile/ledger.js.map +1 -0
  366. package/dist/types/reconcile/pr-loop.js +8 -0
  367. package/dist/types/reconcile/pr-loop.js.map +1 -0
  368. package/dist/types/reconcile/vm-reap.js +8 -0
  369. package/dist/types/reconcile/vm-reap.js.map +1 -0
  370. package/dist/types/reconcile/workspace-decide.js +7 -0
  371. package/dist/types/reconcile/workspace-decide.js.map +1 -0
  372. package/dist/types/reconcile.js +9 -0
  373. package/dist/types/reconcile.js.map +1 -0
  374. package/dist/types/runlog.js +7 -0
  375. package/dist/types/runlog.js.map +1 -0
  376. package/dist/types/runner/actions-runner.js +12 -0
  377. package/dist/types/runner/actions-runner.js.map +1 -0
  378. package/dist/types/runner/gondolin-dispatch.js +5 -0
  379. package/dist/types/runner/gondolin-dispatch.js.map +1 -0
  380. package/dist/types/runner/injection.js +6 -0
  381. package/dist/types/runner/injection.js.map +1 -0
  382. package/dist/types/runner/runner-loop.js +5 -0
  383. package/dist/types/runner/runner-loop.js.map +1 -0
  384. package/dist/types/runner/turn.js +4 -0
  385. package/dist/types/runner/turn.js.map +1 -0
  386. package/dist/types/runner/vm-plan.js +4 -0
  387. package/dist/types/runner/vm-plan.js.map +1 -0
  388. package/dist/types/runtime.js +9 -0
  389. package/dist/types/runtime.js.map +1 -0
  390. package/dist/types/schedule/admission.js +7 -0
  391. package/dist/types/schedule/admission.js.map +1 -0
  392. package/dist/types/schedule/circuit-breaker.js +2 -0
  393. package/dist/types/schedule/circuit-breaker.js.map +1 -0
  394. package/dist/types/schedule/eligibility.js +9 -0
  395. package/dist/types/schedule/eligibility.js.map +1 -0
  396. package/dist/types/schedule/orchestrator-loop.js +10 -0
  397. package/dist/types/schedule/orchestrator-loop.js.map +1 -0
  398. package/dist/types/schedule/sleep-cycle.js +4 -0
  399. package/dist/types/schedule/sleep-cycle.js.map +1 -0
  400. package/dist/types/schedule/slots.js +8 -0
  401. package/dist/types/schedule/slots.js.map +1 -0
  402. package/dist/types/schedule/tick.js +9 -0
  403. package/dist/types/schedule/tick.js.map +1 -0
  404. package/dist/types/server/mcp-runtime.js +8 -0
  405. package/dist/types/server/mcp-runtime.js.map +1 -0
  406. package/dist/types/workflow/parse.js +4 -0
  407. package/dist/types/workflow/parse.js.map +1 -0
  408. package/package.json +22 -10
  409. package/patches/@earendil-works+gondolin+0.12.0.patch +173 -0
  410. package/prompts/Reflect.md +91 -0
  411. package/prompts/Review.md +97 -0
  412. package/prompts/Todo.md +96 -0
  413. package/prompts/_footer.md +41 -0
  414. package/prompts/_preamble.md +42 -0
  415. package/prompts-minimal/Todo.md +26 -0
  416. package/scripts/postinstall.mjs +63 -0
  417. package/scripts/vm-agent.mjs +312 -90
  418. package/WORKFLOW.md +0 -744
  419. package/dist/acp-bridge.js +0 -324
  420. package/dist/acp-bridge.js.map +0 -1
  421. package/dist/actions/cache.js +0 -191
  422. package/dist/actions/cache.js.map +0 -1
  423. package/dist/actions/effects.js +0 -41
  424. package/dist/actions/effects.js.map +0 -1
  425. package/dist/actions/executor.js +0 -570
  426. package/dist/actions/executor.js.map +0 -1
  427. package/dist/actions/index.js +0 -13
  428. package/dist/actions/index.js.map +0 -1
  429. package/dist/actions/parsing.js.map +0 -1
  430. package/dist/actions/predicate-env.js +0 -27
  431. package/dist/actions/predicate-env.js.map +0 -1
  432. package/dist/actions/predicates.js +0 -49
  433. package/dist/actions/predicates.js.map +0 -1
  434. package/dist/actions/templating.js +0 -66
  435. package/dist/actions/templating.js.map +0 -1
  436. package/dist/actions/types.js +0 -15
  437. package/dist/actions/types.js.map +0 -1
  438. package/dist/agent/acp.js +0 -473
  439. package/dist/agent/acp.js.map +0 -1
  440. package/dist/agent/adapter-names.js +0 -159
  441. package/dist/agent/adapter-names.js.map +0 -1
  442. package/dist/agent/adapters.js +0 -511
  443. package/dist/agent/adapters.js.map +0 -1
  444. package/dist/agent/credential-extractors.js +0 -342
  445. package/dist/agent/credential-extractors.js.map +0 -1
  446. package/dist/agent/credential-secrets.js +0 -628
  447. package/dist/agent/credential-secrets.js.map +0 -1
  448. package/dist/agent/credential-ticker.js +0 -57
  449. package/dist/agent/credential-ticker.js.map +0 -1
  450. package/dist/agent/gondolin-creds-staging.js +0 -356
  451. package/dist/agent/gondolin-creds-staging.js.map +0 -1
  452. package/dist/agent/gondolin-dispatch.js +0 -375
  453. package/dist/agent/gondolin-dispatch.js.map +0 -1
  454. package/dist/agent/gondolin.js +0 -124
  455. package/dist/agent/gondolin.js.map +0 -1
  456. package/dist/agent/runner-decisions.js +0 -134
  457. package/dist/agent/runner-decisions.js.map +0 -1
  458. package/dist/agent/runner.js +0 -1456
  459. package/dist/agent/runner.js.map +0 -1
  460. package/dist/agent/tool-call-summary.js +0 -102
  461. package/dist/agent/tool-call-summary.js.map +0 -1
  462. package/dist/agent/vm-acp-mapping.js +0 -73
  463. package/dist/agent/vm-acp-mapping.js.map +0 -1
  464. package/dist/agent/vm-guards.js +0 -262
  465. package/dist/agent/vm-guards.js.map +0 -1
  466. package/dist/agent/vm-port.js +0 -22
  467. package/dist/agent/vm-port.js.map +0 -1
  468. package/dist/agent/vm-process-registry.js +0 -79
  469. package/dist/agent/vm-process-registry.js.map +0 -1
  470. package/dist/bin/cli-args.js +0 -105
  471. package/dist/bin/cli-args.js.map +0 -1
  472. package/dist/errors.js +0 -15
  473. package/dist/errors.js.map +0 -1
  474. package/dist/http-disk.js +0 -135
  475. package/dist/http-disk.js.map +0 -1
  476. package/dist/http-handlers.js.map +0 -1
  477. package/dist/http.js.map +0 -1
  478. package/dist/issues.js +0 -178
  479. package/dist/issues.js.map +0 -1
  480. package/dist/logging.js +0 -203
  481. package/dist/logging.js.map +0 -1
  482. package/dist/mcp.js +0 -706
  483. package/dist/mcp.js.map +0 -1
  484. package/dist/memory.js +0 -85
  485. package/dist/memory.js.map +0 -1
  486. package/dist/orchestrator-decisions.js +0 -331
  487. package/dist/orchestrator-decisions.js.map +0 -1
  488. package/dist/orchestrator.js +0 -1569
  489. package/dist/orchestrator.js.map +0 -1
  490. package/dist/prompt.js +0 -65
  491. package/dist/prompt.js.map +0 -1
  492. package/dist/reconciler/cache.js +0 -65
  493. package/dist/reconciler/cache.js.map +0 -1
  494. package/dist/reconciler/index.js +0 -448
  495. package/dist/reconciler/index.js.map +0 -1
  496. package/dist/reconciler/ledger.js +0 -131
  497. package/dist/reconciler/ledger.js.map +0 -1
  498. package/dist/reconciler/pr-adapters.js +0 -174
  499. package/dist/reconciler/pr-adapters.js.map +0 -1
  500. package/dist/reconciler/pr-decide.js.map +0 -1
  501. package/dist/reconciler/pr.js +0 -422
  502. package/dist/reconciler/pr.js.map +0 -1
  503. package/dist/reconciler/types.js +0 -12
  504. package/dist/reconciler/types.js.map +0 -1
  505. package/dist/reconciler/vm.js +0 -243
  506. package/dist/reconciler/vm.js.map +0 -1
  507. package/dist/reconciler/workspace-defaults.js +0 -83
  508. package/dist/reconciler/workspace-defaults.js.map +0 -1
  509. package/dist/reconciler/workspace.js +0 -272
  510. package/dist/reconciler/workspace.js.map +0 -1
  511. package/dist/runlog.js +0 -403
  512. package/dist/runlog.js.map +0 -1
  513. package/dist/scaffold.js +0 -165
  514. package/dist/scaffold.js.map +0 -1
  515. package/dist/trackers/local.js +0 -445
  516. package/dist/trackers/local.js.map +0 -1
  517. package/dist/trackers/types.js +0 -10
  518. package/dist/trackers/types.js.map +0 -1
  519. package/dist/types.js +0 -3
  520. package/dist/types.js.map +0 -1
  521. package/dist/util/clock.js +0 -12
  522. package/dist/util/clock.js.map +0 -1
  523. package/dist/util/crypto.js +0 -25
  524. package/dist/util/crypto.js.map +0 -1
  525. package/dist/util/frontmatter.js +0 -70
  526. package/dist/util/frontmatter.js.map +0 -1
  527. package/dist/util/fs-issues.js +0 -22
  528. package/dist/util/fs-issues.js.map +0 -1
  529. package/dist/util/process.js +0 -152
  530. package/dist/util/process.js.map +0 -1
  531. package/dist/util/workspace-key.js +0 -10
  532. package/dist/util/workspace-key.js.map +0 -1
  533. package/dist/workflow-loader.js +0 -147
  534. package/dist/workflow-loader.js.map +0 -1
  535. package/dist/workflow.js +0 -822
  536. package/dist/workflow.js.map +0 -1
  537. package/dist/workspace-types.js +0 -8
  538. package/dist/workspace-types.js.map +0 -1
  539. package/dist/workspace.js +0 -443
  540. package/dist/workspace.js.map +0 -1
package/dist/workflow.js DELETED
@@ -1,822 +0,0 @@
1
- // WORKFLOW.md parser and typed config view (SPEC §4). Pure: no fs, no process.
2
- // The on-disk read and watcher live in `./workflow-loader.ts` (shell).
3
- import path from 'node:path';
4
- import os from 'node:os';
5
- import { parseFrontMatter, FrontMatterError } from './util/frontmatter.js';
6
- import { isKnownAdapter } from './agent/adapter-names.js';
7
- import { parseActionsBlock } from './actions/parsing.js';
8
- import { WorkflowError } from './errors.js';
9
- import { log } from './logging.js';
10
- export { WorkflowError };
11
- // §4.2: split YAML front matter from prompt body. Thin wrapper over the shared
12
- // parser that translates FrontMatterError → WorkflowError so callers keep
13
- // matching on the existing error codes.
14
- export function splitFrontMatter(text) {
15
- let fm;
16
- try {
17
- fm = parseFrontMatter(text);
18
- }
19
- catch (err) {
20
- if (err instanceof FrontMatterError) {
21
- const code = err.code === 'not_a_map' ? 'workflow_front_matter_not_a_map' : 'workflow_parse_error';
22
- throw new WorkflowError(code, err.message);
23
- }
24
- throw err;
25
- }
26
- return { config: fm.fields, body: fm.body };
27
- }
28
- /**
29
- * Pure entry point: split front matter, build the typed view, and return both
30
- * shapes. The shell loader reads the file from disk and the operator's env,
31
- * then calls this. `env` defaults to an empty map so tests that do not exercise
32
- * `$VAR` expansion need not thread it through.
33
- */
34
- export function parseWorkflow(text, workflowPath, env = {}) {
35
- const { config: raw, body } = splitFrontMatter(text);
36
- const definition = { config: raw, prompt_template: body };
37
- const config = buildServiceConfig(raw, workflowPath, env);
38
- return { definition, config };
39
- }
40
- // $VAR / ~ expansion for path/command fields. `env` carries the variable map
41
- // (the shell loader passes process.env; tests pass an explicit shape).
42
- export function expandVar(value, env = {}) {
43
- if (typeof value !== 'string')
44
- return value;
45
- let s = value;
46
- if (s.startsWith('~/') || s === '~') {
47
- s = s === '~' ? os.homedir() : path.join(os.homedir(), s.slice(2));
48
- }
49
- const m = s.match(/^\$([A-Z_][A-Z0-9_]*)$/);
50
- if (m) {
51
- const envVal = env[m[1]];
52
- return envVal ?? '';
53
- }
54
- return s;
55
- }
56
- function asString(v) {
57
- if (typeof v === 'string')
58
- return v;
59
- return null;
60
- }
61
- function asInt(v, fallback) {
62
- if (typeof v === 'number' && Number.isFinite(v))
63
- return Math.trunc(v);
64
- if (typeof v === 'string' && /^-?\d+$/.test(v))
65
- return parseInt(v, 10);
66
- return fallback;
67
- }
68
- function asStringList(v, fallback) {
69
- if (Array.isArray(v))
70
- return v.filter((x) => typeof x === 'string');
71
- return fallback;
72
- }
73
- function getObject(parent, key) {
74
- const v = parent[key];
75
- if (v && typeof v === 'object' && !Array.isArray(v))
76
- return v;
77
- return {};
78
- }
79
- // workspace.github_repo: a GitHub `owner/repo` slug that enables push/PR mode,
80
- // or null for local-only. There is no auto-detection — the value is either
81
- // absent (local-only) or a literal slug. Empty string / "none"
82
- // (case-insensitive) normalize to null so an operator can disable push mode
83
- // without deleting the key. Any other value MUST be a GitHub owner/repo slug:
84
- // `owner` is GitHub's `[alnum]`/`-` charset (no `.`, `:`, `@`, scheme, or host)
85
- // and `repo` is `[alnum]`/`.`/`_`/`-`. This rejects whole-URL and SSH-style
86
- // remotes (`https://github.com/foo/bar`, `git@github.com:foo/bar`) and bare
87
- // names (`foo`) at parse time, so a typo can't slip through and build a broken
88
- // `https://github.com/<garbage>.git` origin that fails later during setup.
89
- // Takes the raw YAML value (not asString'd) so a present-but-wrong-type value
90
- // (`github_repo: true`, `123`, `{}`) is rejected rather than coerced to null —
91
- // otherwise the fail-fast contract has a hole that silently disables push/PR.
92
- function parseGithubRepo(input) {
93
- if (input === undefined || input === null)
94
- return null;
95
- if (typeof input !== 'string') {
96
- throw new WorkflowError('workflow_parse_error', `workspace.github_repo must be a string "owner/repo" slug or "none" (got ${typeof input})`);
97
- }
98
- const trimmed = input.trim();
99
- if (trimmed === '' || trimmed.toLowerCase() === 'none')
100
- return null;
101
- if (!/^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+$/.test(trimmed)) {
102
- throw new WorkflowError('workflow_parse_error', `workspace.github_repo must be a GitHub "owner/repo" slug or "none" (got: ${input})`);
103
- }
104
- return trimmed;
105
- }
106
- // Build a fully typed ServiceConfig from a parsed front matter map. `env`
107
- // supplies the variable map for `$VAR` expansion; defaults to {} so pure
108
- // callers don't need to thread one in.
109
- export function buildServiceConfig(raw, workflowPath, env = {}) {
110
- const workflowAbs = path.resolve(workflowPath);
111
- const workflowDir = path.dirname(workflowAbs);
112
- // tracker (§4.3.1)
113
- const trackerRaw = getObject(raw, 'tracker');
114
- const trackerKind = (asString(trackerRaw['kind']) ?? '').trim();
115
- // local-tracker extension: optional `tracker.root` path.
116
- const trackerRootRaw = asString(trackerRaw['root']);
117
- let trackerRoot = null;
118
- if (trackerRootRaw) {
119
- const expanded = expandVar(trackerRootRaw, env);
120
- if (expanded === '') {
121
- throw new WorkflowError('workflow_parse_error', `tracker.root references an unset variable: ${trackerRootRaw}`);
122
- }
123
- trackerRoot = path.isAbsolute(expanded) ? expanded : path.resolve(workflowDir, expanded);
124
- }
125
- else if (trackerKind === 'local') {
126
- // Default local tracker root: <workflow-dir>/issues
127
- trackerRoot = path.resolve(workflowDir, 'issues');
128
- }
129
- const states = parseStatesBlock(raw['states']);
130
- const tracker = {
131
- kind: trackerKind,
132
- states,
133
- root: trackerRoot,
134
- };
135
- // polling (§4.3.2)
136
- const pollingRaw = getObject(raw, 'polling');
137
- const polling = {
138
- interval_ms: asInt(pollingRaw['interval_ms'], 30_000),
139
- };
140
- // workspace (§4.3.3)
141
- const workspaceRaw = getObject(raw, 'workspace');
142
- const wsRootInput = asString(workspaceRaw['root']);
143
- let workspaceRoot;
144
- if (wsRootInput) {
145
- const expanded = expandVar(wsRootInput, env);
146
- if (expanded === '') {
147
- throw new WorkflowError('workflow_parse_error', `workspace.root references an unset variable: ${wsRootInput}`);
148
- }
149
- workspaceRoot = path.isAbsolute(expanded) ? expanded : path.resolve(workflowDir, expanded);
150
- }
151
- else {
152
- workspaceRoot = path.join(os.tmpdir(), 'symphony_workspaces');
153
- }
154
- const baseBranchInput = asString(workspaceRaw['base_branch']);
155
- const baseBranch = baseBranchInput && baseBranchInput.trim().length > 0 ? baseBranchInput.trim() : 'main';
156
- const workspace = {
157
- root: path.resolve(workspaceRoot),
158
- github_repo: parseGithubRepo(workspaceRaw['github_repo']),
159
- base_branch: baseBranch,
160
- };
161
- // logs (symphony extension): per-issue JSONL run logs. Default sits next to the workspace
162
- // root under `.symphony/logs/` so all symphony-managed state for a project lives in one
163
- // tree. Same expansion rules as workspace.root.
164
- const logsRaw = getObject(raw, 'logs');
165
- const logsRootInput = asString(logsRaw['root']);
166
- let logsRoot;
167
- if (logsRootInput) {
168
- const expanded = expandVar(logsRootInput, env);
169
- if (expanded === '') {
170
- throw new WorkflowError('workflow_parse_error', `logs.root references an unset variable: ${logsRootInput}`);
171
- }
172
- logsRoot = path.isAbsolute(expanded) ? expanded : path.resolve(workflowDir, expanded);
173
- }
174
- else {
175
- logsRoot = path.resolve(workflowDir, '.symphony', 'logs');
176
- }
177
- const logs = { root: path.resolve(logsRoot) };
178
- // agent (§4.3.4)
179
- const agentRaw = getObject(raw, 'agent');
180
- const maxTurns = asInt(agentRaw['max_turns'], 20);
181
- if (maxTurns <= 0) {
182
- throw new WorkflowError('workflow_parse_error', 'agent.max_turns must be positive');
183
- }
184
- // Memory-aware admission cap (issue 27). Default-on with a 2 GiB host reserve — that's
185
- // enough headroom for the orchestrator process, the per-VM Gondolin runners,
186
- // and the kernel's working set on a typical workstation. Operators can disable the cap (set
187
- // `memory_admission_enabled: false`) on hosts that don't expose /proc/meminfo or where
188
- // the static cap is already the binding constraint.
189
- const memAdmissionEnabledRaw = agentRaw['memory_admission_enabled'];
190
- const memoryAdmissionEnabled = memAdmissionEnabledRaw === undefined ? true : memAdmissionEnabledRaw !== false;
191
- const hostMemoryReserveMib = asInt(agentRaw['host_memory_reserve_mib'], 2048);
192
- if (hostMemoryReserveMib < 0) {
193
- throw new WorkflowError('workflow_parse_error', 'agent.host_memory_reserve_mib must be a non-negative integer');
194
- }
195
- // Circuit breaker (issue 128). Default 5: after five consecutive identical
196
- // failures the orchestrator stops retrying and routes the issue to a holding
197
- // state. 0 disables the breaker; 1 would trip on the first failure (no retry
198
- // ever), which is rarely wanted, so the parser rejects it as a likely
199
- // misconfiguration — use 0 to disable or >= 2 to bound the loop.
200
- const circuitBreakerThreshold = asInt(agentRaw['circuit_breaker_threshold'], 5);
201
- if (circuitBreakerThreshold < 0 || circuitBreakerThreshold === 1) {
202
- throw new WorkflowError('workflow_parse_error', 'agent.circuit_breaker_threshold must be 0 (disabled) or an integer >= 2');
203
- }
204
- const agent = {
205
- max_concurrent_agents: asInt(agentRaw['max_concurrent_agents'], 10),
206
- max_turns: maxTurns,
207
- max_retry_backoff_ms: asInt(agentRaw['max_retry_backoff_ms'], 300_000),
208
- memory_admission_enabled: memoryAdmissionEnabled,
209
- host_memory_reserve_mib: hostMemoryReserveMib,
210
- circuit_breaker_threshold: circuitBreakerThreshold,
211
- };
212
- // acp (Symphony extension; see §4.3.5). `adapter` selects
213
- // one of symphony's known profiles (claude, codex, opencode); symphony auto-derives the
214
- // launch command from the adapter profile. Credentials are NOT staged into the workspace:
215
- // the guest only ever holds a token-shaped placeholder, and the host substitutes the real
216
- // upstream token into the outbound request at Gondolin egress (TLS-MITM via
217
- // `createHttpHooks` in src/agent/credential-secrets.ts). The real host credential
218
- // (`~/.claude/.credentials.json` for claude; `~/.codex/auth.json` access token or
219
- // `OPENAI_API_KEY` for codex; the GitHub Copilot token exchanged from
220
- // `~/.local/share/opencode/auth.json` for opencode) never enters the VM.
221
- //
222
- // `acp.bridge` configures the host-side TCP listener that the in-VM agent dials back
223
- // to for ACP traffic. The bridge replaced the earlier in-VM-exec stdio path; see
224
- // src/acp-bridge.ts for rationale.
225
- const acpRaw = getObject(raw, 'acp');
226
- const bridgeRaw = getObject(acpRaw, 'bridge');
227
- const modelRaw = asString(acpRaw['model']);
228
- const modelTrimmed = modelRaw === null ? null : modelRaw.trim();
229
- const effortRaw = asString(acpRaw['effort']);
230
- const effortTrimmed = effortRaw === null ? null : effortRaw.trim();
231
- const acp = {
232
- adapter: asString(acpRaw['adapter']) ?? 'claude',
233
- model: modelTrimmed && modelTrimmed.length > 0 ? modelTrimmed : null,
234
- effort: effortTrimmed && effortTrimmed.length > 0 ? effortTrimmed : null,
235
- shell: asString(acpRaw['shell']) ?? 'bash',
236
- prompt_timeout_ms: asInt(acpRaw['prompt_timeout_ms'], 3_600_000),
237
- read_timeout_ms: asInt(acpRaw['read_timeout_ms'], 30_000),
238
- stall_timeout_ms: asInt(acpRaw['stall_timeout_ms'], 300_000),
239
- bridge: {
240
- bind_host: asString(bridgeRaw['bind_host']) ?? '0.0.0.0',
241
- bind_port: asInt(bridgeRaw['bind_port'], 8788),
242
- reach_host: asString(bridgeRaw['reach_host']) ?? '127.0.0.1',
243
- reach_url: asString(bridgeRaw['reach_url']),
244
- connect_timeout_ms: asInt(bridgeRaw['connect_timeout_ms'], 30_000),
245
- },
246
- };
247
- // credentials extension (issue 113). Defaults work out of the box for the
248
- // common case: run the host ticker every 6 hours.
249
- const credentialsRaw = getObject(raw, 'credentials');
250
- const credentials = {
251
- ticker_interval_ms: asInt(credentialsRaw['ticker_interval_ms'], 6 * 60 * 60 * 1000),
252
- };
253
- // gondolin VM extension
254
- const gondolinRaw = getObject(raw, 'gondolin');
255
- const volumesRaw = gondolinRaw['volumes'];
256
- const volumes = Array.isArray(volumesRaw)
257
- ? volumesRaw.flatMap((v) => {
258
- if (!v || typeof v !== 'object' || Array.isArray(v))
259
- return [];
260
- const m = v;
261
- const hostRaw = asString(m['host']);
262
- const guest = asString(m['guest']);
263
- if (!hostRaw || !guest)
264
- return [];
265
- const expandedHost = expandVar(hostRaw, env);
266
- if (expandedHost === '')
267
- return [];
268
- const host = path.isAbsolute(expandedHost)
269
- ? expandedHost
270
- : path.resolve(workflowDir, expandedHost);
271
- const readonly = m['readonly'] === true;
272
- return [{ host, guest, readonly }];
273
- })
274
- : [];
275
- const gondolin = {
276
- image: asString(gondolinRaw['image']),
277
- cpus: asInt(gondolinRaw['cpus'], 2),
278
- mem_mib: asInt(gondolinRaw['mem_mib'], 2048),
279
- volumes,
280
- // `forward_env` is forwarded into the VM boot env, but the runner strips EVERY
281
- // credential-bearing var (`stripCredentialEnv`) before boot — the guest holds
282
- // only the token-shaped placeholder Gondolin substitutes at egress, never a real
283
- // key. So even if an operator lists `OPENAI_API_KEY` here, it never reaches a VM.
284
- // The defaults are retained for any future forward-env-strategy adapter.
285
- forward_env: asStringList(gondolinRaw['forward_env'], [
286
- 'OPENAI_API_KEY',
287
- 'ANTHROPIC_API_KEY',
288
- ]),
289
- };
290
- // egress firewall: the general dev-tooling allowlist the in-VM agent may reach
291
- // (npm/git/CDNs) for gates. DISTINCT from the credential layer's per-adapter
292
- // substitution hosts — nothing listed here ever gets a real token substituted
293
- // (see credential-secrets.ts buildAdapterHooksConfig). Empty default: the agent
294
- // can reach only each adapter's inference host until the operator opts hosts in.
295
- const egressRaw = getObject(raw, 'egress');
296
- const egress = {
297
- allowed_hosts: asStringList(egressRaw['allowed_hosts'], []),
298
- };
299
- // server extension (§9.5)
300
- const serverRaw = getObject(raw, 'server');
301
- const server = {
302
- port: typeof serverRaw['port'] === 'number' ? serverRaw['port'] : null,
303
- host: asString(serverRaw['host']) ?? '127.0.0.1',
304
- };
305
- // mcp extension: per-issue MCP server (transition + request_human_steering + propose_issue
306
- // tools) injected into each ACP session. `host` defaults to the QEMU slirp gateway; the port is the
307
- // actually-bound HTTP server's port (resolved at runtime, not config-parse time, so
308
- // `--port` and an unset server.port can never desync). `host_url` is an explicit full-URL
309
- // override for cases where the VM can't reach the orchestrator via the host gateway.
310
- const mcpRaw = getObject(raw, 'mcp');
311
- const mcpEnabledRaw = mcpRaw['enabled'];
312
- const mcpEnabled = mcpEnabledRaw === undefined ? true : mcpEnabledRaw !== false;
313
- const mcp = {
314
- enabled: mcpEnabled,
315
- // 127.0.0.1 works because Gondolin maps a synthetic guest host to the host's
316
- // loopback (`tcp.hosts`). (Empirically verified;
317
- // 10.0.2.2 — the QEMU slirp gateway — is NOT reachable here.) Other VMMs
318
- // can override via the `host` field in the WORKFLOW.md mcp block.
319
- host: asString(mcpRaw['host']) ?? '127.0.0.1',
320
- explicit_host_url: asString(mcpRaw['host_url']),
321
- };
322
- // pr (issue 38, slimmed in issue 139). Optional block; default off. The slim
323
- // host-global engine toggle: `pr: { enabled, poll_interval_ms }`. The
324
- // merge/close/route targets and auto-merge strategy live ON the terminal
325
- // states they describe (`states.<name>.pr`, parsed in parseStatesBlock) and
326
- // are derived by scanning states (`derivePrRouting`), never named here.
327
- const prRaw = getObject(raw, 'pr');
328
- const pr = {
329
- enabled: prRaw['enabled'] === true,
330
- poll_interval_ms: asInt(prRaw['poll_interval_ms'], 30_000),
331
- };
332
- if (pr.poll_interval_ms < 0) {
333
- throw new WorkflowError('workflow_parse_error', 'pr.poll_interval_ms must be non-negative');
334
- }
335
- // sleep_cycle (issue 125; retired in issue 140). The auto-arm trigger moved
336
- // ONTO the active state it arms (`states.<name>.arm`, parsed in
337
- // parseStatesBlock). A legacy top-level `sleep_cycle:` block is folded onto the
338
- // reflect_state it named (foldLegacySleepCycle) for one release with a
339
- // deprecation warning; there is no top-level sleep-cycle field on the resulting
340
- // ServiceConfig anymore — the orchestrator derives the armed state by scanning
341
- // states (`deriveArmRouting`).
342
- foldLegacySleepCycle(states, getObject(raw, 'sleep_cycle'), Object.prototype.hasOwnProperty.call(raw, 'sleep_cycle'));
343
- return {
344
- workflow_path: workflowAbs,
345
- workflow_dir: workflowDir,
346
- tracker,
347
- polling,
348
- workspace,
349
- logs,
350
- agent,
351
- acp,
352
- gondolin,
353
- egress,
354
- server,
355
- mcp,
356
- pr,
357
- credentials,
358
- states,
359
- };
360
- }
361
- /**
362
- * Per-state `arm:` block (issue 140). Optional, valid on an active state.
363
- * `issue` is the recurring issue armed into this state; `from` the holding state
364
- * it rests in between runs; `on_idle` / `after_terminal` are the two triggers.
365
- * The structural shape (types, `from` non-empty, `after_terminal` non-negative)
366
- * is validated here; the cross-reference (arm only on active states, `from` is a
367
- * declared holding state, `issue` required, at most one armed state) lives in
368
- * `validateStates`. Returns `undefined` when the block is absent. `on_idle`
369
- * defaults to false (opt-in, symmetric with `after_terminal: 0`).
370
- */
371
- function parseStateArmBlock(stateName, raw) {
372
- if (raw === undefined || raw === null)
373
- return undefined;
374
- if (typeof raw !== 'object' || Array.isArray(raw)) {
375
- throw new WorkflowError('workflow_parse_error', `state "${stateName}": arm must be a map (issue / from / on_idle / after_terminal)`);
376
- }
377
- const m = raw;
378
- const issueRaw = asString(m['issue']);
379
- const issue = issueRaw && issueRaw.trim().length > 0 ? issueRaw.trim() : null;
380
- const fromRaw = asString(m['from']);
381
- if (!fromRaw || fromRaw.trim().length === 0) {
382
- throw new WorkflowError('workflow_parse_error', `state "${stateName}": arm.from must be a non-empty holding state name`);
383
- }
384
- const afterTerminal = asInt(m['after_terminal'], 0);
385
- if (afterTerminal < 0) {
386
- throw new WorkflowError('workflow_parse_error', `state "${stateName}": arm.after_terminal must be a non-negative integer (0 disables the terminal-count trigger)`);
387
- }
388
- return {
389
- issue,
390
- from: fromRaw.trim(),
391
- on_idle: m['on_idle'] === true,
392
- after_terminal: afterTerminal,
393
- };
394
- }
395
- export function deriveArmRouting(states) {
396
- for (const [name, sc] of Object.entries(states)) {
397
- if (sc.role !== 'active' || !sc.arm)
398
- continue;
399
- return {
400
- armState: name,
401
- issue: sc.arm.issue,
402
- from: sc.arm.from,
403
- onIdle: sc.arm.on_idle,
404
- afterTerminal: sc.arm.after_terminal,
405
- };
406
- }
407
- return { armState: null, issue: null, from: null, onIdle: false, afterTerminal: 0 };
408
- }
409
- /**
410
- * Fold a deprecated top-level `sleep_cycle:` block onto the active state it named
411
- * (issue 140). Mirrors the now-removed legacy PR-autopilot fold: the trigger
412
- * that used to live as named strings (`reflect_state` / `dormant_state` / `issue_id` /
413
- * `arm_on_idle` / `arm_after_done`) is injected onto the reflect_state's `arm:`
414
- * field so the state scan (`deriveArmRouting`) is the single runtime source of
415
- * truth. A state that already declares `arm:` wins on conflict. Emits one
416
- * deprecation warning whenever the block is present. Only folds when the legacy
417
- * block is `enabled: true` — in the new model declaring `arm:` IS the enable, so
418
- * a disabled legacy block produces no arm. The legacy `arm_on_idle` default of
419
- * true is preserved (`!== false`). A `reflect_state` matching no declared state
420
- * is a silent no-op (the warning already points at the new shape).
421
- */
422
- function foldLegacySleepCycle(states, legacyRaw, present) {
423
- if (!present)
424
- return;
425
- log.warn('sleep_cycle: is deprecated; declare the auto-arm trigger on the active state it arms as `states.<name>.arm` (issue / from / on_idle / after_terminal)', {});
426
- // Preserve the original sleep-cycle parser's non-negative validation: a
427
- // negative `arm_after_done` was a parse error, not a silent disable. Validated
428
- // whenever the block is present (independent of `enabled`), matching the
429
- // retired `validateSleepCycle` and the new `arm.after_terminal` parser.
430
- const afterTerminal = asInt(legacyRaw['arm_after_done'], 0);
431
- if (afterTerminal < 0) {
432
- throw new WorkflowError('workflow_parse_error', 'sleep_cycle.arm_after_done must be a non-negative integer (0 disables the terminal-count trigger)');
433
- }
434
- if (legacyRaw['enabled'] !== true)
435
- return;
436
- const reflectRaw = asString(legacyRaw['reflect_state']);
437
- const reflectState = reflectRaw && reflectRaw.trim().length > 0 ? reflectRaw.trim() : 'Reflect';
438
- const dormantRaw = asString(legacyRaw['dormant_state']);
439
- const dormantState = dormantRaw && dormantRaw.trim().length > 0 ? dormantRaw.trim() : 'Dormant';
440
- const issueRaw = asString(legacyRaw['issue_id']);
441
- const issue = issueRaw && issueRaw.trim().length > 0 ? issueRaw.trim() : null;
442
- injectStateArm(states, reflectState, {
443
- issue,
444
- from: dormantState,
445
- on_idle: legacyRaw['arm_on_idle'] !== false,
446
- after_terminal: afterTerminal,
447
- });
448
- }
449
- /**
450
- * Inject a derived `arm:` block onto the (case-insensitively) named active state,
451
- * but only when that state declares no `arm:` of its own — state-level config
452
- * always wins over a folded legacy value. No-op when the name matches no state.
453
- */
454
- function injectStateArm(states, name, arm) {
455
- const lower = name.toLowerCase();
456
- for (const [stateName, sc] of Object.entries(states)) {
457
- if (stateName.toLowerCase() !== lower)
458
- continue;
459
- if (sc.arm)
460
- return;
461
- sc.arm = arm;
462
- return;
463
- }
464
- }
465
- // Parse the top-level `states:` block. The block is mandatory: every workflow
466
- // must declare at least one `active`, one `terminal`, and one `holding` state
467
- // (validation happens in `validateStates`). Insertion order matters —
468
- // downstream consumers (dashboard, role-filtered active/terminal listings)
469
- // follow declaration order — so we build a plain object incrementally rather
470
- // than reconstructing via `Object.fromEntries`.
471
- function parseStatesBlock(raw) {
472
- if (raw === undefined || raw === null) {
473
- throw new WorkflowError('workflow_parse_error', 'workflow YAML must declare a top-level `states:` block with at least one active, one terminal, and one holding state. See WORKFLOW.template.md for the schema.');
474
- }
475
- if (typeof raw !== 'object' || Array.isArray(raw)) {
476
- throw new WorkflowError('workflow_parse_error', 'states: must be a map of name → config');
477
- }
478
- const entries = Object.entries(raw);
479
- if (entries.length === 0) {
480
- throw new WorkflowError('workflow_parse_error', 'workflow YAML `states:` block is empty; declare at least one active, one terminal, and one holding state. See WORKFLOW.template.md for the schema.');
481
- }
482
- const out = {};
483
- for (const [name, value] of entries) {
484
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
485
- throw new WorkflowError('workflow_parse_error', `state "${name}": value must be a map`);
486
- }
487
- const m = value;
488
- const roleRaw = asString(m['role']);
489
- if (roleRaw !== 'active' && roleRaw !== 'terminal' && roleRaw !== 'holding') {
490
- throw new WorkflowError('workflow_parse_error', `state "${name}": role must be one of active|terminal|holding (got: ${String(m['role'])})`);
491
- }
492
- const adapter = asString(m['adapter']);
493
- const modelRaw = asString(m['model']);
494
- const modelTrimmed = modelRaw === null ? undefined : modelRaw.trim();
495
- const model = modelTrimmed === undefined ? undefined : modelTrimmed.length > 0 ? modelTrimmed : null;
496
- // Same undefined-vs-null semantics as `model`: a missing key inherits the
497
- // workflow-level `acp.effort`; a blank/whitespace string normalizes to null
498
- // (an explicit "use the adapter default for this state" signal).
499
- const effortRaw = asString(m['effort']);
500
- const effortTrimmed = effortRaw === null ? undefined : effortRaw.trim();
501
- const effort = effortTrimmed === undefined ? undefined : effortTrimmed.length > 0 ? effortTrimmed : null;
502
- let maxTurns;
503
- if (m['max_turns'] !== undefined) {
504
- const n = asInt(m['max_turns'], -1);
505
- if (n <= 0) {
506
- throw new WorkflowError('workflow_parse_error', `state "${name}": max_turns must be a positive integer`);
507
- }
508
- maxTurns = n;
509
- }
510
- // Per-state concurrency cap (issue 137) — same positive-integer validation
511
- // as max_turns. Undefined when omitted (no per-state cap; only the global
512
- // agent.max_concurrent_agents ceiling applies).
513
- let maxConcurrent;
514
- if (m['max_concurrent'] !== undefined) {
515
- const n = asInt(m['max_concurrent'], -1);
516
- if (n <= 0) {
517
- throw new WorkflowError('workflow_parse_error', `state "${name}": max_concurrent must be a positive integer`);
518
- }
519
- maxConcurrent = n;
520
- }
521
- let allowed;
522
- if (m['allowed_transitions'] === undefined) {
523
- allowed = undefined;
524
- }
525
- else if (m['allowed_transitions'] === null) {
526
- allowed = null;
527
- }
528
- else if (Array.isArray(m['allowed_transitions'])) {
529
- allowed = m['allowed_transitions'].filter((x) => typeof x === 'string');
530
- }
531
- else {
532
- throw new WorkflowError('workflow_parse_error', `state "${name}": allowed_transitions must be a list of state names (or null/omitted)`);
533
- }
534
- const stateActions = parseActionsBlock(name, m['actions']);
535
- // eval_mode is a strict boolean opt-in: only true enables it, any other
536
- // value (including undefined, null, "true" string) leaves it off. Strict
537
- // typing here matches the rest of the YAML-flag plumbing in the parser
538
- // and stops a YAML-quoting accident ("true") from silently enabling the
539
- // mounts.
540
- const evalModeRaw = m['eval_mode'];
541
- if (evalModeRaw !== undefined && typeof evalModeRaw !== 'boolean') {
542
- throw new WorkflowError('workflow_parse_error', `state "${name}": eval_mode must be a boolean (true/false)`);
543
- }
544
- const statePr = parseStatePrBlock(name, m['pr']);
545
- const stateArm = parseStateArmBlock(name, m['arm']);
546
- const sc = { role: roleRaw };
547
- if (adapter !== null)
548
- sc.adapter = adapter;
549
- if (model !== undefined)
550
- sc.model = model;
551
- if (effort !== undefined)
552
- sc.effort = effort;
553
- if (maxTurns !== undefined)
554
- sc.max_turns = maxTurns;
555
- if (maxConcurrent !== undefined)
556
- sc.max_concurrent = maxConcurrent;
557
- if (allowed !== undefined)
558
- sc.allowed_transitions = allowed;
559
- if (stateActions !== undefined)
560
- sc.actions = stateActions;
561
- if (evalModeRaw === true)
562
- sc.eval_mode = true;
563
- if (statePr !== undefined)
564
- sc.pr = statePr;
565
- if (stateArm !== undefined)
566
- sc.arm = stateArm;
567
- out[name] = sc;
568
- }
569
- return out;
570
- }
571
- /**
572
- * Per-state `pr:` block (issue 139). Optional, valid on a terminal state.
573
- * `auto_merge` (squash|merge|rebase) marks the merge state and picks the
574
- * `gh pr merge --auto` strategy; `on_conflict.route_to` names the active state
575
- * a non-mergeable PR is routed back into; `close: true` marks the close state.
576
- * Returns `undefined` when the block is absent or declares nothing meaningful
577
- * (so an empty `pr: {}` doesn't shadow a legacy fold). The structural shape is
578
- * validated here; the cross-reference (route_to is a declared state, pr only on
579
- * terminal states, merge/close uniqueness) is in `validateStates`.
580
- */
581
- function parseStatePrBlock(stateName, raw) {
582
- if (raw === undefined || raw === null)
583
- return undefined;
584
- if (typeof raw !== 'object' || Array.isArray(raw)) {
585
- throw new WorkflowError('workflow_parse_error', `state "${stateName}": pr must be a map (auto_merge / on_conflict / close)`);
586
- }
587
- const m = raw;
588
- const out = {};
589
- if (m['auto_merge'] !== undefined && m['auto_merge'] !== null) {
590
- const s = asString(m['auto_merge']);
591
- if (s !== 'squash' && s !== 'merge' && s !== 'rebase') {
592
- throw new WorkflowError('workflow_parse_error', `state "${stateName}": pr.auto_merge must be one of squash|merge|rebase`);
593
- }
594
- out.auto_merge = s;
595
- }
596
- if (m['on_conflict'] !== undefined && m['on_conflict'] !== null) {
597
- const oc = m['on_conflict'];
598
- if (typeof oc !== 'object' || Array.isArray(oc)) {
599
- throw new WorkflowError('workflow_parse_error', `state "${stateName}": pr.on_conflict must be a map with a route_to field`);
600
- }
601
- const routeTo = asString(oc['route_to']);
602
- if (!routeTo || routeTo.trim().length === 0) {
603
- throw new WorkflowError('workflow_parse_error', `state "${stateName}": pr.on_conflict.route_to must be a non-empty state name`);
604
- }
605
- out.on_conflict = { route_to: routeTo.trim() };
606
- }
607
- if (m['close'] !== undefined && m['close'] !== null) {
608
- if (typeof m['close'] !== 'boolean') {
609
- throw new WorkflowError('workflow_parse_error', `state "${stateName}": pr.close must be a boolean`);
610
- }
611
- if (m['close'] === true)
612
- out.close = true;
613
- }
614
- return Object.keys(out).length > 0 ? out : undefined;
615
- }
616
- export function derivePrRouting(states) {
617
- let mergeState = null;
618
- let closeState = null;
619
- let conflictRouteTo = null;
620
- let strategy = 'squash';
621
- for (const [name, sc] of Object.entries(states)) {
622
- if (sc.role !== 'terminal' || !sc.pr)
623
- continue;
624
- if (sc.pr.auto_merge && mergeState === null) {
625
- mergeState = name;
626
- strategy = sc.pr.auto_merge;
627
- conflictRouteTo = sc.pr.on_conflict?.route_to ?? null;
628
- }
629
- if (sc.pr.close && closeState === null) {
630
- closeState = name;
631
- }
632
- }
633
- return { mergeState, closeState, conflictRouteTo, strategy };
634
- }
635
- /**
636
- * Resolve the typed action list a given state should run on transition-in.
637
- * Case-insensitive state lookup; returns the parsed `WorkflowAction[]` (or
638
- * undefined when the state has no actions block). The runner consults this on
639
- * transition into a terminal state to drive the push/PR handoff.
640
- */
641
- export function resolveActionsForState(cfg, stateName) {
642
- const states = cfg.states;
643
- let key = null;
644
- if (Object.prototype.hasOwnProperty.call(states, stateName)) {
645
- key = stateName;
646
- }
647
- else {
648
- const lower = stateName.toLowerCase();
649
- for (const name of Object.keys(states)) {
650
- if (name.toLowerCase() === lower) {
651
- key = name;
652
- break;
653
- }
654
- }
655
- }
656
- if (key === null)
657
- return undefined;
658
- return states[key].actions;
659
- }
660
- // Dispatch preflight validation (structural, pure). The fs-touching probes —
661
- // `tracker.root` existence and the adapter credential files — live in the shell
662
- // loader's `validateDispatchIo`, which the orchestrator calls alongside this
663
- // function. Both adapters are startup-probed: claude requires a single readable
664
- // host file (`~/.claude/.credentials.json`); codex passes when either
665
- // `~/.codex/auth.json` holds a token (ChatGPT-OAuth `tokens.access_token` or a
666
- // top-level `OPENAI_API_KEY`) or the host `OPENAI_API_KEY` env var is set. Keeping this
667
- // structural half pure means tests and the reload tick can re-run it cheaply on
668
- // every reconcile without re-hitting the disk.
669
- export function validateDispatch(cfg) {
670
- if (cfg.tracker.kind !== 'local') {
671
- return `unsupported_tracker_kind: ${cfg.tracker.kind || '<missing>'}`;
672
- }
673
- if (!cfg.tracker.root)
674
- return 'tracker.root must be set for local tracker';
675
- // `cfg.states` is always populated by buildServiceConfig — the parser refuses
676
- // workflows without a `states:` block — so callers never need a fallback here.
677
- const statesError = validateStates(cfg.states);
678
- if (statesError)
679
- return statesError;
680
- // cfg.agent is always populated by buildServiceConfig; guard for legacy
681
- // hand-built ServiceConfigs (older test fixtures) that omit the block.
682
- const concurrencyError = validateConcurrencyCaps(cfg.states, cfg.agent?.max_concurrent_agents);
683
- if (concurrencyError)
684
- return concurrencyError;
685
- if (!isKnownAdapter(cfg.acp.adapter)) {
686
- return `acp.adapter "${cfg.acp.adapter}" is not a known profile; use one of: claude, codex, opencode`;
687
- }
688
- // PR autopilot routing (issue 139) is validated structurally inside
689
- // `validateStates` (pr: only on terminal states; on_conflict.route_to must be
690
- // a declared state; at most one merge/close state). The state's own `role` is
691
- // authoritative, so the old `validatePrAutopilot` role re-validator is gone.
692
- // Auto-arm trigger (issue 140) is validated structurally inside
693
- // `validateStates` (arm: only on active states; arm.from must be a declared
694
- // holding state; arm.issue required; at most one armed state). The state's own
695
- // `role` is authoritative, so the old `validateSleepCycle` role re-validator is
696
- // gone.
697
- return null;
698
- }
699
- /**
700
- * Validate that the sum of per-state `max_concurrent` caps does not exceed the
701
- * global `agent.max_concurrent_agents` host ceiling (issue 137). A sum greater
702
- * than the ceiling can never be satisfied — the global clamp binds first — so it
703
- * is almost always a misconfiguration worth surfacing at startup. Returns null
704
- * when in budget or when the ceiling is unknown (legacy hand-built configs that
705
- * omit `agent`). The legacy by-name map is already folded into the per-state
706
- * caps by the time this runs, so its entries count toward the sum too.
707
- */
708
- function validateConcurrencyCaps(states, ceiling) {
709
- if (typeof ceiling !== 'number')
710
- return null;
711
- let sum = 0;
712
- for (const sc of Object.values(states)) {
713
- if (typeof sc.max_concurrent === 'number')
714
- sum += sc.max_concurrent;
715
- }
716
- if (sum > ceiling) {
717
- return `sum of per-state max_concurrent caps (${sum}) exceeds agent.max_concurrent_agents (${ceiling})`;
718
- }
719
- return null;
720
- }
721
- // State-map validation, exposed as a string|null so it composes with the rest of
722
- // `validateDispatch`. Checks declared in the same order the operator would hit
723
- // them while iterating on a malformed workflow: structural (roles, uniqueness),
724
- // then cross-references (allowed_transitions targets), then host-resource
725
- // dependencies (adapter known + credential readable).
726
- function validateStates(states) {
727
- const names = Object.keys(states);
728
- if (names.length === 0)
729
- return 'states: at least one state must be declared';
730
- let hasActive = false;
731
- let hasTerminal = false;
732
- let hasHolding = false;
733
- for (const cfg of Object.values(states)) {
734
- if (cfg.role === 'active')
735
- hasActive = true;
736
- else if (cfg.role === 'terminal')
737
- hasTerminal = true;
738
- else if (cfg.role === 'holding')
739
- hasHolding = true;
740
- }
741
- if (!hasActive)
742
- return 'states: at least one state must have role: active';
743
- if (!hasTerminal)
744
- return 'states: at least one state must have role: terminal';
745
- // `holding` is required so `propose_issue` always has a declared landing
746
- // directory; the dashboard's triage approve/discard surface also needs it.
747
- if (!hasHolding)
748
- return 'states: at least one state must have role: holding';
749
- const seen = new Map();
750
- for (const name of names) {
751
- const key = name.toLowerCase();
752
- const prior = seen.get(key);
753
- if (prior !== undefined) {
754
- return `states: duplicate state name (case-insensitive): "${prior}" and "${name}"`;
755
- }
756
- seen.set(key, name);
757
- }
758
- // PR autopilot routing (issue 139): `pr:` is only meaningful on a terminal
759
- // state, `on_conflict.route_to` must name a declared state, and at most one
760
- // terminal state may declare the merge (`auto_merge`) or close (`close`)
761
- // behavior so `derivePrRouting`'s first-match is unambiguous.
762
- let mergeStateCount = 0;
763
- let closeStateCount = 0;
764
- let armStateCount = 0;
765
- for (const [name, cfg] of Object.entries(states)) {
766
- if (cfg.allowed_transitions) {
767
- for (const target of cfg.allowed_transitions) {
768
- if (!seen.has(target.toLowerCase())) {
769
- return `state "${name}": allowed_transitions references undeclared state "${target}"`;
770
- }
771
- }
772
- }
773
- if (cfg.adapter !== undefined && !isKnownAdapter(cfg.adapter)) {
774
- return `state "${name}": adapter "${cfg.adapter}" is not a known profile; use one of: claude, codex, opencode`;
775
- }
776
- if (cfg.pr) {
777
- if (cfg.role !== 'terminal') {
778
- return `state "${name}": pr: is only valid on a terminal state (got role: ${cfg.role})`;
779
- }
780
- if (cfg.pr.auto_merge)
781
- mergeStateCount += 1;
782
- if (cfg.pr.close)
783
- closeStateCount += 1;
784
- const routeTo = cfg.pr.on_conflict?.route_to;
785
- if (routeTo && !seen.has(routeTo.toLowerCase())) {
786
- return `state "${name}": pr.on_conflict.route_to references undeclared state "${routeTo}"`;
787
- }
788
- }
789
- // Auto-arm trigger (issue 140): `arm:` is only meaningful on an active state,
790
- // `arm.issue` is required, `arm.from` must name a declared holding state, and
791
- // at most one active state may declare arm so `deriveArmRouting`'s first-match
792
- // is unambiguous. The state's own `role` is authoritative — no separate
793
- // sleep-cycle role re-validator.
794
- if (cfg.arm) {
795
- armStateCount += 1;
796
- if (cfg.role !== 'active') {
797
- return `state "${name}": arm: is only valid on an active state (got role: ${cfg.role})`;
798
- }
799
- if (!cfg.arm.issue || cfg.arm.issue.trim().length === 0) {
800
- return `state "${name}": arm.issue is required (the recurring issue to arm into this state)`;
801
- }
802
- const fromCanonical = seen.get(cfg.arm.from.toLowerCase());
803
- if (!fromCanonical) {
804
- return `state "${name}": arm.from references undeclared state "${cfg.arm.from}"`;
805
- }
806
- if (states[fromCanonical].role !== 'holding') {
807
- return `state "${name}": arm.from "${cfg.arm.from}" must be a holding state (got role: ${states[fromCanonical].role})`;
808
- }
809
- }
810
- }
811
- if (armStateCount > 1) {
812
- return `states: at most one active state may declare arm (found ${armStateCount})`;
813
- }
814
- if (mergeStateCount > 1) {
815
- return `states: at most one terminal state may declare pr.auto_merge (found ${mergeStateCount})`;
816
- }
817
- if (closeStateCount > 1) {
818
- return `states: at most one terminal state may declare pr.close (found ${closeStateCount})`;
819
- }
820
- return null;
821
- }
822
- //# sourceMappingURL=workflow.js.map