lastlight 0.1.15 → 0.2.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 (478) hide show
  1. package/README.md +265 -118
  2. package/agent-context/rules.md +62 -9
  3. package/agent-context/security.md +69 -0
  4. package/config/default.yaml +89 -0
  5. package/deploy/.env.production.example +20 -2
  6. package/deploy/entrypoint.sh +16 -17
  7. package/deploy/native/README.md +160 -0
  8. package/deploy/native/install.sh +110 -0
  9. package/deploy/native/lastlight.env.example +95 -0
  10. package/deploy/native/lastlight.service +50 -0
  11. package/deploy/sandbox-entrypoint.sh +59 -42
  12. package/dist/admin/auth.d.ts +1 -1
  13. package/dist/admin/auth.js +3 -1
  14. package/dist/admin/auth.js.map +1 -1
  15. package/dist/admin/chat-session-reader.d.ts +11 -14
  16. package/dist/admin/chat-session-reader.js +27 -51
  17. package/dist/admin/chat-session-reader.js.map +1 -1
  18. package/dist/admin/docker.d.ts +37 -0
  19. package/dist/admin/docker.js +86 -1
  20. package/dist/admin/docker.js.map +1 -1
  21. package/dist/admin/index.js +38 -3
  22. package/dist/admin/index.js.map +1 -1
  23. package/dist/admin/log-search.test.d.ts +1 -0
  24. package/dist/admin/log-search.test.js +78 -0
  25. package/dist/admin/log-search.test.js.map +1 -0
  26. package/dist/admin/routes.d.ts +52 -3
  27. package/dist/admin/routes.js +780 -63
  28. package/dist/admin/routes.js.map +1 -1
  29. package/dist/admin/routes.test.js +680 -14
  30. package/dist/admin/routes.test.js.map +1 -1
  31. package/dist/admin/server-logs.test.d.ts +1 -0
  32. package/dist/admin/server-logs.test.js +58 -0
  33. package/dist/admin/server-logs.test.js.map +1 -0
  34. package/dist/admin/sessions.d.ts +26 -39
  35. package/dist/admin/sessions.js +86 -310
  36. package/dist/admin/sessions.js.map +1 -1
  37. package/dist/admin/sessions.test.d.ts +1 -0
  38. package/dist/admin/sessions.test.js +78 -0
  39. package/dist/admin/sessions.test.js.map +1 -0
  40. package/dist/admin/version.d.ts +15 -0
  41. package/dist/admin/version.js +73 -0
  42. package/dist/admin/version.js.map +1 -0
  43. package/dist/admin/version.test.d.ts +1 -0
  44. package/dist/admin/version.test.js +71 -0
  45. package/dist/admin/version.test.js.map +1 -0
  46. package/dist/cli-config.d.ts +55 -0
  47. package/dist/cli-config.js +119 -0
  48. package/dist/cli-config.js.map +1 -0
  49. package/dist/cli-config.test.d.ts +1 -0
  50. package/dist/cli-config.test.js +92 -0
  51. package/dist/cli-config.test.js.map +1 -0
  52. package/dist/cli-format.d.ts +19 -0
  53. package/dist/cli-format.js +107 -0
  54. package/dist/cli-format.js.map +1 -0
  55. package/dist/cli-server.d.ts +73 -0
  56. package/dist/cli-server.js +347 -0
  57. package/dist/cli-server.js.map +1 -0
  58. package/dist/cli-server.test.d.ts +1 -0
  59. package/dist/cli-server.test.js +38 -0
  60. package/dist/cli-server.test.js.map +1 -0
  61. package/dist/cli-timeline.d.ts +35 -0
  62. package/dist/cli-timeline.js +373 -0
  63. package/dist/cli-timeline.js.map +1 -0
  64. package/dist/cli-timeline.test.d.ts +1 -0
  65. package/dist/cli-timeline.test.js +104 -0
  66. package/dist/cli-timeline.test.js.map +1 -0
  67. package/dist/cli.d.ts +0 -19
  68. package/dist/cli.js +910 -194
  69. package/dist/cli.js.map +1 -1
  70. package/dist/config-overlay.test.d.ts +1 -0
  71. package/dist/config-overlay.test.js +112 -0
  72. package/dist/config-overlay.test.js.map +1 -0
  73. package/dist/config-resolve.d.ts +28 -0
  74. package/dist/config-resolve.js +47 -0
  75. package/dist/config-resolve.js.map +1 -0
  76. package/dist/config-resolve.test.d.ts +1 -0
  77. package/dist/config-resolve.test.js +68 -0
  78. package/dist/config-resolve.test.js.map +1 -0
  79. package/dist/config.d.ts +62 -49
  80. package/dist/config.js +452 -76
  81. package/dist/config.js.map +1 -1
  82. package/dist/config.test.js +201 -32
  83. package/dist/config.test.js.map +1 -1
  84. package/dist/connectors/github-webhook.js +83 -5
  85. package/dist/connectors/github-webhook.js.map +1 -1
  86. package/dist/connectors/github-webhook.test.d.ts +1 -0
  87. package/dist/connectors/github-webhook.test.js +156 -0
  88. package/dist/connectors/github-webhook.test.js.map +1 -0
  89. package/dist/connectors/messaging/session-manager.d.ts +18 -0
  90. package/dist/connectors/messaging/session-manager.js +83 -2
  91. package/dist/connectors/messaging/session-manager.js.map +1 -1
  92. package/dist/connectors/messaging/session-manager.test.d.ts +1 -0
  93. package/dist/connectors/messaging/session-manager.test.js +144 -0
  94. package/dist/connectors/messaging/session-manager.test.js.map +1 -0
  95. package/dist/connectors/slack/connector.d.ts +9 -0
  96. package/dist/connectors/slack/connector.js +15 -0
  97. package/dist/connectors/slack/connector.js.map +1 -1
  98. package/dist/connectors/slack/mrkdwn.js +81 -0
  99. package/dist/connectors/slack/mrkdwn.js.map +1 -1
  100. package/dist/connectors/slack/mrkdwn.test.js +49 -0
  101. package/dist/connectors/slack/mrkdwn.test.js.map +1 -1
  102. package/dist/connectors/types.d.ts +1 -1
  103. package/dist/cron/fanout.d.ts +33 -0
  104. package/dist/cron/fanout.js +55 -0
  105. package/dist/cron/fanout.js.map +1 -0
  106. package/dist/cron/fanout.test.d.ts +1 -0
  107. package/dist/cron/fanout.test.js +73 -0
  108. package/dist/cron/fanout.test.js.map +1 -0
  109. package/dist/cron/jobs.d.ts +5 -0
  110. package/dist/cron/jobs.js +10 -3
  111. package/dist/cron/jobs.js.map +1 -1
  112. package/dist/cron/scheduler.d.ts +12 -0
  113. package/dist/cron/scheduler.js +27 -1
  114. package/dist/cron/scheduler.js.map +1 -1
  115. package/dist/engine/agent-executor.d.ts +158 -0
  116. package/dist/engine/agent-executor.js +1140 -0
  117. package/dist/engine/agent-executor.js.map +1 -0
  118. package/dist/engine/agent-executor.test.d.ts +1 -0
  119. package/dist/engine/agent-executor.test.js +395 -0
  120. package/dist/engine/agent-executor.test.js.map +1 -0
  121. package/dist/engine/chat-runner.d.ts +123 -0
  122. package/dist/engine/chat-runner.js +379 -0
  123. package/dist/engine/chat-runner.js.map +1 -0
  124. package/dist/engine/chat-runner.test.d.ts +1 -0
  125. package/dist/engine/chat-runner.test.js +104 -0
  126. package/dist/engine/chat-runner.test.js.map +1 -0
  127. package/dist/engine/chat-skills.d.ts +45 -0
  128. package/dist/engine/chat-skills.js +184 -0
  129. package/dist/engine/chat-skills.js.map +1 -0
  130. package/dist/engine/chat-skills.test.d.ts +1 -0
  131. package/dist/engine/chat-skills.test.js +20 -0
  132. package/dist/engine/chat-skills.test.js.map +1 -0
  133. package/dist/engine/chat.d.ts +20 -23
  134. package/dist/engine/chat.js +251 -155
  135. package/dist/engine/chat.js.map +1 -1
  136. package/dist/engine/chat.test.d.ts +1 -0
  137. package/dist/engine/chat.test.js +42 -0
  138. package/dist/engine/chat.test.js.map +1 -0
  139. package/dist/engine/classifier.d.ts +35 -2
  140. package/dist/engine/classifier.js +170 -31
  141. package/dist/engine/classifier.js.map +1 -1
  142. package/dist/engine/classifier.test.d.ts +1 -0
  143. package/dist/engine/classifier.test.js +115 -0
  144. package/dist/engine/classifier.test.js.map +1 -0
  145. package/dist/engine/dispatcher.d.ts +60 -0
  146. package/dist/engine/dispatcher.js +601 -0
  147. package/dist/engine/dispatcher.js.map +1 -0
  148. package/dist/engine/dispatcher.test.d.ts +1 -0
  149. package/dist/engine/dispatcher.test.js +511 -0
  150. package/dist/engine/dispatcher.test.js.map +1 -0
  151. package/dist/engine/event-shim.d.ts +117 -0
  152. package/dist/engine/event-shim.js +435 -0
  153. package/dist/engine/event-shim.js.map +1 -0
  154. package/dist/engine/event-shim.test.d.ts +1 -0
  155. package/dist/engine/event-shim.test.js +194 -0
  156. package/dist/engine/event-shim.test.js.map +1 -0
  157. package/dist/engine/git-auth.d.ts +13 -17
  158. package/dist/engine/git-auth.js +99 -42
  159. package/dist/engine/git-auth.js.map +1 -1
  160. package/dist/engine/git-auth.test.d.ts +1 -0
  161. package/dist/engine/git-auth.test.js +117 -0
  162. package/dist/engine/git-auth.test.js.map +1 -0
  163. package/dist/engine/github-app-client.d.ts +7 -0
  164. package/dist/engine/github-app-client.js +16 -0
  165. package/dist/engine/github-app-client.js.map +1 -0
  166. package/dist/engine/github-app-client.test.d.ts +1 -0
  167. package/dist/engine/github-app-client.test.js +44 -0
  168. package/dist/engine/github-app-client.test.js.map +1 -0
  169. package/dist/engine/github-tools.d.ts +12 -0
  170. package/dist/engine/github-tools.js +261 -0
  171. package/dist/engine/github-tools.js.map +1 -0
  172. package/dist/engine/github.d.ts +1027 -43
  173. package/dist/engine/github.js +153 -14
  174. package/dist/engine/github.js.map +1 -1
  175. package/dist/engine/llm.d.ts +47 -0
  176. package/dist/engine/llm.js +221 -0
  177. package/dist/engine/llm.js.map +1 -0
  178. package/dist/engine/llm.test.d.ts +1 -0
  179. package/dist/engine/llm.test.js +189 -0
  180. package/dist/engine/llm.test.js.map +1 -0
  181. package/dist/engine/profiles.d.ts +249 -0
  182. package/dist/engine/profiles.js +42 -0
  183. package/dist/engine/profiles.js.map +1 -0
  184. package/dist/engine/router.d.ts +12 -6
  185. package/dist/engine/router.js +339 -81
  186. package/dist/engine/router.js.map +1 -1
  187. package/dist/engine/router.test.js +428 -78
  188. package/dist/engine/router.test.js.map +1 -1
  189. package/dist/engine/screen.d.ts +41 -0
  190. package/dist/engine/screen.js +93 -0
  191. package/dist/engine/screen.js.map +1 -0
  192. package/dist/engine/screen.test.d.ts +1 -0
  193. package/dist/engine/screen.test.js +82 -0
  194. package/dist/engine/screen.test.js.map +1 -0
  195. package/dist/index.js +377 -432
  196. package/dist/index.js.map +1 -1
  197. package/dist/managed-repos.d.ts +7 -9
  198. package/dist/managed-repos.js +12 -15
  199. package/dist/managed-repos.js.map +1 -1
  200. package/dist/managed-repos.test.js +24 -19
  201. package/dist/managed-repos.test.js.map +1 -1
  202. package/dist/notify/index.d.ts +15 -0
  203. package/dist/notify/index.js +6 -0
  204. package/dist/notify/index.js.map +1 -0
  205. package/dist/notify/model.d.ts +57 -0
  206. package/dist/notify/model.js +89 -0
  207. package/dist/notify/model.js.map +1 -0
  208. package/dist/notify/model.test.d.ts +1 -0
  209. package/dist/notify/model.test.js +109 -0
  210. package/dist/notify/model.test.js.map +1 -0
  211. package/dist/notify/notifier.d.ts +17 -0
  212. package/dist/notify/notifier.js +87 -0
  213. package/dist/notify/notifier.js.map +1 -0
  214. package/dist/notify/notifier.test.d.ts +1 -0
  215. package/dist/notify/notifier.test.js +100 -0
  216. package/dist/notify/notifier.test.js.map +1 -0
  217. package/dist/notify/render.d.ts +21 -0
  218. package/dist/notify/render.js +59 -0
  219. package/dist/notify/render.js.map +1 -0
  220. package/dist/notify/render.test.d.ts +1 -0
  221. package/dist/notify/render.test.js +65 -0
  222. package/dist/notify/render.test.js.map +1 -0
  223. package/dist/notify/transports/github.d.ts +26 -0
  224. package/dist/notify/transports/github.js +26 -0
  225. package/dist/notify/transports/github.js.map +1 -0
  226. package/dist/notify/transports/slack.d.ts +26 -0
  227. package/dist/notify/transports/slack.js +28 -0
  228. package/dist/notify/transports/slack.js.map +1 -0
  229. package/dist/notify/transports.test.d.ts +1 -0
  230. package/dist/notify/transports.test.js +71 -0
  231. package/dist/notify/transports.test.js.map +1 -0
  232. package/dist/notify/types.d.ts +95 -0
  233. package/dist/notify/types.js +16 -0
  234. package/dist/notify/types.js.map +1 -0
  235. package/dist/sandbox/docker-compose.test.d.ts +1 -0
  236. package/dist/sandbox/docker-compose.test.js +128 -0
  237. package/dist/sandbox/docker-compose.test.js.map +1 -0
  238. package/dist/sandbox/docker.d.ts +97 -9
  239. package/dist/sandbox/docker.js +192 -41
  240. package/dist/sandbox/docker.js.map +1 -1
  241. package/dist/sandbox/docker.test.d.ts +1 -0
  242. package/dist/sandbox/docker.test.js +137 -0
  243. package/dist/sandbox/docker.test.js.map +1 -0
  244. package/dist/sandbox/egress-allowlist.d.ts +69 -0
  245. package/dist/sandbox/egress-allowlist.js +164 -0
  246. package/dist/sandbox/egress-allowlist.js.map +1 -0
  247. package/dist/sandbox/egress-allowlist.test.d.ts +1 -0
  248. package/dist/sandbox/egress-allowlist.test.js +65 -0
  249. package/dist/sandbox/egress-allowlist.test.js.map +1 -0
  250. package/dist/sandbox/egress-firewall-config.d.ts +127 -0
  251. package/dist/sandbox/egress-firewall-config.js +434 -0
  252. package/dist/sandbox/egress-firewall-config.js.map +1 -0
  253. package/dist/sandbox/egress-firewall-config.test.d.ts +1 -0
  254. package/dist/sandbox/egress-firewall-config.test.js +244 -0
  255. package/dist/sandbox/egress-firewall-config.test.js.map +1 -0
  256. package/dist/sandbox/images.d.ts +23 -0
  257. package/dist/sandbox/images.js +55 -0
  258. package/dist/sandbox/images.js.map +1 -0
  259. package/dist/sandbox/index.d.ts +82 -3
  260. package/dist/sandbox/index.js +223 -93
  261. package/dist/sandbox/index.js.map +1 -1
  262. package/dist/sandbox/index.test.d.ts +1 -0
  263. package/dist/sandbox/index.test.js +157 -0
  264. package/dist/sandbox/index.test.js.map +1 -0
  265. package/dist/session-log.d.ts +63 -0
  266. package/dist/session-log.js +290 -0
  267. package/dist/session-log.js.map +1 -0
  268. package/dist/session-log.test.d.ts +1 -0
  269. package/dist/session-log.test.js +85 -0
  270. package/dist/session-log.test.js.map +1 -0
  271. package/dist/setup.d.ts +30 -1
  272. package/dist/setup.js +241 -25
  273. package/dist/setup.js.map +1 -1
  274. package/dist/setup.test.js +138 -6
  275. package/dist/setup.test.js.map +1 -1
  276. package/dist/state/approval-store.d.ts +86 -0
  277. package/dist/state/approval-store.js +140 -0
  278. package/dist/state/approval-store.js.map +1 -0
  279. package/dist/state/build-assets.d.ts +50 -0
  280. package/dist/state/build-assets.js +154 -0
  281. package/dist/state/build-assets.js.map +1 -0
  282. package/dist/state/build-assets.test.d.ts +1 -0
  283. package/dist/state/build-assets.test.js +106 -0
  284. package/dist/state/build-assets.test.js.map +1 -0
  285. package/dist/state/db.d.ts +65 -333
  286. package/dist/state/db.js +112 -835
  287. package/dist/state/db.js.map +1 -1
  288. package/dist/state/db.test.js +127 -91
  289. package/dist/state/db.test.js.map +1 -1
  290. package/dist/state/execution-store.d.ts +257 -0
  291. package/dist/state/execution-store.js +527 -0
  292. package/dist/state/execution-store.js.map +1 -0
  293. package/dist/state/migrate.d.ts +15 -0
  294. package/dist/state/migrate.js +175 -0
  295. package/dist/state/migrate.js.map +1 -0
  296. package/dist/state/workflow-run-store.d.ts +195 -0
  297. package/dist/state/workflow-run-store.js +363 -0
  298. package/dist/state/workflow-run-store.js.map +1 -0
  299. package/dist/state/workflow-run-store.test.d.ts +1 -0
  300. package/dist/state/workflow-run-store.test.js +264 -0
  301. package/dist/state/workflow-run-store.test.js.map +1 -0
  302. package/dist/telemetry/index.d.ts +38 -0
  303. package/dist/telemetry/index.js +253 -0
  304. package/dist/telemetry/index.js.map +1 -0
  305. package/dist/telemetry/index.test.d.ts +1 -0
  306. package/dist/telemetry/index.test.js +73 -0
  307. package/dist/telemetry/index.test.js.map +1 -0
  308. package/dist/telemetry/pi-events.d.ts +12 -0
  309. package/dist/telemetry/pi-events.js +134 -0
  310. package/dist/telemetry/pi-events.js.map +1 -0
  311. package/dist/telemetry/pi-events.test.d.ts +1 -0
  312. package/dist/telemetry/pi-events.test.js +69 -0
  313. package/dist/telemetry/pi-events.test.js.map +1 -0
  314. package/dist/workflows/dag.d.ts +13 -1
  315. package/dist/workflows/dag.js +7 -3
  316. package/dist/workflows/dag.js.map +1 -1
  317. package/dist/workflows/dag.test.js +22 -0
  318. package/dist/workflows/dag.test.js.map +1 -1
  319. package/dist/workflows/golden-build.test.d.ts +1 -0
  320. package/dist/workflows/golden-build.test.js +45 -0
  321. package/dist/workflows/golden-build.test.js.map +1 -0
  322. package/dist/workflows/loader-overlay.test.d.ts +1 -0
  323. package/dist/workflows/loader-overlay.test.js +101 -0
  324. package/dist/workflows/loader-overlay.test.js.map +1 -0
  325. package/dist/workflows/loader.d.ts +36 -11
  326. package/dist/workflows/loader.js +311 -87
  327. package/dist/workflows/loader.js.map +1 -1
  328. package/dist/workflows/loader.test.js +114 -10
  329. package/dist/workflows/loader.test.js.map +1 -1
  330. package/dist/workflows/loop-eval.js +2 -0
  331. package/dist/workflows/loop-eval.js.map +1 -1
  332. package/dist/workflows/loop-eval.test.js +14 -0
  333. package/dist/workflows/loop-eval.test.js.map +1 -1
  334. package/dist/workflows/phase-executor.d.ts +145 -0
  335. package/dist/workflows/phase-executor.js +691 -0
  336. package/dist/workflows/phase-executor.js.map +1 -0
  337. package/dist/workflows/phase-executor.test.d.ts +1 -0
  338. package/dist/workflows/phase-executor.test.js +434 -0
  339. package/dist/workflows/phase-executor.test.js.map +1 -0
  340. package/dist/workflows/phase-ref.d.ts +44 -0
  341. package/dist/workflows/phase-ref.js +78 -0
  342. package/dist/workflows/phase-ref.js.map +1 -0
  343. package/dist/workflows/phase-ref.test.d.ts +1 -0
  344. package/dist/workflows/phase-ref.test.js +24 -0
  345. package/dist/workflows/phase-ref.test.js.map +1 -0
  346. package/dist/workflows/resume.d.ts +5 -11
  347. package/dist/workflows/resume.js +88 -7
  348. package/dist/workflows/resume.js.map +1 -1
  349. package/dist/workflows/runner.d.ts +34 -27
  350. package/dist/workflows/runner.js +252 -930
  351. package/dist/workflows/runner.js.map +1 -1
  352. package/dist/workflows/runner.test.js +346 -35
  353. package/dist/workflows/runner.test.js.map +1 -1
  354. package/dist/workflows/schema.d.ts +52 -10
  355. package/dist/workflows/schema.js +147 -7
  356. package/dist/workflows/schema.js.map +1 -1
  357. package/dist/workflows/simple.d.ts +67 -3
  358. package/dist/workflows/simple.js +248 -87
  359. package/dist/workflows/simple.js.map +1 -1
  360. package/dist/workflows/simple.test.d.ts +1 -0
  361. package/dist/workflows/simple.test.js +107 -0
  362. package/dist/workflows/simple.test.js.map +1 -0
  363. package/dist/workflows/templates.d.ts +29 -0
  364. package/dist/workflows/templates.js +42 -4
  365. package/dist/workflows/templates.js.map +1 -1
  366. package/dist/workflows/templates.test.js +103 -0
  367. package/dist/workflows/templates.test.js.map +1 -1
  368. package/dist/workflows/triggers.d.ts +21 -0
  369. package/dist/workflows/triggers.js +44 -0
  370. package/dist/workflows/triggers.js.map +1 -0
  371. package/dist/workflows/verdict.d.ts +19 -0
  372. package/dist/workflows/verdict.js +30 -0
  373. package/dist/workflows/verdict.js.map +1 -0
  374. package/dist/workflows/verdict.test.d.ts +1 -0
  375. package/dist/workflows/verdict.test.js +44 -0
  376. package/dist/workflows/verdict.test.js.map +1 -0
  377. package/dist/worktree/manager.d.ts +1 -1
  378. package/dist/worktree/manager.js +14 -14
  379. package/dist/worktree/manager.js.map +1 -1
  380. package/dist/worktree/manager.test.d.ts +1 -0
  381. package/dist/worktree/manager.test.js +87 -0
  382. package/dist/worktree/manager.test.js.map +1 -0
  383. package/docker-compose.yml +206 -5
  384. package/package.json +19 -9
  385. package/sandbox.Dockerfile +97 -18
  386. package/skills/README.md +40 -0
  387. package/skills/browser-qa/SKILL.md +193 -0
  388. package/skills/browser-qa/scripts/agent-browser.mjs +380 -0
  389. package/skills/building/SKILL.md +95 -0
  390. package/skills/chat/SKILL.md +30 -11
  391. package/skills/code-review/SKILL.md +59 -0
  392. package/skills/debug-production/SKILL.md +108 -0
  393. package/skills/demo/SKILL.md +194 -0
  394. package/skills/demo/scripts/compose-demo.sh +205 -0
  395. package/skills/issue-answer/SKILL.md +90 -0
  396. package/skills/issue-comment/SKILL.md +38 -24
  397. package/skills/issue-triage/SKILL.md +103 -37
  398. package/skills/issue-triage/references/AGENT-BRIEF.md +61 -0
  399. package/skills/pr-comment/SKILL.md +64 -0
  400. package/skills/pr-review/SKILL.md +65 -49
  401. package/skills/qa-test/SKILL.md +97 -0
  402. package/skills/repo-health/SKILL.md +48 -45
  403. package/skills/security-feedback/SKILL.md +173 -0
  404. package/skills/security-feedback/references/templates.md +77 -0
  405. package/skills/security-review/SKILL.md +213 -0
  406. package/skills/security-review/references/issue-format.md +302 -0
  407. package/skills/verify/SKILL.md +118 -0
  408. package/workflows/answer.yaml +37 -0
  409. package/workflows/build.yaml +32 -22
  410. package/workflows/cron-security.yaml +6 -0
  411. package/workflows/demo.yaml +55 -0
  412. package/workflows/explore.yaml +9 -0
  413. package/workflows/pr-comment.yaml +16 -0
  414. package/workflows/pr-fix.yaml +3 -0
  415. package/workflows/pr-review.yaml +4 -1
  416. package/workflows/prompts/answer.md +67 -0
  417. package/workflows/prompts/architect.md +24 -7
  418. package/workflows/prompts/demo.md +100 -0
  419. package/workflows/prompts/executor.md +29 -20
  420. package/workflows/prompts/explore-ask.md +51 -22
  421. package/workflows/prompts/explore-publish.md +2 -2
  422. package/workflows/prompts/explore-read.md +24 -10
  423. package/workflows/prompts/explore-synthesize.md +15 -8
  424. package/workflows/prompts/fix.md +9 -10
  425. package/workflows/prompts/guardrails.md +26 -8
  426. package/workflows/prompts/pr-fix.md +6 -7
  427. package/workflows/prompts/pr.md +11 -10
  428. package/workflows/prompts/qa-browser.md +95 -0
  429. package/workflows/prompts/qa-synth.md +44 -0
  430. package/workflows/prompts/qa-test.md +60 -0
  431. package/workflows/prompts/re-reviewer.md +8 -9
  432. package/workflows/prompts/reviewer.md +10 -9
  433. package/workflows/prompts/verify-browser.md +93 -0
  434. package/workflows/prompts/verify-synth.md +44 -0
  435. package/workflows/prompts/verify.md +63 -0
  436. package/workflows/qa-test.yaml +87 -0
  437. package/workflows/security-feedback.yaml +25 -0
  438. package/workflows/security-review.yaml +29 -0
  439. package/workflows/verify.yaml +85 -0
  440. package/deploy/mcp-config.tmpl.json +0 -14
  441. package/dist/cron/rate-limits.d.ts +0 -26
  442. package/dist/cron/rate-limits.js +0 -193
  443. package/dist/cron/rate-limits.js.map +0 -1
  444. package/dist/engine/executor.d.ts +0 -75
  445. package/dist/engine/executor.js +0 -292
  446. package/dist/engine/executor.js.map +0 -1
  447. package/mcp-github-app/package.json +0 -18
  448. package/mcp-github-app/src/auth.js +0 -66
  449. package/mcp-github-app/src/github.js +0 -371
  450. package/mcp-github-app/src/index.js +0 -534
  451. package/skills/devops/webhook-subscriptions/SKILL.md +0 -180
  452. package/skills/github/DESCRIPTION.md +0 -3
  453. package/skills/github/codebase-inspection/SKILL.md +0 -115
  454. package/skills/github/github-auth/SKILL.md +0 -54
  455. package/skills/github/github-auth/scripts/gh-env.sh +0 -66
  456. package/skills/github/github-code-review/SKILL.md +0 -480
  457. package/skills/github/github-code-review/references/review-output-template.md +0 -74
  458. package/skills/github/github-issues/SKILL.md +0 -369
  459. package/skills/github/github-issues/templates/bug-report.md +0 -35
  460. package/skills/github/github-issues/templates/feature-request.md +0 -31
  461. package/skills/github/github-pr-workflow/SKILL.md +0 -395
  462. package/skills/github/github-pr-workflow/references/ci-troubleshooting.md +0 -183
  463. package/skills/github/github-pr-workflow/references/conventional-commits.md +0 -71
  464. package/skills/github/github-pr-workflow/templates/pr-body-bugfix.md +0 -35
  465. package/skills/github/github-pr-workflow/templates/pr-body-feature.md +0 -33
  466. package/skills/github/github-repo-management/SKILL.md +0 -515
  467. package/skills/github/github-repo-management/references/github-api-cheatsheet.md +0 -161
  468. package/skills/github-orchestrator/SKILL.md +0 -103
  469. package/skills/mcp/DESCRIPTION.md +0 -3
  470. package/skills/mcp/mcporter/SKILL.md +0 -122
  471. package/skills/mcp/native-mcp/SKILL.md +0 -356
  472. package/skills/software-development/architect/SKILL.md +0 -154
  473. package/skills/software-development/assure-guardrails/SKILL.md +0 -239
  474. package/skills/software-development/plan/SKILL.md +0 -64
  475. package/skills/software-development/requesting-code-review/SKILL.md +0 -291
  476. package/skills/software-development/subagent-driven-development/SKILL.md +0 -433
  477. package/skills/software-development/systematic-debugging/SKILL.md +0 -366
  478. package/skills/software-development/test-driven-development/SKILL.md +0 -342
@@ -1,87 +1,13 @@
1
- import { randomUUID } from "crypto";
2
- import { execSync } from "child_process";
3
- import { readFileSync } from "fs";
4
- import { join, resolve } from "path";
5
- import { executeAgent } from "../engine/executor.js";
6
- import { resolveModel } from "../config.js";
7
- import { listRunningContainers } from "../admin/docker.js";
1
+ import { resolveModel, resolveVariant } from "../config.js";
8
2
  import { loadPromptTemplate } from "./loader.js";
9
3
  import { renderTemplate } from "./templates.js";
10
- import { evalUntilExpression } from "./loop-eval.js";
11
4
  import { buildDag, getReadyNodes, getNodesToSkip, isComplete } from "./dag.js";
12
- /**
13
- * Load a skill's SKILL.md instructions from skills/<name>/SKILL.md, falling
14
- * back to .claude/skills/<name>/SKILL.md if the project-local copy isn't
15
- * present (matches the legacy executeSkill lookup order).
16
- */
17
- function loadSkillInstructions(skillName) {
18
- for (const base of [resolve("skills"), resolve(".claude/skills")]) {
19
- try {
20
- return readFileSync(join(base, skillName, "SKILL.md"), "utf-8");
21
- }
22
- catch {
23
- /* try next path */
24
- }
25
- }
26
- throw new Error(`Skill not found: skills/${skillName}/SKILL.md`);
27
- }
28
- /**
29
- * Build the agent prompt for a phase, handling both `prompt:` (template file)
30
- * and `skill:` (SKILL.md reference) phase definitions.
31
- *
32
- * Skill phases produce the same prompt shape the legacy executeSkill used:
33
- * "Follow these skill instructions:\n\n<SKILL.md>\n\nContext:\n<key: value lines>"
34
- * Template variables ({{owner}}, {{issueNumber}}, etc.) are still rendered in
35
- * the SKILL.md content so skills can reference workflow context if they want.
36
- */
37
- function buildPhasePrompt(phase, ctx, extraCtx) {
38
- const fullCtx = extraCtx ? { ...ctx, ...extraCtx } : ctx;
39
- if (phase.skill) {
40
- const skillContent = loadSkillInstructions(phase.skill);
41
- const renderedSkill = renderTemplate(skillContent, fullCtx);
42
- // Build a context block from the workflow context — same shape that the
43
- // legacy executeSkill produced, so existing skill instructions still work.
44
- const contextLines = Object.entries(fullCtx)
45
- .filter(([, v]) => v !== undefined && v !== null)
46
- .map(([k, v]) => `${k}: ${typeof v === "object" ? JSON.stringify(v) : v}`)
47
- .join("\n");
48
- return `Follow these skill instructions:\n\n${renderedSkill}\n\nContext:\n${contextLines}`;
49
- }
50
- if (phase.prompt) {
51
- const template = loadPromptTemplate(phase.prompt);
52
- return renderTemplate(template, fullCtx);
53
- }
54
- throw new Error(`Phase "${phase.name}" has neither prompt: nor skill: — cannot build prompt`);
55
- }
56
- // ── Phase-level deduplication ────────────────────────────────────────────────
57
- /**
58
- * Check if a sandbox container is actually running for a given taskId prefix.
59
- */
60
- async function isContainerAlive(taskId) {
61
- try {
62
- const containers = await listRunningContainers();
63
- return containers.some((c) => c.taskId === taskId);
64
- }
65
- catch {
66
- return false;
67
- }
68
- }
69
- /**
70
- * Check if an error was caused by manual termination.
71
- */
72
- export function isTerminated(error) {
73
- if (!error)
74
- return false;
75
- const lower = error.toLowerCase();
76
- return (lower.includes("terminated") ||
77
- lower.includes("killed") ||
78
- lower.includes("exit undefined") ||
79
- (lower.includes("container") && lower.includes("not running")));
80
- }
81
- function pickResult(r) {
82
- return { success: r.success, output: r.output, error: r.error };
83
- }
84
- function gitAccessProfileForWorkflow(workflowName) {
5
+ import { qaImageAvailable, SANDBOX_IMAGE_QA } from "../sandbox/images.js";
6
+ import { PhaseExecutor, isTerminated, } from "./phase-executor.js";
7
+ import { collapseDetail } from "../notify/render.js";
8
+ // `isTerminated` used to live here; re-exported for API stability.
9
+ export { isTerminated };
10
+ export function gitAccessProfileForWorkflow(workflowName) {
85
11
  switch (workflowName) {
86
12
  case "build":
87
13
  case "pr-fix":
@@ -90,159 +16,58 @@ function gitAccessProfileForWorkflow(workflowName) {
90
16
  return "review-write";
91
17
  case "issue-triage":
92
18
  case "issue-comment":
19
+ case "pr-comment":
93
20
  case "explore":
21
+ case "answer":
22
+ case "security-review":
23
+ // verify / qa-test / demo read the repo and post a findings/demo comment —
24
+ // they never push code, so issues-write (contents:read + issues:write) is
25
+ // enough.
26
+ case "verify":
27
+ case "qa-test":
28
+ case "demo":
94
29
  return "issues-write";
30
+ case "security-feedback":
31
+ return "repo-write";
95
32
  default:
96
33
  return "read";
97
34
  }
98
35
  }
99
- function gitSandboxAccessForWorkflow(workflowName, owner, repo) {
36
+ export function gitSandboxAccessForWorkflow(workflowName, owner, repo, prePopulateBranch, runId) {
100
37
  const profile = gitAccessProfileForWorkflow(workflowName);
101
38
  return {
102
39
  owner,
103
40
  repo,
104
41
  profile,
105
- // Only high-trust code-writing workflows get sandbox-side app-key access.
106
- allowMcpAppAuth: profile === "repo-write",
107
- };
108
- }
109
- /**
110
- * Run a single agent phase with DB-tracked deduplication.
111
- */
112
- async function runPhase(workflowName, phaseName, taskId, triggerId, prompt, config, db, modelOverride, workflowRunId, githubAccess) {
113
- const dedupKey = `${workflowName}:${phaseName}`;
114
- if (db) {
115
- const status = db.shouldRunPhase(dedupKey, triggerId, workflowRunId);
116
- if (status === "running") {
117
- const alive = await isContainerAlive(taskId);
118
- if (alive) {
119
- console.log(`[runner] Phase ${phaseName} is already running (container alive) — skipping`);
120
- return { skipped: true, reason: "running" };
121
- }
122
- console.log(`[runner] Phase ${phaseName} was running but container is dead — cleaning up`);
123
- db.markStaleAsFailed(dedupKey, triggerId, workflowRunId);
124
- }
125
- else if (status === "done") {
126
- console.log(`[runner] Phase ${phaseName} already completed successfully — skipping`);
127
- return { skipped: true, reason: "done" };
128
- }
129
- const executionId = randomUUID();
130
- db.recordStart({
131
- id: executionId,
132
- triggerType: "webhook",
133
- triggerId,
134
- skill: dedupKey,
135
- repo: undefined,
136
- issueNumber: undefined,
137
- startedAt: new Date().toISOString(),
138
- workflowRunId,
139
- });
140
- const phaseConfig = modelOverride ? { ...config, model: modelOverride } : config;
141
- const result = await executeAgent(prompt, phaseConfig, {
142
- taskId,
143
- githubAccess,
144
- // Persist the session id as soon as it arrives so the dashboard can
145
- // show live agent logs for an in-flight phase, not just completed ones.
146
- onSessionId: (sessionId) => {
147
- try {
148
- db.recordSessionId(executionId, sessionId);
149
- }
150
- catch (err) {
151
- console.warn(`[runner] Failed to persist session id mid-run for ${phaseName}:`, err);
152
- }
153
- },
154
- });
155
- db.recordFinish(executionId, {
156
- success: result.success,
157
- error: result.error,
158
- turns: result.turns,
159
- durationMs: result.durationMs,
160
- sessionId: result.sessionId,
161
- costUsd: result.costUsd,
162
- inputTokens: result.inputTokens,
163
- cacheCreationInputTokens: result.cacheCreationInputTokens,
164
- cacheReadInputTokens: result.cacheReadInputTokens,
165
- outputTokens: result.outputTokens,
166
- apiDurationMs: result.apiDurationMs,
167
- stopReason: result.stopReason,
168
- });
169
- return { result, skipped: false };
170
- }
171
- const phaseConfig = modelOverride ? { ...config, model: modelOverride } : config;
172
- const result = await executeAgent(prompt, phaseConfig, { taskId, githubAccess });
173
- return { result, skipped: false };
174
- }
175
- // ── Resume logic ─────────────────────────────────────────────────────────────
176
- /**
177
- * Resolve a recorded phase name to its index in `definition.phases`.
178
- * Handles the generated iteration labels the runner writes for looping phases:
179
- *
180
- * `${phase}_iter_${n}` — generic_loop iteration
181
- * `${phase}_fix_${n}` — reviewer-style loop fix cycle
182
- * `${phase}_${n}` — reviewer-style loop re-review cycle
183
- *
184
- * Returns -1 when the name doesn't match any phase (unknown/untracked labels
185
- * like `waiting_approval` or user set_phase values such as `complete`).
186
- */
187
- export function phaseIndexInDefinition(definition, name) {
188
- const exact = definition.phases.findIndex((p) => p.name === name);
189
- if (exact >= 0)
190
- return exact;
191
- const tryStrip = (re) => {
192
- const m = name.match(re);
193
- if (!m)
194
- return -1;
195
- return definition.phases.findIndex((p) => p.name === m[1]);
42
+ // Never forward the App PEM into sandboxes. The harness already mints a
43
+ // profile-scoped token and forwards it as GITHUB_TOKEN, so the agent gets
44
+ // github tools in static-token mode without the App private key ever
45
+ // entering the sandbox.
46
+ allowMcpAppAuth: false,
47
+ prePopulateBranch,
48
+ runId,
49
+ // Read-only workflows never need git history clone at --depth 1. Only
50
+ // the code-pushing profiles (build / pr-fix / security-feedback) keep the
51
+ // deeper clone for rebase/amend headroom.
52
+ shallow: profile !== "repo-write",
196
53
  };
197
- const iterIdx = tryStrip(/^(.*)_iter_\d+$/);
198
- if (iterIdx >= 0)
199
- return iterIdx;
200
- const fixIdx = tryStrip(/^(.*)_fix_\d+$/);
201
- if (fixIdx >= 0)
202
- return fixIdx;
203
- const cycleIdx = tryStrip(/^(.*)_\d+$/);
204
- if (cycleIdx >= 0)
205
- return cycleIdx;
206
- return -1;
207
- }
208
- /**
209
- * Given the phase the runner last completed, return the name of the phase the
210
- * runner should run next. Returns `null` when there is no next phase (i.e. the
211
- * workflow is done).
212
- */
213
- export function nextPhaseAfter(definition, completedPhase) {
214
- const idx = phaseIndexInDefinition(definition, completedPhase);
215
- if (idx < 0 || idx >= definition.phases.length - 1)
216
- return null;
217
- return definition.phases[idx + 1].name;
218
- }
219
- /**
220
- * Find the phase that owns an approval gate with the given name — either via
221
- * `phase.approval_gate` or `phase.loop.approval_gate`. Returns `null` when no
222
- * phase declares that gate. Gate names should be unique within a workflow.
223
- */
224
- function findGateOwningPhase(definition, gateName) {
225
- for (const p of definition.phases) {
226
- if (p.approval_gate === gateName)
227
- return p.name;
228
- if (p.loop?.approval_gate === gateName)
229
- return p.name;
230
- }
231
- return null;
232
- }
233
- // ── Main workflow runner ─────────────────────────────────────────────────────
234
- /** Returns true if any phase declares explicit dependencies — triggers DAG execution path. */
235
- function hasDependencies(definition) {
236
- return definition.phases.some((p) => p.depends_on && p.depends_on.length > 0);
237
54
  }
55
+ // ── Unified workflow scheduler ───────────────────────────────────────────────
238
56
  /**
239
57
  * Run an agent workflow defined by a YAML definition.
240
- * Interprets phases, approval gates, and loops generically — the runner has
241
- * no knowledge of specific phase names.
58
+ *
59
+ * Every workflow executes as a DAG: workflows that declare no `depends_on`
60
+ * are run as a synthesized chain (each phase depends on the one before it),
61
+ * reproducing the old linear semantics including the failure cascade. Ready
62
+ * nodes are executed **one at a time in declaration order** (sequential).
63
+ * Each node's body — context / standard agent / reviewer-loop / generic-loop,
64
+ * plus approval and reply gates — lives in {@link PhaseExecutor}; the
65
+ * scheduler here owns the DAG, the `phases[]`/`outputs{}` accumulation, and
66
+ * the in-memory node status.
242
67
  */
243
- export async function runWorkflow(definition, ctx, config, callbacks, db, models, approvalConfig, workflowId) {
68
+ export async function runWorkflow(definition, ctx, config, callbacks, db, models, approvalConfig, workflowId, variants) {
244
69
  const phases = [];
245
- const phaseOutputs = {};
70
+ const outputs = {};
246
71
  const { taskId } = ctx;
247
72
  // Slack-originated runs carry an explicit `slack:` trigger id — everything
248
73
  // else (GitHub webhook, CLI) uses the legacy owner/repo#N shape.
@@ -252,25 +77,73 @@ export async function runWorkflow(definition, ctx, config, callbacks, db, models
252
77
  // iteration at the right index and templates can read {{scratch.*}}.
253
78
  const scratch = ctx.scratch
254
79
  ? { ...ctx.scratch }
255
- : (db && workflowId ? { ...(db.getWorkflowRun(workflowId)?.scratch ?? {}) } : {});
80
+ : (db && workflowId ? { ...(db.runs.getRun(workflowId)?.scratch ?? {}) } : {});
256
81
  ctx.scratch = scratch;
257
- const githubAccess = gitSandboxAccessForWorkflow(definition.name, ctx.owner, ctx.repo);
82
+ const prePopulateBranch = typeof ctx.prePopulateBranch === "string"
83
+ ? ctx.prePopulateBranch
84
+ : undefined;
85
+ const githubAccess = gitSandboxAccessForWorkflow(definition.name, ctx.owner, ctx.repo, prePopulateBranch, workflowId);
258
86
  const notify = callbacks.postComment || (async () => { });
87
+ const reporter = callbacks.reporter;
259
88
  const onStart = callbacks.onPhaseStart || (async () => { });
260
89
  const onEnd = callbacks.onPhaseEnd || (async () => { });
90
+ // Terminal step key — dynamic loop steps (re-review / fix cycles) are
91
+ // inserted just above it so the checklist reads top-to-bottom in run order.
92
+ const lastPhaseKey = [...definition.phases]
93
+ .reverse()
94
+ .find((p) => (p.type ?? "agent") !== "context")?.name;
261
95
  const modelFor = (taskType) => models ? resolveModel(models, taskType) : undefined;
262
- /** Render a prompt template with current context. */
96
+ const variantFor = (taskType) => variants ? resolveVariant(variants, taskType) : undefined;
97
+ /** Render a prompt template with current context + outputs. */
263
98
  const renderPrompt = (promptPath, extraCtx) => {
264
99
  const template = loadPromptTemplate(promptPath);
265
- return renderTemplate(template, { ...ctx, phaseOutputs, ...(extraCtx || {}) });
100
+ return renderTemplate(template, { ...ctx, phaseOutputs: outputs, ...(extraCtx || {}) });
266
101
  };
267
- /** Render a YAML-provided message template and post it as a comment. */
102
+ /**
103
+ * Render a YAML message template and post it as a *standalone* message.
104
+ * Routes through `reporter.note()` when the in-place checklist is active,
105
+ * else the legacy `postComment`.
106
+ */
268
107
  const notifyMessage = async (template, extraCtx) => {
269
108
  if (!template)
270
109
  return;
271
- const rendered = renderTemplate(template, { ...ctx, phaseOutputs, ...(extraCtx || {}) });
272
- if (rendered.trim())
110
+ const rendered = renderTemplate(template, { ...ctx, phaseOutputs: outputs, ...(extraCtx || {}) });
111
+ if (!rendered.trim())
112
+ return;
113
+ if (reporter)
114
+ await reporter.note(rendered);
115
+ else
116
+ await notify(rendered);
117
+ };
118
+ /** Post a pre-rendered standalone message (already-built string). */
119
+ const postNote = async (text) => {
120
+ if (!text.trim())
121
+ return;
122
+ if (reporter)
123
+ await reporter.note(text);
124
+ else
125
+ await notify(text);
126
+ };
127
+ /** Transition a checklist step (and optionally render a YAML message detail). */
128
+ const reportStep = async (key, status, template, extraCtx, opts) => {
129
+ const rendered = template
130
+ ? renderTemplate(template, { ...ctx, phaseOutputs: outputs, ...(extraCtx || {}) }).trim()
131
+ : "";
132
+ if (reporter) {
133
+ const detail = collapseDetail(rendered);
134
+ if (opts?.insert) {
135
+ const step = { key, label: opts.label ?? key, status, detail };
136
+ await reporter.insertStep(step, opts.insertBefore ?? lastPhaseKey);
137
+ }
138
+ else {
139
+ await reporter.step(key, status, detail);
140
+ }
141
+ if (opts?.alsoNote && rendered)
142
+ await reporter.note(rendered);
143
+ }
144
+ else if (rendered) {
273
145
  await notify(rendered);
146
+ }
274
147
  };
275
148
  /** Persist a phase transition to the DB workflow run. */
276
149
  const persistPhase = (phase, summary) => {
@@ -281,758 +154,207 @@ export async function runWorkflow(definition, ctx, config, callbacks, db, models
281
154
  success: true,
282
155
  summary,
283
156
  };
284
- db.updateWorkflowPhase(workflowId, phase, entry);
157
+ db.runs.appendPhase(workflowId, phase, entry);
285
158
  }
286
159
  };
287
160
  /** Mark the workflow run as failed. */
288
161
  const failWorkflow = (errorMsg) => {
289
162
  if (db && workflowId) {
290
- db.finishWorkflowRun(workflowId, "failed", errorMsg);
163
+ db.runs.finishRun(workflowId, "failed", { error: errorMsg });
291
164
  }
292
165
  };
293
- // Determine resume point from the DB row's currentPhase. If the row doesn't
294
- // exist (no DB), default to running everything from the first phase.
295
- let resumeFromIdx = 0;
296
- if (db && workflowId) {
297
- const run = db.getWorkflowRun(workflowId);
298
- if (run?.currentPhase) {
299
- const next = nextPhaseAfter(definition, run.currentPhase);
300
- if (next) {
301
- const idx = phaseIndexInDefinition(definition, next);
302
- if (idx >= 0)
303
- resumeFromIdx = idx;
304
- }
305
- }
306
- }
307
- const shouldRun = (phaseName) => {
308
- const idx = phaseIndexInDefinition(definition, phaseName);
309
- // Unknown phases always run — they're not tracked in the definition order.
310
- if (idx === -1)
311
- return true;
312
- return idx >= resumeFromIdx;
313
- };
314
166
  /** Should an approval gate with this name actually pause the workflow? */
315
167
  const gateEnabled = (gateName) => !!gateName && approvalConfig?.[gateName] === true;
316
- // ── Execute phases ────────────────────────────────────────────────────────
317
- if (hasDependencies(definition)) {
318
- return runDagWorkflow(definition, ctx, config, callbacks, db, models, approvalConfig, workflowId, { phases, phaseOutputs, triggerId, notify, notifyMessage, onStart, onEnd, modelFor, renderPrompt, persistPhase, failWorkflow, gateEnabled, githubAccess });
319
- }
320
- for (const phase of definition.phases) {
321
- const { name: phaseName, type: phaseType = "agent" } = phase;
322
- // Linear workflows share one sandbox workspace across phases via `taskId`.
323
- // (DAG path uses phase-scoped taskIds to avoid concurrent write races.)
324
- // ── Context-only phase (no agent execution) ───────────────────────────
325
- if (phaseType === "context") {
326
- if (shouldRun(phaseName)) {
327
- await onStart(phaseName);
328
- phases.push({ phase: phaseName, success: true, output: "Context assembled" });
329
- // Persist a phase_history entry so the dashboard pipeline marks
330
- // context phases as 'done' instead of leaving them stuck on
331
- // 'pending' — they have no execution row to derive status from.
332
- persistPhase(phaseName, "Context assembled");
333
- await onEnd(phaseName, phases[phases.length - 1]);
168
+ // ── Collaborators ───────────────────────────────────────────────────────────
169
+ const runScope = {
170
+ definition,
171
+ ctx,
172
+ config,
173
+ taskId,
174
+ triggerId,
175
+ githubAccess,
176
+ scratch,
177
+ db,
178
+ workflowId,
179
+ };
180
+ const phaseReporter = {
181
+ onStart,
182
+ onEnd,
183
+ step: reportStep,
184
+ message: notifyMessage,
185
+ postNote,
186
+ persistPhase,
187
+ failWorkflow,
188
+ };
189
+ const phaseResolver = {
190
+ modelFor,
191
+ variantFor,
192
+ renderPrompt,
193
+ gateEnabled,
194
+ };
195
+ const executor = new PhaseExecutor(runScope, phaseReporter, phaseResolver);
196
+ // ── Schedule ─────────────────────────────────────────────────────────────────
197
+ const dag = buildDag(definition.phases, { chainIfNoDeps: true });
198
+ // Phase lookup + the backend this run is actually executing on. Used by the
199
+ // `requires_sandbox` gate below — config.sandbox is undefined when no backend
200
+ // override is set, which resolves to gondolin (see agent-executor).
201
+ const phaseByName = new Map(definition.phases.map((p) => [p.name, p]));
202
+ const activeBackend = config.sandbox ?? "gondolin";
203
+ while (!isComplete(dag)) {
204
+ // Honour a cancel that landed during the previous phase's execution.
205
+ if (db && workflowId) {
206
+ const latest = db.runs.getRun(workflowId);
207
+ if (latest?.status === "cancelled") {
208
+ console.log(`[runner] ${definition.name} cancelled — stopping`);
209
+ return { success: false, phases };
334
210
  }
335
- continue;
336
211
  }
337
- // ── Agent phase ───────────────────────────────────────────────────────
338
- if (!phase.prompt && !phase.skill) {
339
- console.warn(`[runner] Phase "${phaseName}" has type=agent but neither prompt: nor skill: skipping`);
340
- continue;
212
+ // Skip nodes whose trigger rule fails (deps terminal, rule unsatisfied).
213
+ // This is how a failure cascades to the end of a chain as skips. Skips are
214
+ // recorded in the executions ledger the single source of truth the
215
+ // dashboard derives phase status from.
216
+ const toSkip = getNodesToSkip(dag);
217
+ for (const node of toSkip) {
218
+ node.status = "skipped";
219
+ phases.push({ phase: node.name, success: true, output: "Skipped (trigger rule not satisfied)" });
220
+ db?.executions.recordSkippedPhase(`${definition.name}:${node.name}`, triggerId, workflowId, githubAccess.repo);
221
+ await reportStep(node.name, "skipped");
341
222
  }
342
- // Check if this is a looping phase (e.g. reviewer)
343
- if (phase.loop) {
344
- const loop = phase.loop;
345
- const MAX_CYCLES = loop.max_cycles;
346
- let approved = false;
347
- let fixCycles = 0;
348
- if (!shouldRun(phaseName)) {
349
- // The phase was already completed mark as approved and continue to next phase
350
- approved = true;
351
- phases.push({ phase: phaseName, success: true, output: "Already completed" });
352
- }
353
- while (!approved && fixCycles <= MAX_CYCLES) {
354
- const reviewLabel = fixCycles === 0 ? phaseName : `${phaseName}_${fixCycles + 1}`;
355
- if (!shouldRun(phaseName) && fixCycles === 0) {
356
- approved = true;
357
- break;
358
- }
359
- await onStart(reviewLabel);
360
- await notifyMessage(loop.messages?.on_cycle_start, { cycle: fixCycles + 1, maxCycles: MAX_CYCLES });
361
- // Choose prompt: first cycle uses phase.prompt or phase.skill (via
362
- // buildPhasePrompt), subsequent cycles always use the re_review_prompt
363
- // template path defined in loop.on_request_changes.
364
- const reviewPrompt = fixCycles === 0
365
- ? buildPhasePrompt(phase, { ...ctx, phaseOutputs }, { fixCycle: fixCycles })
366
- : renderPrompt(loop.on_request_changes.re_review_prompt, { fixCycle: fixCycles });
367
- // Resolve model
368
- const reviewModelRaw = phase.model ? renderTemplate(phase.model, ctx) : undefined;
369
- const reviewModel = reviewModelRaw || modelFor(phaseName);
370
- const rr = await runPhase(definition.name, reviewLabel, taskId, triggerId, reviewPrompt, config, db, reviewModel, workflowId, githubAccess);
371
- if (rr.skipped) {
372
- if (rr.reason === "running") {
373
- await notifyMessage(phase.messages?.on_skipped_done);
374
- return { success: false, phases };
375
- }
376
- approved = true;
377
- phases.push({ phase: reviewLabel, success: true, output: "Already completed" });
378
- break;
379
- }
380
- phases.push({ phase: reviewLabel, ...pickResult(rr.result) });
381
- await onEnd(reviewLabel, phases[phases.length - 1]);
382
- // Parse verdict
383
- const reviewerOutput = (rr.result.output || "").trim();
384
- const verdictMarker = reviewerOutput.match(/^\s*VERDICT:\s*(APPROVED|REQUEST_CHANGES)\s*$/im);
385
- let isApproved;
386
- if (verdictMarker) {
387
- isApproved = verdictMarker[1].toUpperCase() === "APPROVED";
388
- }
389
- else {
390
- const upper = reviewerOutput.toUpperCase();
391
- const hasRequestChanges = /\bREQUEST_CHANGES\b/.test(upper);
392
- isApproved = !hasRequestChanges && /^APPROVED\b/.test(upper);
393
- console.warn(`[runner] Reviewer output missing VERDICT: marker — using fallback detection (isApproved=${isApproved})`);
394
- }
395
- if (isApproved) {
396
- approved = true;
397
- persistPhase(reviewLabel, "APPROVED");
398
- await notifyMessage(loop.messages?.on_approved, { cycle: fixCycles + 1 });
399
- }
400
- else if (fixCycles < MAX_CYCLES) {
401
- fixCycles++;
402
- persistPhase(reviewLabel, "REQUEST_CHANGES");
403
- // Approval gate before fix loop
404
- const gateKey = loop.approval_gate;
405
- if (gateEnabled(gateKey) && db && workflowId) {
406
- const approvalId = randomUUID();
407
- db.createApproval({
408
- id: approvalId,
409
- workflowRunId: workflowId,
410
- gate: gateKey,
411
- summary: `Reviewer requested changes (cycle ${fixCycles}/${MAX_CYCLES}) on phase ${phaseName}.`,
412
- requestedBy: ctx.sender,
413
- createdAt: new Date().toISOString(),
414
- });
415
- db.updateWorkflowPhase(workflowId, "waiting_approval", {
416
- phase: "waiting_approval",
417
- timestamp: new Date().toISOString(),
418
- success: true,
419
- summary: `Waiting for approval: ${gateKey} (${approvalId})`,
420
- });
421
- db.pauseWorkflowRun(workflowId);
422
- await notifyMessage(loop.messages?.on_pause_for_approval, {
423
- cycle: fixCycles,
424
- maxCycles: MAX_CYCLES,
425
- gateKey,
426
- });
427
- return { success: true, phases, paused: true };
428
- }
429
- await notifyMessage(loop.messages?.on_request_changes, { cycle: fixCycles, maxCycles: MAX_CYCLES });
430
- // Run fix phase
431
- const fixLabel = `${phaseName}_fix_${fixCycles}`;
432
- await onStart(fixLabel);
433
- await notifyMessage(loop.messages?.on_fix_start, { cycle: fixCycles, maxCycles: MAX_CYCLES });
434
- const fixModelRaw = loop.on_request_changes.fix_model
435
- ? renderTemplate(loop.on_request_changes.fix_model, ctx)
436
- : undefined;
437
- const fixModel = fixModelRaw || modelFor(`${phaseName}_fix`) || modelFor(phaseName);
438
- const fixPromptRendered = renderPrompt(loop.on_request_changes.fix_prompt, {
439
- fixCycle: fixCycles,
440
- });
441
- const fr = await runPhase(definition.name, fixLabel, taskId, triggerId, fixPromptRendered, config, db, fixModel, workflowId, githubAccess);
442
- if (fr.skipped) {
443
- if (fr.reason === "running") {
444
- await notifyMessage(phase.messages?.on_skipped_done);
445
- return { success: false, phases };
446
- }
447
- phases.push({ phase: fixLabel, success: true, output: "Already completed" });
448
- }
449
- else {
450
- phases.push({ phase: fixLabel, ...pickResult(fr.result) });
451
- await onEnd(fixLabel, phases[phases.length - 1]);
452
- if (!fr.result.success) {
453
- if (!isTerminated(fr.result.error)) {
454
- await notifyMessage(loop.messages?.on_fix_failed, { cycle: fixCycles, maxCycles: MAX_CYCLES });
455
- }
456
- break;
457
- }
458
- persistPhase(fixLabel);
459
- }
460
- }
461
- else {
462
- persistPhase(reviewLabel, "REQUEST_CHANGES — max cycles reached");
463
- await notifyMessage(loop.messages?.on_max_cycles, { cycle: fixCycles, maxCycles: MAX_CYCLES });
464
- break;
465
- }
223
+ const ready = getReadyNodes(dag);
224
+ // Capability gate: skip any ready node whose declared capability isn't
225
+ // available on this host. Safe-by-default graceful degradation — a gated
226
+ // phase (e.g. the browser-QA step that needs the docker image) silently
227
+ // no-ops instead of failing the workflow. Two reasons trigger a skip:
228
+ // 1. `requires_sandbox` names a backend other than the one running.
229
+ // 2. `sandbox_image: qa` but the browser-QA image isn't built here (only
230
+ // checked on dockeron other backends the field is inert).
231
+ // Uses the same non-failing skip mechanics as the trigger-rule skip above,
232
+ // plus the phase's `on_skipped_done` message so the user sees why.
233
+ const gatedSkip = [];
234
+ for (const node of ready) {
235
+ const phaseDef = phaseByName.get(node.name);
236
+ const req = phaseDef?.requires_sandbox;
237
+ if (req !== undefined && req !== activeBackend) {
238
+ gatedSkip.push({ node, reason: `requires ${req} sandbox; running ${activeBackend}` });
466
239
  }
467
- // Expose loop outcome as a structured phase output so downstream phases
468
- // can read {{phaseName.approved}} / {{phaseName.cycles}} in their prompts.
469
- if (phase.output_var) {
470
- phaseOutputs[phase.output_var] = { approved, cycles: fixCycles };
240
+ else if (phaseDef?.sandbox_image === "qa" &&
241
+ activeBackend === "docker" &&
242
+ !qaImageAvailable()) {
243
+ gatedSkip.push({ node, reason: `requires the ${SANDBOX_IMAGE_QA} image, not built on this host` });
471
244
  }
472
- continue;
473
245
  }
474
- // ── Generic loop phase ────────────────────────────────────────────────
475
- if (phase.generic_loop) {
476
- const loop = phase.generic_loop;
477
- const MAX_ITER = loop.max_iterations;
478
- const MAX_PREV_OUTPUT_BYTES = 10 * 1024; // cap accumulated output at 10KB
479
- if (!shouldRun(phaseName)) {
480
- phases.push({ phase: phaseName, success: true, output: "Already completed" });
481
- continue;
482
- }
483
- // Reply-gate loops resume mid-flight: read the saved iteration out
484
- // of scratch[scratch_key] so we pick up at N+1 instead of 1 and
485
- // don't re-run iterations whose dedup rows are already "done".
486
- const scratchKey = loop.scratch_key;
487
- const scratchSlot = (scratchKey
488
- ? scratch[scratchKey] ?? {}
489
- : {});
490
- const resumeFromIter = loop.gate_kind === "reply" && typeof scratchSlot.iteration === "number"
491
- ? Math.min(scratchSlot.iteration, MAX_ITER)
492
- : 0;
493
- let iteration = resumeFromIter;
494
- let complete = false;
495
- let previousOutput = scratchSlot.lastOutput ?? "";
496
- while (!complete && iteration < MAX_ITER) {
497
- iteration++;
498
- const iterLabel = `${phaseName}_iter_${iteration}`;
499
- await onStart(iterLabel);
500
- // Build prompt with loop context vars
501
- const iterCtx = {
502
- iteration,
503
- maxIterations: MAX_ITER,
504
- previousOutput: loop.fresh_context ? "" : previousOutput,
505
- phaseOutputs,
506
- scratch,
507
- };
508
- const prompt = buildPhasePrompt(phase, ctx, iterCtx);
509
- const modelRaw = phase.model ? renderTemplate(phase.model, ctx) : undefined;
510
- const model = modelRaw || modelFor(phaseName);
511
- const ir = await runPhase(definition.name, iterLabel, taskId, triggerId, prompt, config, db, model, workflowId, githubAccess);
512
- if (ir.skipped) {
513
- if (ir.reason === "running") {
514
- await notifyMessage(phase.messages?.on_skipped_done, { iteration });
515
- return { success: false, phases };
516
- }
517
- // Already done — treat as complete
518
- phases.push({ phase: iterLabel, success: true, output: "Already completed" });
519
- complete = true;
520
- break;
521
- }
522
- phases.push({ phase: iterLabel, ...pickResult(ir.result) });
523
- await onEnd(iterLabel, phases[phases.length - 1]);
524
- if (!ir.result.success) {
525
- if (!isTerminated(ir.result.error)) {
526
- await notifyMessage(phase.messages?.on_failure, { iteration });
527
- }
528
- failWorkflow(ir.result.error);
529
- return { success: false, phases };
530
- }
531
- const iterOutput = ir.result.output || "";
532
- // Accumulate previousOutput (cap at MAX_PREV_OUTPUT_BYTES)
533
- if (!loop.fresh_context) {
534
- const combined = previousOutput ? `${previousOutput}\n${iterOutput}` : iterOutput;
535
- previousOutput = combined.length > MAX_PREV_OUTPUT_BYTES
536
- ? combined.slice(-MAX_PREV_OUTPUT_BYTES)
537
- : combined;
538
- }
539
- // Evaluate until expression
540
- let conditionMet = false;
541
- if (loop.until) {
542
- conditionMet = evalUntilExpression(loop.until, {
543
- output: iterOutput,
544
- scratch,
545
- ...Object.fromEntries(Object.entries(ctx)
546
- .filter(([, v]) => typeof v === "string")
547
- .map(([k, v]) => [k, v])),
548
- });
549
- }
550
- // Evaluate until_bash
551
- if (!conditionMet && loop.until_bash) {
552
- try {
553
- execSync(loop.until_bash, { timeout: 30_000, stdio: "pipe", cwd: config.sandboxDir ?? config.cwd });
554
- conditionMet = true; // exit 0
555
- }
556
- catch {
557
- conditionMet = false; // non-zero exit
558
- }
559
- }
560
- if (conditionMet) {
561
- complete = true;
562
- if (scratchKey && db && workflowId) {
563
- db.updateWorkflowRunScratch(workflowId, {
564
- [scratchKey]: { ...scratchSlot, iteration, ready: true, lastOutput: iterOutput },
565
- });
566
- scratch[scratchKey] = {
567
- ...scratch[scratchKey],
568
- iteration,
569
- ready: true,
570
- lastOutput: iterOutput,
571
- };
572
- }
573
- persistPhase(iterLabel, `iteration ${iteration} — condition met`);
574
- break;
575
- }
576
- // Interactive gate between iterations
577
- if (loop.interactive && !complete && db && workflowId) {
578
- const isReply = loop.gate_kind === "reply";
579
- const gateMsg = loop.gate_message
580
- ? renderTemplate(loop.gate_message, { ...ctx, phaseOutputs, iteration, maxIterations: MAX_ITER, scratch })
581
- : `Loop iteration ${iteration}/${MAX_ITER} complete.`;
582
- // Persist iteration + last output into scratch BEFORE pausing so
583
- // the resume path can pick up at N+1 instead of re-running from 1.
584
- if (scratchKey) {
585
- const slot = {
586
- ...scratch[scratchKey],
587
- iteration,
588
- lastOutput: iterOutput,
589
- };
590
- scratch[scratchKey] = slot;
591
- db.updateWorkflowRunScratch(workflowId, { [scratchKey]: slot });
592
- }
593
- const approvalId = randomUUID();
594
- db.createApproval({
595
- id: approvalId,
596
- workflowRunId: workflowId,
597
- gate: `${phaseName}_iter_${iteration}`,
598
- summary: gateMsg,
599
- kind: isReply ? "reply" : "approve",
600
- requestedBy: ctx.sender,
601
- createdAt: new Date().toISOString(),
602
- });
603
- db.updateWorkflowPhase(workflowId, "waiting_approval", {
604
- phase: "waiting_approval",
605
- timestamp: new Date().toISOString(),
606
- success: true,
607
- summary: `Waiting for ${isReply ? "reply" : "approval"}: ${phaseName}_iter_${iteration} (${approvalId})`,
608
- });
609
- db.pauseWorkflowRun(workflowId);
610
- if (isReply) {
611
- // Combine the agent's questions + gate hint into one message
612
- // so it reads as a single comment on GitHub / Slack.
613
- const parts = [iterOutput.trim(), gateMsg.trim()].filter(Boolean);
614
- if (parts.length > 0)
615
- await notify(parts.join("\n\n---\n\n"));
616
- }
617
- else {
618
- await notify(`**${phaseName} iteration ${iteration}/${MAX_ITER} complete** — approval required to continue.\n\n` +
619
- `${gateMsg}\n\n` +
620
- `**To continue:** comment \`@last-light approve\`\n` +
621
- `**To abort:** comment \`@last-light reject [reason]\``);
622
- }
623
- return { success: true, phases, paused: true };
624
- }
625
- persistPhase(iterLabel);
626
- }
627
- if (!complete) {
628
- await notifyMessage(phase.messages?.on_failure, {
629
- iteration,
630
- maxIterations: MAX_ITER,
246
+ if (gatedSkip.length > 0) {
247
+ for (const { node, reason } of gatedSkip) {
248
+ node.status = "skipped";
249
+ const phaseDef = phaseByName.get(node.name);
250
+ phases.push({
251
+ phase: node.name,
252
+ success: true,
253
+ output: `Skipped (${reason})`,
631
254
  });
255
+ db?.executions.recordSkippedPhase(`${definition.name}:${node.name}`, triggerId, workflowId, githubAccess.repo);
256
+ await reportStep(node.name, "skipped", phaseDef?.messages?.on_skipped_done);
632
257
  }
633
- if (phase.output_var) {
634
- phaseOutputs[phase.output_var] = { completed: complete, iterations: iteration };
635
- }
636
- continue;
258
+ continue; // re-evaluate the DAG with these nodes now terminal
637
259
  }
638
- // ── Standard (non-looping) agent phase ───────────────────────────────
639
- if (!shouldRun(phaseName))
260
+ if (ready.length === 0) {
261
+ if (toSkip.length === 0)
262
+ break; // stuck (shouldn't happen in a valid DAG)
263
+ continue; // only had skips — loop to process downstream
264
+ }
265
+ // Sequential: run the earliest-declared ready node, one at a time.
266
+ // Resume is ledger-driven: a completed phase's `runPhase` call returns
267
+ // skipped:done via `shouldRunPhase`, so re-running from the top is safe.
268
+ const node = ready[0];
269
+ node.status = "running";
270
+ let outcome;
271
+ try {
272
+ outcome = await executor.execute(node, outputs);
273
+ }
274
+ catch (err) {
275
+ // An agent call threw (OOM / unexpected). Mark the node failed so the
276
+ // failure cascades to downstream skips, mirroring a normal failure.
277
+ console.error(`[runner] Phase "${node.name}" threw unexpectedly:`, err);
278
+ phases.push({ phase: node.name, success: false, error: String(err), output: "" });
279
+ node.status = "failed";
640
280
  continue;
641
- await onStart(phaseName);
642
- await notifyMessage(phase.messages?.on_start);
643
- // Resolve model
644
- const modelRaw = phase.model ? renderTemplate(phase.model, ctx) : undefined;
645
- const model = modelRaw || modelFor(phaseName);
646
- const prompt = buildPhasePrompt(phase, ctx, { phaseOutputs });
647
- const pr = await runPhase(definition.name, phaseName, taskId, triggerId, prompt, config, db, model, workflowId, githubAccess);
648
- if (pr.skipped) {
649
- if (pr.reason === "running") {
650
- await notifyMessage(phase.messages?.on_skipped_done);
651
- return { success: false, phases };
652
- }
653
- phases.push({ phase: phaseName, success: true, output: "Already completed" });
654
- // Persist a phase_history entry even when the phase was deduped, so
655
- // the dashboard's pipeline view shows the phase as 'done' instead of
656
- // stuck on 'active'/pending. Without this the run looks half-finished
657
- // even though it succeeded.
658
- persistPhase(phaseName, "Already completed (deduplicated)");
659
- await onEnd(phaseName, { phase: phaseName, success: true, output: "Already completed" });
660
- await notifyMessage(phase.messages?.on_skipped_done);
661
281
  }
662
- else {
663
- phases.push({ phase: phaseName, ...pickResult(pr.result) });
664
- await onEnd(phaseName, phases[phases.length - 1]);
665
- // Record phase output (raw string) for downstream ${phaseName.output}
666
- // substitution and, when phase.output_var is set, under the aliased key
667
- // so prompts can read {{aliased}} / ${aliased.output} / {{aliased.field}}.
668
- const rawOutput = pr.result.output ?? "";
669
- phaseOutputs[phaseName] = rawOutput;
670
- if (phase.output_var) {
671
- phaseOutputs[phase.output_var] = rawOutput;
672
- }
673
- if (!pr.result.success) {
674
- if (!isTerminated(pr.result.error)) {
675
- await notifyMessage(phase.messages?.on_failure);
676
- }
677
- failWorkflow(pr.result.error);
678
- return { success: false, phases };
679
- }
680
- // Check on_output rules
681
- if (phase.on_output) {
682
- const outputUpper = (pr.result.output?.toUpperCase() || "");
683
- if (phase.on_output.contains_BLOCKED && outputUpper.includes("BLOCKED")) {
684
- const rule = phase.on_output.contains_BLOCKED;
685
- const hasUnlessLabel = rule.unless_label && ctx.issueLabels.includes(rule.unless_label);
686
- const titleMatches = !!rule.unless_title_matches &&
687
- new RegExp(rule.unless_title_matches, "i").test(ctx.issueTitle || "");
688
- if (hasUnlessLabel || titleMatches) {
689
- // Rule bypassed — fall through to phase success path.
690
- await notifyMessage(rule.bypass_message || phase.messages?.on_blocked_bypassed);
691
- }
692
- else if (rule.action === "fail") {
693
- db?.markLatestAsFailed(`${definition.name}:${phaseName}`, triggerId, rule.message || "BLOCKED", workflowId);
694
- failWorkflow(rule.message || "BLOCKED");
695
- const blockedTemplate = rule.message || phase.messages?.on_blocked;
696
- if (blockedTemplate) {
697
- await notifyMessage(blockedTemplate);
698
- }
699
- return { success: false, phases };
700
- }
701
- }
702
- }
703
- // Approval gate
704
- if (phase.approval_gate && gateEnabled(phase.approval_gate) && db && workflowId) {
705
- const gateKey = phase.approval_gate;
706
- const approvalId = randomUUID();
707
- db.createApproval({
708
- id: approvalId,
709
- workflowRunId: workflowId,
710
- gate: gateKey,
711
- summary: `${phaseName} complete — awaiting ${gateKey} approval.`,
712
- requestedBy: ctx.sender,
713
- createdAt: new Date().toISOString(),
714
- });
715
- db.updateWorkflowPhase(workflowId, "waiting_approval", {
716
- phase: "waiting_approval",
717
- timestamp: new Date().toISOString(),
718
- success: true,
719
- summary: `Waiting for approval: ${gateKey} (${approvalId})`,
720
- });
721
- db.pauseWorkflowRun(workflowId);
722
- await notifyMessage(phase.approval_gate_message, { gateKey });
723
- return { success: true, phases, paused: true };
724
- }
725
- persistPhase(phaseName);
726
- await notifyMessage(phase.messages?.on_success);
282
+ for (const r of outcome.results)
283
+ phases.push(r);
284
+ if (outcome.outputVars)
285
+ Object.assign(outputs, outcome.outputVars);
286
+ if (outcome.aborted) {
287
+ // Dedup running-skip another instance owns this phase. Stop without
288
+ // cascading skips; this isn't a phase failure.
289
+ return { success: false, phases };
727
290
  }
291
+ if (outcome.paused) {
292
+ return { success: true, phases, paused: true };
293
+ }
294
+ node.status = outcome.status;
728
295
  }
729
296
  // ── Workflow wrap-up ──────────────────────────────────────────────────────
730
297
  //
731
298
  // If the definition declares an `on_success.set_phase` terminal marker on any
732
- // phase, record it so the DB row shows the workflow as fully complete. We
733
- // also try to extract a PR number from the terminal phase's output so
734
- // callers can surface it — purely opportunistic, no hardcoded phase names.
299
+ // phase, record it so the DB row shows the workflow as fully complete. Also
300
+ // opportunistically extract a PR number from the terminal phase's output.
735
301
  const anyFailed = phases.some((p) => !p.success);
736
302
  const success = !anyFailed;
737
303
  let prNumber;
304
+ let prUrl;
738
305
  const terminalPhase = [...definition.phases].reverse().find((p) => p.on_success?.set_phase);
739
306
  if (terminalPhase) {
740
307
  const terminalResult = phases.find((p) => p.phase === terminalPhase.name);
741
308
  const prMatch = terminalResult?.output?.match(/#(\d+)/);
742
309
  if (prMatch)
743
310
  prNumber = parseInt(prMatch[1], 10);
744
- if (success && terminalPhase.on_success?.set_phase) {
745
- persistPhase(terminalPhase.on_success.set_phase, prNumber ? `PR #${prNumber}` : undefined);
746
- }
311
+ const urlMatch = terminalResult?.output?.match(/https?:\/\/[^\s)]+\/pull\/\d+/);
312
+ if (urlMatch)
313
+ prUrl = urlMatch[0];
747
314
  }
748
315
  if (success) {
749
316
  if (db && workflowId) {
750
- db.finishWorkflowRun(workflowId, "succeeded");
317
+ // Fold the `on_success.set_phase` terminal marker into the same
318
+ // transaction as the status flip so the dashboard never sees one
319
+ // without the other.
320
+ const terminalMarker = terminalPhase?.on_success?.set_phase
321
+ ? { phase: terminalPhase.on_success.set_phase, summary: prNumber ? `PR #${prNumber}` : undefined }
322
+ : undefined;
323
+ db.runs.finishRun(workflowId, "succeeded", terminalMarker ? { terminalMarker } : {});
751
324
  }
752
325
  }
753
326
  else {
754
327
  const firstFailure = phases.find((p) => !p.success);
755
328
  failWorkflow(firstFailure?.error || "workflow failed");
756
329
  }
757
- return { success, phases, prNumber };
758
- }
759
- /**
760
- * DAG-based workflow execution. Called when any phase declares `depends_on`.
761
- * Runs independent phases concurrently via Promise.allSettled.
762
- */
763
- async function runDagWorkflow(definition, ctx, config, callbacks, db, models, approvalConfig, workflowId, runnerCtx) {
764
- const { phases, phaseOutputs: outputs, triggerId, githubAccess, notifyMessage, onStart, onEnd, modelFor, renderPrompt, persistPhase, failWorkflow, gateEnabled, } = runnerCtx;
765
- const { taskId } = ctx;
766
- const dag = buildDag(definition.phases);
767
- const phaseMap = new Map(definition.phases.map((p) => [p.name, p]));
768
- /**
769
- * Execute a single standard agent phase (no loop). Returns PhaseResult and
770
- * whether the workflow should pause (approval gate).
771
- */
772
- async function executeSinglePhase(phase, phaseName) {
773
- await notifyMessage(phase.messages?.on_start);
774
- const phaseCtx = { phaseOutputs: { ...outputs } };
775
- const prompt = buildPhasePrompt(phase, ctx, phaseCtx);
776
- const modelRaw = phase.model ? renderTemplate(phase.model, ctx) : undefined;
777
- const model = modelRaw || modelFor(phaseName);
778
- const pr = await runPhase(definition.name, phaseName, `${taskId}-${phaseName}`, triggerId, prompt, config, db, model, workflowId, githubAccess);
779
- if (pr.skipped) {
780
- return { result: { phase: phaseName, success: true, output: "Already completed" } };
781
- }
782
- const result = { phase: phaseName, ...pickResult(pr.result) };
783
- if (!pr.result.success) {
784
- await notifyMessage(phase.messages?.on_failure);
785
- return { result };
786
- }
787
- // Check on_output rules
788
- if (phase.on_output?.contains_BLOCKED && (pr.result.output?.toUpperCase() || "").includes("BLOCKED")) {
789
- const rule = phase.on_output.contains_BLOCKED;
790
- const hasUnlessLabel = rule.unless_label && ctx.issueLabels.includes(rule.unless_label);
791
- const titleMatches = !!rule.unless_title_matches &&
792
- new RegExp(rule.unless_title_matches, "i").test(ctx.issueTitle || "");
793
- if (hasUnlessLabel || titleMatches) {
794
- await notifyMessage(rule.bypass_message || phase.messages?.on_blocked_bypassed);
795
- }
796
- else if (rule.action === "fail") {
797
- db?.markLatestAsFailed(`${definition.name}:${phaseName}`, triggerId, rule.message || "BLOCKED", workflowId);
798
- failWorkflow(rule.message || "BLOCKED");
799
- await notifyMessage(rule.message || phase.messages?.on_blocked);
800
- return { result: { phase: phaseName, success: false, output: pr.result.output ?? "", error: "BLOCKED" } };
801
- }
330
+ // Single final update: render the workflow's `final_message` against the
331
+ // accumulated outputs and deliver it once — folded into the checklist comment
332
+ // as its footer when the in-place reporter is active, else posted as one
333
+ // standalone comment. Empty render ⇒ no-op (e.g. the synthesizing phase was
334
+ // skipped). This is what lets verify/qa-test end with a single combined
335
+ // verdict instead of a comment per phase.
336
+ if (success && definition.final_message) {
337
+ const finalRendered = renderTemplate(definition.final_message, {
338
+ ...ctx,
339
+ phaseOutputs: outputs,
340
+ }).trim();
341
+ if (finalRendered) {
342
+ if (reporter)
343
+ await reporter.footer(finalRendered);
344
+ else
345
+ await notify(finalRendered);
802
346
  }
803
- // Approval gate
804
- if (phase.approval_gate && gateEnabled(phase.approval_gate) && db && workflowId) {
805
- const gateKey = phase.approval_gate;
806
- const approvalId = randomUUID();
807
- db.createApproval({
808
- id: approvalId,
809
- workflowRunId: workflowId,
810
- gate: gateKey,
811
- summary: `${phaseName} complete — awaiting ${gateKey} approval.`,
812
- requestedBy: ctx.sender,
813
- createdAt: new Date().toISOString(),
814
- });
815
- db.updateWorkflowPhase(workflowId, "waiting_approval", {
816
- phase: "waiting_approval",
817
- timestamp: new Date().toISOString(),
818
- success: true,
819
- summary: `Waiting for approval: ${gateKey} (${approvalId})`,
820
- });
821
- db.pauseWorkflowRun(workflowId);
822
- await notifyMessage(phase.approval_gate_message, { gateKey });
823
- return { result, paused: true };
824
- }
825
- persistPhase(phaseName);
826
- await notifyMessage(phase.messages?.on_success);
827
- return { result };
828
347
  }
829
- // ── Main DAG execution loop ────────────────────────────────────────────────
830
- while (!isComplete(dag)) {
831
- // First, mark nodes that should be skipped (trigger rule fails but deps are terminal)
832
- const toSkip = getNodesToSkip(dag);
833
- for (const node of toSkip) {
834
- node.status = "skipped";
835
- phases.push({ phase: node.name, success: true, output: "Skipped (trigger rule not satisfied)" });
836
- if (db && workflowId) {
837
- db.updateNodeStatus(workflowId, node.name, "skipped");
838
- }
839
- }
840
- const ready = getReadyNodes(dag);
841
- if (ready.length === 0) {
842
- if (toSkip.length === 0)
843
- break; // stuck (shouldn't happen in a valid DAG)
844
- continue; // only had skips — loop to process downstream
845
- }
846
- // Mark all ready nodes as running before dispatching
847
- for (const node of ready) {
848
- node.status = "running";
849
- if (db && workflowId) {
850
- db.updateNodeStatus(workflowId, node.name, "running");
851
- }
852
- }
853
- // Dispatch all ready nodes concurrently
854
- const nodePromises = ready.map(async (node) => {
855
- try {
856
- const phase = phaseMap.get(node.name);
857
- await onStart(node.name);
858
- // Context phase — immediate
859
- if (phase.type === "context" || !phase.type) {
860
- const r = { phase: node.name, success: true, output: "Context assembled" };
861
- // Same persist-history fix as the linear runner so the dashboard
862
- // marks context nodes done.
863
- persistPhase(node.name, "Context assembled");
864
- await onEnd(node.name, r);
865
- return { node, result: r, paused: false, alreadyPushed: false };
866
- }
867
- // Phase with neither prompt nor skill — skip
868
- if (!phase.prompt && !phase.skill) {
869
- console.warn(`[dag] Phase "${node.name}" has type=agent but neither prompt: nor skill: — skipping`);
870
- const r = { phase: node.name, success: true, output: "Skipped (no prompt or skill)" };
871
- return { node, result: r, paused: false, alreadyPushed: false };
872
- }
873
- // Loop phase (reviewer-style) — run sequentially as a single DAG node
874
- if (phase.loop) {
875
- const loop = phase.loop;
876
- const MAX_CYCLES = loop.max_cycles;
877
- let approved = false;
878
- let fixCycles = 0;
879
- while (!approved && fixCycles <= MAX_CYCLES) {
880
- const reviewLabel = fixCycles === 0 ? node.name : `${node.name}_${fixCycles + 1}`;
881
- const reviewPrompt = fixCycles === 0
882
- ? buildPhasePrompt(phase, ctx, { phaseOutputs: { ...outputs }, fixCycle: fixCycles })
883
- : renderPrompt(loop.on_request_changes.re_review_prompt, { phaseOutputs: { ...outputs }, fixCycle: fixCycles });
884
- const reviewModelRaw = phase.model ? renderTemplate(phase.model, ctx) : undefined;
885
- const reviewModel = reviewModelRaw || modelFor(node.name);
886
- const rr = await runPhase(definition.name, reviewLabel, `${taskId}-${reviewLabel}`, triggerId, reviewPrompt, config, db, reviewModel, workflowId, githubAccess);
887
- if (rr.skipped) {
888
- approved = true;
889
- phases.push({ phase: reviewLabel, success: true, output: "Already completed" });
890
- break;
891
- }
892
- phases.push({ phase: reviewLabel, ...pickResult(rr.result) });
893
- await onEnd(reviewLabel, phases[phases.length - 1]);
894
- const reviewerOutput = (rr.result.output || "").trim();
895
- const verdictMarker = reviewerOutput.match(/^\s*VERDICT:\s*(APPROVED|REQUEST_CHANGES)\s*$/im);
896
- const isApproved = verdictMarker
897
- ? verdictMarker[1].toUpperCase() === "APPROVED"
898
- : !(/\bREQUEST_CHANGES\b/.test(reviewerOutput.toUpperCase())) && /^APPROVED\b/.test(reviewerOutput.toUpperCase());
899
- if (isApproved) {
900
- approved = true;
901
- }
902
- else if (fixCycles < MAX_CYCLES) {
903
- fixCycles++;
904
- const fixLabel = `${node.name}_fix_${fixCycles}`;
905
- const fixPromptRendered = renderPrompt(loop.on_request_changes.fix_prompt, { phaseOutputs: { ...outputs }, fixCycle: fixCycles });
906
- const fixModelRaw = loop.on_request_changes.fix_model ? renderTemplate(loop.on_request_changes.fix_model, ctx) : undefined;
907
- const fixModel = fixModelRaw || modelFor(`${node.name}_fix`) || modelFor(node.name);
908
- const fr = await runPhase(definition.name, fixLabel, `${taskId}-fix${fixCycles}`, triggerId, fixPromptRendered, config, db, fixModel, workflowId, githubAccess);
909
- if (!fr.skipped) {
910
- phases.push({ phase: fixLabel, ...pickResult(fr.result) });
911
- await onEnd(fixLabel, phases[phases.length - 1]);
912
- }
913
- }
914
- else {
915
- break;
916
- }
917
- }
918
- const loopResult = { phase: node.name, success: approved, output: approved ? "Approved" : "Request changes" };
919
- if (phase.output_var) {
920
- outputs[phase.output_var] = { approved, cycles: fixCycles };
921
- }
922
- return { node, result: loopResult, paused: false, alreadyPushed: true };
923
- }
924
- // Generic loop phase
925
- if (phase.generic_loop) {
926
- const loop = phase.generic_loop;
927
- const MAX_ITER = loop.max_iterations;
928
- const MAX_PREV_OUTPUT_BYTES = 10 * 1024;
929
- let iteration = 0;
930
- let complete = false;
931
- let previousOutput = "";
932
- while (!complete && iteration < MAX_ITER) {
933
- iteration++;
934
- const iterLabel = `${node.name}_iter_${iteration}`;
935
- const iterCtx = {
936
- iteration,
937
- maxIterations: MAX_ITER,
938
- previousOutput: loop.fresh_context ? "" : previousOutput,
939
- phaseOutputs: { ...outputs },
940
- };
941
- const iterPrompt = buildPhasePrompt(phase, ctx, iterCtx);
942
- const modelRaw = phase.model ? renderTemplate(phase.model, ctx) : undefined;
943
- const model = modelRaw || modelFor(node.name);
944
- const ir = await runPhase(definition.name, iterLabel, `${taskId}-${iterLabel}`, triggerId, iterPrompt, config, db, model, workflowId, githubAccess);
945
- if (ir.skipped) {
946
- complete = true;
947
- break;
948
- }
949
- phases.push({ phase: iterLabel, ...pickResult(ir.result) });
950
- await onEnd(iterLabel, phases[phases.length - 1]);
951
- if (!ir.result.success) {
952
- failWorkflow(ir.result.error);
953
- return { node, result: { phase: node.name, success: false, output: "", error: ir.result.error }, paused: false, alreadyPushed: true };
954
- }
955
- const iterOutput = ir.result.output || "";
956
- if (!loop.fresh_context) {
957
- const combined = previousOutput ? `${previousOutput}\n${iterOutput}` : iterOutput;
958
- previousOutput = combined.length > MAX_PREV_OUTPUT_BYTES ? combined.slice(-MAX_PREV_OUTPUT_BYTES) : combined;
959
- }
960
- let conditionMet = false;
961
- if (loop.until) {
962
- conditionMet = evalUntilExpression(loop.until, { output: iterOutput, ...Object.fromEntries(Object.entries(ctx).filter(([, v]) => typeof v === "string").map(([k, v]) => [k, v])) });
963
- }
964
- if (!conditionMet && loop.until_bash) {
965
- try {
966
- execSync(loop.until_bash, { timeout: 30_000, stdio: "pipe", cwd: config.sandboxDir ?? config.cwd });
967
- conditionMet = true;
968
- }
969
- catch {
970
- conditionMet = false;
971
- }
972
- }
973
- if (conditionMet) {
974
- complete = true;
975
- }
976
- }
977
- if (!complete) {
978
- await notifyMessage(phase.messages?.on_failure, { iteration, maxIterations: MAX_ITER });
979
- }
980
- if (phase.output_var) {
981
- outputs[phase.output_var] = { completed: complete, iterations: iteration };
982
- }
983
- return { node, result: { phase: node.name, success: true, output: previousOutput || "" }, paused: false, alreadyPushed: true };
984
- }
985
- // Standard agent phase
986
- const { result, paused } = await executeSinglePhase(phase, node.name);
987
- await onEnd(node.name, result);
988
- return { node, result, paused: paused ?? false, alreadyPushed: false };
989
- }
990
- catch (err) {
991
- console.error(`[dag] Phase "${node.name}" threw unexpectedly:`, err);
992
- const result = { phase: node.name, success: false, error: String(err), output: "" };
993
- return { node, result, paused: false, alreadyPushed: false };
994
- }
995
- });
996
- const settled = await Promise.allSettled(nodePromises);
997
- let anyPaused = false;
998
- for (const settledItem of settled) {
999
- if (settledItem.status === "rejected") {
1000
- console.error("[dag] Phase promise rejected:", settledItem.reason);
1001
- continue;
1002
- }
1003
- const { node, result, paused, alreadyPushed } = settledItem.value;
1004
- if (!alreadyPushed) {
1005
- phases.push(result);
1006
- }
1007
- if (paused) {
1008
- anyPaused = true;
1009
- node.status = "succeeded"; // treat paused as succeeded so downstream trigger rules fire correctly — the workflow will resume from this node's successors after approval
1010
- continue;
1011
- }
1012
- node.status = result.success ? "succeeded" : "failed";
1013
- if (db && workflowId) {
1014
- db.updateNodeStatus(workflowId, node.name, node.status);
1015
- }
1016
- // Always record the raw phase output (both by phase name and any
1017
- // configured output_var alias) so downstream prompts can interpolate via
1018
- // ${phaseName.output} or {{alias}}. Loop phases that emit structured
1019
- // objects set their output_var themselves above — don't clobber those.
1020
- const phaseDef = phaseMap.get(node.name);
1021
- if (result.output != null) {
1022
- if (outputs[node.name] === undefined || typeof outputs[node.name] === "string") {
1023
- outputs[node.name] = result.output;
1024
- }
1025
- if (phaseDef.output_var && outputs[phaseDef.output_var] === undefined) {
1026
- outputs[phaseDef.output_var] = result.output;
1027
- }
1028
- }
1029
- }
1030
- if (anyPaused) {
1031
- return { success: true, phases, paused: true };
348
+ if (reporter) {
349
+ if (success && prNumber && terminalPhase) {
350
+ const link = prUrl ? `[PR #${prNumber}](${prUrl})` : `PR #${prNumber}`;
351
+ await reporter.step(terminalPhase.name, "done", link);
1032
352
  }
353
+ const prSuffix = prNumber ? ` — PR #${prNumber}` : "";
354
+ await reporter.noteTerminal(success
355
+ ? `✅ **${definition.name} complete**${prSuffix}.`
356
+ : `❌ **${definition.name} failed** — see the checklist above for the failing step.`);
1033
357
  }
1034
- // Determine overall success
1035
- const anyFailed = phases.some((p) => !p.success);
1036
- return { success: !anyFailed, phases };
358
+ return { success, phases, prNumber };
1037
359
  }
1038
360
  //# sourceMappingURL=runner.js.map