@mclawnet/swarm 0.1.13 → 0.1.14

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 (320) hide show
  1. package/dist/__tests__/always-on-activity-reader.test.d.ts +2 -0
  2. package/dist/__tests__/always-on-activity-reader.test.d.ts.map +1 -0
  3. package/dist/__tests__/always-on-activity-reader.test.js +193 -0
  4. package/dist/__tests__/always-on-activity-reader.test.js.map +1 -0
  5. package/dist/__tests__/always-on-config.test.d.ts +2 -0
  6. package/dist/__tests__/always-on-config.test.d.ts.map +1 -0
  7. package/dist/__tests__/always-on-config.test.js +285 -0
  8. package/dist/__tests__/always-on-config.test.js.map +1 -0
  9. package/dist/__tests__/always-on-manager.test.d.ts +2 -0
  10. package/dist/__tests__/always-on-manager.test.d.ts.map +1 -0
  11. package/dist/__tests__/always-on-manager.test.js +797 -0
  12. package/dist/__tests__/always-on-manager.test.js.map +1 -0
  13. package/dist/__tests__/always-on-parity.test.d.ts +2 -0
  14. package/dist/__tests__/always-on-parity.test.d.ts.map +1 -0
  15. package/dist/__tests__/always-on-parity.test.js +20 -0
  16. package/dist/__tests__/always-on-parity.test.js.map +1 -0
  17. package/dist/__tests__/cascade-picker.test.d.ts +2 -0
  18. package/dist/__tests__/cascade-picker.test.d.ts.map +1 -0
  19. package/dist/__tests__/cascade-picker.test.js +122 -0
  20. package/dist/__tests__/cascade-picker.test.js.map +1 -0
  21. package/dist/__tests__/coordinator-shipment.test.d.ts +2 -0
  22. package/dist/__tests__/coordinator-shipment.test.d.ts.map +1 -0
  23. package/dist/__tests__/coordinator-shipment.test.js +280 -0
  24. package/dist/__tests__/coordinator-shipment.test.js.map +1 -0
  25. package/dist/__tests__/coordinator-workspace-recover.test.d.ts +2 -0
  26. package/dist/__tests__/coordinator-workspace-recover.test.d.ts.map +1 -0
  27. package/dist/__tests__/coordinator-workspace-recover.test.js +140 -0
  28. package/dist/__tests__/coordinator-workspace-recover.test.js.map +1 -0
  29. package/dist/__tests__/coordinator-workspace.test.d.ts +2 -0
  30. package/dist/__tests__/coordinator-workspace.test.d.ts.map +1 -0
  31. package/dist/__tests__/coordinator-workspace.test.js +135 -0
  32. package/dist/__tests__/coordinator-workspace.test.js.map +1 -0
  33. package/dist/__tests__/default-runner-epipe.test.d.ts +2 -0
  34. package/dist/__tests__/default-runner-epipe.test.d.ts.map +1 -0
  35. package/dist/__tests__/default-runner-epipe.test.js +43 -0
  36. package/dist/__tests__/default-runner-epipe.test.js.map +1 -0
  37. package/dist/__tests__/discovery-scheduler.test.d.ts +2 -0
  38. package/dist/__tests__/discovery-scheduler.test.d.ts.map +1 -0
  39. package/dist/__tests__/discovery-scheduler.test.js +367 -0
  40. package/dist/__tests__/discovery-scheduler.test.js.map +1 -0
  41. package/dist/__tests__/env-forward-e2e.test.d.ts +2 -0
  42. package/dist/__tests__/env-forward-e2e.test.d.ts.map +1 -0
  43. package/dist/__tests__/env-forward-e2e.test.js +57 -0
  44. package/dist/__tests__/env-forward-e2e.test.js.map +1 -0
  45. package/dist/__tests__/gh-pr-creator.test.d.ts +2 -0
  46. package/dist/__tests__/gh-pr-creator.test.d.ts.map +1 -0
  47. package/dist/__tests__/gh-pr-creator.test.js +107 -0
  48. package/dist/__tests__/gh-pr-creator.test.js.map +1 -0
  49. package/dist/__tests__/git-worktree-provider.test.d.ts +2 -0
  50. package/dist/__tests__/git-worktree-provider.test.d.ts.map +1 -0
  51. package/dist/__tests__/git-worktree-provider.test.js +98 -0
  52. package/dist/__tests__/git-worktree-provider.test.js.map +1 -0
  53. package/dist/__tests__/gitignore-check.test.d.ts +2 -0
  54. package/dist/__tests__/gitignore-check.test.d.ts.map +1 -0
  55. package/dist/__tests__/gitignore-check.test.js +39 -0
  56. package/dist/__tests__/gitignore-check.test.js.map +1 -0
  57. package/dist/__tests__/idea-research-source.test.d.ts +2 -0
  58. package/dist/__tests__/idea-research-source.test.d.ts.map +1 -0
  59. package/dist/__tests__/idea-research-source.test.js +425 -0
  60. package/dist/__tests__/idea-research-source.test.js.map +1 -0
  61. package/dist/__tests__/idea-todo-source.test.d.ts +2 -0
  62. package/dist/__tests__/idea-todo-source.test.d.ts.map +1 -0
  63. package/dist/__tests__/idea-todo-source.test.js +258 -0
  64. package/dist/__tests__/idea-todo-source.test.js.map +1 -0
  65. package/dist/__tests__/introspection-dedupe.test.d.ts +2 -0
  66. package/dist/__tests__/introspection-dedupe.test.d.ts.map +1 -0
  67. package/dist/__tests__/introspection-dedupe.test.js +484 -0
  68. package/dist/__tests__/introspection-dedupe.test.js.map +1 -0
  69. package/dist/__tests__/introspection-source.test.d.ts +2 -0
  70. package/dist/__tests__/introspection-source.test.d.ts.map +1 -0
  71. package/dist/__tests__/introspection-source.test.js +1051 -0
  72. package/dist/__tests__/introspection-source.test.js.map +1 -0
  73. package/dist/__tests__/migration-roles.test.js +1 -22
  74. package/dist/__tests__/migration-roles.test.js.map +1 -1
  75. package/dist/__tests__/reconcile-researching.test.d.ts +2 -0
  76. package/dist/__tests__/reconcile-researching.test.d.ts.map +1 -0
  77. package/dist/__tests__/reconcile-researching.test.js +224 -0
  78. package/dist/__tests__/reconcile-researching.test.js.map +1 -0
  79. package/dist/__tests__/role-loader-preamble-all.test.js +3 -1
  80. package/dist/__tests__/role-loader-preamble-all.test.js.map +1 -1
  81. package/dist/__tests__/role-loader.test.js +95 -0
  82. package/dist/__tests__/role-loader.test.js.map +1 -1
  83. package/dist/__tests__/role-prompt-no-legacy-protocol.test.js +3 -1
  84. package/dist/__tests__/role-prompt-no-legacy-protocol.test.js.map +1 -1
  85. package/dist/__tests__/secret-scrub.test.d.ts +2 -0
  86. package/dist/__tests__/secret-scrub.test.d.ts.map +1 -0
  87. package/dist/__tests__/secret-scrub.test.js +55 -0
  88. package/dist/__tests__/secret-scrub.test.js.map +1 -0
  89. package/dist/__tests__/shipment-actions.test.d.ts +2 -0
  90. package/dist/__tests__/shipment-actions.test.d.ts.map +1 -0
  91. package/dist/__tests__/shipment-actions.test.js +378 -0
  92. package/dist/__tests__/shipment-actions.test.js.map +1 -0
  93. package/dist/__tests__/shipment-persistence.test.d.ts +2 -0
  94. package/dist/__tests__/shipment-persistence.test.d.ts.map +1 -0
  95. package/dist/__tests__/shipment-persistence.test.js +120 -0
  96. package/dist/__tests__/shipment-persistence.test.js.map +1 -0
  97. package/dist/__tests__/shipment-pipeline.test.d.ts +2 -0
  98. package/dist/__tests__/shipment-pipeline.test.d.ts.map +1 -0
  99. package/dist/__tests__/shipment-pipeline.test.js +392 -0
  100. package/dist/__tests__/shipment-pipeline.test.js.map +1 -0
  101. package/dist/__tests__/shipment-report.test.d.ts +2 -0
  102. package/dist/__tests__/shipment-report.test.d.ts.map +1 -0
  103. package/dist/__tests__/shipment-report.test.js +78 -0
  104. package/dist/__tests__/shipment-report.test.js.map +1 -0
  105. package/dist/__tests__/shipment-stdin-integration.test.d.ts +2 -0
  106. package/dist/__tests__/shipment-stdin-integration.test.d.ts.map +1 -0
  107. package/dist/__tests__/shipment-stdin-integration.test.js +49 -0
  108. package/dist/__tests__/shipment-stdin-integration.test.js.map +1 -0
  109. package/dist/__tests__/shipment-type-parity.test.d.ts +2 -0
  110. package/dist/__tests__/shipment-type-parity.test.d.ts.map +1 -0
  111. package/dist/__tests__/shipment-type-parity.test.js +10 -0
  112. package/dist/__tests__/shipment-type-parity.test.js.map +1 -0
  113. package/dist/__tests__/snapshot-copy-provider.test.d.ts +2 -0
  114. package/dist/__tests__/snapshot-copy-provider.test.d.ts.map +1 -0
  115. package/dist/__tests__/snapshot-copy-provider.test.js +88 -0
  116. package/dist/__tests__/snapshot-copy-provider.test.js.map +1 -0
  117. package/dist/__tests__/swarm-coordinator-backend.test.js +153 -0
  118. package/dist/__tests__/swarm-coordinator-backend.test.js.map +1 -1
  119. package/dist/__tests__/swarm-coordinator-complete-intercept.test.d.ts +2 -0
  120. package/dist/__tests__/swarm-coordinator-complete-intercept.test.d.ts.map +1 -0
  121. package/dist/__tests__/swarm-coordinator-complete-intercept.test.js +111 -0
  122. package/dist/__tests__/swarm-coordinator-complete-intercept.test.js.map +1 -0
  123. package/dist/__tests__/task-store-source.test.d.ts +2 -0
  124. package/dist/__tests__/task-store-source.test.d.ts.map +1 -0
  125. package/dist/__tests__/task-store-source.test.js +56 -0
  126. package/dist/__tests__/task-store-source.test.js.map +1 -0
  127. package/dist/__tests__/transport-detect.test.d.ts +2 -0
  128. package/dist/__tests__/transport-detect.test.d.ts.map +1 -0
  129. package/dist/__tests__/transport-detect.test.js +92 -0
  130. package/dist/__tests__/transport-detect.test.js.map +1 -0
  131. package/dist/__tests__/workcycle-runner-cascade.test.d.ts +2 -0
  132. package/dist/__tests__/workcycle-runner-cascade.test.d.ts.map +1 -0
  133. package/dist/__tests__/workcycle-runner-cascade.test.js +203 -0
  134. package/dist/__tests__/workcycle-runner-cascade.test.js.map +1 -0
  135. package/dist/__tests__/workcycle-runner.test.d.ts +2 -0
  136. package/dist/__tests__/workcycle-runner.test.d.ts.map +1 -0
  137. package/dist/__tests__/workcycle-runner.test.js +369 -0
  138. package/dist/__tests__/workcycle-runner.test.js.map +1 -0
  139. package/dist/__tests__/workspace-diff.test.d.ts +2 -0
  140. package/dist/__tests__/workspace-diff.test.d.ts.map +1 -0
  141. package/dist/__tests__/workspace-diff.test.js +62 -0
  142. package/dist/__tests__/workspace-diff.test.js.map +1 -0
  143. package/dist/__tests__/workspace-manager.test.d.ts +2 -0
  144. package/dist/__tests__/workspace-manager.test.d.ts.map +1 -0
  145. package/dist/__tests__/workspace-manager.test.js +120 -0
  146. package/dist/__tests__/workspace-manager.test.js.map +1 -0
  147. package/dist/__tests__/workspace-types.test.d.ts +2 -0
  148. package/dist/__tests__/workspace-types.test.d.ts.map +1 -0
  149. package/dist/__tests__/workspace-types.test.js +37 -0
  150. package/dist/__tests__/workspace-types.test.js.map +1 -0
  151. package/dist/__tests__/worktree-gc.test.d.ts +2 -0
  152. package/dist/__tests__/worktree-gc.test.d.ts.map +1 -0
  153. package/dist/__tests__/worktree-gc.test.js +183 -0
  154. package/dist/__tests__/worktree-gc.test.js.map +1 -0
  155. package/dist/always-on/activity-reader.d.ts +27 -0
  156. package/dist/always-on/activity-reader.d.ts.map +1 -0
  157. package/dist/always-on/activity-reader.js +95 -0
  158. package/dist/always-on/activity-reader.js.map +1 -0
  159. package/dist/always-on/always-on-manager.d.ts +170 -0
  160. package/dist/always-on/always-on-manager.d.ts.map +1 -0
  161. package/dist/always-on/always-on-manager.js +538 -0
  162. package/dist/always-on/always-on-manager.js.map +1 -0
  163. package/dist/always-on/config.d.ts +141 -0
  164. package/dist/always-on/config.d.ts.map +1 -0
  165. package/dist/always-on/config.js +324 -0
  166. package/dist/always-on/config.js.map +1 -0
  167. package/dist/always-on/discovery-scheduler.d.ts +60 -0
  168. package/dist/always-on/discovery-scheduler.d.ts.map +1 -0
  169. package/dist/always-on/discovery-scheduler.js +287 -0
  170. package/dist/always-on/discovery-scheduler.js.map +1 -0
  171. package/dist/always-on/ideas-client.d.ts +23 -0
  172. package/dist/always-on/ideas-client.d.ts.map +1 -0
  173. package/dist/always-on/ideas-client.js +13 -0
  174. package/dist/always-on/ideas-client.js.map +1 -0
  175. package/dist/always-on/reconcile-researching.d.ts +42 -0
  176. package/dist/always-on/reconcile-researching.d.ts.map +1 -0
  177. package/dist/always-on/reconcile-researching.js +133 -0
  178. package/dist/always-on/reconcile-researching.js.map +1 -0
  179. package/dist/always-on/task-sources/cascade-picker.d.ts +42 -0
  180. package/dist/always-on/task-sources/cascade-picker.d.ts.map +1 -0
  181. package/dist/always-on/task-sources/cascade-picker.js +65 -0
  182. package/dist/always-on/task-sources/cascade-picker.js.map +1 -0
  183. package/dist/always-on/task-sources/idea-dedupe.d.ts +62 -0
  184. package/dist/always-on/task-sources/idea-dedupe.d.ts.map +1 -0
  185. package/dist/always-on/task-sources/idea-dedupe.js +130 -0
  186. package/dist/always-on/task-sources/idea-dedupe.js.map +1 -0
  187. package/dist/always-on/task-sources/idea-research-source.d.ts +46 -0
  188. package/dist/always-on/task-sources/idea-research-source.d.ts.map +1 -0
  189. package/dist/always-on/task-sources/idea-research-source.js +308 -0
  190. package/dist/always-on/task-sources/idea-research-source.js.map +1 -0
  191. package/dist/always-on/task-sources/idea-sort.d.ts +3 -0
  192. package/dist/always-on/task-sources/idea-sort.d.ts.map +1 -0
  193. package/dist/always-on/task-sources/idea-sort.js +25 -0
  194. package/dist/always-on/task-sources/idea-sort.js.map +1 -0
  195. package/dist/always-on/task-sources/idea-todo-source.d.ts +48 -0
  196. package/dist/always-on/task-sources/idea-todo-source.d.ts.map +1 -0
  197. package/dist/always-on/task-sources/idea-todo-source.js +226 -0
  198. package/dist/always-on/task-sources/idea-todo-source.js.map +1 -0
  199. package/dist/always-on/task-sources/introspection-source.d.ts +101 -0
  200. package/dist/always-on/task-sources/introspection-source.d.ts.map +1 -0
  201. package/dist/always-on/task-sources/introspection-source.js +695 -0
  202. package/dist/always-on/task-sources/introspection-source.js.map +1 -0
  203. package/dist/always-on/task-sources/task-store-source.d.ts +15 -0
  204. package/dist/always-on/task-sources/task-store-source.d.ts.map +1 -0
  205. package/dist/always-on/task-sources/task-store-source.js +59 -0
  206. package/dist/always-on/task-sources/task-store-source.js.map +1 -0
  207. package/dist/always-on/task-sources/types.d.ts +108 -0
  208. package/dist/always-on/task-sources/types.d.ts.map +1 -0
  209. package/dist/always-on/task-sources/types.js +13 -0
  210. package/dist/always-on/task-sources/types.js.map +1 -0
  211. package/dist/always-on/types.d.ts +76 -0
  212. package/dist/always-on/types.d.ts.map +1 -0
  213. package/dist/always-on/types.js +17 -0
  214. package/dist/always-on/types.js.map +1 -0
  215. package/dist/always-on/workcycle-runner.d.ts +115 -0
  216. package/dist/always-on/workcycle-runner.d.ts.map +1 -0
  217. package/dist/always-on/workcycle-runner.js +285 -0
  218. package/dist/always-on/workcycle-runner.js.map +1 -0
  219. package/dist/always-on/worktree-gc.d.ts +41 -0
  220. package/dist/always-on/worktree-gc.d.ts.map +1 -0
  221. package/dist/always-on/worktree-gc.js +167 -0
  222. package/dist/always-on/worktree-gc.js.map +1 -0
  223. package/dist/index.d.ts +26 -2
  224. package/dist/index.d.ts.map +1 -1
  225. package/dist/index.js +24 -1
  226. package/dist/index.js.map +1 -1
  227. package/dist/persistence.d.ts +37 -1
  228. package/dist/persistence.d.ts.map +1 -1
  229. package/dist/persistence.js +48 -0
  230. package/dist/persistence.js.map +1 -1
  231. package/dist/retrospective.d.ts.map +1 -1
  232. package/dist/retrospective.js +6 -0
  233. package/dist/retrospective.js.map +1 -1
  234. package/dist/roles/role-loader.d.ts +1 -1
  235. package/dist/roles/role-loader.d.ts.map +1 -1
  236. package/dist/roles/role-loader.js +18 -0
  237. package/dist/roles/role-loader.js.map +1 -1
  238. package/dist/roles/types.d.ts +12 -0
  239. package/dist/roles/types.d.ts.map +1 -1
  240. package/dist/shipment/gh-pr-creator.d.ts +28 -0
  241. package/dist/shipment/gh-pr-creator.d.ts.map +1 -0
  242. package/dist/shipment/gh-pr-creator.js +80 -0
  243. package/dist/shipment/gh-pr-creator.js.map +1 -0
  244. package/dist/shipment/report.d.ts +27 -0
  245. package/dist/shipment/report.d.ts.map +1 -0
  246. package/dist/shipment/report.js +41 -0
  247. package/dist/shipment/report.js.map +1 -0
  248. package/dist/shipment/secret-scrub.d.ts +12 -0
  249. package/dist/shipment/secret-scrub.d.ts.map +1 -0
  250. package/dist/shipment/secret-scrub.js +30 -0
  251. package/dist/shipment/secret-scrub.js.map +1 -0
  252. package/dist/shipment/shipment-actions.d.ts +85 -0
  253. package/dist/shipment/shipment-actions.d.ts.map +1 -0
  254. package/dist/shipment/shipment-actions.js +190 -0
  255. package/dist/shipment/shipment-actions.js.map +1 -0
  256. package/dist/shipment/shipment-pipeline.d.ts +48 -0
  257. package/dist/shipment/shipment-pipeline.d.ts.map +1 -0
  258. package/dist/shipment/shipment-pipeline.js +256 -0
  259. package/dist/shipment/shipment-pipeline.js.map +1 -0
  260. package/dist/shipment/transport-detect.d.ts +16 -0
  261. package/dist/shipment/transport-detect.d.ts.map +1 -0
  262. package/dist/shipment/transport-detect.js +54 -0
  263. package/dist/shipment/transport-detect.js.map +1 -0
  264. package/dist/shipment/workspace-diff.d.ts +39 -0
  265. package/dist/shipment/workspace-diff.d.ts.map +1 -0
  266. package/dist/shipment/workspace-diff.js +64 -0
  267. package/dist/shipment/workspace-diff.js.map +1 -0
  268. package/dist/swarm-coordinator.d.ts +20 -1
  269. package/dist/swarm-coordinator.d.ts.map +1 -1
  270. package/dist/swarm-coordinator.js +193 -10
  271. package/dist/swarm-coordinator.js.map +1 -1
  272. package/dist/types.d.ts +62 -0
  273. package/dist/types.d.ts.map +1 -1
  274. package/dist/workspace/git-worktree-provider.d.ts +11 -0
  275. package/dist/workspace/git-worktree-provider.d.ts.map +1 -0
  276. package/dist/workspace/git-worktree-provider.js +123 -0
  277. package/dist/workspace/git-worktree-provider.js.map +1 -0
  278. package/dist/workspace/gitignore-check.d.ts +10 -0
  279. package/dist/workspace/gitignore-check.d.ts.map +1 -0
  280. package/dist/workspace/gitignore-check.js +25 -0
  281. package/dist/workspace/gitignore-check.js.map +1 -0
  282. package/dist/workspace/index.d.ts +5 -0
  283. package/dist/workspace/index.d.ts.map +1 -0
  284. package/dist/workspace/index.js +5 -0
  285. package/dist/workspace/index.js.map +1 -0
  286. package/dist/workspace/snapshot-copy-provider.d.ts +11 -0
  287. package/dist/workspace/snapshot-copy-provider.d.ts.map +1 -0
  288. package/dist/workspace/snapshot-copy-provider.js +66 -0
  289. package/dist/workspace/snapshot-copy-provider.js.map +1 -0
  290. package/dist/workspace/types.d.ts +36 -0
  291. package/dist/workspace/types.d.ts.map +1 -0
  292. package/dist/workspace/types.js +2 -0
  293. package/dist/workspace/types.js.map +1 -0
  294. package/dist/workspace/workspace-manager.d.ts +30 -0
  295. package/dist/workspace/workspace-manager.d.ts.map +1 -0
  296. package/dist/workspace/workspace-manager.js +104 -0
  297. package/dist/workspace/workspace-manager.js.map +1 -0
  298. package/package.json +4 -4
  299. package/roles/queen.md +1 -0
  300. package/templates/introspection.md +64 -0
  301. package/templates/research-only.md +58 -0
  302. package/roles/preset-analyst-simons.md +0 -39
  303. package/roles/preset-architect-knuth.md +0 -39
  304. package/roles/preset-designer-norman.md +0 -39
  305. package/roles/preset-designer.md +0 -39
  306. package/roles/preset-dev-carmack.md +0 -39
  307. package/roles/preset-dev-gosling.md +0 -39
  308. package/roles/preset-developer.md +0 -52
  309. package/roles/preset-manager-grove.md +0 -39
  310. package/roles/preset-manager-musk.md +0 -39
  311. package/roles/preset-pm.md +0 -78
  312. package/roles/preset-researcher-feynman.md +0 -39
  313. package/roles/preset-reviewer.md +0 -46
  314. package/roles/preset-strategist-buffett.md +0 -39
  315. package/roles/preset-strategist-munger.md +0 -39
  316. package/roles/preset-strategist-sunzi.md +0 -39
  317. package/roles/preset-tester-beck.md +0 -40
  318. package/roles/preset-tester.md +0 -47
  319. package/roles/preset-writer-orwell.md +0 -39
  320. package/roles/preset-writer.md +0 -39
@@ -0,0 +1,695 @@
1
+ // M8.2 — IntrospectionSource: L3 fallback in the task-source cascade.
2
+ //
3
+ // Unlike L1/L2a/L2b, this source NEVER returns an executable TaskRef. Its
4
+ // `pick(projectRoot)` side-effectfully drives the agent's self-introspection
5
+ // loop and always returns null — the cascade short-circuits, the cycle "does
6
+ // nothing" from the runner's perspective, and the user wakes up to new
7
+ // auto-discovered idea suggestions on the next visit.
8
+ //
9
+ // M8 hotfix I1+I2 — fire-and-forget. Previously pick() spawned the swarm
10
+ // and awaited it inline (up to 10 min), which:
11
+ // (a) blocked the always-on scheduler from firing anything else for the
12
+ // entire wait window (I2), and
13
+ // (b) burned a dailyBudget tick on a cycle that produced no in-cycle work
14
+ // (I1, because runner returned status="skipped" but ticksToday still
15
+ // incremented).
16
+ // The fix is a two-phase handshake:
17
+ // 1. spawn cycle — pick() creates the swarm, persists
18
+ // `pendingIntrospectionSwarmId` + bumps `lastIntrospectionAt`, returns
19
+ // null without awaiting the swarm. The scheduler is free to fire other
20
+ // sources on the next tick.
21
+ // 2. ingest cycle (next pick after terminal) — pick() observes the
22
+ // pending swarm has finished, reads the output file, POSTs the cohort
23
+ // to /api/ideas, clears the pending breadcrumb, and returns null. The
24
+ // ingest never blocks; if the swarm is still running, we just return
25
+ // null again and try the next tick.
26
+ // An agent restart between spawn and ingest is safe: the pending swarmId is
27
+ // persisted to always-on.json and the recovery.json on disk lets the
28
+ // SwarmCoordinator re-attach.
29
+ //
30
+ // Guard-rails (per design doc D4):
31
+ // - Opt-in (default off). `config.taskSources?.introspection === true`.
32
+ // - Independent 24h cooldown via `introspectionCooldownHours`.
33
+ // - Self-suppression: if the previous introspection's ideas were all
34
+ // archived by the user, skip this one (`lastIntrospectionRejected`).
35
+ // - Cap at 3 ideas/cycle regardless of how many the queen produced.
36
+ // - Priority is always forced to "low" — agent suggestions never compete
37
+ // with user-written ideas for top slots (Q4=A enforcement).
38
+ //
39
+ // Output format (mirrors the introspection.md template):
40
+ // The queen writes a JSON array to a path provided in the task prompt:
41
+ // <projectRoot>/.clawnet/introspection/<swarmId>.json
42
+ // The ingest phase reads + parses + POSTs the file. Failure modes (no
43
+ // file / malformed JSON / empty array) clear the pending breadcrumb and
44
+ // move on — the cooldown gates the next spawn so a broken swarm can't
45
+ // spin in a tight loop.
46
+ import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
47
+ import { randomUUID } from "node:crypto";
48
+ import { dirname, join } from "node:path";
49
+ import { encodeCwd } from "@mclawnet/shared";
50
+ import { DEFAULT_INTROSPECTION_COOLDOWN_HOURS, resolveTaskSources, } from "../config.js";
51
+ import { appendToIndex, buildDedupeIndex, findDuplicate, } from "./idea-dedupe.js";
52
+ export const INTROSPECTION_TEMPLATE_NAME = "introspection";
53
+ export const INTROSPECTION_SOURCE_TAG = "auto-discovered";
54
+ export const INTROSPECTION_SOURCE_ROLE = "queen-introspection";
55
+ /** Hard cap per design doc D4 — no more than 3 ideas per introspection. */
56
+ export const INTROSPECTION_MAX_IDEAS = 3;
57
+ /**
58
+ * M8.2c — write-race guard for the spawner-owned completion signal. The
59
+ * queen's `cat > <output> <<'EOF' ... EOF` lands as several syscalls; we
60
+ * require N ms of mtime stillness before declaring "queen finished writing".
61
+ */
62
+ export const SPAWNER_COMPLETE_MTIME_GUARD_MS = 5_000;
63
+ /**
64
+ * M8.2c — absolute spawn-to-ingest cap. Even if the output file never lands
65
+ * (queen crashed / token exhausted / coordinator lost the swarm), the
66
+ * spawner stops waiting after this. `processPendingSwarm` then takes the
67
+ * "missing output" path: clears the pending breadcrumb and the next cycle
68
+ * (subject to cooldown) gets to retry. Set high enough that a normal
69
+ * introspection cycle (~30s of read+grep+write) is never near the cap.
70
+ */
71
+ export const SPAWNER_COMPLETE_HARD_TIMEOUT_MS = 2 * 60 * 60 * 1000;
72
+ /**
73
+ * Parse the queen's output file into a candidate array. Accepts either:
74
+ * (a) a clean JSON file whose root is an array, or
75
+ * (b) a file that contains other text and ends with a JSON array — we
76
+ * scan for the *last* `[` and try JSON.parse from there.
77
+ *
78
+ * Returns [] on any failure. Callers treat [] as "queen produced nothing
79
+ * useful" — they still bump lastIntrospectionAt so cooldown holds.
80
+ */
81
+ export function parseCandidatesOutput(raw) {
82
+ if (!raw || raw.trim().length === 0)
83
+ return [];
84
+ // Strategy 1: parse the entire file.
85
+ try {
86
+ const parsed = JSON.parse(raw);
87
+ if (Array.isArray(parsed))
88
+ return coerceCandidates(parsed);
89
+ }
90
+ catch {
91
+ // fall through to strategy 2
92
+ }
93
+ // Strategy 2: locate the last `[` and parse a balanced slice from there.
94
+ const lastOpen = raw.lastIndexOf("[");
95
+ if (lastOpen < 0)
96
+ return [];
97
+ // Find a matching close. Walk forward counting brackets; bail on first
98
+ // balanced match. This is good enough for queen-produced JSON which is
99
+ // never going to have stringly-embedded brackets at our cap of 3 items.
100
+ let depth = 0;
101
+ let end = -1;
102
+ let inString = false;
103
+ let escape = false;
104
+ for (let i = lastOpen; i < raw.length; i++) {
105
+ const ch = raw[i];
106
+ if (inString) {
107
+ if (escape) {
108
+ escape = false;
109
+ }
110
+ else if (ch === "\\") {
111
+ escape = true;
112
+ }
113
+ else if (ch === '"') {
114
+ inString = false;
115
+ }
116
+ continue;
117
+ }
118
+ if (ch === '"') {
119
+ inString = true;
120
+ continue;
121
+ }
122
+ if (ch === "[")
123
+ depth++;
124
+ else if (ch === "]") {
125
+ depth--;
126
+ if (depth === 0) {
127
+ end = i;
128
+ break;
129
+ }
130
+ }
131
+ }
132
+ if (end < 0)
133
+ return [];
134
+ const slice = raw.slice(lastOpen, end + 1);
135
+ try {
136
+ const parsed = JSON.parse(slice);
137
+ if (Array.isArray(parsed))
138
+ return coerceCandidates(parsed);
139
+ }
140
+ catch {
141
+ /* fall through */
142
+ }
143
+ return [];
144
+ }
145
+ function coerceCandidates(arr) {
146
+ const out = [];
147
+ for (const raw of arr) {
148
+ if (!raw || typeof raw !== "object")
149
+ continue;
150
+ const r = raw;
151
+ const title = typeof r.title === "string" ? r.title.trim() : "";
152
+ if (!title)
153
+ continue;
154
+ const body = typeof r.body === "string" ? r.body : "";
155
+ const priority = typeof r.priority === "string" ? r.priority : undefined;
156
+ out.push({ title, body, ...(priority ? { priority } : {}) });
157
+ }
158
+ return out;
159
+ }
160
+ /**
161
+ * Build the task prompt handed to the introspection swarm. Embeds a
162
+ * workspace-RELATIVE output path so the queen writes inside whatever cwd
163
+ * her role process is given (workspace.cwd when worktree isolation is on,
164
+ * or projectRoot when it falls through to no workspace). Spawner reads it
165
+ * back by resolving the same relative path against the actual workspace
166
+ * cwd at drain time.
167
+ *
168
+ * Pre-B4: this used to embed `<projectRoot>/.clawnet/introspection/<id>.json`
169
+ * absolute and the queen would `cat >` straight to projectRoot, bypassing
170
+ * the worktree. PR-B Suspect B fix.
171
+ */
172
+ export function buildIntrospectionTaskPrompt(projectRoot, relativeOutputPath) {
173
+ return [
174
+ `You are running an introspection cycle on the project rooted at: ${projectRoot}`,
175
+ "",
176
+ "Goal: produce 1-3 NEW idea candidates that aren't already tracked.",
177
+ "Tools you may use: Read, Grep, Bash (read-only: ls / cat / git log / git status / git diff --stat / git show).",
178
+ "Tools you must NOT use: Write, Edit, NotebookEdit, or any state-mutating command (git apply, git commit, package install, etc.).",
179
+ "",
180
+ `Output: write a JSON array to this path RELATIVE to your current working directory:`,
181
+ ` ${relativeOutputPath}`,
182
+ "(your cwd is the swarm workspace — write the file there, do NOT use an absolute path)",
183
+ "",
184
+ "Schema of each array element:",
185
+ ' { "title": "short title", "body": "why it matters + scope", "priority": "low" }',
186
+ "",
187
+ "Hard limits: at most 3 items. Priority is overridden to 'low' downstream — you can omit it.",
188
+ "When you have written the file, call `swarm_complete` to end the cycle. The spawner will pick up the candidates next tick and release coordinator resources immediately. If you skip the call, the spawner-owned fallback (output-file detection or paused-state detection) will reap the cycle eventually, but explicit completion is faster and avoids holding the backend session.",
189
+ ].join("\n");
190
+ }
191
+ /** Internal: returns true when the swarm is in a terminal state (or gone from registry). */
192
+ function isTerminalSwarm(coord, swarmId) {
193
+ const swarm = coord.getSwarm(swarmId);
194
+ if (!swarm) {
195
+ // Gone from registry — could mean cleanup already ran, or the agent
196
+ // restarted and we didn't re-recover this swarm. Treat as "unknown
197
+ // terminal" so the ingest phase still tries to read the output file
198
+ // (it's the source of truth) and clears the pending breadcrumb.
199
+ return { terminal: true, status: "unknown" };
200
+ }
201
+ if (swarm.status === "completed")
202
+ return { terminal: true, status: "completed" };
203
+ if (swarm.status === "failed")
204
+ return { terminal: true, status: "failed" };
205
+ if (swarm.status === "cancelled")
206
+ return { terminal: true, status: "cancelled" };
207
+ return { terminal: false, status: "running" };
208
+ }
209
+ export function createIntrospectionSource(opts) {
210
+ const { ideasClient, swarmCoordinator, getConfig, updateConfig, introspectionTemplateName = INTROSPECTION_TEMPLATE_NAME, clock = () => new Date(), warn, info, generateSwarmId = () => `m8-introspect-${randomUUID()}`, resolveOutputPath = (projectRoot, swarmId) => join(projectRoot, ".clawnet", "introspection", `${swarmId}.json`), } = opts;
211
+ /**
212
+ * PR-B Suspect B fix — workspace-aware output path resolver.
213
+ * Prefer the swarm's actual workspace.cwd (set when WorkspaceManager
214
+ * prepared a worktree / snapshot copy) so we read where the queen
215
+ * actually wrote. Falls back to projectRoot via the (now historical)
216
+ * `resolveOutputPath` opt — preserves existing tests that fake the
217
+ * coordinator without a workspace.
218
+ *
219
+ * PR-B follow-up — after `swarm_complete` deletes the swarm from the
220
+ * registry, `swarmCoordinator.getSwarm()` returns undefined and the
221
+ * spawner loses the workspace handle. Pass the persisted
222
+ * `pendingIntrospectionWorkspaceCwd` (captured at spawn time) so the
223
+ * resolver still lands inside the worktree even when the live handle is
224
+ * gone. Without this, B2 (queen-driven swarm_complete) silently loses
225
+ * every cohort: lookup → undefined → projectRoot fallback → file-not-
226
+ * found warn → 0 ingest → breadcrumb cleared.
227
+ */
228
+ function resolveActualOutputPath(projectRoot, swarmId, persistedCwd) {
229
+ const swarm = swarmCoordinator.getSwarm(swarmId);
230
+ const base = swarm?.workspace?.cwd ?? persistedCwd ?? projectRoot;
231
+ // Keep the well-known relative tail so an injected resolveOutputPath
232
+ // override (tests) still wins when base === projectRoot.
233
+ if (base === projectRoot)
234
+ return resolveOutputPath(projectRoot, swarmId);
235
+ return join(base, ".clawnet", "introspection", `${swarmId}.json`);
236
+ }
237
+ /**
238
+ * PR-B — the prompt embeds this RELATIVE path; the queen's role process
239
+ * is given workspace.cwd as its cwd, so writing to a relative path lands
240
+ * inside the workspace. Pre-B4 we embedded an absolute projectRoot path,
241
+ * which made the queen escape the worktree.
242
+ */
243
+ function relativeOutputPath(swarmId) {
244
+ return join(".", ".clawnet", "introspection", `${swarmId}.json`);
245
+ }
246
+ /**
247
+ * M8.2c — spawner-owned completion signal. Returns true when the queen
248
+ * appears to have finished (output file exists and has been stable for the
249
+ * mtime guard window) OR the hard timeout has elapsed. Pure inspection —
250
+ * never mutates state. Used by `processPendingSwarm` to decide whether to
251
+ * force the swarm into a terminal state when the coordinator's idle
252
+ * fallback would otherwise leave it in `paused`. Pulled out as a helper
253
+ * so the public `isComplete` source hook can share the same logic.
254
+ */
255
+ function isOwnerComplete(projectRoot, pendingId, cfg) {
256
+ const now = clock().getTime();
257
+ // Hard timeout: spawn-to-give-up cap. Uses lastIntrospectionAt which is
258
+ // set to `clock().toISOString()` at spawn time. (The S1 hub-outage rewind
259
+ // only fires AFTER ingest has cleared pendingId, so it never overlaps
260
+ // with the hard-timeout path here.)
261
+ if (cfg.lastIntrospectionAt) {
262
+ const spawnedAt = new Date(cfg.lastIntrospectionAt).getTime();
263
+ if (Number.isFinite(spawnedAt) && now - spawnedAt > SPAWNER_COMPLETE_HARD_TIMEOUT_MS) {
264
+ return true;
265
+ }
266
+ }
267
+ const outputPath = resolveActualOutputPath(projectRoot, pendingId, cfg.pendingIntrospectionWorkspaceCwd);
268
+ if (!existsSync(outputPath))
269
+ return false;
270
+ try {
271
+ const st = statSync(outputPath);
272
+ return now - st.mtimeMs > SPAWNER_COMPLETE_MTIME_GUARD_MS;
273
+ }
274
+ catch {
275
+ return false;
276
+ }
277
+ }
278
+ /**
279
+ * Ingest phase — read the pending swarm's output, POST ideas, clear
280
+ * pending. Always-clears the pending breadcrumb (terminal swarm) so a
281
+ * malformed/missing output doesn't wedge the source forever; the
282
+ * cooldown (set at spawn) gates the next attempt.
283
+ *
284
+ * S1 hotfix: when the swarm produced candidates but every POST failed
285
+ * (likely hub outage), rewind `lastIntrospectionAt` so the cooldown
286
+ * shortens to ~1 hour instead of carrying the full 24h cooldown for an
287
+ * effectively-empty cycle.
288
+ *
289
+ * M8.2c: when the coordinator hasn't transitioned to a terminal state yet
290
+ * (single-queen introspection swarms get stuck in `paused` because the
291
+ * idle-fallback in startQueenCheck never sees an "active worker"), the
292
+ * spawner-owned completion signal (output file + mtime guard, or hard
293
+ * timeout) lets us force `coord.complete(swarmId)` ourselves and proceed
294
+ * to ingest in the same tick. Without this the cycle would have to wait
295
+ * for the coordinator's 100-min auto-pause AND a follow-up B2a-style
296
+ * tolerance path that doesn't exist.
297
+ */
298
+ async function processPendingSwarm(projectRoot, pendingId, cfg) {
299
+ let state = isTerminalSwarm(swarmCoordinator, pendingId);
300
+ // M8.2c follow-up — read the output BEFORE any coordinator transition.
301
+ // The destroy fallback below disposes the swarm's workspace
302
+ // (workspaceManager.dispose deletes it from disk). Today the
303
+ // introspection swarm doesn't run inside an isolated worktree so the
304
+ // output lives in the project's main `.clawnet/introspection/` dir and
305
+ // survives. Once B4 lands worktree isolation for always-on cycles, the
306
+ // output will land inside the disposable workspace and a destroy-before-
307
+ // read would silently lose the 3 candidates. Caching candidates here
308
+ // makes the ingest order-independent of the coordinator transition.
309
+ const outputPath = resolveActualOutputPath(projectRoot, pendingId, cfg.pendingIntrospectionWorkspaceCwd);
310
+ let candidates = [];
311
+ let outputExisted = false;
312
+ if (existsSync(outputPath)) {
313
+ outputExisted = true;
314
+ try {
315
+ const raw = readFileSync(outputPath, "utf-8");
316
+ candidates = parseCandidatesOutput(raw);
317
+ }
318
+ catch (err) {
319
+ warn?.({ err: err instanceof Error ? err.message : String(err), outputPath, pendingId }, "introspection-source: failed to read pending output (clearing pending)");
320
+ }
321
+ }
322
+ if (!state.terminal) {
323
+ if (!isOwnerComplete(projectRoot, pendingId, cfg)) {
324
+ info?.({ pendingId, projectRoot }, "introspection-source: pending swarm still running — will check next tick");
325
+ return;
326
+ }
327
+ // Spawner observed its own completion signal (output file written, or
328
+ // hard timeout elapsed). Force the coordinator to transition out of
329
+ // paused/running so the rest of the ingest path runs against a stable
330
+ // terminal state and downstream UI shows "completed" not "paused".
331
+ try {
332
+ await swarmCoordinator.complete(pendingId);
333
+ info?.({ pendingId, projectRoot }, "introspection-source: spawner-owned complete (forced terminal transition)");
334
+ }
335
+ catch (err) {
336
+ // Non-fatal: the output file's already been cached above and the
337
+ // pending breadcrumb is always cleared at the end. Try `destroy` as
338
+ // a last-resort cleanup so the swarm doesn't leak in the
339
+ // coordinator registry when `complete` was the wrong transition —
340
+ // the R3 I-A chain below keys off `state.status` and would skip
341
+ // both `completed` and `failed` branches when state is still
342
+ // "paused"/"running", leaving the registry entry alive forever.
343
+ warn?.({ err: err instanceof Error ? err.message : String(err), pendingId }, "introspection-source: spawner-owned complete failed (trying destroy fallback)");
344
+ try {
345
+ await swarmCoordinator.destroy(pendingId);
346
+ }
347
+ catch {
348
+ /* best-effort — output is already cached above */
349
+ }
350
+ }
351
+ state = isTerminalSwarm(swarmCoordinator, pendingId);
352
+ }
353
+ if (!outputExisted) {
354
+ warn?.({ pendingId, outputPath, swarmStatus: state.status }, "introspection-source: pending swarm output missing (clearing pending)");
355
+ }
356
+ const cohort = candidates.slice(0, INTROSPECTION_MAX_IDEAS);
357
+ let projectId = null;
358
+ try {
359
+ projectId = encodeCwd(projectRoot);
360
+ }
361
+ catch {
362
+ projectId = null;
363
+ }
364
+ // M8.2 dedupe pre-fetch: pull existing same-project intro ideas so we can
365
+ // skip candidates the queen has already proposed in a prior cycle. Failure
366
+ // here is non-fatal: we set `dedupeAvailable=false` and fall through to
367
+ // the insert loop unchanged. The hub-side `listIdeasSchema` caps `limit`
368
+ // at 100; that's plenty for the typical introspection corpus (a few months
369
+ // of 3-ideas-per-cycle still fits comfortably).
370
+ let dedupeIndex = [];
371
+ let dedupeAvailable = false;
372
+ if (projectId && cohort.length > 0) {
373
+ try {
374
+ const existing = await ideasClient.list({
375
+ projectId,
376
+ tag: INTROSPECTION_SOURCE_TAG,
377
+ limit: 100,
378
+ });
379
+ // Defensive client-side filter — at the time of writing `list` returns
380
+ // every status, but if that ever changes to "active-only" this filter
381
+ // is still safe (archived rows we don't want to dedupe against either
382
+ // way — the user explicitly archived them).
383
+ const active = existing.filter((i) => i.status !== "archived");
384
+ dedupeIndex = buildDedupeIndex(active.map((i) => ({ id: i.id, title: i.title, body: i.body ?? "" })));
385
+ dedupeAvailable = true;
386
+ }
387
+ catch (err) {
388
+ warn?.({ err: err instanceof Error ? err.message : String(err), projectId }, "introspection-source: dedupe pre-fetch failed (fail-open, proceeding with insert)");
389
+ }
390
+ }
391
+ const posted = [];
392
+ let suppressedDuplicates = 0;
393
+ for (const c of cohort) {
394
+ if (dedupeAvailable) {
395
+ const match = findDuplicate({ title: c.title, body: c.body ?? "" }, dedupeIndex);
396
+ if (match) {
397
+ suppressedDuplicates++;
398
+ info?.({
399
+ title: c.title,
400
+ matchedIdeaId: match.matchedIdeaId,
401
+ projectId,
402
+ reason: match.reason,
403
+ ...(match.score !== undefined ? { jaccard: match.score } : {}),
404
+ }, "introspection-source: duplicate-suppressed");
405
+ continue;
406
+ }
407
+ }
408
+ const input = {
409
+ title: c.title.slice(0, 500),
410
+ body: (c.body ?? "").slice(0, 50_000),
411
+ status: "idea",
412
+ priority: "low",
413
+ tags: [INTROSPECTION_SOURCE_TAG],
414
+ sourceRole: INTROSPECTION_SOURCE_ROLE,
415
+ sourceSwarmId: pendingId,
416
+ ...(projectId ? { projectId } : {}),
417
+ };
418
+ try {
419
+ const created = await ideasClient.create(input);
420
+ posted.push(created);
421
+ // Extend the index with the freshly-created idea so any subsequent
422
+ // candidate in this same cohort is checked against it too. Prevents
423
+ // the queen accidentally smuggling two near-identical ideas through
424
+ // in one cycle.
425
+ if (dedupeAvailable) {
426
+ appendToIndex(dedupeIndex, {
427
+ id: created.id,
428
+ title: c.title,
429
+ body: c.body ?? "",
430
+ });
431
+ }
432
+ }
433
+ catch (err) {
434
+ warn?.({ err: err instanceof Error ? err.message : String(err), title: c.title }, "introspection-source: ideas.create failed");
435
+ }
436
+ }
437
+ // S2 hotfix + R3 I-A: release the swarm from the coordinator registry
438
+ // for EVERY terminal state, not just "completed". Mirrors
439
+ // WorkCycleRunner.waitForTerminalState (workcycle-runner.ts:340-376):
440
+ // completed → coord.complete(swarmId) (idempotent — also runs cleanup)
441
+ // failed → coord.fail(swarmId)
442
+ // cancelled → no-op (already terminal externally; the coordinator
443
+ // cancel path is what put it there and already cleaned up)
444
+ // unknown → no-op (gone from registry — nothing to release)
445
+ // Pre-R3, only `completed` ran cleanup, so a swarm that returned
446
+ // `failed`/`cancelled`/`unknown` stayed in the registry forever and
447
+ // leaked across cycles.
448
+ if (state.status === "completed") {
449
+ try {
450
+ await swarmCoordinator.complete(pendingId);
451
+ }
452
+ catch (err) {
453
+ warn?.({ err: err instanceof Error ? err.message : String(err), pendingId }, "introspection-source: coordinator.complete failed (best-effort)");
454
+ }
455
+ }
456
+ else if (state.status === "failed") {
457
+ try {
458
+ await swarmCoordinator.fail(pendingId);
459
+ }
460
+ catch (err) {
461
+ warn?.({ err: err instanceof Error ? err.message : String(err), pendingId }, "introspection-source: coordinator.fail failed (best-effort)");
462
+ }
463
+ }
464
+ // "cancelled" and "unknown" need no coordinator call: cancelled is
465
+ // already terminal in the coordinator (whoever cancelled it cleaned up),
466
+ // and unknown means the swarm is no longer in the registry at all.
467
+ info?.({
468
+ pendingId,
469
+ swarmStatus: state.status,
470
+ produced: candidates.length,
471
+ posted: posted.length,
472
+ skippedOverflow: Math.max(0, candidates.length - INTROSPECTION_MAX_IDEAS),
473
+ suppressedDuplicates,
474
+ }, "introspection-source: ingest complete");
475
+ // S1 hotfix: hub outage triage. If the queen produced candidates but
476
+ // every POST failed, rewind the cooldown so we retry in ~1h instead of
477
+ // waiting the full window. Only triggers when there *were* candidates
478
+ // (no candidates = nothing to retry, no point shortening cooldown).
479
+ //
480
+ // M8.2 dedupe note: subtract `suppressedDuplicates` so a cohort where
481
+ // every idea was a known duplicate (legitimate zero-POST outcome) does
482
+ // not falsely trip the hub-outage path.
483
+ const patch = {
484
+ pendingIntrospectionSwarmId: null,
485
+ pendingIntrospectionWorkspaceCwd: null,
486
+ };
487
+ const attempted = cohort.length - suppressedDuplicates;
488
+ if (attempted > 0 && posted.length === 0) {
489
+ const cooldownHours = cfg.introspectionCooldownHours ?? DEFAULT_INTROSPECTION_COOLDOWN_HOURS;
490
+ const rewindMs = Math.max(0, cooldownHours - 1) * 3600_000;
491
+ patch.lastIntrospectionAt = new Date(clock().getTime() - rewindMs).toISOString();
492
+ info?.({ pendingId, candidates: cohort.length, attempted, posted: 0, rewindMs }, "introspection-source: hub outage triage — rewinding lastIntrospectionAt");
493
+ }
494
+ await updateConfig(projectRoot, patch);
495
+ }
496
+ return {
497
+ name: "queen-introspection",
498
+ kind: "queen-introspection",
499
+ /**
500
+ * M8.2c — spawner-owned completion signal exposed publicly. Introspection
501
+ * swarms are single-queen fire-and-forget cycles: there's never an active
502
+ * non-queen worker, so the coordinator's idle-check would otherwise stall
503
+ * them in `paused` after ~100 min, and `processPendingSwarm` (which keys
504
+ * off swarm status) would never ingest. The source's own `drainPending`
505
+ * calls `coordinator.complete()` once this returns true.
506
+ */
507
+ async isComplete(projectRoot, swarmId) {
508
+ return isOwnerComplete(projectRoot, swarmId, getConfig(projectRoot));
509
+ },
510
+ /**
511
+ * R3 I-B — pre-cascade drain hook. Runs every tick BEFORE the cascade
512
+ * picker, regardless of whether `taskSources.introspection` is on.
513
+ * Without this hook, the scenario "user enables introspection → swarm
514
+ * spawned → user toggles introspection OFF before the swarm reaches
515
+ * terminal state" leaks `pendingIntrospectionSwarmId` (the source's
516
+ * own pick() is skipped while disabled, so its Step-0 drain never
517
+ * fires) AND the swarm stays in the coordinator registry forever
518
+ * (only re-enabling the toggle ever reaps it). The drain is opt-in
519
+ * (skips when no pending breadcrumb), independent of the toggle, and
520
+ * best-effort — internal failures are swallowed by processPendingSwarm.
521
+ */
522
+ async drainPending(projectRoot) {
523
+ const cfg = getConfig(projectRoot);
524
+ if (!cfg.pendingIntrospectionSwarmId)
525
+ return;
526
+ await processPendingSwarm(projectRoot, cfg.pendingIntrospectionSwarmId, cfg);
527
+ },
528
+ async pick(projectRoot) {
529
+ const cfg = getConfig(projectRoot);
530
+ const sources = resolveTaskSources(cfg.taskSources);
531
+ if (!sources.introspection) {
532
+ // Default path: introspection is opt-in. Silent (debug-level) — no
533
+ // log spam when the user simply hasn't turned it on.
534
+ return null;
535
+ }
536
+ // ── Step 0: drain any pending swarm spawned on a prior tick ──────
537
+ // Fire-and-forget design (hotfix I1/I2). If a spawn cycle left a
538
+ // breadcrumb, we ingest the result here without ever blocking the
539
+ // scheduler. Always returns null after — the cascade falls through
540
+ // and the runner skips this cycle. We deliberately do NOT proceed to
541
+ // step 1+ even when the ingest completed; one cycle does one thing
542
+ // (ingest OR spawn), never both. This keeps reasoning simple and
543
+ // avoids surprising the user with two POSTs in one tick.
544
+ //
545
+ // R3 I-B note: in the on-toggle path, `AlwaysOnManager.drainPending`
546
+ // has already attempted the drain before this pick() runs, so by the
547
+ // time we get here the breadcrumb is usually cleared. The defensive
548
+ // re-check still matters because (a) the swarm may still be running
549
+ // (drainPending leaves the breadcrumb in place when not terminal),
550
+ // and (b) when `taskSources` is wired without an AlwaysOnManager
551
+ // (tests, ad-hoc usage) the manager-level drain doesn't run.
552
+ if (cfg.pendingIntrospectionSwarmId) {
553
+ await processPendingSwarm(projectRoot, cfg.pendingIntrospectionSwarmId, cfg);
554
+ return null;
555
+ }
556
+ // ── Step 1: self-suppression pre-check ──────────────────────────
557
+ // If the previous introspection ran, evaluate whether all of its
558
+ // produced ideas got archived. We do this BEFORE the cooldown gate so
559
+ // a user who actually kept some ideas clears the suppression sooner
560
+ // (and so a stuck suppression flag doesn't outlast the cooldown).
561
+ if (cfg.lastIntrospectionAt) {
562
+ try {
563
+ const projectId = encodeCwd(projectRoot);
564
+ // Pull a generous slice — we filter further in-memory by
565
+ // lastIntrospectionAt and tag/source. Limit 100 covers up to ~33
566
+ // introspections at 3 ideas each.
567
+ const recent = await ideasClient.list({
568
+ projectId,
569
+ tag: INTROSPECTION_SOURCE_TAG,
570
+ limit: 100,
571
+ });
572
+ const epsilonMs = 60_000; // 1-minute slack for clock skew
573
+ // N2 hotfix: use the injected clock + Date.getTime() consistently
574
+ // instead of mixing Date.parse with the wall clock. Tests
575
+ // injecting `clock` were observing the wall-clock now() during
576
+ // the cohort filter, which made deterministic fixtures flaky.
577
+ const since = new Date(cfg.lastIntrospectionAt).getTime();
578
+ const cohort = recent.filter((i) => i.sourceRole === INTROSPECTION_SOURCE_ROLE &&
579
+ new Date(i.createdAt).getTime() >= since - epsilonMs);
580
+ if (cohort.length > 0) {
581
+ const allArchived = cohort.every((i) => i.status === "archived");
582
+ const desired = allArchived;
583
+ if (cfg.lastIntrospectionRejected !== desired) {
584
+ await updateConfig(projectRoot, { lastIntrospectionRejected: desired });
585
+ }
586
+ if (allArchived) {
587
+ info?.({ cohortSize: cohort.length, projectId }, "introspection-source: previous cohort all archived — suppressing");
588
+ return null;
589
+ }
590
+ }
591
+ }
592
+ catch (err) {
593
+ // Best-effort: a hub outage shouldn't permanently break introspection.
594
+ warn?.({ err: err instanceof Error ? err.message : String(err) }, "introspection-source: self-suppression check failed (continuing)");
595
+ }
596
+ }
597
+ else if (cfg.lastIntrospectionRejected) {
598
+ // Defensive: cooldown got cleared but the rejected flag persisted.
599
+ // Don't trust it without a cohort to back it up.
600
+ await updateConfig(projectRoot, { lastIntrospectionRejected: null });
601
+ }
602
+ // After the pre-check, the flag may still be set (skipped the cohort
603
+ // sync above due to no lastIntrospectionAt). Honour it.
604
+ const cfg2 = getConfig(projectRoot);
605
+ if (cfg2.lastIntrospectionRejected === true) {
606
+ info?.({ projectRoot }, "introspection-source: lastIntrospectionRejected=true — skipping");
607
+ return null;
608
+ }
609
+ // ── Step 2: cooldown gate ────────────────────────────────────────
610
+ const cooldownHours = cfg2.introspectionCooldownHours ?? DEFAULT_INTROSPECTION_COOLDOWN_HOURS;
611
+ const cooldownMs = Math.max(0, cooldownHours) * 3600_000;
612
+ if (cfg2.lastIntrospectionAt && cooldownMs > 0) {
613
+ // N2 hotfix: same Date.getTime() consistency as the cohort filter.
614
+ const sinceMs = clock().getTime() - new Date(cfg2.lastIntrospectionAt).getTime();
615
+ if (sinceMs < cooldownMs) {
616
+ info?.({ sinceMs, cooldownMs, lastIntrospectionAt: cfg2.lastIntrospectionAt }, "introspection-source: cooldown not elapsed — skipping");
617
+ return null;
618
+ }
619
+ }
620
+ // ── Step 3: spawn the introspection swarm (fire-and-forget) ──────
621
+ const swarmId = generateSwarmId();
622
+ // Pre-create the output directory in projectRoot so a fallback (no
623
+ // workspace) write succeeds without queen having to mkdir herself.
624
+ // With workspace isolation the queen's cwd will be workspace.cwd
625
+ // and she creates the directory there as needed; the projectRoot
626
+ // pre-mkdir is harmless in that case.
627
+ const fallbackOutputPath = resolveOutputPath(projectRoot, swarmId);
628
+ try {
629
+ mkdirSync(dirname(fallbackOutputPath), { recursive: true });
630
+ }
631
+ catch {
632
+ /* best-effort */
633
+ }
634
+ // PR-B Suspect B fix: prompt embeds a RELATIVE path. Queen's cwd is
635
+ // workspace.cwd (set by spawnRole → sessionAdapter.createSession with
636
+ // `workDir: swarm.workspace?.cwd ?? swarm.workDir`), so a relative
637
+ // write lands inside the workspace. Spawner reads back via
638
+ // `resolveActualOutputPath` which checks workspace.cwd first.
639
+ const taskPrompt = buildIntrospectionTaskPrompt(projectRoot, relativeOutputPath(swarmId));
640
+ // We await coordinator.create — it's the *registration* call (fast)
641
+ // and we need to know it succeeded before persisting the pending
642
+ // breadcrumb. The swarm itself runs in the background after create()
643
+ // returns; this is the fire-and-forget edge.
644
+ try {
645
+ await swarmCoordinator.create(swarmId, {
646
+ workDir: projectRoot,
647
+ templateName: introspectionTemplateName,
648
+ task: taskPrompt,
649
+ kind: "always-on-introspection",
650
+ });
651
+ }
652
+ catch (err) {
653
+ warn?.({ err: err instanceof Error ? err.message : String(err), swarmId }, "introspection-source: coordinator.create failed — bumping cooldown to avoid retry storm");
654
+ await updateConfig(projectRoot, {
655
+ lastIntrospectionAt: clock().toISOString(),
656
+ lastIntrospectionRejected: false,
657
+ });
658
+ return null;
659
+ }
660
+ // ── Step 4: persist pending breadcrumb + cooldown ────────────────
661
+ // This is the entire side-effect of a spawn cycle. The ingest cycle
662
+ // (next pick after swarm reaches terminal state) handles the rest.
663
+ //
664
+ // PR-B follow-up — capture the workspace.cwd assigned by
665
+ // WorkspaceManager.create so `processPendingSwarm` can still resolve
666
+ // the candidates file after `swarm_complete` deletes the swarm from
667
+ // the registry (B2 path). `getSwarm()` is the only reliable way to
668
+ // read workspace.cwd post-create; we snapshot it here and let the
669
+ // persisted value carry through restart + post-delete reads.
670
+ const spawned = swarmCoordinator.getSwarm(swarmId);
671
+ const workspaceCwd = spawned?.workspace?.cwd;
672
+ await updateConfig(projectRoot, {
673
+ lastIntrospectionAt: clock().toISOString(),
674
+ lastIntrospectionRejected: false,
675
+ pendingIntrospectionSwarmId: swarmId,
676
+ ...(workspaceCwd ? { pendingIntrospectionWorkspaceCwd: workspaceCwd } : {}),
677
+ });
678
+ info?.({ swarmId, projectRoot, templateName: introspectionTemplateName }, "introspection-source: swarm spawned (fire-and-forget) — ingest on next tick after terminal");
679
+ return null;
680
+ },
681
+ // Source-driven flow — runner never claims an introspection task because
682
+ // pick() always returns null. These hooks satisfy the interface but are
683
+ // no-ops by design.
684
+ async onClaim() {
685
+ /* no-op */
686
+ },
687
+ async onSuccess() {
688
+ /* no-op */
689
+ },
690
+ async onFailure() {
691
+ /* no-op */
692
+ },
693
+ };
694
+ }
695
+ //# sourceMappingURL=introspection-source.js.map