@ag-eco/agentplate-cli 0.13.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 (455) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +462 -0
  3. package/agents/ap-co-creation.md +90 -0
  4. package/agents/builder.md +144 -0
  5. package/agents/coordinator.md +377 -0
  6. package/agents/lead.md +435 -0
  7. package/agents/merger.md +164 -0
  8. package/agents/monitor.md +214 -0
  9. package/agents/orchestrator.md +239 -0
  10. package/agents/reviewer.md +140 -0
  11. package/agents/scout.md +125 -0
  12. package/agents/supervisor.md +427 -0
  13. package/package.json +66 -0
  14. package/src/agents/capabilities.test.ts +85 -0
  15. package/src/agents/capabilities.ts +125 -0
  16. package/src/agents/checkpoint.test.ts +88 -0
  17. package/src/agents/checkpoint.ts +101 -0
  18. package/src/agents/copilot-hooks-deployer.test.ts +162 -0
  19. package/src/agents/copilot-hooks-deployer.ts +93 -0
  20. package/src/agents/guard-rules.test.ts +372 -0
  21. package/src/agents/guard-rules.ts +97 -0
  22. package/src/agents/headless-mail-injector.test.ts +709 -0
  23. package/src/agents/headless-mail-injector.ts +377 -0
  24. package/src/agents/headless-prompt.test.ts +102 -0
  25. package/src/agents/headless-prompt.ts +68 -0
  26. package/src/agents/hooks-deployer.test.ts +3119 -0
  27. package/src/agents/hooks-deployer.ts +804 -0
  28. package/src/agents/identity.test.ts +604 -0
  29. package/src/agents/identity.ts +384 -0
  30. package/src/agents/lifecycle.test.ts +196 -0
  31. package/src/agents/lifecycle.ts +183 -0
  32. package/src/agents/mail-poll-detect.test.ts +153 -0
  33. package/src/agents/mail-poll-detect.ts +73 -0
  34. package/src/agents/manifest.test.ts +1026 -0
  35. package/src/agents/manifest.ts +376 -0
  36. package/src/agents/overlay.test.ts +1058 -0
  37. package/src/agents/overlay.ts +490 -0
  38. package/src/agents/scope-detect.test.ts +190 -0
  39. package/src/agents/scope-detect.ts +146 -0
  40. package/src/agents/turn-lock.test.ts +181 -0
  41. package/src/agents/turn-lock.ts +235 -0
  42. package/src/agents/turn-runner-dispatch.test.ts +182 -0
  43. package/src/agents/turn-runner-dispatch.ts +105 -0
  44. package/src/agents/turn-runner.test.ts +2312 -0
  45. package/src/agents/turn-runner.ts +1383 -0
  46. package/src/beads/client.test.ts +217 -0
  47. package/src/beads/client.ts +230 -0
  48. package/src/beads/molecules.test.ts +338 -0
  49. package/src/beads/molecules.ts +198 -0
  50. package/src/commands/agents.test.ts +328 -0
  51. package/src/commands/agents.ts +299 -0
  52. package/src/commands/clean.test.ts +797 -0
  53. package/src/commands/clean.ts +791 -0
  54. package/src/commands/completions.test.ts +348 -0
  55. package/src/commands/completions.ts +981 -0
  56. package/src/commands/coordinator.test.ts +2975 -0
  57. package/src/commands/coordinator.ts +1841 -0
  58. package/src/commands/costs.test.ts +1183 -0
  59. package/src/commands/costs.ts +599 -0
  60. package/src/commands/dashboard.test.ts +954 -0
  61. package/src/commands/dashboard.ts +1212 -0
  62. package/src/commands/discover.test.ts +288 -0
  63. package/src/commands/discover.ts +202 -0
  64. package/src/commands/doctor.test.ts +303 -0
  65. package/src/commands/doctor.ts +311 -0
  66. package/src/commands/ecosystem.test.ts +226 -0
  67. package/src/commands/ecosystem.ts +248 -0
  68. package/src/commands/errors.test.ts +654 -0
  69. package/src/commands/errors.ts +197 -0
  70. package/src/commands/feed.test.ts +709 -0
  71. package/src/commands/feed.ts +260 -0
  72. package/src/commands/group.test.ts +475 -0
  73. package/src/commands/group.ts +546 -0
  74. package/src/commands/hooks.test.ts +458 -0
  75. package/src/commands/hooks.ts +263 -0
  76. package/src/commands/init.test.ts +1011 -0
  77. package/src/commands/init.ts +967 -0
  78. package/src/commands/inspect.test.ts +1239 -0
  79. package/src/commands/inspect.ts +648 -0
  80. package/src/commands/log.test.ts +1913 -0
  81. package/src/commands/log.ts +958 -0
  82. package/src/commands/logs.test.ts +801 -0
  83. package/src/commands/logs.ts +483 -0
  84. package/src/commands/mail.test.ts +1501 -0
  85. package/src/commands/mail.ts +848 -0
  86. package/src/commands/merge.test.ts +864 -0
  87. package/src/commands/merge.ts +381 -0
  88. package/src/commands/metrics.test.ts +458 -0
  89. package/src/commands/metrics.ts +129 -0
  90. package/src/commands/monitor.test.ts +191 -0
  91. package/src/commands/monitor.ts +409 -0
  92. package/src/commands/nudge.test.ts +579 -0
  93. package/src/commands/nudge.ts +646 -0
  94. package/src/commands/orchestrator.ts +42 -0
  95. package/src/commands/prime.test.ts +612 -0
  96. package/src/commands/prime.ts +359 -0
  97. package/src/commands/replay.test.ts +757 -0
  98. package/src/commands/replay.ts +231 -0
  99. package/src/commands/run.test.ts +469 -0
  100. package/src/commands/run.ts +353 -0
  101. package/src/commands/serve/agent-actions.test.ts +210 -0
  102. package/src/commands/serve/agent-actions.ts +192 -0
  103. package/src/commands/serve/build.test.ts +202 -0
  104. package/src/commands/serve/build.ts +206 -0
  105. package/src/commands/serve/coordinator-actions.test.ts +339 -0
  106. package/src/commands/serve/coordinator-actions.ts +410 -0
  107. package/src/commands/serve/dev.test.ts +168 -0
  108. package/src/commands/serve/dev.ts +117 -0
  109. package/src/commands/serve/mail-actions.test.ts +312 -0
  110. package/src/commands/serve/mail-actions.ts +167 -0
  111. package/src/commands/serve/rest.test.ts +1680 -0
  112. package/src/commands/serve/rest.ts +1130 -0
  113. package/src/commands/serve/static.ts +51 -0
  114. package/src/commands/serve/ws.test.ts +361 -0
  115. package/src/commands/serve/ws.ts +332 -0
  116. package/src/commands/serve.test.ts +459 -0
  117. package/src/commands/serve.ts +654 -0
  118. package/src/commands/sling.test.ts +1583 -0
  119. package/src/commands/sling.ts +1351 -0
  120. package/src/commands/spec.test.ts +179 -0
  121. package/src/commands/spec.ts +105 -0
  122. package/src/commands/status.test.ts +614 -0
  123. package/src/commands/status.ts +403 -0
  124. package/src/commands/stop.test.ts +964 -0
  125. package/src/commands/stop.ts +319 -0
  126. package/src/commands/supervisor.test.ts +185 -0
  127. package/src/commands/supervisor.ts +537 -0
  128. package/src/commands/trace.test.ts +762 -0
  129. package/src/commands/trace.ts +205 -0
  130. package/src/commands/update.test.ts +466 -0
  131. package/src/commands/update.ts +263 -0
  132. package/src/commands/upgrade.test.ts +48 -0
  133. package/src/commands/upgrade.ts +240 -0
  134. package/src/commands/watch.test.ts +257 -0
  135. package/src/commands/watch.ts +308 -0
  136. package/src/commands/worktree.test.ts +1297 -0
  137. package/src/commands/worktree.ts +451 -0
  138. package/src/config.test.ts +1535 -0
  139. package/src/config.ts +1064 -0
  140. package/src/doctor/agents.test.ts +523 -0
  141. package/src/doctor/agents.ts +399 -0
  142. package/src/doctor/config-check.test.ts +191 -0
  143. package/src/doctor/config-check.ts +183 -0
  144. package/src/doctor/consistency.test.ts +807 -0
  145. package/src/doctor/consistency.ts +347 -0
  146. package/src/doctor/databases.test.ts +350 -0
  147. package/src/doctor/databases.ts +243 -0
  148. package/src/doctor/dependencies.test.ts +296 -0
  149. package/src/doctor/dependencies.ts +272 -0
  150. package/src/doctor/ecosystem.test.ts +308 -0
  151. package/src/doctor/ecosystem.ts +156 -0
  152. package/src/doctor/logs.test.ts +253 -0
  153. package/src/doctor/logs.ts +295 -0
  154. package/src/doctor/merge-queue.test.ts +315 -0
  155. package/src/doctor/merge-queue.ts +167 -0
  156. package/src/doctor/providers.test.ts +409 -0
  157. package/src/doctor/providers.ts +250 -0
  158. package/src/doctor/serve.test.ts +95 -0
  159. package/src/doctor/serve.ts +86 -0
  160. package/src/doctor/structure.test.ts +423 -0
  161. package/src/doctor/structure.ts +285 -0
  162. package/src/doctor/types.ts +43 -0
  163. package/src/doctor/version.test.ts +241 -0
  164. package/src/doctor/version.ts +132 -0
  165. package/src/doctor/watchdog.test.ts +167 -0
  166. package/src/doctor/watchdog.ts +214 -0
  167. package/src/e2e/init-sling-lifecycle.test.ts +283 -0
  168. package/src/errors.test.ts +350 -0
  169. package/src/errors.ts +217 -0
  170. package/src/events/store.test.ts +660 -0
  171. package/src/events/store.ts +369 -0
  172. package/src/events/tailer.test.ts +719 -0
  173. package/src/events/tailer.ts +332 -0
  174. package/src/events/tool-filter.test.ts +330 -0
  175. package/src/events/tool-filter.ts +126 -0
  176. package/src/index.ts +533 -0
  177. package/src/insights/analyzer.test.ts +466 -0
  178. package/src/insights/analyzer.ts +203 -0
  179. package/src/insights/quality-gates.test.ts +141 -0
  180. package/src/insights/quality-gates.ts +156 -0
  181. package/src/json.test.ts +72 -0
  182. package/src/json.ts +53 -0
  183. package/src/loam/client.test.ts +752 -0
  184. package/src/loam/client.ts +664 -0
  185. package/src/logging/color.test.ts +252 -0
  186. package/src/logging/color.ts +105 -0
  187. package/src/logging/format.test.ts +110 -0
  188. package/src/logging/format.ts +255 -0
  189. package/src/logging/logger.test.ts +814 -0
  190. package/src/logging/logger.ts +266 -0
  191. package/src/logging/reporter.test.ts +259 -0
  192. package/src/logging/reporter.ts +110 -0
  193. package/src/logging/sanitizer.test.ts +190 -0
  194. package/src/logging/sanitizer.ts +57 -0
  195. package/src/logging/theme.ts +140 -0
  196. package/src/mail/broadcast.test.ts +204 -0
  197. package/src/mail/broadcast.ts +92 -0
  198. package/src/mail/client.test.ts +774 -0
  199. package/src/mail/client.ts +236 -0
  200. package/src/mail/store.test.ts +898 -0
  201. package/src/mail/store.ts +425 -0
  202. package/src/merge/lock.test.ts +149 -0
  203. package/src/merge/lock.ts +140 -0
  204. package/src/merge/predict.test.ts +387 -0
  205. package/src/merge/predict.ts +249 -0
  206. package/src/merge/queue.test.ts +426 -0
  207. package/src/merge/queue.ts +246 -0
  208. package/src/merge/resolver.test.ts +1993 -0
  209. package/src/merge/resolver.ts +926 -0
  210. package/src/metrics/pricing.test.ts +258 -0
  211. package/src/metrics/pricing.ts +135 -0
  212. package/src/metrics/store.test.ts +978 -0
  213. package/src/metrics/store.ts +501 -0
  214. package/src/metrics/summary.test.ts +398 -0
  215. package/src/metrics/summary.ts +178 -0
  216. package/src/metrics/transcript.test.ts +483 -0
  217. package/src/metrics/transcript.ts +114 -0
  218. package/src/runtimes/__fixtures__/claude-stream-fixture.ts +22 -0
  219. package/src/runtimes/aider.test.ts +124 -0
  220. package/src/runtimes/aider.ts +147 -0
  221. package/src/runtimes/amp.test.ts +164 -0
  222. package/src/runtimes/amp.ts +154 -0
  223. package/src/runtimes/claude.test.ts +1474 -0
  224. package/src/runtimes/claude.ts +579 -0
  225. package/src/runtimes/codex.test.ts +805 -0
  226. package/src/runtimes/codex.ts +273 -0
  227. package/src/runtimes/connections.test.ts +214 -0
  228. package/src/runtimes/connections.ts +103 -0
  229. package/src/runtimes/copilot.test.ts +707 -0
  230. package/src/runtimes/copilot.ts +316 -0
  231. package/src/runtimes/cursor.test.ts +497 -0
  232. package/src/runtimes/cursor.ts +205 -0
  233. package/src/runtimes/gemini.test.ts +537 -0
  234. package/src/runtimes/gemini.ts +243 -0
  235. package/src/runtimes/goose.test.ts +133 -0
  236. package/src/runtimes/goose.ts +157 -0
  237. package/src/runtimes/headless-connection.test.ts +264 -0
  238. package/src/runtimes/headless-connection.ts +158 -0
  239. package/src/runtimes/opencode.test.ts +325 -0
  240. package/src/runtimes/opencode.ts +188 -0
  241. package/src/runtimes/pi-guards.test.ts +486 -0
  242. package/src/runtimes/pi-guards.ts +367 -0
  243. package/src/runtimes/pi.test.ts +789 -0
  244. package/src/runtimes/pi.ts +305 -0
  245. package/src/runtimes/registry.test.ts +196 -0
  246. package/src/runtimes/registry.ts +99 -0
  247. package/src/runtimes/sapling.test.ts +1267 -0
  248. package/src/runtimes/sapling.ts +710 -0
  249. package/src/runtimes/types.ts +266 -0
  250. package/src/schema-consistency.test.ts +246 -0
  251. package/src/sessions/compat.test.ts +281 -0
  252. package/src/sessions/compat.ts +105 -0
  253. package/src/sessions/store.test.ts +1748 -0
  254. package/src/sessions/store.ts +858 -0
  255. package/src/test-helpers.test.ts +124 -0
  256. package/src/test-helpers.ts +145 -0
  257. package/src/test-setup.test.ts +31 -0
  258. package/src/test-setup.ts +28 -0
  259. package/src/tools/loam/api.ts +368 -0
  260. package/src/tools/loam/cli.ts +278 -0
  261. package/src/tools/loam/commands/add.ts +52 -0
  262. package/src/tools/loam/commands/archive.ts +214 -0
  263. package/src/tools/loam/commands/audit.ts +276 -0
  264. package/src/tools/loam/commands/compact.ts +1062 -0
  265. package/src/tools/loam/commands/completions.ts +79 -0
  266. package/src/tools/loam/commands/config.ts +381 -0
  267. package/src/tools/loam/commands/delete-domain.ts +121 -0
  268. package/src/tools/loam/commands/delete.ts +316 -0
  269. package/src/tools/loam/commands/diff.ts +200 -0
  270. package/src/tools/loam/commands/doctor.ts +1113 -0
  271. package/src/tools/loam/commands/edit.ts +226 -0
  272. package/src/tools/loam/commands/init.ts +31 -0
  273. package/src/tools/loam/commands/learn.ts +179 -0
  274. package/src/tools/loam/commands/move.ts +323 -0
  275. package/src/tools/loam/commands/onboard.ts +374 -0
  276. package/src/tools/loam/commands/outcome.ts +185 -0
  277. package/src/tools/loam/commands/prime.ts +688 -0
  278. package/src/tools/loam/commands/prune.ts +614 -0
  279. package/src/tools/loam/commands/query.ts +218 -0
  280. package/src/tools/loam/commands/rank.ts +180 -0
  281. package/src/tools/loam/commands/ready.ts +189 -0
  282. package/src/tools/loam/commands/record.ts +1210 -0
  283. package/src/tools/loam/commands/restore.ts +166 -0
  284. package/src/tools/loam/commands/search.ts +327 -0
  285. package/src/tools/loam/commands/setup.ts +887 -0
  286. package/src/tools/loam/commands/status.ts +103 -0
  287. package/src/tools/loam/commands/sync.ts +298 -0
  288. package/src/tools/loam/commands/update.ts +19 -0
  289. package/src/tools/loam/commands/upgrade.ts +93 -0
  290. package/src/tools/loam/commands/validate.ts +190 -0
  291. package/src/tools/loam/index.ts +62 -0
  292. package/src/tools/loam/log.ts +127 -0
  293. package/src/tools/loam/registry/builtins.ts +409 -0
  294. package/src/tools/loam/registry/custom.ts +431 -0
  295. package/src/tools/loam/registry/init.ts +55 -0
  296. package/src/tools/loam/registry/template.ts +40 -0
  297. package/src/tools/loam/registry/type-registry.ts +113 -0
  298. package/src/tools/loam/schemas/config-schema.ts +489 -0
  299. package/src/tools/loam/schemas/config.ts +245 -0
  300. package/src/tools/loam/schemas/index.ts +18 -0
  301. package/src/tools/loam/schemas/record-schema.ts +191 -0
  302. package/src/tools/loam/schemas/record.ts +115 -0
  303. package/src/tools/loam/utils/active-work.ts +205 -0
  304. package/src/tools/loam/utils/anchor-validity.ts +80 -0
  305. package/src/tools/loam/utils/archive.ts +146 -0
  306. package/src/tools/loam/utils/audit.ts +667 -0
  307. package/src/tools/loam/utils/bm25.ts +238 -0
  308. package/src/tools/loam/utils/budget.ts +142 -0
  309. package/src/tools/loam/utils/config.ts +344 -0
  310. package/src/tools/loam/utils/dir-anchors.ts +62 -0
  311. package/src/tools/loam/utils/domain-rules.ts +114 -0
  312. package/src/tools/loam/utils/expertise.ts +393 -0
  313. package/src/tools/loam/utils/format-helpers.ts +96 -0
  314. package/src/tools/loam/utils/format.ts +1234 -0
  315. package/src/tools/loam/utils/git-context.ts +50 -0
  316. package/src/tools/loam/utils/git.ts +183 -0
  317. package/src/tools/loam/utils/hooks.ts +299 -0
  318. package/src/tools/loam/utils/index.ts +52 -0
  319. package/src/tools/loam/utils/json-output.ts +13 -0
  320. package/src/tools/loam/utils/lock.ts +76 -0
  321. package/src/tools/loam/utils/markers.ts +48 -0
  322. package/src/tools/loam/utils/numeric-flags.ts +20 -0
  323. package/src/tools/loam/utils/palette.ts +44 -0
  324. package/src/tools/loam/utils/prime-ranking.ts +135 -0
  325. package/src/tools/loam/utils/recipe-discovery.ts +195 -0
  326. package/src/tools/loam/utils/runtime-flags.ts +28 -0
  327. package/src/tools/loam/utils/scoring.ts +94 -0
  328. package/src/tools/loam/utils/version.ts +116 -0
  329. package/src/tools/sprout/commands/block.ts +64 -0
  330. package/src/tools/sprout/commands/blocked.ts +86 -0
  331. package/src/tools/sprout/commands/close.ts +129 -0
  332. package/src/tools/sprout/commands/completions.ts +198 -0
  333. package/src/tools/sprout/commands/config.ts +238 -0
  334. package/src/tools/sprout/commands/create.ts +164 -0
  335. package/src/tools/sprout/commands/dep.ts +148 -0
  336. package/src/tools/sprout/commands/doctor.ts +979 -0
  337. package/src/tools/sprout/commands/init.ts +83 -0
  338. package/src/tools/sprout/commands/label.ts +178 -0
  339. package/src/tools/sprout/commands/list.ts +210 -0
  340. package/src/tools/sprout/commands/migrate.ts +133 -0
  341. package/src/tools/sprout/commands/onboard.ts +207 -0
  342. package/src/tools/sprout/commands/plan-show.ts +278 -0
  343. package/src/tools/sprout/commands/plan.ts +2526 -0
  344. package/src/tools/sprout/commands/prime.ts +399 -0
  345. package/src/tools/sprout/commands/ready.ts +245 -0
  346. package/src/tools/sprout/commands/search.ts +221 -0
  347. package/src/tools/sprout/commands/show.ts +277 -0
  348. package/src/tools/sprout/commands/stats.ts +146 -0
  349. package/src/tools/sprout/commands/sync.ts +134 -0
  350. package/src/tools/sprout/commands/tpl.ts +364 -0
  351. package/src/tools/sprout/commands/unblock.ts +115 -0
  352. package/src/tools/sprout/commands/update.ts +257 -0
  353. package/src/tools/sprout/commands/upgrade.ts +91 -0
  354. package/src/tools/sprout/config-schema.ts +152 -0
  355. package/src/tools/sprout/config.ts +355 -0
  356. package/src/tools/sprout/filter.ts +107 -0
  357. package/src/tools/sprout/format.ts +43 -0
  358. package/src/tools/sprout/id.ts +22 -0
  359. package/src/tools/sprout/index.ts +204 -0
  360. package/src/tools/sprout/log.ts +76 -0
  361. package/src/tools/sprout/markers.ts +22 -0
  362. package/src/tools/sprout/output.ts +121 -0
  363. package/src/tools/sprout/plan-backref.ts +93 -0
  364. package/src/tools/sprout/plan-context.ts +81 -0
  365. package/src/tools/sprout/plan-domain.ts +139 -0
  366. package/src/tools/sprout/plan-lifecycle.ts +65 -0
  367. package/src/tools/sprout/plan-loam.ts +207 -0
  368. package/src/tools/sprout/plan-schema.ts +209 -0
  369. package/src/tools/sprout/sort.ts +31 -0
  370. package/src/tools/sprout/store.ts +172 -0
  371. package/src/tools/sprout/types.ts +118 -0
  372. package/src/tools/sprout/validation.ts +119 -0
  373. package/src/tools/sprout/version.ts +1 -0
  374. package/src/tools/sprout/yaml.ts +387 -0
  375. package/src/tools/trellis/commands/archive.ts +87 -0
  376. package/src/tools/trellis/commands/completions.ts +610 -0
  377. package/src/tools/trellis/commands/config.ts +382 -0
  378. package/src/tools/trellis/commands/create.ts +252 -0
  379. package/src/tools/trellis/commands/diff.ts +150 -0
  380. package/src/tools/trellis/commands/doctor.ts +771 -0
  381. package/src/tools/trellis/commands/emit.ts +365 -0
  382. package/src/tools/trellis/commands/history.ts +83 -0
  383. package/src/tools/trellis/commands/import.ts +198 -0
  384. package/src/tools/trellis/commands/init.ts +81 -0
  385. package/src/tools/trellis/commands/list.ts +103 -0
  386. package/src/tools/trellis/commands/onboard.ts +156 -0
  387. package/src/tools/trellis/commands/pin.ts +172 -0
  388. package/src/tools/trellis/commands/prime.ts +193 -0
  389. package/src/tools/trellis/commands/render.ts +122 -0
  390. package/src/tools/trellis/commands/schema.ts +353 -0
  391. package/src/tools/trellis/commands/show.ts +115 -0
  392. package/src/tools/trellis/commands/stats.ts +65 -0
  393. package/src/tools/trellis/commands/sync.ts +112 -0
  394. package/src/tools/trellis/commands/tree.ts +123 -0
  395. package/src/tools/trellis/commands/update.ts +330 -0
  396. package/src/tools/trellis/commands/upgrade.ts +95 -0
  397. package/src/tools/trellis/commands/validate.ts +166 -0
  398. package/src/tools/trellis/config-schema.ts +81 -0
  399. package/src/tools/trellis/config.ts +108 -0
  400. package/src/tools/trellis/frontmatter.ts +348 -0
  401. package/src/tools/trellis/id.ts +24 -0
  402. package/src/tools/trellis/index.ts +209 -0
  403. package/src/tools/trellis/markers.ts +28 -0
  404. package/src/tools/trellis/output.ts +84 -0
  405. package/src/tools/trellis/render.ts +212 -0
  406. package/src/tools/trellis/store.ts +144 -0
  407. package/src/tools/trellis/types.ts +82 -0
  408. package/src/tools/trellis/validate.ts +199 -0
  409. package/src/tools/trellis/yaml.ts +309 -0
  410. package/src/tracker/beads.test.ts +454 -0
  411. package/src/tracker/beads.ts +56 -0
  412. package/src/tracker/factory.test.ts +90 -0
  413. package/src/tracker/factory.ts +65 -0
  414. package/src/tracker/sprout.test.ts +461 -0
  415. package/src/tracker/sprout.ts +182 -0
  416. package/src/tracker/types.ts +52 -0
  417. package/src/trellis/client.test.ts +107 -0
  418. package/src/trellis/client.ts +179 -0
  419. package/src/types.ts +970 -0
  420. package/src/utils/bin.test.ts +10 -0
  421. package/src/utils/bin.ts +37 -0
  422. package/src/utils/browser.test.ts +49 -0
  423. package/src/utils/browser.ts +48 -0
  424. package/src/utils/fs.test.ts +119 -0
  425. package/src/utils/fs.ts +62 -0
  426. package/src/utils/pid.test.ts +152 -0
  427. package/src/utils/pid.ts +130 -0
  428. package/src/utils/process-scan.test.ts +53 -0
  429. package/src/utils/process-scan.ts +76 -0
  430. package/src/utils/time.test.ts +43 -0
  431. package/src/utils/time.ts +37 -0
  432. package/src/utils/version.test.ts +33 -0
  433. package/src/utils/version.ts +70 -0
  434. package/src/version.ts +5 -0
  435. package/src/watchdog/daemon.test.ts +3721 -0
  436. package/src/watchdog/daemon.ts +1257 -0
  437. package/src/watchdog/health.test.ts +830 -0
  438. package/src/watchdog/health.ts +434 -0
  439. package/src/watchdog/triage.test.ts +205 -0
  440. package/src/watchdog/triage.ts +205 -0
  441. package/src/worktree/manager.test.ts +720 -0
  442. package/src/worktree/manager.ts +405 -0
  443. package/src/worktree/process.test.ts +172 -0
  444. package/src/worktree/process.ts +131 -0
  445. package/src/worktree/tmux.test.ts +1616 -0
  446. package/src/worktree/tmux.ts +721 -0
  447. package/templates/CLAUDE.md.tmpl +100 -0
  448. package/templates/copilot-hooks.json.tmpl +13 -0
  449. package/templates/hooks.json.tmpl +109 -0
  450. package/templates/overlay.md.tmpl +88 -0
  451. package/ui/dist/apple-touch-icon-bdy6teep.png +0 -0
  452. package/ui/dist/chunk-8s31f05k.css +1 -0
  453. package/ui/dist/chunk-vm5rz679.js +300 -0
  454. package/ui/dist/favicon-nzb39vza.svg +4 -0
  455. package/ui/dist/index.html +17 -0
@@ -0,0 +1,1062 @@
1
+ import { createInterface } from "node:readline";
2
+ import chalk from "chalk";
3
+ import type { Command } from "commander";
4
+ import { getRegistry, type TypeDefinition } from "../registry/type-registry.ts";
5
+ import type { ExpertiseRecord, RecordType } from "../schemas/record.ts";
6
+ import { archiveRecords } from "../utils/archive.ts";
7
+ import { getExpertisePath, readConfig } from "../utils/config.ts";
8
+ import {
9
+ generateRecordId,
10
+ readExpertiseFile,
11
+ resolveRecordId,
12
+ writeExpertiseFile,
13
+ } from "../utils/expertise.ts";
14
+ import { getRecordSummary } from "../utils/format.ts";
15
+ import { runHooks } from "../utils/hooks.ts";
16
+ import { outputJson, outputJsonError } from "../utils/json-output.ts";
17
+ import { withFileLock } from "../utils/lock.ts";
18
+ import { accent, brand, isQuiet } from "../utils/palette.ts";
19
+
20
+ // Payload sent to `pre-compact` hooks. A hook may print `{ replacement: <full
21
+ // record body> }` on stdout to override the mechanical merge; if absent or
22
+ // empty, the mechanical strategy (concat/keep_latest/merge_outcomes) runs as
23
+ // today. The `replacement` field is validated against the unified AJV schema
24
+ // before being written.
25
+ interface PreCompactPayload {
26
+ domain: string;
27
+ type: RecordType;
28
+ records: ExpertiseRecord[];
29
+ replacement?: ExpertiseRecord;
30
+ }
31
+
32
+ // Strict numeric flag parsing — see mx-5b9578 / src/commands/rank.ts.
33
+ // `Number.parseInt("10abc", 10)` silently returns 10; use regex + Number() so
34
+ // typos like `--min-group abc` or `--max-records 50xyz` are rejected.
35
+ const POSITIVE_INT_RE = /^\d+$/;
36
+
37
+ function parseStrictPositiveInt(raw: string): number | null {
38
+ if (!POSITIVE_INT_RE.test(raw)) return null;
39
+ const n = Number(raw);
40
+ return Number.isFinite(n) && n >= 1 ? n : null;
41
+ }
42
+
43
+ interface CompactCandidate {
44
+ domain: string;
45
+ type: RecordType;
46
+ records: Array<{
47
+ id: string | undefined;
48
+ summary: string;
49
+ recorded_at: string;
50
+ }>;
51
+ }
52
+
53
+ // For merge_outcomes types, finer grouping by idKey collapses true duplicates
54
+ // (same statement / same canonical key, differing outcomes). Other strategies
55
+ // group by type only — built-ins behave as before.
56
+ function groupingKeyFor(record: ExpertiseRecord): string {
57
+ const def = getRegistry().get(record.type);
58
+ if (def?.compact === "merge_outcomes") {
59
+ const v = (record as unknown as Record<string, unknown>)[def.idKey];
60
+ return `${record.type}::${String(v ?? "")}`;
61
+ }
62
+ return record.type;
63
+ }
64
+
65
+ function findCandidates(
66
+ domain: string,
67
+ records: ExpertiseRecord[],
68
+ now: Date,
69
+ shelfLife: { tactical: number; observational: number },
70
+ minGroupSize = 3,
71
+ ): CompactCandidate[] {
72
+ // Group records by type (or by type+idKey for merge_outcomes types)
73
+ const byKey = new Map<string, { type: RecordType; records: ExpertiseRecord[] }>();
74
+ for (const r of records) {
75
+ const key = groupingKeyFor(r);
76
+ const slot = byKey.get(key);
77
+ if (slot) {
78
+ slot.records.push(r);
79
+ } else {
80
+ byKey.set(key, { type: r.type, records: [r] });
81
+ }
82
+ }
83
+
84
+ const candidates: CompactCandidate[] = [];
85
+
86
+ for (const { type, records: group } of byKey.values()) {
87
+ if (group.length < 2) continue;
88
+
89
+ // Include groups where at least one record is stale or the group is large enough
90
+ const hasStale = group.some((r) => {
91
+ if (r.classification === "foundational") return false;
92
+ const ageMs = now.getTime() - new Date(r.recorded_at).getTime();
93
+ const ageDays = Math.floor(ageMs / (1000 * 60 * 60 * 24));
94
+ if (r.classification === "tactical") return ageDays > shelfLife.tactical;
95
+ if (r.classification === "observational") return ageDays > shelfLife.observational;
96
+ return false;
97
+ });
98
+
99
+ if (hasStale || group.length >= minGroupSize) {
100
+ candidates.push({
101
+ domain,
102
+ type,
103
+ records: group.map((r) => ({
104
+ id: r.id,
105
+ summary: getRecordSummary(r),
106
+ recorded_at: r.recorded_at,
107
+ })),
108
+ });
109
+ }
110
+ }
111
+
112
+ return candidates;
113
+ }
114
+
115
+ function resolveRecordIds(records: ExpertiseRecord[], identifiers: string[]): number[] {
116
+ const indices: number[] = [];
117
+ for (const id of identifiers) {
118
+ const result = resolveRecordId(records, id);
119
+ if (!result.ok) {
120
+ throw new Error(result.error);
121
+ }
122
+ indices.push(result.index);
123
+ }
124
+ return indices;
125
+ }
126
+
127
+ async function confirmAction(prompt: string): Promise<boolean> {
128
+ const rl = createInterface({
129
+ input: process.stdin,
130
+ output: process.stdout,
131
+ });
132
+
133
+ return new Promise((resolve) => {
134
+ rl.question(`${prompt} (y/N): `, (answer) => {
135
+ rl.close();
136
+ resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes");
137
+ });
138
+ });
139
+ }
140
+
141
+ export function registerCompactCommand(program: Command): void {
142
+ program
143
+ .command("compact")
144
+ .argument("[domain]", "expertise domain (required for --apply)")
145
+ .description("Compact records: analyze candidates or apply a compaction")
146
+ .option("--analyze", "show compaction candidates")
147
+ .option("--apply", "apply a compaction (replace records with summary)")
148
+ .option("--auto", "automatically compact all candidates")
149
+ .option("--dry-run", "preview what --auto would do without writing (use with --auto)")
150
+ .option("--min-group <size>", "minimum group size for auto-compaction (default: 5)", "5")
151
+ .option("--max-records <count>", "maximum records to compact in one run (default: 50)", "50")
152
+ .option("--yes", "skip confirmation prompts (use with --auto)")
153
+ .option("--records <ids>", "comma-separated record IDs to compact")
154
+ .option("--type <type>", "record type for the replacement")
155
+ .option("--name <name>", "name for replacement (pattern/reference/guide)")
156
+ .option("--title <title>", "title for replacement (decision)")
157
+ .option("--description <description>", "description for replacement")
158
+ .option("--content <content>", "content for replacement (convention)")
159
+ .option("--resolution <resolution>", "resolution for replacement (failure)")
160
+ .option("--rationale <rationale>", "rationale for replacement (decision)")
161
+ .action(async (domain: string | undefined, options: Record<string, unknown>) => {
162
+ const jsonMode = program.opts().json === true;
163
+
164
+ if (options.analyze) {
165
+ await handleAnalyze(jsonMode, domain);
166
+ } else if (options.auto) {
167
+ await handleAuto(options, jsonMode, domain);
168
+ } else if (options.apply) {
169
+ if (!domain) {
170
+ const msg = "Domain is required for --apply.";
171
+ if (jsonMode) {
172
+ outputJsonError("compact", msg);
173
+ } else {
174
+ console.error(chalk.red(`Error: ${msg}`));
175
+ }
176
+ process.exitCode = 1;
177
+ return;
178
+ }
179
+ await handleApply(domain, options, jsonMode);
180
+ } else {
181
+ const msg = "Specify --analyze, --auto, or --apply.";
182
+ if (jsonMode) {
183
+ outputJsonError("compact", msg);
184
+ } else {
185
+ console.error(chalk.red(`Error: ${msg}`));
186
+ }
187
+ process.exitCode = 1;
188
+ }
189
+ });
190
+ }
191
+
192
+ async function handleAnalyze(jsonMode: boolean, domain?: string): Promise<void> {
193
+ const config = await readConfig();
194
+ const now = new Date();
195
+ const shelfLife = config.classification_defaults.shelf_life;
196
+ const allCandidates: CompactCandidate[] = [];
197
+
198
+ // Filter to specific domain if provided, otherwise check all domains
199
+ const domainsToCheck = domain ? [domain] : Object.keys(config.domains);
200
+
201
+ // Validate domain if specified
202
+ if (domain && !(domain in config.domains)) {
203
+ const msg = `Domain "${domain}" not found in config.`;
204
+ if (jsonMode) {
205
+ outputJsonError("compact", msg);
206
+ } else {
207
+ console.error(chalk.red(`Error: ${msg}`));
208
+ }
209
+ process.exitCode = 1;
210
+ return;
211
+ }
212
+
213
+ for (const d of domainsToCheck) {
214
+ const filePath = getExpertisePath(d);
215
+ const records = await readExpertiseFile(filePath);
216
+ if (records.length < 2) continue;
217
+ const candidates = findCandidates(d, records, now, shelfLife);
218
+ allCandidates.push(...candidates);
219
+ }
220
+
221
+ if (jsonMode) {
222
+ outputJson({
223
+ success: true,
224
+ command: "compact",
225
+ action: "analyze",
226
+ candidates: allCandidates,
227
+ });
228
+ return;
229
+ }
230
+
231
+ if (allCandidates.length === 0) {
232
+ if (!isQuiet()) console.log(brand("No compaction candidates found."));
233
+ return;
234
+ }
235
+
236
+ // Group candidates by domain for better organization
237
+ const byDomain = new Map<string, CompactCandidate[]>();
238
+ for (const c of allCandidates) {
239
+ if (!byDomain.has(c.domain)) {
240
+ byDomain.set(c.domain, []);
241
+ }
242
+ byDomain.get(c.domain)?.push(c);
243
+ }
244
+
245
+ const totalGroups = allCandidates.length;
246
+ const totalRecords = allCandidates.reduce((sum, c) => sum + c.records.length, 0);
247
+
248
+ console.log(chalk.bold("\nCompaction candidates:\n"));
249
+ console.log(
250
+ chalk.dim(`Found ${totalGroups} groups (${totalRecords} records that could be compacted)\n`),
251
+ );
252
+
253
+ for (const [domain, candidates] of byDomain) {
254
+ console.log(chalk.bold(`${domain}:`));
255
+ for (const c of candidates) {
256
+ console.log(` ${chalk.cyan(c.type)} (${c.records.length} records)`);
257
+ for (const r of c.records.slice(0, 3)) {
258
+ console.log(` ${r.id ? accent(r.id) : chalk.dim("(no id)")}: ${r.summary}`);
259
+ }
260
+ if (c.records.length > 3) {
261
+ console.log(chalk.dim(` ... and ${c.records.length - 3} more`));
262
+ }
263
+ }
264
+ console.log();
265
+ }
266
+
267
+ console.log(chalk.dim("To compact manually:"));
268
+ console.log(
269
+ chalk.dim(" loam compact <domain> --apply --records <ids> --type <type> [fields...]"),
270
+ );
271
+ console.log(chalk.dim("\nTo compact automatically:"));
272
+ console.log(chalk.dim(" loam compact --auto [--dry-run]"));
273
+ }
274
+
275
+ async function handleAuto(
276
+ options: Record<string, unknown>,
277
+ jsonMode: boolean,
278
+ domain?: string,
279
+ ): Promise<void> {
280
+ const config = await readConfig();
281
+ const now = new Date();
282
+ const shelfLife = config.classification_defaults.shelf_life;
283
+
284
+ const dryRun = options.dryRun === true;
285
+ const skipConfirmation = options.yes === true;
286
+
287
+ const minGroupRaw = options.minGroup as string | undefined;
288
+ const minGroupSize = minGroupRaw === undefined ? 5 : parseStrictPositiveInt(minGroupRaw);
289
+ if (minGroupSize === null) {
290
+ const msg = `--min-group must be a positive integer (got "${minGroupRaw}").`;
291
+ if (jsonMode) {
292
+ outputJsonError("compact", msg);
293
+ } else {
294
+ console.error(chalk.red(`Error: ${msg}`));
295
+ }
296
+ process.exitCode = 1;
297
+ return;
298
+ }
299
+
300
+ const maxRecordsRaw = options.maxRecords as string | undefined;
301
+ const maxRecords = maxRecordsRaw === undefined ? 50 : parseStrictPositiveInt(maxRecordsRaw);
302
+ if (maxRecords === null) {
303
+ const msg = `--max-records must be a positive integer (got "${maxRecordsRaw}").`;
304
+ if (jsonMode) {
305
+ outputJsonError("compact", msg);
306
+ } else {
307
+ console.error(chalk.red(`Error: ${msg}`));
308
+ }
309
+ process.exitCode = 1;
310
+ return;
311
+ }
312
+
313
+ // Filter to specific domain if provided, otherwise check all domains
314
+ const domainsToCheck = domain ? [domain] : Object.keys(config.domains);
315
+
316
+ // Validate domain if specified
317
+ if (domain && !(domain in config.domains)) {
318
+ const msg = `Domain "${domain}" not found in config.`;
319
+ if (jsonMode) {
320
+ outputJsonError("compact", msg);
321
+ } else {
322
+ console.error(chalk.red(`Error: ${msg}`));
323
+ }
324
+ process.exitCode = 1;
325
+ return;
326
+ }
327
+
328
+ // Collect all candidates across specified domains
329
+ const allCandidates: Array<{ domain: string; candidate: CompactCandidate }> = [];
330
+
331
+ for (const d of domainsToCheck) {
332
+ const filePath = getExpertisePath(d);
333
+ const records = await readExpertiseFile(filePath);
334
+ if (records.length < 2) continue;
335
+
336
+ const candidates = findCandidates(d, records, now, shelfLife, minGroupSize);
337
+ for (const candidate of candidates) {
338
+ allCandidates.push({ domain: d, candidate });
339
+ }
340
+ }
341
+
342
+ if (allCandidates.length === 0) {
343
+ if (jsonMode) {
344
+ outputJson({
345
+ success: true,
346
+ command: "compact",
347
+ action: dryRun ? "dry-run" : "auto",
348
+ compacted: 0,
349
+ results: [],
350
+ });
351
+ } else {
352
+ if (!isQuiet()) console.log(brand("No compaction candidates found."));
353
+ }
354
+ return;
355
+ }
356
+
357
+ // Calculate total records to compact and apply max limit
358
+ let totalRecordsToCompact = 0;
359
+ const candidatesToProcess: Array<{
360
+ domain: string;
361
+ candidate: CompactCandidate;
362
+ }> = [];
363
+
364
+ for (const item of allCandidates) {
365
+ if (totalRecordsToCompact + item.candidate.records.length > maxRecords) {
366
+ break;
367
+ }
368
+ candidatesToProcess.push(item);
369
+ totalRecordsToCompact += item.candidate.records.length;
370
+ }
371
+
372
+ // Show summary
373
+ if (!jsonMode && !dryRun) {
374
+ console.log(chalk.bold("\nCompaction summary:\n"));
375
+ console.log(` ${candidatesToProcess.length} groups will be compacted`);
376
+ console.log(` ${totalRecordsToCompact} records → ${candidatesToProcess.length} records\n`);
377
+
378
+ for (const { domain, candidate } of candidatesToProcess) {
379
+ console.log(
380
+ `${chalk.cyan(`${domain}/${candidate.type}`)} (${candidate.records.length} records)`,
381
+ );
382
+ for (const r of candidate.records.slice(0, 3)) {
383
+ console.log(` ${r.id ? accent(r.id) : chalk.dim("(no id)")}: ${r.summary}`);
384
+ }
385
+ if (candidate.records.length > 3) {
386
+ console.log(chalk.dim(` ... and ${candidate.records.length - 3} more`));
387
+ }
388
+ console.log();
389
+ }
390
+
391
+ if (allCandidates.length > candidatesToProcess.length) {
392
+ const skipped = allCandidates.length - candidatesToProcess.length;
393
+ console.log(
394
+ chalk.yellow(`Note: ${skipped} additional groups skipped due to --max-records limit\n`),
395
+ );
396
+ }
397
+ }
398
+
399
+ // Dry-run mode: show detailed preview of what would be done
400
+ if (dryRun) {
401
+ if (jsonMode) {
402
+ outputJson({
403
+ success: true,
404
+ command: "compact",
405
+ action: "dry-run",
406
+ wouldCompact: totalRecordsToCompact,
407
+ groups: candidatesToProcess.map(({ domain, candidate }) => ({
408
+ domain,
409
+ type: candidate.type,
410
+ count: candidate.records.length,
411
+ records: candidate.records,
412
+ })),
413
+ });
414
+ } else {
415
+ console.log(chalk.bold("\nDry-run preview:\n"));
416
+ console.log(` ${candidatesToProcess.length} groups would be compacted`);
417
+ console.log(` ${totalRecordsToCompact} records → ${candidatesToProcess.length} records\n`);
418
+
419
+ for (const { domain, candidate } of candidatesToProcess) {
420
+ console.log(
421
+ `${chalk.cyan(`${domain}/${candidate.type}`)} (${candidate.records.length} records)`,
422
+ );
423
+ for (const r of candidate.records.slice(0, 3)) {
424
+ console.log(` ${r.id ? accent(r.id) : chalk.dim("(no id)")}: ${r.summary}`);
425
+ }
426
+ if (candidate.records.length > 3) {
427
+ console.log(chalk.dim(` ... and ${candidate.records.length - 3} more`));
428
+ }
429
+ console.log();
430
+ }
431
+
432
+ if (allCandidates.length > candidatesToProcess.length) {
433
+ const skipped = allCandidates.length - candidatesToProcess.length;
434
+ console.log(
435
+ chalk.yellow(`Note: ${skipped} additional groups skipped due to --max-records limit\n`),
436
+ );
437
+ }
438
+
439
+ if (!isQuiet())
440
+ console.log(
441
+ `${brand("✓")} ${brand(`Dry-run complete. Would compact ${totalRecordsToCompact} records across ${candidatesToProcess.length} groups.`)}`,
442
+ );
443
+ if (!isQuiet()) console.log(chalk.dim(" Run without --dry-run to apply changes."));
444
+ }
445
+ return;
446
+ }
447
+
448
+ // Ask for confirmation unless --yes was passed
449
+ if (!jsonMode && !skipConfirmation) {
450
+ const confirmed = await confirmAction("Proceed with compaction?");
451
+ if (!confirmed) {
452
+ console.log(chalk.yellow("Compaction cancelled."));
453
+ return;
454
+ }
455
+ }
456
+
457
+ // Apply compaction
458
+ let totalCompacted = 0;
459
+ const results: Array<{ domain: string; type: RecordType; count: number }> = [];
460
+ const warnings: string[] = [];
461
+
462
+ // Group candidates by domain for efficient processing
463
+ const byDomain = new Map<string, CompactCandidate[]>();
464
+ for (const { domain, candidate } of candidatesToProcess) {
465
+ if (!byDomain.has(domain)) {
466
+ byDomain.set(domain, []);
467
+ }
468
+ byDomain.get(domain)?.push(candidate);
469
+ }
470
+
471
+ for (const [domain, candidates] of byDomain) {
472
+ const filePath = getExpertisePath(domain);
473
+ // Originals collected during the locked mutation; archived AFTER the lock
474
+ // releases so the archive write doesn't deadlock against the live lock.
475
+ const toArchive: ExpertiseRecord[] = [];
476
+
477
+ await withFileLock(filePath, async () => {
478
+ const records = await readExpertiseFile(filePath);
479
+ let updatedRecords = [...records];
480
+
481
+ for (const candidate of candidates) {
482
+ // Find the actual record objects for this candidate
483
+ const recordsToCompact = updatedRecords.filter(
484
+ (r) => r.type === candidate.type && candidate.records.some((cr) => cr.id === r.id),
485
+ );
486
+
487
+ if (recordsToCompact.length < 2) continue;
488
+
489
+ // Pre-compact hook may supply a semantically summarized replacement;
490
+ // when absent, fall back to mechanical mergeRecords(). On hook block
491
+ // or invalid replacement, skip this group with a warning so unrelated
492
+ // groups in the same domain still compact.
493
+ const res = await resolveReplacement(domain, candidate.type, recordsToCompact);
494
+ if (!res.ok) {
495
+ warnings.push(`${domain}/${candidate.type}: ${res.reason}`);
496
+ continue;
497
+ }
498
+ const replacement = res.replacement;
499
+
500
+ // Remove old records
501
+ const idsToRemove = new Set(recordsToCompact.map((r) => r.id));
502
+ updatedRecords = updatedRecords.filter((r) => !idsToRemove.has(r.id));
503
+
504
+ // Add replacement
505
+ updatedRecords.push(replacement);
506
+
507
+ toArchive.push(...recordsToCompact);
508
+ totalCompacted += recordsToCompact.length;
509
+ results.push({
510
+ domain,
511
+ type: candidate.type,
512
+ count: recordsToCompact.length,
513
+ });
514
+ }
515
+
516
+ // Write back if changes were made
517
+ if (updatedRecords.length !== records.length) {
518
+ await writeExpertiseFile(filePath, updatedRecords);
519
+ }
520
+ });
521
+
522
+ // Soft-archive the originals so `lm restore <id>` can undo a compaction.
523
+ // Runs after the live lock releases (archiveRecords takes its own lock
524
+ // on the archive file).
525
+ if (toArchive.length > 0) {
526
+ await archiveRecords(domain, toArchive, new Date(), "compacted");
527
+ }
528
+ }
529
+
530
+ if (jsonMode) {
531
+ outputJson({
532
+ success: true,
533
+ command: "compact",
534
+ action: "auto",
535
+ compacted: totalCompacted,
536
+ results,
537
+ warnings: warnings.length > 0 ? warnings : undefined,
538
+ });
539
+ return;
540
+ }
541
+
542
+ if (!isQuiet())
543
+ console.log(
544
+ `\n${brand("✓")} ${brand(`Auto-compacted ${totalCompacted} records across ${results.length} groups`)}`,
545
+ );
546
+ for (const r of results) {
547
+ if (!isQuiet()) console.log(chalk.dim(` ${r.domain}/${r.type}: ${r.count} records → 1`));
548
+ }
549
+ for (const w of warnings) {
550
+ if (!isQuiet()) console.error(chalk.yellow(`Warning: ${w}`));
551
+ }
552
+ }
553
+
554
+ // Resolve a replacement record either by running a `pre-compact` hook (when
555
+ // one is registered and produces a `{ replacement }` body) or by falling back
556
+ // to the mechanical mergeRecords() strategy. Returns:
557
+ // - { ok: true, replacement }: write this record.
558
+ // - { ok: false, reason }: skip this group (hook blocked or returned an
559
+ // invalid replacement). Caller decides whether to abort or continue.
560
+ type ReplacementResult =
561
+ | { ok: true; replacement: ExpertiseRecord; ranHook: boolean }
562
+ | { ok: false; reason: string };
563
+
564
+ export async function resolveReplacement(
565
+ domain: string,
566
+ type: RecordType,
567
+ recordsToCompact: ExpertiseRecord[],
568
+ opts: { fallback?: () => ExpertiseRecord; cwd?: string } = {},
569
+ ): Promise<ReplacementResult> {
570
+ const hookRes = await runHooks<PreCompactPayload>(
571
+ "pre-compact",
572
+ { domain, type, records: recordsToCompact },
573
+ opts.cwd ? { cwd: opts.cwd } : {},
574
+ );
575
+ if (hookRes.blocked) {
576
+ return {
577
+ ok: false,
578
+ reason: hookRes.blockReason ?? "pre-compact hook blocked the compaction",
579
+ };
580
+ }
581
+
582
+ if (hookRes.ranAny && hookRes.payload?.replacement) {
583
+ const candidate = { ...hookRes.payload.replacement };
584
+ // Auto-populate the supersedes link if the hook omitted it: the
585
+ // replacement must always carry the originals' IDs so `lm restore` works
586
+ // and history is traceable. The hook may also set it explicitly.
587
+ const sourceIds = recordsToCompact.map((r) => r.id).filter((id): id is string => !!id);
588
+ if (!Array.isArray(candidate.supersedes) || candidate.supersedes.length === 0) {
589
+ candidate.supersedes = sourceIds;
590
+ }
591
+ // Drop any incoming id so we recompute deterministically from the body.
592
+ delete (candidate as { id?: string }).id;
593
+ const validator = getRegistry().validator;
594
+ if (!validator(candidate)) {
595
+ const errs = (validator.errors ?? []).map((e) => `${e.instancePath} ${e.message}`).join("; ");
596
+ return { ok: false, reason: `pre-compact hook produced an invalid record: ${errs}` };
597
+ }
598
+ candidate.id = generateRecordId(candidate);
599
+ return { ok: true, replacement: candidate, ranHook: true };
600
+ }
601
+
602
+ if (opts.fallback) {
603
+ return { ok: true, replacement: opts.fallback(), ranHook: false };
604
+ }
605
+ return { ok: true, replacement: mergeRecords(recordsToCompact), ranHook: false };
606
+ }
607
+
608
+ export function mergeRecords(records: ExpertiseRecord[]): ExpertiseRecord {
609
+ if (records.length === 0) {
610
+ throw new Error("Cannot merge empty record list");
611
+ }
612
+
613
+ const first = records[0];
614
+ if (!first) {
615
+ throw new Error("Cannot merge empty record list");
616
+ }
617
+
618
+ const def = getRegistry().get(first.type);
619
+ if (!def) {
620
+ throw new Error(`Unknown record type: ${first.type}`);
621
+ }
622
+
623
+ let result: ExpertiseRecord;
624
+ switch (def.compact) {
625
+ case "concat":
626
+ result = compactConcat(records, def);
627
+ break;
628
+ case "keep_latest":
629
+ result = compactKeepLatest(records, def);
630
+ break;
631
+ case "merge_outcomes":
632
+ result = compactMergeOutcomes(records, def);
633
+ break;
634
+ case "manual":
635
+ throw new Error(
636
+ `Type "${def.name}" has compact strategy "manual" — use \`loam compact --apply\` to compact manually.`,
637
+ );
638
+ }
639
+
640
+ // Generate ID for the merged record
641
+ result.id = generateRecordId(result);
642
+ return result;
643
+ }
644
+
645
+ function commonMergeBase(
646
+ records: ExpertiseRecord[],
647
+ def: TypeDefinition,
648
+ ): {
649
+ supersedes: string[];
650
+ tags: string[] | undefined;
651
+ files: string[] | undefined;
652
+ } {
653
+ const supersedes = records.map((r) => r.id).filter(Boolean) as string[];
654
+ const allTags = records.flatMap((r) => r.tags ?? []);
655
+ const tags = allTags.length > 0 ? Array.from(new Set(allTags)) : undefined;
656
+ let files: string[] | undefined;
657
+ if (def.extractsFiles) {
658
+ const all = records.flatMap((r) => {
659
+ const v = (r as unknown as Record<string, unknown>)[def.filesField];
660
+ return Array.isArray(v) ? (v as string[]) : [];
661
+ });
662
+ files = all.length > 0 ? Array.from(new Set(all)) : undefined;
663
+ }
664
+ return { supersedes, tags, files };
665
+ }
666
+
667
+ // "Label-like" required fields that take the longest value during concat
668
+ // instead of being joined. Matches the historical built-in mergeRecords
669
+ // behavior (pattern.name, decision.title, etc.). Custom types using these
670
+ // field names get the same treatment, which is the expected ergonomic.
671
+ const LONGEST_PICK_FIELDS = new Set(["name", "title"]);
672
+
673
+ function compactConcat(records: ExpertiseRecord[], def: TypeDefinition): ExpertiseRecord {
674
+ const base = commonMergeBase(records, def);
675
+ const out: Record<string, unknown> = {
676
+ type: def.name,
677
+ classification: "foundational",
678
+ recorded_at: new Date().toISOString(),
679
+ supersedes: base.supersedes,
680
+ };
681
+ if (base.tags) out.tags = base.tags;
682
+ if (def.extractsFiles && base.files) out[def.filesField] = base.files;
683
+
684
+ for (const field of def.required) {
685
+ const values = records
686
+ .map((r) => (r as unknown as Record<string, unknown>)[field])
687
+ .filter((v): v is string => typeof v === "string");
688
+ if (LONGEST_PICK_FIELDS.has(field)) {
689
+ out[field] = values.reduce(
690
+ (longest, v) => (v.length > longest.length ? v : longest),
691
+ values[0] ?? "",
692
+ );
693
+ } else {
694
+ out[field] = values.join("\n\n");
695
+ }
696
+ }
697
+ return out as unknown as ExpertiseRecord;
698
+ }
699
+
700
+ function compactKeepLatest(records: ExpertiseRecord[], def: TypeDefinition): ExpertiseRecord {
701
+ const base = commonMergeBase(records, def);
702
+ const sorted = [...records].sort(
703
+ (a, b) => new Date(b.recorded_at).getTime() - new Date(a.recorded_at).getTime(),
704
+ );
705
+ const latest = sorted[0];
706
+ if (!latest) throw new Error("Cannot compact empty record list");
707
+ const merged: Record<string, unknown> = { ...(latest as unknown as Record<string, unknown>) };
708
+ merged.classification = "foundational";
709
+ merged.recorded_at = new Date().toISOString();
710
+ merged.supersedes = base.supersedes;
711
+ if (base.tags) merged.tags = base.tags;
712
+ else delete merged.tags;
713
+ if (def.extractsFiles && base.files) merged[def.filesField] = base.files;
714
+ delete merged.id;
715
+ return merged as unknown as ExpertiseRecord;
716
+ }
717
+
718
+ function compactMergeOutcomes(records: ExpertiseRecord[], def: TypeDefinition): ExpertiseRecord {
719
+ const base = commonMergeBase(records, def);
720
+ // Combine outcomes from every input record.
721
+ const allOutcomes = records.flatMap((r) => r.outcomes ?? []);
722
+
723
+ // Take the latest record as the canonical shape, then merge outcomes.
724
+ const sorted = [...records].sort(
725
+ (a, b) => new Date(b.recorded_at).getTime() - new Date(a.recorded_at).getTime(),
726
+ );
727
+ const latest = sorted[0];
728
+ if (!latest) throw new Error("Cannot compact empty record list");
729
+ const merged: Record<string, unknown> = { ...(latest as unknown as Record<string, unknown>) };
730
+ merged.classification = "foundational";
731
+ merged.recorded_at = new Date().toISOString();
732
+ merged.supersedes = base.supersedes;
733
+ if (base.tags) merged.tags = base.tags;
734
+ else delete merged.tags;
735
+ if (def.extractsFiles && base.files) merged[def.filesField] = base.files;
736
+ if (allOutcomes.length > 0) merged.outcomes = allOutcomes;
737
+ delete merged.id;
738
+ return merged as unknown as ExpertiseRecord;
739
+ }
740
+
741
+ async function handleApply(
742
+ domain: string,
743
+ options: Record<string, unknown>,
744
+ jsonMode: boolean,
745
+ ): Promise<void> {
746
+ const config = await readConfig();
747
+
748
+ if (!(domain in config.domains)) {
749
+ const msg = `Domain "${domain}" not found in config.`;
750
+ if (jsonMode) {
751
+ outputJsonError("compact", msg);
752
+ } else {
753
+ console.error(chalk.red(`Error: ${msg}`));
754
+ }
755
+ process.exitCode = 1;
756
+ return;
757
+ }
758
+
759
+ if (typeof options.records !== "string") {
760
+ const msg = "--records is required for --apply.";
761
+ if (jsonMode) {
762
+ outputJsonError("compact", msg);
763
+ } else {
764
+ console.error(chalk.red(`Error: ${msg}`));
765
+ }
766
+ process.exitCode = 1;
767
+ return;
768
+ }
769
+
770
+ const filePath = getExpertisePath(domain);
771
+ // Track records that were compacted so we can archive them after the live
772
+ // lock releases. Empty when the apply path errors out before writing.
773
+ let recordsToArchive: ExpertiseRecord[] = [];
774
+
775
+ await withFileLock(filePath, async () => {
776
+ const records = await readExpertiseFile(filePath);
777
+ const identifiers = (options.records as string)
778
+ .split(",")
779
+ .map((s) => s.trim())
780
+ .filter(Boolean);
781
+
782
+ let indicesToRemove: number[];
783
+ try {
784
+ indicesToRemove = resolveRecordIds(records, identifiers);
785
+ } catch (err) {
786
+ const msg = (err as Error).message;
787
+ if (jsonMode) {
788
+ outputJsonError("compact", msg);
789
+ } else {
790
+ console.error(chalk.red(`Error: ${msg}`));
791
+ }
792
+ process.exitCode = 1;
793
+ return;
794
+ }
795
+
796
+ if (indicesToRemove.length < 2) {
797
+ const msg = "Compaction requires at least 2 records.";
798
+ if (jsonMode) {
799
+ outputJsonError("compact", msg);
800
+ } else {
801
+ console.error(chalk.red(`Error: ${msg}`));
802
+ }
803
+ process.exitCode = 1;
804
+ return;
805
+ }
806
+
807
+ // Build replacement record
808
+ const firstIdx = indicesToRemove[0];
809
+ const recordType =
810
+ (options.type as RecordType | undefined) ??
811
+ (firstIdx !== undefined ? records[firstIdx]?.type : undefined) ??
812
+ "convention";
813
+ const recordedAt = new Date().toISOString();
814
+ const sourceRecords = indicesToRemove
815
+ .map((i) => records[i])
816
+ .filter((r): r is ExpertiseRecord => r !== undefined);
817
+ const compactedFrom = sourceRecords.map((r) => r.id).filter((id): id is string => !!id);
818
+
819
+ // Pre-compact hook: when configured, its `{ replacement }` overrides the
820
+ // user-supplied flags. When absent, we build replacement from --type /
821
+ // --name / --description / etc. as before.
822
+ const hookRes = await runHooks<PreCompactPayload>("pre-compact", {
823
+ domain,
824
+ type: recordType,
825
+ records: sourceRecords,
826
+ });
827
+ if (hookRes.blocked) {
828
+ const msg = hookRes.blockReason ?? "pre-compact hook blocked the compaction";
829
+ if (jsonMode) {
830
+ outputJsonError("compact", msg);
831
+ } else {
832
+ console.error(chalk.red(`Error: ${msg}`));
833
+ }
834
+ process.exitCode = 1;
835
+ return;
836
+ }
837
+ for (const w of hookRes.warnings) {
838
+ if (!jsonMode) console.error(chalk.yellow(`Warning: ${w}`));
839
+ }
840
+
841
+ let replacement: ExpertiseRecord | undefined;
842
+ if (hookRes.ranAny && hookRes.payload?.replacement) {
843
+ const candidate = { ...hookRes.payload.replacement };
844
+ if (!Array.isArray(candidate.supersedes) || candidate.supersedes.length === 0) {
845
+ candidate.supersedes = compactedFrom;
846
+ }
847
+ delete (candidate as { id?: string }).id;
848
+ const validate = getRegistry().validator;
849
+ if (!validate(candidate)) {
850
+ const errs = (validate.errors ?? [])
851
+ .map((e) => `${e.instancePath} ${e.message}`)
852
+ .join("; ");
853
+ const msg = `pre-compact hook produced an invalid record: ${errs}`;
854
+ if (jsonMode) {
855
+ outputJsonError("compact", msg);
856
+ } else {
857
+ console.error(chalk.red(`Error: ${msg}`));
858
+ }
859
+ process.exitCode = 1;
860
+ return;
861
+ }
862
+ candidate.id = generateRecordId(candidate);
863
+ replacement = candidate;
864
+ }
865
+
866
+ if (!replacement) {
867
+ switch (recordType) {
868
+ case "convention": {
869
+ const content =
870
+ (options.content as string | undefined) ?? (options.description as string | undefined);
871
+ if (!content) {
872
+ const msg = "Replacement convention requires --content or --description.";
873
+ if (jsonMode) {
874
+ outputJsonError("compact", msg);
875
+ } else {
876
+ console.error(chalk.red(`Error: ${msg}`));
877
+ }
878
+ process.exitCode = 1;
879
+ return;
880
+ }
881
+ replacement = {
882
+ type: "convention",
883
+ content,
884
+ classification: "foundational",
885
+ recorded_at: recordedAt,
886
+ };
887
+ break;
888
+ }
889
+ case "pattern": {
890
+ const name = options.name as string | undefined;
891
+ const description = options.description as string | undefined;
892
+ if (!name || !description) {
893
+ const msg = "Replacement pattern requires --name and --description.";
894
+ if (jsonMode) {
895
+ outputJsonError("compact", msg);
896
+ } else {
897
+ console.error(chalk.red(`Error: ${msg}`));
898
+ }
899
+ process.exitCode = 1;
900
+ return;
901
+ }
902
+ replacement = {
903
+ type: "pattern",
904
+ name,
905
+ description,
906
+ classification: "foundational",
907
+ recorded_at: recordedAt,
908
+ };
909
+ break;
910
+ }
911
+ case "failure": {
912
+ const description = options.description as string | undefined;
913
+ const resolution = options.resolution as string | undefined;
914
+ if (!description || !resolution) {
915
+ const msg = "Replacement failure requires --description and --resolution.";
916
+ if (jsonMode) {
917
+ outputJsonError("compact", msg);
918
+ } else {
919
+ console.error(chalk.red(`Error: ${msg}`));
920
+ }
921
+ process.exitCode = 1;
922
+ return;
923
+ }
924
+ replacement = {
925
+ type: "failure",
926
+ description,
927
+ resolution,
928
+ classification: "foundational",
929
+ recorded_at: recordedAt,
930
+ };
931
+ break;
932
+ }
933
+ case "decision": {
934
+ const title = options.title as string | undefined;
935
+ const rationale = options.rationale as string | undefined;
936
+ if (!title || !rationale) {
937
+ const msg = "Replacement decision requires --title and --rationale.";
938
+ if (jsonMode) {
939
+ outputJsonError("compact", msg);
940
+ } else {
941
+ console.error(chalk.red(`Error: ${msg}`));
942
+ }
943
+ process.exitCode = 1;
944
+ return;
945
+ }
946
+ replacement = {
947
+ type: "decision",
948
+ title,
949
+ rationale,
950
+ classification: "foundational",
951
+ recorded_at: recordedAt,
952
+ };
953
+ break;
954
+ }
955
+ case "reference": {
956
+ const name = options.name as string | undefined;
957
+ const description = options.description as string | undefined;
958
+ if (!name || !description) {
959
+ const msg = "Replacement reference requires --name and --description.";
960
+ if (jsonMode) {
961
+ outputJsonError("compact", msg);
962
+ } else {
963
+ console.error(chalk.red(`Error: ${msg}`));
964
+ }
965
+ process.exitCode = 1;
966
+ return;
967
+ }
968
+ replacement = {
969
+ type: "reference",
970
+ name,
971
+ description,
972
+ classification: "foundational",
973
+ recorded_at: recordedAt,
974
+ };
975
+ break;
976
+ }
977
+ case "guide": {
978
+ const name = options.name as string | undefined;
979
+ const description = options.description as string | undefined;
980
+ if (!name || !description) {
981
+ const msg = "Replacement guide requires --name and --description.";
982
+ if (jsonMode) {
983
+ outputJsonError("compact", msg);
984
+ } else {
985
+ console.error(chalk.red(`Error: ${msg}`));
986
+ }
987
+ process.exitCode = 1;
988
+ return;
989
+ }
990
+ replacement = {
991
+ type: "guide",
992
+ name,
993
+ description,
994
+ classification: "foundational",
995
+ recorded_at: recordedAt,
996
+ };
997
+ break;
998
+ }
999
+ default: {
1000
+ const msg = `Unknown record type "${recordType}".`;
1001
+ if (jsonMode) {
1002
+ outputJsonError("compact", msg);
1003
+ } else {
1004
+ console.error(chalk.red(`Error: ${msg}`));
1005
+ }
1006
+ process.exitCode = 1;
1007
+ return;
1008
+ }
1009
+ }
1010
+
1011
+ // Add supersedes links to the compacted-from records
1012
+ if (compactedFrom.length > 0) {
1013
+ replacement.supersedes = compactedFrom;
1014
+ }
1015
+
1016
+ // Validate replacement (cached on registry)
1017
+ const validate = getRegistry().validator;
1018
+ replacement.id = generateRecordId(replacement);
1019
+ if (!validate(replacement)) {
1020
+ const errors = (validate.errors ?? []).map((err) => `${err.instancePath} ${err.message}`);
1021
+ const msg = `Replacement record failed validation: ${errors.join("; ")}`;
1022
+ if (jsonMode) {
1023
+ outputJsonError("compact", msg);
1024
+ } else {
1025
+ console.error(chalk.red(`Error: ${msg}`));
1026
+ }
1027
+ process.exitCode = 1;
1028
+ return;
1029
+ }
1030
+ }
1031
+
1032
+ // Remove old records and append replacement
1033
+ const removeSet = new Set(indicesToRemove);
1034
+ const remaining = records.filter((_, i) => !removeSet.has(i));
1035
+ remaining.push(replacement);
1036
+ await writeExpertiseFile(filePath, remaining);
1037
+
1038
+ // Capture for archive (outside lock; archiveRecords takes its own lock
1039
+ // on the archive file).
1040
+ recordsToArchive = sourceRecords;
1041
+
1042
+ if (jsonMode) {
1043
+ outputJson({
1044
+ success: true,
1045
+ command: "compact",
1046
+ action: "applied",
1047
+ domain,
1048
+ removed: indicesToRemove.length,
1049
+ replacement,
1050
+ });
1051
+ } else {
1052
+ if (!isQuiet())
1053
+ console.log(
1054
+ `${brand("✓")} ${brand(`Compacted ${indicesToRemove.length} ${recordType} records into 1 in ${domain}`)}`,
1055
+ );
1056
+ }
1057
+ });
1058
+
1059
+ if (recordsToArchive.length > 0) {
1060
+ await archiveRecords(domain, recordsToArchive, new Date(), "compacted");
1061
+ }
1062
+ }