@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,1210 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import chalk from "chalk";
3
+ import { type Command, Option } from "commander";
4
+ import { getRegistry, type TypeDefinition } from "../registry/type-registry.ts";
5
+ import type { Classification, Evidence, ExpertiseRecord, Outcome } from "../schemas/record.ts";
6
+ import { addDomain, getExpertisePath, readConfig } from "../utils/config.ts";
7
+ import {
8
+ assertWritableDirAnchor,
9
+ inferDirAnchors,
10
+ normalizeDirAnchor,
11
+ } from "../utils/dir-anchors.ts";
12
+ import {
13
+ findMissingDomainFields,
14
+ findRejectedRequiredFields,
15
+ getAllowedTypes,
16
+ getRequiredFields,
17
+ } from "../utils/domain-rules.ts";
18
+ import {
19
+ appendRecord,
20
+ findDuplicate,
21
+ readExpertiseFile,
22
+ writeExpertiseFile,
23
+ } from "../utils/expertise.ts";
24
+ import { getContextFiles, getCurrentCommit } from "../utils/git-context.ts";
25
+ import { runHooks } from "../utils/hooks.ts";
26
+ import { outputJson, outputJsonError } from "../utils/json-output.ts";
27
+ import { withFileLock } from "../utils/lock.ts";
28
+ import { parseStrictNonNegativeNumber } from "../utils/numeric-flags.ts";
29
+ import { brand, isQuiet } from "../utils/palette.ts";
30
+ import { isAllowDomainMismatch } from "../utils/runtime-flags.ts";
31
+
32
+ function buildTypeRequirements(): Record<string, string> {
33
+ const out: Record<string, string> = {};
34
+ for (const def of getRegistry().enabled()) {
35
+ out[def.name] = `${def.name} records require: ${def.required.join(", ")}`;
36
+ }
37
+ return out;
38
+ }
39
+
40
+ // snake_case field name → camelCase Commander option key (e.g. "test_results" → "testResults").
41
+ function fieldToOptionKey(field: string): string {
42
+ return field.replace(/_(.)/g, (_, c: string) => c.toUpperCase());
43
+ }
44
+
45
+ // Returns the positional content fallback for built-in types that historically
46
+ // accept it. Returns null for custom types and built-ins without fallback.
47
+ function positionalFallbackField(def: TypeDefinition): string | null {
48
+ if (def.kind !== "builtin") return null;
49
+ if (def.name === "convention") return "content";
50
+ if (def.name === "pattern" || def.name === "reference" || def.name === "guide") {
51
+ return "description";
52
+ }
53
+ return null;
54
+ }
55
+
56
+ interface BaseRecordParts {
57
+ classification: Classification;
58
+ recorded_at: string;
59
+ evidence?: Evidence;
60
+ tags?: string[];
61
+ relates_to?: string[];
62
+ supersedes?: string[];
63
+ outcomes?: Outcome[];
64
+ dir_anchors?: string[];
65
+ }
66
+
67
+ function buildRecordFromOptions(
68
+ def: TypeDefinition,
69
+ content: string | undefined,
70
+ options: Record<string, unknown>,
71
+ base: BaseRecordParts,
72
+ ): { record: ExpertiseRecord | null; missing: Array<{ flag: string; placeholder: string }> } {
73
+ const fallbackField = positionalFallbackField(def);
74
+ const r: Record<string, unknown> = {
75
+ type: def.name,
76
+ classification: base.classification,
77
+ recorded_at: base.recorded_at,
78
+ };
79
+ if (base.evidence) r.evidence = base.evidence;
80
+ if (base.tags && base.tags.length > 0) r.tags = base.tags;
81
+ if (base.relates_to && base.relates_to.length > 0) r.relates_to = base.relates_to;
82
+ if (base.supersedes && base.supersedes.length > 0) r.supersedes = base.supersedes;
83
+ if (base.outcomes) r.outcomes = base.outcomes;
84
+ if (base.dir_anchors && base.dir_anchors.length > 0) r.dir_anchors = base.dir_anchors;
85
+
86
+ const missing: Array<{ flag: string; placeholder: string }> = [];
87
+
88
+ const collectField = (field: string, isRequired: boolean): void => {
89
+ const optKey = fieldToOptionKey(field);
90
+ let value: unknown = options[optKey];
91
+ if (value === undefined && fallbackField === field && content !== undefined) {
92
+ value = content;
93
+ }
94
+ if (value === undefined || value === "") {
95
+ if (isRequired)
96
+ missing.push({ flag: `--${field.replace(/_/g, "-")}`, placeholder: `<${field}>` });
97
+ return;
98
+ }
99
+ // extractsFiles: split comma-separated string from --files flag
100
+ if (def.extractsFiles && field === def.filesField && typeof value === "string") {
101
+ r[field] = value
102
+ .split(",")
103
+ .map((s) => s.trim())
104
+ .filter(Boolean);
105
+ return;
106
+ }
107
+ r[field] = value;
108
+ };
109
+
110
+ for (const field of def.required) collectField(field, true);
111
+ for (const field of def.optional) collectField(field, false);
112
+
113
+ // Auto-populate files from git context for extractsFiles types when not provided.
114
+ if (def.extractsFiles && r[def.filesField] === undefined) {
115
+ const ctx = getContextFiles();
116
+ if (ctx.length > 0) r[def.filesField] = ctx;
117
+ }
118
+
119
+ if (missing.length > 0) return { record: null, missing };
120
+ return { record: r as unknown as ExpertiseRecord, missing: [] };
121
+ }
122
+
123
+ function isNamedType(def: TypeDefinition): boolean {
124
+ // "Named" types upsert on duplicate; "anonymous" types skip on duplicate.
125
+ // Built-ins: convention/failure are anonymous; pattern/decision/reference/guide are named.
126
+ if (def.kind === "builtin") {
127
+ return def.name !== "convention" && def.name !== "failure";
128
+ }
129
+ // Custom types: treat as named iff dedup_key isn't content_hash (since
130
+ // content_hash dedup behaves like an anonymous exact-match check).
131
+ return def.dedupKey !== "content_hash";
132
+ }
133
+
134
+ function buildRetryCommand(
135
+ domain: string,
136
+ content: string | undefined,
137
+ options: Record<string, unknown>,
138
+ missingFlags: Array<{ flag: string; placeholder: string }>,
139
+ ): string {
140
+ const parts = ["lm record", domain];
141
+ if (content) parts.push(JSON.stringify(content));
142
+ if (options.type) parts.push(`--type ${options.type as string}`);
143
+ if (options.classification && options.classification !== "tactical") {
144
+ parts.push(`--classification ${options.classification as string}`);
145
+ }
146
+ if (options.name) parts.push(`--name ${JSON.stringify(options.name as string)}`);
147
+ if (options.content) parts.push(`--content ${JSON.stringify(options.content as string)}`);
148
+ if (options.description)
149
+ parts.push(`--description ${JSON.stringify(options.description as string)}`);
150
+ if (options.resolution)
151
+ parts.push(`--resolution ${JSON.stringify(options.resolution as string)}`);
152
+ if (options.title) parts.push(`--title ${JSON.stringify(options.title as string)}`);
153
+ if (options.rationale) parts.push(`--rationale ${JSON.stringify(options.rationale as string)}`);
154
+ if (options.files) parts.push(`--files ${JSON.stringify(options.files as string)}`);
155
+ if (Array.isArray(options.dirAnchor)) {
156
+ for (const dir of options.dirAnchor as string[]) {
157
+ parts.push(`--dir-anchor ${JSON.stringify(dir)}`);
158
+ }
159
+ }
160
+ if (options.tags) parts.push(`--tags ${JSON.stringify(options.tags as string)}`);
161
+ if (options.evidenceCommit) parts.push(`--evidence-commit ${options.evidenceCommit as string}`);
162
+ if (options.evidenceIssue)
163
+ parts.push(`--evidence-issue ${JSON.stringify(options.evidenceIssue as string)}`);
164
+ if (options.evidenceFile)
165
+ parts.push(`--evidence-file ${JSON.stringify(options.evidenceFile as string)}`);
166
+ if (options.evidenceBead)
167
+ parts.push(`--evidence-bead ${JSON.stringify(options.evidenceBead as string)}`);
168
+ if (options.evidenceSprout)
169
+ parts.push(`--evidence-sprout ${JSON.stringify(options.evidenceSprout as string)}`);
170
+ if (options.evidenceGh)
171
+ parts.push(`--evidence-gh ${JSON.stringify(options.evidenceGh as string)}`);
172
+ if (options.evidenceLinear)
173
+ parts.push(`--evidence-linear ${JSON.stringify(options.evidenceLinear as string)}`);
174
+ for (const { flag, placeholder } of missingFlags) {
175
+ parts.push(`${flag} ${JSON.stringify(placeholder)}`);
176
+ }
177
+ return parts.join(" ");
178
+ }
179
+
180
+ /**
181
+ * Process records from stdin (JSON single object or array)
182
+ * Validates, dedups, and appends with file locking
183
+ */
184
+ export async function processStdinRecords(
185
+ domain: string,
186
+ _jsonMode: boolean,
187
+ force: boolean,
188
+ dryRun: boolean,
189
+ stdinData?: string,
190
+ cwd?: string,
191
+ ): Promise<{
192
+ created: number;
193
+ updated: number;
194
+ skipped: number;
195
+ errors: string[];
196
+ warnings: string[];
197
+ }> {
198
+ const config = await readConfig(cwd);
199
+
200
+ if (!(domain in config.domains)) {
201
+ await addDomain(domain, cwd);
202
+ }
203
+
204
+ // Read stdin (or use provided data for testing)
205
+ const inputData = stdinData ?? readFileSync(0, "utf-8");
206
+ let inputRecords: unknown[];
207
+
208
+ try {
209
+ const parsed = JSON.parse(inputData);
210
+ inputRecords = Array.isArray(parsed) ? parsed : [parsed];
211
+ } catch (err) {
212
+ throw new Error(
213
+ `Failed to parse JSON from stdin: ${err instanceof Error ? err.message : String(err)}`,
214
+ );
215
+ }
216
+
217
+ // Validate each record against schema (cached on registry)
218
+ const validate = getRegistry().validator;
219
+ const allowedTypes = getAllowedTypes(config, domain);
220
+ const requiredFields = getRequiredFields(config, domain);
221
+
222
+ const errors: string[] = [];
223
+ const validRecords: ExpertiseRecord[] = [];
224
+
225
+ for (let i = 0; i < inputRecords.length; i++) {
226
+ const record = inputRecords[i];
227
+
228
+ // Ensure recorded_at and classification are set
229
+ if (typeof record === "object" && record !== null) {
230
+ if (!("recorded_at" in record)) {
231
+ (record as Record<string, unknown>).recorded_at = new Date().toISOString();
232
+ }
233
+ if (!("classification" in record)) {
234
+ (record as Record<string, unknown>).classification = "tactical";
235
+ }
236
+ }
237
+
238
+ if (!validate(record)) {
239
+ const validationErrors = (validate.errors ?? [])
240
+ .map((err) => `${err.instancePath} ${err.message}`)
241
+ .join("; ");
242
+ const recordType =
243
+ typeof record === "object" && record !== null
244
+ ? (record as Record<string, unknown>).type
245
+ : undefined;
246
+ const requirements = buildTypeRequirements();
247
+ const typeHint =
248
+ typeof recordType === "string" && requirements[recordType]
249
+ ? `. Hint: ${requirements[recordType]}`
250
+ : "";
251
+ const rejectedRequired = findRejectedRequiredFields(validate.errors, requiredFields);
252
+ const domainHint =
253
+ rejectedRequired.length > 0
254
+ ? `. Domain "${domain}" requires field(s) ${rejectedRequired
255
+ .map((f) => `"${f}"`)
256
+ .join(
257
+ ", ",
258
+ )}, but type "${typeof recordType === "string" ? recordType : "?"}" does not declare them. Declare a custom_type that holds these fields, or remove them from required_fields in loam.config.yaml.`
259
+ : "";
260
+ errors.push(`Record ${i}: ${validationErrors}${typeHint}${domainHint}`);
261
+ continue;
262
+ }
263
+
264
+ const allowDomainMismatch = isAllowDomainMismatch();
265
+ if (allowedTypes && !allowDomainMismatch) {
266
+ const recordType = (record as Record<string, unknown>).type;
267
+ if (typeof recordType === "string" && !allowedTypes.includes(recordType)) {
268
+ errors.push(
269
+ `Record ${i}: type "${recordType}" is not allowed in domain "${domain}". Allowed types: ${allowedTypes.join(", ")}.`,
270
+ );
271
+ continue;
272
+ }
273
+ }
274
+
275
+ if (requiredFields && !allowDomainMismatch) {
276
+ const missing = findMissingDomainFields(record as Record<string, unknown>, requiredFields);
277
+ if (missing.length > 0) {
278
+ errors.push(
279
+ `Record ${i}: domain "${domain}" requires field(s) ${missing.map((f) => `"${f}"`).join(", ")}.`,
280
+ );
281
+ continue;
282
+ }
283
+ }
284
+
285
+ validRecords.push(record as ExpertiseRecord);
286
+ }
287
+
288
+ // Emit one warning per disabled type seen across the batch, not per record.
289
+ const warnings: string[] = [];
290
+ const seenDisabledTypes = new Set<string>();
291
+ for (const r of validRecords) {
292
+ if (getRegistry().isDisabled(r.type) && !seenDisabledTypes.has(r.type)) {
293
+ seenDisabledTypes.add(r.type);
294
+ const msg = `type "${r.type}" is disabled (declared in disabled_types). Records will still be written; consider migrating off this type.`;
295
+ warnings.push(msg);
296
+ if (!isQuiet()) {
297
+ console.error(chalk.yellow(`Warning: ${msg}`));
298
+ }
299
+ }
300
+ }
301
+
302
+ if (validRecords.length === 0) {
303
+ return { created: 0, updated: 0, skipped: 0, errors, warnings };
304
+ }
305
+
306
+ // Fire pre-record per record before locking. A blocked hook drops just that
307
+ // record (added to errors); the rest of the batch still writes. Skipped in
308
+ // dry-run since hooks shouldn't fire when previewing.
309
+ const recordsToWrite: ExpertiseRecord[] = [];
310
+ if (dryRun) {
311
+ recordsToWrite.push(...validRecords);
312
+ } else {
313
+ for (let i = 0; i < validRecords.length; i++) {
314
+ const r = validRecords[i];
315
+ if (!r) continue;
316
+ const preRes = await runHooks<{ domain: string; record: ExpertiseRecord }>(
317
+ "pre-record",
318
+ { domain, record: r },
319
+ { cwd },
320
+ );
321
+ warnings.push(...preRes.warnings);
322
+ if (preRes.blocked) {
323
+ errors.push(`Record ${i}: ${preRes.blockReason ?? "pre-record hook blocked the write"}`);
324
+ continue;
325
+ }
326
+ let final = r;
327
+ if (preRes.ranAny && preRes.payload?.record) {
328
+ const mutated = preRes.payload.record;
329
+ if (mutated !== r) {
330
+ if (!validate(mutated)) {
331
+ const errs = (validate.errors ?? [])
332
+ .map((e) => `${e.instancePath} ${e.message}`)
333
+ .join("; ");
334
+ errors.push(`Record ${i}: pre-record hook produced an invalid record: ${errs}`);
335
+ continue;
336
+ }
337
+ final = mutated;
338
+ }
339
+ }
340
+ recordsToWrite.push(final);
341
+ }
342
+ }
343
+
344
+ if (recordsToWrite.length === 0) {
345
+ return { created: 0, updated: 0, skipped: 0, errors, warnings };
346
+ }
347
+
348
+ // Process valid records with file locking (skip write in dry-run mode)
349
+ const filePath = getExpertisePath(domain, cwd);
350
+ let created = 0;
351
+ let updated = 0;
352
+ let skipped = 0;
353
+
354
+ const registry = getRegistry();
355
+ const isNamedRecord = (record: ExpertiseRecord): boolean => {
356
+ const def = registry.get(record.type);
357
+ return def ? isNamedType(def) : false;
358
+ };
359
+
360
+ // Track records that were actually written so post-record can fire on each
361
+ // after the lock releases.
362
+ const writtenForPostHook: Array<{
363
+ domain: string;
364
+ record: ExpertiseRecord;
365
+ action: "created" | "updated";
366
+ }> = [];
367
+
368
+ if (dryRun) {
369
+ // Dry-run: check for duplicates without writing
370
+ const existing = await readExpertiseFile(filePath);
371
+ const currentRecords = [...existing];
372
+
373
+ for (const record of recordsToWrite) {
374
+ const dup = findDuplicate(currentRecords, record);
375
+
376
+ if (dup && !force) {
377
+ if (isNamedRecord(record)) {
378
+ updated++;
379
+ } else {
380
+ skipped++;
381
+ }
382
+ } else {
383
+ created++;
384
+ }
385
+ }
386
+ } else {
387
+ // Normal mode: write with file locking
388
+ await withFileLock(filePath, async () => {
389
+ const existing = await readExpertiseFile(filePath);
390
+ const currentRecords = [...existing];
391
+
392
+ for (const record of recordsToWrite) {
393
+ const dup = findDuplicate(currentRecords, record);
394
+
395
+ if (dup && !force) {
396
+ if (isNamedRecord(record)) {
397
+ // Upsert: replace in place, merging outcomes from existing
398
+ const existingRecord = currentRecords[dup.index];
399
+ if (!existingRecord) continue;
400
+ const mergedOutcomes = [...(existingRecord.outcomes ?? []), ...(record.outcomes ?? [])];
401
+ const upsertRecord =
402
+ mergedOutcomes.length > 0 ? { ...record, outcomes: mergedOutcomes } : record;
403
+ currentRecords[dup.index] = upsertRecord;
404
+ writtenForPostHook.push({ domain, record: upsertRecord, action: "updated" });
405
+ updated++;
406
+ } else {
407
+ // Exact match: skip
408
+ skipped++;
409
+ }
410
+ } else {
411
+ // New record: append
412
+ currentRecords.push(record);
413
+ writtenForPostHook.push({ domain, record, action: "created" });
414
+ created++;
415
+ }
416
+ }
417
+
418
+ // Write all changes at once
419
+ if (created > 0 || updated > 0) {
420
+ await writeExpertiseFile(filePath, currentRecords);
421
+ }
422
+ });
423
+ }
424
+
425
+ // Fire post-record outside the lock for each actual write.
426
+ for (const written of writtenForPostHook) {
427
+ const postRes = await runHooks("post-record", written, { cwd });
428
+ warnings.push(...postRes.warnings);
429
+ }
430
+
431
+ return { created, updated, skipped, errors, warnings };
432
+ }
433
+
434
+ export function registerRecordCommand(program: Command): void {
435
+ const registry = getRegistry();
436
+ const typeChoices = registry.names();
437
+
438
+ const cmd = program
439
+ .command("record")
440
+ .argument("<domain>", "expertise domain")
441
+ .argument("[content]", "record content")
442
+ .description("Record an expertise record")
443
+ .addOption(new Option("--type <type>", "record type").choices(typeChoices))
444
+ .addOption(
445
+ new Option("--classification <classification>", "classification level")
446
+ .choices(["foundational", "tactical", "observational"])
447
+ .default("tactical"),
448
+ )
449
+ .option("--name <name>", "name of the convention or pattern")
450
+ .option(
451
+ "--content <content>",
452
+ "content for convention records (explicit flag wins over positional [content])",
453
+ )
454
+ .option("--description <description>", "description of the record")
455
+ .option("--resolution <resolution>", "resolution for failure records")
456
+ .option("--title <title>", "title for decision records")
457
+ .option("--rationale <rationale>", "rationale for decision records")
458
+ .option("--files <files>", "related files (comma-separated)")
459
+ .option(
460
+ "--dir-anchor <path>",
461
+ "repo-relative directory the record applies to (repeatable; auto-populated from changed files when omitted)",
462
+ (value: string, prev: string[] = []) => [...prev, value],
463
+ [] as string[],
464
+ )
465
+ .option("--tags <tags>", "comma-separated tags")
466
+ .option(
467
+ "--evidence-commit <commit>",
468
+ "evidence: commit hash (auto-populated from git if omitted)",
469
+ )
470
+ .option("--evidence-issue <issue>", "evidence: issue reference")
471
+ .option("--evidence-file <file>", "evidence: file path")
472
+ .option("--evidence-bead <bead>", "evidence: bead ID")
473
+ .option("--evidence-sprout <id>", "evidence: sprout issue ID")
474
+ .option("--evidence-gh <ref>", "evidence: GitHub issue or PR reference")
475
+ .option("--evidence-linear <ticket>", "evidence: Linear ticket reference")
476
+ .option("--relates-to <ids>", "comma-separated record IDs this relates to")
477
+ .option("--supersedes <ids>", "comma-separated record IDs this supersedes")
478
+ .addOption(
479
+ new Option("--outcome-status <status>", "outcome status").choices([
480
+ "success",
481
+ "failure",
482
+ "partial",
483
+ ]),
484
+ )
485
+ .option("--outcome-duration <ms>", "outcome duration in milliseconds")
486
+ .option("--outcome-test-results <text>", "outcome test results summary")
487
+ .option("--outcome-agent <agent>", "outcome agent name")
488
+ .option("--force", "force recording even if duplicate exists")
489
+ .option("--stdin", "read JSON record(s) from stdin (single object or array)")
490
+ .option("--batch <file>", "read JSON record(s) from file (single object or array)")
491
+ .option("--dry-run", "preview what would be recorded without writing")
492
+ .addHelpText(
493
+ "after",
494
+ `
495
+ Required fields per record type:
496
+ convention --content (or positional [content])
497
+ pattern --name, --description (or [content])
498
+ failure --description, --resolution
499
+ decision --title, --rationale
500
+ reference --name, --description (or [content])
501
+ guide --name, --description (or [content])
502
+
503
+ Batch recording examples:
504
+ lm record cli --batch records.json
505
+ lm record cli --batch records.json --dry-run
506
+ echo '[{"type":"convention","content":"test"}]' > batch.json && lm record cli --batch batch.json
507
+ `,
508
+ );
509
+
510
+ // Dynamically register --<field> flags for custom-type fields not already
511
+ // covered by the built-in flag set. This makes Phase 2 custom_types
512
+ // (declared in loam.config.yaml) feel first-class on the CLI.
513
+ const declaredOptionNames = new Set(
514
+ cmd.options.map((o) => o.name()).concat(["files"]), // --files declared above
515
+ );
516
+ for (const def of registry.enabled()) {
517
+ if (def.kind === "builtin") continue;
518
+ for (const field of [...def.required, ...def.optional]) {
519
+ const flagName = field.replace(/_/g, "-");
520
+ if (declaredOptionNames.has(flagName)) continue;
521
+ declaredOptionNames.add(flagName);
522
+ cmd.option(`--${flagName} <${field}>`, `${def.name} field: ${field}`);
523
+ }
524
+ }
525
+
526
+ cmd.action(
527
+ async (domain: string, content: string | undefined, options: Record<string, unknown>) => {
528
+ const jsonMode = program.opts().json === true;
529
+
530
+ // Handle --batch mode
531
+ if (options.batch) {
532
+ const batchFile = options.batch as string;
533
+ const dryRun = options.dryRun === true;
534
+
535
+ if (!existsSync(batchFile)) {
536
+ if (jsonMode) {
537
+ outputJsonError("record", `Batch file not found: ${batchFile}`);
538
+ } else {
539
+ console.error(chalk.red(`Error: batch file not found: ${batchFile}`));
540
+ }
541
+ process.exitCode = 1;
542
+ return;
543
+ }
544
+
545
+ try {
546
+ const fileContent = readFileSync(batchFile, "utf-8");
547
+ const result = await processStdinRecords(
548
+ domain,
549
+ jsonMode,
550
+ options.force === true,
551
+ dryRun,
552
+ fileContent,
553
+ );
554
+
555
+ if (result.errors.length > 0) {
556
+ if (jsonMode) {
557
+ outputJsonError("record", `Validation errors: ${result.errors.join("; ")}`);
558
+ } else {
559
+ console.error(chalk.red("Validation errors:"));
560
+ for (const error of result.errors) {
561
+ console.error(chalk.red(` ${error}`));
562
+ }
563
+ }
564
+ }
565
+
566
+ if (jsonMode) {
567
+ outputJson({
568
+ success: result.errors.length === 0 || result.created + result.updated > 0,
569
+ command: "record",
570
+ action: dryRun ? "dry-run" : "batch",
571
+ domain,
572
+ created: result.created,
573
+ updated: result.updated,
574
+ skipped: result.skipped,
575
+ errors: result.errors,
576
+ warnings: result.warnings,
577
+ });
578
+ } else {
579
+ if (dryRun) {
580
+ const total = result.created + result.updated;
581
+ if (total > 0 || result.skipped > 0) {
582
+ if (!isQuiet())
583
+ console.log(
584
+ `${brand("✓")} ${brand(`Dry-run complete. Would process ${total} record(s) in ${domain}:`)}`,
585
+ );
586
+ if (result.created > 0) {
587
+ if (!isQuiet()) console.log(chalk.dim(` Create: ${result.created}`));
588
+ }
589
+ if (result.updated > 0) {
590
+ if (!isQuiet()) console.log(chalk.dim(` Update: ${result.updated}`));
591
+ }
592
+ if (result.skipped > 0) {
593
+ if (!isQuiet()) console.log(chalk.dim(` Skip: ${result.skipped}`));
594
+ }
595
+ if (!isQuiet()) console.log(chalk.dim(" Run without --dry-run to apply changes."));
596
+ } else {
597
+ if (!isQuiet()) console.log(chalk.yellow("No records would be processed."));
598
+ }
599
+ } else {
600
+ if (result.created > 0) {
601
+ if (!isQuiet())
602
+ console.log(
603
+ `${brand("✓")} ${brand(`Created ${result.created} record(s) in ${domain}`)}`,
604
+ );
605
+ }
606
+ if (result.updated > 0) {
607
+ if (!isQuiet())
608
+ console.log(
609
+ `${brand("✓")} ${brand(`Updated ${result.updated} record(s) in ${domain}`)}`,
610
+ );
611
+ }
612
+ if (result.skipped > 0) {
613
+ if (!isQuiet())
614
+ console.log(chalk.yellow(`Skipped ${result.skipped} duplicate(s) in ${domain}`));
615
+ }
616
+ }
617
+ }
618
+
619
+ if (result.errors.length > 0 && result.created + result.updated === 0) {
620
+ process.exitCode = 1;
621
+ }
622
+ } catch (err) {
623
+ if (jsonMode) {
624
+ outputJsonError("record", err instanceof Error ? err.message : String(err));
625
+ } else {
626
+ console.error(chalk.red(`Error: ${err instanceof Error ? err.message : String(err)}`));
627
+ }
628
+ process.exitCode = 1;
629
+ }
630
+ return;
631
+ }
632
+
633
+ // Handle --stdin mode
634
+ if (options.stdin === true) {
635
+ const dryRun = options.dryRun === true;
636
+
637
+ try {
638
+ const result = await processStdinRecords(
639
+ domain,
640
+ jsonMode,
641
+ options.force === true,
642
+ dryRun,
643
+ );
644
+
645
+ if (result.errors.length > 0) {
646
+ if (jsonMode) {
647
+ outputJsonError("record", `Validation errors: ${result.errors.join("; ")}`);
648
+ } else {
649
+ console.error(chalk.red("Validation errors:"));
650
+ for (const error of result.errors) {
651
+ console.error(chalk.red(` ${error}`));
652
+ }
653
+ }
654
+ }
655
+
656
+ if (jsonMode) {
657
+ outputJson({
658
+ success: result.errors.length === 0 || result.created + result.updated > 0,
659
+ command: "record",
660
+ action: dryRun ? "dry-run" : "stdin",
661
+ domain,
662
+ created: result.created,
663
+ updated: result.updated,
664
+ skipped: result.skipped,
665
+ errors: result.errors,
666
+ warnings: result.warnings,
667
+ });
668
+ } else {
669
+ if (dryRun) {
670
+ const total = result.created + result.updated;
671
+ if (total > 0 || result.skipped > 0) {
672
+ if (!isQuiet())
673
+ console.log(
674
+ `${brand("✓")} ${brand(`Dry-run complete. Would process ${total} record(s) in ${domain}:`)}`,
675
+ );
676
+ if (result.created > 0) {
677
+ if (!isQuiet()) console.log(chalk.dim(` Create: ${result.created}`));
678
+ }
679
+ if (result.updated > 0) {
680
+ if (!isQuiet()) console.log(chalk.dim(` Update: ${result.updated}`));
681
+ }
682
+ if (result.skipped > 0) {
683
+ if (!isQuiet()) console.log(chalk.dim(` Skip: ${result.skipped}`));
684
+ }
685
+ if (!isQuiet()) console.log(chalk.dim(" Run without --dry-run to apply changes."));
686
+ } else {
687
+ if (!isQuiet()) console.log(chalk.yellow("No records would be processed."));
688
+ }
689
+ } else {
690
+ if (result.created > 0) {
691
+ if (!isQuiet())
692
+ console.log(
693
+ `${brand("✓")} ${brand(`Created ${result.created} record(s) in ${domain}`)}`,
694
+ );
695
+ }
696
+ if (result.updated > 0) {
697
+ if (!isQuiet())
698
+ console.log(
699
+ `${brand("✓")} ${brand(`Updated ${result.updated} record(s) in ${domain}`)}`,
700
+ );
701
+ }
702
+ if (result.skipped > 0) {
703
+ if (!isQuiet())
704
+ console.log(chalk.yellow(`Skipped ${result.skipped} duplicate(s) in ${domain}`));
705
+ }
706
+ }
707
+ }
708
+
709
+ if (result.errors.length > 0 && result.created + result.updated === 0) {
710
+ process.exitCode = 1;
711
+ }
712
+ } catch (err) {
713
+ if (jsonMode) {
714
+ outputJsonError("record", err instanceof Error ? err.message : String(err));
715
+ } else {
716
+ console.error(chalk.red(`Error: ${err instanceof Error ? err.message : String(err)}`));
717
+ }
718
+ process.exitCode = 1;
719
+ }
720
+ return;
721
+ }
722
+ const config = await readConfig();
723
+
724
+ if (!(domain in config.domains)) {
725
+ await addDomain(domain);
726
+ if (!isQuiet()) {
727
+ console.log(`${brand("✓")} ${brand(`Auto-created domain "${domain}"`)}`);
728
+ }
729
+ }
730
+
731
+ // Validate --type is provided for non-stdin mode
732
+ if (!options.type) {
733
+ const choicesMsg = `--type is required (${typeChoices.join(", ")})`;
734
+ if (jsonMode) {
735
+ outputJsonError("record", choicesMsg);
736
+ } else {
737
+ console.error(chalk.red(`Error: ${choicesMsg}`));
738
+ }
739
+ process.exitCode = 1;
740
+ return;
741
+ }
742
+
743
+ const recordType = options.type as string;
744
+ const classification = (options.classification as Classification) ?? "tactical";
745
+ const recordedAt = new Date().toISOString();
746
+
747
+ // Build evidence if any evidence option is provided
748
+ let evidence: Evidence | undefined;
749
+ if (
750
+ options.evidenceCommit ||
751
+ options.evidenceIssue ||
752
+ options.evidenceFile ||
753
+ options.evidenceBead ||
754
+ options.evidenceSprout ||
755
+ options.evidenceGh ||
756
+ options.evidenceLinear
757
+ ) {
758
+ evidence = {};
759
+ if (options.evidenceCommit) evidence.commit = options.evidenceCommit as string;
760
+ if (options.evidenceIssue) evidence.issue = options.evidenceIssue as string;
761
+ if (options.evidenceFile) evidence.file = options.evidenceFile as string;
762
+ if (options.evidenceBead) evidence.bead = options.evidenceBead as string;
763
+ if (options.evidenceSprout) evidence.sprout = options.evidenceSprout as string;
764
+ if (options.evidenceGh) evidence.gh = options.evidenceGh as string;
765
+ if (options.evidenceLinear) evidence.linear = options.evidenceLinear as string;
766
+ }
767
+
768
+ // Auto-populate evidence.commit from git HEAD if not explicitly provided
769
+ if (!options.evidenceCommit) {
770
+ const autoCommit = getCurrentCommit();
771
+ if (autoCommit) {
772
+ evidence = evidence ?? {};
773
+ evidence.commit = autoCommit;
774
+ }
775
+ }
776
+
777
+ const tags =
778
+ typeof options.tags === "string"
779
+ ? options.tags
780
+ .split(",")
781
+ .map((t) => (t as string).trim())
782
+ .filter(Boolean)
783
+ : undefined;
784
+
785
+ const relatesTo =
786
+ typeof options.relatesTo === "string"
787
+ ? options.relatesTo
788
+ .split(",")
789
+ .map((id: string) => id.trim())
790
+ .filter(Boolean)
791
+ : undefined;
792
+
793
+ const supersedes =
794
+ typeof options.supersedes === "string"
795
+ ? options.supersedes
796
+ .split(",")
797
+ .map((id: string) => id.trim())
798
+ .filter(Boolean)
799
+ : undefined;
800
+
801
+ let outcomes: Outcome[] | undefined;
802
+ if (options.outcomeStatus) {
803
+ const o: Outcome = {
804
+ status: options.outcomeStatus as "success" | "failure" | "partial",
805
+ };
806
+ if (options.outcomeDuration !== undefined) {
807
+ const parsed = parseStrictNonNegativeNumber(options.outcomeDuration as string);
808
+ if (parsed === null) {
809
+ const msg = `--outcome-duration must be a non-negative number (got "${options.outcomeDuration as string}").`;
810
+ if (jsonMode) {
811
+ outputJsonError("record", msg);
812
+ } else {
813
+ console.error(chalk.red(`Error: ${msg}`));
814
+ }
815
+ process.exitCode = 1;
816
+ return;
817
+ }
818
+ o.duration = parsed;
819
+ }
820
+ if (options.outcomeTestResults) {
821
+ o.test_results = options.outcomeTestResults as string;
822
+ }
823
+ if (options.outcomeAgent) {
824
+ o.agent = options.outcomeAgent as string;
825
+ }
826
+ outcomes = [o];
827
+ }
828
+
829
+ // dir_anchors: explicit --dir-anchor wins; otherwise infer from changed
830
+ // files (3+ files sharing a parent directory). Normalized to drop
831
+ // trailing slashes and a leading "./"; "." / repo root collapses to
832
+ // nothing stored. Absolute paths and ".." traversal are rejected with
833
+ // a formatted error before validation builds the record.
834
+ let explicitDirAnchors: string[] = [];
835
+ if (Array.isArray(options.dirAnchor)) {
836
+ try {
837
+ for (const raw of options.dirAnchor as string[]) {
838
+ assertWritableDirAnchor(raw);
839
+ }
840
+ } catch (err) {
841
+ const msg = err instanceof Error ? err.message : String(err);
842
+ if (jsonMode) {
843
+ outputJsonError("record", msg);
844
+ } else {
845
+ console.error(chalk.red(`Error: ${msg}`));
846
+ }
847
+ process.exitCode = 1;
848
+ return;
849
+ }
850
+ explicitDirAnchors = (options.dirAnchor as string[])
851
+ .map((p) => normalizeDirAnchor(p))
852
+ .filter((p) => p.length > 0);
853
+ }
854
+ let dirAnchors: string[] | undefined;
855
+ if (explicitDirAnchors.length > 0) {
856
+ dirAnchors = [...new Set(explicitDirAnchors)].sort();
857
+ } else if (options.files === undefined) {
858
+ const inferred = inferDirAnchors(getContextFiles());
859
+ if (inferred.length > 0) dirAnchors = inferred;
860
+ }
861
+
862
+ const def = getRegistry().get(recordType);
863
+ if (!def) {
864
+ const msg = `Unknown record type "${recordType}". Available: ${typeChoices.join(", ")}.`;
865
+ if (jsonMode) {
866
+ outputJsonError("record", msg);
867
+ } else {
868
+ console.error(chalk.red(`Error: ${msg}`));
869
+ }
870
+ process.exitCode = 1;
871
+ return;
872
+ }
873
+
874
+ // Per-domain allowed_types gate. Empty/missing means all registered
875
+ // types allowed (back-compat). disabled_types is independent — a type
876
+ // in allowed_types still writes (with deprecation warning) even when
877
+ // also disabled; cross-project peers in shared domains shouldn't
878
+ // hard-fail on an upstream config change. --allow-domain-mismatch
879
+ // skips this gate (worktree/CI lag escape hatch).
880
+ const allowDomainMismatch = isAllowDomainMismatch();
881
+ const allowedTypes = getAllowedTypes(config, domain);
882
+ if (allowedTypes && !allowDomainMismatch && !allowedTypes.includes(recordType)) {
883
+ const allowedList = allowedTypes.join(", ");
884
+ const msg = `type "${recordType}" is not allowed in domain "${domain}". Allowed types: ${allowedList}.`;
885
+ if (jsonMode) {
886
+ outputJsonError("record", msg);
887
+ } else {
888
+ console.error(chalk.red(`Error: ${msg}`));
889
+ const suggestedType = allowedTypes[0] ?? recordType;
890
+ const retryCmd = buildRetryCommand(
891
+ domain,
892
+ content,
893
+ { ...options, type: suggestedType },
894
+ [],
895
+ );
896
+ console.error(chalk.dim(` Retry: ${retryCmd}`));
897
+ }
898
+ process.exitCode = 1;
899
+ return;
900
+ }
901
+
902
+ // Phase 3: disabled types still write but emit a deprecation warning.
903
+ const disabledWarning = getRegistry().isDisabled(recordType)
904
+ ? `type "${recordType}" is disabled (declared in disabled_types). Records will still be written; consider migrating off this type.`
905
+ : null;
906
+ if (disabledWarning && !isQuiet() && !jsonMode) {
907
+ console.error(chalk.yellow(`Warning: ${disabledWarning}`));
908
+ }
909
+
910
+ const built = buildRecordFromOptions(def, content, options, {
911
+ classification,
912
+ recorded_at: recordedAt,
913
+ evidence,
914
+ tags,
915
+ relates_to: relatesTo,
916
+ supersedes,
917
+ outcomes,
918
+ dir_anchors: dirAnchors,
919
+ });
920
+
921
+ if (!built.record) {
922
+ const missingFlags = built.missing.map((m) => m.flag);
923
+ const flagList =
924
+ missingFlags.length > 0 ? missingFlags.join(", ") : def.required.join(", ");
925
+ const retryCmd = buildRetryCommand(domain, content, options, built.missing);
926
+ const msg = `${def.name} records are missing required flag(s): ${flagList}. Example: ${retryCmd}`;
927
+ if (jsonMode) {
928
+ outputJsonError("record", msg);
929
+ } else {
930
+ console.error(
931
+ chalk.red(`Error: ${def.name} records are missing required flag(s): ${flagList}.`),
932
+ );
933
+ console.error(chalk.dim(` Retry: ${retryCmd}`));
934
+ }
935
+ process.exitCode = 1;
936
+ return;
937
+ }
938
+
939
+ let record: ExpertiseRecord = built.record;
940
+
941
+ // Validate against JSON schema (cached on registry)
942
+ const validate = getRegistry().validator;
943
+ // Pre-fetch required_fields so we can rewrite the AJV error when a
944
+ // rejected additionalProperty is one the domain demands — otherwise
945
+ // users see a confusing oneOf/additionalProperties soup with no hint
946
+ // that the real cause is the closed schema vs. domain rule mismatch.
947
+ const requiredFields = getRequiredFields(config, domain);
948
+ if (!validate(record)) {
949
+ const errors = (validate.errors ?? []).map((err) => `${err.instancePath} ${err.message}`);
950
+ const requirements = buildTypeRequirements();
951
+ const typeHint = requirements[recordType] ? `. Hint: ${requirements[recordType]}` : "";
952
+ const rejectedRequired = findRejectedRequiredFields(validate.errors, requiredFields);
953
+ const domainHint =
954
+ rejectedRequired.length > 0
955
+ ? `Domain "${domain}" requires field(s) ${rejectedRequired
956
+ .map((f) => `"${f}"`)
957
+ .join(
958
+ ", ",
959
+ )}, but type "${recordType}" does not declare them. Declare a custom_type that holds these fields, or remove them from required_fields in loam.config.yaml.`
960
+ : "";
961
+ if (jsonMode) {
962
+ const suffix = domainHint ? `. ${domainHint}` : "";
963
+ outputJsonError(
964
+ "record",
965
+ `Schema validation failed: ${errors.join("; ")}${typeHint}${suffix}`,
966
+ );
967
+ } else {
968
+ console.error(chalk.red("Error: record failed schema validation:"));
969
+ for (const err of validate.errors ?? []) {
970
+ console.error(chalk.red(` ${err.instancePath} ${err.message}`));
971
+ }
972
+ if (requirements[recordType]) {
973
+ console.error(chalk.yellow(`Hint: ${requirements[recordType]}`));
974
+ }
975
+ if (domainHint) {
976
+ console.error(chalk.yellow(`Hint: ${domainHint}`));
977
+ }
978
+ }
979
+ process.exitCode = 1;
980
+ return;
981
+ }
982
+
983
+ // Per-domain required_fields gate. Stacks on top of per-type required
984
+ // fields enforced by the schema above. Lists all missing fields in a
985
+ // single error so users fix the config in one pass.
986
+ // --allow-domain-mismatch skips this gate (worktree/CI lag escape hatch).
987
+ if (requiredFields && !allowDomainMismatch) {
988
+ const missingFields = findMissingDomainFields(
989
+ record as unknown as Record<string, unknown>,
990
+ requiredFields,
991
+ );
992
+ if (missingFields.length > 0) {
993
+ const fieldList = missingFields.map((f) => `"${f}"`).join(", ");
994
+ const msg = `domain "${domain}" requires field(s) ${fieldList}.`;
995
+ if (jsonMode) {
996
+ outputJsonError("record", msg);
997
+ } else {
998
+ console.error(chalk.red(`Error: ${msg}`));
999
+ const retryCmd = buildRetryCommand(
1000
+ domain,
1001
+ content,
1002
+ options,
1003
+ missingFields.map((f) => ({
1004
+ flag: `--${f.replace(/_/g, "-")}`,
1005
+ placeholder: `<${f}>`,
1006
+ })),
1007
+ );
1008
+ console.error(chalk.dim(` Retry: ${retryCmd}`));
1009
+ }
1010
+ process.exitCode = 1;
1011
+ return;
1012
+ }
1013
+ }
1014
+
1015
+ const filePath = getExpertisePath(domain);
1016
+ const dryRun = options.dryRun === true;
1017
+
1018
+ // Fire pre-record hook outside the file lock. Skipped in dry-run since
1019
+ // dry-run shouldn't trigger external side effects (slack, scanners, etc).
1020
+ let preRecordWarnings: string[] = [];
1021
+ if (!dryRun) {
1022
+ const preResult = await runHooks<{ domain: string; record: ExpertiseRecord }>(
1023
+ "pre-record",
1024
+ { domain, record },
1025
+ );
1026
+ if (preResult.blocked) {
1027
+ const reason = preResult.blockReason ?? "pre-record hook blocked the write";
1028
+ if (jsonMode) {
1029
+ outputJsonError("record", reason);
1030
+ } else {
1031
+ console.error(chalk.red(`Error: ${reason}`));
1032
+ }
1033
+ process.exitCode = 1;
1034
+ return;
1035
+ }
1036
+ preRecordWarnings = preResult.warnings;
1037
+ if (preResult.ranAny && preResult.payload?.record) {
1038
+ const mutated = preResult.payload.record;
1039
+ if (mutated !== record) {
1040
+ if (!validate(mutated)) {
1041
+ const errs = (validate.errors ?? [])
1042
+ .map((e) => `${e.instancePath} ${e.message}`)
1043
+ .join("; ");
1044
+ const msg = `pre-record hook produced an invalid record: ${errs}`;
1045
+ if (jsonMode) {
1046
+ outputJsonError("record", msg);
1047
+ } else {
1048
+ console.error(chalk.red(`Error: ${msg}`));
1049
+ }
1050
+ process.exitCode = 1;
1051
+ return;
1052
+ }
1053
+ record = mutated;
1054
+ }
1055
+ }
1056
+ }
1057
+
1058
+ if (dryRun) {
1059
+ // Dry-run: check for duplicates without writing
1060
+ const existing = await readExpertiseFile(filePath);
1061
+ const dup = findDuplicate(existing, record);
1062
+
1063
+ let action = "created";
1064
+ if (dup && !options.force) {
1065
+ action = isNamedType(def) ? "updated" : "skipped";
1066
+ }
1067
+
1068
+ if (jsonMode) {
1069
+ outputJson({
1070
+ success: true,
1071
+ command: "record",
1072
+ action: "dry-run",
1073
+ wouldDo: action,
1074
+ domain,
1075
+ type: recordType,
1076
+ record,
1077
+ ...(disabledWarning ? { warnings: [disabledWarning] } : {}),
1078
+ });
1079
+ } else {
1080
+ if (action === "created") {
1081
+ if (!isQuiet())
1082
+ console.log(
1083
+ `${brand("✓")} ${brand(`Dry-run: Would create ${recordType} in ${domain}`)}`,
1084
+ );
1085
+ } else if (action === "updated") {
1086
+ if (!isQuiet())
1087
+ console.log(
1088
+ `${brand("✓")} ${brand(`Dry-run: Would update existing ${recordType} in ${domain}`)}`,
1089
+ );
1090
+ } else {
1091
+ if (!isQuiet())
1092
+ console.log(
1093
+ chalk.yellow(
1094
+ `Dry-run: Duplicate ${recordType} already exists in ${domain}. Would skip.`,
1095
+ ),
1096
+ );
1097
+ }
1098
+ if (!isQuiet()) console.log(chalk.dim(" Run without --dry-run to apply changes."));
1099
+ }
1100
+ } else {
1101
+ // Normal mode: write with file locking, then fire post-record outside
1102
+ // the lock so observation hooks (slack notifications, etc.) can't
1103
+ // stall holders or risk re-entrant deadlock.
1104
+ type WriteOutcome =
1105
+ | { action: "created"; record: ExpertiseRecord }
1106
+ | { action: "updated"; record: ExpertiseRecord; index: number }
1107
+ | { action: "skipped"; index: number };
1108
+
1109
+ const outcome = await withFileLock<WriteOutcome | null>(filePath, async () => {
1110
+ const existing = await readExpertiseFile(filePath);
1111
+ const dup = findDuplicate(existing, record);
1112
+
1113
+ if (dup && !options.force) {
1114
+ if (isNamedType(def)) {
1115
+ const existingRecord = existing[dup.index];
1116
+ if (!existingRecord) return null;
1117
+ const mergedOutcomes = [
1118
+ ...(existingRecord.outcomes ?? []),
1119
+ ...(record.outcomes ?? []),
1120
+ ];
1121
+ const upsertRecord =
1122
+ mergedOutcomes.length > 0 ? { ...record, outcomes: mergedOutcomes } : record;
1123
+ existing[dup.index] = upsertRecord;
1124
+ await writeExpertiseFile(filePath, existing);
1125
+ return { action: "updated", record: upsertRecord, index: dup.index };
1126
+ }
1127
+ return { action: "skipped", index: dup.index };
1128
+ }
1129
+ await appendRecord(filePath, record);
1130
+ return { action: "created", record };
1131
+ });
1132
+
1133
+ if (!outcome) return;
1134
+
1135
+ // Post-record only fires for actual writes (created/updated). Skipped
1136
+ // duplicates are no-ops by design.
1137
+ const postWarnings: string[] = [];
1138
+ if (outcome.action === "created" || outcome.action === "updated") {
1139
+ const postResult = await runHooks("post-record", {
1140
+ domain,
1141
+ record: outcome.record,
1142
+ action: outcome.action,
1143
+ });
1144
+ postWarnings.push(...postResult.warnings);
1145
+ }
1146
+
1147
+ const collectedWarnings = [
1148
+ ...(disabledWarning ? [disabledWarning] : []),
1149
+ ...preRecordWarnings,
1150
+ ...postWarnings,
1151
+ ];
1152
+
1153
+ if (jsonMode) {
1154
+ if (outcome.action === "updated") {
1155
+ outputJson({
1156
+ success: true,
1157
+ command: "record",
1158
+ action: "updated",
1159
+ domain,
1160
+ type: recordType,
1161
+ index: outcome.index + 1,
1162
+ record: outcome.record,
1163
+ ...(collectedWarnings.length > 0 ? { warnings: collectedWarnings } : {}),
1164
+ });
1165
+ } else if (outcome.action === "skipped") {
1166
+ outputJson({
1167
+ success: true,
1168
+ command: "record",
1169
+ action: "skipped",
1170
+ domain,
1171
+ type: recordType,
1172
+ index: outcome.index + 1,
1173
+ ...(collectedWarnings.length > 0 ? { warnings: collectedWarnings } : {}),
1174
+ });
1175
+ } else {
1176
+ outputJson({
1177
+ success: true,
1178
+ command: "record",
1179
+ action: "created",
1180
+ domain,
1181
+ type: recordType,
1182
+ record: outcome.record,
1183
+ ...(collectedWarnings.length > 0 ? { warnings: collectedWarnings } : {}),
1184
+ });
1185
+ }
1186
+ } else {
1187
+ if (outcome.action === "updated") {
1188
+ if (!isQuiet())
1189
+ console.log(
1190
+ `${brand("✓")} ${brand(`Updated existing ${recordType} in ${domain} (record #${outcome.index + 1})`)}`,
1191
+ );
1192
+ } else if (outcome.action === "skipped") {
1193
+ if (!isQuiet())
1194
+ console.log(
1195
+ chalk.yellow(
1196
+ `Duplicate ${recordType} already exists in ${domain} (record #${outcome.index + 1}). Use --force to add anyway.`,
1197
+ ),
1198
+ );
1199
+ } else {
1200
+ if (!isQuiet())
1201
+ console.log(`${brand("✓")} ${brand(`Recorded ${recordType} in ${domain}`)}`);
1202
+ }
1203
+ for (const w of [...preRecordWarnings, ...postWarnings]) {
1204
+ console.error(chalk.yellow(`Warning: ${w}`));
1205
+ }
1206
+ }
1207
+ }
1208
+ },
1209
+ );
1210
+ }