@alisio/plugin-swarm 0.0.0-stage → 0.1.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 (266) hide show
  1. package/.agents/agents/swarm-architect.md +20 -0
  2. package/.agents/agents/swarm-cleaner.md +20 -0
  3. package/.agents/agents/swarm-coder.md +20 -0
  4. package/.agents/agents/swarm-hardener.md +20 -0
  5. package/.agents/agents/swarm-lieutenant.md +20 -0
  6. package/.agents/agents/swarm-qa.md +20 -0
  7. package/.agents/agents/swarm-refactorer.md +20 -0
  8. package/.agents/agents/swarm-specifier.md +20 -0
  9. package/.agents/skills/swarm-architecture-rules/SKILL.md +43 -0
  10. package/.agents/skills/swarm-clarification/SKILL.md +42 -0
  11. package/.agents/skills/swarm-cleaner-metrics/SKILL.md +44 -0
  12. package/.agents/skills/swarm-engineering-constitution/SKILL.md +44 -0
  13. package/.agents/skills/swarm-gherkin-spec/SKILL.md +43 -0
  14. package/.agents/skills/swarm-handoff-protocol/SKILL.md +45 -0
  15. package/.agents/skills/swarm-lieutenant/SKILL.md +41 -0
  16. package/.agents/skills/swarm-mutation-hardening/SKILL.md +43 -0
  17. package/.agents/skills/swarm-qa-acceptance/SKILL.md +42 -0
  18. package/.agents/skills/swarm-tdd-slice/SKILL.md +44 -0
  19. package/.agents/skills/swarm-toolchain-profile/SKILL.md +41 -0
  20. package/LICENSE +21 -0
  21. package/README.es.md +337 -0
  22. package/README.md +322 -2
  23. package/assets/approval-flow.svg +1 -0
  24. package/assets/architecture.svg +1 -0
  25. package/assets/audit-states.svg +1 -0
  26. package/assets/dashboard.css +964 -0
  27. package/assets/dashboard.html +123 -0
  28. package/assets/dashboard.js +965 -0
  29. package/assets/dashboard.svg +1 -0
  30. package/assets/handoff-sequence.svg +1 -0
  31. package/assets/pack-pipelines.svg +1 -0
  32. package/assets/packs/four-pack/pack.json +18 -0
  33. package/assets/packs/six-pack/pack.json +22 -0
  34. package/assets/packs/two-pack/pack.json +14 -0
  35. package/assets/screenshots/dashboard-board-dark.png +0 -0
  36. package/assets/screenshots/dashboard-board-light.png +0 -0
  37. package/assets/screenshots/dashboard-documents-diff.png +0 -0
  38. package/assets/screenshots/dashboard-empty-state.png +0 -0
  39. package/assets/screenshots/dashboard-phone.png +0 -0
  40. package/assets/screenshots/dashboard-reject-dialog.png +0 -0
  41. package/assets/screenshots/dashboard-transcript-tail.png +0 -0
  42. package/assets/screenshots/dashboard-work-queue.png +0 -0
  43. package/assets/task-states.svg +1 -0
  44. package/assets/toolchains/node-ts.json +61 -0
  45. package/cover.svg +46 -0
  46. package/dist/adapters/child-session-runner.d.ts +36 -0
  47. package/dist/adapters/child-session-runner.d.ts.map +1 -0
  48. package/dist/adapters/child-session-runner.js +156 -0
  49. package/dist/adapters/child-session-runner.js.map +1 -0
  50. package/dist/adapters/fs-board-store.d.ts +11 -0
  51. package/dist/adapters/fs-board-store.d.ts.map +1 -0
  52. package/dist/adapters/fs-board-store.js +27 -0
  53. package/dist/adapters/fs-board-store.js.map +1 -0
  54. package/dist/adapters/fs-handoff-store.d.ts +36 -0
  55. package/dist/adapters/fs-handoff-store.d.ts.map +1 -0
  56. package/dist/adapters/fs-handoff-store.js +254 -0
  57. package/dist/adapters/fs-handoff-store.js.map +1 -0
  58. package/dist/adapters/fs-task-state-store.d.ts +16 -0
  59. package/dist/adapters/fs-task-state-store.d.ts.map +1 -0
  60. package/dist/adapters/fs-task-state-store.js +66 -0
  61. package/dist/adapters/fs-task-state-store.js.map +1 -0
  62. package/dist/adapters/git-worktree.d.ts +31 -0
  63. package/dist/adapters/git-worktree.d.ts.map +1 -0
  64. package/dist/adapters/git-worktree.js +209 -0
  65. package/dist/adapters/git-worktree.js.map +1 -0
  66. package/dist/adapters/process-exec.d.ts +36 -0
  67. package/dist/adapters/process-exec.d.ts.map +1 -0
  68. package/dist/adapters/process-exec.js +138 -0
  69. package/dist/adapters/process-exec.js.map +1 -0
  70. package/dist/adapters/process-gate-runner.d.ts +39 -0
  71. package/dist/adapters/process-gate-runner.d.ts.map +1 -0
  72. package/dist/adapters/process-gate-runner.js +242 -0
  73. package/dist/adapters/process-gate-runner.js.map +1 -0
  74. package/dist/adapters/unavailable-runner.d.ts +4 -0
  75. package/dist/adapters/unavailable-runner.d.ts.map +1 -0
  76. package/dist/adapters/unavailable-runner.js +12 -0
  77. package/dist/adapters/unavailable-runner.js.map +1 -0
  78. package/dist/app/audit.d.ts +23 -0
  79. package/dist/app/audit.d.ts.map +1 -0
  80. package/dist/app/audit.js +9 -0
  81. package/dist/app/audit.js.map +1 -0
  82. package/dist/app/budget.d.ts +29 -0
  83. package/dist/app/budget.d.ts.map +1 -0
  84. package/dist/app/budget.js +78 -0
  85. package/dist/app/budget.js.map +1 -0
  86. package/dist/app/chat-store.d.ts +18 -0
  87. package/dist/app/chat-store.d.ts.map +1 -0
  88. package/dist/app/chat-store.js +48 -0
  89. package/dist/app/chat-store.js.map +1 -0
  90. package/dist/app/documents.d.ts +33 -0
  91. package/dist/app/documents.d.ts.map +1 -0
  92. package/dist/app/documents.js +63 -0
  93. package/dist/app/documents.js.map +1 -0
  94. package/dist/app/forge.d.ts +106 -0
  95. package/dist/app/forge.d.ts.map +1 -0
  96. package/dist/app/forge.js +420 -0
  97. package/dist/app/forge.js.map +1 -0
  98. package/dist/app/operator.d.ts +46 -0
  99. package/dist/app/operator.d.ts.map +1 -0
  100. package/dist/app/operator.js +168 -0
  101. package/dist/app/operator.js.map +1 -0
  102. package/dist/app/pidfile.d.ts +18 -0
  103. package/dist/app/pidfile.d.ts.map +1 -0
  104. package/dist/app/pidfile.js +56 -0
  105. package/dist/app/pidfile.js.map +1 -0
  106. package/dist/app/project.d.ts +93 -0
  107. package/dist/app/project.d.ts.map +1 -0
  108. package/dist/app/project.js +291 -0
  109. package/dist/app/project.js.map +1 -0
  110. package/dist/app/prompts.d.ts +48 -0
  111. package/dist/app/prompts.d.ts.map +1 -0
  112. package/dist/app/prompts.js +88 -0
  113. package/dist/app/prompts.js.map +1 -0
  114. package/dist/app/pump.d.ts +144 -0
  115. package/dist/app/pump.d.ts.map +1 -0
  116. package/dist/app/pump.js +707 -0
  117. package/dist/app/pump.js.map +1 -0
  118. package/dist/app/services.d.ts +112 -0
  119. package/dist/app/services.d.ts.map +1 -0
  120. package/dist/app/services.js +323 -0
  121. package/dist/app/services.js.map +1 -0
  122. package/dist/app/state.d.ts +70 -0
  123. package/dist/app/state.d.ts.map +1 -0
  124. package/dist/app/state.js +96 -0
  125. package/dist/app/state.js.map +1 -0
  126. package/dist/args.d.ts +10 -0
  127. package/dist/args.d.ts.map +1 -0
  128. package/dist/args.js +33 -0
  129. package/dist/args.js.map +1 -0
  130. package/dist/coordinator.d.ts +80 -0
  131. package/dist/coordinator.d.ts.map +1 -0
  132. package/dist/coordinator.js +481 -0
  133. package/dist/coordinator.js.map +1 -0
  134. package/dist/dashboard/api.d.ts +28 -0
  135. package/dist/dashboard/api.d.ts.map +1 -0
  136. package/dist/dashboard/api.js +178 -0
  137. package/dist/dashboard/api.js.map +1 -0
  138. package/dist/dashboard/auth.d.ts +13 -0
  139. package/dist/dashboard/auth.d.ts.map +1 -0
  140. package/dist/dashboard/auth.js +36 -0
  141. package/dist/dashboard/auth.js.map +1 -0
  142. package/dist/dashboard/open.d.ts +7 -0
  143. package/dist/dashboard/open.d.ts.map +1 -0
  144. package/dist/dashboard/open.js +17 -0
  145. package/dist/dashboard/open.js.map +1 -0
  146. package/dist/dashboard/server.d.ts +28 -0
  147. package/dist/dashboard/server.d.ts.map +1 -0
  148. package/dist/dashboard/server.js +176 -0
  149. package/dist/dashboard/server.js.map +1 -0
  150. package/dist/doctor.d.ts +28 -0
  151. package/dist/doctor.d.ts.map +1 -0
  152. package/dist/doctor.js +108 -0
  153. package/dist/doctor.js.map +1 -0
  154. package/dist/domain/attention.d.ts +20 -0
  155. package/dist/domain/attention.d.ts.map +1 -0
  156. package/dist/domain/attention.js +41 -0
  157. package/dist/domain/attention.js.map +1 -0
  158. package/dist/domain/envelope.d.ts +37 -0
  159. package/dist/domain/envelope.d.ts.map +1 -0
  160. package/dist/domain/envelope.js +106 -0
  161. package/dist/domain/envelope.js.map +1 -0
  162. package/dist/domain/handoff.d.ts +57 -0
  163. package/dist/domain/handoff.d.ts.map +1 -0
  164. package/dist/domain/handoff.js +191 -0
  165. package/dist/domain/handoff.js.map +1 -0
  166. package/dist/domain/identifiers.d.ts +18 -0
  167. package/dist/domain/identifiers.d.ts.map +1 -0
  168. package/dist/domain/identifiers.js +70 -0
  169. package/dist/domain/identifiers.js.map +1 -0
  170. package/dist/domain/pack.d.ts +45 -0
  171. package/dist/domain/pack.d.ts.map +1 -0
  172. package/dist/domain/pack.js +207 -0
  173. package/dist/domain/pack.js.map +1 -0
  174. package/dist/domain/pipeline.d.ts +33 -0
  175. package/dist/domain/pipeline.d.ts.map +1 -0
  176. package/dist/domain/pipeline.js +50 -0
  177. package/dist/domain/pipeline.js.map +1 -0
  178. package/dist/domain/task.d.ts +26 -0
  179. package/dist/domain/task.d.ts.map +1 -0
  180. package/dist/domain/task.js +154 -0
  181. package/dist/domain/task.js.map +1 -0
  182. package/dist/domain/taskstate.d.ts +37 -0
  183. package/dist/domain/taskstate.d.ts.map +1 -0
  184. package/dist/domain/taskstate.js +98 -0
  185. package/dist/domain/taskstate.js.map +1 -0
  186. package/dist/gates/dry.d.ts +14 -0
  187. package/dist/gates/dry.d.ts.map +1 -0
  188. package/dist/gates/dry.js +71 -0
  189. package/dist/gates/dry.js.map +1 -0
  190. package/dist/gates/parsers.d.ts +34 -0
  191. package/dist/gates/parsers.d.ts.map +1 -0
  192. package/dist/gates/parsers.js +120 -0
  193. package/dist/gates/parsers.js.map +1 -0
  194. package/dist/index.d.ts +45 -0
  195. package/dist/index.d.ts.map +1 -0
  196. package/dist/index.js +207 -0
  197. package/dist/index.js.map +1 -0
  198. package/dist/options.d.ts +13 -0
  199. package/dist/options.d.ts.map +1 -0
  200. package/dist/options.js +27 -0
  201. package/dist/options.js.map +1 -0
  202. package/dist/panel.d.ts +5 -0
  203. package/dist/panel.d.ts.map +1 -0
  204. package/dist/panel.js +52 -0
  205. package/dist/panel.js.map +1 -0
  206. package/dist/ports/agent-runner.d.ts +51 -0
  207. package/dist/ports/agent-runner.d.ts.map +1 -0
  208. package/dist/ports/agent-runner.js +2 -0
  209. package/dist/ports/agent-runner.js.map +1 -0
  210. package/dist/ports/board-store.d.ts +7 -0
  211. package/dist/ports/board-store.d.ts.map +1 -0
  212. package/dist/ports/board-store.js +2 -0
  213. package/dist/ports/board-store.js.map +1 -0
  214. package/dist/ports/clock.d.ts +5 -0
  215. package/dist/ports/clock.d.ts.map +1 -0
  216. package/dist/ports/clock.js +2 -0
  217. package/dist/ports/clock.js.map +1 -0
  218. package/dist/ports/gate-runner.d.ts +34 -0
  219. package/dist/ports/gate-runner.d.ts.map +1 -0
  220. package/dist/ports/gate-runner.js +2 -0
  221. package/dist/ports/gate-runner.js.map +1 -0
  222. package/dist/ports/handoff-store.d.ts +39 -0
  223. package/dist/ports/handoff-store.d.ts.map +1 -0
  224. package/dist/ports/handoff-store.js +2 -0
  225. package/dist/ports/handoff-store.js.map +1 -0
  226. package/dist/ports/isolation.d.ts +43 -0
  227. package/dist/ports/isolation.d.ts.map +1 -0
  228. package/dist/ports/isolation.js +2 -0
  229. package/dist/ports/isolation.js.map +1 -0
  230. package/dist/ports/notifier.d.ts +28 -0
  231. package/dist/ports/notifier.d.ts.map +1 -0
  232. package/dist/ports/notifier.js +2 -0
  233. package/dist/ports/notifier.js.map +1 -0
  234. package/dist/ports/task-state-store.d.ts +10 -0
  235. package/dist/ports/task-state-store.d.ts.map +1 -0
  236. package/dist/ports/task-state-store.js +2 -0
  237. package/dist/ports/task-state-store.js.map +1 -0
  238. package/dist/refs.d.ts +10 -0
  239. package/dist/refs.d.ts.map +1 -0
  240. package/dist/refs.js +19 -0
  241. package/dist/refs.js.map +1 -0
  242. package/dist/resources.d.ts +55 -0
  243. package/dist/resources.d.ts.map +1 -0
  244. package/dist/resources.js +255 -0
  245. package/dist/resources.js.map +1 -0
  246. package/dist/status.d.ts +18 -0
  247. package/dist/status.d.ts.map +1 -0
  248. package/dist/status.js +60 -0
  249. package/dist/status.js.map +1 -0
  250. package/dist/storage.d.ts +18 -0
  251. package/dist/storage.d.ts.map +1 -0
  252. package/dist/storage.js +79 -0
  253. package/dist/storage.js.map +1 -0
  254. package/dist/toolchains/profile.d.ts +21 -0
  255. package/dist/toolchains/profile.d.ts.map +1 -0
  256. package/dist/toolchains/profile.js +60 -0
  257. package/dist/toolchains/profile.js.map +1 -0
  258. package/dist/toolchains/thresholds.d.ts +29 -0
  259. package/dist/toolchains/thresholds.d.ts.map +1 -0
  260. package/dist/toolchains/thresholds.js +45 -0
  261. package/dist/toolchains/thresholds.js.map +1 -0
  262. package/dist/version.d.ts +2 -0
  263. package/dist/version.d.ts.map +1 -0
  264. package/dist/version.js +3 -0
  265. package/dist/version.js.map +1 -0
  266. package/package.json +51 -3
@@ -0,0 +1,707 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { parseEnvelope } from "../domain/envelope.js";
3
+ import { PHANTOM_SENDER } from "../domain/handoff.js";
4
+ import { findRole, roleIds } from "../domain/pack.js";
5
+ import { approvalRequiredAfter, joinSource, releasePlan, route, } from "../domain/pipeline.js";
6
+ import { MAX_HOLD_TEXT } from "../domain/taskstate.js";
7
+ import { Mutex } from "../storage.js";
8
+ import { nextAudit, startAudit } from "./audit.js";
9
+ import { budgetAttention } from "./budget.js";
10
+ import { answerPrompt, auditPrompt, gateFailurePrompt, handoffBody, rejectionPrompt, rejectionRouteBody, resumedAnswerPrompt, rolePrompt, } from "./prompts.js";
11
+ const noop = () => undefined;
12
+ const message = (error) => error instanceof Error ? error.message : String(error);
13
+ const statusFor = (reason) => reason === "clarifying" ? "clarifying" : "blocked";
14
+ /**
15
+ * In-process handoff pump (AD-4): delivers handoffs, runs one role at a time per task, drives the
16
+ * audit handshake and the quality gates. Event-driven (`wake`) with a safety tick; `drain` runs to
17
+ * quiescence for tests and for the foreground `run` command.
18
+ */
19
+ export class Pump {
20
+ host;
21
+ running = new Map();
22
+ active = new Map();
23
+ holds = new Map();
24
+ tickLock = new Mutex();
25
+ timer;
26
+ halted = false;
27
+ constructor(host) {
28
+ this.host = host;
29
+ }
30
+ start() {
31
+ if (this.timer)
32
+ return;
33
+ this.timer = setInterval(() => void this.tick().catch(noop), this.host.tickMs);
34
+ this.timer.unref();
35
+ this.wake();
36
+ }
37
+ /** Stop scheduling new runs; in-flight runs are awaited by `settled`. */
38
+ halt() {
39
+ this.halted = true;
40
+ if (this.timer)
41
+ clearInterval(this.timer);
42
+ this.timer = undefined;
43
+ }
44
+ async settled() {
45
+ await Promise.allSettled([...this.running.values()]);
46
+ }
47
+ wake() {
48
+ if (!this.timer || this.halted)
49
+ return;
50
+ setImmediate(() => void this.tick().catch(noop));
51
+ }
52
+ isHalted() {
53
+ return this.halted;
54
+ }
55
+ /** The soft token cap is reached: no new runs start until the operator raises it. */
56
+ isPaused() {
57
+ return this.host.meter?.exceeded === true;
58
+ }
59
+ /** The task is being worked on by a role right now. */
60
+ isTaskRunning(taskId) {
61
+ return [...this.active.values()].includes(taskId);
62
+ }
63
+ /** One line for the dashboard activity log; never throws into the pump. */
64
+ activity(kind, run, message) {
65
+ this.host.notifier.notify({
66
+ type: "activity",
67
+ project: this.host.name,
68
+ at: this.host.clock.now().toISOString(),
69
+ kind,
70
+ role: run.role,
71
+ task: run.taskName,
72
+ message,
73
+ });
74
+ }
75
+ /** Roles running right now, with the task each one works on. */
76
+ activeRoles() {
77
+ return new Map(this.active);
78
+ }
79
+ /** The hold of a task (clarification, block or gate failure), if any. */
80
+ holdOf(taskId) {
81
+ return [...this.holds.values()].find((held) => held.taskId === taskId);
82
+ }
83
+ /** Start a run for every idle role that has mail, up to the concurrency cap. */
84
+ tick() {
85
+ return this.tickLock.run(async () => {
86
+ if (this.halted || this.isPaused())
87
+ return;
88
+ for (const role of roleIds(this.host.pack)) {
89
+ if (this.running.size >= this.host.maxConcurrent)
90
+ break;
91
+ if (this.running.has(role) || this.holds.has(role))
92
+ continue;
93
+ if ((await this.host.handoffs.pending(role)) === 0)
94
+ continue;
95
+ this.track(role, this.runRole(role));
96
+ }
97
+ });
98
+ }
99
+ track(role, work) {
100
+ const tracked = work.catch(noop).finally(() => {
101
+ this.running.delete(role);
102
+ this.active.delete(role);
103
+ this.wake();
104
+ });
105
+ this.running.set(role, tracked);
106
+ }
107
+ async hasWork() {
108
+ if (this.halted || this.isPaused())
109
+ return false;
110
+ for (const role of roleIds(this.host.pack)) {
111
+ if (this.holds.has(role))
112
+ continue;
113
+ if ((await this.host.handoffs.pending(role)) > 0)
114
+ return true;
115
+ }
116
+ return false;
117
+ }
118
+ /** Run until no role has mail (held roles excluded) and nothing is running. */
119
+ async drain() {
120
+ for (;;) {
121
+ await this.tick();
122
+ if (this.running.size === 0) {
123
+ if (!(await this.hasWork()))
124
+ return;
125
+ continue;
126
+ }
127
+ await Promise.race([...this.running.values()]);
128
+ }
129
+ }
130
+ // ---- operator actions on holds ----
131
+ async answer(taskId, text) {
132
+ const held = this.holdOf(taskId);
133
+ if (held?.reason !== "clarifying") {
134
+ throw new Error("The task is not waiting for an answer");
135
+ }
136
+ this.holds.delete(held.role);
137
+ if (held.restored) {
138
+ // The session is gone: the answer travels in the next prompt of a fresh run.
139
+ await this.host.state.update(taskId, held.taskName, (state) => {
140
+ state.hold = undefined;
141
+ state.answer = { question: held.text, text };
142
+ });
143
+ await this.host.updateCard(taskId, { lane: held.role, status: "queued" });
144
+ this.wake();
145
+ return;
146
+ }
147
+ await this.clearHold(held, false);
148
+ held.retried = false;
149
+ await this.host.updateCard(taskId, { status: "working" });
150
+ this.active.set(held.role, taskId);
151
+ const work = this.converse(held, answerPrompt(text)).catch((error) => this.fail(held, error));
152
+ this.track(held.role, work);
153
+ await work;
154
+ }
155
+ /** Retry: put a held role's work back in its inbox with a fresh allowance. */
156
+ async releaseHold(taskId) {
157
+ const held = this.holdOf(taskId);
158
+ if (!held)
159
+ throw new Error("There is nothing to retry: the task is not held");
160
+ this.holds.delete(held.role);
161
+ if (held.parked)
162
+ await this.host.handoffs.discard(held.parked);
163
+ for (const item of held.items) {
164
+ await this.host.handoffs.move(item, { kind: "inbox", role: held.role, box: "new" });
165
+ }
166
+ await this.clearHold(held, true);
167
+ await this.host.updateCard(taskId, { lane: held.role, status: "queued" });
168
+ this.wake();
169
+ }
170
+ /** Accept the held work as it is: release its parked handoff despite the failure. */
171
+ async acceptHold(taskId) {
172
+ const held = this.holdOf(taskId);
173
+ if (!held?.parked || held.restored || held.reason === "clarifying") {
174
+ throw new Error("There is nothing to accept: retry or delete the task instead");
175
+ }
176
+ this.holds.delete(held.role);
177
+ await this.clearHold(held, true);
178
+ await this.release(held);
179
+ }
180
+ /** A held task is deleted: forget the hold without touching files (the operator archives them). */
181
+ forget(taskId) {
182
+ const held = this.holdOf(taskId);
183
+ if (held)
184
+ this.holds.delete(held.role);
185
+ }
186
+ /** Rebuild a hold from its persisted state after a restart. */
187
+ restoreHold(state) {
188
+ const hold = state.hold;
189
+ if (!hold)
190
+ return;
191
+ this.holds.set(hold.role, {
192
+ role: hold.role,
193
+ workdir: this.host.workdir(hold.role),
194
+ taskName: state.task,
195
+ taskId: state.taskId,
196
+ items: [],
197
+ retried: false,
198
+ bounces: state.bounces[hold.role] ?? 0,
199
+ reason: hold.kind,
200
+ text: hold.text,
201
+ restored: true,
202
+ });
203
+ }
204
+ async clearHold(held, resetBounces) {
205
+ const state = await this.host.state.get(held.taskId);
206
+ if (!state?.hold && !(resetBounces && state?.bounces[held.role]))
207
+ return;
208
+ await this.host.state.update(held.taskId, held.taskName, (current) => {
209
+ current.hold = undefined;
210
+ if (resetBounces)
211
+ current.bounces[held.role] = 0;
212
+ });
213
+ }
214
+ // ---- running a role ----
215
+ async runRole(role) {
216
+ const def = findRole(this.host.pack, role);
217
+ if (!def)
218
+ return;
219
+ const batch = def.receive === "batch" || joinSource(this.host.pack, role) !== undefined;
220
+ const items = await this.host.handoffs.claim(role, { batch });
221
+ const first = items[0];
222
+ if (!first)
223
+ return;
224
+ const order = roleIds(this.host.pack);
225
+ items.sort((a, b) => order.indexOf(a.handoff.from) - order.indexOf(b.handoff.from));
226
+ this.active.set(role, first.handoff.taskId);
227
+ const state = await this.host.state.get(first.handoff.taskId);
228
+ const run = {
229
+ role,
230
+ workdir: this.host.workdir(role),
231
+ taskName: first.handoff.task,
232
+ taskId: first.handoff.taskId,
233
+ items,
234
+ retried: false,
235
+ bounces: state?.bounces[role] ?? 0,
236
+ };
237
+ try {
238
+ const work = items.filter((item) => !item.handoff.nonForwarding);
239
+ const copies = items.filter((item) => item.handoff.nonForwarding);
240
+ const merges = [];
241
+ for (const item of copies)
242
+ await this.mergeCopy(run, item);
243
+ if (work.length === 0) {
244
+ await this.host.handoffs.complete(items);
245
+ return;
246
+ }
247
+ await this.host.updateCard(run.taskId, { lane: role, status: "working" });
248
+ for (const item of work) {
249
+ const commit = item.handoff.commit;
250
+ if (!commit)
251
+ continue;
252
+ const result = await this.host.isolation.merge(run.workdir, commit);
253
+ if (result.status === "error") {
254
+ return await this.hold(run, "blocked", `Merging ${commit} failed: ${result.message ?? "unknown error"}`, false);
255
+ }
256
+ this.activity("merge", run, `Merged ${commit} into ${role}: ${result.status}`);
257
+ merges.push({
258
+ commit,
259
+ status: result.status,
260
+ ...(result.files ? { files: result.files } : {}),
261
+ });
262
+ }
263
+ if (merges.some((merge) => merge.status === "conflict")) {
264
+ await this.host.updateCard(run.taskId, { status: "merging" });
265
+ }
266
+ run.baseline = await this.host.isolation.headCommit(run.workdir);
267
+ const baseline = run.baseline;
268
+ if (!state?.bases[role]) {
269
+ await this.host.state.update(run.taskId, run.taskName, (current) => {
270
+ current.bases[role] = baseline;
271
+ });
272
+ }
273
+ const prompt = [
274
+ rolePrompt({
275
+ project: this.host.name,
276
+ role,
277
+ task: run.taskName,
278
+ taskId: run.taskId,
279
+ items: work,
280
+ merges,
281
+ }),
282
+ ...(state?.answer
283
+ ? ["", resumedAnswerPrompt(state.answer.question, state.answer.text)]
284
+ : []),
285
+ ].join("\n");
286
+ if (state?.answer) {
287
+ await this.host.state.update(run.taskId, run.taskName, (current) => {
288
+ current.answer = undefined;
289
+ });
290
+ }
291
+ await this.converse(run, prompt);
292
+ }
293
+ catch (error) {
294
+ await this.fail(run, error);
295
+ }
296
+ }
297
+ /** A merge-only copy: merge when possible, never leave a conflicted tree behind. */
298
+ async mergeCopy(run, item) {
299
+ const commit = item.handoff.commit;
300
+ if (!commit)
301
+ return;
302
+ const result = await this.host.isolation.merge(run.workdir, commit);
303
+ if (result.status === "conflict")
304
+ await this.host.isolation.abortMerge(run.workdir);
305
+ if (result.status === "conflict" || result.status === "error") {
306
+ this.host.notifier.notify({
307
+ type: "error",
308
+ project: this.host.name,
309
+ message: `Could not merge ${commit} into the ${run.role} working directory`,
310
+ });
311
+ }
312
+ }
313
+ async ask(run, prompt) {
314
+ const agent = findRole(this.host.pack, run.role)?.agent ?? run.role;
315
+ const result = await this.host.runner.run({
316
+ sessionKey: `${this.host.name}/${run.role}`,
317
+ project: this.host.name,
318
+ role: run.role,
319
+ agent,
320
+ workdir: run.workdir,
321
+ prompt,
322
+ });
323
+ await this.record(result.usage);
324
+ if (result.status === "cancelled")
325
+ return { type: "cancelled" };
326
+ if (result.status === "failed") {
327
+ return { type: "rejected", reason: `The run failed: ${result.error ?? "unknown error"}` };
328
+ }
329
+ const parsed = parseEnvelope(result.text, {
330
+ ...(result.turnsExceeded ? { turnsExceeded: true } : {}),
331
+ });
332
+ return parsed.ok
333
+ ? { type: "envelope", envelope: parsed.envelope }
334
+ : { type: "rejected", reason: parsed.reason };
335
+ }
336
+ async record(usage) {
337
+ const meter = this.host.meter;
338
+ if (!usage || !meter)
339
+ return;
340
+ try {
341
+ if (await meter.record(usage.input + usage.output)) {
342
+ const item = budgetAttention(this.host.name, meter, this.host.clock.now().toISOString());
343
+ if (item)
344
+ this.host.notifier.notify({ type: "attention", item });
345
+ }
346
+ }
347
+ catch {
348
+ // The meter only guards cost: a failed write must not fail the run.
349
+ }
350
+ }
351
+ async converse(run, firstPrompt) {
352
+ let prompt = firstPrompt;
353
+ const reject = async (reason) => {
354
+ if (run.retried) {
355
+ await this.hold(run, "blocked", `Output rejected twice: ${reason}`, false);
356
+ return false;
357
+ }
358
+ run.retried = true;
359
+ prompt = rejectionPrompt(reason);
360
+ return true;
361
+ };
362
+ for (;;) {
363
+ const reply = await this.ask(run, prompt);
364
+ if (reply.type === "cancelled")
365
+ return this.requeue(run);
366
+ if (reply.type === "rejected") {
367
+ if (await reject(reply.reason))
368
+ continue;
369
+ return;
370
+ }
371
+ const envelope = reply.envelope;
372
+ if (envelope.kind === "needs_clarification") {
373
+ return this.hold(run, "clarifying", envelope.question, true);
374
+ }
375
+ if (envelope.kind === "blocked") {
376
+ if (this.rejectsBack(run.role))
377
+ return this.rejectBack(run, envelope.reason);
378
+ return this.hold(run, "blocked", envelope.reason, true);
379
+ }
380
+ if (envelope.kind === "note") {
381
+ if (await reject("A note envelope is not accepted here; hand off, ask or report blocked"))
382
+ continue;
383
+ return;
384
+ }
385
+ if (!(await this.host.isolation.hasCommit(run.workdir, envelope.commit))) {
386
+ if (await reject(`Commit ${envelope.commit} does not exist in your working directory`))
387
+ continue;
388
+ return;
389
+ }
390
+ if (!run.audit) {
391
+ run.audit = startAudit(envelope.commit);
392
+ await this.park(run, envelope);
393
+ prompt = auditPrompt(envelope.commit);
394
+ continue;
395
+ }
396
+ const step = nextAudit(run.audit, envelope.commit, this.host.pack.limits.maxAuditRounds);
397
+ if (step.action === "release") {
398
+ const next = await this.afterAudit(run);
399
+ if (next === undefined)
400
+ return;
401
+ prompt = next;
402
+ continue;
403
+ }
404
+ if (step.action === "exhausted") {
405
+ return this.hold(run, "blocked", `No stable commit after ${step.challenges} audit rounds`, false);
406
+ }
407
+ run.audit = step.state;
408
+ await this.park(run, envelope);
409
+ prompt = auditPrompt(envelope.commit);
410
+ }
411
+ }
412
+ // ---- quality gates (AD-6) ----
413
+ async checkGates(run) {
414
+ const gates = this.host.gates;
415
+ const names = this.host.pack.gates[run.role] ?? [];
416
+ if (!gates || names.length === 0)
417
+ return undefined;
418
+ for (const gate of names) {
419
+ const report = await gates.run({
420
+ project: this.host.name,
421
+ task: run.taskName,
422
+ role: run.role,
423
+ gate,
424
+ workdir: run.workdir,
425
+ toolchain: this.host.pack.toolchain,
426
+ thresholds: this.host.pack.thresholds,
427
+ ...(run.baseline ? { since: run.baseline } : {}),
428
+ });
429
+ this.activity("gate", run, `${gate}: ${report.error ? "could not run" : report.passed ? "passed" : "failed"}`);
430
+ if (report.error)
431
+ return { report, infrastructure: true };
432
+ if (!report.passed)
433
+ return { report, infrastructure: false };
434
+ }
435
+ return undefined;
436
+ }
437
+ /**
438
+ * Run the role's gates after an unchanged audit. A failing gate bounces the report back to the
439
+ * same role (a new audit round) until `maxBounces`, then the task is held for a decision.
440
+ * Returns the next prompt to continue the conversation, or `undefined` when the run is over.
441
+ */
442
+ async afterAudit(run) {
443
+ const failure = await this.checkGates(run);
444
+ if (!failure) {
445
+ await this.release(run);
446
+ return undefined;
447
+ }
448
+ const { report } = failure;
449
+ if (failure.infrastructure) {
450
+ if (this.halted) {
451
+ await this.requeue(run);
452
+ return undefined;
453
+ }
454
+ await this.hold(run, "gate-failed", `The ${report.gate} gate could not run: ${report.error ?? "unknown error"}`, true);
455
+ return undefined;
456
+ }
457
+ const findings = report.findings.length > 0 ? report.findings : [`${report.gate} failed`];
458
+ run.bounces += 1;
459
+ const limit = this.host.pack.limits.maxBounces;
460
+ const bounces = run.bounces;
461
+ this.activity("bounce", run, `${report.gate} failed: bounce ${bounces} of ${limit}`);
462
+ await this.host.state.update(run.taskId, run.taskName, (state) => {
463
+ state.bounces[run.role] = bounces;
464
+ });
465
+ if (run.bounces > limit) {
466
+ await this.hold(run, "gate-failed", [
467
+ `The ${report.gate} gate still fails after ${limit} bounce(s):`,
468
+ ...findings.map((f) => `- ${f}`),
469
+ ].join("\n"), true);
470
+ return undefined;
471
+ }
472
+ run.audit = undefined;
473
+ return gateFailurePrompt({ gate: report.gate, findings }, run.bounces, limit);
474
+ }
475
+ // ---- QA rejection routing (spec 8.4) ----
476
+ /** Only the last role (the terminal acceptance role) routes findings back. */
477
+ rejectsBack(role) {
478
+ const ids = roleIds(this.host.pack);
479
+ return ids.length > 1 && ids.at(-1) === role;
480
+ }
481
+ rejectTarget(role, findings) {
482
+ const ids = roleIds(this.host.pack);
483
+ const index = ids.indexOf(role);
484
+ const hint = /^route:\s*([a-z][a-z0-9-]{0,23})\s*$/m.exec(findings)?.[1];
485
+ if (hint && ids.indexOf(hint) >= 0 && ids.indexOf(hint) < index)
486
+ return hint;
487
+ const configured = findRole(this.host.pack, role)?.rejectTo;
488
+ if (configured)
489
+ return configured;
490
+ return ids.includes("coder") && ids.indexOf("coder") < index ? "coder" : ids[0];
491
+ }
492
+ async rejectBack(run, findings) {
493
+ const limit = this.host.pack.limits.maxBounces;
494
+ run.bounces += 1;
495
+ const bounces = run.bounces;
496
+ await this.host.state.update(run.taskId, run.taskName, (state) => {
497
+ state.bounces[run.role] = bounces;
498
+ });
499
+ if (run.bounces > limit) {
500
+ return this.hold(run, "blocked", `${run.role} rejected the work ${run.bounces} times (limit ${limit}). Last findings:\n${findings}`, true);
501
+ }
502
+ const target = this.rejectTarget(run.role, findings);
503
+ const note = {
504
+ id: randomUUID(),
505
+ from: PHANTOM_SENDER,
506
+ to: [target],
507
+ priority: run.items[0]?.handoff.priority ?? 50,
508
+ type: "note",
509
+ task: run.taskName,
510
+ taskId: run.taskId,
511
+ approved: false,
512
+ nonForwarding: false,
513
+ createdAt: this.host.clock.now().toISOString(),
514
+ body: rejectionRouteBody(run.role, findings),
515
+ };
516
+ await this.host.handoffs.inject(note);
517
+ await this.host.handoffs.complete(run.items);
518
+ if (run.parked)
519
+ await this.host.handoffs.discard(run.parked);
520
+ await this.host.updateCard(run.taskId, { lane: target, status: "queued" });
521
+ }
522
+ // ---- parking and delivery ----
523
+ /** Build the outgoing handoff for a verified envelope and park it as `audit_pending`. */
524
+ async park(run, envelope) {
525
+ if (run.parked)
526
+ await this.host.handoffs.discard(run.parked);
527
+ const plan = releasePlan(this.host.pack, run.role);
528
+ const to = plan.terminal && plan.to.length === 0 ? [run.role] : plan.to;
529
+ const handoff = {
530
+ id: randomUUID(),
531
+ from: run.role,
532
+ to,
533
+ priority: run.items[0]?.handoff.priority ?? 50,
534
+ type: "git_handoff",
535
+ task: run.taskName,
536
+ taskId: run.taskId,
537
+ commit: envelope.commit,
538
+ approved: false,
539
+ nonForwarding: plan.terminal,
540
+ createdAt: this.host.clock.now().toISOString(),
541
+ body: handoffBody({
542
+ from: run.role,
543
+ commit: envelope.commit,
544
+ summary: envelope.summary,
545
+ evidence: envelope.evidence,
546
+ nonForwarding: plan.terminal,
547
+ }),
548
+ };
549
+ run.parked = await this.host.handoffs.write(handoff, { kind: "audit_pending", role: run.role });
550
+ this.activity("handoff", run, `Handed off ${envelope.commit} (audit pending)`);
551
+ }
552
+ async release(run) {
553
+ const parked = run.parked;
554
+ const plan = releasePlan(this.host.pack, run.role);
555
+ const audits = (this.host.card(run.taskId)?.auditCount ?? 0) + 1;
556
+ await this.settle(run);
557
+ if (approvalRequiredAfter(this.host.pack, run.role) && !plan.terminal) {
558
+ await this.host.handoffs.move(parked, { kind: "pending_approval", role: run.role });
559
+ this.activity("approval", run, "Waiting for your approval");
560
+ await this.host.handoffs.complete(run.items);
561
+ await this.host.updateCard(run.taskId, {
562
+ lane: run.role,
563
+ status: "waiting_approval",
564
+ auditCount: audits,
565
+ });
566
+ return;
567
+ }
568
+ if (plan.stage) {
569
+ await this.host.handoffs.move(parked, { kind: "join_pending", role: run.role });
570
+ await this.host.handoffs.complete(run.items);
571
+ await this.host.updateCard(run.taskId, {
572
+ lane: run.role,
573
+ status: "working",
574
+ auditCount: audits,
575
+ });
576
+ await this.tryJoin(run.taskId, plan.stage);
577
+ return;
578
+ }
579
+ await this.deliverParked(parked, { items: run.items, auditCount: audits });
580
+ }
581
+ /** A role that finished cleanly forgets its bounces and any stale hold. */
582
+ async settle(run) {
583
+ const state = await this.host.state.get(run.taskId);
584
+ if (!state || (!state.hold && !state.bounces[run.role]))
585
+ return;
586
+ await this.host.state.update(run.taskId, run.taskName, (current) => {
587
+ current.hold = undefined;
588
+ current.bounces[run.role] = 0;
589
+ });
590
+ }
591
+ /**
592
+ * Deliver a parked handoff: outbox, merge-only copies for earlier roles, then the recipients.
593
+ * Used by the audit release, the approval gate and the parallel join.
594
+ */
595
+ async deliverParked(parked, options = {}) {
596
+ const role = parked.location.role;
597
+ const plan = releasePlan(this.host.pack, role);
598
+ const outbox = await this.host.handoffs.move(parked, { kind: "outbox", role }, options.approve ? { approved: true } : {});
599
+ let extra;
600
+ const mergeOnly = route(this.host.pack, role).mergeOnly;
601
+ if (!plan.terminal && mergeOnly.length > 0) {
602
+ extra = await this.host.handoffs.write({
603
+ ...parked.handoff,
604
+ id: randomUUID(),
605
+ to: mergeOnly,
606
+ nonForwarding: true,
607
+ body: handoffBody({
608
+ from: role,
609
+ commit: parked.handoff.commit,
610
+ summary: "Earlier roles merge this work.",
611
+ evidence: [],
612
+ nonForwarding: true,
613
+ }),
614
+ }, { kind: "outbox", role });
615
+ }
616
+ // Complete the inbox items before delivering: the outbox file is durable, so a crash in between
617
+ // is replayed by `recover` instead of re-running the role.
618
+ if (options.items)
619
+ await this.host.handoffs.complete(options.items);
620
+ await this.host.handoffs.deliver(outbox);
621
+ if (extra)
622
+ await this.host.handoffs.deliver(extra);
623
+ const auditPatch = options.auditCount !== undefined ? { auditCount: options.auditCount } : {};
624
+ if (plan.terminal) {
625
+ await this.host.updateCard(parked.handoff.taskId, {
626
+ lane: "done",
627
+ status: "done",
628
+ ...auditPatch,
629
+ });
630
+ this.host.notifier.notify({
631
+ type: "task-done",
632
+ project: this.host.name,
633
+ task: parked.handoff.task,
634
+ });
635
+ }
636
+ else {
637
+ await this.host.updateCard(parked.handoff.taskId, {
638
+ lane: plan.to[0],
639
+ status: "queued",
640
+ ...auditPatch,
641
+ });
642
+ }
643
+ }
644
+ // ---- parallel stage join (spec 4.2, 8.4) ----
645
+ /**
646
+ * Deliver the parked handoffs of a stage once every stage role has handed off, in pack order.
647
+ * The tick lock is held so the next role never claims a half-delivered join.
648
+ */
649
+ tryJoin(taskId, stage) {
650
+ return this.tickLock.run(async () => {
651
+ const { records } = await this.host.handoffs.scan();
652
+ const parked = stage.map((role) => records.find((record) => record.location.kind === "join_pending" &&
653
+ record.location.role === role &&
654
+ record.handoff.taskId === taskId));
655
+ if (parked.some((record) => !record))
656
+ return;
657
+ for (const record of parked)
658
+ await this.deliverParked(record);
659
+ this.wake();
660
+ });
661
+ }
662
+ /** After a restart: complete joins whose stage roles had all handed off before the crash. */
663
+ async resumeJoins() {
664
+ const stages = this.host.pack.parallel ?? [];
665
+ if (stages.length === 0)
666
+ return;
667
+ const { records } = await this.host.handoffs.scan();
668
+ for (const stage of stages) {
669
+ const tasks = new Set(records
670
+ .filter((r) => r.location.kind === "join_pending" && stage.includes(r.location.role))
671
+ .map((r) => r.handoff.taskId));
672
+ for (const taskId of tasks)
673
+ await this.tryJoin(taskId, stage);
674
+ }
675
+ }
676
+ // ---- holds ----
677
+ /** Hold the role (it keeps its in-process items) and ask the operator. */
678
+ async hold(run, reason, text, persist) {
679
+ this.holds.set(run.role, { ...run, reason, text });
680
+ if (persist) {
681
+ await this.host.state.update(run.taskId, run.taskName, (state) => {
682
+ state.hold = {
683
+ kind: reason,
684
+ role: run.role,
685
+ text: text.slice(0, MAX_HOLD_TEXT),
686
+ createdAt: this.host.clock.now().toISOString(),
687
+ };
688
+ });
689
+ }
690
+ await this.host.updateCard(run.taskId, { status: statusFor(reason) });
691
+ }
692
+ /** A cancelled run puts the work back and stops the pump (project closing). */
693
+ async requeue(run) {
694
+ this.halted = true;
695
+ if (run.parked)
696
+ await this.host.handoffs.discard(run.parked);
697
+ for (const item of run.items) {
698
+ await this.host.handoffs.move(item, { kind: "inbox", role: run.role, box: "new" });
699
+ }
700
+ await this.host.updateCard(run.taskId, { lane: run.role, status: "queued" });
701
+ }
702
+ async fail(run, error) {
703
+ this.host.notifier.notify({ type: "error", project: this.host.name, message: message(error) });
704
+ await this.hold(run, "blocked", message(error), false).catch(noop);
705
+ }
706
+ }
707
+ //# sourceMappingURL=pump.js.map