@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,894 @@
1
+ /**
2
+ * ProjectStore — the project layer.
3
+ *
4
+ * A "project" is a directory under `projectsRoot` containing:
5
+ * - project.yaml (metadata; schema below)
6
+ * - CHANGELOG.md (reverse-chron, curator-appended + hand-edited)
7
+ * - freeform .md / files
8
+ *
9
+ * This mirrors the documented standard at ~/herds/personal/projects/
10
+ * (see _template/ and README.md there). The directory name == slug.
11
+ *
12
+ * The slug-as-directory IS the link to Claude Code sessions: Claude stores
13
+ * transcripts under ~/.claude/projects/<cwd-with-slashes-as-dashes>/, so the
14
+ * keeper agent's working_directory (= the project dir) ties sessions to the
15
+ * project with no extra tagging.
16
+ *
17
+ * The type surface (project.yaml schema + DTOs), the MIME/file-kind helpers, and
18
+ * the pure path/slug helpers were extracted into sibling modules (issue #403) —
19
+ * `project-types.ts`, `project-mime.ts`, `project-paths.ts` — and are re-exported
20
+ * below so every existing `import { ... } from "./projects.js"` keeps resolving.
21
+ */
22
+ import { promises as fs } from "node:fs";
23
+ import path from "node:path";
24
+ import YAML from "yaml";
25
+ import { DEFAULT_MODEL, DEFAULT_PERMISSION_MODE, DEFAULT_MAX_TURNS, DEFAULT_DOCKER, isKnownModel, } from "./models.js";
26
+ import { cloneRepo } from "./git.js";
27
+ import { sanitizeSchedules } from "./schedule-config.js";
28
+ import { sanitizeHooks } from "./hook-config.js";
29
+ import { sanitizeTrigger, sanitizeTriggers, isValidTriggerName, } from "./trigger-config.js";
30
+ import { sanitizeRecoveryOverride } from "./recovery-config.js";
31
+ import { sanitizeCurationOverride } from "./curation-config.js";
32
+ import { sanitizeAttachmentsOverride } from "./attachments-config.js";
33
+ // --- re-export barrels (issue #403) ------------------------------------------
34
+ // The moved symbols are re-exported from here so external importers keep finding
35
+ // them at `./projects.js` — the same one-place-to-import discipline as before.
36
+ import { IMAGE_MIME, VIDEO_MIME, DOCUMENT_MIME, fileKind, contentTypeFor, } from "./project-mime.js";
37
+ export { IMAGE_MIME, VIDEO_MIME, DOCUMENT_MIME, fileKind, contentTypeFor };
38
+ import { SLUG_RE, ROOT_KEY, isRootKey, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, today, claudeTemplate, ProjectError, } from "./project-paths.js";
39
+ export { ROOT_KEY, isRootKey, isValidRepoUrl, repoCheckoutName, workingDirFor, slugify, ProjectError, };
40
+ import { normalizeLinks, } from "./project-types.js";
41
+ import * as projectFiles from "./project-files.js";
42
+ // Barrel: re-export all type declarations + normalizeLinks + the config-type
43
+ // re-exports (PaddockSchedule/PaddockHook/PaddockTrigger/RecoveryOverride/
44
+ // AttachmentsOverride) that project-types carries forward.
45
+ export * from "./project-types.js";
46
+ const PROJECT_FILE = "project.yaml";
47
+ const CHANGELOG_FILE = "CHANGELOG.md";
48
+ const OVERVIEW_FILE = "OVERVIEW.md";
49
+ const CLAUDE_FILE = "CLAUDE.md";
50
+ /** Heading under which the sweeper appends newly-discovered durable facts. */
51
+ const CLAUDE_CURATED_HEADING = "## Curated notes";
52
+ /** Filename of the sidecar `.gitignore` that keeps a nested checkout out of the data repo. */
53
+ const GITIGNORE_FILE = ".gitignore";
54
+ /**
55
+ * Sanitise a per-project offered-models override (issue #457 Step 2): keep only
56
+ * known catalog ids, trimmed and de-duped, order-preserving. Returns `undefined`
57
+ * when the input isn't a usable non-empty list (absent / not an array / all
58
+ * unknown), so a project with no override — or a corrupt hand-edit — cleanly
59
+ * inherits the instance list rather than persisting/exposing a bad value. The
60
+ * subset-of-instance-list check is enforced separately at PATCH time (the store
61
+ * has no `cfg` handle); this is the shape/known-id backstop.
62
+ */
63
+ function sanitizeModelsOverride(raw) {
64
+ if (!Array.isArray(raw))
65
+ return undefined;
66
+ const seen = new Set();
67
+ const ids = [];
68
+ for (const item of raw) {
69
+ if (typeof item !== "string")
70
+ continue;
71
+ const id = item.trim();
72
+ if (!id || seen.has(id) || !isKnownModel(id))
73
+ continue;
74
+ seen.add(id);
75
+ ids.push(id);
76
+ }
77
+ return ids.length > 0 ? ids : undefined;
78
+ }
79
+ export class ProjectStore {
80
+ root;
81
+ constructor(root) {
82
+ this.root = root;
83
+ }
84
+ /** Ensure the projects root exists. Call once at startup. */
85
+ async init() {
86
+ await fs.mkdir(this.root, { recursive: true });
87
+ // The root workspace always exists, so its transcripts always need ignoring
88
+ // — this is housekeeping, not a feature gate. It writes at most once.
89
+ await this.ensureRootGitignore();
90
+ }
91
+ /**
92
+ * The on-disk directory backing a workspace key.
93
+ *
94
+ * A key is a path RELATIVE to `projectsRoot`, and the root workspace's key is
95
+ * `""` — so `path.join` resolves it with no special case at all. The previous
96
+ * design needed a branch here for a reserved slug, and that branch had to be
97
+ * duplicated in `project-files.ts` (where it was missed, 404ing every root
98
+ * file route). There is now no branch to forget.
99
+ */
100
+ dirFor(key) {
101
+ return path.join(this.root, key);
102
+ }
103
+ /**
104
+ * Keep the root's `.chats/` out of the backing repo. Root chats are ordinary
105
+ * chats and get the same treatment project chats already do
106
+ * ({@link ensureSidecarGitignore}) — transcripts are append-heavy JSONL and are
107
+ * not tracked anywhere today.
108
+ */
109
+ async ensureRootGitignore() {
110
+ const file = path.join(this.root, GITIGNORE_FILE);
111
+ let existing = "";
112
+ try {
113
+ existing = await fs.readFile(file, "utf8");
114
+ }
115
+ catch {
116
+ /* no .gitignore yet */
117
+ }
118
+ const have = new Set(existing.split("\n").map((l) => l.trim()));
119
+ // Tolerate the equivalent forms an instance-wide .gitignore may already use.
120
+ if (have.has("/.chats/") || have.has(".chats/") || have.has(".chats"))
121
+ return;
122
+ const body = existing && !existing.endsWith("\n") ? `${existing}\n` : existing;
123
+ await fs.writeFile(file, `${body}${existing ? "" : "# Paddock instance data repo.\n"}` +
124
+ `# Root chat transcripts (issue #516) — not tracked, like every project's.\n/.chats/\n`, "utf8");
125
+ }
126
+ /** List all projects, newest-updated first. Skips `_`-prefixed dirs. */
127
+ async list() {
128
+ let entries;
129
+ try {
130
+ entries = await fs.readdir(this.root, { withFileTypes: true });
131
+ }
132
+ catch (err) {
133
+ if (err.code === "ENOENT")
134
+ return [];
135
+ throw err;
136
+ }
137
+ const projects = [];
138
+ for (const e of entries) {
139
+ if (!e.isDirectory())
140
+ continue;
141
+ if (e.name.startsWith("_") || e.name.startsWith("."))
142
+ continue;
143
+ const p = await this.readSafe(e.name);
144
+ if (p)
145
+ projects.push(p);
146
+ }
147
+ projects.sort((a, b) => (a.updated < b.updated ? 1 : a.updated > b.updated ? -1 : 0));
148
+ return projects;
149
+ }
150
+ /**
151
+ * Get one workspace by key. Throws `ProjectError("not_found")` if missing.
152
+ *
153
+ * The root key (`""`) always resolves — the instance's own directory is always
154
+ * there — so this never throws for the root.
155
+ */
156
+ async get(slug) {
157
+ const p = await this.readSafe(slug);
158
+ if (!p)
159
+ throw new ProjectError(`Project not found: ${slug}`, "not_found");
160
+ return p;
161
+ }
162
+ /**
163
+ * Whether a workspace exists. For a project that means a `project.yaml`
164
+ * record; the root workspace always exists, record or not.
165
+ */
166
+ async exists(slug) {
167
+ if (isRootKey(slug))
168
+ return true;
169
+ try {
170
+ await fs.access(path.join(this.dirFor(slug), PROJECT_FILE));
171
+ return true;
172
+ }
173
+ catch {
174
+ return false;
175
+ }
176
+ }
177
+ /**
178
+ * Create a project: mkdir + write project.yaml (from template) + seed
179
+ * CHANGELOG.md. Idempotency: throws ProjectError("exists") if the slug
180
+ * directory already holds a project.yaml.
181
+ */
182
+ async create(input) {
183
+ const name = input.name?.trim();
184
+ if (!name)
185
+ throw new ProjectError("Project name is required", "invalid");
186
+ const slug = (input.slug?.trim() || slugify(name));
187
+ if (!SLUG_RE.test(slug)) {
188
+ throw new ProjectError(`Invalid slug "${slug}" (must be kebab-case: a-z, 0-9, hyphens)`, "invalid");
189
+ }
190
+ if (await this.exists(slug)) {
191
+ throw new ProjectError(`Project already exists: ${slug}`, "exists");
192
+ }
193
+ // Repo-backed project (issue #187): validate the URL up front so a bad value
194
+ // never reaches `git clone` and the project isn't half-created.
195
+ const repo = input.repo?.trim() || undefined;
196
+ if (repo && !isValidRepoUrl(repo)) {
197
+ throw new ProjectError(`Invalid repo URL: ${repo}`, "invalid");
198
+ }
199
+ const now = today();
200
+ const yaml = {
201
+ name,
202
+ slug,
203
+ status: input.status ?? "active",
204
+ domain: input.domain ?? [],
205
+ // Keep `group` off the yaml when empty so unsorted projects round-trip
206
+ // without a noisy `group: ""` line (mirrors the optional `model` handling).
207
+ ...(input.group?.trim() ? { group: input.group.trim().toLowerCase() } : {}),
208
+ visibility: input.visibility ?? "public",
209
+ started: now,
210
+ updated: now,
211
+ summary: input.summary ?? "",
212
+ links: input.links ?? [],
213
+ pinned: [],
214
+ // Carry `repo` only when repo-backed (same round-trip discipline as model).
215
+ ...(repo ? { repo } : {}),
216
+ };
217
+ const dir = this.dirFor(slug);
218
+ await fs.mkdir(dir, { recursive: true });
219
+ // For a repo-backed project, clone the external repo into the nested checkout
220
+ // BEFORE writing any metadata, so a clone failure rolls the whole thing back
221
+ // (rm the freshly-made dir) rather than leaving a broken project.yaml behind.
222
+ if (repo) {
223
+ const checkoutName = repoCheckoutName(repo);
224
+ const checkoutDir = path.join(dir, checkoutName);
225
+ try {
226
+ await cloneRepo(repo, checkoutDir);
227
+ }
228
+ catch (err) {
229
+ await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined);
230
+ throw new ProjectError(err instanceof Error ? err.message : `Failed to clone ${repo}`, "invalid");
231
+ }
232
+ // Keep the nested checkout (a full git repo) OUT of the instance data repo:
233
+ // a sidecar `.gitignore` ignores the checkout dir (git-in-git per #187's
234
+ // option A). `.chats/` (our transcript store) is likewise data-repo noise.
235
+ await this.ensureSidecarGitignore(dir, checkoutName);
236
+ }
237
+ await this.writeYaml(slug, yaml);
238
+ await fs.writeFile(path.join(dir, CHANGELOG_FILE), [
239
+ `# Changelog — ${name}`,
240
+ "",
241
+ "<!--",
242
+ "Reverse-chronological. Newest entry on top, under a `## YYYY-MM-DD` heading.",
243
+ "-->",
244
+ "",
245
+ `## ${now}`,
246
+ "- Project opened.",
247
+ "",
248
+ ].join("\n"), "utf8");
249
+ // Seed a minimal per-project CLAUDE.md (issue #177) — but ONLY for a NOTEBOOK
250
+ // project. A repo-backed project defers to the repo's OWN CLAUDE.md (loaded
251
+ // natively via the cwd walk-up from the checkout); the sweeper must never
252
+ // touch that upstream-owned file, so we don't seed a competing one (#187).
253
+ if (!repo) {
254
+ await fs.writeFile(path.join(dir, CLAUDE_FILE), claudeTemplate(name, yaml.summary), "utf8");
255
+ }
256
+ // No OVERVIEW.md at creation — the first sweep writes it.
257
+ return this.toDto(dir, yaml, false);
258
+ }
259
+ /**
260
+ * Promote an existing NOTEBOOK project into a REPO-BACKED one IN PLACE (issue
261
+ * #213), preserving its chats + sidecar metadata. This relaxes #187's create-time
262
+ * `repo` immutability on this ONE path: an existing project (a subdir of the data
263
+ * repo) gains an external git repo as its keeper's working directory.
264
+ *
265
+ * What it does (mirrors `create()`'s repo-backed branch, but non-destructively):
266
+ * 1. Clone the repo into the nested checkout `<dir>/<repo-name>/` — FIRST, so a
267
+ * clone failure rolls back (rm just the checkout) and leaves the notebook
268
+ * wholly intact (project.yaml, `.chats/`, OVERVIEW/CHANGELOG untouched).
269
+ * 2. Write the sidecar `.gitignore` (`/<repo-name>/` + `/.chats/`) so the
270
+ * checkout + transcript store stay out of the data repo.
271
+ * 3. Set `repo:` in project.yaml → the DTO flips to repo-backed and the keeper's
272
+ * cwd becomes the checkout ({@link workingDirFor}).
273
+ * 4. Drop the sweeper-owned per-project `CLAUDE.md`: a repo-backed project defers
274
+ * to the repo's OWN `CLAUDE.md` (loaded natively from the checkout). Leaving
275
+ * the notebook's would leak into the checkout's cwd walk-up (the metadata dir
276
+ * is an ancestor of the nested checkout) — so it's removed (it survives in the
277
+ * data repo's git history).
278
+ *
279
+ * The existing chats need NO transcript surgery: they already live in `<dir>/.chats/`;
280
+ * the caller's {@link import("./herdctl.js").HerdctlService.ensureProjectAgent} re-runs
281
+ * `ensureProjectChats(newWorkingDir, dir)` which re-symlinks the new cwd's encoded
282
+ * transcript path at that same `.chats/` store, so every chat stays listed + resumable
283
+ * (issue #213 open-question #1, resolved: Claude Code tolerates recorded-cwd ≠ process-cwd).
284
+ *
285
+ * Guards: throws `ProjectError("invalid")` for a not-yet-notebook (already
286
+ * repo-backed) project or a bad URL, and `ProjectError("exists")` if a
287
+ * `<repo-name>/` directory is already present (never clobber existing files).
288
+ */
289
+ async promote(slug, repoUrl) {
290
+ // The root workspace IS `projectsRoot`, already the instance's own backing
291
+ // repo — cloning a second repo inside it is never what's meant.
292
+ if (isRootKey(slug)) {
293
+ throw new ProjectError("The root workspace cannot be repo-backed", "invalid");
294
+ }
295
+ const current = await this.get(slug); // throws not_found
296
+ if (current.repoBacked) {
297
+ throw new ProjectError(`Project is already repo-backed: ${slug}`, "invalid");
298
+ }
299
+ const repo = repoUrl?.trim();
300
+ if (!repo || !isValidRepoUrl(repo)) {
301
+ throw new ProjectError(`Invalid repo URL: ${repoUrl}`, "invalid");
302
+ }
303
+ const dir = current.dir;
304
+ const checkoutName = repoCheckoutName(repo);
305
+ const checkoutDir = path.join(dir, checkoutName);
306
+ // Never clobber an existing dir of that name (e.g. a stray checkout or a real
307
+ // subdirectory of the notebook) — refuse before cloning.
308
+ if (await fs
309
+ .access(checkoutDir)
310
+ .then(() => true)
311
+ .catch(() => false)) {
312
+ throw new ProjectError(`A "${checkoutName}" directory already exists in ${slug}; refusing to overwrite`, "exists");
313
+ }
314
+ // Clone FIRST so a failure rolls back to a clean notebook (rm the checkout only,
315
+ // never the project dir + its chats). Mirrors create()'s rollback discipline.
316
+ try {
317
+ await cloneRepo(repo, checkoutDir);
318
+ }
319
+ catch (err) {
320
+ await fs.rm(checkoutDir, { recursive: true, force: true }).catch(() => undefined);
321
+ throw new ProjectError(err instanceof Error ? err.message : `Failed to clone ${repo}`, "invalid");
322
+ }
323
+ // `writeYaml` is the atomic COMMIT point: until it succeeds the notebook is
324
+ // byte-identical to before (only the checkout dir exists), so a failure here
325
+ // rolls back by removing JUST the checkout — nothing else has been mutated.
326
+ // We deliberately DON'T touch the sidecar `.gitignore` or the notebook's
327
+ // CLAUDE.md until after the commit, so a rare `writeYaml` failure can't leave a
328
+ // botched promote's notebook altered (e.g. a `.gitignore` that now ignores
329
+ // `/.chats/`). (Warren #370.)
330
+ const next = {
331
+ ...this.stripDto(current),
332
+ repo,
333
+ updated: today(),
334
+ };
335
+ try {
336
+ await this.writeYaml(slug, next);
337
+ }
338
+ catch (err) {
339
+ await fs.rm(checkoutDir, { recursive: true, force: true }).catch(() => undefined);
340
+ throw err;
341
+ }
342
+ // Post-commit finalization — the project is now validly repo-backed; these
343
+ // steps must NOT roll the promotion back (they only tidy up), so they're
344
+ // best-effort: keep the nested checkout + transcript store out of the data repo,
345
+ // and drop the notebook's sweeper-owned CLAUDE.md so the repo's OWN one applies
346
+ // (it survives in the data-repo history). A failure here leaves a valid
347
+ // repo-backed project, matching create()'s own non-transactional finalization.
348
+ await this.ensureSidecarGitignore(dir, checkoutName).catch(() => undefined);
349
+ await fs.rm(path.join(dir, CLAUDE_FILE), { force: true }).catch(() => undefined);
350
+ return this.toDto(dir, next, await this.overviewExists(slug));
351
+ }
352
+ /** Update mutable metadata fields and bump `updated`. */
353
+ async update(slug, patch) {
354
+ const current = await this.get(slug);
355
+ // driveMode + maxSpawnDepth are tri-state (set / clear / leave), so they're
356
+ // applied explicitly below rather than via the blanket spread — a plain spread
357
+ // can't express "delete this field", which is how an override is cleared back
358
+ // to inherit.
359
+ const { driveMode: driveModePatch, maxSpawnDepth: maxSpawnDepthPatch, hooksMcpEnabled: hooksMcpPatch, recovery: recoveryPatch, attachments: attachmentsPatch, curation: curationPatch, models: modelsPatch, ...rest } = patch;
360
+ const next = {
361
+ ...this.stripDto(current),
362
+ ...rest,
363
+ slug: current.slug, // immutable
364
+ started: current.started, // immutable
365
+ updated: today(),
366
+ };
367
+ if (driveModePatch === null) {
368
+ // Clear the per-project override -> inherit the global default (issue #122).
369
+ delete next.driveMode;
370
+ }
371
+ else if (driveModePatch !== undefined) {
372
+ next.driveMode = driveModePatch;
373
+ }
374
+ if (maxSpawnDepthPatch === null) {
375
+ // Clear the per-project override -> inherit the instance default (#262).
376
+ delete next.maxSpawnDepth;
377
+ }
378
+ else if (maxSpawnDepthPatch !== undefined) {
379
+ next.maxSpawnDepth = maxSpawnDepthPatch;
380
+ }
381
+ if (hooksMcpPatch === null) {
382
+ // Clear the per-project override -> inherit the instance default (G5).
383
+ delete next.hooksMcpEnabled;
384
+ }
385
+ else if (hooksMcpPatch !== undefined) {
386
+ next.hooksMcpEnabled = hooksMcpPatch;
387
+ }
388
+ if (recoveryPatch === null) {
389
+ // Clear the per-project override -> inherit every instance default (#301).
390
+ delete next.recovery;
391
+ }
392
+ else if (recoveryPatch !== undefined) {
393
+ // Sanitise the incoming override; an all-invalid object clears it (undefined).
394
+ const clean = sanitizeRecoveryOverride(recoveryPatch);
395
+ if (clean)
396
+ next.recovery = clean;
397
+ else
398
+ delete next.recovery;
399
+ }
400
+ if (attachmentsPatch === null) {
401
+ // Clear the per-project override -> inherit every instance default (#328).
402
+ delete next.attachments;
403
+ }
404
+ else if (attachmentsPatch !== undefined) {
405
+ const clean = sanitizeAttachmentsOverride(attachmentsPatch);
406
+ if (clean)
407
+ next.attachments = clean;
408
+ else
409
+ delete next.attachments;
410
+ }
411
+ if (curationPatch === null) {
412
+ // Clear the per-project override -> inherit every instance default (#384).
413
+ delete next.curation;
414
+ }
415
+ else if (curationPatch !== undefined) {
416
+ // Sanitise the incoming override; an all-invalid object clears it (undefined).
417
+ const clean = sanitizeCurationOverride(curationPatch);
418
+ if (clean)
419
+ next.curation = clean;
420
+ else
421
+ delete next.curation;
422
+ }
423
+ if (modelsPatch === null) {
424
+ // Clear the per-project override -> offer the instance list again (#457).
425
+ delete next.models;
426
+ }
427
+ else if (modelsPatch !== undefined) {
428
+ // Sanitise; an empty/all-invalid list clears the override (undefined). The
429
+ // subset-of-instance-list check is enforced upstream at the route.
430
+ const clean = sanitizeModelsOverride(modelsPatch);
431
+ if (clean)
432
+ next.models = clean;
433
+ else
434
+ delete next.models;
435
+ }
436
+ await this.writeYaml(slug, next);
437
+ return this.toDto(current.dir, next, await this.overviewExists(slug));
438
+ }
439
+ // --- overview (sweep-curated current state) ----------------------------
440
+ /** Read OVERVIEW.md, or "" if it doesn't exist yet. */
441
+ async readOverview(slug) {
442
+ try {
443
+ return await fs.readFile(path.join(this.dirFor(slug), OVERVIEW_FILE), "utf8");
444
+ }
445
+ catch (err) {
446
+ if (err.code === "ENOENT")
447
+ return "";
448
+ throw err;
449
+ }
450
+ }
451
+ /** Replace OVERVIEW.md wholesale (the sweep regenerates it each time). */
452
+ async writeOverview(slug, content) {
453
+ await fs.writeFile(path.join(this.dirFor(slug), OVERVIEW_FILE), content, "utf8");
454
+ }
455
+ /** Read CHANGELOG.md, or "" if it doesn't exist yet (issue #188). */
456
+ async readChangelog(slug) {
457
+ try {
458
+ return await fs.readFile(path.join(this.dirFor(slug), CHANGELOG_FILE), "utf8");
459
+ }
460
+ catch (err) {
461
+ if (err.code === "ENOENT")
462
+ return "";
463
+ throw err;
464
+ }
465
+ }
466
+ /** Whether OVERVIEW.md exists for this project. */
467
+ async overviewExists(slug) {
468
+ try {
469
+ await fs.access(path.join(this.dirFor(slug), OVERVIEW_FILE));
470
+ return true;
471
+ }
472
+ catch {
473
+ return false;
474
+ }
475
+ }
476
+ // --- CLAUDE.md (durable identity & conventions, sweep-amended) ----------
477
+ /** Read CLAUDE.md, or "" if it doesn't exist yet (issue #177). */
478
+ async readClaudeMd(slug) {
479
+ try {
480
+ return await fs.readFile(path.join(this.dirFor(slug), CLAUDE_FILE), "utf8");
481
+ }
482
+ catch (err) {
483
+ if (err.code === "ENOENT")
484
+ return "";
485
+ throw err;
486
+ }
487
+ }
488
+ /**
489
+ * Replace the CLAUDE.md "Curated notes" section wholesale with a sweeper-
490
+ * curated body (issue #379), preserving ALL human-authored content above the
491
+ * managed heading. This supersedes the old amend-only `appendClaudeMd`, which
492
+ * blind-appended and could never dedup — the sweeper only ever saw the first
493
+ * 2000 chars of the file, so it re-added "known" facts believing they were new.
494
+ * The sweeper now sees the full file and returns the ENTIRE curated-notes body,
495
+ * dedup'd/pruned to fit its budget. Everything up to and including the
496
+ * `## Curated notes` heading is kept verbatim; only the body below is rewritten.
497
+ * The file/heading is seeded if absent (older projects predating #177). A blank
498
+ * body is a no-op — the caller passes content only when there's something to
499
+ * write (NOCHANGE means "leave the file untouched", not "empty it").
500
+ */
501
+ async writeClaudeCurated(slug, curatedBody) {
502
+ const trimmed = curatedBody.trim();
503
+ if (!trimmed)
504
+ return;
505
+ const dir = this.dirFor(slug);
506
+ const file = path.join(dir, CLAUDE_FILE);
507
+ let body;
508
+ try {
509
+ body = await fs.readFile(file, "utf8");
510
+ }
511
+ catch {
512
+ body = claudeTemplate(slug, "");
513
+ }
514
+ const idx = body.indexOf(CLAUDE_CURATED_HEADING);
515
+ const head = idx === -1
516
+ ? `${body.trimEnd()}\n\n${CLAUDE_CURATED_HEADING}`
517
+ : body.slice(0, idx + CLAUDE_CURATED_HEADING.length);
518
+ await fs.writeFile(file, `${head.trimEnd()}\n\n${trimmed}\n`, "utf8");
519
+ }
520
+ // --- pins (sibling-tab files) ------------------------------------------
521
+ /**
522
+ * Pin a file: validate it exists in the project dir, dedupe, persist in
523
+ * project.yaml. Returns the updated project. Throws ProjectError("invalid")
524
+ * if the file doesn't exist or escapes the project dir.
525
+ */
526
+ async pinFile(slug, file) {
527
+ const current = await this.get(slug);
528
+ const name = file?.trim();
529
+ if (!name)
530
+ throw new ProjectError("File name is required", "invalid");
531
+ // Reuse readFile's traversal guard + existence check (throws if missing).
532
+ await this.readFile(slug, name).catch(() => {
533
+ throw new ProjectError(`File not found: ${name}`, "invalid");
534
+ });
535
+ const pinned = current.pinned.includes(name)
536
+ ? current.pinned
537
+ : [...current.pinned, name];
538
+ const next = { ...this.stripDto(current), pinned, updated: today() };
539
+ await this.writeYaml(slug, next);
540
+ return this.toDto(current.dir, next, await this.overviewExists(slug));
541
+ }
542
+ /** Unpin a file (no-op if not pinned). Returns the updated project. */
543
+ async unpinFile(slug, file) {
544
+ const current = await this.get(slug);
545
+ const pinned = current.pinned.filter((f) => f !== file);
546
+ const next = { ...this.stripDto(current), pinned, updated: today() };
547
+ await this.writeYaml(slug, next);
548
+ return this.toDto(current.dir, next, await this.overviewExists(slug));
549
+ }
550
+ /**
551
+ * Add or replace one unified trigger in `project.yaml`, keyed by name (Epic T /
552
+ * T1) — the persistence half of a trigger mutation (the caller arms it against
553
+ * herdctl separately via `TriggerService`/`HerdctlService`). The record is validated
554
+ * + normalised by the Zod schema ({@link sanitizeTrigger}); an invalid name or record
555
+ * throws `ProjectError("invalid")`. Returns the updated project DTO.
556
+ */
557
+ async setTrigger(slug, name, trigger) {
558
+ const current = await this.get(slug);
559
+ if (!isValidTriggerName(name)) {
560
+ throw new ProjectError(`Invalid trigger name: ${name}`, "invalid");
561
+ }
562
+ const clean = sanitizeTrigger(trigger);
563
+ if (!clean)
564
+ throw new ProjectError("Invalid trigger definition", "invalid");
565
+ const triggers = { ...(current.triggers ?? {}), [name]: clean };
566
+ const next = { ...this.stripDto(current), triggers, updated: today() };
567
+ await this.writeYaml(slug, next);
568
+ return this.toDto(current.dir, next, await this.overviewExists(slug));
569
+ }
570
+ /**
571
+ * Remove a trigger from `project.yaml` (no-op if absent). Returns the updated
572
+ * project DTO. The caller disarms the trigger's agent / schedule via `TriggerService`.
573
+ *
574
+ */
575
+ async removeTrigger(slug, name) {
576
+ const current = await this.get(slug);
577
+ const rest = { ...(current.triggers ?? {}) };
578
+ delete rest[name];
579
+ const stripped = this.stripDto(current);
580
+ if (Object.keys(rest).length > 0)
581
+ stripped.triggers = rest;
582
+ else
583
+ delete stripped.triggers;
584
+ const next = { ...stripped, updated: today() };
585
+ await this.writeYaml(slug, next);
586
+ return this.toDto(current.dir, next, await this.overviewExists(slug));
587
+ }
588
+ /**
589
+ * Delete a project directory and everything in it (project.yaml, CHANGELOG.md,
590
+ * and any files the keeper agent created). Throws ProjectError("not_found")
591
+ * if the slug has no project.yaml, so callers can return a clean 404.
592
+ *
593
+ * Note: this removes the project DIRECTORY only. The caller (server) is
594
+ * responsible for dropping the generated keeper-agent yaml + regenerating
595
+ * herdctl.yaml + reloading the fleet — the inverse of the create flow.
596
+ */
597
+ async remove(slug) {
598
+ // The root workspace's directory IS the projects root — deleting
599
+ // it would take every project with it. The resolved-path guard below already
600
+ // refuses, but say so explicitly rather than leaning on a coincidence.
601
+ if (isRootKey(slug)) {
602
+ throw new ProjectError("Refusing to delete the root workspace", "invalid");
603
+ }
604
+ const project = await this.get(slug); // throws not_found
605
+ const dir = this.dirFor(slug);
606
+ // Guard against deleting the projects root itself if a bad slug slipped in.
607
+ const resolved = path.resolve(dir);
608
+ if (resolved === path.resolve(this.root) || !resolved.startsWith(path.resolve(this.root) + path.sep)) {
609
+ throw new ProjectError("Refusing to delete outside the projects root", "invalid");
610
+ }
611
+ await fs.rm(dir, { recursive: true, force: true });
612
+ return project;
613
+ }
614
+ /**
615
+ * Replace CHANGELOG.md wholesale with a sweeper-curated body (issue #379). The
616
+ * sweeper now returns the FULL changelog — adding a dated entry for genuinely-
617
+ * new activity, coalescing duplicates, and dropping/summarizing the oldest to
618
+ * stay under its token budget — instead of one blind-appended bullet. That's
619
+ * what stops the file (and the per-chat preload that injects it) growing without
620
+ * bound. The `# Changelog — <slug>` title is owned by Paddock and re-asserted
621
+ * here so the model never has to reproduce it; if the model included its own
622
+ * top-level heading we drop it to avoid a duplicate title. A blank body is a
623
+ * no-op (guards against wiping the file on a malformed reply).
624
+ */
625
+ async writeChangelog(slug, body) {
626
+ const trimmed = body.trim();
627
+ if (!trimmed)
628
+ return;
629
+ const file = path.join(this.dirFor(slug), CHANGELOG_FILE);
630
+ const title = `# Changelog — ${slug}`;
631
+ // Drop a leading top-level heading the model may have emitted, so the
632
+ // Paddock-owned title isn't duplicated.
633
+ const withoutTitle = /^#\s/.test(trimmed) ? trimmed.replace(/^#[^\n]*\n+/, "") : trimmed;
634
+ await fs.writeFile(file, `${title}\n\n${withoutTitle.trim()}\n`, "utf8");
635
+ }
636
+ // --- freeform file serving (delegated to project-files.ts, issue #403) ---
637
+ // Thin wrappers over the pure `(root, slug, …)` helpers so `ProjectStore`'s
638
+ // public file-read API (and its ProjectError codes) is unchanged.
639
+ /**
640
+ * List one level of a project directory (issue #259). See
641
+ * {@link import("./project-files.js").listFiles}.
642
+ */
643
+ async listFiles(slug, subpath = "") {
644
+ return projectFiles.listFiles(this.root, slug, subpath);
645
+ }
646
+ /** Read a freeform file's contents as UTF-8 text (path-traversal guarded). */
647
+ async readFile(slug, name) {
648
+ return projectFiles.readProjectFile(this.root, slug, name);
649
+ }
650
+ /**
651
+ * Read a file's raw bytes + its MIME type (issue #61), for the binary/image
652
+ * endpoint. See {@link import("./project-files.js").readFileBytes}.
653
+ */
654
+ async readFileBytes(slug, name) {
655
+ return projectFiles.readFileBytes(this.root, slug, name);
656
+ }
657
+ /**
658
+ * Read a file plus a render-kind hint derived from its extension (issues #3 /
659
+ * #61). See {@link import("./project-files.js").readFileWithKind}.
660
+ */
661
+ async readFileWithKind(slug, name) {
662
+ return projectFiles.readFileWithKind(this.root, slug, name);
663
+ }
664
+ // --- internals ---------------------------------------------------------
665
+ /**
666
+ * Read one workspace, or `null` if the key doesn't name one.
667
+ *
668
+ * **The root workspace always exists.** For a project, `project.yaml` IS the
669
+ * existence gate — no record, not a project. But the root is the instance
670
+ * itself: its directory is always there, so a missing record just means
671
+ * "nothing has been customised yet" and every field falls back to a default
672
+ * (see {@link normalize}). The record is written lazily, on the first setting
673
+ * that actually changes.
674
+ *
675
+ * This is what removes the whole opt-in apparatus the previous design needed —
676
+ * no `createRoot`, no enable card, and no `/chat` that 404s on a fresh box.
677
+ */
678
+ async readSafe(key) {
679
+ const dir = this.dirFor(key);
680
+ let yaml;
681
+ try {
682
+ const raw = await fs.readFile(path.join(dir, PROJECT_FILE), "utf8");
683
+ const parsed = YAML.parse(raw);
684
+ if (!parsed || typeof parsed !== "object") {
685
+ if (!isRootKey(key))
686
+ return null;
687
+ yaml = this.normalize({}, key);
688
+ }
689
+ else {
690
+ yaml = this.normalize(parsed, key);
691
+ }
692
+ }
693
+ catch {
694
+ if (!isRootKey(key))
695
+ return null;
696
+ yaml = this.normalize({}, key);
697
+ }
698
+ // overviewExists is a cheap fs.access; do it after the yaml parse succeeds.
699
+ const hasOverview = await this.overviewExists(key);
700
+ return this.toDto(dir, yaml, hasOverview);
701
+ }
702
+ /** Fill defaults / coerce a parsed project.yaml into a complete ProjectYaml. */
703
+ normalize(p, key) {
704
+ const started = p.started ?? today();
705
+ return {
706
+ // The root workspace has no slug to fall back to, so it reads as its own
707
+ // directory's basename. (No instance-name config field exists yet; when
708
+ // one lands, this is the single place it should feed.)
709
+ name: p.name ?? (isRootKey(key) ? path.basename(path.resolve(this.root)) : key),
710
+ slug: p.slug ?? key,
711
+ status: p.status ?? "active",
712
+ domain: Array.isArray(p.domain) ? p.domain : [],
713
+ // Carry `group` through only when it's a non-empty string (an absent area
714
+ // stays absent on disk — same round-trip discipline as `model`).
715
+ ...(typeof p.group === "string" && p.group.trim()
716
+ ? { group: p.group.trim().toLowerCase() }
717
+ : {}),
718
+ visibility: p.visibility ?? "public",
719
+ started,
720
+ updated: p.updated ?? started,
721
+ summary: p.summary ?? "",
722
+ // Coerce to well-formed {label,url} objects — a legacy/hand-edited file may
723
+ // carry a bare string list, which otherwise crashes the Settings pane.
724
+ links: normalizeLinks(p.links),
725
+ pinned: Array.isArray(p.pinned)
726
+ ? p.pinned.filter((f) => typeof f === "string")
727
+ : [],
728
+ // Carry model through only when present on disk; the DTO resolves the
729
+ // default (an absent model stays absent in the yaml so existing files
730
+ // without `model` still round-trip unchanged).
731
+ ...(typeof p.model === "string" ? { model: p.model } : {}),
732
+ // models allow-list (issue #457 Step 2): carried only when at least one known
733
+ // catalog id survives sanitization — an absent/all-invalid override stays
734
+ // absent so files without it round-trip unchanged, and the offered list is
735
+ // resolved against the instance list in the web, NOT baked concrete here.
736
+ ...(() => {
737
+ const m = sanitizeModelsOverride(p.models);
738
+ return m ? { models: m } : {};
739
+ })(),
740
+ // Keeper-agent overrides (issue #12): same round-trip discipline as model
741
+ // — carried only when present so files without them are unchanged.
742
+ ...(typeof p.permissionMode === "string" ? { permissionMode: p.permissionMode } : {}),
743
+ ...(typeof p.maxTurns === "number" ? { maxTurns: p.maxTurns } : {}),
744
+ ...(typeof p.docker === "boolean" ? { docker: p.docker } : {}),
745
+ // driveMode (Paddock#111): carried only when explicitly set — an absent
746
+ // value means "inherit the global default" and is resolved at dispatch
747
+ // (`project.driveMode ?? cfg.driveMode`), NOT here, so the env-level
748
+ // global can still take effect for projects that don't override it.
749
+ ...(typeof p.driveMode === "string" ? { driveMode: p.driveMode } : {}),
750
+ // maxSpawnDepth (issue #262): carried only when explicitly set — an absent
751
+ // value means "inherit the instance default" and is resolved at dispatch
752
+ // (`resolveMaxSpawnDepth(project.maxSpawnDepth, cfg.maxSpawnDepth)`), NOT
753
+ // here, so the instance default still applies to non-overriding projects.
754
+ ...(typeof p.maxSpawnDepth === "number" ? { maxSpawnDepth: p.maxSpawnDepth } : {}),
755
+ // hooksMcpEnabled (Epic G / G5): carried only when explicitly set — an absent
756
+ // value means "inherit the instance default" and is resolved at dispatch
757
+ // (`resolveHooksMcpEnabled(project.hooksMcpEnabled, cfg.hooksMcpEnabled)`), NOT
758
+ // here, so the instance default still applies to non-overriding projects.
759
+ ...(typeof p.hooksMcpEnabled === "boolean" ? { hooksMcpEnabled: p.hooksMcpEnabled } : {}),
760
+ // recovery (issue #301): carried only when at least one valid field survives
761
+ // sanitization — an absent/all-invalid override stays absent so files without
762
+ // it round-trip unchanged, and each field is resolved at dispatch
763
+ // (`resolveRecoveryConfig(project.recovery, cfg.recovery)`), NOT here.
764
+ ...(() => {
765
+ const r = sanitizeRecoveryOverride(p.recovery);
766
+ return r ? { recovery: r } : {};
767
+ })(),
768
+ // curation (issue #384): carried only when at least one valid field survives
769
+ // sanitization — an absent/all-invalid override stays absent so files without
770
+ // it round-trip unchanged, and each field is resolved at sweep time
771
+ // (`resolveCurationConfig(project.curation, cfg.curation)`), NOT here.
772
+ ...(() => {
773
+ const c = sanitizeCurationOverride(p.curation);
774
+ return c ? { curation: c } : {};
775
+ })(),
776
+ // repo (issue #187): carried only when present — its presence is what marks
777
+ // the project repo-backed and drives the workingDir resolution in toDto.
778
+ ...(typeof p.repo === "string" && p.repo.trim() ? { repo: p.repo.trim() } : {}),
779
+ // schedules (issue #265): carried only when at least one well-formed entry
780
+ // survives sanitization — an absent/empty map stays absent on disk, so files
781
+ // without schedules round-trip unchanged. A malformed entry is dropped (not
782
+ // thrown) so a bad hand-edit can't brick the project's keeper registration.
783
+ ...(() => {
784
+ const s = sanitizeSchedules(p.schedules);
785
+ return s && Object.keys(s).length > 0 ? { schedules: s } : {};
786
+ })(),
787
+ // hooks (Epic G / G1): same discipline as schedules — carried only when at
788
+ // least one well-formed entry survives sanitization, so hook-less files
789
+ // round-trip byte-identically and a malformed hand-edit is dropped (not thrown)
790
+ // rather than bricking the project's agent registration.
791
+ ...(() => {
792
+ const h = sanitizeHooks(p.hooks);
793
+ return h && Object.keys(h).length > 0 ? { hooks: h } : {};
794
+ })(),
795
+ // triggers (Epic T / T1): same discipline as schedules/hooks — carried only
796
+ // when at least one well-formed entry survives Zod validation, so trigger-less
797
+ // files round-trip byte-identically and a malformed hand-edit is dropped (not
798
+ // thrown) rather than bricking the project's agent registration.
799
+ ...(() => {
800
+ const t = sanitizeTriggers(p.triggers);
801
+ return t && Object.keys(t).length > 0 ? { triggers: t } : {};
802
+ })(),
803
+ };
804
+ }
805
+ /**
806
+ * Ensure the project dir's sidecar `.gitignore` ignores the nested repo-backed
807
+ * checkout (`/<checkoutName>/`) and the transcript store (`/.chats/`), so neither
808
+ * is tracked by the instance data repo (issue #187 option A). Idempotent and
809
+ * merge-aware: an existing `.gitignore` (rare for a notebook, but possible) keeps
810
+ * its lines and only the missing entries are appended — used by both `create()`
811
+ * and the in-place `promote()` (#213).
812
+ */
813
+ async ensureSidecarGitignore(dir, checkoutName) {
814
+ const file = path.join(dir, GITIGNORE_FILE);
815
+ let existing = "";
816
+ try {
817
+ existing = await fs.readFile(file, "utf8");
818
+ }
819
+ catch {
820
+ /* no .gitignore yet — write a fresh one below */
821
+ }
822
+ const want = [`/${checkoutName}/`, `/.chats/`];
823
+ const have = new Set(existing.split("\n").map((l) => l.trim()));
824
+ const missing = want.filter((l) => !have.has(l));
825
+ if (existing && missing.length === 0)
826
+ return; // already covers everything
827
+ if (!existing) {
828
+ await fs.writeFile(file, [
829
+ `# Repo-backed project checkout (issue #187) — not tracked by the data repo.`,
830
+ ...want,
831
+ "",
832
+ ].join("\n"), "utf8");
833
+ return;
834
+ }
835
+ // Append only the missing lines to the existing file (preserve user content).
836
+ const body = existing.endsWith("\n") ? existing : `${existing}\n`;
837
+ await fs.writeFile(file, `${body}${missing.join("\n")}\n`, "utf8");
838
+ }
839
+ async writeYaml(slug, yaml) {
840
+ const header = "# Paddock project metadata. Directory name MUST equal `slug`.\n" +
841
+ "# status: idea | active | paused | blocked | done | abandoned\n";
842
+ const body = YAML.stringify(yaml);
843
+ await fs.writeFile(path.join(this.dirFor(slug), PROJECT_FILE), header + body, "utf8");
844
+ }
845
+ toDto(dir, yaml, hasOverview) {
846
+ return {
847
+ ...yaml,
848
+ // Always concrete in the DTO; "" means Unsorted.
849
+ group: yaml.group ?? "",
850
+ pinned: yaml.pinned ?? [],
851
+ // Always concrete in the DTO: an absent on-disk model resolves to the
852
+ // keeper default (CONTRACT-v3 §4).
853
+ model: yaml.model ?? DEFAULT_MODEL,
854
+ // Offered-models override stays RAW (per-project only — resolved against the
855
+ // instance allow-list in the web, never baked concrete here; the `model`/
856
+ // `recovery` discipline), re-sanitised so a corrupt hand-edit never reaches
857
+ // the web. Absent ⇒ omitted (undefined), so the project offers the instance
858
+ // list (issue #457 Step 2).
859
+ models: sanitizeModelsOverride(yaml.models),
860
+ // Keeper-agent overrides — always concrete in the DTO (issue #12).
861
+ permissionMode: yaml.permissionMode ?? DEFAULT_PERMISSION_MODE,
862
+ maxTurns: yaml.maxTurns ?? DEFAULT_MAX_TURNS,
863
+ docker: yaml.docker ?? DEFAULT_DOCKER,
864
+ // Recovery override stays RAW (per-project only — resolved against the
865
+ // instance default at dispatch, never baked concrete here; the `driveMode`
866
+ // discipline), but re-sanitised so a corrupt hand-edit never reaches the
867
+ // web. Absent ⇒ omitted (undefined), so the project inherits every default.
868
+ recovery: sanitizeRecoveryOverride(yaml.recovery),
869
+ // Attachment override stays RAW (per-project only — resolved against the
870
+ // instance default at request time, never baked concrete here), re-sanitised
871
+ // so a corrupt hand-edit never reaches the web. Absent ⇒ omitted (#328).
872
+ attachments: sanitizeAttachmentsOverride(yaml.attachments),
873
+ // Curation-budget override stays RAW (per-project only — resolved against
874
+ // the instance default at sweep time, never baked concrete here), re-
875
+ // sanitised so a corrupt hand-edit never reaches the web. Absent ⇒ omitted (#384).
876
+ curation: sanitizeCurationOverride(yaml.curation),
877
+ dir,
878
+ // Repo-backed (#187): the keeper's cwd is the nested checkout; a notebook's
879
+ // cwd is the metadata dir itself. `repoBacked` is the presence of `repo`.
880
+ workingDir: workingDirFor(dir, yaml.repo),
881
+ repoBacked: Boolean(yaml.repo),
882
+ hasOverview,
883
+ };
884
+ }
885
+ stripDto(p) {
886
+ const { dir: _dir, workingDir: _workingDir, repoBacked: _repoBacked, hasOverview: _hasOverview, group, ...rest } = p;
887
+ void _dir;
888
+ void _workingDir;
889
+ void _repoBacked;
890
+ void _hasOverview;
891
+ // Keep an empty area off the yaml so it isn't persisted as `group: ""`.
892
+ return group?.trim() ? { ...rest, group } : rest;
893
+ }
894
+ }