akm-cli 0.9.0-beta.6 → 0.9.0-rc.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 (326) hide show
  1. package/CHANGELOG.md +663 -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/stash-skeleton/facts/conventions/assets/agent.md +38 -0
  31. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +38 -0
  32. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +39 -0
  33. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +40 -0
  34. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +43 -0
  35. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +38 -0
  36. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +43 -0
  37. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +40 -0
  38. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +43 -0
  39. package/dist/assets/templates/html/health.html +281 -111
  40. package/dist/assets/wiki/ingest-workflow-template.md +45 -16
  41. package/dist/assets/wiki/schema-template.md +4 -4
  42. package/dist/cli/clack.js +56 -0
  43. package/dist/cli/config-migrate.js +7 -1
  44. package/dist/cli/confirm.js +1 -1
  45. package/dist/cli/parse-args.js +46 -1
  46. package/dist/cli/shared.js +28 -0
  47. package/dist/cli.js +25 -14
  48. package/dist/commands/agent/agent-dispatch.js +3 -2
  49. package/dist/commands/agent/agent-support.js +0 -7
  50. package/dist/commands/agent/contribute-cli.js +26 -7
  51. package/dist/commands/config-cli.js +26 -13
  52. package/dist/commands/env/child-env.js +47 -0
  53. package/dist/commands/env/env-cli.js +220 -227
  54. package/dist/commands/env/env.js +14 -67
  55. package/dist/commands/env/secret-cli.js +140 -138
  56. package/dist/commands/feedback-cli.js +153 -147
  57. package/dist/commands/graph/graph-cli.js +5 -13
  58. package/dist/commands/graph/graph.js +76 -72
  59. package/dist/commands/health/advisories.js +151 -0
  60. package/dist/commands/health/checks.js +103 -16
  61. package/dist/commands/health/html-report.js +447 -81
  62. package/dist/commands/health/improve-metrics.js +771 -0
  63. package/dist/commands/health/llm-usage.js +65 -0
  64. package/dist/commands/health/md-report.js +103 -0
  65. package/dist/commands/health/metrics.js +278 -0
  66. package/dist/commands/health/stash-exposure.js +46 -0
  67. package/dist/commands/health/surfaces.js +216 -0
  68. package/dist/commands/health/task-runs.js +135 -0
  69. package/dist/commands/health/types.js +26 -0
  70. package/dist/commands/health/windows.js +195 -0
  71. package/dist/commands/health.js +91 -1083
  72. package/dist/commands/improve/anti-collapse.js +170 -0
  73. package/dist/commands/improve/calibration.js +161 -0
  74. package/dist/commands/improve/collapse-detector.js +421 -0
  75. package/dist/commands/improve/consolidate/chunking.js +141 -0
  76. package/dist/commands/improve/consolidate/eligibility.js +64 -0
  77. package/dist/commands/improve/consolidate/merge.js +145 -0
  78. package/dist/commands/improve/consolidate/sanitize.js +231 -0
  79. package/dist/commands/{lint.js → improve/consolidate/types.js} +1 -1
  80. package/dist/commands/improve/consolidate.js +1313 -1278
  81. package/dist/commands/improve/dedup.js +482 -0
  82. package/dist/commands/improve/distill/content-repair.js +202 -0
  83. package/dist/commands/improve/distill/promote-memory.js +229 -0
  84. package/dist/commands/improve/distill/quality-gate.js +236 -0
  85. package/dist/commands/improve/distill-guards.js +127 -0
  86. package/dist/commands/improve/distill-promotion-policy.js +826 -167
  87. package/dist/commands/improve/distill.js +243 -599
  88. package/dist/commands/improve/eligibility.js +434 -0
  89. package/dist/commands/improve/encoding-salience.js +205 -0
  90. package/dist/commands/improve/extract-cli.js +179 -59
  91. package/dist/commands/improve/extract-prompt.js +55 -4
  92. package/dist/commands/improve/extract-watch.js +140 -0
  93. package/dist/commands/improve/extract.js +409 -43
  94. package/dist/commands/improve/feedback-valence.js +54 -0
  95. package/dist/commands/improve/hot-probation.js +45 -0
  96. package/dist/commands/improve/improve-auto-accept.js +160 -7
  97. package/dist/commands/improve/improve-cli.js +115 -73
  98. package/dist/commands/improve/improve-profiles.js +32 -8
  99. package/dist/commands/improve/improve-result-file.js +15 -25
  100. package/dist/commands/improve/improve-session.js +58 -0
  101. package/dist/commands/improve/improve.js +510 -2537
  102. package/dist/commands/improve/locks.js +154 -0
  103. package/dist/commands/improve/loop-stages.js +1100 -0
  104. package/dist/commands/improve/memory/memory-belief.js +14 -15
  105. package/dist/commands/improve/memory/memory-contradiction-detect.js +83 -60
  106. package/dist/commands/improve/memory/memory-improve.js +27 -27
  107. package/dist/commands/improve/outcome-loop.js +270 -0
  108. package/dist/commands/improve/preparation.js +2002 -0
  109. package/dist/commands/improve/proactive-maintenance.js +115 -0
  110. package/dist/commands/improve/procedural.js +398 -0
  111. package/dist/commands/improve/recombine.js +818 -0
  112. package/dist/commands/improve/reflect-noise.js +0 -0
  113. package/dist/commands/improve/reflect.js +212 -45
  114. package/dist/commands/improve/salience.js +455 -0
  115. package/dist/commands/improve/schema-similarity-gate.js +168 -0
  116. package/dist/commands/improve/shared.js +51 -0
  117. package/dist/commands/improve/triage.js +93 -0
  118. package/dist/commands/lint/agent-linter.js +19 -24
  119. package/dist/commands/lint/base-linter.js +173 -60
  120. package/dist/commands/lint/command-linter.js +19 -24
  121. package/dist/commands/lint/env-key-rules.js +38 -1
  122. package/dist/commands/lint/fact-linter.js +39 -0
  123. package/dist/commands/lint/index.js +31 -13
  124. package/dist/commands/lint/memory-linter.js +1 -1
  125. package/dist/commands/lint/registry.js +7 -2
  126. package/dist/commands/lint/task-linter.js +3 -3
  127. package/dist/commands/lint/workflow-linter.js +26 -1
  128. package/dist/commands/observability-cli.js +4 -4
  129. package/dist/commands/proposal/drain-policies.js +13 -4
  130. package/dist/commands/proposal/drain.js +45 -51
  131. package/dist/commands/proposal/legacy-import.js +115 -0
  132. package/dist/commands/proposal/proposal-cli.js +24 -34
  133. package/dist/commands/proposal/proposal.js +7 -1
  134. package/dist/commands/proposal/propose.js +8 -3
  135. package/dist/commands/proposal/repository.js +829 -0
  136. package/dist/commands/proposal/validators/proposal-quality-validators.js +9 -8
  137. package/dist/commands/proposal/validators/proposals.js +93 -882
  138. package/dist/commands/read/curate.js +419 -103
  139. package/dist/commands/read/knowledge.js +10 -3
  140. package/dist/commands/read/remember-cli.js +133 -138
  141. package/dist/commands/read/search-cli.js +15 -8
  142. package/dist/commands/read/search.js +22 -11
  143. package/dist/commands/read/show.js +106 -14
  144. package/dist/commands/registry-cli.js +76 -87
  145. package/dist/commands/remember.js +11 -12
  146. package/dist/commands/sources/add-cli.js +91 -95
  147. package/dist/commands/sources/history.js +1 -1
  148. package/dist/commands/sources/init.js +66 -18
  149. package/dist/commands/sources/installed-stashes.js +11 -3
  150. package/dist/commands/sources/schema-repair.js +44 -46
  151. package/dist/commands/sources/self-update.js +2 -2
  152. package/dist/commands/sources/source-add.js +7 -3
  153. package/dist/commands/sources/sources-cli.js +3 -3
  154. package/dist/commands/sources/stash-cli.js +29 -41
  155. package/dist/commands/sources/stash-skeleton.js +57 -8
  156. package/dist/commands/tasks/default-tasks.js +15 -2
  157. package/dist/commands/tasks/tasks-cli.js +20 -29
  158. package/dist/commands/tasks/tasks.js +39 -11
  159. package/dist/commands/wiki-cli.js +23 -38
  160. package/dist/commands/workflow-cli.js +15 -1
  161. package/dist/core/asset/asset-registry.js +3 -1
  162. package/dist/core/asset/asset-spec.js +21 -4
  163. package/dist/core/asset/frontmatter.js +188 -167
  164. package/dist/core/asset/markdown.js +8 -0
  165. package/dist/core/authoring-rules.js +92 -0
  166. package/dist/core/common.js +4 -23
  167. package/dist/core/concurrent.js +10 -1
  168. package/dist/core/config/config-io.js +10 -1
  169. package/dist/core/config/config-migration.js +18 -40
  170. package/dist/core/config/config-schema.js +389 -58
  171. package/dist/core/config/config-types.js +3 -3
  172. package/dist/core/config/config.js +67 -22
  173. package/dist/core/deep-merge.js +38 -0
  174. package/dist/core/errors.js +1 -0
  175. package/dist/core/eval/rank-metrics.js +113 -0
  176. package/dist/core/events.js +4 -7
  177. package/dist/core/improve-types.js +47 -8
  178. package/dist/core/logs-db.js +14 -75
  179. package/dist/core/parse.js +36 -16
  180. package/dist/core/paths.js +21 -18
  181. package/dist/core/standards/resolve-standards-context.js +87 -0
  182. package/dist/core/standards/resolve-stash-standards.js +99 -0
  183. package/dist/core/standards/resolve-type-conventions.js +66 -0
  184. package/dist/core/state/migrations.js +770 -0
  185. package/dist/core/state-db.js +142 -1091
  186. package/dist/core/structured.js +69 -0
  187. package/dist/core/time.js +53 -0
  188. package/dist/core/warn.js +21 -0
  189. package/dist/core/write-source.js +37 -0
  190. package/dist/indexer/db/db.js +356 -780
  191. package/dist/indexer/db/entry-mapper.js +41 -0
  192. package/dist/indexer/db/graph-db.js +129 -86
  193. package/dist/indexer/db/llm-cache.js +2 -2
  194. package/dist/indexer/db/schema.js +516 -0
  195. package/dist/indexer/ensure-index.js +103 -24
  196. package/dist/indexer/feedback/utility-policy.js +75 -0
  197. package/dist/indexer/graph/graph-boost.js +51 -41
  198. package/dist/indexer/graph/graph-extraction.js +207 -4
  199. package/dist/indexer/index-writer-lock.js +106 -0
  200. package/dist/indexer/index-written-assets.js +105 -0
  201. package/dist/indexer/indexer.js +291 -310
  202. package/dist/indexer/passes/dir-staleness.js +114 -0
  203. package/dist/indexer/passes/memory-inference.js +13 -5
  204. package/dist/indexer/passes/metadata.js +20 -0
  205. package/dist/indexer/read-preflight.js +23 -0
  206. package/dist/indexer/search/db-search.js +89 -13
  207. package/dist/indexer/search/fts-query.js +51 -0
  208. package/dist/indexer/search/ranking-contributors.js +95 -9
  209. package/dist/indexer/search/ranking.js +79 -3
  210. package/dist/indexer/search/search-fields.js +6 -0
  211. package/dist/indexer/search/search-source.js +32 -21
  212. package/dist/indexer/search/semantic-status.js +4 -0
  213. package/dist/indexer/walk/matchers.js +9 -0
  214. package/dist/indexer/walk/walker.js +21 -13
  215. package/dist/integrations/agent/builders.js +39 -13
  216. package/dist/integrations/agent/config.js +20 -59
  217. package/dist/integrations/agent/detect.js +9 -0
  218. package/dist/integrations/agent/index.js +3 -19
  219. package/dist/integrations/agent/model-aliases.js +7 -2
  220. package/dist/integrations/agent/profiles.js +7 -1
  221. package/dist/integrations/agent/prompts.js +75 -9
  222. package/dist/integrations/agent/runner-dispatch.js +59 -0
  223. package/dist/integrations/agent/runner.js +13 -9
  224. package/dist/integrations/agent/spawn.js +69 -67
  225. package/dist/integrations/harnesses/claude/agent-builder.js +1 -1
  226. package/dist/integrations/harnesses/claude/index.js +2 -0
  227. package/dist/integrations/harnesses/claude/session-log.js +11 -1
  228. package/dist/integrations/harnesses/index.js +2 -3
  229. package/dist/integrations/harnesses/opencode/agent-builder.js +1 -1
  230. package/dist/integrations/harnesses/opencode/index.js +2 -0
  231. package/dist/integrations/harnesses/opencode/session-log.js +173 -3
  232. package/dist/integrations/harnesses/opencode-sdk/index.js +2 -2
  233. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +98 -17
  234. package/dist/integrations/harnesses/types.js +1 -0
  235. package/dist/integrations/session-logs/index.js +16 -0
  236. package/dist/llm/call-ai.js +2 -2
  237. package/dist/llm/client.js +57 -15
  238. package/dist/llm/embedder.js +67 -4
  239. package/dist/llm/embedders/cache.js +3 -1
  240. package/dist/llm/embedders/deterministic.js +66 -0
  241. package/dist/llm/embedders/local.js +73 -3
  242. package/dist/llm/feature-gate.js +16 -15
  243. package/dist/llm/graph-extract.js +67 -44
  244. package/dist/llm/memory-infer-impl.js +138 -0
  245. package/dist/llm/memory-infer.js +1 -127
  246. package/dist/llm/metadata-enhance.js +44 -31
  247. package/dist/llm/structured-call.js +49 -0
  248. package/dist/migrate-storage-node.mjs +8 -0
  249. package/dist/output/context.js +5 -5
  250. package/dist/output/renderers.js +85 -14
  251. package/dist/output/shapes/curate.js +14 -2
  252. package/dist/output/shapes/helpers.js +0 -3
  253. package/dist/output/shapes/passthrough.js +2 -1
  254. package/dist/output/text/helpers.js +29 -1
  255. package/dist/output/text/workflow.js +1 -0
  256. package/dist/registry/providers/skills-sh.js +21 -147
  257. package/dist/registry/providers/static-index.js +15 -157
  258. package/dist/registry/resolve.js +27 -9
  259. package/dist/runtime.js +25 -1
  260. package/dist/scripts/migrate-storage.js +2718 -2354
  261. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +891 -597
  262. package/dist/setup/detect.js +9 -0
  263. package/dist/setup/legacy-config.js +106 -0
  264. package/dist/setup/prompt.js +57 -0
  265. package/dist/setup/providers.js +14 -0
  266. package/dist/setup/registry-stash-loader.js +12 -0
  267. package/dist/setup/semantic-assets.js +124 -0
  268. package/dist/setup/setup.js +52 -1614
  269. package/dist/setup/steps/connection.js +734 -0
  270. package/dist/setup/steps/output.js +31 -0
  271. package/dist/setup/steps/platforms.js +124 -0
  272. package/dist/setup/steps/semantic.js +27 -0
  273. package/dist/setup/steps/sources.js +222 -0
  274. package/dist/setup/steps/stashdir.js +42 -0
  275. package/dist/setup/steps/tasks.js +152 -0
  276. package/dist/sources/include.js +6 -2
  277. package/dist/sources/providers/filesystem.js +0 -1
  278. package/dist/sources/providers/git-install.js +210 -0
  279. package/dist/sources/providers/git-provider.js +234 -0
  280. package/dist/sources/providers/git-stash.js +248 -0
  281. package/dist/sources/providers/git.js +10 -661
  282. package/dist/sources/providers/npm.js +2 -6
  283. package/dist/sources/providers/provider-utils.js +13 -7
  284. package/dist/sources/providers/sync-from-ref.js +9 -1
  285. package/dist/sources/providers/tar-utils.js +16 -8
  286. package/dist/sources/providers/website.js +9 -5
  287. package/dist/sources/website-ingest.js +187 -29
  288. package/dist/sources/wiki-fetchers/registry.js +53 -0
  289. package/dist/sources/wiki-fetchers/youtube.js +239 -0
  290. package/dist/storage/database.js +45 -10
  291. package/dist/storage/managed-db.js +82 -0
  292. package/dist/storage/repositories/canaries-repository.js +107 -0
  293. package/dist/storage/repositories/consolidation-repository.js +38 -0
  294. package/dist/storage/repositories/embeddings-repository.js +72 -0
  295. package/dist/storage/repositories/events-repository.js +187 -0
  296. package/dist/storage/repositories/extract-sessions-repository.js +96 -0
  297. package/dist/storage/repositories/improve-runs-repository.js +146 -0
  298. package/dist/storage/repositories/index-db.js +14 -8
  299. package/dist/storage/repositories/proposals-repository.js +220 -0
  300. package/dist/storage/repositories/recombine-repository.js +213 -0
  301. package/dist/storage/repositories/registry-cache.js +93 -0
  302. package/dist/storage/repositories/registry-index-cache-repository.js +46 -0
  303. package/dist/storage/repositories/task-history-repository.js +93 -0
  304. package/dist/storage/sqlite-pragmas.js +146 -0
  305. package/dist/tasks/backends/cron.js +1 -1
  306. package/dist/tasks/backends/index.js +9 -0
  307. package/dist/tasks/backends/launchd.js +1 -1
  308. package/dist/tasks/backends/schtasks.js +1 -1
  309. package/dist/tasks/{resolveAkmBin.js → resolve-akm-bin.js} +2 -2
  310. package/dist/tasks/runner.js +15 -13
  311. package/dist/text-import-hook.mjs +0 -0
  312. package/dist/wiki/wiki.js +52 -11
  313. package/dist/workflows/cli.js +1 -0
  314. package/dist/workflows/db.js +3 -4
  315. package/dist/workflows/runtime/runs.js +43 -118
  316. package/dist/workflows/runtime/workflow-asset-loader.js +125 -0
  317. package/dist/workflows/validate-summary.js +2 -7
  318. package/docs/README.md +69 -18
  319. package/docs/data-and-telemetry.md +5 -4
  320. package/docs/migration/release-notes/0.7.0.md +1 -1
  321. package/docs/migration/release-notes/0.9.0.md +39 -0
  322. package/package.json +10 -10
  323. package/dist/assets/tasks/core/update-stashes.yml +0 -4
  324. package/dist/commands/db-cli.js +0 -23
  325. package/dist/indexer/db/db-backup.js +0 -376
  326. package/dist/indexer/passes/staleness-detect.js +0 -488
@@ -2,693 +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, } 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
- if (!input.force) {
338
- const skip = checkDedupAndCooldown(db, stashDir, normalizedRef, input, ctx);
339
- if (skip)
340
- return skip;
341
- }
342
- const created = nowIso(ctx);
343
- // Phase 6A: validate confidence is a finite number in [0, 1]. Anything else
344
- // is dropped silently — we never store NaN, Infinity, or out-of-range values.
345
- // Callers that mis-report confidence should not poison the auto-accept gate.
346
- const sanitizedConfidence = typeof input.confidence === "number" &&
347
- Number.isFinite(input.confidence) &&
348
- input.confidence >= 0 &&
349
- input.confidence <= 1
350
- ? input.confidence
351
- : undefined;
352
- const proposal = {
353
- id: newId(ctx),
354
- ref: normalizedRef,
355
- status: "pending",
356
- source: input.source,
357
- ...(input.sourceRun !== undefined ? { sourceRun: input.sourceRun } : {}),
358
- createdAt: created,
359
- updatedAt: created,
360
- payload: {
361
- content: input.payload.content,
362
- ...(input.payload.frontmatter !== undefined ? { frontmatter: input.payload.frontmatter } : {}),
363
- },
364
- ...(sanitizedConfidence !== undefined ? { confidence: sanitizedConfidence } : {}),
365
- };
366
- upsertProposal(db, proposal, stashDir);
367
- return proposal;
368
- });
369
- }
370
- /**
371
- * Evaluate the F-2 dedup / cooldown guards against the store. Returns the
372
- * skip record when a guard fires, or undefined when the create may proceed.
373
- */
374
- function checkDedupAndCooldown(db, stashDir, normalizedRef, input, ctx) {
375
- const newHash = contentHash(input.payload.content);
376
- const nowMs = (ctx?.now ?? Date.now)();
377
- const cooldownMs = cooldownMsForSource(input.source);
378
- // Scan pending proposals for ref+source matches.
379
- const pending = listStateProposals(db, { stashDir, ref: normalizedRef, status: "pending" }).filter((p) => p.source === input.source);
380
- if (pending.length > 0) {
381
- // Check for identical content hash first (silent skip).
382
- const hashMatch = pending.find((p) => contentHash(p.payload.content) === newHash);
383
- if (hashMatch) {
384
- return {
385
- skipped: true,
386
- reason: "content_hash_match",
387
- message: `Identical proposal for ${normalizedRef} already pending (id: ${hashMatch.id}).`,
388
- existingProposalId: hashMatch.id,
389
- };
390
- }
391
- // Duplicate pending for same ref+source (different content).
392
- const firstPending = pending[0];
393
- return {
394
- skipped: true,
395
- reason: "duplicate_pending",
396
- message: `A pending proposal for ${normalizedRef} from source "${input.source}" already exists (id: ${firstPending?.id ?? "unknown"}). Pass force:true to enqueue alongside it.`,
397
- existingProposalId: firstPending?.id,
398
- };
399
- }
400
- // Check cooldown against recently rejected proposals.
401
- const rejected = listStateProposals(db, { stashDir, ref: normalizedRef, status: "rejected" })
402
- .filter((p) => p.source === input.source)
403
- .sort((a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime());
404
- const mostRecent = rejected[0];
405
- if (mostRecent !== undefined) {
406
- // Check content hash against recently rejected.
407
- if (contentHash(mostRecent.payload.content) === newHash) {
408
- return {
409
- skipped: true,
410
- reason: "content_hash_match",
411
- message: `Identical proposal for ${normalizedRef} was already rejected (id: ${mostRecent.id}).`,
412
- existingProposalId: mostRecent.id,
413
- };
414
- }
415
- // Check cooldown window.
416
- const rejectedAt = new Date(mostRecent.updatedAt ?? 0).getTime();
417
- if (nowMs - rejectedAt < cooldownMs) {
418
- const cooldownDays = cooldownMs / MS_PER_DAY;
419
- const remainingDays = Math.ceil((cooldownMs - (nowMs - rejectedAt)) / MS_PER_DAY);
420
- return {
421
- skipped: true,
422
- reason: "cooldown",
423
- message: `Proposal for ${normalizedRef} from source "${input.source}" is in cooldown ` +
424
- `(${cooldownDays}d window, ~${remainingDays}d remaining). Pass force:true to bypass.`,
425
- existingProposalId: mostRecent.id,
426
- };
427
- }
428
- }
429
- return undefined;
430
- }
431
- /**
432
- * List proposals for one stash. By default returns only the live (pending)
433
- * queue; pass `{ includeArchive: true }` to include accepted / rejected /
434
- * reverted entries as well.
435
- */
436
- export function listProposals(stashDir, options = {}, ctx) {
437
- return withProposalsDb(stashDir, ctx, (db) => {
438
- // Without includeArchive, only the live queue is visible — an explicit
439
- // non-pending status filter therefore matches nothing (mirrors the
440
- // historical live-directory scan).
441
- if (!options.includeArchive && options.status !== undefined && options.status !== "pending") {
442
- return [];
443
- }
444
- const status = options.includeArchive ? options.status : "pending";
445
- return listStateProposals(db, {
446
- stashDir,
447
- ...(status !== undefined ? { status } : {}),
448
- ...(options.ref !== undefined ? { ref: options.ref } : {}),
449
- }).filter((p) => {
450
- if (!options.type)
451
- return true;
452
- try {
453
- return parseAssetRef(p.ref).type === options.type;
454
- }
455
- catch {
456
- return false;
457
- }
458
- });
459
- });
460
- }
461
- /**
462
- * Look up a proposal by id (live or archived).
463
- * Throws `NotFoundError` when no match exists in this stash.
464
- */
465
- export function getProposal(stashDir, id, ctx) {
466
- return withProposalsDb(stashDir, ctx, (db) => requireProposal(db, stashDir, id));
467
- }
468
- function requireProposal(db, stashDir, id) {
469
- const proposal = getStateProposal(db, id, stashDir);
470
- if (!proposal) {
471
- throw new NotFoundError(`Proposal "${id}" not found.`, "FILE_NOT_FOUND");
472
- }
473
- return proposal;
474
- }
475
- /**
476
- * Resolve a proposal by full UUID, UUID prefix, or asset ref.
477
- *
478
- * Resolution order:
479
- * 1. Exact UUID match (existing behaviour).
480
- * 2. Asset ref (contains `:`) — finds the most-recent pending proposal for
481
- * that ref; falls back to archived if nothing is pending.
482
- * 3. UUID prefix — matches any PENDING proposal whose id starts with the
483
- * given string; throws if ambiguous.
484
- */
485
- export function resolveProposalId(stashDir, idOrRef, ctx) {
486
- return withProposalsDb(stashDir, ctx, (db) => {
487
- // 1. Exact UUID.
488
- const exact = getStateProposal(db, idOrRef, stashDir);
489
- if (exact)
490
- return exact;
491
- // 2. Asset ref (e.g. "skill:akm-dream") — most recent pending, else most
492
- // recent archived.
493
- if (idOrRef.includes(":")) {
494
- const byRecency = (proposals) => proposals.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime())[0];
495
- const pending = byRecency(listStateProposals(db, { stashDir, ref: idOrRef, status: "pending" }));
496
- if (pending)
497
- return pending;
498
- const archived = byRecency(listStateProposals(db, { stashDir, ref: idOrRef }));
499
- if (archived)
500
- return archived;
501
- throw new NotFoundError(`No proposal found for ref "${idOrRef}".`, "FILE_NOT_FOUND");
502
- }
503
- // 3. UUID prefix (pending queue only).
504
- const prefixMatches = listStateProposalIdsByPrefix(db, stashDir, idOrRef);
505
- if (prefixMatches.length === 1)
506
- return requireProposal(db, stashDir, prefixMatches[0]);
507
- if (prefixMatches.length > 1) {
508
- throw new UsageError(`Ambiguous prefix "${idOrRef}" — matches: ${prefixMatches.join(", ")}`, "INVALID_FLAG_VALUE");
509
- }
510
- throw new NotFoundError(`Proposal "${idOrRef}" not found.`, "FILE_NOT_FOUND");
511
- });
512
- }
513
- /**
514
- * Archive a proposal: flip its status to `accepted` / `rejected`, bump
515
- * `updatedAt`, and record the review block. Used by both accept and reject
516
- * paths so the live queue only contains pending entries.
517
- */
518
- export function archiveProposal(stashDir, id, status, reason, ctx) {
519
- return withProposalsDb(stashDir, ctx, (db) => {
520
- const existing = requireProposal(db, stashDir, id);
521
- const updated = {
522
- ...existing,
523
- status,
524
- updatedAt: nowIso(ctx),
525
- review: {
526
- outcome: status,
527
- ...(reason !== undefined ? { reason } : {}),
528
- decidedAt: nowIso(ctx),
529
- },
530
- };
531
- upsertProposal(db, updated, stashDir);
532
- return updated;
533
- });
534
- }
535
- /**
536
- * Record an automated gate's decision onto a proposal (#577).
537
- *
538
- * Stamps `gateDecision` (decision / reason / confidence / thresholds) onto the
539
- * row so `akm proposal show` and `list` can explain why a proposal landed where
540
- * it did. The decision is metadata about the adjudication, so this does NOT
541
- * change `status` or bump `updatedAt` — a `deferred` proposal stays `pending`,
542
- * and the accept / reject status flips are owned by {@link promoteProposal} /
543
- * {@link archiveProposal}. `decidedAt` defaults to now when the caller omits it.
544
- *
545
- * Best-effort: a proposal that no longer exists (e.g. concurrently archived) is
546
- * skipped silently rather than throwing, so a gate run never aborts mid-batch.
547
- * Returns the updated proposal, or undefined when no matching row exists.
548
- */
549
- export function recordGateDecision(stashDir, id, decision, ctx) {
550
- return withProposalsDb(stashDir, ctx, (db) => {
551
- const existing = getStateProposal(db, id, stashDir);
552
- if (!existing)
553
- return undefined;
554
- const updated = {
555
- ...existing,
556
- gateDecision: { ...decision, decidedAt: decision.decidedAt ?? nowIso(ctx) },
557
- };
558
- upsertProposal(db, updated, stashDir);
559
- return updated;
560
- });
561
- }
562
- /**
563
- * Scan all pending proposals and reject those whose target asset no longer
564
- * exists on disk across any of `sourceDirs`. Intended to run as a periodic
565
- * maintenance pass (see `runImproveMaintenancePasses`) — it keeps the queue
566
- * from accumulating stale reviewer work after large refactors or deletes.
567
- *
568
- * Scope rule: only `source=reflect` proposals are subject to orphan rejection.
569
- * Lessons, propose, distill, and consolidate proposals legitimately target
570
- * assets that don't exist yet and must never be purged.
571
- */
572
- export function purgeOrphanProposals(stashDir, sourceDirs, ctx) {
573
- const t0 = Date.now();
574
- const orphans = [];
575
- const byType = {};
576
- const pending = listProposals(stashDir, { status: "pending" }, ctx);
577
- const reflectPending = pending.filter((p) => p.source === "reflect");
578
- for (const p of reflectPending) {
579
- let parsed;
580
- try {
581
- parsed = parseAssetRef(p.ref);
582
- }
583
- catch {
584
- continue;
585
- }
586
- // Lessons are new-asset proposals by definition — they cannot be orphaned.
587
- if (parsed.type === "lesson")
588
- continue;
589
- const spec = TYPE_DIRS[parsed.type];
590
- if (!spec)
591
- continue;
592
- const exists = sourceDirs.some((root) => {
593
- const typeRoot = path.join(root, spec);
594
- const candidate = resolveAssetPathFromName(parsed.type, typeRoot, parsed.name);
595
- return fs.existsSync(candidate);
596
- });
597
- if (!exists) {
598
- try {
599
- archiveProposal(stashDir, p.id, "rejected", "Asset no longer exists on disk", ctx);
600
- orphans.push({ id: p.id, ref: p.ref, reason: "asset_missing" });
601
- byType[parsed.type] = (byType[parsed.type] ?? 0) + 1;
602
- }
603
- catch (err) {
604
- // Best-effort — the purge is non-fatal. Log and continue.
605
- warn(`[proposals] purgeOrphanProposals: failed to reject ${p.id}: ${err instanceof Error ? err.message : String(err)}`);
606
- }
607
- }
608
- }
609
- return {
610
- checked: reflectPending.length,
611
- rejected: orphans.length,
612
- durationMs: Date.now() - t0,
613
- byType,
614
- orphans,
615
- };
616
- }
617
- /**
618
- * Archive pending proposals older than `config.archiveRetentionDays` (Advantage
619
- * D6b / Phase 6B).
620
- *
621
- * Reviewer fatigue and queue rot are the dominant failure modes of any
622
- * human-in-the-loop pipeline (Settles 2009 active-learning survey). Pending
623
- * proposals that have aged past the retention window are very rarely accepted
624
- * — the reviewer either intentionally declined to act on them, or the asset
625
- * they target has drifted enough that the proposal is no longer relevant.
626
- * Auto-expiring them keeps the live queue focused on actionable work; the
627
- * archive preserves the full audit trail.
628
- *
629
- * Each expired proposal is archived with status `rejected` and reason
630
- * `"expired: no action within retention window"`. A `proposal_expired` event
631
- * is appended for each expired proposal so downstream observability (events
632
- * dashboards, source-acceptance-rate aggregations) can see expiry separately
633
- * from explicit rejections.
634
- *
635
- * Idempotent: a second call within the same retention window finds nothing
636
- * to expire (the archived entries are no longer in the pending queue).
637
- */
638
- export function expireStaleProposals(stashDir, config, ctx) {
639
- const t0 = Date.now();
640
- const retentionDays = config.archiveRetentionDays ?? 90;
641
- const expiredProposals = [];
642
- // retentionDays === 0 disables TTL cleanup globally (mirrors how
643
- // consolidate.ts interprets the same config value).
644
- if (retentionDays <= 0) {
645
- return {
646
- checked: 0,
647
- expired: 0,
648
- durationMs: Date.now() - t0,
649
- retentionDays,
650
- expiredProposals,
651
- };
652
- }
653
- const retentionMs = retentionDays * MS_PER_DAY;
654
- const nowMs = (ctx?.now ?? Date.now)();
655
- const pending = listProposals(stashDir, { status: "pending" }, ctx);
656
- for (const p of pending) {
657
- const createdMs = new Date(p.createdAt).getTime();
658
- if (!Number.isFinite(createdMs))
659
- continue;
660
- const ageMs = nowMs - createdMs;
661
- if (ageMs < retentionMs)
662
- continue;
663
- try {
664
- archiveProposal(stashDir, p.id, "rejected", "expired: no action within retention window", ctx);
665
- const ageDays = Math.floor(ageMs / MS_PER_DAY);
666
- expiredProposals.push({ id: p.id, ref: p.ref, ageDays });
667
- appendEvent({
668
- eventType: "proposal_expired",
669
- ref: p.ref,
670
- metadata: {
671
- proposalId: p.id,
672
- source: p.source,
673
- ...(p.sourceRun !== undefined ? { sourceRun: p.sourceRun } : {}),
674
- ageDays,
675
- retentionDays,
676
- },
677
- });
678
- }
679
- catch (err) {
680
- // Best-effort — a single failure must not block the pass.
681
- warn(`[proposals] expireStaleProposals: failed to expire ${p.id}: ${err instanceof Error ? err.message : String(err)}`);
682
- }
683
- }
684
- return {
685
- checked: pending.length,
686
- expired: expiredProposals.length,
687
- durationMs: Date.now() - t0,
688
- retentionDays,
689
- expiredProposals,
690
- };
691
- }
692
14
  /**
693
15
  * Validate a proposal payload before promotion. Generic by default — any
694
16
  * proposal must parse cleanly and carry a non-empty body. Lessons get the
@@ -699,207 +21,96 @@ export function expireStaleProposals(stashDir, config, ctx) {
699
21
  export function validateProposal(proposal) {
700
22
  return runProposalValidators(proposal);
701
23
  }
702
- /**
703
- * Validate a proposal, then promote it through the canonical
704
- * {@link writeAssetToSource} dispatch (the single place that branches on
705
- * `source.kind`). On success the proposal is archived with status `accepted`.
706
- * Validation failures throw a `UsageError` carrying every finding so the CLI
707
- * can render a single clear error envelope.
708
- *
709
- * Phase 6C: when the target asset already exists at the resolved write path,
710
- * its prior content is captured BEFORE the write and stored on the archived
711
- * proposal record (`backupContent`) so `akm proposal revert` can restore it.
712
- * Genuinely-new assets carry no backup.
713
- */
714
- export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
715
- const proposal = getProposal(stashDir, id, ctx);
716
- if (proposal.status !== "pending") {
717
- throw new UsageError(`Proposal ${id} is not pending (current status: ${proposal.status}). Only pending proposals can be accepted.`, "INVALID_FLAG_VALUE");
718
- }
719
- const report = validateProposal(proposal);
720
- if (!report.ok) {
721
- const message = report.findings.map((f) => `[${f.kind}] ${f.message}`).join("\n");
722
- 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.");
723
- }
724
- const ref = parseAssetRef(proposal.ref);
725
- if (!TYPE_DIRS[ref.type]) {
726
- throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
727
- }
728
- const target = resolveWriteTarget(config, options.target);
729
- // Phase 6C: capture the prior content (if any) BEFORE writing the new
730
- // asset. We use the resolved write target to compute the exact path the
731
- // asset would land at — same resolver `writeAssetToSource` uses — so the
732
- // backup always mirrors what would be overwritten.
733
- let backupContent;
734
- try {
735
- const targetFilePath = resolveAssetFilePathSafe(target.source, ref);
736
- if (targetFilePath && fs.existsSync(targetFilePath)) {
737
- 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
+ }
738
79
  }
739
- }
740
- catch (err) {
741
- // Backup capture is best-effort. A failure here must not block promotion
742
- // (the user explicitly asked to accept); we surface a warning so the
743
- // missing-revert path is visible.
744
- warn(`[proposals] promoteProposal: failed to capture backup for ${id}: ${err instanceof Error ? err.message : String(err)}`);
745
- }
746
- const written = await writeAssetToSource(target.source, target.config, ref, proposal.payload.content);
747
- // 0.9.0 (issue #507): single batch commit at the write boundary for git
748
- // targets. No-op for filesystem/primary-stash targets.
749
- commitWriteTargetBoundary(target, `Update ${formatRefForMessage(ref)}`);
750
- const archived = archiveProposal(stashDir, id, "accepted", undefined, ctx);
751
- // Persist the backup content on the archived proposal record so the revert
752
- // flow can restore the prior asset state.
753
- if (backupContent !== undefined) {
754
- const withBackup = { ...archived, backupContent };
755
- withProposalsDb(stashDir, ctx, (db) => upsertProposal(db, withBackup, stashDir));
756
- return { proposal: withBackup, assetPath: written.path, ref: written.ref };
757
- }
758
- return { proposal: archived, assetPath: written.path, ref: written.ref };
759
- }
760
- /**
761
- * Restore the prior content of an accepted proposal from the backup captured
762
- * at promotion time (Advantage D6c / Phase 6C).
763
- *
764
- * Pre-conditions:
765
- * - `id` resolves to a proposal with `status === "accepted"`.
766
- * - The proposal carries `backupContent` (captured by promoteProposal when
767
- * the target asset existed before the write).
768
- *
769
- * On success:
770
- * - The backup content is written back through {@link writeAssetToSource},
771
- * so the canonical write-dispatch invariant is preserved.
772
- * - The proposal record is updated to `status: "reverted"`.
773
- * - Caller emits a `proposal_reverted` event in the CLI layer (mirrors how
774
- * `promoted` / `rejected` are emitted by the CLI command, not the core).
775
- *
776
- * Errors are thrown as `UsageError` / `NotFoundError` so the CLI can map them
777
- * cleanly to exit codes — see `src/commands/proposal/proposal.ts` for the
778
- * wrapper.
779
- */
780
- export async function revertProposal(stashDir, config, id, options = {}, ctx) {
781
- const proposal = getProposal(stashDir, id, ctx);
782
- if (proposal.status !== "accepted") {
783
- throw new UsageError(`only accepted proposals can be reverted (proposal ${id} status: ${proposal.status})`, "INVALID_FLAG_VALUE");
784
- }
785
- if (proposal.backupContent === undefined) {
786
- 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.");
787
- }
788
- const ref = parseAssetRef(proposal.ref);
789
- if (!TYPE_DIRS[ref.type]) {
790
- throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
791
- }
792
- const target = resolveWriteTarget(config, options.target);
793
- const written = await writeAssetToSource(target.source, target.config, ref, proposal.backupContent);
794
- // 0.9.0 (issue #507): single batch commit at the write boundary for git
795
- // targets. No-op for filesystem/primary-stash targets.
796
- commitWriteTargetBoundary(target, `Revert ${formatRefForMessage(ref)}`);
797
- // Update the proposal record to status: "reverted" and bump updatedAt +
798
- // review so the audit trail reflects the second decision.
799
- const now = nowIso(ctx);
800
- const reverted = {
801
- ...proposal,
802
- status: "reverted",
803
- updatedAt: now,
804
- review: {
805
- outcome: "rejected",
806
- reason: "reverted: prior content restored from backup",
807
- decidedAt: now,
808
- },
809
- };
810
- withProposalsDb(stashDir, ctx, (db) => upsertProposal(db, reverted, stashDir));
811
- return { proposal: reverted, assetPath: written.path, ref: written.ref };
812
- }
813
- /**
814
- * Compute a diff between a proposal payload and the existing on-disk asset.
815
- * Uses {@link resolveWriteTarget} to find where the asset would land — so the
816
- * diff matches exactly what `accept` will write. Falls back to "new asset"
817
- * when no asset is currently materialised at the target ref.
818
- */
819
- export function diffProposal(stashDir, config, id, options = {}, ctx) {
820
- const proposal = getProposal(stashDir, id, ctx);
821
- const ref = parseAssetRef(proposal.ref);
822
- let targetPath;
823
- let existing = null;
824
- try {
825
- const target = resolveWriteTarget(config, options.target);
826
- targetPath = resolveAssetFilePathSafe(target.source, ref);
827
- if (targetPath && fs.existsSync(targetPath)) {
828
- 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;
829
85
  }
830
- }
831
- catch {
832
- // No writable target configured — still return a "new asset" diff so
833
- // callers can see the proposed payload without erroring out.
834
- }
835
- const proposed = proposal.payload.content;
836
- if (existing === null) {
837
- return {
838
- existing: null,
839
- proposed,
840
- unified: formatNewAssetDiff(proposal.ref, proposed),
841
- isNew: true,
842
- ...(targetPath ? { targetPath } : {}),
843
- };
844
- }
845
- return {
846
- existing,
847
- proposed,
848
- unified: formatUnifiedDiff(existing, proposed, proposal.ref),
849
- isNew: false,
850
- ...(targetPath ? { targetPath } : {}),
851
- };
852
- }
853
- function resolveAssetFilePathSafe(source, ref) {
854
- const typeDir = TYPE_DIRS[ref.type];
855
- if (!typeDir)
856
- return undefined;
857
- const typeRoot = path.join(source.path, typeDir);
858
- try {
859
- return resolveAssetPathFromName(ref.type, typeRoot, ref.name);
860
- }
861
- catch {
862
- return undefined;
863
- }
864
- }
865
- /**
866
- * Minimal unified-diff renderer. We deliberately avoid pulling a runtime
867
- * dependency just for this — proposals diffs are usually small (a single
868
- * lesson / skill file), so the LCS-free greedy renderer below is plenty for
869
- * humans to review. The output mirrors `git diff --no-index` for the first
870
- * `@@ … @@` hunk: enough to be familiar, not so detailed that we re-implement
871
- * a full LCS table.
872
- */
873
- export function formatUnifiedDiff(left, right, label) {
874
- if (left === right)
875
- return "";
876
- const leftLines = left.split("\n");
877
- const rightLines = right.split("\n");
878
- const lines = [`--- ${label} (existing)`, `+++ ${label} (proposed)`];
879
- // Pad to the longer side so alignment is one-to-one. Real diff tools use
880
- // LCS to align matching runs; we don't need that fidelity for a review
881
- // surface — both halves are visible regardless.
882
- const max = Math.max(leftLines.length, rightLines.length);
883
- lines.push(`@@ 1,${leftLines.length} 1,${rightLines.length} @@`);
884
- for (let i = 0; i < max; i += 1) {
885
- const l = leftLines[i];
886
- const r = rightLines[i];
887
- if (l === r && l !== undefined) {
888
- 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.
889
90
  continue;
890
91
  }
891
- if (l !== undefined)
892
- lines.push(`-${l}`);
893
- if (r !== undefined)
894
- lines.push(`+${r}`);
895
- }
896
- return lines.join("\n");
897
- }
898
- function formatNewAssetDiff(ref, content) {
899
- const lines = [`--- /dev/null`, `+++ ${ref} (proposed, new asset)`];
900
- lines.push(`@@ 0,0 1,${content.split("\n").length} @@`);
901
- for (const line of content.split("\n")) {
902
- 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
+ });
903
114
  }
904
- return lines.join("\n");
115
+ return repaired;
905
116
  }