@mclawnet/swarm 0.1.13 → 0.1.15

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,1051 @@
1
+ // M8.2 + M8 hotfix I1/I2 — IntrospectionSource unit tests.
2
+ //
3
+ // Verifies opt-in gate, cooldown gate, self-suppression, fire-and-forget
4
+ // swarm spawn, output parsing on a SECOND pick (ingest cycle), idea POST
5
+ // cap + priority enforcement, hub-outage cooldown rewind (S1), and the
6
+ // pending-swarm bookkeeping.
7
+ //
8
+ // The two-phase pick model: first pick() spawns + persists
9
+ // `pendingIntrospectionSwarmId`; the second pick() observes the pending
10
+ // swarm is terminal, parses the queen's output and POSTs the cohort.
11
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
12
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { encodeCwd } from "@mclawnet/shared";
16
+ import { createIntrospectionSource, parseCandidatesOutput, buildIntrospectionTaskPrompt, INTROSPECTION_SOURCE_ROLE, INTROSPECTION_SOURCE_TAG, SPAWNER_COMPLETE_MTIME_GUARD_MS, SPAWNER_COMPLETE_HARD_TIMEOUT_MS, } from "../always-on/task-sources/introspection-source.js";
17
+ const PROJECT_ROOT = "/proj-introspect";
18
+ const PROJECT_ID = encodeCwd(PROJECT_ROOT);
19
+ function makeIdea(over) {
20
+ return {
21
+ id: "i1",
22
+ userId: "u1",
23
+ projectId: PROJECT_ID,
24
+ title: "title",
25
+ body: "",
26
+ status: "idea",
27
+ priority: "low",
28
+ tags: [],
29
+ sourceSessionId: null,
30
+ sourceMessageId: null,
31
+ sourceSwarmId: null,
32
+ sourceRole: null,
33
+ linkedSessionId: null,
34
+ linkedSwarmTaskId: null,
35
+ researchReports: [],
36
+ researchKind: null,
37
+ createdAt: "2026-01-01T00:00:00.000Z",
38
+ updatedAt: "2026-01-01T00:00:00.000Z",
39
+ ...over,
40
+ };
41
+ }
42
+ function makeClient(hooks = {}) {
43
+ const creates = [];
44
+ const client = {
45
+ creates,
46
+ async list() {
47
+ return hooks.listResults ?? [];
48
+ },
49
+ async get() {
50
+ return null;
51
+ },
52
+ async patch(id, partial) {
53
+ return makeIdea({ id, ...partial });
54
+ },
55
+ async create(input) {
56
+ if (hooks.throwOnCreate)
57
+ throw new Error("hub down");
58
+ hooks.onCreate?.(input);
59
+ const created = makeIdea({
60
+ id: `created-${creates.length + 1}`,
61
+ title: input.title,
62
+ body: input.body ?? "",
63
+ priority: input.priority ?? "low",
64
+ status: input.status ?? "idea",
65
+ tags: input.tags ?? [],
66
+ sourceRole: input.sourceRole ?? null,
67
+ sourceSwarmId: input.sourceSwarmId ?? null,
68
+ projectId: input.projectId ?? null,
69
+ });
70
+ creates.push(created);
71
+ return created;
72
+ },
73
+ };
74
+ return client;
75
+ }
76
+ function makeCoord(opts = {}) {
77
+ // Preserve null vs undefined distinction: `null` means "gone from registry"
78
+ // (getSwarm returns undefined); omitted means default ("completed").
79
+ let currentStatus = "swarmStatus" in opts ? opts.swarmStatus : "completed";
80
+ const coord = {
81
+ create: opts.createImpl ?? (async () => { }),
82
+ getSwarm: (_id) => currentStatus === null ? undefined : { status: currentStatus },
83
+ destroy: async (id) => {
84
+ opts.onDestroy?.(id);
85
+ // Simulate real coordinator: destroy evicts the swarm from the
86
+ // registry so a subsequent isTerminalSwarm() returns "unknown".
87
+ // Without this the destroy-fallback test could only verify the
88
+ // method was called, not that it actually cleaned up.
89
+ currentStatus = null;
90
+ },
91
+ complete: async (id) => {
92
+ opts.onComplete?.(id);
93
+ if (opts.completeThrows)
94
+ throw new Error("coord.complete failed");
95
+ if (opts.mutateStatusOnComplete) {
96
+ // Mirror real coordinator: complete() ends with runRetroAndCleanup,
97
+ // whose last step is `this.swarms.delete(swarmId)`. After complete,
98
+ // getSwarm returns undefined and isTerminalSwarm reports "unknown",
99
+ // NOT "completed". Setting currentStatus to null reflects this so
100
+ // tests don't assert code paths that production can't reach.
101
+ currentStatus = null;
102
+ }
103
+ },
104
+ fail: async (id) => {
105
+ opts.onFail?.(id);
106
+ },
107
+ };
108
+ return coord;
109
+ }
110
+ function makeConfig(over = {}) {
111
+ return {
112
+ mode: "active",
113
+ dailyBudget: 3,
114
+ taskSources: { introspection: true },
115
+ introspectionCooldownHours: 24,
116
+ ...over,
117
+ };
118
+ }
119
+ // ── parseCandidatesOutput ──────────────────────────────────────────────
120
+ describe("parseCandidatesOutput", () => {
121
+ it("parses a clean JSON-array file", () => {
122
+ const out = parseCandidatesOutput('[{"title":"a","body":"x"}]');
123
+ expect(out).toEqual([{ title: "a", body: "x" }]);
124
+ });
125
+ it("recovers a JSON array that follows narrative text", () => {
126
+ const raw = `the queen thinks:\nhere are some ideas\n[{"title":"hello","body":"world","priority":"medium"}]`;
127
+ const out = parseCandidatesOutput(raw);
128
+ expect(out).toEqual([{ title: "hello", body: "world", priority: "medium" }]);
129
+ });
130
+ it("returns [] on empty / non-array input", () => {
131
+ expect(parseCandidatesOutput("")).toEqual([]);
132
+ expect(parseCandidatesOutput("not json")).toEqual([]);
133
+ expect(parseCandidatesOutput('{"foo":1}')).toEqual([]);
134
+ });
135
+ it("drops entries without a title", () => {
136
+ const out = parseCandidatesOutput('[{"body":"oops"},{"title":"keeper"}]');
137
+ expect(out).toEqual([{ title: "keeper", body: "" }]);
138
+ });
139
+ it("uses the last [ in the file when multiple arrays appear", () => {
140
+ const raw = `[1,2,3] then\n[{"title":"newer"}]`;
141
+ const out = parseCandidatesOutput(raw);
142
+ expect(out).toEqual([{ title: "newer", body: "" }]);
143
+ });
144
+ });
145
+ describe("buildIntrospectionTaskPrompt", () => {
146
+ it("embeds projectRoot and absolute output path", () => {
147
+ const prompt = buildIntrospectionTaskPrompt("/p", "/p/.clawnet/introspection/swarm-1.json");
148
+ expect(prompt).toContain("/p");
149
+ expect(prompt).toContain("/p/.clawnet/introspection/swarm-1.json");
150
+ expect(prompt).toContain("at most 3 items");
151
+ });
152
+ });
153
+ // ── IntrospectionSource gating ─────────────────────────────────────────
154
+ describe("IntrospectionSource gating", () => {
155
+ it("returns null when taskSources.introspection !== true", async () => {
156
+ const cfg = makeConfig({ taskSources: { introspection: false } });
157
+ const source = createIntrospectionSource({
158
+ ideasClient: makeClient(),
159
+ swarmCoordinator: makeCoord(),
160
+ getConfig: () => cfg,
161
+ updateConfig: async (_pr) => { },
162
+ });
163
+ expect(await source.pick(PROJECT_ROOT)).toBeNull();
164
+ });
165
+ it("defaults to off when taskSources entirely absent", async () => {
166
+ const cfg = makeConfig({ taskSources: undefined });
167
+ const created = vi.fn(async () => { });
168
+ const source = createIntrospectionSource({
169
+ ideasClient: makeClient(),
170
+ swarmCoordinator: makeCoord({ createImpl: created }),
171
+ getConfig: () => cfg,
172
+ updateConfig: async (_pr) => { },
173
+ });
174
+ expect(await source.pick(PROJECT_ROOT)).toBeNull();
175
+ expect(created).not.toHaveBeenCalled();
176
+ });
177
+ it("returns null when cooldown not elapsed", async () => {
178
+ const cfg = makeConfig({
179
+ lastIntrospectionAt: new Date("2026-06-06T11:00:00Z").toISOString(),
180
+ introspectionCooldownHours: 24,
181
+ });
182
+ const created = vi.fn(async () => { });
183
+ const source = createIntrospectionSource({
184
+ ideasClient: makeClient(),
185
+ swarmCoordinator: makeCoord({ createImpl: created }),
186
+ getConfig: () => cfg,
187
+ updateConfig: async (_pr) => { },
188
+ clock: () => new Date("2026-06-06T12:00:00Z"),
189
+ });
190
+ expect(await source.pick(PROJECT_ROOT)).toBeNull();
191
+ expect(created).not.toHaveBeenCalled();
192
+ });
193
+ it("suppresses next cycle when lastIntrospectionRejected=true is backed by an all-archived cohort", async () => {
194
+ // Setup: prior cycle ran 2 days ago and all its produced ideas are archived.
195
+ const lastAt = new Date("2026-06-06T00:00:00Z").toISOString();
196
+ const archivedCohort = [
197
+ makeIdea({
198
+ id: "i1",
199
+ status: "archived",
200
+ sourceRole: INTROSPECTION_SOURCE_ROLE,
201
+ tags: [INTROSPECTION_SOURCE_TAG],
202
+ createdAt: "2026-06-06T00:01:00Z",
203
+ }),
204
+ ];
205
+ const cfg = makeConfig({
206
+ lastIntrospectionAt: lastAt,
207
+ lastIntrospectionRejected: true,
208
+ });
209
+ const created = vi.fn(async () => { });
210
+ const source = createIntrospectionSource({
211
+ ideasClient: makeClient({ listResults: archivedCohort }),
212
+ swarmCoordinator: makeCoord({ createImpl: created }),
213
+ getConfig: () => cfg,
214
+ updateConfig: async (_pr) => { },
215
+ clock: () => new Date("2026-06-08T00:00:00Z"),
216
+ });
217
+ expect(await source.pick(PROJECT_ROOT)).toBeNull();
218
+ expect(created).not.toHaveBeenCalled();
219
+ });
220
+ it("clears stale lastIntrospectionRejected when there's no lastIntrospectionAt (defensive)", async () => {
221
+ // The flag without a cooldown breadcrumb is treated as orphaned: clear
222
+ // it and let the cycle proceed. Otherwise a corrupted config could lock
223
+ // introspection forever with no way to recover via the cohort sync.
224
+ const cfg = makeConfig({
225
+ lastIntrospectionRejected: true,
226
+ // No lastIntrospectionAt set.
227
+ });
228
+ const created = vi.fn(async () => { });
229
+ const patches = [];
230
+ const source = createIntrospectionSource({
231
+ ideasClient: makeClient(),
232
+ swarmCoordinator: makeCoord({ createImpl: created }),
233
+ getConfig: () => cfg,
234
+ updateConfig: async (_pr, patch) => {
235
+ patches.push(patch);
236
+ Object.assign(cfg, patch);
237
+ },
238
+ });
239
+ await source.pick(PROJECT_ROOT);
240
+ expect(patches[0]).toEqual({ lastIntrospectionRejected: null });
241
+ expect(created).toHaveBeenCalledTimes(1);
242
+ });
243
+ });
244
+ // ── Fire-and-forget spawn + ingest ─────────────────────────────────────
245
+ describe("IntrospectionSource fire-and-forget happy path", () => {
246
+ let tmpHome;
247
+ let projectRoot;
248
+ let outputPath;
249
+ beforeEach(() => {
250
+ tmpHome = mkdtempSync(join(tmpdir(), "introspect-"));
251
+ projectRoot = tmpHome; // use the temp dir as projectRoot so writes work
252
+ outputPath = join(projectRoot, ".clawnet", "introspection", "swarm-x.json");
253
+ });
254
+ afterEach(() => {
255
+ rmSync(tmpHome, { recursive: true, force: true });
256
+ });
257
+ it("spawn cycle: pick() persists pendingIntrospectionSwarmId and returns null WITHOUT awaiting swarm", async () => {
258
+ // No output file written yet → if pick() were awaiting the swarm we'd
259
+ // still see zero POSTs anyway, but we ALSO want to see the pending
260
+ // breadcrumb persisted as proof we didn't block on the swarm.
261
+ const client = makeClient();
262
+ // CI tsc inferred mock.calls[0] as [] (no args) and tripped TS2493 on
263
+ // index access below. Give the spy an explicit signature so calls[0] is
264
+ // [swarmId, opts].
265
+ const created = vi.fn(async (_swarmId, _opts) => { });
266
+ const patches = [];
267
+ const cfg = makeConfig();
268
+ const source = createIntrospectionSource({
269
+ ideasClient: client,
270
+ swarmCoordinator: makeCoord({ createImpl: created }),
271
+ getConfig: () => cfg,
272
+ updateConfig: async (_pr, patch) => {
273
+ patches.push(patch);
274
+ Object.assign(cfg, patch);
275
+ },
276
+ generateSwarmId: () => "swarm-x",
277
+ clock: () => new Date("2026-06-07T00:00:00Z"),
278
+ });
279
+ const result = await source.pick(projectRoot);
280
+ expect(result).toBeNull();
281
+ expect(created).toHaveBeenCalledTimes(1);
282
+ // PR-B D11 — introspection source stamps kind so UI / GC / shipment
283
+ // can tell these read-only "produce ideas" swarms apart from ordinary
284
+ // always-on cycles + user-driven chat swarms.
285
+ expect(created.mock.calls[0][1]).toMatchObject({ kind: "always-on-introspection" });
286
+ expect(client.creates).toHaveLength(0);
287
+ // Pending breadcrumb persisted along with cooldown stamp.
288
+ expect(patches.at(-1)).toMatchObject({
289
+ lastIntrospectionAt: "2026-06-07T00:00:00.000Z",
290
+ lastIntrospectionRejected: false,
291
+ pendingIntrospectionSwarmId: "swarm-x",
292
+ });
293
+ });
294
+ it("ingest cycle: pick() with pending swarm + terminal status reads output and POSTs", async () => {
295
+ mkdirSync(join(projectRoot, ".clawnet", "introspection"), { recursive: true });
296
+ writeFileSync(outputPath, JSON.stringify([
297
+ { title: "Idea A", body: "body A", priority: "high" },
298
+ { title: "Idea B", body: "body B" },
299
+ ]));
300
+ const client = makeClient();
301
+ const completedCalls = [];
302
+ const cfg = makeConfig({
303
+ // Pretend a prior spawn cycle landed a pending breadcrumb.
304
+ pendingIntrospectionSwarmId: "swarm-x",
305
+ lastIntrospectionAt: new Date("2026-06-07T00:00:00Z").toISOString(),
306
+ });
307
+ const source = createIntrospectionSource({
308
+ ideasClient: client,
309
+ swarmCoordinator: makeCoord({
310
+ swarmStatus: "completed",
311
+ onComplete: (id) => completedCalls.push(id),
312
+ }),
313
+ getConfig: () => cfg,
314
+ updateConfig: async (_pr, patch) => {
315
+ Object.assign(cfg, patch);
316
+ // simulate persistence by deleting the field when null
317
+ if ("pendingIntrospectionSwarmId" in patch && patch.pendingIntrospectionSwarmId === null) {
318
+ delete cfg.pendingIntrospectionSwarmId;
319
+ }
320
+ },
321
+ clock: () => new Date("2026-06-07T01:00:00Z"),
322
+ });
323
+ const result = await source.pick(projectRoot);
324
+ expect(result).toBeNull();
325
+ expect(client.creates).toHaveLength(2);
326
+ expect(client.creates[0].priority).toBe("low");
327
+ expect(client.creates[0].title).toBe("Idea A");
328
+ expect(client.creates[0].tags).toContain(INTROSPECTION_SOURCE_TAG);
329
+ expect(client.creates[0].sourceRole).toBe(INTROSPECTION_SOURCE_ROLE);
330
+ expect(client.creates[0].sourceSwarmId).toBe("swarm-x");
331
+ // S2 hotfix: coordinator.complete was called on the terminal swarm.
332
+ expect(completedCalls).toEqual(["swarm-x"]);
333
+ // Pending breadcrumb cleared so next cycle can spawn fresh.
334
+ expect(cfg.pendingIntrospectionSwarmId).toBeUndefined();
335
+ });
336
+ it("ingest cycle: caps at 3 ideas even when swarm produced 5", async () => {
337
+ mkdirSync(join(projectRoot, ".clawnet", "introspection"), { recursive: true });
338
+ writeFileSync(outputPath, JSON.stringify(Array.from({ length: 5 }, (_, i) => ({ title: `Idea ${i + 1}`, body: "" }))));
339
+ const client = makeClient();
340
+ const cfg = makeConfig({ pendingIntrospectionSwarmId: "swarm-x" });
341
+ const source = createIntrospectionSource({
342
+ ideasClient: client,
343
+ swarmCoordinator: makeCoord({ swarmStatus: "completed" }),
344
+ getConfig: () => cfg,
345
+ updateConfig: async (_pr, patch) => {
346
+ Object.assign(cfg, patch);
347
+ },
348
+ });
349
+ await source.pick(projectRoot);
350
+ expect(client.creates).toHaveLength(3);
351
+ expect(client.creates.map((i) => i.title)).toEqual([
352
+ "Idea 1",
353
+ "Idea 2",
354
+ "Idea 3",
355
+ ]);
356
+ });
357
+ it("ingest cycle: pending swarm still running → returns null, leaves pending", async () => {
358
+ const client = makeClient();
359
+ const cfg = makeConfig({ pendingIntrospectionSwarmId: "swarm-x" });
360
+ const patches = [];
361
+ const source = createIntrospectionSource({
362
+ ideasClient: client,
363
+ swarmCoordinator: {
364
+ create: async () => { },
365
+ getSwarm: () => ({ status: "running" }),
366
+ destroy: async () => { },
367
+ complete: async () => { },
368
+ },
369
+ getConfig: () => cfg,
370
+ updateConfig: async (_pr, patch) => {
371
+ patches.push(patch);
372
+ },
373
+ });
374
+ expect(await source.pick(projectRoot)).toBeNull();
375
+ expect(client.creates).toHaveLength(0);
376
+ // No update at all — pending stays as-is.
377
+ expect(patches).toHaveLength(0);
378
+ expect(cfg.pendingIntrospectionSwarmId).toBe("swarm-x");
379
+ });
380
+ it("ingest cycle: malformed output clears pending and posts nothing", async () => {
381
+ mkdirSync(join(projectRoot, ".clawnet", "introspection"), { recursive: true });
382
+ writeFileSync(outputPath, "this is not json at all");
383
+ const client = makeClient();
384
+ const cfg = makeConfig({ pendingIntrospectionSwarmId: "swarm-x" });
385
+ let lastPatch = null;
386
+ const source = createIntrospectionSource({
387
+ ideasClient: client,
388
+ swarmCoordinator: makeCoord({ swarmStatus: "completed" }),
389
+ getConfig: () => cfg,
390
+ updateConfig: async (_pr, patch) => {
391
+ lastPatch = patch;
392
+ Object.assign(cfg, patch);
393
+ if (patch.pendingIntrospectionSwarmId === null) {
394
+ delete cfg.pendingIntrospectionSwarmId;
395
+ }
396
+ },
397
+ });
398
+ await source.pick(projectRoot);
399
+ expect(client.creates).toHaveLength(0);
400
+ expect(lastPatch.pendingIntrospectionSwarmId).toBeNull();
401
+ expect(cfg.pendingIntrospectionSwarmId).toBeUndefined();
402
+ });
403
+ it("ingest cycle: missing output file clears pending and posts nothing", async () => {
404
+ const client = makeClient();
405
+ const cfg = makeConfig({ pendingIntrospectionSwarmId: "swarm-y" });
406
+ let lastPatch = null;
407
+ const source = createIntrospectionSource({
408
+ ideasClient: client,
409
+ swarmCoordinator: makeCoord({ swarmStatus: "completed" }),
410
+ getConfig: () => cfg,
411
+ updateConfig: async (_pr, patch) => {
412
+ lastPatch = patch;
413
+ Object.assign(cfg, patch);
414
+ if (patch.pendingIntrospectionSwarmId === null) {
415
+ delete cfg.pendingIntrospectionSwarmId;
416
+ }
417
+ },
418
+ generateSwarmId: () => "swarm-y",
419
+ });
420
+ await source.pick(projectRoot);
421
+ expect(client.creates).toHaveLength(0);
422
+ expect(lastPatch.pendingIntrospectionSwarmId).toBeNull();
423
+ });
424
+ it("spawn failure: bumps cooldown so no retry storm; pending NOT persisted", async () => {
425
+ const client = makeClient();
426
+ const patches = [];
427
+ const cfg = makeConfig();
428
+ const source = createIntrospectionSource({
429
+ ideasClient: client,
430
+ swarmCoordinator: makeCoord({
431
+ createImpl: async () => {
432
+ throw new Error("spawn failed");
433
+ },
434
+ }),
435
+ getConfig: () => cfg,
436
+ updateConfig: async (_pr, patch) => {
437
+ patches.push(patch);
438
+ Object.assign(cfg, patch);
439
+ },
440
+ clock: () => new Date("2026-06-07T03:00:00Z"),
441
+ });
442
+ await source.pick(projectRoot);
443
+ expect(client.creates).toHaveLength(0);
444
+ // Cooldown bumped; pending NOT set (the spawn was rejected by coordinator).
445
+ expect(patches[0]).toEqual({
446
+ lastIntrospectionAt: "2026-06-07T03:00:00.000Z",
447
+ lastIntrospectionRejected: false,
448
+ });
449
+ expect(cfg.pendingIntrospectionSwarmId).toBeUndefined();
450
+ });
451
+ it("S1 hotfix: hub outage during POSTs rewinds lastIntrospectionAt so retry comes sooner", async () => {
452
+ mkdirSync(join(projectRoot, ".clawnet", "introspection"), { recursive: true });
453
+ writeFileSync(outputPath, JSON.stringify([{ title: "Lonely Idea", body: "" }]));
454
+ const client = makeClient({ throwOnCreate: true });
455
+ const patches = [];
456
+ const cfg = makeConfig({
457
+ pendingIntrospectionSwarmId: "swarm-x",
458
+ lastIntrospectionAt: new Date("2026-06-07T00:00:00Z").toISOString(),
459
+ introspectionCooldownHours: 24,
460
+ });
461
+ const source = createIntrospectionSource({
462
+ ideasClient: client,
463
+ swarmCoordinator: makeCoord({ swarmStatus: "completed" }),
464
+ getConfig: () => cfg,
465
+ updateConfig: async (_pr, patch) => {
466
+ patches.push(patch);
467
+ Object.assign(cfg, patch);
468
+ },
469
+ clock: () => new Date("2026-06-08T00:00:00Z"),
470
+ });
471
+ await source.pick(projectRoot);
472
+ expect(client.creates).toHaveLength(0);
473
+ // The patch should rewind lastIntrospectionAt to (now - 23h). At
474
+ // clock=2026-06-08T00:00:00Z the rewound stamp is 2026-06-07T01:00:00Z.
475
+ expect(patches[0]).toMatchObject({
476
+ pendingIntrospectionSwarmId: null,
477
+ lastIntrospectionAt: "2026-06-07T01:00:00.000Z",
478
+ });
479
+ });
480
+ });
481
+ // ── R3 I-A: terminal-state cleanup covers all 4 statuses ───────────────
482
+ describe("IntrospectionSource terminal-state cleanup (R3 I-A)", () => {
483
+ let tmpHome;
484
+ let projectRoot;
485
+ let outputPath;
486
+ beforeEach(() => {
487
+ tmpHome = mkdtempSync(join(tmpdir(), "introspect-r3-"));
488
+ projectRoot = tmpHome;
489
+ outputPath = join(projectRoot, ".clawnet", "introspection", "swarm-r3.json");
490
+ mkdirSync(join(projectRoot, ".clawnet", "introspection"), { recursive: true });
491
+ writeFileSync(outputPath, JSON.stringify([{ title: "x", body: "" }]));
492
+ });
493
+ afterEach(() => {
494
+ rmSync(tmpHome, { recursive: true, force: true });
495
+ });
496
+ it("status=completed → calls coordinator.complete (not fail)", async () => {
497
+ const completes = [];
498
+ const fails = [];
499
+ const cfg = makeConfig({ pendingIntrospectionSwarmId: "swarm-r3" });
500
+ const source = createIntrospectionSource({
501
+ ideasClient: makeClient(),
502
+ swarmCoordinator: makeCoord({
503
+ swarmStatus: "completed",
504
+ onComplete: (id) => completes.push(id),
505
+ onFail: (id) => fails.push(id),
506
+ }),
507
+ getConfig: () => cfg,
508
+ updateConfig: async (_pr, patch) => {
509
+ Object.assign(cfg, patch);
510
+ if ("pendingIntrospectionSwarmId" in patch && patch.pendingIntrospectionSwarmId === null) {
511
+ delete cfg.pendingIntrospectionSwarmId;
512
+ }
513
+ },
514
+ });
515
+ await source.pick(projectRoot);
516
+ expect(completes).toEqual(["swarm-r3"]);
517
+ expect(fails).toEqual([]);
518
+ expect(cfg.pendingIntrospectionSwarmId).toBeUndefined();
519
+ });
520
+ it("status=failed → calls coordinator.fail (not complete)", async () => {
521
+ const completes = [];
522
+ const fails = [];
523
+ const cfg = makeConfig({ pendingIntrospectionSwarmId: "swarm-r3" });
524
+ const source = createIntrospectionSource({
525
+ ideasClient: makeClient(),
526
+ swarmCoordinator: makeCoord({
527
+ swarmStatus: "failed",
528
+ onComplete: (id) => completes.push(id),
529
+ onFail: (id) => fails.push(id),
530
+ }),
531
+ getConfig: () => cfg,
532
+ updateConfig: async (_pr, patch) => {
533
+ Object.assign(cfg, patch);
534
+ if ("pendingIntrospectionSwarmId" in patch && patch.pendingIntrospectionSwarmId === null) {
535
+ delete cfg.pendingIntrospectionSwarmId;
536
+ }
537
+ },
538
+ });
539
+ await source.pick(projectRoot);
540
+ expect(fails).toEqual(["swarm-r3"]);
541
+ expect(completes).toEqual([]);
542
+ expect(cfg.pendingIntrospectionSwarmId).toBeUndefined();
543
+ });
544
+ it("status=cancelled → no coordinator call (already terminal externally) but pending cleared", async () => {
545
+ const completes = [];
546
+ const fails = [];
547
+ const cfg = makeConfig({ pendingIntrospectionSwarmId: "swarm-r3" });
548
+ const source = createIntrospectionSource({
549
+ ideasClient: makeClient(),
550
+ swarmCoordinator: makeCoord({
551
+ swarmStatus: "cancelled",
552
+ onComplete: (id) => completes.push(id),
553
+ onFail: (id) => fails.push(id),
554
+ }),
555
+ getConfig: () => cfg,
556
+ updateConfig: async (_pr, patch) => {
557
+ Object.assign(cfg, patch);
558
+ if ("pendingIntrospectionSwarmId" in patch && patch.pendingIntrospectionSwarmId === null) {
559
+ delete cfg.pendingIntrospectionSwarmId;
560
+ }
561
+ },
562
+ });
563
+ await source.pick(projectRoot);
564
+ expect(completes).toEqual([]);
565
+ expect(fails).toEqual([]);
566
+ expect(cfg.pendingIntrospectionSwarmId).toBeUndefined();
567
+ });
568
+ it("status=unknown (gone from registry) → no coordinator call but pending cleared", async () => {
569
+ const completes = [];
570
+ const fails = [];
571
+ const cfg = makeConfig({ pendingIntrospectionSwarmId: "swarm-r3" });
572
+ const source = createIntrospectionSource({
573
+ ideasClient: makeClient(),
574
+ swarmCoordinator: makeCoord({
575
+ swarmStatus: null, // → getSwarm returns undefined → status=unknown
576
+ onComplete: (id) => completes.push(id),
577
+ onFail: (id) => fails.push(id),
578
+ }),
579
+ getConfig: () => cfg,
580
+ updateConfig: async (_pr, patch) => {
581
+ Object.assign(cfg, patch);
582
+ if ("pendingIntrospectionSwarmId" in patch && patch.pendingIntrospectionSwarmId === null) {
583
+ delete cfg.pendingIntrospectionSwarmId;
584
+ }
585
+ },
586
+ });
587
+ await source.pick(projectRoot);
588
+ expect(completes).toEqual([]);
589
+ expect(fails).toEqual([]);
590
+ expect(cfg.pendingIntrospectionSwarmId).toBeUndefined();
591
+ });
592
+ });
593
+ // ── Self-suppression ───────────────────────────────────────────────────
594
+ describe("IntrospectionSource self-suppression", () => {
595
+ it("sets lastIntrospectionRejected=true when all previous cohort archived", async () => {
596
+ const lastAt = new Date("2026-06-06T00:00:00Z").toISOString();
597
+ const cohort = [
598
+ makeIdea({
599
+ id: "i1",
600
+ status: "archived",
601
+ sourceRole: INTROSPECTION_SOURCE_ROLE,
602
+ tags: [INTROSPECTION_SOURCE_TAG],
603
+ createdAt: "2026-06-06T00:01:00Z",
604
+ }),
605
+ makeIdea({
606
+ id: "i2",
607
+ status: "archived",
608
+ sourceRole: INTROSPECTION_SOURCE_ROLE,
609
+ tags: [INTROSPECTION_SOURCE_TAG],
610
+ createdAt: "2026-06-06T00:02:00Z",
611
+ }),
612
+ ];
613
+ const cfg = makeConfig({
614
+ lastIntrospectionAt: lastAt,
615
+ lastIntrospectionRejected: false,
616
+ });
617
+ const patches = [];
618
+ const source = createIntrospectionSource({
619
+ ideasClient: makeClient({ listResults: cohort }),
620
+ swarmCoordinator: makeCoord(),
621
+ getConfig: () => cfg,
622
+ updateConfig: async (_pr, patch) => {
623
+ patches.push(patch);
624
+ Object.assign(cfg, patch);
625
+ },
626
+ clock: () => new Date("2026-06-08T00:00:00Z"), // past 24h cooldown
627
+ });
628
+ const result = await source.pick(PROJECT_ROOT);
629
+ expect(result).toBeNull();
630
+ expect(patches[0]).toEqual({ lastIntrospectionRejected: true });
631
+ });
632
+ it("clears lastIntrospectionRejected when ≥1 previous idea survives", async () => {
633
+ const lastAt = new Date("2026-06-06T00:00:00Z").toISOString();
634
+ const cohort = [
635
+ makeIdea({
636
+ id: "i1",
637
+ status: "todo", // promoted by user
638
+ sourceRole: INTROSPECTION_SOURCE_ROLE,
639
+ tags: [INTROSPECTION_SOURCE_TAG],
640
+ createdAt: "2026-06-06T00:01:00Z",
641
+ }),
642
+ makeIdea({
643
+ id: "i2",
644
+ status: "archived",
645
+ sourceRole: INTROSPECTION_SOURCE_ROLE,
646
+ tags: [INTROSPECTION_SOURCE_TAG],
647
+ createdAt: "2026-06-06T00:02:00Z",
648
+ }),
649
+ ];
650
+ const cfg = makeConfig({
651
+ lastIntrospectionAt: lastAt,
652
+ lastIntrospectionRejected: true, // stale flag from prior cohort
653
+ });
654
+ const patches = [];
655
+ const source = createIntrospectionSource({
656
+ ideasClient: makeClient({ listResults: cohort }),
657
+ swarmCoordinator: makeCoord(),
658
+ getConfig: () => cfg,
659
+ updateConfig: async (_pr, patch) => {
660
+ patches.push(patch);
661
+ Object.assign(cfg, patch);
662
+ },
663
+ clock: () => new Date("2026-06-08T00:00:00Z"),
664
+ });
665
+ await source.pick(PROJECT_ROOT);
666
+ // First patch clears the suppression flag.
667
+ expect(patches[0]).toEqual({ lastIntrospectionRejected: false });
668
+ // Cycle then runs to spawn → bumps the cooldown breadcrumbs +
669
+ // pendingIntrospectionSwarmId.
670
+ expect(patches.at(-1)).toMatchObject({
671
+ lastIntrospectionAt: expect.any(String),
672
+ lastIntrospectionRejected: false,
673
+ pendingIntrospectionSwarmId: expect.any(String),
674
+ });
675
+ });
676
+ it("ignores cohort entries older than lastIntrospectionAt (with epsilon)", async () => {
677
+ const lastAt = new Date("2026-06-06T12:00:00Z").toISOString();
678
+ const cohort = [
679
+ makeIdea({
680
+ id: "old-archived",
681
+ status: "archived",
682
+ sourceRole: INTROSPECTION_SOURCE_ROLE,
683
+ tags: [INTROSPECTION_SOURCE_TAG],
684
+ createdAt: "2026-06-01T00:00:00Z", // way before lastIntrospectionAt
685
+ }),
686
+ makeIdea({
687
+ id: "new-todo",
688
+ status: "todo",
689
+ sourceRole: INTROSPECTION_SOURCE_ROLE,
690
+ tags: [INTROSPECTION_SOURCE_TAG],
691
+ createdAt: "2026-06-06T12:00:30Z", // within epsilon of lastIntrospectionAt
692
+ }),
693
+ ];
694
+ const cfg = makeConfig({
695
+ lastIntrospectionAt: lastAt,
696
+ });
697
+ const patches = [];
698
+ const source = createIntrospectionSource({
699
+ ideasClient: makeClient({ listResults: cohort }),
700
+ swarmCoordinator: makeCoord(),
701
+ getConfig: () => cfg,
702
+ updateConfig: async (_pr, patch) => {
703
+ patches.push(patch);
704
+ Object.assign(cfg, patch);
705
+ },
706
+ clock: () => new Date("2026-06-08T00:00:00Z"),
707
+ });
708
+ await source.pick(PROJECT_ROOT);
709
+ // The "new-todo" entry survives the filter and proves the cohort isn't
710
+ // all-archived → flag should be false, not true.
711
+ expect(patches[0]).toEqual({ lastIntrospectionRejected: false });
712
+ });
713
+ });
714
+ // ── B2c: spawner-owned lifecycle (Framing B) ───────────────────────────
715
+ //
716
+ // Verifies that IntrospectionSource declares "owned" lifecycle, exposes a
717
+ // public isComplete() hook, and that drainPending forces the coordinator
718
+ // into a terminal state when the spawner-side completion signal (output
719
+ // file + mtime guard, or hard timeout) is observed — even if the swarm is
720
+ // stuck in `paused` or `running` because the coordinator's idle-fallback
721
+ // hasn't triggered yet.
722
+ describe("IntrospectionSource spawner-owned lifecycle (B2c)", () => {
723
+ let tmpHome;
724
+ let projectRoot;
725
+ let outputPath;
726
+ let nowMs;
727
+ let spawnIso;
728
+ beforeEach(() => {
729
+ tmpHome = mkdtempSync(join(tmpdir(), "introspect-b2c-"));
730
+ projectRoot = tmpHome;
731
+ outputPath = join(projectRoot, ".clawnet", "introspection", "swarm-b2c.json");
732
+ mkdirSync(join(projectRoot, ".clawnet", "introspection"), { recursive: true });
733
+ // Anchor all time math to wall-clock now so file mtime and injected
734
+ // clock share the same axis. Each test sets nowMs forward as needed
735
+ // (e.g. +60s past mtime guard, +3h past hard timeout) using offsets
736
+ // relative to spawnIso.
737
+ nowMs = Date.now();
738
+ spawnIso = new Date(nowMs).toISOString();
739
+ });
740
+ afterEach(() => {
741
+ rmSync(tmpHome, { recursive: true, force: true });
742
+ });
743
+ it("exposes isComplete (public spawner-owned signal hook)", () => {
744
+ const source = createIntrospectionSource({
745
+ ideasClient: makeClient(),
746
+ swarmCoordinator: makeCoord(),
747
+ getConfig: () => makeConfig(),
748
+ updateConfig: async () => { },
749
+ });
750
+ expect(typeof source.isComplete).toBe("function");
751
+ });
752
+ it("isComplete: false when no output file and within hard timeout", async () => {
753
+ const cfg = makeConfig({ lastIntrospectionAt: spawnIso });
754
+ const source = createIntrospectionSource({
755
+ ideasClient: makeClient(),
756
+ swarmCoordinator: makeCoord(),
757
+ getConfig: () => cfg,
758
+ updateConfig: async () => { },
759
+ clock: () => new Date(nowMs + SPAWNER_COMPLETE_HARD_TIMEOUT_MS / 2),
760
+ });
761
+ expect(await source.isComplete(projectRoot, "swarm-b2c")).toBe(false);
762
+ });
763
+ it("isComplete: false when output just written (mtime guard)", async () => {
764
+ writeFileSync(outputPath, JSON.stringify([{ title: "x" }]));
765
+ const cfg = makeConfig({ lastIntrospectionAt: spawnIso });
766
+ const source = createIntrospectionSource({
767
+ ideasClient: makeClient(),
768
+ swarmCoordinator: makeCoord(),
769
+ getConfig: () => cfg,
770
+ updateConfig: async () => { },
771
+ // clock pinned to inside the mtime guard window
772
+ clock: () => new Date(nowMs + Math.floor(SPAWNER_COMPLETE_MTIME_GUARD_MS / 2)),
773
+ });
774
+ expect(await source.isComplete(projectRoot, "swarm-b2c")).toBe(false);
775
+ });
776
+ it("isComplete: true when output stable beyond mtime guard", async () => {
777
+ writeFileSync(outputPath, JSON.stringify([{ title: "x" }]));
778
+ const cfg = makeConfig({ lastIntrospectionAt: spawnIso });
779
+ const source = createIntrospectionSource({
780
+ ideasClient: makeClient(),
781
+ swarmCoordinator: makeCoord(),
782
+ getConfig: () => cfg,
783
+ updateConfig: async () => { },
784
+ clock: () => new Date(nowMs + SPAWNER_COMPLETE_MTIME_GUARD_MS + 1_000),
785
+ });
786
+ expect(await source.isComplete(projectRoot, "swarm-b2c")).toBe(true);
787
+ });
788
+ it("isComplete: true when hard timeout elapsed even without output", async () => {
789
+ const cfg = makeConfig({ lastIntrospectionAt: spawnIso });
790
+ const source = createIntrospectionSource({
791
+ ideasClient: makeClient(),
792
+ swarmCoordinator: makeCoord(),
793
+ getConfig: () => cfg,
794
+ updateConfig: async () => { },
795
+ clock: () => new Date(nowMs + SPAWNER_COMPLETE_HARD_TIMEOUT_MS + 60_000),
796
+ });
797
+ expect(await source.isComplete(projectRoot, "swarm-b2c")).toBe(true);
798
+ });
799
+ it("drainPending: swarm=paused + isComplete=false → no force, no ingest, pendingId kept", async () => {
800
+ // No output file → isComplete=false even though swarm is non-terminal
801
+ const completes = [];
802
+ const cfg = makeConfig({
803
+ lastIntrospectionAt: spawnIso,
804
+ pendingIntrospectionSwarmId: "swarm-b2c",
805
+ });
806
+ const patches = [];
807
+ const source = createIntrospectionSource({
808
+ ideasClient: makeClient(),
809
+ swarmCoordinator: makeCoord({
810
+ swarmStatus: "paused",
811
+ onComplete: (id) => completes.push(id),
812
+ }),
813
+ getConfig: () => cfg,
814
+ updateConfig: async (_pr, patch) => {
815
+ patches.push(patch);
816
+ },
817
+ clock: () => new Date(nowMs + Math.floor(SPAWNER_COMPLETE_HARD_TIMEOUT_MS / 4)),
818
+ });
819
+ await source.drainPending(projectRoot);
820
+ expect(completes).toEqual([]); // no force
821
+ expect(patches).toEqual([]); // pendingId not cleared
822
+ });
823
+ it("drainPending: swarm=paused + isComplete=true → force complete + R3 I-A skips (production parity)", async () => {
824
+ writeFileSync(outputPath, JSON.stringify([
825
+ { title: "i1", body: "b1" },
826
+ { title: "i2", body: "b2" },
827
+ ]));
828
+ const completes = [];
829
+ const creates = [];
830
+ const cfg = makeConfig({
831
+ lastIntrospectionAt: spawnIso,
832
+ pendingIntrospectionSwarmId: "swarm-b2c",
833
+ });
834
+ const patches = [];
835
+ const source = createIntrospectionSource({
836
+ ideasClient: makeClient({ onCreate: (i) => creates.push(i) }),
837
+ // mutateStatusOnComplete=true: mirrors real coord which calls
838
+ // runRetroAndCleanup → swarms.delete after marking completed. So the
839
+ // post-complete isTerminalSwarm sees "unknown" (swarm gone), NOT
840
+ // "completed". The R3 I-A cleanup chain in processPendingSwarm
841
+ // then correctly skips firing a second complete() — which is the
842
+ // ACTUAL production behaviour. An earlier round of this test had a
843
+ // fake that lied about this and asserted 2 calls, masking the real
844
+ // single-call flow. See PR #170 Round-3 review.
845
+ swarmCoordinator: makeCoord({
846
+ swarmStatus: "paused",
847
+ mutateStatusOnComplete: true,
848
+ onComplete: (id) => completes.push(id),
849
+ }),
850
+ getConfig: () => cfg,
851
+ updateConfig: async (_pr, patch) => {
852
+ patches.push(patch);
853
+ Object.assign(cfg, patch);
854
+ },
855
+ clock: () => new Date(nowMs + SPAWNER_COMPLETE_MTIME_GUARD_MS + 1_000),
856
+ });
857
+ await source.drainPending(projectRoot);
858
+ // Exactly once: the spawner-owned force-complete. R3 I-A sees "unknown"
859
+ // (registry evicted) and skips its own complete/fail branches.
860
+ // Catches both regression directions — missing the force AND
861
+ // accidentally double-firing the cleanup chain.
862
+ expect(completes).toEqual(["swarm-b2c"]);
863
+ expect(creates).toHaveLength(2);
864
+ expect(creates.map((c) => c.title)).toEqual(["i1", "i2"]);
865
+ const clearPatch = patches.find((p) => "pendingIntrospectionSwarmId" in p);
866
+ expect(clearPatch?.pendingIntrospectionSwarmId).toBeNull();
867
+ });
868
+ it("drainPending: spawner-owned complete throws → destroy fallback + ingest still proceeds", async () => {
869
+ writeFileSync(outputPath, JSON.stringify([{ title: "still-ingested" }]));
870
+ const creates = [];
871
+ const destroys = [];
872
+ const cfg = makeConfig({
873
+ lastIntrospectionAt: spawnIso,
874
+ pendingIntrospectionSwarmId: "swarm-b2c",
875
+ });
876
+ const patches = [];
877
+ const coord = makeCoord({
878
+ swarmStatus: "paused",
879
+ completeThrows: true,
880
+ onDestroy: (id) => destroys.push(id),
881
+ });
882
+ const source = createIntrospectionSource({
883
+ ideasClient: makeClient({ onCreate: (i) => creates.push(i) }),
884
+ swarmCoordinator: coord,
885
+ getConfig: () => cfg,
886
+ updateConfig: async (_pr, patch) => {
887
+ patches.push(patch);
888
+ Object.assign(cfg, patch);
889
+ },
890
+ clock: () => new Date(nowMs + SPAWNER_COMPLETE_MTIME_GUARD_MS + 1_000),
891
+ });
892
+ // Should NOT throw — best-effort transition + destroy fallback
893
+ await source.drainPending(projectRoot);
894
+ // Registry leak prevention: destroy was called AND the registry
895
+ // actually evicted the swarm (fake mirrors real coord behaviour).
896
+ expect(destroys).toEqual(["swarm-b2c"]);
897
+ expect(coord.getSwarm("swarm-b2c")).toBeUndefined();
898
+ expect(creates).toHaveLength(1); // ingest happened anyway
899
+ const clearPatch = patches.find((p) => "pendingIntrospectionSwarmId" in p);
900
+ expect(clearPatch?.pendingIntrospectionSwarmId).toBeNull();
901
+ });
902
+ });
903
+ // ── PR-B follow-up: workspace.cwd race fix ─────────────────────────────
904
+ //
905
+ // After PR-B Sub-PR-B2 added the queen-driven `swarm_complete` path, the
906
+ // coordinator deletes the swarm from its registry BEFORE the next tick's
907
+ // `processPendingSwarm` runs. `resolveActualOutputPath` previously relied
908
+ // on `swarmCoordinator.getSwarm(swarmId).workspace?.cwd` to find where the
909
+ // queen wrote candidates — post-delete it returns undefined and the
910
+ // fallback (projectRoot) finds no file → silent 0 ingest. Fix: persist
911
+ // `workspace.cwd` to `always-on.json` alongside `pendingIntrospectionSwarmId`
912
+ // at spawn time, and have the resolver prefer the persisted value when
913
+ // `getSwarm()` is empty.
914
+ describe("IntrospectionSource workspace.cwd persistence (PR-B race fix)", () => {
915
+ let tmpHome;
916
+ let projectRoot;
917
+ let worktreeCwd;
918
+ beforeEach(() => {
919
+ tmpHome = mkdtempSync(join(tmpdir(), "introspect-race-"));
920
+ projectRoot = tmpHome;
921
+ worktreeCwd = join(tmpHome, ".worktrees", "swarm-x");
922
+ mkdirSync(join(worktreeCwd, ".clawnet", "introspection"), { recursive: true });
923
+ });
924
+ afterEach(() => {
925
+ rmSync(tmpHome, { recursive: true, force: true });
926
+ });
927
+ it("spawn cycle persists pendingIntrospectionWorkspaceCwd when WorkspaceManager assigns a cwd", async () => {
928
+ const client = makeClient();
929
+ const created = vi.fn(async (_swarmId, _opts) => { });
930
+ const patches = [];
931
+ const cfg = makeConfig();
932
+ // Custom coord that returns workspace.cwd on getSwarm so the spawn
933
+ // path can capture it. Mirrors WorkspaceManager.create having already
934
+ // assigned a workspace before swarmCoordinator.create returns.
935
+ const coord = {
936
+ create: created,
937
+ getSwarm: (_id) => ({ status: "running", workspace: { cwd: worktreeCwd } }),
938
+ destroy: async () => { },
939
+ complete: async () => { },
940
+ fail: async () => { },
941
+ };
942
+ const source = createIntrospectionSource({
943
+ ideasClient: client,
944
+ swarmCoordinator: coord,
945
+ getConfig: () => cfg,
946
+ updateConfig: async (_pr, patch) => {
947
+ patches.push(patch);
948
+ Object.assign(cfg, patch);
949
+ },
950
+ generateSwarmId: () => "swarm-x",
951
+ clock: () => new Date("2026-06-07T00:00:00Z"),
952
+ });
953
+ await source.pick(projectRoot);
954
+ // Final patch carries both the swarm id and the workspace cwd.
955
+ expect(patches.at(-1)).toMatchObject({
956
+ pendingIntrospectionSwarmId: "swarm-x",
957
+ pendingIntrospectionWorkspaceCwd: worktreeCwd,
958
+ });
959
+ });
960
+ it("ingest reads candidates from persisted workspace.cwd when swarm is gone from registry (B2 path)", async () => {
961
+ // Queen wrote 3 candidates to the worktree; swarm_complete has already
962
+ // run and deleted the swarm from the registry. The pendingIntrospection
963
+ // breadcrumb + persisted cwd are still on disk.
964
+ const queenOutput = join(worktreeCwd, ".clawnet", "introspection", "swarm-x.json");
965
+ writeFileSync(queenOutput, JSON.stringify([
966
+ { title: "inFlight leak fix", body: "raise watchdog" },
967
+ { title: "true-release auto-pause", body: "closeSession + dispose" },
968
+ { title: "continuationSwarmId", body: "carry conversation across cycles" },
969
+ ]));
970
+ const client = makeClient();
971
+ const cfg = makeConfig({
972
+ pendingIntrospectionSwarmId: "swarm-x",
973
+ pendingIntrospectionWorkspaceCwd: worktreeCwd,
974
+ // Spawn far enough ago to clear isOwnerComplete mtime guard, and far
975
+ // enough behind the hard timeout that we don't trip the hard-timeout
976
+ // branch by accident.
977
+ lastIntrospectionAt: new Date("2026-06-07T00:00:00Z").toISOString(),
978
+ });
979
+ // Coordinator no longer has the swarm — swarm_complete already deleted
980
+ // it. getSwarm returns undefined; isTerminalSwarm reports "unknown".
981
+ const coord = makeCoord({ swarmStatus: null });
982
+ const patches = [];
983
+ const source = createIntrospectionSource({
984
+ ideasClient: client,
985
+ swarmCoordinator: coord,
986
+ getConfig: () => cfg,
987
+ updateConfig: async (_pr, patch) => {
988
+ patches.push(patch);
989
+ Object.assign(cfg, patch);
990
+ if ("pendingIntrospectionSwarmId" in patch && patch.pendingIntrospectionSwarmId === null) {
991
+ delete cfg.pendingIntrospectionSwarmId;
992
+ }
993
+ if ("pendingIntrospectionWorkspaceCwd" in patch && patch.pendingIntrospectionWorkspaceCwd === null) {
994
+ delete cfg.pendingIntrospectionWorkspaceCwd;
995
+ }
996
+ },
997
+ // Far enough past spawnAt that the mtime guard (5s) has elapsed,
998
+ // but well within hard timeout (2h).
999
+ clock: () => new Date("2026-06-07T00:00:10Z"),
1000
+ });
1001
+ await source.drainPending(projectRoot);
1002
+ // ALL 3 candidates were ingested — the fix saves the cohort that
1003
+ // would otherwise be silently lost.
1004
+ expect(client.creates).toHaveLength(3);
1005
+ expect(client.creates.map((c) => c.title)).toEqual([
1006
+ "inFlight leak fix",
1007
+ "true-release auto-pause",
1008
+ "continuationSwarmId",
1009
+ ]);
1010
+ // Both breadcrumbs cleared in lockstep.
1011
+ expect(cfg.pendingIntrospectionSwarmId).toBeUndefined();
1012
+ expect(cfg.pendingIntrospectionWorkspaceCwd).toBeUndefined();
1013
+ const clearPatch = patches.find((p) => "pendingIntrospectionSwarmId" in p);
1014
+ expect(clearPatch?.pendingIntrospectionWorkspaceCwd).toBeNull();
1015
+ });
1016
+ it("regression: without persisted cwd, post-delete drain silently loses candidates (control)", async () => {
1017
+ // Same setup as above EXCEPT pendingIntrospectionWorkspaceCwd is absent
1018
+ // — this is the pre-fix state. Without persisted cwd, the resolver
1019
+ // falls back to projectRoot/.clawnet/introspection/, where the file
1020
+ // does NOT live, so 0 candidates are ingested. This test guards the
1021
+ // fix by demonstrating the bug it solves.
1022
+ const queenOutput = join(worktreeCwd, ".clawnet", "introspection", "swarm-x.json");
1023
+ writeFileSync(queenOutput, JSON.stringify([{ title: "would-be-lost", body: "" }]));
1024
+ const client = makeClient();
1025
+ const cfg = makeConfig({
1026
+ pendingIntrospectionSwarmId: "swarm-x",
1027
+ // pendingIntrospectionWorkspaceCwd intentionally absent
1028
+ lastIntrospectionAt: new Date("2026-06-07T00:00:00Z").toISOString(),
1029
+ });
1030
+ const coord = makeCoord({ swarmStatus: null });
1031
+ const source = createIntrospectionSource({
1032
+ ideasClient: client,
1033
+ swarmCoordinator: coord,
1034
+ getConfig: () => cfg,
1035
+ updateConfig: async (_pr, patch) => {
1036
+ Object.assign(cfg, patch);
1037
+ if ("pendingIntrospectionSwarmId" in patch && patch.pendingIntrospectionSwarmId === null) {
1038
+ delete cfg.pendingIntrospectionSwarmId;
1039
+ }
1040
+ },
1041
+ clock: () => new Date("2026-06-07T00:00:10Z"),
1042
+ });
1043
+ await source.drainPending(projectRoot);
1044
+ // Bug-state: candidate file existed but resolver looked at wrong path
1045
+ // → silent 0 ingest, breadcrumb still cleared. This is exactly the
1046
+ // failure mode that the persisted cwd fix above prevents.
1047
+ expect(client.creates).toHaveLength(0);
1048
+ expect(cfg.pendingIntrospectionSwarmId).toBeUndefined();
1049
+ });
1050
+ });
1051
+ //# sourceMappingURL=introspection-source.test.js.map