akm-cli 0.9.0-beta.9 → 0.9.0-rc.1

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 (381) hide show
  1. package/CHANGELOG.md +715 -0
  2. package/README.md +12 -4
  3. package/dist/akm +38 -0
  4. package/dist/akm-migrate-storage +38 -0
  5. package/dist/assets/help/help-improve.md +9 -6
  6. package/dist/assets/hints/cli-hints-full.md +6 -5
  7. package/dist/assets/profiles/default.json +9 -4
  8. package/dist/assets/profiles/frequent.json +1 -1
  9. package/dist/assets/profiles/memory-focus.json +1 -1
  10. package/dist/assets/profiles/proactive-maintenance.json +25 -0
  11. package/dist/assets/profiles/quick.json +1 -1
  12. package/dist/assets/profiles/recombine-only.json +21 -0
  13. package/dist/assets/profiles/reflect-distill.json +30 -0
  14. package/dist/assets/profiles/synthesize.json +15 -0
  15. package/dist/assets/profiles/thorough.json +1 -1
  16. package/dist/assets/prompts/consolidate-system.md +23 -0
  17. package/dist/assets/prompts/contradiction-judge.md +33 -0
  18. package/dist/assets/prompts/distill-knowledge-system.md +22 -0
  19. package/dist/assets/prompts/distill-lesson-system.md +36 -0
  20. package/dist/assets/prompts/extract-session.md +11 -3
  21. package/dist/assets/prompts/graph-extract-system.md +1 -0
  22. package/dist/assets/prompts/graph-extract-user-prompt.md +1 -1
  23. package/dist/assets/prompts/memory-infer-system.md +1 -0
  24. package/dist/assets/prompts/memory-infer-user.md +5 -0
  25. package/dist/assets/prompts/metadata-enhance-system.md +1 -0
  26. package/dist/assets/prompts/procedural-system.md +44 -0
  27. package/dist/assets/prompts/recombine-system.md +40 -0
  28. package/dist/assets/prompts/staleness-detect-system.md +6 -0
  29. package/dist/assets/prompts/validate-summary-judge.md +1 -0
  30. package/dist/assets/prompts/workflow-unit-preamble.md +26 -0
  31. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +38 -0
  32. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +38 -0
  33. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +39 -0
  34. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +40 -0
  35. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +43 -0
  36. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +38 -0
  37. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +43 -0
  38. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +40 -0
  39. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +43 -0
  40. package/dist/assets/templates/html/health.html +281 -111
  41. package/dist/assets/wiki/ingest-workflow-template.md +45 -16
  42. package/dist/assets/wiki/schema-template.md +4 -4
  43. package/dist/cli/clack.js +56 -0
  44. package/dist/cli/config-migrate.js +7 -1
  45. package/dist/cli/confirm.js +1 -1
  46. package/dist/cli/parse-args.js +46 -1
  47. package/dist/cli/shared.js +28 -0
  48. package/dist/cli.js +25 -21
  49. package/dist/commands/agent/agent-dispatch.js +3 -2
  50. package/dist/commands/agent/agent-support.js +0 -7
  51. package/dist/commands/agent/contribute-cli.js +26 -7
  52. package/dist/commands/config-cli.js +26 -13
  53. package/dist/commands/env/child-env.js +47 -0
  54. package/dist/commands/env/env-binding.js +95 -0
  55. package/dist/commands/env/env-cli.js +228 -292
  56. package/dist/commands/env/env.js +14 -67
  57. package/dist/commands/env/secret-cli.js +140 -138
  58. package/dist/commands/feedback-cli.js +156 -155
  59. package/dist/commands/graph/graph-cli.js +5 -13
  60. package/dist/commands/graph/graph.js +3 -3
  61. package/dist/commands/health/advisories.js +151 -0
  62. package/dist/commands/health/checks.js +103 -16
  63. package/dist/commands/health/html-report.js +447 -81
  64. package/dist/commands/health/improve-metrics.js +771 -0
  65. package/dist/commands/health/llm-usage.js +65 -0
  66. package/dist/commands/health/md-report.js +103 -0
  67. package/dist/commands/health/metrics.js +278 -0
  68. package/dist/commands/health/stash-exposure.js +46 -0
  69. package/dist/commands/health/surfaces.js +216 -0
  70. package/dist/commands/health/task-runs.js +135 -0
  71. package/dist/commands/health/types.js +26 -0
  72. package/dist/commands/health/windows.js +195 -0
  73. package/dist/commands/health.js +91 -1091
  74. package/dist/commands/improve/anti-collapse.js +170 -0
  75. package/dist/commands/improve/calibration.js +161 -0
  76. package/dist/commands/improve/collapse-detector.js +421 -0
  77. package/dist/commands/improve/consolidate/chunking.js +141 -0
  78. package/dist/commands/improve/consolidate/eligibility.js +64 -0
  79. package/dist/commands/improve/consolidate/merge.js +145 -0
  80. package/dist/commands/improve/consolidate/sanitize.js +231 -0
  81. package/dist/commands/{lint.js → improve/consolidate/types.js} +1 -1
  82. package/dist/commands/improve/consolidate.js +1295 -1277
  83. package/dist/commands/improve/dedup.js +482 -0
  84. package/dist/commands/improve/distill/content-repair.js +202 -0
  85. package/dist/commands/improve/distill/promote-memory.js +229 -0
  86. package/dist/commands/improve/distill/quality-gate.js +236 -0
  87. package/dist/commands/improve/distill-guards.js +127 -0
  88. package/dist/commands/improve/distill-promotion-policy.js +826 -167
  89. package/dist/commands/improve/distill.js +228 -605
  90. package/dist/commands/improve/eligibility.js +434 -0
  91. package/dist/commands/improve/encoding-salience.js +205 -0
  92. package/dist/commands/improve/extract-cli.js +179 -59
  93. package/dist/commands/improve/extract-prompt.js +54 -3
  94. package/dist/commands/improve/extract-watch.js +140 -0
  95. package/dist/commands/improve/extract.js +409 -43
  96. package/dist/commands/improve/feedback-valence.js +54 -0
  97. package/dist/commands/improve/hot-probation.js +45 -0
  98. package/dist/commands/improve/improve-auto-accept.js +157 -10
  99. package/dist/commands/improve/improve-cli.js +115 -73
  100. package/dist/commands/improve/improve-profiles.js +28 -8
  101. package/dist/commands/improve/improve-result-file.js +15 -25
  102. package/dist/commands/improve/improve-session.js +58 -0
  103. package/dist/commands/improve/improve.js +485 -2764
  104. package/dist/commands/improve/locks.js +154 -0
  105. package/dist/commands/improve/loop-stages.js +1100 -0
  106. package/dist/commands/improve/memory/memory-belief.js +14 -15
  107. package/dist/commands/improve/memory/memory-contradiction-detect.js +83 -60
  108. package/dist/commands/improve/memory/memory-improve.js +27 -27
  109. package/dist/commands/improve/outcome-loop.js +270 -0
  110. package/dist/commands/improve/preparation.js +2002 -0
  111. package/dist/commands/improve/proactive-maintenance.js +37 -35
  112. package/dist/commands/improve/procedural.js +398 -0
  113. package/dist/commands/improve/recombine.js +818 -0
  114. package/dist/commands/improve/reflect-noise.js +0 -0
  115. package/dist/commands/improve/reflect.js +206 -45
  116. package/dist/commands/improve/salience.js +455 -0
  117. package/dist/commands/improve/schema-similarity-gate.js +168 -0
  118. package/dist/commands/improve/shared.js +51 -0
  119. package/dist/commands/improve/triage.js +93 -0
  120. package/dist/commands/lint/agent-linter.js +19 -24
  121. package/dist/commands/lint/base-linter.js +173 -60
  122. package/dist/commands/lint/command-linter.js +19 -24
  123. package/dist/commands/lint/env-key-rules.js +38 -1
  124. package/dist/commands/lint/fact-linter.js +39 -0
  125. package/dist/commands/lint/index.js +31 -13
  126. package/dist/commands/lint/memory-linter.js +1 -1
  127. package/dist/commands/lint/registry.js +7 -2
  128. package/dist/commands/lint/task-linter.js +3 -3
  129. package/dist/commands/lint/workflow-linter.js +26 -1
  130. package/dist/commands/observability-cli.js +4 -4
  131. package/dist/commands/proposal/drain-policies.js +13 -4
  132. package/dist/commands/proposal/drain.js +45 -51
  133. package/dist/commands/proposal/legacy-import.js +115 -0
  134. package/dist/commands/proposal/proposal-cli.js +24 -34
  135. package/dist/commands/proposal/proposal.js +2 -1
  136. package/dist/commands/proposal/propose.js +8 -3
  137. package/dist/commands/proposal/repository.js +829 -0
  138. package/dist/commands/proposal/validators/proposal-quality-validators.js +9 -8
  139. package/dist/commands/proposal/validators/proposals.js +93 -895
  140. package/dist/commands/read/curate.js +410 -111
  141. package/dist/commands/read/knowledge.js +10 -3
  142. package/dist/commands/read/remember-cli.js +133 -138
  143. package/dist/commands/read/search-cli.js +15 -8
  144. package/dist/commands/read/search.js +22 -11
  145. package/dist/commands/read/show.js +106 -14
  146. package/dist/commands/registry-cli.js +76 -87
  147. package/dist/commands/remember.js +11 -12
  148. package/dist/commands/sources/add-cli.js +91 -95
  149. package/dist/commands/sources/history.js +1 -1
  150. package/dist/commands/sources/init.js +66 -18
  151. package/dist/commands/sources/installed-stashes.js +11 -3
  152. package/dist/commands/sources/migration-help.js +7 -4
  153. package/dist/commands/sources/schema-repair.js +44 -46
  154. package/dist/commands/sources/self-update.js +2 -2
  155. package/dist/commands/sources/source-add.js +7 -3
  156. package/dist/commands/sources/sources-cli.js +3 -3
  157. package/dist/commands/sources/stash-cli.js +19 -39
  158. package/dist/commands/sources/stash-skeleton.js +57 -8
  159. package/dist/commands/tasks/default-tasks.js +15 -2
  160. package/dist/commands/tasks/tasks-cli.js +20 -29
  161. package/dist/commands/tasks/tasks.js +39 -11
  162. package/dist/commands/wiki-cli.js +23 -38
  163. package/dist/commands/workflow-cli.js +291 -13
  164. package/dist/core/asset/asset-registry.js +3 -1
  165. package/dist/core/asset/asset-spec.js +79 -5
  166. package/dist/core/asset/frontmatter.js +188 -167
  167. package/dist/core/asset/markdown.js +8 -0
  168. package/dist/core/authoring-rules.js +92 -0
  169. package/dist/core/common.js +4 -23
  170. package/dist/core/concurrent.js +10 -1
  171. package/dist/core/config/config-io.js +10 -1
  172. package/dist/core/config/config-migration.js +18 -40
  173. package/dist/core/config/config-schema.js +403 -62
  174. package/dist/core/config/config-types.js +3 -3
  175. package/dist/core/config/config.js +67 -22
  176. package/dist/core/deep-merge.js +38 -0
  177. package/dist/core/errors.js +1 -0
  178. package/dist/core/eval/rank-metrics.js +113 -0
  179. package/dist/core/events.js +4 -7
  180. package/dist/core/improve-types.js +47 -8
  181. package/dist/core/json-schema.js +142 -0
  182. package/dist/core/logs-db.js +14 -75
  183. package/dist/core/parse.js +36 -16
  184. package/dist/core/paths.js +18 -18
  185. package/dist/core/standards/resolve-standards-context.js +87 -0
  186. package/dist/core/standards/resolve-stash-standards.js +99 -0
  187. package/dist/core/standards/resolve-type-conventions.js +66 -0
  188. package/dist/core/state/migrations.js +770 -0
  189. package/dist/core/state-db.js +132 -1126
  190. package/dist/core/structured.js +69 -0
  191. package/dist/core/time.js +53 -0
  192. package/dist/core/warn.js +21 -0
  193. package/dist/core/write-source.js +37 -0
  194. package/dist/indexer/db/db.js +261 -770
  195. package/dist/indexer/db/entry-mapper.js +41 -0
  196. package/dist/indexer/db/graph-db.js +129 -86
  197. package/dist/indexer/db/llm-cache.js +2 -2
  198. package/dist/indexer/db/schema.js +516 -0
  199. package/dist/indexer/ensure-index.js +36 -92
  200. package/dist/indexer/feedback/utility-policy.js +75 -0
  201. package/dist/indexer/graph/graph-boost.js +51 -41
  202. package/dist/indexer/graph/graph-extraction.js +207 -4
  203. package/dist/indexer/index-writer-lock.js +18 -11
  204. package/dist/indexer/index-written-assets.js +105 -0
  205. package/dist/indexer/indexer.js +182 -204
  206. package/dist/indexer/passes/dir-staleness.js +114 -0
  207. package/dist/indexer/passes/memory-inference.js +13 -5
  208. package/dist/indexer/passes/metadata.js +20 -0
  209. package/dist/indexer/read-preflight.js +23 -0
  210. package/dist/indexer/search/db-search.js +89 -13
  211. package/dist/indexer/search/fts-query.js +51 -0
  212. package/dist/indexer/search/ranking-contributors.js +95 -9
  213. package/dist/indexer/search/ranking.js +79 -3
  214. package/dist/indexer/search/search-fields.js +6 -0
  215. package/dist/indexer/search/search-source.js +32 -21
  216. package/dist/indexer/search/semantic-status.js +4 -0
  217. package/dist/indexer/walk/matchers.js +48 -0
  218. package/dist/indexer/walk/walker.js +21 -13
  219. package/dist/integrations/agent/builders.js +41 -13
  220. package/dist/integrations/agent/config.js +20 -59
  221. package/dist/integrations/agent/detect.js +9 -0
  222. package/dist/integrations/agent/index.js +3 -19
  223. package/dist/integrations/agent/model-aliases.js +16 -2
  224. package/dist/integrations/agent/profiles.js +79 -6
  225. package/dist/integrations/agent/prompts.js +75 -9
  226. package/dist/integrations/agent/runner-dispatch.js +83 -0
  227. package/dist/integrations/agent/runner.js +13 -9
  228. package/dist/integrations/agent/spawn.js +206 -81
  229. package/dist/integrations/harnesses/aider/agent-builder.js +113 -0
  230. package/dist/integrations/harnesses/aider/index.js +58 -0
  231. package/dist/integrations/harnesses/aider/result-extractor.js +53 -0
  232. package/dist/integrations/harnesses/amazonq/agent-builder.js +153 -0
  233. package/dist/integrations/harnesses/amazonq/index.js +59 -0
  234. package/dist/integrations/harnesses/amazonq/result-extractor.js +48 -0
  235. package/dist/integrations/harnesses/claude/agent-builder.js +46 -7
  236. package/dist/integrations/harnesses/claude/index.js +27 -23
  237. package/dist/integrations/harnesses/claude/result-extractor.js +52 -0
  238. package/dist/integrations/harnesses/claude/session-log.js +10 -0
  239. package/dist/integrations/harnesses/codex/agent-builder.js +137 -0
  240. package/dist/integrations/harnesses/codex/index.js +63 -0
  241. package/dist/integrations/harnesses/codex/result-extractor.js +73 -0
  242. package/dist/integrations/harnesses/copilot/agent-builder.js +122 -0
  243. package/dist/integrations/harnesses/copilot/index.js +60 -0
  244. package/dist/integrations/harnesses/copilot/result-extractor.js +151 -0
  245. package/dist/integrations/harnesses/gemini/agent-builder.js +121 -0
  246. package/dist/integrations/harnesses/gemini/index.js +60 -0
  247. package/dist/integrations/harnesses/gemini/result-extractor.js +121 -0
  248. package/dist/integrations/harnesses/index.js +28 -7
  249. package/dist/integrations/harnesses/opencode/agent-builder.js +1 -1
  250. package/dist/integrations/harnesses/opencode/index.js +17 -16
  251. package/dist/integrations/harnesses/opencode/session-log.js +173 -3
  252. package/dist/integrations/harnesses/opencode-sdk/harness.js +65 -0
  253. package/dist/integrations/harnesses/opencode-sdk/index.js +10 -34
  254. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +642 -71
  255. package/dist/integrations/harnesses/openhands/agent-builder.js +126 -0
  256. package/dist/integrations/harnesses/openhands/index.js +58 -0
  257. package/dist/integrations/harnesses/openhands/result-extractor.js +103 -0
  258. package/dist/integrations/harnesses/pi/agent-builder.js +104 -0
  259. package/dist/integrations/harnesses/pi/index.js +58 -0
  260. package/dist/integrations/harnesses/pi/result-extractor.js +135 -0
  261. package/dist/integrations/harnesses/types.js +8 -0
  262. package/dist/integrations/session-logs/index.js +40 -11
  263. package/dist/llm/call-ai.js +2 -2
  264. package/dist/llm/client.js +34 -11
  265. package/dist/llm/embedder.js +67 -4
  266. package/dist/llm/embedders/cache.js +3 -1
  267. package/dist/llm/embedders/deterministic.js +66 -0
  268. package/dist/llm/embedders/local.js +73 -3
  269. package/dist/llm/feature-gate.js +16 -15
  270. package/dist/llm/graph-extract.js +67 -44
  271. package/dist/llm/memory-infer-impl.js +138 -0
  272. package/dist/llm/memory-infer.js +1 -127
  273. package/dist/llm/metadata-enhance.js +44 -31
  274. package/dist/llm/structured-call.js +49 -0
  275. package/dist/migrate-storage-node.mjs +8 -0
  276. package/dist/output/context.js +5 -5
  277. package/dist/output/renderers.js +87 -15
  278. package/dist/output/shapes/curate.js +14 -2
  279. package/dist/output/shapes/helpers.js +0 -3
  280. package/dist/output/shapes/passthrough.js +6 -1
  281. package/dist/output/text/helpers.js +241 -2
  282. package/dist/output/text/workflow.js +4 -1
  283. package/dist/registry/providers/skills-sh.js +21 -147
  284. package/dist/registry/providers/static-index.js +15 -157
  285. package/dist/registry/resolve.js +27 -9
  286. package/dist/runtime.js +25 -1
  287. package/dist/schemas/akm-config.json +14225 -0
  288. package/dist/schemas/akm-workflow.json +328 -0
  289. package/dist/scripts/migrate-storage.js +2743 -8390
  290. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +1652 -607
  291. package/dist/setup/detect.js +9 -0
  292. package/dist/setup/legacy-config.js +106 -0
  293. package/dist/setup/prompt.js +57 -0
  294. package/dist/setup/providers.js +14 -0
  295. package/dist/setup/registry-stash-loader.js +12 -0
  296. package/dist/setup/semantic-assets.js +124 -0
  297. package/dist/setup/setup.js +52 -1614
  298. package/dist/setup/steps/connection.js +734 -0
  299. package/dist/setup/steps/output.js +31 -0
  300. package/dist/setup/steps/platforms.js +124 -0
  301. package/dist/setup/steps/semantic.js +27 -0
  302. package/dist/setup/steps/sources.js +222 -0
  303. package/dist/setup/steps/stashdir.js +42 -0
  304. package/dist/setup/steps/tasks.js +152 -0
  305. package/dist/sources/include.js +6 -2
  306. package/dist/sources/providers/filesystem.js +0 -1
  307. package/dist/sources/providers/git-install.js +210 -0
  308. package/dist/sources/providers/git-provider.js +234 -0
  309. package/dist/sources/providers/git-stash.js +248 -0
  310. package/dist/sources/providers/git.js +10 -661
  311. package/dist/sources/providers/npm.js +2 -6
  312. package/dist/sources/providers/provider-utils.js +13 -7
  313. package/dist/sources/providers/sync-from-ref.js +9 -1
  314. package/dist/sources/providers/website.js +9 -5
  315. package/dist/sources/website-ingest.js +187 -29
  316. package/dist/sources/wiki-fetchers/registry.js +53 -0
  317. package/dist/sources/wiki-fetchers/youtube.js +239 -0
  318. package/dist/storage/database.js +45 -10
  319. package/dist/storage/managed-db.js +82 -0
  320. package/dist/storage/repositories/canaries-repository.js +107 -0
  321. package/dist/storage/repositories/consolidation-repository.js +38 -0
  322. package/dist/storage/repositories/embeddings-repository.js +72 -0
  323. package/dist/storage/repositories/events-repository.js +187 -0
  324. package/dist/storage/repositories/extract-sessions-repository.js +96 -0
  325. package/dist/storage/repositories/improve-runs-repository.js +146 -0
  326. package/dist/storage/repositories/index-db.js +14 -8
  327. package/dist/storage/repositories/proposals-repository.js +220 -0
  328. package/dist/storage/repositories/recombine-repository.js +213 -0
  329. package/dist/storage/repositories/registry-cache.js +93 -0
  330. package/dist/storage/repositories/registry-index-cache-repository.js +46 -0
  331. package/dist/storage/repositories/task-history-repository.js +93 -0
  332. package/dist/storage/repositories/workflow-runs-repository.js +189 -1
  333. package/dist/storage/sqlite-pragmas.js +146 -0
  334. package/dist/tasks/backends/cron.js +1 -1
  335. package/dist/tasks/backends/index.js +9 -0
  336. package/dist/tasks/backends/launchd.js +1 -1
  337. package/dist/tasks/backends/schtasks.js +1 -1
  338. package/dist/tasks/{resolveAkmBin.js → resolve-akm-bin.js} +2 -2
  339. package/dist/tasks/runner.js +15 -13
  340. package/dist/text-import-hook.mjs +1 -1
  341. package/dist/wiki/wiki.js +52 -11
  342. package/dist/workflows/authoring/authoring.js +123 -10
  343. package/dist/workflows/authoring/workflow-program-template.yaml +31 -0
  344. package/dist/workflows/cli.js +5 -0
  345. package/dist/workflows/db.js +138 -4
  346. package/dist/workflows/exec/brief.js +484 -0
  347. package/dist/workflows/exec/native-executor.js +975 -0
  348. package/dist/workflows/exec/param-secrets.js +115 -0
  349. package/dist/workflows/exec/report.js +1295 -0
  350. package/dist/workflows/exec/run-workflow.js +596 -0
  351. package/dist/workflows/exec/scheduler.js +100 -0
  352. package/dist/workflows/exec/step-work.js +1156 -0
  353. package/dist/workflows/exec/unit-writer.js +23 -0
  354. package/dist/workflows/exec/watch.js +116 -0
  355. package/dist/workflows/exec/worktree.js +171 -0
  356. package/dist/workflows/ir/compile.js +388 -0
  357. package/dist/workflows/ir/params.js +54 -0
  358. package/dist/workflows/ir/plan-hash.js +33 -0
  359. package/dist/workflows/ir/schema.js +4 -0
  360. package/dist/workflows/parser.js +3 -1
  361. package/dist/workflows/program/expressions.js +369 -0
  362. package/dist/workflows/program/parser.js +760 -0
  363. package/dist/workflows/program/project.js +105 -0
  364. package/dist/workflows/program/schema.js +54 -0
  365. package/dist/workflows/renderer.js +82 -5
  366. package/dist/workflows/runtime/agent-identity.js +59 -14
  367. package/dist/workflows/runtime/runs.js +248 -153
  368. package/dist/workflows/runtime/unit-checkin.js +45 -0
  369. package/dist/workflows/runtime/workflow-asset-loader.js +188 -0
  370. package/dist/workflows/validate-summary.js +26 -10
  371. package/dist/workflows/validator.js +1 -1
  372. package/docs/README.md +69 -18
  373. package/docs/data-and-telemetry.md +7 -5
  374. package/docs/migration/release-notes/0.7.0.md +1 -1
  375. package/docs/migration/release-notes/0.9.0-beta.60.md +19 -0
  376. package/docs/migration/release-notes/0.9.0.md +39 -0
  377. package/package.json +10 -10
  378. package/dist/assets/tasks/core/update-stashes.yml +0 -4
  379. package/dist/commands/db-cli.js +0 -23
  380. package/dist/indexer/db/db-backup.js +0 -376
  381. package/dist/indexer/passes/staleness-detect.js +0 -488
@@ -2,706 +2,15 @@
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  /**
5
- * Proposal substrate (#225, storage consolidated in #578).
5
+ * Proposal validation and content repair.
6
6
  *
7
- * One durable proposal store for every future reflection / generation flow
8
- * (`akm reflect`, `akm propose`, `akm distill`, lesson distillation, …).
9
- * Proposals are *queue state*, not source-of-truth assetsthey sit in the
10
- * queue waiting for human (or automated) review and only become assets after
11
- * `akm proposal accept` validates and promotes them via
12
- * {@link writeAssetToSource}.
13
- *
14
- * # Storage
15
- *
16
- * The canonical store is the `proposals` table in `state.db` (SQLite, WAL
17
- * mode — see `src/core/state-db.ts`). Rows are partitioned by `stash_dir` so
18
- * multi-stash installs keep independent queues, and the `status` column
19
- * distinguishes the live queue (`pending`) from the archive (`accepted` /
20
- * `rejected` / `reverted`). There is no separate archive location — archival
21
- * is a status flip, and the full audit trail (review outcome, reason, backup
22
- * content for revert) lives on the row.
23
- *
24
- * ## Legacy filesystem import
25
- *
26
- * Before 0.9.0 proposals lived as per-uuid JSON directories under
27
- * `<stashDir>/.akm/proposals/` (live) and `…/proposals/archive/` (archived).
28
- * The first proposal operation against a stash imports any legacy
29
- * `proposal.json` files into the table (INSERT OR IGNORE keyed on the UUID,
30
- * so re-runs never duplicate) and records the stash in `proposal_fs_imports`
31
- * so later invocations skip the directory walk. The legacy files are left in
32
- * place untouched — they are inert after import and may be removed by the
33
- * operator at leisure.
34
- *
35
- * # Why the queue bypasses `writeAssetToSource`
36
- *
37
- * The architectural rule "all writes go through `writeAssetToSource`" applies
38
- * to *assets*. Proposals are **not** assets — they live outside the asset
39
- * tree (in state.db, parallel to how events do). Routing them through
40
- * `writeAssetToSource` would force them into a `TYPE_DIRS` slot, would commit
41
- * them to git, and would leak unaccepted drafts through the normal indexer.
42
- * The {@link promoteProposal} step is the bridge: it routes the accepted
43
- * payload through `writeAssetToSource` so the actual asset write still
44
- * funnels through the single dispatch point in `src/core/write-source.ts`.
7
+ * The proposal repository, domain service, and legacy filesystem import moved
8
+ * to `../repository.ts` and `../legacy-import.ts` (#578 storage consolidation).
9
+ * This module keeps only the two proposal *validators* {@link validateProposal}
10
+ * and {@link repairProposalContent}.
45
11
  */
46
- import { createHash, randomUUID } from "node:crypto";
47
- import fs from "node:fs";
48
- import path from "node:path";
49
- import { makeAssetRef, parseAssetRef } from "../../../core/asset/asset-ref.js";
50
- import { resolveAssetPathFromName, TYPE_DIRS } from "../../../core/asset/asset-spec.js";
51
- import { NotFoundError, UsageError } from "../../../core/errors.js";
52
- import { appendEvent } from "../../../core/events.js";
53
- import { getStateDbPath, getStateProposal, hasImportedFsProposals, insertProposalIfAbsent, listStateProposalIdsByPrefix, listStateProposals, openStateDatabase, recordFsProposalsImport, upsertProposal, withImmediateTransaction, } from "../../../core/state-db.js";
54
- import { warn } from "../../../core/warn.js";
55
- import { commitWriteTargetBoundary, formatRefForMessage, resolveWriteTarget, writeAssetToSource, } from "../../../core/write-source.js";
12
+ import { repairTruncatedDescription } from "../../../core/text-truncation.js";
56
13
  import { runProposalValidators } from "./proposal-validators.js";
57
- // ── Source allow-list (F-4 / #385) ──────────────────────────────────────────
58
- /**
59
- * Curated allow-list of valid `source` values for proposals (F-4 / #385).
60
- *
61
- * Rationale (W3C PROV-DM 2013): Provenance records require typed, validated
62
- * sources for meaningful aggregation. Accept-rate-per-source is the core
63
- * self-measurement metric for recursive self-improvement: if reflect proposals
64
- * are accepted at 20% and distill proposals at 60%, that guides resource
65
- * allocation. Free-text typos (`"reflct"`) produce unaggregatable events.
66
- *
67
- * Automated sources (those in {@link AUTOMATED_PROPOSAL_SOURCES}) require a
68
- * `sourceRun` field for full PROV-DM traceability.
69
- */
70
- export const PROPOSAL_SOURCES = [
71
- // Automated sources — require sourceRun for traceability.
72
- "reflect",
73
- "distill",
74
- "consolidate",
75
- "extract",
76
- "improve",
77
- // Semi-automated / tool-driven.
78
- "feedback",
79
- // Human-initiated / CLI-driven.
80
- "propose",
81
- "remember",
82
- "import",
83
- // Internal / system.
84
- "distill_quality_rejected",
85
- "schema-repair",
86
- ];
87
- /** Automated sources that SHOULD include a `sourceRun` for PROV-DM traceability. */
88
- export const AUTOMATED_PROPOSAL_SOURCES = [
89
- "reflect",
90
- "distill",
91
- "consolidate",
92
- "extract",
93
- "improve",
94
- "schema-repair",
95
- ];
96
- /**
97
- * Check whether a string is a valid {@link ProposalSource}.
98
- * Unknown source values are accepted with a runtime warning rather than a hard
99
- * error, to allow extensions without breaking existing callers.
100
- */
101
- export function isValidProposalSource(source) {
102
- return PROPOSAL_SOURCES.includes(source);
103
- }
104
- /**
105
- * Check whether a source value is an automated source requiring `sourceRun`.
106
- */
107
- export function isAutomatedProposalSource(source) {
108
- return AUTOMATED_PROPOSAL_SOURCES.includes(source);
109
- }
110
- /** Type guard: true when createProposal returned a skipped record. */
111
- export function isProposalSkipped(result) {
112
- return result.skipped === true;
113
- }
114
- // ── Dedup / cooldown constants ───────────────────────────────────────────────
115
- const MS_PER_DAY = 86_400_000;
116
- /**
117
- * Post-rejection cooldown windows by source. After a proposal is rejected,
118
- * `createProposal` silently skips new proposals for the same `ref+source`
119
- * until the window expires (unless `force: true` is passed).
120
- *
121
- * Rationale (Settles 2009 active-learning survey; Argilla/Label Studio HITL):
122
- * Reviewer fatigue is a blocker for the human-in-the-loop guarantee. Cooldowns
123
- * prevent nightly improve runs from re-flooding the queue with near-identical
124
- * proposals the reviewer just declined.
125
- *
126
- * - reflect: 14 days (agent-based; slower feedback loops)
127
- * - distill: 30 days (LLM-based; even more prone to regeneration loops)
128
- * - default: 7 days (conservative fallback for other sources)
129
- */
130
- const COOLDOWN_MS = {
131
- reflect: 14 * MS_PER_DAY,
132
- distill: 30 * MS_PER_DAY,
133
- };
134
- const DEFAULT_COOLDOWN_MS = 7 * MS_PER_DAY;
135
- function cooldownMsForSource(source) {
136
- return COOLDOWN_MS[source] ?? DEFAULT_COOLDOWN_MS;
137
- }
138
- /** Compute a stable SHA-256 hex digest of a proposal's content string. */
139
- function contentHash(content) {
140
- return createHash("sha256").update(content, "utf8").digest("hex");
141
- }
142
- // ── Store access ─────────────────────────────────────────────────────────────
143
- function nowIso(ctx) {
144
- const fn = ctx?.now ?? Date.now;
145
- return new Date(fn()).toISOString();
146
- }
147
- function newId(ctx) {
148
- const fn = ctx?.randomUUID ?? randomUUID;
149
- return fn();
150
- }
151
- /**
152
- * Open the state database (honouring the `ctx.dbPath` test seam), run the
153
- * legacy filesystem import for `stashDir` if it has not happened yet, hand the
154
- * connection to `fn`, and close it in a `finally`. Every public function in
155
- * this module funnels its store access through here so the legacy import is
156
- * guaranteed to have run before any read or write.
157
- */
158
- function withProposalsDb(stashDir, ctx, fn) {
159
- const db = openStateDatabase(ctx?.dbPath ?? getStateDbPath());
160
- try {
161
- importLegacyProposalFiles(db, stashDir);
162
- return fn(db);
163
- }
164
- finally {
165
- db.close();
166
- }
167
- }
168
- // ── Legacy filesystem import (#578) ─────────────────────────────────────────
169
- /** Legacy (pre-0.9.0) proposal directory: `<stashDir>/.akm/proposals[/archive]`. */
170
- function legacyProposalsRoot(stashDir, archive) {
171
- const root = path.join(stashDir, ".akm", "proposals");
172
- return archive ? path.join(root, "archive") : root;
173
- }
174
- /**
175
- * One-shot import of legacy `proposal.json` files into the `proposals` table.
176
- *
177
- * Idempotent at two levels: the `proposal_fs_imports` ledger skips the
178
- * directory walk after the first successful import, and INSERT OR IGNORE
179
- * (keyed on the proposal UUID) protects against duplicates even if the walk
180
- * re-runs. Legacy `backup.<ext>` files are inlined into `backupContent` so
181
- * `akm proposal revert` keeps working for proposals accepted before 0.9.0.
182
- *
183
- * The legacy files are never modified or deleted — after import they are
184
- * inert artifacts the operator can remove at leisure.
185
- */
186
- function importLegacyProposalFiles(db, stashDir) {
187
- if (hasImportedFsProposals(db, stashDir))
188
- return;
189
- const liveRoot = legacyProposalsRoot(stashDir, false);
190
- if (!fs.existsSync(liveRoot))
191
- return;
192
- let imported = 0;
193
- for (const archive of [false, true]) {
194
- const root = legacyProposalsRoot(stashDir, archive);
195
- let entries;
196
- try {
197
- entries = fs.readdirSync(root, { withFileTypes: true });
198
- }
199
- catch {
200
- continue;
201
- }
202
- for (const entry of entries) {
203
- if (!entry.isDirectory() || entry.name === "archive")
204
- continue;
205
- const proposalDir = path.join(root, entry.name);
206
- const proposal = readLegacyProposalFile(proposalDir);
207
- if (!proposal)
208
- continue;
209
- if (insertProposalIfAbsent(db, proposal, stashDir))
210
- imported += 1;
211
- }
212
- }
213
- recordFsProposalsImport(db, stashDir, imported);
214
- if (imported > 0) {
215
- warn(`[proposals] imported ${imported} legacy proposal file(s) from ${liveRoot} into state.db`);
216
- }
217
- }
218
- /**
219
- * Parse one legacy proposal directory into a {@link Proposal}, inlining the
220
- * backup file (when present) as `backupContent`. Returns undefined — with a
221
- * warning — when the `proposal.json` is missing, unreadable, or malformed, so
222
- * a single corrupt legacy entry never blocks the import of the rest.
223
- */
224
- function readLegacyProposalFile(proposalDir) {
225
- const filePath = path.join(proposalDir, "proposal.json");
226
- let parsed;
227
- try {
228
- parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
229
- }
230
- catch (err) {
231
- warn(`[proposals] skipping legacy proposal at ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
232
- return undefined;
233
- }
234
- if (typeof parsed !== "object" ||
235
- parsed === null ||
236
- typeof parsed.id !== "string" ||
237
- typeof parsed.ref !== "string") {
238
- warn(`[proposals] skipping legacy proposal at ${filePath}: not a proposal object`);
239
- return undefined;
240
- }
241
- const { backup, ...rest } = parsed;
242
- let backupContent;
243
- if (typeof backup === "string" && backup.length > 0) {
244
- try {
245
- backupContent = fs.readFileSync(path.join(proposalDir, backup), "utf8");
246
- }
247
- catch {
248
- // Backup file lost — import the proposal anyway; revert for it will
249
- // surface "no backup available", same as a new-asset proposal.
250
- }
251
- }
252
- return {
253
- ...rest,
254
- payload: {
255
- content: rest.payload?.content ?? "",
256
- ...(rest.payload?.frontmatter ? { frontmatter: rest.payload.frontmatter } : {}),
257
- },
258
- createdAt: rest.createdAt ?? "",
259
- updatedAt: rest.updatedAt ?? rest.createdAt ?? "",
260
- status: rest.status ?? "pending",
261
- source: rest.source ?? "import",
262
- ...(backupContent !== undefined ? { backupContent } : {}),
263
- };
264
- }
265
- // ── Public API ──────────────────────────────────────────────────────────────
266
- /**
267
- * Create a new pending proposal. The id is a stable random UUID, so two
268
- * proposals with the same `ref` never collide.
269
- *
270
- * **Dedup / cooldown guard** (F-2 / #363):
271
- *
272
- * Before writing, this function checks:
273
- * 1. `duplicate_pending` — a pending proposal already exists for the same
274
- * `ref+source`. Pass `input.force = true` to bypass.
275
- * 2. `content_hash_match` — an identical content hash is already pending or
276
- * was recently rejected for this `ref+source`. Bypass with `force: true`.
277
- * 3. `cooldown` — a proposal for this `ref+source` was rejected within the
278
- * source-specific cooldown window (reflect: 14 d, distill: 30 d,
279
- * others: 7 d). Bypass with `force: true`.
280
- *
281
- * When a guard fires the function returns a `CreateProposalSkipped` record
282
- * instead of writing. Use {@link isProposalSkipped} to detect it.
283
- */
284
- export function createProposal(stashDir, input, ctx) {
285
- // F-4 / #385: Validate source against the allow-list. Unknown values are
286
- // warned (not rejected) for backward compatibility — extension callers
287
- // that pass custom source strings must not break.
288
- if (!isValidProposalSource(input.source)) {
289
- warn(`[proposal] Unknown source "${input.source}". ` +
290
- `Expected one of: ${PROPOSAL_SOURCES.join(", ")}. ` +
291
- "Typos in source values produce unaggregatable accept-rate-per-source metrics.");
292
- }
293
- else if (isAutomatedProposalSource(input.source) && !input.sourceRun) {
294
- // Advisory warning: automated sources should include sourceRun for PROV-DM
295
- // traceability. This is not a hard error to avoid breaking existing callers.
296
- warn(`[proposal] Automated source "${input.source}" created a proposal without sourceRun. ` +
297
- "Add sourceRun to enable accept-rate-per-run aggregation (W3C PROV-DM).");
298
- }
299
- // Deterministic input validation. Reject obviously-invalid proposals at
300
- // the source rather than letting them enter the queue and waste reviewer
301
- // time. Each rejection emits `proposal_creation_rejected` with a typed
302
- // reason so we can see *which* check is firing in the event stream.
303
- const rejectProposal = (reason, message) => {
304
- appendEvent({
305
- eventType: "proposal_creation_rejected",
306
- ref: input.ref,
307
- metadata: { source: input.source, reason },
308
- });
309
- throw new UsageError(message, "INVALID_PROPOSAL");
310
- };
311
- let parsedRef;
312
- try {
313
- parsedRef = parseAssetRef(input.ref);
314
- }
315
- catch (err) {
316
- return rejectProposal("invalid_ref", `Invalid proposal ref "${input.ref}": ${err instanceof Error ? err.message : String(err)}`);
317
- }
318
- if (!TYPE_DIRS[parsedRef.type]) {
319
- return rejectProposal("unknown_type", `Unknown asset type "${parsedRef.type}" in proposal ref "${input.ref}". Known types: ${Object.keys(TYPE_DIRS).sort().join(", ")}.`);
320
- }
321
- if (!input.payload.content.trim()) {
322
- return rejectProposal("empty_content", `Proposal for "${input.ref}" has empty content.`);
323
- }
324
- // Description check is only enforced for `consolidate` source — that's the
325
- // automated pipeline that historically produced proposals with missing or
326
- // malformed frontmatter, polluting the queue with hundreds of unusable
327
- // entries. Reflect / distill / propose proposals have varied legitimate
328
- // shapes and should not be rejected here for missing description.
329
- if (input.source === "consolidate") {
330
- const desc = input.payload.frontmatter?.description;
331
- if (typeof desc !== "string" || desc.trim() === "") {
332
- return rejectProposal("missing_description", `Proposal for "${input.ref}" (source=consolidate) has empty or missing frontmatter description.`);
333
- }
334
- }
335
- const normalizedRef = makeAssetRef(parsedRef.type, parsedRef.name, parsedRef.origin);
336
- return withProposalsDb(stashDir, ctx, (db) => {
337
- return withImmediateTransaction(db, () => {
338
- if (!input.force) {
339
- const skip = checkDedupAndCooldown(db, stashDir, normalizedRef, input, ctx);
340
- if (skip)
341
- return skip;
342
- }
343
- const created = nowIso(ctx);
344
- // Phase 6A: validate confidence is a finite number in [0, 1]. Anything else
345
- // is dropped silently — we never store NaN, Infinity, or out-of-range values.
346
- // Callers that mis-report confidence should not poison the auto-accept gate.
347
- const sanitizedConfidence = typeof input.confidence === "number" &&
348
- Number.isFinite(input.confidence) &&
349
- input.confidence >= 0 &&
350
- input.confidence <= 1
351
- ? input.confidence
352
- : undefined;
353
- const proposal = {
354
- id: newId(ctx),
355
- ref: normalizedRef,
356
- status: "pending",
357
- source: input.source,
358
- ...(input.sourceRun !== undefined ? { sourceRun: input.sourceRun } : {}),
359
- createdAt: created,
360
- updatedAt: created,
361
- payload: {
362
- content: input.payload.content,
363
- ...(input.payload.frontmatter !== undefined ? { frontmatter: input.payload.frontmatter } : {}),
364
- },
365
- ...(sanitizedConfidence !== undefined ? { confidence: sanitizedConfidence } : {}),
366
- // Attribution tagging: persist the eligibility lane so it survives to
367
- // accept/reject/revert time. See EligibilitySource.
368
- ...(input.eligibilitySource !== undefined ? { eligibilitySource: input.eligibilitySource } : {}),
369
- };
370
- upsertProposal(db, proposal, stashDir);
371
- return proposal;
372
- });
373
- });
374
- }
375
- /**
376
- * Evaluate the F-2 dedup / cooldown guards against the store. Returns the
377
- * skip record when a guard fires, or undefined when the create may proceed.
378
- */
379
- function checkDedupAndCooldown(db, stashDir, normalizedRef, input, ctx) {
380
- const newHash = contentHash(input.payload.content);
381
- const nowMs = (ctx?.now ?? Date.now)();
382
- const cooldownMs = cooldownMsForSource(input.source);
383
- // Scan pending proposals for ref+source matches.
384
- const pending = listStateProposals(db, { stashDir, ref: normalizedRef, status: "pending" }).filter((p) => p.source === input.source);
385
- if (pending.length > 0) {
386
- // Check for identical content hash first (silent skip).
387
- const hashMatch = pending.find((p) => contentHash(p.payload.content) === newHash);
388
- if (hashMatch) {
389
- return {
390
- skipped: true,
391
- reason: "content_hash_match",
392
- message: `Identical proposal for ${normalizedRef} already pending (id: ${hashMatch.id}).`,
393
- existingProposalId: hashMatch.id,
394
- };
395
- }
396
- // Duplicate pending for same ref+source (different content).
397
- const firstPending = pending[0];
398
- return {
399
- skipped: true,
400
- reason: "duplicate_pending",
401
- message: `A pending proposal for ${normalizedRef} from source "${input.source}" already exists (id: ${firstPending?.id ?? "unknown"}). Pass force:true to enqueue alongside it.`,
402
- existingProposalId: firstPending?.id,
403
- };
404
- }
405
- // Check cooldown against recently rejected proposals.
406
- const rejected = listStateProposals(db, { stashDir, ref: normalizedRef, status: "rejected" })
407
- .filter((p) => p.source === input.source)
408
- .sort((a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime());
409
- const mostRecent = rejected[0];
410
- if (mostRecent !== undefined) {
411
- // Check content hash against recently rejected.
412
- if (contentHash(mostRecent.payload.content) === newHash) {
413
- return {
414
- skipped: true,
415
- reason: "content_hash_match",
416
- message: `Identical proposal for ${normalizedRef} was already rejected (id: ${mostRecent.id}).`,
417
- existingProposalId: mostRecent.id,
418
- };
419
- }
420
- // Check cooldown window.
421
- const rejectedAt = new Date(mostRecent.updatedAt ?? 0).getTime();
422
- if (nowMs - rejectedAt < cooldownMs) {
423
- const cooldownDays = cooldownMs / MS_PER_DAY;
424
- const remainingDays = Math.ceil((cooldownMs - (nowMs - rejectedAt)) / MS_PER_DAY);
425
- return {
426
- skipped: true,
427
- reason: "cooldown",
428
- message: `Proposal for ${normalizedRef} from source "${input.source}" is in cooldown ` +
429
- `(${cooldownDays}d window, ~${remainingDays}d remaining). Pass force:true to bypass.`,
430
- existingProposalId: mostRecent.id,
431
- };
432
- }
433
- }
434
- return undefined;
435
- }
436
- /**
437
- * List proposals for one stash. By default returns only the live (pending)
438
- * queue; pass `{ includeArchive: true }` to include accepted / rejected /
439
- * reverted entries as well.
440
- */
441
- export function listProposals(stashDir, options = {}, ctx) {
442
- return withProposalsDb(stashDir, ctx, (db) => {
443
- // Without includeArchive, only the live queue is visible — an explicit
444
- // non-pending status filter therefore matches nothing (mirrors the
445
- // historical live-directory scan).
446
- if (!options.includeArchive && options.status !== undefined && options.status !== "pending") {
447
- return [];
448
- }
449
- const status = options.includeArchive ? options.status : "pending";
450
- return listStateProposals(db, {
451
- stashDir,
452
- ...(status !== undefined ? { status } : {}),
453
- ...(options.ref !== undefined ? { ref: options.ref } : {}),
454
- }).filter((p) => {
455
- if (!options.type)
456
- return true;
457
- try {
458
- return parseAssetRef(p.ref).type === options.type;
459
- }
460
- catch {
461
- return false;
462
- }
463
- });
464
- });
465
- }
466
- /**
467
- * Look up a proposal by id (live or archived).
468
- * Throws `NotFoundError` when no match exists in this stash.
469
- */
470
- export function getProposal(stashDir, id, ctx) {
471
- return withProposalsDb(stashDir, ctx, (db) => requireProposal(db, stashDir, id));
472
- }
473
- function requireProposal(db, stashDir, id) {
474
- const proposal = getStateProposal(db, id, stashDir);
475
- if (!proposal) {
476
- throw new NotFoundError(`Proposal "${id}" not found.`, "FILE_NOT_FOUND");
477
- }
478
- return proposal;
479
- }
480
- /**
481
- * Resolve a proposal by full UUID, UUID prefix, or asset ref.
482
- *
483
- * Resolution order:
484
- * 1. Exact UUID match (existing behaviour).
485
- * 2. Asset ref (contains `:`) — finds the most-recent pending proposal for
486
- * that ref; falls back to archived if nothing is pending.
487
- * 3. UUID prefix — matches any PENDING proposal whose id starts with the
488
- * given string; throws if ambiguous.
489
- */
490
- export function resolveProposalId(stashDir, idOrRef, ctx) {
491
- return withProposalsDb(stashDir, ctx, (db) => {
492
- // 1. Exact UUID.
493
- const exact = getStateProposal(db, idOrRef, stashDir);
494
- if (exact)
495
- return exact;
496
- // 2. Asset ref (e.g. "skill:akm-dream") — most recent pending, else most
497
- // recent archived.
498
- if (idOrRef.includes(":")) {
499
- const byRecency = (proposals) => proposals.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime())[0];
500
- const pending = byRecency(listStateProposals(db, { stashDir, ref: idOrRef, status: "pending" }));
501
- if (pending)
502
- return pending;
503
- const archived = byRecency(listStateProposals(db, { stashDir, ref: idOrRef }));
504
- if (archived)
505
- return archived;
506
- throw new NotFoundError(`No proposal found for ref "${idOrRef}".`, "FILE_NOT_FOUND");
507
- }
508
- // 3. UUID prefix (pending queue only).
509
- const prefixMatches = listStateProposalIdsByPrefix(db, stashDir, idOrRef);
510
- if (prefixMatches.length === 1)
511
- return requireProposal(db, stashDir, prefixMatches[0]);
512
- if (prefixMatches.length > 1) {
513
- throw new UsageError(`Ambiguous prefix "${idOrRef}" — matches: ${prefixMatches.join(", ")}`, "INVALID_FLAG_VALUE");
514
- }
515
- throw new NotFoundError(`Proposal "${idOrRef}" not found.`, "FILE_NOT_FOUND");
516
- });
517
- }
518
- /**
519
- * Archive a proposal: flip its status to `accepted` / `rejected`, bump
520
- * `updatedAt`, and record the review block. Used by both accept and reject
521
- * paths so the live queue only contains pending entries.
522
- */
523
- export function archiveProposal(stashDir, id, status, reason, ctx) {
524
- return withProposalsDb(stashDir, ctx, (db) => {
525
- return withImmediateTransaction(db, () => {
526
- const existing = requireProposal(db, stashDir, id);
527
- if (existing.status !== "pending") {
528
- throw new UsageError(`Proposal ${id} is not pending (current status: ${existing.status}). Only pending proposals can be ${status}.`, "INVALID_FLAG_VALUE");
529
- }
530
- const decidedAt = nowIso(ctx);
531
- const updated = {
532
- ...existing,
533
- status,
534
- updatedAt: decidedAt,
535
- review: {
536
- outcome: status,
537
- ...(reason !== undefined ? { reason } : {}),
538
- decidedAt,
539
- },
540
- };
541
- upsertProposal(db, updated, stashDir);
542
- return updated;
543
- });
544
- });
545
- }
546
- /**
547
- * Record an automated gate's decision onto a proposal (#577).
548
- *
549
- * Stamps `gateDecision` (decision / reason / confidence / thresholds) onto the
550
- * row so `akm proposal show` and `list` can explain why a proposal landed where
551
- * it did. The decision is metadata about the adjudication, so this does NOT
552
- * change `status` or bump `updatedAt` — a `deferred` proposal stays `pending`,
553
- * and the accept / reject status flips are owned by {@link promoteProposal} /
554
- * {@link archiveProposal}. `decidedAt` defaults to now when the caller omits it.
555
- *
556
- * Best-effort: a proposal that no longer exists (e.g. concurrently archived) is
557
- * skipped silently rather than throwing, so a gate run never aborts mid-batch.
558
- * Returns the updated proposal, or undefined when no matching row exists.
559
- */
560
- export function recordGateDecision(stashDir, id, decision, ctx) {
561
- return withProposalsDb(stashDir, ctx, (db) => {
562
- return withImmediateTransaction(db, () => {
563
- const existing = getStateProposal(db, id, stashDir);
564
- if (!existing || existing.status !== "pending")
565
- return undefined;
566
- const updated = {
567
- ...existing,
568
- gateDecision: { ...decision, decidedAt: decision.decidedAt ?? nowIso(ctx) },
569
- };
570
- upsertProposal(db, updated, stashDir);
571
- return updated;
572
- });
573
- });
574
- }
575
- /**
576
- * Scan all pending proposals and reject those whose target asset no longer
577
- * exists on disk across any of `sourceDirs`. Intended to run as a periodic
578
- * maintenance pass (see `runImproveMaintenancePasses`) — it keeps the queue
579
- * from accumulating stale reviewer work after large refactors or deletes.
580
- *
581
- * Scope rule: only `source=reflect` proposals are subject to orphan rejection.
582
- * Lessons, propose, distill, and consolidate proposals legitimately target
583
- * assets that don't exist yet and must never be purged.
584
- */
585
- export function purgeOrphanProposals(stashDir, sourceDirs, ctx) {
586
- const t0 = Date.now();
587
- const orphans = [];
588
- const byType = {};
589
- const pending = listProposals(stashDir, { status: "pending" }, ctx);
590
- const reflectPending = pending.filter((p) => p.source === "reflect");
591
- for (const p of reflectPending) {
592
- let parsed;
593
- try {
594
- parsed = parseAssetRef(p.ref);
595
- }
596
- catch {
597
- continue;
598
- }
599
- // Lessons are new-asset proposals by definition — they cannot be orphaned.
600
- if (parsed.type === "lesson")
601
- continue;
602
- const spec = TYPE_DIRS[parsed.type];
603
- if (!spec)
604
- continue;
605
- const exists = sourceDirs.some((root) => {
606
- const typeRoot = path.join(root, spec);
607
- const candidate = resolveAssetPathFromName(parsed.type, typeRoot, parsed.name);
608
- return fs.existsSync(candidate);
609
- });
610
- if (!exists) {
611
- try {
612
- archiveProposal(stashDir, p.id, "rejected", "Asset no longer exists on disk", ctx);
613
- orphans.push({ id: p.id, ref: p.ref, reason: "asset_missing" });
614
- byType[parsed.type] = (byType[parsed.type] ?? 0) + 1;
615
- }
616
- catch (err) {
617
- // Best-effort — the purge is non-fatal. Log and continue.
618
- warn(`[proposals] purgeOrphanProposals: failed to reject ${p.id}: ${err instanceof Error ? err.message : String(err)}`);
619
- }
620
- }
621
- }
622
- return {
623
- checked: reflectPending.length,
624
- rejected: orphans.length,
625
- durationMs: Date.now() - t0,
626
- byType,
627
- orphans,
628
- };
629
- }
630
- /**
631
- * Archive pending proposals older than `config.archiveRetentionDays` (Advantage
632
- * D6b / Phase 6B).
633
- *
634
- * Reviewer fatigue and queue rot are the dominant failure modes of any
635
- * human-in-the-loop pipeline (Settles 2009 active-learning survey). Pending
636
- * proposals that have aged past the retention window are very rarely accepted
637
- * — the reviewer either intentionally declined to act on them, or the asset
638
- * they target has drifted enough that the proposal is no longer relevant.
639
- * Auto-expiring them keeps the live queue focused on actionable work; the
640
- * archive preserves the full audit trail.
641
- *
642
- * Each expired proposal is archived with status `rejected` and reason
643
- * `"expired: no action within retention window"`. A `proposal_expired` event
644
- * is appended for each expired proposal so downstream observability (events
645
- * dashboards, source-acceptance-rate aggregations) can see expiry separately
646
- * from explicit rejections.
647
- *
648
- * Idempotent: a second call within the same retention window finds nothing
649
- * to expire (the archived entries are no longer in the pending queue).
650
- */
651
- export function expireStaleProposals(stashDir, config, ctx) {
652
- const t0 = Date.now();
653
- const retentionDays = config.archiveRetentionDays ?? 90;
654
- const expiredProposals = [];
655
- // retentionDays === 0 disables TTL cleanup globally (mirrors how
656
- // consolidate.ts interprets the same config value).
657
- if (retentionDays <= 0) {
658
- return {
659
- checked: 0,
660
- expired: 0,
661
- durationMs: Date.now() - t0,
662
- retentionDays,
663
- expiredProposals,
664
- };
665
- }
666
- const retentionMs = retentionDays * MS_PER_DAY;
667
- const nowMs = (ctx?.now ?? Date.now)();
668
- const pending = listProposals(stashDir, { status: "pending" }, ctx);
669
- for (const p of pending) {
670
- const createdMs = new Date(p.createdAt).getTime();
671
- if (!Number.isFinite(createdMs))
672
- continue;
673
- const ageMs = nowMs - createdMs;
674
- if (ageMs < retentionMs)
675
- continue;
676
- try {
677
- archiveProposal(stashDir, p.id, "rejected", "expired: no action within retention window", ctx);
678
- const ageDays = Math.floor(ageMs / MS_PER_DAY);
679
- expiredProposals.push({ id: p.id, ref: p.ref, ageDays });
680
- appendEvent({
681
- eventType: "proposal_expired",
682
- ref: p.ref,
683
- metadata: {
684
- proposalId: p.id,
685
- source: p.source,
686
- ...(p.sourceRun !== undefined ? { sourceRun: p.sourceRun } : {}),
687
- ageDays,
688
- retentionDays,
689
- },
690
- });
691
- }
692
- catch (err) {
693
- // Best-effort — a single failure must not block the pass.
694
- warn(`[proposals] expireStaleProposals: failed to expire ${p.id}: ${err instanceof Error ? err.message : String(err)}`);
695
- }
696
- }
697
- return {
698
- checked: pending.length,
699
- expired: expiredProposals.length,
700
- durationMs: Date.now() - t0,
701
- retentionDays,
702
- expiredProposals,
703
- };
704
- }
705
14
  /**
706
15
  * Validate a proposal payload before promotion. Generic by default — any
707
16
  * proposal must parse cleanly and carry a non-empty body. Lessons get the
@@ -712,207 +21,96 @@ export function expireStaleProposals(stashDir, config, ctx) {
712
21
  export function validateProposal(proposal) {
713
22
  return runProposalValidators(proposal);
714
23
  }
715
- /**
716
- * Validate a proposal, then promote it through the canonical
717
- * {@link writeAssetToSource} dispatch (the single place that branches on
718
- * `source.kind`). On success the proposal is archived with status `accepted`.
719
- * Validation failures throw a `UsageError` carrying every finding so the CLI
720
- * can render a single clear error envelope.
721
- *
722
- * Phase 6C: when the target asset already exists at the resolved write path,
723
- * its prior content is captured BEFORE the write and stored on the archived
724
- * proposal record (`backupContent`) so `akm proposal revert` can restore it.
725
- * Genuinely-new assets carry no backup.
726
- */
727
- export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
728
- const proposal = getProposal(stashDir, id, ctx);
729
- if (proposal.status !== "pending") {
730
- throw new UsageError(`Proposal ${id} is not pending (current status: ${proposal.status}). Only pending proposals can be accepted.`, "INVALID_FLAG_VALUE");
731
- }
732
- const report = validateProposal(proposal);
733
- if (!report.ok) {
734
- const message = report.findings.map((f) => `[${f.kind}] ${f.message}`).join("\n");
735
- throw new UsageError(`Proposal ${id} failed validation:\n${message}`, "MISSING_REQUIRED_ARGUMENT", "Fix the proposal payload (frontmatter / content) and try again, or reject the proposal with a reason.");
736
- }
737
- const ref = parseAssetRef(proposal.ref);
738
- if (!TYPE_DIRS[ref.type]) {
739
- throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
740
- }
741
- const target = resolveWriteTarget(config, options.target);
742
- // Phase 6C: capture the prior content (if any) BEFORE writing the new
743
- // asset. We use the resolved write target to compute the exact path the
744
- // asset would land at — same resolver `writeAssetToSource` uses — so the
745
- // backup always mirrors what would be overwritten.
746
- let backupContent;
747
- try {
748
- const targetFilePath = resolveAssetFilePathSafe(target.source, ref);
749
- if (targetFilePath && fs.existsSync(targetFilePath)) {
750
- backupContent = fs.readFileSync(targetFilePath, "utf8");
24
+ // ── Content repair ──────────────────────────────────────────────────────────
25
+ /**
26
+ * Attempt bounded, deterministic repair of mechanically-fixable defects in a
27
+ * proposal's markdown content. NEVER fabricates text only strips known-bad
28
+ * structure and applies {@link repairTruncatedDescription} to a truncated
29
+ * description when one is detected.
30
+ *
31
+ * Repairs performed (in order):
32
+ * 1. Strip body lines that restate frontmatter fields as pseudo-frontmatter
33
+ * (e.g. `**description**: …` or `when_to_use: …` in the body).
34
+ * 2. Remove stray body `---` horizontal-rule lines (leaving exactly the two
35
+ * frontmatter fences when the content has a valid frontmatter block).
36
+ * 3. Apply {@link repairTruncatedDescription} to a truncated/hanging
37
+ * `description` field in the frontmatter.
38
+ *
39
+ * Returns the repaired content string. When no repairs apply the input is
40
+ * returned byte-identical so callers can use strict equality to detect
41
+ * whether a repair actually happened.
42
+ *
43
+ * CRITICAL: This function is CONTENT-PRESERVING. Callers MUST re-validate the
44
+ * repaired output via {@link validateProposal} / {@link runProposalValidators}
45
+ * before promotion — a repair that makes things *worse* (or is simply
46
+ * insufficient) must be caught by the existing gate.
47
+ */
48
+ export function repairProposalContent(content) {
49
+ if (typeof content !== "string" || content.trim() === "")
50
+ return content;
51
+ // Determine whether the content has a frontmatter block so we know how
52
+ // many `---` fence lines are expected.
53
+ const hasFrontmatter = /^---\r?\n[\s\S]*?\r?\n---/.test(content);
54
+ // Split into lines for structural repairs.
55
+ const lines = content.split(/\r?\n/);
56
+ // Track whether we are inside the opening frontmatter block so we can
57
+ // leave it untouched and only repair the body.
58
+ let inFrontmatter = false;
59
+ // Frontmatter fence index tracking: first fence opens FM, second closes it.
60
+ let fmOpenSeen = false;
61
+ let fmCloseSeen = false;
62
+ const repairedLines = [];
63
+ for (const line of lines) {
64
+ const isFence = /^---\s*$/.test(line);
65
+ // Track frontmatter fences (first two `---` fences delimit the FM block).
66
+ if (isFence && !fmCloseSeen) {
67
+ if (!fmOpenSeen) {
68
+ fmOpenSeen = true;
69
+ inFrontmatter = true;
70
+ repairedLines.push(line);
71
+ continue;
72
+ }
73
+ if (inFrontmatter) {
74
+ fmCloseSeen = true;
75
+ inFrontmatter = false;
76
+ repairedLines.push(line);
77
+ continue;
78
+ }
751
79
  }
752
- }
753
- catch (err) {
754
- // Backup capture is best-effort. A failure here must not block promotion
755
- // (the user explicitly asked to accept); we surface a warning so the
756
- // missing-revert path is visible.
757
- warn(`[proposals] promoteProposal: failed to capture backup for ${id}: ${err instanceof Error ? err.message : String(err)}`);
758
- }
759
- const written = await writeAssetToSource(target.source, target.config, ref, proposal.payload.content);
760
- // 0.9.0 (issue #507): single batch commit at the write boundary for git
761
- // targets. No-op for filesystem/primary-stash targets.
762
- commitWriteTargetBoundary(target, `Update ${formatRefForMessage(ref)}`);
763
- const archived = archiveProposal(stashDir, id, "accepted", undefined, ctx);
764
- // Persist the backup content on the archived proposal record so the revert
765
- // flow can restore the prior asset state.
766
- if (backupContent !== undefined) {
767
- const withBackup = { ...archived, backupContent };
768
- withProposalsDb(stashDir, ctx, (db) => upsertProposal(db, withBackup, stashDir));
769
- return { proposal: withBackup, assetPath: written.path, ref: written.ref };
770
- }
771
- return { proposal: archived, assetPath: written.path, ref: written.ref };
772
- }
773
- /**
774
- * Restore the prior content of an accepted proposal from the backup captured
775
- * at promotion time (Advantage D6c / Phase 6C).
776
- *
777
- * Pre-conditions:
778
- * - `id` resolves to a proposal with `status === "accepted"`.
779
- * - The proposal carries `backupContent` (captured by promoteProposal when
780
- * the target asset existed before the write).
781
- *
782
- * On success:
783
- * - The backup content is written back through {@link writeAssetToSource},
784
- * so the canonical write-dispatch invariant is preserved.
785
- * - The proposal record is updated to `status: "reverted"`.
786
- * - Caller emits a `proposal_reverted` event in the CLI layer (mirrors how
787
- * `promoted` / `rejected` are emitted by the CLI command, not the core).
788
- *
789
- * Errors are thrown as `UsageError` / `NotFoundError` so the CLI can map them
790
- * cleanly to exit codes — see `src/commands/proposal/proposal.ts` for the
791
- * wrapper.
792
- */
793
- export async function revertProposal(stashDir, config, id, options = {}, ctx) {
794
- const proposal = getProposal(stashDir, id, ctx);
795
- if (proposal.status !== "accepted") {
796
- throw new UsageError(`only accepted proposals can be reverted (proposal ${id} status: ${proposal.status})`, "INVALID_FLAG_VALUE");
797
- }
798
- if (proposal.backupContent === undefined) {
799
- throw new UsageError(`no backup available for this proposal (id: ${id})`, "MISSING_REQUIRED_ARGUMENT", "Backups are only captured when a proposal overwrites an existing asset — new-asset proposals cannot be reverted via this path; delete the asset directly instead.");
800
- }
801
- const ref = parseAssetRef(proposal.ref);
802
- if (!TYPE_DIRS[ref.type]) {
803
- throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
804
- }
805
- const target = resolveWriteTarget(config, options.target);
806
- const written = await writeAssetToSource(target.source, target.config, ref, proposal.backupContent);
807
- // 0.9.0 (issue #507): single batch commit at the write boundary for git
808
- // targets. No-op for filesystem/primary-stash targets.
809
- commitWriteTargetBoundary(target, `Revert ${formatRefForMessage(ref)}`);
810
- // Update the proposal record to status: "reverted" and bump updatedAt +
811
- // review so the audit trail reflects the second decision.
812
- const now = nowIso(ctx);
813
- const reverted = {
814
- ...proposal,
815
- status: "reverted",
816
- updatedAt: now,
817
- review: {
818
- outcome: "rejected",
819
- reason: "reverted: prior content restored from backup",
820
- decidedAt: now,
821
- },
822
- };
823
- withProposalsDb(stashDir, ctx, (db) => upsertProposal(db, reverted, stashDir));
824
- return { proposal: reverted, assetPath: written.path, ref: written.ref };
825
- }
826
- /**
827
- * Compute a diff between a proposal payload and the existing on-disk asset.
828
- * Uses {@link resolveWriteTarget} to find where the asset would land — so the
829
- * diff matches exactly what `accept` will write. Falls back to "new asset"
830
- * when no asset is currently materialised at the target ref.
831
- */
832
- export function diffProposal(stashDir, config, id, options = {}, ctx) {
833
- const proposal = getProposal(stashDir, id, ctx);
834
- const ref = parseAssetRef(proposal.ref);
835
- let targetPath;
836
- let existing = null;
837
- try {
838
- const target = resolveWriteTarget(config, options.target);
839
- targetPath = resolveAssetFilePathSafe(target.source, ref);
840
- if (targetPath && fs.existsSync(targetPath)) {
841
- existing = fs.readFileSync(targetPath, "utf8");
80
+ // We are now in the body (past the frontmatter or no frontmatter).
81
+ if (inFrontmatter) {
82
+ // Still inside the frontmatter keep as-is.
83
+ repairedLines.push(line);
84
+ continue;
842
85
  }
843
- }
844
- catch {
845
- // No writable target configured — still return a "new asset" diff so
846
- // callers can see the proposed payload without erroring out.
847
- }
848
- const proposed = proposal.payload.content;
849
- if (existing === null) {
850
- return {
851
- existing: null,
852
- proposed,
853
- unified: formatNewAssetDiff(proposal.ref, proposed),
854
- isNew: true,
855
- ...(targetPath ? { targetPath } : {}),
856
- };
857
- }
858
- return {
859
- existing,
860
- proposed,
861
- unified: formatUnifiedDiff(existing, proposed, proposal.ref),
862
- isNew: false,
863
- ...(targetPath ? { targetPath } : {}),
864
- };
865
- }
866
- function resolveAssetFilePathSafe(source, ref) {
867
- const typeDir = TYPE_DIRS[ref.type];
868
- if (!typeDir)
869
- return undefined;
870
- const typeRoot = path.join(source.path, typeDir);
871
- try {
872
- return resolveAssetPathFromName(ref.type, typeRoot, ref.name);
873
- }
874
- catch {
875
- return undefined;
876
- }
877
- }
878
- /**
879
- * Minimal unified-diff renderer. We deliberately avoid pulling a runtime
880
- * dependency just for this — proposals diffs are usually small (a single
881
- * lesson / skill file), so the LCS-free greedy renderer below is plenty for
882
- * humans to review. The output mirrors `git diff --no-index` for the first
883
- * `@@ … @@` hunk: enough to be familiar, not so detailed that we re-implement
884
- * a full LCS table.
885
- */
886
- export function formatUnifiedDiff(left, right, label) {
887
- if (left === right)
888
- return "";
889
- const leftLines = left.split("\n");
890
- const rightLines = right.split("\n");
891
- const lines = [`--- ${label} (existing)`, `+++ ${label} (proposed)`];
892
- // Pad to the longer side so alignment is one-to-one. Real diff tools use
893
- // LCS to align matching runs; we don't need that fidelity for a review
894
- // surface — both halves are visible regardless.
895
- const max = Math.max(leftLines.length, rightLines.length);
896
- lines.push(`@@ 1,${leftLines.length} 1,${rightLines.length} @@`);
897
- for (let i = 0; i < max; i += 1) {
898
- const l = leftLines[i];
899
- const r = rightLines[i];
900
- if (l === r && l !== undefined) {
901
- lines.push(` ${l}`);
86
+ // Repair 1: Strip pseudo-frontmatter restatements in the body.
87
+ // Matches lines like `**description**: …` or `when_to_use: …`.
88
+ if (/^\s*(\*\*|__)?\s*(description|when_to_use)\s*(\*\*|__)?\s*:/i.test(line)) {
89
+ // Drop the line it is a structural defect, not user content.
902
90
  continue;
903
91
  }
904
- if (l !== undefined)
905
- lines.push(`-${l}`);
906
- if (r !== undefined)
907
- lines.push(`+${r}`);
908
- }
909
- return lines.join("\n");
910
- }
911
- function formatNewAssetDiff(ref, content) {
912
- const lines = [`--- /dev/null`, `+++ ${ref} (proposed, new asset)`];
913
- lines.push(`@@ 0,0 1,${content.split("\n").length} @@`);
914
- for (const line of content.split("\n")) {
915
- lines.push(`+${line}`);
92
+ // Repair 2: Remove stray `---` horizontal-rule lines in the body.
93
+ // We keep these only when the content has NO frontmatter (in that case
94
+ // `---` is a legitimate thematic break in plain-body content).
95
+ if (isFence && hasFrontmatter) {
96
+ // Drop: these are extra `---` fences beyond the two frontmatter delimiters.
97
+ continue;
98
+ }
99
+ repairedLines.push(line);
100
+ }
101
+ let repaired = repairedLines.join("\n");
102
+ // Repair 3: Apply repairTruncatedDescription to the description field.
103
+ // We operate on the raw text rather than re-parsing YAML to avoid
104
+ // reformatting unrelated frontmatter keys.
105
+ if (hasFrontmatter) {
106
+ // Extract the body text (after the second `---`) so we can pass it to
107
+ // repairTruncatedDescription as context for the swap-in heuristic.
108
+ const bodyMatch = repaired.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?([\s\S]*)$/);
109
+ const bodyText = bodyMatch?.[1] ?? "";
110
+ repaired = repaired.replace(/^(description:\s*)(.*?)(\r?\n)/m, (_match, prefix, rawDesc, nl) => {
111
+ const fixed = repairTruncatedDescription(rawDesc.trim(), bodyText);
112
+ return `${prefix}${fixed}${nl}`;
113
+ });
916
114
  }
917
- return lines.join("\n");
115
+ return repaired;
918
116
  }