@kynetic-ai/spec 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (310) hide show
  1. package/README.md +2 -1
  2. package/dist/acp/client.d.ts +6 -1
  3. package/dist/acp/client.d.ts.map +1 -1
  4. package/dist/acp/client.js +7 -2
  5. package/dist/acp/client.js.map +1 -1
  6. package/dist/acp/framing.d.ts +12 -1
  7. package/dist/acp/framing.d.ts.map +1 -1
  8. package/dist/acp/framing.js +27 -4
  9. package/dist/acp/framing.js.map +1 -1
  10. package/dist/agent-runtime/dispatch.d.ts +292 -0
  11. package/dist/agent-runtime/dispatch.d.ts.map +1 -0
  12. package/dist/agent-runtime/dispatch.js +860 -0
  13. package/dist/agent-runtime/dispatch.js.map +1 -0
  14. package/dist/agent-runtime/index.d.ts +11 -0
  15. package/dist/agent-runtime/index.d.ts.map +1 -0
  16. package/dist/agent-runtime/index.js +11 -0
  17. package/dist/agent-runtime/index.js.map +1 -0
  18. package/dist/agent-runtime/invocation.d.ts +86 -0
  19. package/dist/agent-runtime/invocation.d.ts.map +1 -0
  20. package/dist/agent-runtime/invocation.js +442 -0
  21. package/dist/agent-runtime/invocation.js.map +1 -0
  22. package/dist/agent-runtime/prompts.d.ts +50 -0
  23. package/dist/agent-runtime/prompts.d.ts.map +1 -0
  24. package/dist/agent-runtime/prompts.js +108 -0
  25. package/dist/agent-runtime/prompts.js.map +1 -0
  26. package/dist/agents/spawner.d.ts.map +1 -1
  27. package/dist/agents/spawner.js +60 -4
  28. package/dist/agents/spawner.js.map +1 -1
  29. package/dist/cli/batch-exec.d.ts.map +1 -1
  30. package/dist/cli/batch-exec.js +140 -62
  31. package/dist/cli/batch-exec.js.map +1 -1
  32. package/dist/cli/batch-write-buffer.d.ts +141 -0
  33. package/dist/cli/batch-write-buffer.d.ts.map +1 -0
  34. package/dist/cli/batch-write-buffer.js +400 -0
  35. package/dist/cli/batch-write-buffer.js.map +1 -0
  36. package/dist/cli/commands/agent.d.ts +20 -0
  37. package/dist/cli/commands/agent.d.ts.map +1 -0
  38. package/dist/cli/commands/agent.js +831 -0
  39. package/dist/cli/commands/agent.js.map +1 -0
  40. package/dist/cli/commands/inbox.d.ts.map +1 -1
  41. package/dist/cli/commands/inbox.js +46 -22
  42. package/dist/cli/commands/inbox.js.map +1 -1
  43. package/dist/cli/commands/index.d.ts +1 -0
  44. package/dist/cli/commands/index.d.ts.map +1 -1
  45. package/dist/cli/commands/index.js +1 -0
  46. package/dist/cli/commands/index.js.map +1 -1
  47. package/dist/cli/commands/item.d.ts.map +1 -1
  48. package/dist/cli/commands/item.js +22 -16
  49. package/dist/cli/commands/item.js.map +1 -1
  50. package/dist/cli/commands/log.js +1 -1
  51. package/dist/cli/commands/log.js.map +1 -1
  52. package/dist/cli/commands/meta.d.ts.map +1 -1
  53. package/dist/cli/commands/meta.js +168 -6
  54. package/dist/cli/commands/meta.js.map +1 -1
  55. package/dist/cli/commands/module.d.ts.map +1 -1
  56. package/dist/cli/commands/module.js +2 -1
  57. package/dist/cli/commands/module.js.map +1 -1
  58. package/dist/cli/commands/plan-import.js +19 -3
  59. package/dist/cli/commands/plan-import.js.map +1 -1
  60. package/dist/cli/commands/plan.d.ts.map +1 -1
  61. package/dist/cli/commands/plan.js +87 -43
  62. package/dist/cli/commands/plan.js.map +1 -1
  63. package/dist/cli/commands/ralph.d.ts +5 -56
  64. package/dist/cli/commands/ralph.d.ts.map +1 -1
  65. package/dist/cli/commands/ralph.js +52 -1502
  66. package/dist/cli/commands/ralph.js.map +1 -1
  67. package/dist/cli/commands/search.d.ts.map +1 -1
  68. package/dist/cli/commands/search.js +22 -13
  69. package/dist/cli/commands/search.js.map +1 -1
  70. package/dist/cli/commands/serve.d.ts.map +1 -1
  71. package/dist/cli/commands/serve.js +70 -11
  72. package/dist/cli/commands/serve.js.map +1 -1
  73. package/dist/cli/commands/session/checkpoint.d.ts.map +1 -1
  74. package/dist/cli/commands/session/checkpoint.js +7 -2
  75. package/dist/cli/commands/session/checkpoint.js.map +1 -1
  76. package/dist/cli/commands/session/commands.d.ts.map +1 -1
  77. package/dist/cli/commands/session/commands.js +15 -0
  78. package/dist/cli/commands/session/commands.js.map +1 -1
  79. package/dist/cli/commands/session/context.d.ts.map +1 -1
  80. package/dist/cli/commands/session/context.js +10 -5
  81. package/dist/cli/commands/session/context.js.map +1 -1
  82. package/dist/cli/commands/session/log.d.ts +1 -0
  83. package/dist/cli/commands/session/log.d.ts.map +1 -1
  84. package/dist/cli/commands/session/log.js +124 -8
  85. package/dist/cli/commands/session/log.js.map +1 -1
  86. package/dist/cli/commands/session/stale-close.d.ts +17 -0
  87. package/dist/cli/commands/session/stale-close.d.ts.map +1 -0
  88. package/dist/cli/commands/session/stale-close.js +378 -0
  89. package/dist/cli/commands/session/stale-close.js.map +1 -0
  90. package/dist/cli/commands/setup.d.ts.map +1 -1
  91. package/dist/cli/commands/setup.js +95 -0
  92. package/dist/cli/commands/setup.js.map +1 -1
  93. package/dist/cli/commands/skill-crud.d.ts.map +1 -1
  94. package/dist/cli/commands/skill-crud.js +4 -3
  95. package/dist/cli/commands/skill-crud.js.map +1 -1
  96. package/dist/cli/commands/skill-diff.d.ts.map +1 -1
  97. package/dist/cli/commands/skill-diff.js +15 -0
  98. package/dist/cli/commands/skill-diff.js.map +1 -1
  99. package/dist/cli/commands/skill-install.d.ts.map +1 -1
  100. package/dist/cli/commands/skill-install.js +50 -18
  101. package/dist/cli/commands/skill-install.js.map +1 -1
  102. package/dist/cli/commands/task.d.ts.map +1 -1
  103. package/dist/cli/commands/task.js +536 -310
  104. package/dist/cli/commands/task.js.map +1 -1
  105. package/dist/cli/commands/tasks.js +1 -1
  106. package/dist/cli/commands/tasks.js.map +1 -1
  107. package/dist/cli/commands/triage.d.ts.map +1 -1
  108. package/dist/cli/commands/triage.js +37 -13
  109. package/dist/cli/commands/triage.js.map +1 -1
  110. package/dist/cli/commands/validate.d.ts.map +1 -1
  111. package/dist/cli/commands/validate.js +65 -25
  112. package/dist/cli/commands/validate.js.map +1 -1
  113. package/dist/cli/help/content.d.ts.map +1 -1
  114. package/dist/cli/help/content.js +5 -0
  115. package/dist/cli/help/content.js.map +1 -1
  116. package/dist/cli/index.d.ts.map +1 -1
  117. package/dist/cli/index.js +2 -1
  118. package/dist/cli/index.js.map +1 -1
  119. package/dist/cli/output.d.ts.map +1 -1
  120. package/dist/cli/output.js +5 -1
  121. package/dist/cli/output.js.map +1 -1
  122. package/dist/daemon/project-context.ts +22 -0
  123. package/dist/daemon/routes/agent-dispatch.ts +279 -0
  124. package/dist/daemon/routes/items.ts +22 -0
  125. package/dist/daemon/routes/meta.ts +141 -1
  126. package/dist/daemon/routes/plans.ts +147 -0
  127. package/dist/daemon/routes/sessions.ts +180 -0
  128. package/dist/daemon/routes/tasks.ts +198 -0
  129. package/dist/daemon/routes/validation.ts +1 -1
  130. package/dist/daemon/server.ts +77 -21
  131. package/dist/daemon/websocket/handler.ts +67 -6
  132. package/dist/daemon/websocket/lifecycle.ts +19 -0
  133. package/dist/daemon/websocket/pubsub.ts +74 -3
  134. package/dist/export/html.d.ts.map +1 -1
  135. package/dist/export/html.js +5 -2
  136. package/dist/export/html.js.map +1 -1
  137. package/dist/export/triage.d.ts +1 -1
  138. package/dist/export/triage.d.ts.map +1 -1
  139. package/dist/export/triage.js +5 -3
  140. package/dist/export/triage.js.map +1 -1
  141. package/dist/parser/alignment.d.ts.map +1 -1
  142. package/dist/parser/alignment.js +10 -5
  143. package/dist/parser/alignment.js.map +1 -1
  144. package/dist/parser/assess.js +1 -1
  145. package/dist/parser/assess.js.map +1 -1
  146. package/dist/parser/config.d.ts +6 -6
  147. package/dist/parser/meta.d.ts.map +1 -1
  148. package/dist/parser/meta.js +9 -8
  149. package/dist/parser/meta.js.map +1 -1
  150. package/dist/parser/plan-document.d.ts +12 -12
  151. package/dist/parser/plans.d.ts +7 -0
  152. package/dist/parser/plans.d.ts.map +1 -1
  153. package/dist/parser/plans.js +100 -15
  154. package/dist/parser/plans.js.map +1 -1
  155. package/dist/parser/refs.d.ts +5 -0
  156. package/dist/parser/refs.d.ts.map +1 -1
  157. package/dist/parser/refs.js +17 -12
  158. package/dist/parser/refs.js.map +1 -1
  159. package/dist/parser/shadow.d.ts +1 -1
  160. package/dist/parser/shadow.d.ts.map +1 -1
  161. package/dist/parser/shadow.js +71 -4
  162. package/dist/parser/shadow.js.map +1 -1
  163. package/dist/parser/skill-render.d.ts.map +1 -1
  164. package/dist/parser/skill-render.js +6 -3
  165. package/dist/parser/skill-render.js.map +1 -1
  166. package/dist/parser/validate.d.ts.map +1 -1
  167. package/dist/parser/validate.js +35 -76
  168. package/dist/parser/validate.js.map +1 -1
  169. package/dist/parser/yaml.d.ts +24 -5
  170. package/dist/parser/yaml.d.ts.map +1 -1
  171. package/dist/parser/yaml.js +224 -64
  172. package/dist/parser/yaml.js.map +1 -1
  173. package/dist/schema/meta.d.ts +457 -119
  174. package/dist/schema/meta.d.ts.map +1 -1
  175. package/dist/schema/meta.js +56 -0
  176. package/dist/schema/meta.js.map +1 -1
  177. package/dist/schema/plan.d.ts +22 -22
  178. package/dist/schema/spec.d.ts +39 -39
  179. package/dist/schema/task.d.ts +43 -32
  180. package/dist/schema/task.d.ts.map +1 -1
  181. package/dist/schema/task.js +5 -0
  182. package/dist/schema/task.js.map +1 -1
  183. package/dist/sessions/store.d.ts +126 -0
  184. package/dist/sessions/store.d.ts.map +1 -1
  185. package/dist/sessions/store.js +440 -22
  186. package/dist/sessions/store.js.map +1 -1
  187. package/dist/sessions/types.d.ts +75 -17
  188. package/dist/sessions/types.d.ts.map +1 -1
  189. package/dist/sessions/types.js +51 -1
  190. package/dist/sessions/types.js.map +1 -1
  191. package/dist/triage/actions.d.ts +1 -0
  192. package/dist/triage/actions.d.ts.map +1 -1
  193. package/dist/triage/actions.js +34 -7
  194. package/dist/triage/actions.js.map +1 -1
  195. package/dist/utils/commit.js +1 -1
  196. package/dist/utils/commit.js.map +1 -1
  197. package/dist/web-ui/_app/env.js +1 -0
  198. package/dist/web-ui/_app/immutable/assets/0.BJaYkGW2.css +1 -0
  199. package/dist/web-ui/_app/immutable/assets/9.SzGLxi4x.css +1 -0
  200. package/dist/web-ui/_app/immutable/assets/select-trigger.CV-KWLNP.css +1 -0
  201. package/dist/web-ui/_app/immutable/chunks/-lc0BifF.js +1 -0
  202. package/dist/web-ui/_app/immutable/chunks/62JVKtnb.js +1 -0
  203. package/dist/web-ui/_app/immutable/chunks/8RBjHMN1.js +1 -0
  204. package/dist/web-ui/_app/immutable/chunks/B5LJFxqa.js +1 -0
  205. package/dist/web-ui/_app/immutable/chunks/B5wTVqxm.js +1 -0
  206. package/dist/web-ui/_app/immutable/chunks/B6VSmczZ.js +1 -0
  207. package/dist/web-ui/_app/immutable/chunks/B8a0xDxR.js +1 -0
  208. package/dist/web-ui/_app/immutable/chunks/BEOQc37C.js +1 -0
  209. package/dist/web-ui/_app/immutable/chunks/BHtYorjv.js +1 -0
  210. package/dist/web-ui/_app/immutable/chunks/BJ0JX3ea.js +1 -0
  211. package/dist/web-ui/_app/immutable/chunks/BMuCqDX8.js +1 -0
  212. package/dist/web-ui/_app/immutable/chunks/BP352uRn.js +1 -0
  213. package/dist/web-ui/_app/immutable/chunks/BUZujXJ2.js +1 -0
  214. package/dist/web-ui/_app/immutable/chunks/BVA9Exy-.js +1 -0
  215. package/dist/web-ui/_app/immutable/chunks/BWET-efb.js +1 -0
  216. package/dist/web-ui/_app/immutable/chunks/BXkNecpt.js +1 -0
  217. package/dist/web-ui/_app/immutable/chunks/BYzrIfX8.js +1 -0
  218. package/dist/web-ui/_app/immutable/chunks/BkOJ8DkV.js +1 -0
  219. package/dist/web-ui/_app/immutable/chunks/BpuwufMc.js +1 -0
  220. package/dist/web-ui/_app/immutable/chunks/BwMO4RrG.js +1 -0
  221. package/dist/web-ui/_app/immutable/chunks/BysXJlZb.js +1 -0
  222. package/dist/web-ui/_app/immutable/chunks/C076q4JN.js +1 -0
  223. package/dist/web-ui/_app/immutable/chunks/C33JaVbg.js +1 -0
  224. package/dist/web-ui/_app/immutable/chunks/CGtqifKp.js +1 -0
  225. package/dist/web-ui/_app/immutable/chunks/CHDZZ7OG.js +1 -0
  226. package/dist/web-ui/_app/immutable/chunks/CPPfDSei.js +5 -0
  227. package/dist/web-ui/_app/immutable/chunks/CUir3f4J.js +60 -0
  228. package/dist/web-ui/_app/immutable/chunks/Cncwi6fQ.js +1 -0
  229. package/dist/web-ui/_app/immutable/chunks/CrCIbn0C.js +1 -0
  230. package/dist/web-ui/_app/immutable/chunks/CwELQvbx.js +1 -0
  231. package/dist/web-ui/_app/immutable/chunks/D3vxvonu.js +1 -0
  232. package/dist/web-ui/_app/immutable/chunks/D6TVmR9T.js +1 -0
  233. package/dist/web-ui/_app/immutable/chunks/D7LTux4W.js +1 -0
  234. package/dist/web-ui/_app/immutable/chunks/D82RulSH.js +1 -0
  235. package/dist/web-ui/_app/immutable/chunks/D9QNBZM2.js +2 -0
  236. package/dist/web-ui/_app/immutable/chunks/DAMmvwn4.js +1 -0
  237. package/dist/web-ui/_app/immutable/chunks/DAh4Wfku.js +1 -0
  238. package/dist/web-ui/_app/immutable/chunks/DAx07bEQ.js +1 -0
  239. package/dist/web-ui/_app/immutable/chunks/DBYE9jOd.js +1 -0
  240. package/dist/web-ui/_app/immutable/chunks/DOno4cA2.js +1 -0
  241. package/dist/web-ui/_app/immutable/chunks/DQA8NZIH.js +2 -0
  242. package/dist/web-ui/_app/immutable/chunks/DRfPm2bo.js +1 -0
  243. package/dist/web-ui/_app/immutable/chunks/DhQhksaB.js +1 -0
  244. package/dist/web-ui/_app/immutable/chunks/DjG7s6hm.js +1 -0
  245. package/dist/web-ui/_app/immutable/chunks/DjcCz-PU.js +2 -0
  246. package/dist/web-ui/_app/immutable/chunks/DkltRNvh.js +1 -0
  247. package/dist/web-ui/_app/immutable/chunks/DlaTnPKL.js +1 -0
  248. package/dist/web-ui/_app/immutable/chunks/DvA-KON-.js +1 -0
  249. package/dist/web-ui/_app/immutable/chunks/DxCk-KHc.js +1 -0
  250. package/dist/web-ui/_app/immutable/chunks/DzO4hlg9.js +1 -0
  251. package/dist/web-ui/_app/immutable/chunks/Eo4gF7ih.js +1 -0
  252. package/dist/web-ui/_app/immutable/chunks/ExCq5swK.js +1 -0
  253. package/dist/web-ui/_app/immutable/chunks/T3zZGv51.js +1 -0
  254. package/dist/web-ui/_app/immutable/chunks/XZumBYeP.js +1 -0
  255. package/dist/web-ui/_app/immutable/chunks/_ySfNjkF.js +1 -0
  256. package/dist/web-ui/_app/immutable/chunks/iEtR5cV6.js +1 -0
  257. package/dist/web-ui/_app/immutable/chunks/k_Qegko0.js +1 -0
  258. package/dist/web-ui/_app/immutable/chunks/pE6cYWlS.js +1 -0
  259. package/dist/web-ui/_app/immutable/entry/app.Cgu6uKeS.js +2 -0
  260. package/dist/web-ui/_app/immutable/entry/start.9XifnLoB.js +1 -0
  261. package/dist/web-ui/_app/immutable/nodes/0.DISwcKSK.js +1 -0
  262. package/dist/web-ui/_app/immutable/nodes/1.Cx2Ufqp1.js +1 -0
  263. package/dist/web-ui/_app/immutable/nodes/10.C3z8ijXL.js +1 -0
  264. package/dist/web-ui/_app/immutable/nodes/11.DZdIjZmM.js +1 -0
  265. package/dist/web-ui/_app/immutable/nodes/12.FsIGfAOa.js +1 -0
  266. package/dist/web-ui/_app/immutable/nodes/13.DZoFwagf.js +1 -0
  267. package/dist/web-ui/_app/immutable/nodes/14.DaIzDKbQ.js +1 -0
  268. package/dist/web-ui/_app/immutable/nodes/15.BYyt4XWF.js +2 -0
  269. package/dist/web-ui/_app/immutable/nodes/16.CQkSqpOe.js +1 -0
  270. package/dist/web-ui/_app/immutable/nodes/2.Bkf_j2UJ.js +1 -0
  271. package/dist/web-ui/_app/immutable/nodes/3.kaMCurJG.js +1 -0
  272. package/dist/web-ui/_app/immutable/nodes/4.BSsFPTHG.js +2 -0
  273. package/dist/web-ui/_app/immutable/nodes/5.CpPlcCEZ.js +1 -0
  274. package/dist/web-ui/_app/immutable/nodes/6.BN4FqQmY.js +1 -0
  275. package/dist/web-ui/_app/immutable/nodes/7.9kBYIZik.js +1 -0
  276. package/dist/web-ui/_app/immutable/nodes/8.BuijtZ6B.js +1 -0
  277. package/dist/web-ui/_app/immutable/nodes/9.C-Weba8R.js +1 -0
  278. package/dist/web-ui/_app/version.json +1 -0
  279. package/dist/web-ui/index.html +39 -0
  280. package/dist/web-ui/robots.txt +3 -0
  281. package/package.json +4 -2
  282. package/plugin/.claude-plugin/marketplace.json +1 -1
  283. package/plugin/.claude-plugin/plugin.json +1 -1
  284. package/plugin/plugins/kspec/skills/task-work/SKILL.md +25 -2
  285. package/templates/agents-sections/06-ralph-loop.md +64 -11
  286. package/templates/skills/task-work/SKILL.md +25 -2
  287. package/dist/ralph/cli-renderer.d.ts +0 -27
  288. package/dist/ralph/cli-renderer.d.ts.map +0 -1
  289. package/dist/ralph/cli-renderer.js +0 -250
  290. package/dist/ralph/cli-renderer.js.map +0 -1
  291. package/dist/ralph/events.d.ts +0 -65
  292. package/dist/ralph/events.d.ts.map +0 -1
  293. package/dist/ralph/events.js +0 -600
  294. package/dist/ralph/events.js.map +0 -1
  295. package/dist/ralph/index.d.ts +0 -11
  296. package/dist/ralph/index.d.ts.map +0 -1
  297. package/dist/ralph/index.js +0 -16
  298. package/dist/ralph/index.js.map +0 -1
  299. package/dist/ralph/loop-errors.d.ts +0 -83
  300. package/dist/ralph/loop-errors.d.ts.map +0 -1
  301. package/dist/ralph/loop-errors.js +0 -150
  302. package/dist/ralph/loop-errors.js.map +0 -1
  303. package/dist/ralph/subagent.d.ts +0 -127
  304. package/dist/ralph/subagent.d.ts.map +0 -1
  305. package/dist/ralph/subagent.js +0 -268
  306. package/dist/ralph/subagent.js.map +0 -1
  307. package/dist/ralph/wrap-up.d.ts +0 -127
  308. package/dist/ralph/wrap-up.d.ts.map +0 -1
  309. package/dist/ralph/wrap-up.js +0 -271
  310. package/dist/ralph/wrap-up.js.map +0 -1
@@ -0,0 +1,860 @@
1
+ /**
2
+ * Agent Dispatch Engine
3
+ *
4
+ * Core dispatch runtime for the daemon. Watches for task state changes via
5
+ * file watcher events and direct API event emission from CLI commands. Matches
6
+ * state changes against agent dispatch rules, queues invocations, manages
7
+ * concurrency, and handles deduplication of events from dual sources.
8
+ * Serializes shadow branch mutations when multiple agents run concurrently.
9
+ *
10
+ * AC: @agent-dispatch-engine ac-1 through ac-12
11
+ */
12
+ import * as path from "node:path";
13
+ import { spawnSync } from "node:child_process";
14
+ import { ulid } from "ulid";
15
+ import { initContext, loadAllTasks, loadMetaContext, } from "../parser/index.js";
16
+ import { runInvocation } from "./invocation.js";
17
+ import { getAdapter } from "../agents/adapters.js";
18
+ // ─── Simple Mutex ─────────────────────────────────────────────────────────────
19
+ /**
20
+ * A minimal promise-based mutex for serializing async operations.
21
+ * AC: @agent-dispatch-engine ac-12
22
+ */
23
+ class Mutex {
24
+ _queue = [];
25
+ _locked = false;
26
+ async runExclusive(fn) {
27
+ await this._acquire();
28
+ try {
29
+ return await fn();
30
+ }
31
+ finally {
32
+ this._release();
33
+ }
34
+ }
35
+ _acquire() {
36
+ if (!this._locked) {
37
+ this._locked = true;
38
+ return Promise.resolve();
39
+ }
40
+ return new Promise((resolve) => {
41
+ this._queue.push(resolve);
42
+ });
43
+ }
44
+ _release() {
45
+ const next = this._queue.shift();
46
+ if (next) {
47
+ next();
48
+ }
49
+ else {
50
+ this._locked = false;
51
+ }
52
+ }
53
+ }
54
+ /**
55
+ * Mapping from dispatch event names to task statuses.
56
+ * AC: @agent-dispatch-engine ac-1
57
+ */
58
+ const EVENT_TO_STATUS = {
59
+ "task.in_progress": "in_progress",
60
+ "task.ready": "pending",
61
+ "task.needs_work": "needs_work",
62
+ "task.pending_review": "pending_review",
63
+ };
64
+ const STATUS_TO_EVENT = {
65
+ in_progress: "task.in_progress",
66
+ pending: "task.ready",
67
+ needs_work: "task.needs_work",
68
+ pending_review: "task.pending_review",
69
+ blocked: undefined,
70
+ completed: undefined,
71
+ cancelled: undefined,
72
+ };
73
+ /**
74
+ * Dispatch precedence for runnable task statuses.
75
+ * Lower number = higher scheduling priority.
76
+ *
77
+ * AC: @dispatch-in-progress-priority ac-1
78
+ */
79
+ const STATUS_PRECEDENCE = {
80
+ in_progress: 0,
81
+ needs_work: 1,
82
+ pending: 2,
83
+ pending_review: 3,
84
+ blocked: 4,
85
+ completed: 5,
86
+ cancelled: 6,
87
+ };
88
+ // ─── Prompt Helpers (exported for testing) ───────────────────────────────────
89
+ /**
90
+ * Interpolate {{variable}} placeholders in a prompt template.
91
+ * Unresolved variables pass through unchanged.
92
+ *
93
+ * AC: @agent-dispatch-engine ac-16
94
+ */
95
+ export function interpolateTemplate(template, vars) {
96
+ return template.replace(/\{\{(\w+)\}\}/g, (match, key) => vars[key] ?? match);
97
+ }
98
+ /**
99
+ * Human-readable trigger description for orientation context.
100
+ */
101
+ function triggerDescription(trigger) {
102
+ switch (trigger) {
103
+ case "task.ready":
104
+ return "New task assignment.";
105
+ case "task.in_progress":
106
+ return "Continuing in-progress work.";
107
+ case "task.needs_work":
108
+ return "Fix cycle \u2014 this task was kicked back from review. Address the feedback below.";
109
+ case "task.pending_review":
110
+ return "Task submitted for review.";
111
+ default:
112
+ return `Trigger: ${trigger}`;
113
+ }
114
+ }
115
+ /**
116
+ * Format recent notes for inclusion in dispatch prompts.
117
+ * Takes last N notes, truncates each to maxLen characters, strips newlines.
118
+ */
119
+ function formatRecentNotes(notes, count = 3, maxLen = 200) {
120
+ if (!notes || notes.length === 0)
121
+ return "";
122
+ const recent = notes.slice(-count);
123
+ const lines = recent.map((n) => {
124
+ const date = n.created_at.slice(0, 10);
125
+ const author = n.author ? `@${n.author}` : "unknown";
126
+ const content = n.content.replace(/\n/g, " ").slice(0, maxLen);
127
+ return `- [${date}] ${author}: ${content}`;
128
+ });
129
+ return lines.join("\n");
130
+ }
131
+ /**
132
+ * Build orientation context block for a dispatch prompt.
133
+ * Provides the agent with task title, trigger meaning, and relevant context.
134
+ *
135
+ * AC: @agent-dispatch-engine ac-13, ac-14, ac-15
136
+ */
137
+ export function buildOrientationContext(taskRef, trigger, task) {
138
+ const title = task?.title ?? "(unavailable)";
139
+ const lines = [
140
+ "## Task Context",
141
+ `Task: ${taskRef} \u2014 "${title}"`,
142
+ `Trigger: ${triggerDescription(trigger)}`,
143
+ ];
144
+ // AC: @agent-dispatch-engine ac-14 - Include recent notes for fix cycles
145
+ if (trigger === "task.needs_work" && task?.notes && task.notes.length > 0) {
146
+ const noteText = formatRecentNotes(task.notes);
147
+ if (noteText) {
148
+ lines.push("", "Recent notes:", noteText);
149
+ }
150
+ }
151
+ // AC: @agent-dispatch-engine ac-15 - Include review URL for reviewer
152
+ if (trigger === "task.pending_review") {
153
+ const url = task?.review_url ?? "Not provided \u2014 find PR via task notes or git log.";
154
+ lines.push(`Review URL: ${url}`);
155
+ }
156
+ return lines.join("\n");
157
+ }
158
+ // ─── DispatchEngine ───────────────────────────────────────────────────────────
159
+ /**
160
+ * The core dispatch runtime.
161
+ *
162
+ * Lifecycle:
163
+ * 1. Create with new DispatchEngine(options)
164
+ * 2. Call start() to bootstrap and begin processing
165
+ * 3. Feed state changes via handleStateChange()
166
+ * 4. Call stop() for graceful shutdown
167
+ *
168
+ * AC: @agent-dispatch-engine ac-1 through ac-12
169
+ */
170
+ export class DispatchEngine {
171
+ projectDir;
172
+ specDir;
173
+ cwd;
174
+ dedupWindowMs;
175
+ reconcileIntervalMs;
176
+ kspecCliPath;
177
+ onInvocationEvent;
178
+ onTextChunk;
179
+ /** Queue of pending dispatch entries, per agent id */
180
+ queues = new Map();
181
+ /** Count of active (running) invocations per agent id */
182
+ activeCount = new Map();
183
+ /** Recent dedup keys with their expiry timestamps */
184
+ recentEvents = new Map();
185
+ /** Previous task states (for file watcher diffing) */
186
+ prevTaskStates = new Map();
187
+ /** Mutex serializing shadow branch mutations */
188
+ shadowMutex = new Mutex();
189
+ /** Whether the engine is currently running */
190
+ running = false;
191
+ /** Set of running invocation promises (for graceful shutdown) */
192
+ runningInvocations = new Set();
193
+ /** AbortControllers for active invocations (for graceful cancel on stop) */
194
+ invocationAbortControllers = new Set();
195
+ /** Per-invocation tracking records for status display */
196
+ activeInvocationDetails = new Map();
197
+ /** Monotonic enqueue sequence for deterministic queue ordering */
198
+ nextQueueSequence = 0;
199
+ /** Timer handle for periodic reconciliation. AC: @agent-dispatch-engine ac-20 */
200
+ reconcileTimer = null;
201
+ constructor(options) {
202
+ this.projectDir = options.projectDir;
203
+ this.specDir = options.specDir ?? path.join(options.projectDir, ".kspec");
204
+ this.cwd = options.cwd ?? options.projectDir;
205
+ this.dedupWindowMs = options.dedupWindowMs ?? 2000;
206
+ this.reconcileIntervalMs = (options.reconcileIntervalMs === null || options.reconcileIntervalMs === 0)
207
+ ? 0
208
+ : (options.reconcileIntervalMs ?? 60_000);
209
+ this.kspecCliPath = options.kspecCliPath;
210
+ this.onInvocationEvent = options.onInvocationEvent;
211
+ this.onTextChunk = options.onTextChunk;
212
+ }
213
+ // ─── Public API ─────────────────────────────────────────────────────────────
214
+ /**
215
+ * Start the dispatch engine.
216
+ *
217
+ * Loads current task states and evaluates dispatch rules for bootstrap.
218
+ * AC: @agent-dispatch-engine ac-8
219
+ */
220
+ async start() {
221
+ this.running = true;
222
+ // AC: @agent-dispatch-engine ac-8 - Bootstrap: evaluate existing task states
223
+ await this._bootstrap();
224
+ // AC: @agent-dispatch-engine ac-19, ac-20 - Start periodic reconciliation
225
+ if (this.reconcileIntervalMs > 0) {
226
+ this.reconcileTimer = setInterval(() => {
227
+ if (this.running) {
228
+ this._reconcile().catch((err) => {
229
+ console.error("[dispatch] Reconciliation error:", err);
230
+ });
231
+ }
232
+ }, this.reconcileIntervalMs);
233
+ this.reconcileTimer.unref();
234
+ }
235
+ }
236
+ /**
237
+ * Handle a task state change event from any source (file watcher or API).
238
+ *
239
+ * AC: @agent-dispatch-engine ac-1, ac-2, ac-4, ac-5, ac-6, ac-7
240
+ */
241
+ async handleStateChange(change) {
242
+ if (!this.running)
243
+ return;
244
+ // AC: @agent-dispatch-engine ac-7 - Deduplication
245
+ if (this._isDuplicate(change)) {
246
+ return;
247
+ }
248
+ this._recordEvent(change);
249
+ // AC: @agent-dispatch-engine ac-1 - Match against dispatch rules
250
+ const agents = await this._loadAgents();
251
+ const eventType = STATUS_TO_EVENT[change.toStatus];
252
+ if (!eventType)
253
+ return;
254
+ // Load task data for filter evaluation if not provided
255
+ let taskData = change.task;
256
+ if (!taskData && change.taskId) {
257
+ try {
258
+ const ctx = await initContext(this.projectDir);
259
+ const tasks = await loadAllTasks(ctx);
260
+ taskData = tasks.find((t) => t._ulid === change.taskId);
261
+ }
262
+ catch {
263
+ // Can't load task, filter evaluation will be lenient
264
+ }
265
+ }
266
+ // Make loaded task available for prompt building (AC: @agent-dispatch-engine ac-13)
267
+ if (taskData && !change.task) {
268
+ change.task = taskData;
269
+ }
270
+ for (const agent of agents) {
271
+ for (const rule of (agent.dispatch ?? [])) {
272
+ if (rule.on !== eventType)
273
+ continue;
274
+ // AC: @agent-dispatch-engine ac-6 - Apply filters
275
+ if (!this._matchesFilter(change, rule, taskData))
276
+ continue;
277
+ // AC: @agent-dispatch-engine ac-2 - Each matching agent queued independently
278
+ this._enqueue(agent, change);
279
+ }
280
+ }
281
+ // Drain queues after enqueuing
282
+ await this._drainQueues(agents);
283
+ }
284
+ /**
285
+ * Handle file watcher notification: diff previous vs current task states.
286
+ *
287
+ * AC: @agent-dispatch-engine ac-5
288
+ */
289
+ async handleFileChange(specDir) {
290
+ if (!this.running)
291
+ return;
292
+ try {
293
+ const ctx = await initContext(this.projectDir);
294
+ const tasks = await loadAllTasks(ctx);
295
+ const changes = [];
296
+ const now = Date.now();
297
+ for (const task of tasks) {
298
+ const taskId = task._ulid;
299
+ const currentStatus = task.status;
300
+ const prevStatus = this.prevTaskStates.get(taskId);
301
+ if (prevStatus !== undefined && prevStatus !== currentStatus) {
302
+ changes.push({
303
+ taskId,
304
+ taskRef: `@${taskId}`,
305
+ fromStatus: prevStatus,
306
+ toStatus: currentStatus,
307
+ timestamp: now,
308
+ task,
309
+ });
310
+ }
311
+ this.prevTaskStates.set(taskId, currentStatus);
312
+ }
313
+ // Emit change events for detected transitions
314
+ for (const change of changes) {
315
+ await this.handleStateChange(change);
316
+ }
317
+ }
318
+ catch (err) {
319
+ console.error("[dispatch] Error processing file change:", err);
320
+ }
321
+ }
322
+ /**
323
+ * Stop the dispatch engine gracefully.
324
+ *
325
+ * Sends cancel signals to all active invocations and waits for them to finish.
326
+ * AC: @agent-dispatch-engine ac-11
327
+ */
328
+ async stop() {
329
+ this.running = false;
330
+ // AC: @agent-dispatch-engine ac-20 - Stop periodic reconciliation
331
+ if (this.reconcileTimer !== null) {
332
+ clearInterval(this.reconcileTimer);
333
+ this.reconcileTimer = null;
334
+ }
335
+ // AC: @agent-dispatch-engine ac-11 - Send graceful cancel to all active invocations
336
+ for (const controller of this.invocationAbortControllers) {
337
+ controller.abort();
338
+ }
339
+ // Wait for all running invocations to complete (or abort)
340
+ if (this.runningInvocations.size > 0) {
341
+ await Promise.allSettled(Array.from(this.runningInvocations));
342
+ }
343
+ this.queues.clear();
344
+ this.activeCount.clear();
345
+ this.recentEvents.clear();
346
+ this.invocationAbortControllers.clear();
347
+ this.activeInvocationDetails.clear();
348
+ }
349
+ /**
350
+ * Get the shadow mutex for external callers that need to serialize mutations.
351
+ * AC: @agent-dispatch-engine ac-12
352
+ */
353
+ getShadowMutex() {
354
+ return this.shadowMutex;
355
+ }
356
+ /**
357
+ * Returns current engine status info including per-invocation details.
358
+ * AC: @cli-agent-commands ac-6
359
+ */
360
+ getStatus() {
361
+ let active = 0;
362
+ let queued = 0;
363
+ for (const count of this.activeCount.values())
364
+ active += count;
365
+ for (const entries of this.queues.values())
366
+ queued += entries.length;
367
+ const now = Date.now();
368
+ const invocations = Array.from(this.activeInvocationDetails.values()).map((r) => ({
369
+ invocationId: r.invocationId,
370
+ sessionId: r.sessionId,
371
+ agentId: r.agentId,
372
+ agentName: r.agentName,
373
+ taskRef: r.taskRef,
374
+ elapsedMs: now - r.startedAtMs,
375
+ }));
376
+ const queuedItems = Array.from(this.queues.values()).flatMap((entries) => entries.map((e) => ({
377
+ agentId: e.agent.id,
378
+ agentName: e.agent.name,
379
+ taskRef: e.change.taskRef,
380
+ waitMs: now - e.enqueuedAtMs,
381
+ })));
382
+ return { running: this.running, activeInvocations: active, queuedInvocations: queued, invocations, queued: queuedItems };
383
+ }
384
+ // ─── Private helpers ─────────────────────────────────────────────────────────
385
+ /**
386
+ * Bootstrap: evaluate all current tasks against dispatch rules.
387
+ * AC: @agent-dispatch-engine ac-8
388
+ */
389
+ async _bootstrap() {
390
+ try {
391
+ const enqueued = await this._evaluateAllTasks({ skipIfActive: false });
392
+ if (enqueued > 0) {
393
+ const agents = await this._loadAgents();
394
+ await this._drainQueues(agents);
395
+ }
396
+ }
397
+ catch (err) {
398
+ console.error("[dispatch] Bootstrap error:", err);
399
+ }
400
+ }
401
+ /**
402
+ * Periodic reconciliation: re-evaluate all task states against dispatch rules.
403
+ * Enqueues tasks that match but have no active or queued invocation.
404
+ * AC: @agent-dispatch-engine ac-19
405
+ */
406
+ async _reconcile() {
407
+ const enqueued = await this._evaluateAllTasks({ skipIfActive: true });
408
+ if (enqueued > 0) {
409
+ console.log(`[dispatch] Reconciliation enqueued ${enqueued} task(s)`);
410
+ const agents = await this._loadAgents();
411
+ await this._drainQueues(agents);
412
+ }
413
+ }
414
+ /**
415
+ * Shared logic for bootstrap and reconciliation: load all tasks, seed
416
+ * prevTaskStates, and enqueue tasks matching agent dispatch rules.
417
+ *
418
+ * When skipIfActive is true (reconciliation), tasks with an existing
419
+ * active or queued invocation are skipped.
420
+ *
421
+ * AC: @agent-dispatch-engine ac-8, ac-19
422
+ */
423
+ async _evaluateAllTasks(opts) {
424
+ const ctx = await initContext(this.projectDir);
425
+ const tasks = await loadAllTasks(ctx);
426
+ const agents = await this._loadAgents();
427
+ const now = Date.now();
428
+ let enqueued = 0;
429
+ // Seed/update prevTaskStates so file watcher diffs work correctly
430
+ for (const task of tasks) {
431
+ this.prevTaskStates.set(task._ulid, task.status);
432
+ }
433
+ for (const task of tasks) {
434
+ const currentStatus = task.status;
435
+ const eventType = STATUS_TO_EVENT[currentStatus];
436
+ if (!eventType)
437
+ continue;
438
+ for (const agent of agents) {
439
+ for (const rule of (agent.dispatch ?? [])) {
440
+ if (rule.on !== eventType)
441
+ continue;
442
+ const change = {
443
+ taskId: task._ulid,
444
+ taskRef: `@${task._ulid}`,
445
+ fromStatus: currentStatus,
446
+ toStatus: currentStatus,
447
+ timestamp: now,
448
+ task,
449
+ };
450
+ if (!this._matchesFilter(change, rule, task))
451
+ continue;
452
+ if (opts.skipIfActive && this._hasActiveOrQueuedInvocation(agent.id, task._ulid))
453
+ continue;
454
+ this._enqueue(agent, change);
455
+ enqueued++;
456
+ }
457
+ }
458
+ }
459
+ return enqueued;
460
+ }
461
+ /**
462
+ * Check if an agent already has an active or queued invocation for a task.
463
+ * AC: @agent-dispatch-engine ac-19
464
+ */
465
+ _hasActiveOrQueuedInvocation(agentId, taskId) {
466
+ // Check active invocations
467
+ for (const record of this.activeInvocationDetails.values()) {
468
+ if (record.agentId === agentId && record.taskRef === `@${taskId}`) {
469
+ return true;
470
+ }
471
+ }
472
+ // Check queued entries
473
+ const queue = this.queues.get(agentId) ?? [];
474
+ return queue.some((entry) => entry.change.taskId === taskId);
475
+ }
476
+ /**
477
+ * Load agent definitions from meta context.
478
+ */
479
+ async _loadAgents() {
480
+ try {
481
+ const ctx = await initContext(this.projectDir);
482
+ const meta = await loadMetaContext(ctx);
483
+ return meta.agents;
484
+ }
485
+ catch {
486
+ return [];
487
+ }
488
+ }
489
+ /**
490
+ * Check if a state change matches a dispatch rule's filters.
491
+ * AC: @agent-dispatch-engine ac-6
492
+ */
493
+ _matchesFilter(change, rule, task) {
494
+ if (!rule.filter)
495
+ return true;
496
+ // We need the task to evaluate filters — if not provided, reject to avoid
497
+ // enqueuing non-matching tasks (AC-6: all filters must match)
498
+ if (!task)
499
+ return false;
500
+ const { filter } = rule;
501
+ // Automation filter
502
+ if (filter.automation !== undefined) {
503
+ if (task.automation !== filter.automation) {
504
+ return false;
505
+ }
506
+ }
507
+ // Tags filter
508
+ if (filter.tags && filter.tags.length > 0) {
509
+ const taskTags = task.tags ?? [];
510
+ if (!filter.tags.every((tag) => taskTags.includes(tag))) {
511
+ return false;
512
+ }
513
+ }
514
+ // Priority filter
515
+ if (filter.priority !== undefined) {
516
+ if (task.priority !== filter.priority) {
517
+ return false;
518
+ }
519
+ }
520
+ return true;
521
+ }
522
+ /**
523
+ * Build a deduplication key for a state change.
524
+ * AC: @agent-dispatch-engine ac-7
525
+ */
526
+ _dedupKey(change) {
527
+ return `${change.taskId}:${change.fromStatus}:${change.toStatus}`;
528
+ }
529
+ /**
530
+ * Check whether this event is a duplicate within the dedup window.
531
+ * AC: @agent-dispatch-engine ac-7
532
+ */
533
+ _isDuplicate(change) {
534
+ const key = this._dedupKey(change);
535
+ const expiry = this.recentEvents.get(key);
536
+ if (expiry === undefined)
537
+ return false;
538
+ return change.timestamp < expiry;
539
+ }
540
+ /**
541
+ * Record a state change for deduplication.
542
+ * AC: @agent-dispatch-engine ac-7
543
+ */
544
+ _recordEvent(change) {
545
+ const key = this._dedupKey(change);
546
+ this.recentEvents.set(key, change.timestamp + this.dedupWindowMs);
547
+ // Prune expired entries periodically
548
+ if (this.recentEvents.size > 1000) {
549
+ const now = Date.now();
550
+ for (const [k, expiry] of this.recentEvents) {
551
+ if (expiry < now)
552
+ this.recentEvents.delete(k);
553
+ }
554
+ }
555
+ }
556
+ /**
557
+ * Enqueue a dispatch entry for an agent.
558
+ * AC: @agent-dispatch-engine ac-3
559
+ */
560
+ _enqueue(agent, change) {
561
+ const queue = this.queues.get(agent.id) ?? [];
562
+ const entry = {
563
+ agent,
564
+ change,
565
+ retryCount: 0,
566
+ nextRetryAt: 0,
567
+ enqueuedAtMs: Date.now(),
568
+ sequence: this.nextQueueSequence++,
569
+ };
570
+ this._insertQueueEntry(queue, entry);
571
+ this.queues.set(agent.id, queue);
572
+ }
573
+ /**
574
+ * Insert an entry into an agent queue using deterministic status precedence.
575
+ * AC: @dispatch-in-progress-priority ac-1
576
+ */
577
+ _insertQueueEntry(queue, entry) {
578
+ const insertAt = queue.findIndex((queued) => this._compareQueueEntries(entry, queued) < 0);
579
+ if (insertAt === -1) {
580
+ queue.push(entry);
581
+ return;
582
+ }
583
+ queue.splice(insertAt, 0, entry);
584
+ }
585
+ /**
586
+ * Compare queue entries by dispatch precedence, then by enqueue sequence.
587
+ * AC: @dispatch-in-progress-priority ac-1
588
+ */
589
+ _compareQueueEntries(a, b) {
590
+ const statusDelta = STATUS_PRECEDENCE[a.change.toStatus] - STATUS_PRECEDENCE[b.change.toStatus];
591
+ if (statusDelta !== 0)
592
+ return statusDelta;
593
+ return a.sequence - b.sequence;
594
+ }
595
+ /**
596
+ * Drain queues, spawning invocations up to each agent's max_concurrent limit.
597
+ * AC: @agent-dispatch-engine ac-3, ac-17
598
+ */
599
+ async _drainQueues(agents) {
600
+ // Prevent new invocation starts during/after shutdown.
601
+ if (!this.running)
602
+ return;
603
+ // AC: @agent-dispatch-engine ac-17 - Load current task states once for staleness checks
604
+ let currentTaskStates;
605
+ try {
606
+ const ctx = await initContext(this.projectDir);
607
+ const tasks = await loadAllTasks(ctx);
608
+ currentTaskStates = new Map(tasks.map((t) => [t._ulid, t.status]));
609
+ }
610
+ catch {
611
+ // If we can't load tasks, skip staleness checks (best effort)
612
+ }
613
+ for (const agent of agents) {
614
+ const maxConcurrent = agent.concurrency?.max_concurrent ?? 1;
615
+ const active = this.activeCount.get(agent.id) ?? 0;
616
+ const queue = this.queues.get(agent.id) ?? [];
617
+ // AC: @agent-dispatch-engine ac-17 - Discard stale entries before spawning.
618
+ // Only discard when we have positive evidence the task moved: either the task
619
+ // exists on disk with a different status, or the task was previously tracked
620
+ // (in prevTaskStates) but is no longer found (deleted).
621
+ if (currentTaskStates) {
622
+ const before = queue.length;
623
+ for (let i = queue.length - 1; i >= 0; i--) {
624
+ const entry = queue[i];
625
+ const currentStatus = currentTaskStates.get(entry.change.taskId);
626
+ const expectedEvent = STATUS_TO_EVENT[entry.change.toStatus];
627
+ if (!expectedEvent)
628
+ continue; // No event mapping — skip check
629
+ if (currentStatus === undefined) {
630
+ // Task not on disk — only discard if we previously knew about it
631
+ // (it was deleted). Tasks from pure handleStateChange events without
632
+ // on-disk presence should still be processed.
633
+ if (this.prevTaskStates.has(entry.change.taskId)) {
634
+ queue.splice(i, 1);
635
+ }
636
+ }
637
+ else {
638
+ const currentEvent = STATUS_TO_EVENT[currentStatus];
639
+ if (currentEvent !== expectedEvent) {
640
+ queue.splice(i, 1);
641
+ }
642
+ }
643
+ }
644
+ if (before > queue.length) {
645
+ console.log(`[dispatch] Discarded ${before - queue.length} stale queue entries for agent "${agent.id}"`);
646
+ }
647
+ }
648
+ let slots = maxConcurrent - active;
649
+ while (slots > 0 && queue.length > 0) {
650
+ const now = Date.now();
651
+ const nextReadyIndex = queue.findIndex((entry) => entry.nextRetryAt <= now);
652
+ if (nextReadyIndex === -1) {
653
+ break;
654
+ }
655
+ const [entry] = queue.splice(nextReadyIndex, 1);
656
+ const spawned = this._spawnInvocation(agent, entry);
657
+ if (spawned)
658
+ slots--;
659
+ }
660
+ this.queues.set(agent.id, queue);
661
+ }
662
+ }
663
+ /**
664
+ * Build dispatch-mode prompt guardrails to keep autonomous agents from
665
+ * stopping with handoff text instead of performing required actions.
666
+ *
667
+ * AC: @agent-dispatch-engine ac-13, ac-14, ac-15, ac-16
668
+ */
669
+ _buildDispatchPrompt(agent, change) {
670
+ const trigger = (STATUS_TO_EVENT[change.toStatus] ?? "task.ready");
671
+ const taskRef = change.taskRef;
672
+ // AC: @agent-dispatch-engine ac-16 - Interpolate prompt_template variables
673
+ const templateVars = {
674
+ task_ref: taskRef,
675
+ task_title: change.task?.title ?? "(unavailable)",
676
+ trigger,
677
+ review_url: change.task?.review_url ?? "",
678
+ };
679
+ const rawTemplate = agent.prompt_template ?? `Work on task ${taskRef}`;
680
+ const basePrompt = interpolateTemplate(rawTemplate, templateVars);
681
+ // AC: @agent-dispatch-engine ac-13 - Orientation context
682
+ const orientation = buildOrientationContext(taskRef, trigger, change.task);
683
+ const autonomousPreamble = [
684
+ "AUTONOMOUS DISPATCH MODE (no interactive user is available).",
685
+ "- Do not ask for confirmation, approval, or next-step handoff.",
686
+ "- Execute required commands directly in this invocation.",
687
+ "- Do not end your turn with a recommendations-only summary. Perform the next required action yourself.",
688
+ "- Do not end your turn until the expected task transition is complete, or you have explicitly blocked the task with `kspec task block <task> --reason \"...\"`.",
689
+ "- If you find an open PR/branch from a different task, create or switch to a dedicated branch for this task before committing to avoid PR conflation.",
690
+ ];
691
+ const triggerSpecific = trigger === "task.pending_review"
692
+ ? [
693
+ `Review flow completion criteria for ${taskRef}:`,
694
+ "- Execute your configured review workflow directly (no handoff).",
695
+ `- If blocking issues are found, transition ${taskRef} out of pending_review appropriately (for example needs_work).`,
696
+ `- If review gates are clean, perform your workflow's completion actions directly in this invocation.`,
697
+ ]
698
+ : [
699
+ `Work flow completion criteria for ${taskRef}:`,
700
+ "- Execute your configured work workflow directly (no handoff).",
701
+ `- Perform the required commands to move ${taskRef} to the next appropriate state in this same invocation.`,
702
+ "- If your workflow includes git or PR steps, execute them directly instead of deferring to a human.",
703
+ ];
704
+ return `${basePrompt}\n\n${orientation}\n\n${autonomousPreamble.join("\n")}\n\n${triggerSpecific.join("\n")}`;
705
+ }
706
+ /**
707
+ * Spawn a single invocation for a queue entry.
708
+ * Returns true if an invocation was actually started, false if skipped.
709
+ * AC: @agent-dispatch-engine ac-9, ac-10, ac-11, ac-12
710
+ */
711
+ _spawnInvocation(agent, entry) {
712
+ const agentId = agent.id;
713
+ // Increment active count
714
+ this.activeCount.set(agentId, (this.activeCount.get(agentId) ?? 0) + 1);
715
+ // AC: @agent-dispatch-engine ac-10 - Check adapter resolvability before spawn
716
+ const adapterId = agent.adapter ?? "claude-agent-acp";
717
+ const adapter = getAdapter(adapterId);
718
+ if (!adapter) {
719
+ console.error(`[dispatch] Cannot resolve adapter "${adapterId}" for agent "${agentId}". Skipping invocation.`);
720
+ // Decrement active count since we're not actually running
721
+ const currentActive = this.activeCount.get(agentId) ?? 1;
722
+ this.activeCount.set(agentId, Math.max(0, currentActive - 1));
723
+ // AC: @agent-dispatch-engine ac-10 - Add task note for unresolvable adapter
724
+ if (this.kspecCliPath) {
725
+ spawnSync(process.execPath, [
726
+ this.kspecCliPath,
727
+ "task", "note", entry.change.taskRef,
728
+ `[AGENT-SKIP] Cannot resolve adapter "${adapterId}" for agent "${agentId}". Invocation skipped.`,
729
+ ], { cwd: this.cwd });
730
+ }
731
+ return false;
732
+ }
733
+ // AC: @agent-dispatch-engine ac-11 - Create abort controller for graceful cancellation
734
+ const abortController = new AbortController();
735
+ this.invocationAbortControllers.add(abortController);
736
+ // AC: @cli-agent-commands ac-6 - Pre-assign session ID for status tracking
737
+ const preSessionId = ulid();
738
+ const invocationId = ulid();
739
+ const trackingRecord = {
740
+ invocationId,
741
+ sessionId: preSessionId,
742
+ agentId,
743
+ agentName: agent.name,
744
+ taskRef: entry.change.taskRef,
745
+ startedAtMs: Date.now(),
746
+ };
747
+ this.activeInvocationDetails.set(invocationId, trackingRecord);
748
+ // AC: @daemon-agent-dispatch ac-3, ac-4 - Emit started event
749
+ this.onInvocationEvent?.({
750
+ type: "started",
751
+ session_id: preSessionId,
752
+ agent_id: agentId,
753
+ task_id: entry.change.taskRef,
754
+ status: "started",
755
+ timestamp: Date.now(),
756
+ });
757
+ // AC: @cli-agent-commands ac-13, @daemon-agent-dispatch ac-8 - stream text chunks to watchers
758
+ const taskId = entry.change.taskRef ?? null;
759
+ const onUpdate = this.onTextChunk
760
+ ? (update) => {
761
+ if (update.sessionUpdate === "agent_message_chunk" &&
762
+ update.content.type === "text") {
763
+ this.onTextChunk(preSessionId, agentId, taskId, update.content.text);
764
+ return;
765
+ }
766
+ // Non-text updates (especially tool events) delimit logical message runs.
767
+ // Emit an empty sentinel so watch renderers can end the current line
768
+ // without needing to infer boundaries from prose punctuation.
769
+ this.onTextChunk(preSessionId, agentId, taskId, "");
770
+ }
771
+ : undefined;
772
+ const options = {
773
+ agent,
774
+ specDir: this.specDir,
775
+ cwd: this.cwd,
776
+ taskRef: entry.change.taskRef,
777
+ prompt: this._buildDispatchPrompt(agent, entry.change),
778
+ trigger: (STATUS_TO_EVENT[entry.change.toStatus] ?? "task.ready"),
779
+ kspecCliPath: this.kspecCliPath,
780
+ abortSignal: abortController.signal,
781
+ sessionId: preSessionId,
782
+ onUpdate,
783
+ };
784
+ // AC: @agent-dispatch-engine ac-12 - Wrap invocation in shadow mutex
785
+ const invocationPromise = this.shadowMutex
786
+ .runExclusive(async () => {
787
+ // AC: @agent-dispatch-engine ac-9 - Retry on transient errors
788
+ try {
789
+ await runInvocation(options);
790
+ // Reset retry count on success
791
+ entry.retryCount = 0;
792
+ // AC: @daemon-agent-dispatch ac-3, ac-4 - Emit completed event
793
+ this.onInvocationEvent?.({
794
+ type: "completed",
795
+ session_id: preSessionId,
796
+ agent_id: agentId,
797
+ task_id: entry.change.taskRef,
798
+ status: "completed",
799
+ timestamp: Date.now(),
800
+ });
801
+ }
802
+ catch (err) {
803
+ const retryLimit = agent.budget?.max_retries ?? 3;
804
+ if (entry.retryCount < retryLimit) {
805
+ entry.retryCount++;
806
+ const backoffMs = Math.min(1000 * Math.pow(2, entry.retryCount - 1), 30_000);
807
+ entry.nextRetryAt = Date.now() + backoffMs;
808
+ console.warn(`[dispatch] Invocation for agent "${agentId}" failed (attempt ${entry.retryCount}/${retryLimit}), retrying in ${backoffMs}ms`, err);
809
+ // Re-enqueue for retry while preserving status precedence ordering.
810
+ const queue = this.queues.get(agentId) ?? [];
811
+ this._insertQueueEntry(queue, entry);
812
+ this.queues.set(agentId, queue);
813
+ // AC: @agent-dispatch-engine ac-9 - Schedule wake-up to drain retry
814
+ setTimeout(() => {
815
+ if (this.running) {
816
+ this._loadAgents()
817
+ .then((agents) => this._drainQueues(agents))
818
+ .catch(() => { });
819
+ }
820
+ }, backoffMs);
821
+ }
822
+ else {
823
+ console.error(`[dispatch] Agent "${agentId}" exceeded retry limit. Dropping invocation.`, err);
824
+ // AC: @daemon-agent-dispatch ac-3, ac-4 - Emit failed event when retry limit exceeded
825
+ this.onInvocationEvent?.({
826
+ type: "failed",
827
+ session_id: preSessionId,
828
+ agent_id: agentId,
829
+ task_id: entry.change.taskRef,
830
+ status: "failed",
831
+ timestamp: Date.now(),
832
+ });
833
+ }
834
+ }
835
+ })
836
+ .then(async () => {
837
+ // Decrement active count and drain again
838
+ const currentActive = this.activeCount.get(agentId) ?? 1;
839
+ this.activeCount.set(agentId, Math.max(0, currentActive - 1));
840
+ if (!this.running)
841
+ return;
842
+ // Try to drain more items
843
+ try {
844
+ const agents = await this._loadAgents();
845
+ await this._drainQueues(agents);
846
+ }
847
+ catch {
848
+ // Best effort
849
+ }
850
+ })
851
+ .finally(() => {
852
+ this.runningInvocations.delete(invocationPromise);
853
+ this.invocationAbortControllers.delete(abortController);
854
+ this.activeInvocationDetails.delete(invocationId);
855
+ });
856
+ this.runningInvocations.add(invocationPromise);
857
+ return true;
858
+ }
859
+ }
860
+ //# sourceMappingURL=dispatch.js.map