@edspencer/paddock 0.0.1 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (294) hide show
  1. package/README.md +69 -0
  2. package/install-notice.mjs +29 -0
  3. package/package.json +58 -5
  4. package/packages/server/dist/adoptable.d.ts +127 -0
  5. package/packages/server/dist/adoptable.js +315 -0
  6. package/packages/server/dist/app.d.ts +67 -0
  7. package/packages/server/dist/app.js +325 -0
  8. package/packages/server/dist/archive.d.ts +42 -0
  9. package/packages/server/dist/archive.js +125 -0
  10. package/packages/server/dist/attachments-config.d.ts +114 -0
  11. package/packages/server/dist/attachments-config.js +165 -0
  12. package/packages/server/dist/attachments-hint.d.ts +53 -0
  13. package/packages/server/dist/attachments-hint.js +140 -0
  14. package/packages/server/dist/attachments.d.ts +33 -0
  15. package/packages/server/dist/attachments.js +117 -0
  16. package/packages/server/dist/auth.d.ts +84 -0
  17. package/packages/server/dist/auth.js +247 -0
  18. package/packages/server/dist/background.d.ts +36 -0
  19. package/packages/server/dist/background.js +137 -0
  20. package/packages/server/dist/bind-safety.d.ts +56 -0
  21. package/packages/server/dist/bind-safety.js +51 -0
  22. package/packages/server/dist/brand.d.ts +44 -0
  23. package/packages/server/dist/brand.js +88 -0
  24. package/packages/server/dist/chat-dto.d.ts +210 -0
  25. package/packages/server/dist/chat-dto.js +308 -0
  26. package/packages/server/dist/cli/import-chats.d.ts +21 -0
  27. package/packages/server/dist/cli/import-chats.js +197 -0
  28. package/packages/server/dist/cli/paddock.d.ts +2 -0
  29. package/packages/server/dist/cli/paddock.js +247 -0
  30. package/packages/server/dist/concurrency.d.ts +57 -0
  31. package/packages/server/dist/concurrency.js +71 -0
  32. package/packages/server/dist/config.d.ts +512 -0
  33. package/packages/server/dist/config.js +593 -0
  34. package/packages/server/dist/curation-config.d.ts +55 -0
  35. package/packages/server/dist/curation-config.js +64 -0
  36. package/packages/server/dist/event-bus.d.ts +87 -0
  37. package/packages/server/dist/event-bus.js +48 -0
  38. package/packages/server/dist/git.d.ts +128 -0
  39. package/packages/server/dist/git.js +446 -0
  40. package/packages/server/dist/github-auth.d.ts +44 -0
  41. package/packages/server/dist/github-auth.js +169 -0
  42. package/packages/server/dist/herdctl-agent-config.d.ts +98 -0
  43. package/packages/server/dist/herdctl-agent-config.js +331 -0
  44. package/packages/server/dist/herdctl-agent-names.d.ts +172 -0
  45. package/packages/server/dist/herdctl-agent-names.js +228 -0
  46. package/packages/server/dist/herdctl-jobs-index.d.ts +65 -0
  47. package/packages/server/dist/herdctl-jobs-index.js +225 -0
  48. package/packages/server/dist/herdctl-jobs.d.ts +98 -0
  49. package/packages/server/dist/herdctl-jobs.js +225 -0
  50. package/packages/server/dist/herdctl.d.ts +572 -0
  51. package/packages/server/dist/herdctl.js +1218 -0
  52. package/packages/server/dist/hook-config.d.ts +217 -0
  53. package/packages/server/dist/hook-config.js +224 -0
  54. package/packages/server/dist/http-bytes.d.ts +24 -0
  55. package/packages/server/dist/http-bytes.js +52 -0
  56. package/packages/server/dist/index.d.ts +1 -0
  57. package/packages/server/dist/index.js +14 -0
  58. package/packages/server/dist/instance-config.d.ts +135 -0
  59. package/packages/server/dist/instance-config.js +345 -0
  60. package/packages/server/dist/localcommand.d.ts +23 -0
  61. package/packages/server/dist/localcommand.js +140 -0
  62. package/packages/server/dist/management-auth.d.ts +99 -0
  63. package/packages/server/dist/management-auth.js +165 -0
  64. package/packages/server/dist/management-config.d.ts +164 -0
  65. package/packages/server/dist/management-config.js +286 -0
  66. package/packages/server/dist/management-mcp-server.d.ts +20 -0
  67. package/packages/server/dist/management-mcp-server.js +95 -0
  68. package/packages/server/dist/management-metadata.d.ts +67 -0
  69. package/packages/server/dist/management-metadata.js +56 -0
  70. package/packages/server/dist/management-ops.d.ts +110 -0
  71. package/packages/server/dist/management-ops.js +449 -0
  72. package/packages/server/dist/management-policy.d.ts +172 -0
  73. package/packages/server/dist/management-policy.js +259 -0
  74. package/packages/server/dist/message-provenance.d.ts +112 -0
  75. package/packages/server/dist/message-provenance.js +260 -0
  76. package/packages/server/dist/models.d.ts +164 -0
  77. package/packages/server/dist/models.js +208 -0
  78. package/packages/server/dist/openapi.d.ts +66 -0
  79. package/packages/server/dist/openapi.js +183 -0
  80. package/packages/server/dist/parent-detach.d.ts +30 -0
  81. package/packages/server/dist/parent-detach.js +109 -0
  82. package/packages/server/dist/preload.d.ts +43 -0
  83. package/packages/server/dist/preload.js +55 -0
  84. package/packages/server/dist/project-files.d.ts +76 -0
  85. package/packages/server/dist/project-files.js +191 -0
  86. package/packages/server/dist/project-mime.d.ts +35 -0
  87. package/packages/server/dist/project-mime.js +55 -0
  88. package/packages/server/dist/project-paths.d.ts +88 -0
  89. package/packages/server/dist/project-paths.js +126 -0
  90. package/packages/server/dist/project-types.d.ts +316 -0
  91. package/packages/server/dist/project-types.js +48 -0
  92. package/packages/server/dist/projects.d.ts +202 -0
  93. package/packages/server/dist/projects.js +894 -0
  94. package/packages/server/dist/queued-message.d.ts +35 -0
  95. package/packages/server/dist/queued-message.js +116 -0
  96. package/packages/server/dist/read-state.d.ts +46 -0
  97. package/packages/server/dist/read-state.js +139 -0
  98. package/packages/server/dist/recovery-config.d.ts +111 -0
  99. package/packages/server/dist/recovery-config.js +105 -0
  100. package/packages/server/dist/recovery.d.ts +262 -0
  101. package/packages/server/dist/recovery.js +490 -0
  102. package/packages/server/dist/resume-drain.d.ts +122 -0
  103. package/packages/server/dist/resume-drain.js +158 -0
  104. package/packages/server/dist/route-context.d.ts +168 -0
  105. package/packages/server/dist/route-context.js +39 -0
  106. package/packages/server/dist/route-errors.d.ts +8 -0
  107. package/packages/server/dist/route-errors.js +13 -0
  108. package/packages/server/dist/routes/chats.d.ts +12 -0
  109. package/packages/server/dist/routes/chats.js +1406 -0
  110. package/packages/server/dist/routes/git.d.ts +13 -0
  111. package/packages/server/dist/routes/git.js +236 -0
  112. package/packages/server/dist/routes/mcp.d.ts +83 -0
  113. package/packages/server/dist/routes/mcp.js +250 -0
  114. package/packages/server/dist/routes/meta.d.ts +15 -0
  115. package/packages/server/dist/routes/meta.js +447 -0
  116. package/packages/server/dist/routes/projects.d.ts +25 -0
  117. package/packages/server/dist/routes/projects.js +736 -0
  118. package/packages/server/dist/routes/triggers.d.ts +21 -0
  119. package/packages/server/dist/routes/triggers.js +324 -0
  120. package/packages/server/dist/routes/workspace-mount.d.ts +48 -0
  121. package/packages/server/dist/routes/workspace-mount.js +32 -0
  122. package/packages/server/dist/routes.d.ts +26 -0
  123. package/packages/server/dist/routes.js +29 -0
  124. package/packages/server/dist/run-provenance.d.ts +109 -0
  125. package/packages/server/dist/run-provenance.js +206 -0
  126. package/packages/server/dist/runs.d.ts +92 -0
  127. package/packages/server/dist/runs.js +72 -0
  128. package/packages/server/dist/schedule-config.d.ts +68 -0
  129. package/packages/server/dist/schedule-config.js +75 -0
  130. package/packages/server/dist/schedule-session.d.ts +34 -0
  131. package/packages/server/dist/schedule-session.js +120 -0
  132. package/packages/server/dist/self-mcp-descriptions.d.ts +20 -0
  133. package/packages/server/dist/self-mcp-descriptions.js +145 -0
  134. package/packages/server/dist/self-mcp-read.d.ts +15 -0
  135. package/packages/server/dist/self-mcp-read.js +97 -0
  136. package/packages/server/dist/self-mcp-triggers.d.ts +30 -0
  137. package/packages/server/dist/self-mcp-triggers.js +144 -0
  138. package/packages/server/dist/self-mcp-types.d.ts +248 -0
  139. package/packages/server/dist/self-mcp-types.js +10 -0
  140. package/packages/server/dist/self-mcp-util.d.ts +73 -0
  141. package/packages/server/dist/self-mcp-util.js +157 -0
  142. package/packages/server/dist/self-mcp-write.d.ts +35 -0
  143. package/packages/server/dist/self-mcp-write.js +215 -0
  144. package/packages/server/dist/self-mcp.d.ts +95 -0
  145. package/packages/server/dist/self-mcp.js +448 -0
  146. package/packages/server/dist/send-file-mcp.d.ts +46 -0
  147. package/packages/server/dist/send-file-mcp.js +281 -0
  148. package/packages/server/dist/session-hub.d.ts +186 -0
  149. package/packages/server/dist/session-hub.js +291 -0
  150. package/packages/server/dist/spawn-capability.d.ts +99 -0
  151. package/packages/server/dist/spawn-capability.js +106 -0
  152. package/packages/server/dist/star.d.ts +26 -0
  153. package/packages/server/dist/star.js +94 -0
  154. package/packages/server/dist/start.d.ts +1 -0
  155. package/packages/server/dist/start.js +21 -0
  156. package/packages/server/dist/subagents.d.ts +337 -0
  157. package/packages/server/dist/subagents.js +626 -0
  158. package/packages/server/dist/sweep.d.ts +122 -0
  159. package/packages/server/dist/sweep.js +696 -0
  160. package/packages/server/dist/tooldetails.d.ts +33 -0
  161. package/packages/server/dist/tooldetails.js +466 -0
  162. package/packages/server/dist/transcribe.d.ts +69 -0
  163. package/packages/server/dist/transcribe.js +119 -0
  164. package/packages/server/dist/transcripts.d.ts +42 -0
  165. package/packages/server/dist/transcripts.js +164 -0
  166. package/packages/server/dist/trigger-config.d.ts +278 -0
  167. package/packages/server/dist/trigger-config.js +417 -0
  168. package/packages/server/dist/trigger-runtime.d.ts +94 -0
  169. package/packages/server/dist/trigger-runtime.js +82 -0
  170. package/packages/server/dist/trigger-session.d.ts +34 -0
  171. package/packages/server/dist/trigger-session.js +122 -0
  172. package/packages/server/dist/triggers.d.ts +83 -0
  173. package/packages/server/dist/triggers.js +124 -0
  174. package/packages/server/dist/trusted-proxy.d.ts +57 -0
  175. package/packages/server/dist/trusted-proxy.js +157 -0
  176. package/packages/server/dist/turn-notice.d.ts +154 -0
  177. package/packages/server/dist/turn-notice.js +271 -0
  178. package/packages/server/dist/unread.d.ts +36 -0
  179. package/packages/server/dist/unread.js +123 -0
  180. package/packages/server/dist/usage.d.ts +66 -0
  181. package/packages/server/dist/usage.js +243 -0
  182. package/packages/server/dist/wake-injection.d.ts +156 -0
  183. package/packages/server/dist/wake-injection.js +136 -0
  184. package/packages/server/dist/whisper-local.d.ts +9 -0
  185. package/packages/server/dist/whisper-local.js +122 -0
  186. package/packages/server/dist/ws-context.d.ts +158 -0
  187. package/packages/server/dist/ws-context.js +1 -0
  188. package/packages/server/dist/ws-protocol.d.ts +423 -0
  189. package/packages/server/dist/ws-protocol.js +251 -0
  190. package/packages/server/dist/ws-self-mcp.d.ts +52 -0
  191. package/packages/server/dist/ws-self-mcp.js +28 -0
  192. package/packages/server/dist/ws-triggers.d.ts +29 -0
  193. package/packages/server/dist/ws-triggers.js +261 -0
  194. package/packages/server/dist/ws-turn.d.ts +86 -0
  195. package/packages/server/dist/ws-turn.js +751 -0
  196. package/packages/server/dist/ws.d.ts +62 -0
  197. package/packages/server/dist/ws.js +1024 -0
  198. package/packages/server/package.json +6 -0
  199. package/packages/web/dist/assets/InstanceConfigPage-4cQGTj9Y.js +2 -0
  200. package/packages/web/dist/assets/MarkdownRenderer-CJZFLj_8.js +30 -0
  201. package/packages/web/dist/assets/ProjectRedirect-BHwVbbqB.js +2 -0
  202. package/packages/web/dist/assets/ProjectView-BOvuAITg.js +13 -0
  203. package/packages/web/dist/assets/ProjectsGrid-BCojZOGY.js +2 -0
  204. package/packages/web/dist/assets/arc-BVyxvnkl.js +2 -0
  205. package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-Cd9Puqg1.js +37 -0
  206. package/packages/web/dist/assets/bash-I8pq0VWm.js +2 -0
  207. package/packages/web/dist/assets/blockDiagram-GPEHLZMM-B_AQt_Rx.js +133 -0
  208. package/packages/web/dist/assets/c-D2F1sQUL.js +2 -0
  209. package/packages/web/dist/assets/c4Diagram-AAUBKEIU-7e0TQtXB.js +11 -0
  210. package/packages/web/dist/assets/channel-C_1aISNn.js +2 -0
  211. package/packages/web/dist/assets/chunk-2J33WTMH-Dz4WZmUZ.js +2 -0
  212. package/packages/web/dist/assets/chunk-4BX2VUAB-DVWYldo6.js +2 -0
  213. package/packages/web/dist/assets/chunk-55IACEB6-BIMrizQd.js +2 -0
  214. package/packages/web/dist/assets/chunk-727SXJPM-CL2q9fF1.js +207 -0
  215. package/packages/web/dist/assets/chunk-AQP2D5EJ-BSq3HlQ9.js +232 -0
  216. package/packages/web/dist/assets/chunk-FMBD7UC4-Bgt64pLE.js +16 -0
  217. package/packages/web/dist/assets/chunk-ND2GUHAM-C8Pr2A38.js +2 -0
  218. package/packages/web/dist/assets/chunk-QZHKN3VN-BAOqh-MN.js +2 -0
  219. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-B8JE7cHJ.js +2 -0
  220. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-B8JE7cHJ.js +2 -0
  221. package/packages/web/dist/assets/core-lJeGACQg.js +4 -0
  222. package/packages/web/dist/assets/cose-bilkent-S5V4N54A-BF4tameA.js +2 -0
  223. package/packages/web/dist/assets/cpp-Bm3ii6kY.js +2 -0
  224. package/packages/web/dist/assets/csharp-BNpuYZMG.js +2 -0
  225. package/packages/web/dist/assets/css-DazXZka4.js +2 -0
  226. package/packages/web/dist/assets/cytoscape.esm-CUqq0XTU.js +332 -0
  227. package/packages/web/dist/assets/dagre-BM42HDAG-DdsLM6ql.js +5 -0
  228. package/packages/web/dist/assets/defaultLocale-DX6XiGOO.js +2 -0
  229. package/packages/web/dist/assets/diagram-2AECGRRQ-BQ78S_RM.js +44 -0
  230. package/packages/web/dist/assets/diagram-5GNKFQAL-CAKE6nHE.js +11 -0
  231. package/packages/web/dist/assets/diagram-KO2AKTUF-fEvHRXgr.js +4 -0
  232. package/packages/web/dist/assets/diagram-LMA3HP47-CtWsgeSm.js +25 -0
  233. package/packages/web/dist/assets/diagram-OG6HWLK6-Buog8k7_.js +25 -0
  234. package/packages/web/dist/assets/erDiagram-TEJ5UH35-CWvVVPZS.js +86 -0
  235. package/packages/web/dist/assets/flowDiagram-I6XJVG4X-DsCGXtp4.js +163 -0
  236. package/packages/web/dist/assets/format-DamJQ9LO.js +2 -0
  237. package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-CsdwIvvC.js +293 -0
  238. package/packages/web/dist/assets/gitGraphDiagram-PVQCEYII-hZvrX1sD.js +107 -0
  239. package/packages/web/dist/assets/go-BPx_y7j8.js +2 -0
  240. package/packages/web/dist/assets/graph-BlwPajkw.js +2 -0
  241. package/packages/web/dist/assets/index-CGbZdrm8.js +71 -0
  242. package/packages/web/dist/assets/index-DJDQ210-.css +1 -0
  243. package/packages/web/dist/assets/infoDiagram-5YYISTIA-nE_Vtz_l.js +3 -0
  244. package/packages/web/dist/assets/ini-B3BVgEsG.js +2 -0
  245. package/packages/web/dist/assets/init-Gi6I4Gst.js +2 -0
  246. package/packages/web/dist/assets/ishikawaDiagram-YF4QCWOH-2Nlqs0oH.js +71 -0
  247. package/packages/web/dist/assets/java-B5VKqtH2.js +2 -0
  248. package/packages/web/dist/assets/javascript-BKRaQes9.js +2 -0
  249. package/packages/web/dist/assets/journeyDiagram-JHISSGLW-mjU1fMc4.js +140 -0
  250. package/packages/web/dist/assets/json-DIYVocXf.js +2 -0
  251. package/packages/web/dist/assets/kanban-definition-UN3LZRKU-C_9xNaa3.js +90 -0
  252. package/packages/web/dist/assets/katex-C5jXJg4s.js +258 -0
  253. package/packages/web/dist/assets/kotlin-Cw-eF_bm.js +3 -0
  254. package/packages/web/dist/assets/lastTab-CU1zsF0j.js +2 -0
  255. package/packages/web/dist/assets/layout-Boy8vhSP.js +2 -0
  256. package/packages/web/dist/assets/linear-BxKd91ho.js +2 -0
  257. package/packages/web/dist/assets/mermaid.core-C-CuhD8I.js +310 -0
  258. package/packages/web/dist/assets/mindmap-definition-RKZ34NQL-Bs6yfcZv.js +97 -0
  259. package/packages/web/dist/assets/ordinal-Cboi1Yqb.js +2 -0
  260. package/packages/web/dist/assets/php-CRdd4dfS.js +3 -0
  261. package/packages/web/dist/assets/pieDiagram-4H26LBE5-DNdqX7O5.js +31 -0
  262. package/packages/web/dist/assets/python-kSlZHhJb.js +2 -0
  263. package/packages/web/dist/assets/quadrantDiagram-W4KKPZXB-ZMJl9OdZ.js +8 -0
  264. package/packages/web/dist/assets/queued-LamEU0GH.js +2 -0
  265. package/packages/web/dist/assets/requirementDiagram-4Y6WPE33-RmxMdm2q.js +85 -0
  266. package/packages/web/dist/assets/ruby-7T4ktxyv.js +2 -0
  267. package/packages/web/dist/assets/rust-HC3MBUhY.js +2 -0
  268. package/packages/web/dist/assets/sankeyDiagram-5OEKKPKP-OAAhWcfK.js +41 -0
  269. package/packages/web/dist/assets/scss-DZLA7YXz.js +2 -0
  270. package/packages/web/dist/assets/sequenceDiagram-3UESZ5HK-BWF7rCyN.js +163 -0
  271. package/packages/web/dist/assets/sql-BC88IN-V.js +2 -0
  272. package/packages/web/dist/assets/stateDiagram-AJRCARHV-M3b_zqqo.js +2 -0
  273. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CI7saFDa.js +2 -0
  274. package/packages/web/dist/assets/swift-Dn8kKO_1.js +2 -0
  275. package/packages/web/dist/assets/timeline-definition-PNZ67QCA-BpOCh63N.js +121 -0
  276. package/packages/web/dist/assets/typescript-C2FFdlUC.js +2 -0
  277. package/packages/web/dist/assets/vennDiagram-CIIHVFJN-D_iPH45Q.js +35 -0
  278. package/packages/web/dist/assets/wardley-L42UT6IY-DVD9I2Om.js +174 -0
  279. package/packages/web/dist/assets/wardleyDiagram-YWT4CUSO-Bn1IKwjn.js +79 -0
  280. package/packages/web/dist/assets/xml-BXBhIUeX.js +2 -0
  281. package/packages/web/dist/assets/xychartDiagram-2RQKCTM6-DwtI0bkR.js +8 -0
  282. package/packages/web/dist/assets/yaml-drsDnIKT.js +2 -0
  283. package/packages/web/dist/favicon.ico +0 -0
  284. package/packages/web/dist/fonts/inter-latin.woff2 +0 -0
  285. package/packages/web/dist/fonts/jetbrains-mono-latin.woff2 +0 -0
  286. package/packages/web/dist/icons/apple-touch-icon.png +0 -0
  287. package/packages/web/dist/icons/favicon-16.png +0 -0
  288. package/packages/web/dist/icons/favicon-32.png +0 -0
  289. package/packages/web/dist/icons/icon-192.png +0 -0
  290. package/packages/web/dist/icons/icon-512.png +0 -0
  291. package/packages/web/dist/icons/maskable-512.png +0 -0
  292. package/packages/web/dist/index.html +56 -0
  293. package/packages/web/dist/manifest.webmanifest +32 -0
  294. package/packages/web/dist/sw.js +139 -0
@@ -0,0 +1,696 @@
1
+ /**
2
+ * SweepService — the post-turn overview + changelog curation engine
3
+ * (GitHub issues #2 + #6).
4
+ *
5
+ * After a USER chat turn completes, ws.ts calls `enqueue(slug)`. The service:
6
+ *
7
+ * 1. COALESCES bursts per project: at most one sweep per project per
8
+ * `minIntervalMs` (default 5 min). Rapid turns collapse into a single
9
+ * trailing sweep that runs once the interval has elapsed.
10
+ * 2. SKIPS when there's no new activity since the last sweep — tracked by a
11
+ * per-project watermark (the max keeper-session mtime + the time of the
12
+ * last successful sweep) persisted in a small state file under the data
13
+ * dir, so it survives restarts.
14
+ * 3. Runs OUT OF BAND via a dedicated lightweight sweeper agent (cheap model,
15
+ * Read/Write/Glob/Grep only) whose working_directory is the project dir.
16
+ * The trigger uses HerdctlService.runSweeper (triggerType "manual"), NOT
17
+ * the WS user-chat path, so a sweep can NEVER enqueue another sweep — no
18
+ * recursion.
19
+ *
20
+ * The sweeper is TOOL-LESS: it returns the curated content as plain text in
21
+ * marked sections (`<<<OVERVIEW>>> ... <<<CHANGELOG>>> ... <<<CLAUDE>>> ...
22
+ * <<<END>>>`), and THIS service parses that text and writes the files itself.
23
+ * Each section is either a FULL replacement for that file or the literal
24
+ * NOCHANGE (issue #379): the sweeper is shown each file IN FULL (bounded only by
25
+ * a generous per-file TOKEN BUDGET) and maintains it like a human maintainer —
26
+ * regenerating OVERVIEW.md wholesale, rewriting CHANGELOG.md (add a dated entry
27
+ * only for genuinely-new activity, coalesce duplicates, drop the oldest to fit
28
+ * budget), and rewriting the CLAUDE.md "Curated notes" section (dedup against the
29
+ * whole file; human-authored content above the heading is preserved). This
30
+ * replaces the old design where the sweeper saw only the first 2000 chars of
31
+ * CHANGELOG/CLAUDE and blind-appended — which grew both files (and the per-chat
32
+ * context they feed) without bound. `SweepService` enforces each budget as a
33
+ * backstop after the model returns. If the returned text is structurally
34
+ * unparseable the sweep throws (so the mtime watermark doesn't advance and the
35
+ * next sweep retries) — no partial/garbage writes. All sweep failures are
36
+ * non-fatal: they're logged and never break chat.
37
+ */
38
+ import { promises as fs } from "node:fs";
39
+ import path from "node:path";
40
+ import { keeperAgentName } from "./herdctl.js";
41
+ import { DEFAULT_CURATION, resolveCurationConfig, } from "./curation-config.js";
42
+ import { curatorTriggerOf, triggerPromptFileAbsPath, } from "./trigger-config.js";
43
+ const STATE_FILE = "sweep-state.json";
44
+ const DEFAULT_MIN_INTERVAL_MS = 5 * 60 * 1000;
45
+ /**
46
+ * Optional per-project sweeper-instruction file (issue #G2). Project-relative
47
+ * (resolved inside the project's metadata dir, the sweeper's cwd); git-tracked
48
+ * and keeper-editable. When present, its content is appended to the sweeper's
49
+ * curation prompt; when absent, sweep behaviour is unchanged.
50
+ */
51
+ const SWEEP_INSTRUCTIONS_FILE = ".paddock/hooks/sweep.md";
52
+ /**
53
+ * Generous size cap (chars) for the appended sweep-instruction file. Every other
54
+ * project-controlled field folded into the curation prompt is bounded
55
+ * (changelog/claudeMd via `trim(_, 2000)`, digest via `slice(-40)`/`trim(_, 600)`)
56
+ * so prompt/token cost stays predictable; this keeps a pathologically large
57
+ * `.paddock/hooks/sweep.md` from bloating every sweep. The cap is large enough
58
+ * that any realistic instruction file passes through untouched — and unlike the
59
+ * `trim(...)` helper (which flattens all whitespace to single spaces), it
60
+ * preserves the file's markdown line structure and only truncates the overflow.
61
+ */
62
+ const SWEEP_INSTRUCTIONS_MAX = 8000;
63
+ /**
64
+ * Approximate chars-per-token used to convert a curation TOKEN budget (issue
65
+ * #379) into a char bound for the prompt view + write-time enforcement. English
66
+ * prose/markdown is ~3.5–4 chars/token; 4 is a deliberate slight over-estimate so
67
+ * the char bound is generous (we never under-show the sweeper its own files).
68
+ */
69
+ const TOKENS_TO_CHARS = 4;
70
+ /**
71
+ * Max recent sessions folded into one sweep's digest (issue #379 concurrency
72
+ * fix). The old sweep read only the newest 3 sessions but advanced the watermark
73
+ * past ALL of them, so a 4th+ chat active within the same debounce window was
74
+ * silently never curated. We now digest every session newer than the watermark,
75
+ * capped here so a burst of concurrent chats can't unbound the prompt.
76
+ */
77
+ const MAX_DIGEST_SESSIONS = 6;
78
+ /**
79
+ * Whether post-turn curation is ON for a project. A project that has NOT overridden the
80
+ * default (no curator trigger) → ON (behaves exactly as today). A project that DECLARES
81
+ * a `curate-overview` trigger uses its `enabled` flag authoritatively — so curation can
82
+ * be switched OFF by declaring the trigger `enabled: false` (and, per the frozen T1
83
+ * default of `enabled: false`, a declared curator must set `enabled: true` to sweep).
84
+ */
85
+ export function isCurationEnabled(project) {
86
+ const t = curatorTriggerOf(project.triggers);
87
+ return t ? t.enabled === true : true;
88
+ }
89
+ export class SweepService {
90
+ herdctl;
91
+ projects;
92
+ stateFile;
93
+ minIntervalMs;
94
+ instanceCuration;
95
+ log;
96
+ /** In-memory watermark cache (loaded lazily, written through on change). */
97
+ state = null;
98
+ /** Pending trailing-sweep timers, keyed by slug (coalescing/debounce). */
99
+ timers = new Map();
100
+ /** Slugs with a sweep currently executing (prevents overlap). */
101
+ running = new Set();
102
+ constructor(opts) {
103
+ this.herdctl = opts.herdctl;
104
+ this.projects = opts.projects;
105
+ this.stateFile = path.join(opts.dataDir, STATE_FILE);
106
+ // `PADDOCK_SWEEP_MIN_INTERVAL_MS` is now folded into PaddockConfig (issue
107
+ // #269) and passed in as `minIntervalMs`; the default applies when unset.
108
+ this.minIntervalMs = opts.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
109
+ this.instanceCuration = opts.budget ?? DEFAULT_CURATION;
110
+ this.log = opts.logger ?? consoleLogger();
111
+ }
112
+ /**
113
+ * Request a sweep for a project after a user turn completed. Coalesced and
114
+ * debounced: schedules a single trailing sweep at most once per
115
+ * `minIntervalMs`. Safe to call on every turn. Never throws.
116
+ */
117
+ enqueue(slug) {
118
+ void this.schedule(slug).catch((err) => {
119
+ this.log.warn({ err, slug }, "sweep: enqueue failed (non-fatal)");
120
+ });
121
+ }
122
+ /** Stop all pending timers (call on shutdown). */
123
+ stop() {
124
+ for (const t of this.timers.values())
125
+ clearTimeout(t);
126
+ this.timers.clear();
127
+ }
128
+ // --- scheduling --------------------------------------------------------
129
+ async schedule(slug) {
130
+ // Already a trailing sweep pending for this project -> this turn is folded
131
+ // into it (coalesced). Nothing more to do.
132
+ if (this.timers.has(slug))
133
+ return;
134
+ const wm = await this.watermark(slug);
135
+ const elapsed = Date.now() - wm.lastSweptAt;
136
+ const delay = Math.max(0, this.minIntervalMs - elapsed);
137
+ // Schedule the (single) trailing sweep. If we're past the interval, delay
138
+ // is 0 and it runs on the next tick; otherwise it waits out the remainder
139
+ // so bursts within the window collapse into one sweep at the boundary.
140
+ const timer = setTimeout(() => {
141
+ this.timers.delete(slug);
142
+ void this.runIfActivity(slug).catch((err) => {
143
+ this.log.warn({ err, slug }, "sweep: run failed (non-fatal)");
144
+ });
145
+ }, delay);
146
+ // Don't keep the process alive just for a pending sweep.
147
+ if (typeof timer.unref === "function")
148
+ timer.unref();
149
+ this.timers.set(slug, timer);
150
+ }
151
+ /** Run a sweep only if there's new activity since the last one. */
152
+ async runIfActivity(slug) {
153
+ if (this.running.has(slug)) {
154
+ // A sweep is mid-flight; coalesce by re-arming once it finishes.
155
+ this.enqueue(slug);
156
+ return;
157
+ }
158
+ let project;
159
+ try {
160
+ project = await this.projects.get(slug);
161
+ }
162
+ catch {
163
+ return; // project deleted between enqueue and run — drop silently.
164
+ }
165
+ // T5: the sweeper is the default `curate-overview` (event/afterTurn) trigger. A
166
+ // project that declares it `enabled: false` has switched curation OFF — skip
167
+ // entirely (no sweep, no watermark churn). Absent trigger ⇒ implicit default ⇒ ON,
168
+ // so an un-customised project is unaffected.
169
+ if (!isCurationEnabled(project)) {
170
+ this.log.info({ slug }, "sweep: skipped (curate-overview trigger disabled)");
171
+ return;
172
+ }
173
+ const sessions = await this.herdctl.recentSessions(project, 10).catch(() => []);
174
+ const newestMtime = sessions.reduce((max, s) => {
175
+ return !max || s.mtime > max ? s.mtime : max;
176
+ }, null);
177
+ const wm = await this.watermark(slug);
178
+ // Skip if nothing newer than the last sweep watermark.
179
+ if (newestMtime !== null && wm.lastSweptSessionMtime !== null) {
180
+ if (newestMtime <= wm.lastSweptSessionMtime) {
181
+ this.log.info({ slug }, "sweep: skipped (no new activity)");
182
+ return;
183
+ }
184
+ }
185
+ if (newestMtime === null) {
186
+ this.log.info({ slug }, "sweep: skipped (no sessions yet)");
187
+ return;
188
+ }
189
+ this.running.add(slug);
190
+ try {
191
+ await this.runSweep(project, sessions, wm.lastSweptSessionMtime);
192
+ // Stamp the watermark so a follow-up sweep with no new activity is
193
+ // skipped, and the interval clock restarts.
194
+ await this.setWatermark(slug, {
195
+ lastSweptSessionMtime: newestMtime,
196
+ lastSweptAt: Date.now(),
197
+ });
198
+ }
199
+ catch (err) {
200
+ // Non-fatal: still stamp lastSweptAt so we don't hot-loop on a failing
201
+ // project, but DON'T advance the session-mtime watermark (so the next
202
+ // sweep retries the same activity).
203
+ await this.setWatermark(slug, {
204
+ lastSweptSessionMtime: wm.lastSweptSessionMtime,
205
+ lastSweptAt: Date.now(),
206
+ });
207
+ this.log.error({ err, slug }, "sweep: sweeper run errored (non-fatal)");
208
+ }
209
+ finally {
210
+ this.running.delete(slug);
211
+ }
212
+ }
213
+ // --- the sweep itself --------------------------------------------------
214
+ async runSweep(project, sessions, sinceMtime) {
215
+ const [overview, changelog, claudeMd, digest, fileInstructions, triggerInstructions] = await Promise.all([
216
+ this.projects.readOverview(project.slug),
217
+ this.projects.readFile(project.slug, "CHANGELOG.md").catch(() => ""),
218
+ this.projects.readClaudeMd(project.slug).catch(() => ""),
219
+ this.buildDigest(project, sessions, sinceMtime),
220
+ this.readSweepInstructions(project.slug),
221
+ this.readTriggerInstructions(project),
222
+ ]);
223
+ // The curator trigger's own prompt extension (design §2.1 #4) and the git-tracked
224
+ // `.paddock/hooks/sweep.md` are two channels of the SAME per-project curator
225
+ // guidance — fold both under the one `=== EXTRA … ===` heading (trigger first).
226
+ const extraInstructions = [triggerInstructions, fileInstructions]
227
+ .filter((s) => s.length > 0)
228
+ .join("\n\n");
229
+ // Effective budgets for THIS project (issue #384): a `project.yaml` `curation`
230
+ // override wins field-by-field, else the instance default.
231
+ const budget = resolveCurationConfig(project.curation, this.instanceCuration);
232
+ const prompt = this.curationPrompt({
233
+ project,
234
+ overview,
235
+ changelog,
236
+ claudeMd,
237
+ digest,
238
+ extraInstructions,
239
+ budget,
240
+ });
241
+ const { result, text } = await this.herdctl.runSweeper(project.slug, prompt);
242
+ if (!result.success) {
243
+ throw result.error ?? new Error("sweeper trigger reported failure");
244
+ }
245
+ // The sweeper is tool-less: it returns the three marked sections and THIS
246
+ // service writes the files (issue #379). Each section is either a full
247
+ // replacement or the literal NOCHANGE (→ null). Throw on structurally-
248
+ // unparseable output so the watermark doesn't advance and the next sweep
249
+ // retries (no partial writes).
250
+ const parsed = parseSweeperOutput(text);
251
+ if (!parsed) {
252
+ this.log.warn({ slug: project.slug, sessionId: result.sessionId, jobId: result.jobId }, "sweep: sweeper output missing/unparseable markers — not writing");
253
+ throw new Error("sweeper output missing OVERVIEW/CHANGELOG markers");
254
+ }
255
+ // OVERVIEW.md — regenerated wholesale (unchanged model), box-conventions
256
+ // stripped (#42) and bounded to its token budget as a backstop (#379).
257
+ // NOCHANGE (null) leaves the existing file untouched.
258
+ if (parsed.overview !== null) {
259
+ const clean = enforceHeadBudget(stripBoxConventions(parsed.overview), budgetChars(budget, "overviewMaxTokens"));
260
+ await this.projects.writeOverview(project.slug, clean);
261
+ }
262
+ // CHANGELOG.md — wholesale replace with the sweeper's curated full file
263
+ // (add/coalesce/prune), bounded to its budget by dropping the oldest whole
264
+ // dated sections (#379). NOCHANGE leaves it untouched — the change-detection
265
+ // gate that kills the old "one near-duplicate bullet per sweep" spam.
266
+ if (parsed.changelog !== null) {
267
+ const bounded = enforceChangelogBudget(parsed.changelog, budgetChars(budget, "changelogMaxTokens"));
268
+ await this.projects.writeChangelog(project.slug, bounded);
269
+ }
270
+ // CLAUDE.md — the sweeper now sees the FULL file and returns the entire
271
+ // curated-notes body (dedup'd/pruned), which replaces only that managed
272
+ // section; human-authored content above `## Curated notes` is preserved.
273
+ // NOCHANGE leaves it untouched. A write failure is non-fatal (OVERVIEW/
274
+ // CHANGELOG already landed; the watermark should still advance) → warn.
275
+ //
276
+ // REPO-BACKED projects (issue #187): the CLAUDE.md is the external repo's
277
+ // OWN, upstream-owned file — the sweeper must NEVER write it (that would
278
+ // dirty the checkout and, if pushed, leak curation upstream). OVERVIEW.md +
279
+ // CHANGELOG.md are still curated (sidecarred), just not CLAUDE.md.
280
+ if (parsed.claude !== null && !project.repoBacked) {
281
+ const bounded = enforceHeadBudget(parsed.claude, budgetChars(budget, "claudeMaxTokens"));
282
+ await this.projects.writeClaudeCurated(project.slug, bounded).catch((err) => {
283
+ this.log.warn({ err, slug: project.slug }, "sweep: CLAUDE.md write failed (non-fatal)");
284
+ });
285
+ }
286
+ this.log.info({
287
+ slug: project.slug,
288
+ sessionId: result.sessionId,
289
+ jobId: result.jobId,
290
+ wroteOverview: parsed.overview !== null,
291
+ wroteChangelog: parsed.changelog !== null,
292
+ wroteClaude: parsed.claude !== null,
293
+ }, "sweep: completed");
294
+ }
295
+ /**
296
+ * Build a compact digest of recent session activity to hand to the sweeper,
297
+ * so it doesn't have to discover transcripts itself. We read the messages of
298
+ * every session newer than the last sweep's watermark (issue #379 concurrency
299
+ * fix — so a 4th+ chat active within a debounce window is no longer dropped),
300
+ * capped at {@link MAX_DIGEST_SESSIONS}. On the first sweep (no watermark) we
301
+ * fall back to the newest few. Summarizes roles + tool usage + trimmed text.
302
+ */
303
+ async buildDigest(project, sessions, sinceMtime) {
304
+ // All sessions with activity since the watermark; if none (first sweep or a
305
+ // clock edge), fall back to the newest few so we still curate something.
306
+ const fresh = sinceMtime
307
+ ? sessions.filter((s) => s.mtime > sinceMtime)
308
+ : sessions;
309
+ const recent = (fresh.length > 0 ? fresh : sessions).slice(0, MAX_DIGEST_SESSIONS);
310
+ const parts = [];
311
+ for (const s of recent) {
312
+ const messages = await this.herdctl
313
+ .sessionMessages(keeperAgentName(project.slug), s.sessionId)
314
+ .catch(() => []);
315
+ if (messages.length === 0)
316
+ continue;
317
+ const lines = [];
318
+ for (const m of messages.slice(-40)) {
319
+ if (m.role === "tool") {
320
+ const t = m.toolCall;
321
+ const summary = t?.inputSummary ? ` ${trim(t.inputSummary, 120)}` : "";
322
+ lines.push(` [tool ${t?.toolName ?? "?"}${summary}]${t?.isError ? " (error)" : ""}`);
323
+ }
324
+ else {
325
+ const text = trim(m.content, 600);
326
+ if (text)
327
+ lines.push(` ${m.role}: ${text}`);
328
+ }
329
+ }
330
+ if (lines.length > 0) {
331
+ const label = s.autoName ?? s.preview ?? s.sessionId.slice(0, 8);
332
+ parts.push(`Session "${label}" (updated ${s.mtime}):\n${lines.join("\n")}`);
333
+ }
334
+ }
335
+ return parts.length > 0 ? parts.join("\n\n") : "(no readable recent transcripts)";
336
+ }
337
+ /**
338
+ * Read the OPTIONAL per-project sweeper-instruction file
339
+ * (`.paddock/hooks/sweep.md`, issue #G2) — extra curator guidance a
340
+ * user/keeper commits to shape THIS project's OVERVIEW/CHANGELOG curation.
341
+ *
342
+ * The file is git-tracked and keeper-editable, and lives alongside the
343
+ * project's `project.yaml`/OVERVIEW.md/CHANGELOG.md in the metadata dir — the
344
+ * same directory the sweeper agent runs in (its cwd) and that `readFile`
345
+ * resolves against (traversal-guarded). When absent or blank the sweep behaves
346
+ * exactly as before (no append). Never throws: a read error is treated as "no
347
+ * extra instructions" so curation is never broken by a bad/removed file.
348
+ *
349
+ * This is a sweeper-LOCAL convenience: it only appends text to the tool-less
350
+ * curator's prompt (shaping its output), granting no new capability. It is
351
+ * deliberately NOT routed through the generic hook framework.
352
+ */
353
+ async readSweepInstructions(slug) {
354
+ const raw = await this.projects
355
+ .readFile(slug, SWEEP_INSTRUCTIONS_FILE)
356
+ .catch(() => "");
357
+ const text = raw.trim();
358
+ // Bound the size like every sibling prompt field so a huge file can't bloat
359
+ // every sweep's token cost. Realistic instruction files are well under the
360
+ // cap; only pathological ones truncate (with a warning so it's not silent).
361
+ if (text.length > SWEEP_INSTRUCTIONS_MAX) {
362
+ this.log.warn({ slug, length: text.length, cap: SWEEP_INSTRUCTIONS_MAX }, "sweep: .paddock/hooks/sweep.md exceeds cap — truncating for the prompt");
363
+ return text.slice(0, SWEEP_INSTRUCTIONS_MAX) + "\n…[truncated]";
364
+ }
365
+ return text;
366
+ }
367
+ /**
368
+ * Read the OPTIONAL curator-trigger prompt extension (T5) — the `run.prompt` /
369
+ * `run.promptFile` of the project's `curate-overview` (event/afterTurn) trigger,
370
+ * appended to the sweeper's curation prompt exactly like `.paddock/hooks/sweep.md`.
371
+ * This is how the design's #4 example (`"Also maintain GLOSSARY.md of domain terms."`)
372
+ * extends the built-in sweep. `run.promptFile` resolves under `.paddock/triggers/`
373
+ * (traversal-guarded, `.md`-only) and is read fresh at fire time, exactly as the
374
+ * generic trigger fire path does. Absent trigger / neither field / a read error ⇒ ""
375
+ * (no extra instructions), so curation is never broken by a bad/removed file. Capped
376
+ * like every sibling prompt field so it can't bloat the sweep's token cost.
377
+ */
378
+ async readTriggerInstructions(project) {
379
+ const trigger = curatorTriggerOf(project.triggers);
380
+ if (!trigger)
381
+ return "";
382
+ let body = typeof trigger.run.prompt === "string" ? trigger.run.prompt : "";
383
+ if (trigger.run.promptFile) {
384
+ const abs = triggerPromptFileAbsPath(project.workingDir, trigger.run.promptFile);
385
+ if (abs) {
386
+ const content = await fs.readFile(abs, "utf8").catch(() => null);
387
+ if (content !== null)
388
+ body = content;
389
+ }
390
+ }
391
+ const text = body.trim();
392
+ if (text.length > SWEEP_INSTRUCTIONS_MAX) {
393
+ this.log.warn({ slug: project.slug, length: text.length, cap: SWEEP_INSTRUCTIONS_MAX }, "sweep: curate-overview trigger prompt exceeds cap — truncating");
394
+ return text.slice(0, SWEEP_INSTRUCTIONS_MAX) + "\n…[truncated]";
395
+ }
396
+ return text;
397
+ }
398
+ /** The curation prompt handed to the sweeper agent. */
399
+ curationPrompt(args) {
400
+ const { project, overview, changelog, claudeMd, digest, extraInstructions, budget } = args;
401
+ const today = new Date().toISOString().slice(0, 10);
402
+ // Budgets in tokens (for the instructions the model reads) and the matching
403
+ // char bound for the FULL-FILE views below. Generous vs the old flat 2000-char
404
+ // truncation, so the model can actually see (and therefore dedup) its files.
405
+ const b = budget;
406
+ const changelogView = boundedView(changelog, budgetChars(budget, "changelogMaxTokens"));
407
+ const claudeView = boundedView(claudeMd, budgetChars(budget, "claudeMaxTokens"));
408
+ return [
409
+ `Project: ${project.name} (slug: ${project.slug})`,
410
+ project.summary ? `Summary: ${project.summary}` : "",
411
+ "",
412
+ "You are curating this project's three context files from recent chat " +
413
+ "activity. You are shown each file IN FULL (a curator that only sees a " +
414
+ "fragment re-adds things it already wrote). For each file you return " +
415
+ "either its complete new contents OR the literal NOCHANGE — never a " +
416
+ "fragment to blindly append.",
417
+ "",
418
+ "=== RECENT SESSION ACTIVITY (digest) ===",
419
+ "(This may span SEVERAL chats active since the last sweep — cover them all.)",
420
+ digest,
421
+ "",
422
+ "=== CURRENT OVERVIEW.md (full) ===",
423
+ overview || "(none yet — you are creating it for the first time)",
424
+ "",
425
+ "=== CURRENT CHANGELOG.md (full) ===",
426
+ changelogView || "(empty)",
427
+ "",
428
+ "=== CURRENT CLAUDE.md (full — durable identity & conventions) ===",
429
+ claudeView || "(none yet)",
430
+ "",
431
+ "=== YOUR TASKS ===",
432
+ "Do NOT use any tools. Output ONLY the three sections below, nothing else " +
433
+ "(no preamble, no explanation). Use these LITERAL markers exactly:",
434
+ "",
435
+ "<<<OVERVIEW>>>",
436
+ "The FULL markdown OVERVIEW.md, REPLACING the current one wholesale: a " +
437
+ "concise synthesized snapshot of the project's CURRENT state for an LLM " +
438
+ "to read at the start of a NEW chat — what the project is, key decisions/" +
439
+ "facts, open questions, next steps. NOT a changelog or per-session " +
440
+ `history. Keep it under ~${b.overviewMaxTokens} tokens. (Output NOCHANGE ` +
441
+ "only if the existing overview is already accurate and complete.)",
442
+ "<<<CHANGELOG>>>",
443
+ "The FULL updated CHANGELOG.md. If this activity is a genuinely NEW, user-" +
444
+ `visible change, add ONE bullet under a \`## ${today}\` heading at the ` +
445
+ "TOP (newest-first); reuse the heading if it's already today. Otherwise — " +
446
+ "if the activity is already captured by a recent entry (e.g. continued/" +
447
+ "repeated work, polling, minor follow-ups) — output NOCHANGE and add " +
448
+ "nothing. COALESCE near-duplicate recent bullets into one. Keep the whole " +
449
+ `file under ~${b.changelogMaxTokens} tokens by summarizing or dropping the ` +
450
+ "OLDEST entries; preserve the rest verbatim. Do NOT re-log unchanged state.",
451
+ "<<<CLAUDE>>>",
452
+ "The FULL curated-notes body for CLAUDE.md (everything that should appear " +
453
+ "under the `## Curated notes` heading) — ONLY genuinely durable facts " +
454
+ "about the project's identity/conventions (what it fundamentally is, key " +
455
+ "architectural decisions, how we work on it). You can see the whole file " +
456
+ "above, so DEDUP: fold repeated/near-duplicate notes into one, drop stale " +
457
+ `ones, and keep it under ~${b.claudeMaxTokens} tokens. Do NOT restate ` +
458
+ "current state/tasks/history (those are OVERVIEW/CHANGELOG). If nothing " +
459
+ "durable changed, output exactly NOCHANGE and nothing else in this section.",
460
+ "<<<END>>>",
461
+ "",
462
+ "IMPORTANT — OVERVIEW.md describes the PROJECT, not the box it runs on. Do " +
463
+ "NOT record box/environment operational conventions: how to run, build, " +
464
+ "start, preview, or expose a dev server; port numbers; localhost vs. dev " +
465
+ "hostnames or URLs; where to clone repos; process managers. Those are " +
466
+ "governed authoritatively by the box's own CLAUDE.md and must never be " +
467
+ "re-described, pinned, or contradicted here — a past chat mentioning a " +
468
+ "specific port or a localhost URL is NOT a project fact to record. Capture " +
469
+ "what the project IS plus its decisions, open questions, and next steps.",
470
+ "",
471
+ // Optional per-project curator instructions (`.paddock/hooks/sweep.md`,
472
+ // issue #G2): appended verbatim so a project can steer its own curation
473
+ // (e.g. "always keep a Glossary section", "note API changes prominently").
474
+ // These refine HOW to curate; they do NOT override the literal output
475
+ // markers or the box-conventions rule above.
476
+ extraInstructions
477
+ ? "=== EXTRA PROJECT-SPECIFIC CURATOR INSTRUCTIONS ===\n" +
478
+ "The following instructions were provided by this project to guide its " +
479
+ "curation. Honor them while still obeying the output format and rules " +
480
+ "above.\n\n" +
481
+ extraInstructions
482
+ : "",
483
+ "",
484
+ "Be factual and terse; do not invent details not supported by the activity.",
485
+ ]
486
+ .filter((l) => l !== "")
487
+ .join("\n");
488
+ }
489
+ // --- watermark state ---------------------------------------------------
490
+ async watermark(slug) {
491
+ const state = await this.loadState();
492
+ return state[slug] ?? { lastSweptSessionMtime: null, lastSweptAt: 0 };
493
+ }
494
+ async setWatermark(slug, wm) {
495
+ const state = await this.loadState();
496
+ state[slug] = wm;
497
+ await this.saveState(state);
498
+ }
499
+ async loadState() {
500
+ if (this.state)
501
+ return this.state;
502
+ try {
503
+ const raw = await fs.readFile(this.stateFile, "utf8");
504
+ const parsed = JSON.parse(raw);
505
+ this.state = parsed && typeof parsed === "object" ? parsed : {};
506
+ }
507
+ catch {
508
+ this.state = {};
509
+ }
510
+ return this.state;
511
+ }
512
+ async saveState(state) {
513
+ this.state = state;
514
+ try {
515
+ await fs.writeFile(this.stateFile, JSON.stringify(state, null, 2), "utf8");
516
+ }
517
+ catch (err) {
518
+ this.log.warn({ err }, "sweep: failed to persist watermark state (non-fatal)");
519
+ }
520
+ }
521
+ }
522
+ function trim(s, max) {
523
+ if (!s)
524
+ return "";
525
+ const flat = s.replace(/\s+/g, " ").trim();
526
+ return flat.length > max ? flat.slice(0, max) + "…" : flat;
527
+ }
528
+ /** A curated file's byte budget: its token budget × approximate chars-per-token. */
529
+ function budgetChars(budget, key) {
530
+ return budget[key] * TOKENS_TO_CHARS;
531
+ }
532
+ /**
533
+ * A structure-preserving bounded view of a file for the sweeper's prompt (issue
534
+ * #379) — the head (which, for a newest-first CHANGELOG, is the most recent
535
+ * history) up to `maxChars`, cut on a line boundary with a marker. Unlike the old
536
+ * `trim(_, 2000)` this does NOT flatten whitespace, so the model sees real
537
+ * markdown and can dedup against it. Budgets are generous, so most files pass
538
+ * through whole.
539
+ */
540
+ function boundedView(s, maxChars) {
541
+ if (!s)
542
+ return "";
543
+ if (s.length <= maxChars)
544
+ return s;
545
+ const cut = s.lastIndexOf("\n", maxChars);
546
+ const head = s.slice(0, cut > maxChars * 0.5 ? cut : maxChars);
547
+ return `${head.trimEnd()}\n\n…[older content truncated from this view — preserve it]`;
548
+ }
549
+ /**
550
+ * Backstop enforcement of a head-oriented budget for OVERVIEW / CLAUDE curated
551
+ * body (issue #379): if the model ignored its budget, keep the head up to
552
+ * `maxChars` (line-aligned). The model is asked to stay under budget, so this
553
+ * rarely fires.
554
+ */
555
+ function enforceHeadBudget(s, maxChars) {
556
+ if (s.length <= maxChars)
557
+ return s;
558
+ const cut = s.lastIndexOf("\n", maxChars);
559
+ return `${s.slice(0, cut > maxChars * 0.5 ? cut : maxChars).trimEnd()}\n`;
560
+ }
561
+ /**
562
+ * Backstop enforcement of the CHANGELOG budget (issue #379). CHANGELOG is
563
+ * newest-first (`## YYYY-MM-DD` sections at the top), so we keep whole leading
564
+ * sections until adding the next would exceed `maxChars`, then drop the rest and
565
+ * leave a compaction marker. This guarantees the file (and the per-chat preload
566
+ * that injects it) stays bounded even if the model returns an over-budget file.
567
+ */
568
+ function enforceChangelogBudget(body, maxChars) {
569
+ const trimmed = body.trim();
570
+ if (trimmed.length <= maxChars)
571
+ return trimmed;
572
+ // Split into leading preamble + `## `-headed sections, keeping newest first.
573
+ const lines = trimmed.split("\n");
574
+ const sections = [];
575
+ let current = [];
576
+ for (const line of lines) {
577
+ if (/^##\s/.test(line) && current.length > 0) {
578
+ sections.push(current.join("\n"));
579
+ current = [line];
580
+ }
581
+ else {
582
+ current.push(line);
583
+ }
584
+ }
585
+ if (current.length > 0)
586
+ sections.push(current.join("\n"));
587
+ const kept = [];
588
+ let used = 0;
589
+ const marker = "\n\n_[older changelog entries compacted to stay within budget]_";
590
+ for (const section of sections) {
591
+ const add = (kept.length > 0 ? 2 : 0) + section.length;
592
+ if (used + add > maxChars - marker.length && kept.length > 0)
593
+ break;
594
+ kept.push(section);
595
+ used += add;
596
+ }
597
+ return kept.join("\n\n").trimEnd() + marker;
598
+ }
599
+ /**
600
+ * Parse the tool-less sweeper's marked output (issue #379). Each of the three
601
+ * sections is either a FULL replacement for that file or the literal `NOCHANGE`:
602
+ *
603
+ * <<<OVERVIEW>>>
604
+ * ...full OVERVIEW.md (or NOCHANGE)...
605
+ * <<<CHANGELOG>>>
606
+ * ...full CHANGELOG.md body (or NOCHANGE)...
607
+ * <<<CLAUDE>>>
608
+ * ...full CLAUDE.md curated-notes body (or NOCHANGE)...
609
+ * <<<END>>>
610
+ *
611
+ * Returns `null` (→ caller retries, no partial writes) only on STRUCTURAL failure
612
+ * — the OVERVIEW or CHANGELOG marker is missing/misordered. Otherwise returns the
613
+ * three sections, each `null` when it is empty or `NOCHANGE` (→ leave that file
614
+ * untouched). `<<<CLAUDE>>>` and `<<<END>>>` remain optional (EOF accepted); a
615
+ * reply with no CLAUDE marker yields `claude: null`.
616
+ */
617
+ function parseSweeperOutput(text) {
618
+ if (!text)
619
+ return null;
620
+ const overviewIdx = text.indexOf("<<<OVERVIEW>>>");
621
+ const changelogIdx = text.indexOf("<<<CHANGELOG>>>");
622
+ if (overviewIdx === -1 || changelogIdx === -1 || changelogIdx < overviewIdx)
623
+ return null;
624
+ // The CLAUDE section (if present) closes the changelog; else <<<END>>>/EOF.
625
+ // Match "<<<CLAUDE" so a `<<<CLAUDE:NOCHANGE>>>` variant still delimits it.
626
+ const endIdx = text.indexOf("<<<END>>>", changelogIdx);
627
+ const claudeMarkerIdx = text.indexOf("<<<CLAUDE", changelogIdx);
628
+ const changelogEnd = [claudeMarkerIdx, endIdx]
629
+ .filter((i) => i !== -1)
630
+ .reduce((min, i) => (min === undefined || i < min ? i : min), undefined);
631
+ const overview = sectionOrNull(text.slice(overviewIdx + "<<<OVERVIEW>>>".length, changelogIdx));
632
+ const changelog = sectionOrNull(text.slice(changelogIdx + "<<<CHANGELOG>>>".length, changelogEnd));
633
+ let claude = null;
634
+ const claudeIdx = text.indexOf("<<<CLAUDE>>>", changelogIdx);
635
+ if (claudeIdx !== -1) {
636
+ const claudeEnd = text.indexOf("<<<END>>>", claudeIdx);
637
+ claude = sectionOrNull(text.slice(claudeIdx + "<<<CLAUDE>>>".length, claudeEnd === -1 ? undefined : claudeEnd));
638
+ }
639
+ return { overview, changelog, claude };
640
+ }
641
+ /** A trimmed section, or `null` when it is empty or the literal `NOCHANGE`. */
642
+ function sectionOrNull(raw) {
643
+ const s = raw.trim();
644
+ if (s.length === 0 || /^NOCHANGE$/i.test(s))
645
+ return null;
646
+ return s;
647
+ }
648
+ /**
649
+ * Belt-and-suspenders normalizer for issue #42: keep box/environment
650
+ * operational conventions out of a curated OVERVIEW.md even when the sweeper
651
+ * slips them in despite the prompt. OVERVIEW.md is prepended to every new chat,
652
+ * so a stray "run the dev server on localhost:4100" line there silently
653
+ * overrides the box CLAUDE.md — a self-reinforcing wrong-setup loop.
654
+ *
655
+ * This drops whole markdown sections whose heading denotes "how to run / build /
656
+ * expose a dev server on this box" (heading + body, down to the next heading of
657
+ * the same or higher level). Scope is deliberately narrow: only sections headed
658
+ * by a recognized dev-ops phrase are removed; prose elsewhere is untouched. If
659
+ * stripping would empty the document, the original is returned (we never write a
660
+ * blank OVERVIEW).
661
+ */
662
+ export function stripBoxConventions(overview) {
663
+ if (!overview)
664
+ return overview;
665
+ const out = [];
666
+ let dropAtLevel = null; // set while inside a dropped section
667
+ for (const line of overview.split("\n")) {
668
+ const h = /^(#{1,6})\s+(.*\S)\s*$/.exec(line);
669
+ if (h) {
670
+ const level = h[1].length;
671
+ // A heading at the same or higher level closes the dropped section.
672
+ if (dropAtLevel !== null && level <= dropAtLevel)
673
+ dropAtLevel = null;
674
+ if (dropAtLevel === null && BOX_OPS_HEADING.test(h[2])) {
675
+ dropAtLevel = level;
676
+ continue; // drop the heading itself
677
+ }
678
+ }
679
+ if (dropAtLevel !== null)
680
+ continue; // inside a dropped section
681
+ out.push(line);
682
+ }
683
+ const stripped = out.join("\n").replace(/\n{3,}/g, "\n\n").trim();
684
+ return stripped.length > 0 ? stripped : overview;
685
+ }
686
+ /** Headings that denote box-level "how to run a dev server here" content. */
687
+ const BOX_OPS_HEADING = /\b(local[-\s]dev(elopment)?|dev(elopment)?\s+server|preview\s+server|running\s+locally|run\s+locally|running\s+the\s+app|serving\s+the\s+app|how\s+to\s+run|local\s+setup|dev\s+environment)\b/i;
688
+ function consoleLogger() {
689
+ /* eslint-disable no-console */
690
+ return {
691
+ info: (o, m) => console.log("[sweep]", m ?? "", o),
692
+ warn: (o, m) => console.warn("[sweep]", m ?? "", o),
693
+ error: (o, m) => console.error("[sweep]", m ?? "", o),
694
+ };
695
+ /* eslint-enable no-console */
696
+ }