akm-cli 0.9.0-rc.0 → 0.9.0-rc.13

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 (598) hide show
  1. package/CHANGELOG.md +1283 -22
  2. package/README.md +62 -37
  3. package/SECURITY.md +46 -31
  4. package/dist/akm +162 -38
  5. package/dist/akm-migrate +44 -0
  6. package/dist/assets/backends/schtasks-template.xml +2 -1
  7. package/dist/assets/hints/cli-hints-full.md +268 -118
  8. package/dist/assets/hints/cli-hints-short.md +87 -24
  9. package/dist/assets/{profiles → improve-strategies}/catchup.json +3 -1
  10. package/dist/assets/{profiles → improve-strategies}/consolidate.json +3 -1
  11. package/dist/assets/{profiles → improve-strategies}/default.json +6 -7
  12. package/dist/assets/improve-strategies/frequent.json +15 -0
  13. package/dist/assets/{profiles → improve-strategies}/graph-refresh.json +4 -2
  14. package/dist/assets/{profiles → improve-strategies}/memory-focus.json +4 -1
  15. package/dist/assets/{profiles → improve-strategies}/proactive-maintenance.json +5 -5
  16. package/dist/assets/{profiles → improve-strategies}/quick.json +4 -2
  17. package/dist/assets/improve-strategies/reflect-distill.json +30 -0
  18. package/dist/assets/{profiles → improve-strategies}/thorough.json +1 -1
  19. package/dist/assets/prompts/consolidate-system.md +5 -5
  20. package/dist/assets/prompts/extract-session.md +2 -6
  21. package/dist/assets/prompts/memory-infer-user.md +2 -3
  22. package/dist/assets/prompts/reflect-llm-framed-contract.md +11 -0
  23. package/dist/assets/prompts/reflect-llm-schema-contract.md +3 -0
  24. package/dist/assets/prompts/reflect-output-repair.md +3 -0
  25. package/dist/assets/prompts/workflow-unit-preamble.md +26 -0
  26. package/dist/assets/stash-skeleton/README.md +38 -10
  27. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +8 -0
  28. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +8 -0
  29. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +14 -1
  30. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +13 -1
  31. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +9 -1
  32. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +11 -0
  33. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +9 -0
  34. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +9 -0
  35. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +8 -0
  36. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +100 -0
  37. package/dist/assets/stash-skeleton/facts/conventions/domains.md +64 -0
  38. package/dist/assets/stash-skeleton/facts/conventions/organization.md +136 -0
  39. package/dist/assets/tasks/core/extract.yml +3 -2
  40. package/dist/assets/tasks/core/improve.yml +2 -1
  41. package/dist/assets/tasks/core/index-refresh.yml +1 -0
  42. package/dist/assets/tasks/core/sync.yml +1 -0
  43. package/dist/assets/tasks/core/version-check.yml +2 -1
  44. package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +5 -0
  45. package/dist/assets/tasks/improve/akm-improve-catchup.yml +8 -0
  46. package/dist/assets/tasks/improve/akm-improve-consolidate.yml +5 -0
  47. package/dist/assets/tasks/improve/akm-improve-frequent.yml +5 -0
  48. package/dist/assets/tasks/improve/akm-improve-nightly.yml +5 -0
  49. package/dist/assets/templates/html/health.html +5 -4
  50. package/dist/assets/workflows/workflow-template.md +31 -15
  51. package/dist/cli/invocation.js +279 -0
  52. package/dist/cli/parse-args.js +5 -90
  53. package/dist/cli/retired-commands.js +78 -0
  54. package/dist/cli/shared.js +158 -48
  55. package/dist/cli-node.mjs +2 -1
  56. package/dist/cli.js +747 -293
  57. package/dist/commands/agent/agent-dispatch.js +19 -18
  58. package/dist/commands/agent/agent-support.js +0 -24
  59. package/dist/commands/agent/contribute-cli.js +43 -97
  60. package/dist/commands/completions.js +80 -23
  61. package/dist/commands/config-cli.js +44 -281
  62. package/dist/commands/env/env-binding.js +99 -0
  63. package/dist/commands/env/env-cli.js +84 -224
  64. package/dist/commands/env/env.js +12 -163
  65. package/dist/commands/env/marker-path.js +6 -0
  66. package/dist/commands/env/secret-cli.js +45 -61
  67. package/dist/commands/env/secret.js +32 -62
  68. package/dist/commands/feedback-cli.js +179 -85
  69. package/dist/commands/health/accept-rate.js +58 -0
  70. package/dist/commands/health/advisories.js +7 -8
  71. package/dist/commands/health/checks.js +279 -94
  72. package/dist/commands/health/html-report.js +197 -578
  73. package/dist/commands/health/improve-metrics.js +277 -246
  74. package/dist/commands/health/llm-usage.js +19 -19
  75. package/dist/commands/health/md-report.js +16 -7
  76. package/dist/commands/health/metrics.js +67 -32
  77. package/dist/commands/health/renderers.js +47 -0
  78. package/dist/commands/health/report-view-model.js +508 -0
  79. package/dist/commands/health/stash-exposure.js +1 -1
  80. package/dist/commands/health/surfaces.js +16 -56
  81. package/dist/commands/health/task-runs.js +3 -67
  82. package/dist/{migrate-storage-node.mjs → commands/health/types-checks.js} +1 -5
  83. package/dist/commands/health/types-improve.js +29 -0
  84. package/dist/{output/text/save.js → commands/health/types-metrics.js} +1 -2
  85. package/dist/commands/health/types-result.js +7 -0
  86. package/dist/commands/health/types-runs.js +4 -0
  87. package/dist/commands/health/types-session-log.js +4 -0
  88. package/dist/commands/health/types-windows.js +4 -0
  89. package/dist/commands/health/types.js +26 -21
  90. package/dist/commands/health/windows.js +2 -3
  91. package/dist/commands/health.js +296 -167
  92. package/dist/commands/improve/anti-collapse.js +5 -5
  93. package/dist/commands/improve/autonomy-gate.js +68 -0
  94. package/dist/commands/improve/collapse-detector.js +65 -52
  95. package/dist/commands/improve/consolidate/chunking.js +9 -7
  96. package/dist/commands/improve/consolidate/eligibility.js +1 -23
  97. package/dist/commands/improve/consolidate/merge.js +4 -0
  98. package/dist/commands/improve/consolidate.js +454 -1354
  99. package/dist/commands/improve/content-hash.js +39 -0
  100. package/dist/commands/improve/distill/content-repair.js +4 -10
  101. package/dist/commands/improve/distill/promote-memory.js +89 -64
  102. package/dist/commands/improve/distill/quality-gate.js +118 -42
  103. package/dist/commands/improve/distill-guards.js +1 -1
  104. package/dist/commands/improve/distill-promotion-policy.js +33 -888
  105. package/dist/commands/improve/distill.js +607 -363
  106. package/dist/commands/improve/eligibility.js +165 -79
  107. package/dist/commands/improve/extract-cli.js +35 -126
  108. package/dist/commands/improve/extract-prompt.js +6 -35
  109. package/dist/commands/improve/extract.js +640 -391
  110. package/dist/commands/improve/feedback-valence.js +2 -12
  111. package/dist/commands/improve/improve-cli.js +134 -135
  112. package/dist/commands/improve/improve-result-file.js +30 -50
  113. package/dist/commands/improve/improve-run-types.js +4 -0
  114. package/dist/commands/improve/improve-strategies.js +135 -0
  115. package/dist/commands/improve/improve.js +904 -701
  116. package/dist/commands/improve/locks.js +64 -111
  117. package/dist/commands/improve/loop-stages.js +1110 -923
  118. package/dist/commands/improve/memory/derived-ref.js +124 -0
  119. package/dist/commands/improve/memory/memory-belief.js +79 -7
  120. package/dist/commands/improve/memory/memory-contradiction-detect.js +49 -52
  121. package/dist/commands/improve/memory/memory-improve.js +25 -37
  122. package/dist/commands/improve/outcome-loop.js +25 -88
  123. package/dist/commands/improve/preparation.js +1034 -813
  124. package/dist/commands/improve/proactive-maintenance.js +34 -9
  125. package/dist/commands/improve/proposal-envelope.js +31 -0
  126. package/dist/commands/improve/reflect.js +983 -794
  127. package/dist/commands/improve/run-context.js +119 -0
  128. package/dist/commands/improve/salience.js +24 -127
  129. package/dist/commands/improve/session-asset.js +7 -3
  130. package/dist/commands/improve/shared.js +14 -34
  131. package/dist/commands/improve/source-identity.js +28 -0
  132. package/dist/commands/improve/triage.js +20 -17
  133. package/dist/commands/lint/base-linter.js +340 -313
  134. package/dist/commands/lint/env-key-rules.js +31 -47
  135. package/dist/commands/lint/index.js +185 -30
  136. package/dist/commands/{events.js → log.js} +28 -38
  137. package/dist/commands/migrate-cli.js +54 -0
  138. package/dist/commands/migration-tool.js +55 -0
  139. package/dist/commands/observability-cli.js +70 -208
  140. package/dist/commands/proposal/diff-format.js +50 -0
  141. package/dist/commands/proposal/drain-policies.js +0 -6
  142. package/dist/commands/proposal/drain.js +91 -40
  143. package/dist/commands/proposal/proposal-cli.js +134 -132
  144. package/dist/commands/proposal/proposal-types.js +56 -0
  145. package/dist/commands/proposal/proposal.js +83 -65
  146. package/dist/commands/proposal/propose-cli.js +88 -0
  147. package/dist/commands/proposal/propose.js +105 -88
  148. package/dist/commands/proposal/repository.js +1303 -278
  149. package/dist/commands/proposal/validators/proposal-quality-validators.js +16 -6
  150. package/dist/commands/proposal/validators/proposal-validators.js +61 -12
  151. package/dist/commands/proposal/validators/proposals.js +6 -8
  152. package/dist/commands/read/curate.js +78 -73
  153. package/dist/commands/read/knowledge.js +510 -13
  154. package/dist/commands/read/registry-search.js +2 -2
  155. package/dist/commands/read/remember-cli.js +84 -15
  156. package/dist/commands/read/search-cli.js +203 -96
  157. package/dist/commands/read/search.js +126 -94
  158. package/dist/commands/read/show.js +226 -250
  159. package/dist/commands/registry-cli.js +34 -60
  160. package/dist/commands/remember.js +18 -57
  161. package/dist/commands/sources/add-cli.js +104 -49
  162. package/dist/commands/sources/bundle-cli.js +166 -0
  163. package/dist/commands/sources/bundle-config-ops.js +63 -0
  164. package/dist/commands/sources/info.js +27 -15
  165. package/dist/commands/sources/init.js +30 -40
  166. package/dist/commands/sources/installed-stashes.js +469 -172
  167. package/dist/commands/sources/migration-help.js +7 -4
  168. package/dist/commands/sources/schema-repair.js +10 -9
  169. package/dist/commands/sources/self-update.js +182 -121
  170. package/dist/commands/sources/source-add.js +169 -178
  171. package/dist/commands/sources/source-clone.js +144 -41
  172. package/dist/commands/sources/source-manage.js +94 -59
  173. package/dist/commands/sources/sources-cli.js +64 -205
  174. package/dist/commands/sources/stash-cli.js +91 -54
  175. package/dist/commands/sources/stash-skeleton.js +1 -1
  176. package/dist/commands/tasks/tasks-cli.js +106 -104
  177. package/dist/commands/tasks/tasks.js +445 -262
  178. package/dist/commands/workflow-cli.js +232 -121
  179. package/dist/core/action-contributors.js +1 -1
  180. package/dist/core/activation-policy.js +49 -0
  181. package/dist/core/adapter/adapters/agent-skills-adapter.js +181 -0
  182. package/dist/core/adapter/adapters/akm-adapter.js +528 -0
  183. package/dist/core/adapter/adapters/akm-lint.js +392 -0
  184. package/dist/core/adapter/adapters/akm-metadata.js +387 -0
  185. package/dist/core/adapter/adapters/akm-task-adapter.js +149 -0
  186. package/dist/core/adapter/adapters/akm-workflow-adapter.js +180 -0
  187. package/dist/core/adapter/adapters/claude-adapter.js +61 -0
  188. package/dist/core/adapter/adapters/dotenv-adapter.js +187 -0
  189. package/dist/core/adapter/adapters/generic-files-adapter.js +119 -0
  190. package/dist/core/adapter/adapters/index.js +80 -0
  191. package/dist/core/adapter/adapters/llm-wiki-adapter.js +419 -0
  192. package/dist/core/adapter/adapters/okf-adapter.js +391 -0
  193. package/dist/core/adapter/adapters/opencode-adapter.js +68 -0
  194. package/dist/core/adapter/adapters/shared.js +286 -0
  195. package/dist/core/adapter/adapters/tool-dir-shared.js +217 -0
  196. package/dist/core/adapter/adapters/website-snapshot-adapter.js +155 -0
  197. package/dist/core/adapter/bundle-adapter.js +4 -0
  198. package/dist/core/adapter/detect-adapter.js +17 -0
  199. package/dist/core/adapter/recognize-match.js +44 -0
  200. package/dist/core/adapter/registry.js +56 -0
  201. package/dist/core/adapter/types.js +4 -0
  202. package/dist/core/asset/akm-markdown.js +30 -0
  203. package/dist/core/asset/asset-placement.js +243 -0
  204. package/dist/core/asset/asset-ref.js +110 -79
  205. package/dist/core/asset/asset-serialize.js +20 -0
  206. package/dist/core/asset/frontmatter.js +28 -12
  207. package/dist/core/asset/markdown.js +40 -51
  208. package/dist/core/asset/resolve-ref.js +274 -0
  209. package/dist/core/asset/stash-meta.js +2 -2
  210. package/dist/core/bundle-id.js +51 -0
  211. package/dist/core/common.js +281 -86
  212. package/dist/core/config/config-io.js +42 -128
  213. package/dist/core/config/config-schema.js +233 -834
  214. package/dist/core/config/config-sources.js +162 -39
  215. package/dist/core/config/config-types.js +16 -11
  216. package/dist/core/config/config-version.js +29 -0
  217. package/dist/core/config/config-walker.js +126 -37
  218. package/dist/core/config/config.js +154 -331
  219. package/dist/core/config/deep-merge.js +41 -0
  220. package/dist/core/config/engine-semantics.js +28 -0
  221. package/dist/core/config/experimental.js +21 -0
  222. package/dist/core/config/schema/embedding.js +38 -0
  223. package/dist/core/config/schema/engines.js +116 -0
  224. package/dist/core/config/schema/experimental.js +47 -0
  225. package/dist/core/config/schema/feedback.js +31 -0
  226. package/dist/core/config/schema/improve-processes.js +389 -0
  227. package/dist/core/config/schema/improve.js +94 -0
  228. package/dist/core/config/schema/index-config.js +176 -0
  229. package/dist/core/config/schema/output.js +18 -0
  230. package/dist/core/config/schema/primitives.js +94 -0
  231. package/dist/core/config/schema/search.js +30 -0
  232. package/dist/core/config/schema/setup.js +18 -0
  233. package/dist/core/config/schema/sources-bundles.js +169 -0
  234. package/dist/core/config/schema/workflow.js +29 -0
  235. package/dist/core/env-secret-ref.js +155 -20
  236. package/dist/core/errors.js +17 -15
  237. package/dist/core/events-types.js +4 -0
  238. package/dist/core/events.js +46 -128
  239. package/dist/core/extra-params.js +62 -0
  240. package/dist/core/file-change.js +17 -0
  241. package/dist/core/file-lock.js +202 -57
  242. package/dist/core/fs-txn.js +392 -0
  243. package/dist/core/git-message.js +59 -0
  244. package/dist/core/improve-result.js +167 -0
  245. package/dist/core/json-schema.js +142 -0
  246. package/dist/core/lesson-lint.js +1 -17
  247. package/dist/core/logs-db.js +1 -1
  248. package/dist/core/maintenance-barrier.js +135 -0
  249. package/dist/core/migration-operation.js +44 -0
  250. package/dist/core/mutation-target.js +78 -0
  251. package/dist/core/paths.js +22 -25
  252. package/dist/core/platform.js +10 -0
  253. package/dist/core/recognition-util.js +128 -0
  254. package/dist/core/redaction.js +392 -0
  255. package/dist/core/standards/resolve-standards-context.js +36 -65
  256. package/dist/core/standards/resolve-stash-standards.js +2 -2
  257. package/dist/core/standards/resolve-type-conventions.js +5 -5
  258. package/dist/core/state/migrations.js +242 -11
  259. package/dist/core/state-db.js +98 -10
  260. package/dist/core/structured.js +1 -1
  261. package/dist/core/subprocess.js +303 -0
  262. package/dist/core/text-truncation.js +9 -5
  263. package/dist/core/time.js +20 -0
  264. package/dist/core/type-presentation.js +130 -0
  265. package/dist/core/warn.js +0 -3
  266. package/dist/core/write-source.js +834 -118
  267. package/dist/indexer/bundle-identity-guard.js +92 -0
  268. package/dist/indexer/db/graph-db.js +1 -25
  269. package/dist/indexer/db/llm-cache.js +1 -1
  270. package/dist/indexer/ensure-index.js +30 -9
  271. package/dist/indexer/graph/graph-boost.js +9 -30
  272. package/dist/indexer/graph/graph-extraction.js +41 -27
  273. package/dist/indexer/graph/graph-types.js +4 -0
  274. package/dist/indexer/index-writer-lock.js +93 -49
  275. package/dist/indexer/index-written-assets.js +100 -53
  276. package/dist/indexer/indexer.js +746 -329
  277. package/dist/indexer/init.js +18 -25
  278. package/dist/indexer/installations.js +142 -0
  279. package/dist/indexer/passes/dir-staleness.js +18 -10
  280. package/dist/indexer/passes/memory-inference.js +25 -15
  281. package/dist/indexer/passes/metadata.js +412 -243
  282. package/dist/indexer/scan/doc-to-entry.js +160 -0
  283. package/dist/indexer/scan/drain-dir.js +134 -0
  284. package/dist/indexer/search/db-search.js +292 -108
  285. package/dist/indexer/search/fts-query.js +64 -0
  286. package/dist/indexer/search/ranking-contributors.js +145 -25
  287. package/dist/indexer/search/ranking-types.js +4 -0
  288. package/dist/indexer/search/ranking.js +28 -71
  289. package/dist/indexer/search/search-attribution.js +67 -0
  290. package/dist/indexer/search/search-fields.js +18 -3
  291. package/dist/indexer/search/search-hit-enrichers.js +30 -40
  292. package/dist/indexer/search/search-source.js +157 -111
  293. package/dist/indexer/search/semantic-status.js +4 -1
  294. package/dist/indexer/usage/usage-events.js +10 -30
  295. package/dist/indexer/walk/file-context.js +3 -45
  296. package/dist/indexer/walk/matchers.js +42 -34
  297. package/dist/indexer/walk/path-resolver.js +11 -5
  298. package/dist/indexer/walk/walker.js +42 -14
  299. package/dist/integrations/agent/builder-shared.js +7 -0
  300. package/dist/integrations/agent/builders.js +5 -56
  301. package/dist/integrations/agent/config.js +3 -143
  302. package/dist/integrations/agent/detect.js +17 -2
  303. package/dist/integrations/agent/engine-resolution.js +231 -0
  304. package/dist/integrations/agent/index.js +1 -2
  305. package/dist/integrations/agent/model-aliases.js +16 -2
  306. package/dist/integrations/agent/profiles.js +36 -62
  307. package/dist/integrations/agent/prompts.js +46 -18
  308. package/dist/integrations/agent/runner-dispatch.js +93 -4
  309. package/dist/integrations/agent/runner.js +76 -208
  310. package/dist/integrations/agent/spawn.js +88 -196
  311. package/dist/integrations/harnesses/aider/agent-builder.js +114 -0
  312. package/dist/integrations/harnesses/aider/index.js +48 -0
  313. package/dist/integrations/harnesses/aider/result-extractor.js +53 -0
  314. package/dist/integrations/harnesses/amazonq/agent-builder.js +147 -0
  315. package/dist/integrations/harnesses/amazonq/index.js +45 -0
  316. package/dist/integrations/harnesses/amazonq/result-extractor.js +48 -0
  317. package/dist/integrations/harnesses/claude/agent-builder.js +46 -8
  318. package/dist/integrations/harnesses/claude/config-import.js +1 -3
  319. package/dist/integrations/harnesses/claude/index.js +24 -35
  320. package/dist/integrations/harnesses/claude/result-extractor.js +52 -0
  321. package/dist/integrations/harnesses/claude/session-log.js +27 -75
  322. package/dist/integrations/harnesses/codex/agent-builder.js +138 -0
  323. package/dist/integrations/harnesses/codex/index.js +52 -0
  324. package/dist/integrations/harnesses/codex/result-extractor.js +73 -0
  325. package/dist/integrations/harnesses/copilot/agent-builder.js +122 -0
  326. package/dist/integrations/harnesses/copilot/index.js +48 -0
  327. package/dist/integrations/harnesses/copilot/result-extractor.js +151 -0
  328. package/dist/integrations/harnesses/gemini/agent-builder.js +120 -0
  329. package/dist/integrations/harnesses/gemini/index.js +48 -0
  330. package/dist/integrations/harnesses/gemini/result-extractor.js +121 -0
  331. package/dist/integrations/harnesses/ids.js +24 -0
  332. package/dist/integrations/harnesses/index.js +54 -34
  333. package/dist/integrations/harnesses/opencode/agent-builder.js +23 -5
  334. package/dist/integrations/harnesses/opencode/config-import.js +1 -3
  335. package/dist/integrations/harnesses/opencode/index.js +14 -32
  336. package/dist/integrations/harnesses/opencode/session-log.js +67 -125
  337. package/dist/integrations/harnesses/opencode-sdk/harness.js +51 -0
  338. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +681 -108
  339. package/dist/integrations/harnesses/openhands/agent-builder.js +128 -0
  340. package/dist/integrations/harnesses/openhands/index.js +48 -0
  341. package/dist/integrations/harnesses/openhands/result-extractor.js +103 -0
  342. package/dist/integrations/harnesses/pi/agent-builder.js +97 -0
  343. package/dist/integrations/harnesses/pi/index.js +45 -0
  344. package/dist/integrations/harnesses/pi/result-extractor.js +135 -0
  345. package/dist/integrations/harnesses/shared.js +17 -0
  346. package/dist/integrations/harnesses/types.js +43 -32
  347. package/dist/integrations/lockfile.js +211 -24
  348. package/dist/integrations/session-logs/index.js +36 -39
  349. package/dist/integrations/session-logs/provider-base.js +113 -0
  350. package/dist/llm/client.js +182 -110
  351. package/dist/llm/embedders/deterministic.js +2 -2
  352. package/dist/llm/embedders/remote.js +21 -9
  353. package/dist/llm/feature-gate.js +17 -57
  354. package/dist/llm/graph-extract.js +12 -13
  355. package/dist/llm/index-passes.js +8 -42
  356. package/dist/llm/memory-infer.js +144 -1
  357. package/dist/llm/metadata-enhance.js +45 -30
  358. package/dist/llm/structured-call.js +16 -8
  359. package/dist/llm/usage-persist.js +30 -5
  360. package/dist/llm/usage-telemetry.js +59 -6
  361. package/dist/output/cli-hints.js +1 -2
  362. package/dist/output/command-registry.js +27 -0
  363. package/dist/output/context.js +22 -7
  364. package/dist/output/format-exempt.js +80 -0
  365. package/dist/output/generic-render.js +251 -0
  366. package/dist/output/html-render.js +11 -16
  367. package/dist/output/render-registry.js +57 -0
  368. package/dist/output/renderers.js +14 -279
  369. package/dist/output/shapes/curate.js +10 -1
  370. package/dist/output/shapes/events.js +12 -7
  371. package/dist/output/shapes/helpers.js +58 -84
  372. package/dist/output/shapes/passthrough.js +11 -39
  373. package/dist/output/shapes/proposal/producer.js +15 -7
  374. package/dist/output/shapes/registry.js +12 -6
  375. package/dist/output/shapes.js +0 -9
  376. package/dist/output/text/{init.js → bundle-create.js} +3 -1
  377. package/dist/output/text/bundle-show.js +7 -0
  378. package/dist/output/text/command-format.js +562 -0
  379. package/dist/output/text/env.js +1 -3
  380. package/dist/output/text/events.js +8 -7
  381. package/dist/output/text/helpers.js +15 -1164
  382. package/dist/output/text/proposal/producer.js +4 -2
  383. package/dist/output/text/proposal-format.js +202 -0
  384. package/dist/output/text/registry-commands.js +1 -2
  385. package/dist/output/text/registry.js +12 -6
  386. package/dist/output/text/show-directives.js +117 -0
  387. package/dist/output/text/show-format.js +103 -0
  388. package/dist/output/text/sync.js +5 -0
  389. package/dist/output/text/workflow-format.js +332 -0
  390. package/dist/output/text/workflow.js +3 -2
  391. package/dist/output/text.js +10 -19
  392. package/dist/registry/factory.js +4 -6
  393. package/dist/registry/origin-resolve.js +16 -27
  394. package/dist/registry/providers/skills-sh.js +3 -3
  395. package/dist/registry/providers/static-index.js +15 -25
  396. package/dist/registry/resolve.js +43 -94
  397. package/dist/registry/semver.js +43 -0
  398. package/dist/runtime.js +81 -12
  399. package/dist/scripts/akm-migrate.js +35529 -0
  400. package/dist/setup/detect.js +5 -7
  401. package/dist/setup/detected-engines.js +136 -0
  402. package/dist/setup/engine-config.js +100 -0
  403. package/dist/setup/registry-stash-loader.js +3 -3
  404. package/dist/setup/semantic-assets.js +12 -9
  405. package/dist/setup/setup.js +444 -208
  406. package/dist/setup/steps/connection-shared.js +120 -0
  407. package/dist/setup/steps/connection.js +108 -305
  408. package/dist/setup/steps/platforms.js +13 -12
  409. package/dist/setup/steps/semantic.js +15 -3
  410. package/dist/setup/steps/sources.js +21 -15
  411. package/dist/setup/steps/stashdir.js +6 -4
  412. package/dist/setup/steps/tasks.js +236 -119
  413. package/dist/setup/steps.js +3 -2
  414. package/dist/sources/freshness.js +39 -0
  415. package/dist/sources/provider-factory.js +11 -17
  416. package/dist/sources/providers/filesystem.js +2 -3
  417. package/dist/sources/providers/git-install.js +278 -34
  418. package/dist/sources/providers/git-provider.js +54 -56
  419. package/dist/sources/providers/git-stash.js +420 -91
  420. package/dist/sources/providers/git.js +2 -2
  421. package/dist/sources/providers/npm.js +16 -19
  422. package/dist/sources/providers/provider-utils.js +47 -22
  423. package/dist/sources/providers/sync-from-ref.js +3 -9
  424. package/dist/sources/providers/website.js +2 -2
  425. package/dist/sources/resolve.js +11 -10
  426. package/dist/sources/snapshot-fetchers/types.js +4 -0
  427. package/dist/sources/{website-ingest.js → snapshot-fetchers/website-ingest.js} +110 -41
  428. package/dist/storage/database.js +60 -4
  429. package/dist/storage/engines/sqlite-migrations.js +156 -5
  430. package/dist/storage/locations.js +1 -2
  431. package/dist/storage/repositories/canaries-repository.js +1 -1
  432. package/dist/storage/repositories/events-repository.js +51 -11
  433. package/dist/storage/repositories/improve-runs-repository.js +6 -32
  434. package/dist/storage/repositories/index-connection.js +79 -0
  435. package/dist/storage/repositories/index-db.js +4 -3
  436. package/dist/storage/repositories/index-entries-repository.js +863 -0
  437. package/dist/{indexer/db/entry-mapper.js → storage/repositories/index-entry-mapper.js} +19 -2
  438. package/dist/storage/repositories/index-entry-types.js +4 -0
  439. package/dist/storage/repositories/index-fts-repository.js +167 -0
  440. package/dist/storage/repositories/index-llm-cache-repository.js +108 -0
  441. package/dist/storage/repositories/index-meta-repository.js +49 -0
  442. package/dist/{indexer/db/schema.js → storage/repositories/index-schema.js} +226 -100
  443. package/dist/storage/repositories/index-sql.js +12 -0
  444. package/dist/storage/repositories/index-utility-repository.js +356 -0
  445. package/dist/storage/repositories/index-vec-repository.js +250 -0
  446. package/dist/storage/repositories/outcome-repository.js +119 -0
  447. package/dist/storage/repositories/proposals-repository.js +317 -75
  448. package/dist/storage/repositories/registry-cache.js +1 -1
  449. package/dist/storage/repositories/salience-repository.js +172 -0
  450. package/dist/storage/repositories/task-history-repository.js +110 -3
  451. package/dist/storage/repositories/workflow-runs-repository.js +240 -19
  452. package/dist/tasks/backends/cron.js +169 -46
  453. package/dist/tasks/backends/exec-utils.js +76 -3
  454. package/dist/tasks/backends/index.js +6 -9
  455. package/dist/tasks/backends/launchd.js +292 -55
  456. package/dist/tasks/backends/schtasks.js +557 -70
  457. package/dist/tasks/backends/types.js +4 -0
  458. package/dist/tasks/command-executable.js +93 -0
  459. package/dist/tasks/embedded.js +56 -38
  460. package/dist/tasks/parser.js +156 -64
  461. package/dist/tasks/resolve-akm-bin.js +144 -51
  462. package/dist/tasks/runner.js +377 -209
  463. package/dist/tasks/schedule.js +108 -19
  464. package/dist/tasks/scheduler-invocation.js +296 -0
  465. package/dist/tasks/schema.js +1 -1
  466. package/dist/tasks/task-id.js +35 -0
  467. package/dist/tasks/validator.js +30 -16
  468. package/dist/text-import-hook.mjs +1 -1
  469. package/dist/workflows/authoring/authoring.js +104 -43
  470. package/dist/workflows/authoring/scope-key.js +1 -1
  471. package/dist/workflows/cli.js +0 -16
  472. package/dist/workflows/concurrency-policy.js +15 -0
  473. package/dist/workflows/exec/brief.js +450 -0
  474. package/dist/workflows/exec/frozen-judge.js +47 -0
  475. package/dist/workflows/exec/native-executor.js +1038 -0
  476. package/dist/workflows/exec/param-secrets.js +115 -0
  477. package/dist/workflows/exec/report.js +1460 -0
  478. package/dist/workflows/exec/run-workflow.js +602 -0
  479. package/dist/workflows/exec/scheduler.js +71 -0
  480. package/dist/workflows/exec/step-work.js +1190 -0
  481. package/dist/workflows/exec/unit-writer.js +23 -0
  482. package/dist/workflows/exec/workflow-engine-gate.js +67 -0
  483. package/dist/workflows/exec/worktree.js +171 -0
  484. package/dist/workflows/ir/compile.js +246 -0
  485. package/dist/workflows/ir/freeze.js +233 -0
  486. package/dist/workflows/ir/params.js +54 -0
  487. package/dist/workflows/ir/plan-hash.js +68 -0
  488. package/dist/workflows/ir/schema.js +540 -0
  489. package/dist/workflows/parser.js +878 -304
  490. package/dist/workflows/program/expressions.js +181 -0
  491. package/dist/workflows/program/schema.js +51 -0
  492. package/dist/workflows/renderer.js +100 -45
  493. package/dist/workflows/resource-limits.js +22 -0
  494. package/dist/workflows/runtime/agent-identity.js +59 -14
  495. package/dist/workflows/runtime/checkin.js +1 -1
  496. package/dist/workflows/runtime/plan-classifier.js +131 -0
  497. package/dist/workflows/runtime/runs.js +376 -119
  498. package/dist/workflows/runtime/unit-checkin.js +45 -0
  499. package/dist/workflows/runtime/unit-phases.js +20 -0
  500. package/dist/workflows/runtime/workflow-asset-loader.js +241 -40
  501. package/dist/workflows/schema.js +1 -11
  502. package/dist/workflows/validate-summary.js +2 -3
  503. package/dist/workflows/validator.js +52 -30
  504. package/docs/README.md +42 -78
  505. package/docs/migration/README.md +8 -0
  506. package/docs/migration/release-notes/0.6.0.md +1 -1
  507. package/docs/migration/release-notes/0.7.0.md +9 -8
  508. package/docs/migration/release-notes/0.9.0.md +158 -14
  509. package/docs/migration/v0.7-to-v0.8.md +46 -47
  510. package/docs/migration/v0.8-to-v0.9.md +844 -0
  511. package/docs/reference/README.md +12 -0
  512. package/docs/reference/data-and-telemetry.md +333 -0
  513. package/package.json +21 -17
  514. package/schemas/akm-asset-envelope.json +93 -0
  515. package/schemas/akm-config.json +4636 -0
  516. package/schemas/akm-task.json +87 -0
  517. package/schemas/akm-workflow.json +373 -0
  518. package/dist/akm-migrate-storage +0 -38
  519. package/dist/assets/help/help-accept.md +0 -12
  520. package/dist/assets/help/help-improve.md +0 -84
  521. package/dist/assets/help/help-proposals.md +0 -17
  522. package/dist/assets/help/help-propose.md +0 -17
  523. package/dist/assets/help/help-reject.md +0 -11
  524. package/dist/assets/profiles/frequent.json +0 -13
  525. package/dist/assets/profiles/recombine-only.json +0 -21
  526. package/dist/assets/profiles/reflect-distill.json +0 -30
  527. package/dist/assets/profiles/synthesize.json +0 -15
  528. package/dist/assets/prompts/procedural-system.md +0 -44
  529. package/dist/assets/prompts/recombine-system.md +0 -40
  530. package/dist/assets/prompts/staleness-detect-system.md +0 -6
  531. package/dist/assets/tasks/core/backup.yml +0 -4
  532. package/dist/assets/tasks/graph-refresh-weekly.yml +0 -10
  533. package/dist/assets/templates/html/default.html +0 -78
  534. package/dist/assets/templates/html/vendor/echarts.min.js +0 -45
  535. package/dist/assets/wiki/index-template.md +0 -12
  536. package/dist/assets/wiki/ingest-workflow-template.md +0 -83
  537. package/dist/assets/wiki/log-template.md +0 -8
  538. package/dist/assets/wiki/schema-template.md +0 -61
  539. package/dist/cli/config-migrate.js +0 -150
  540. package/dist/cli/config-validate.js +0 -39
  541. package/dist/commands/graph/graph-cli.js +0 -124
  542. package/dist/commands/graph/graph.js +0 -487
  543. package/dist/commands/improve/calibration.js +0 -161
  544. package/dist/commands/improve/dedup.js +0 -482
  545. package/dist/commands/improve/extract-watch.js +0 -140
  546. package/dist/commands/improve/hot-probation.js +0 -45
  547. package/dist/commands/improve/improve-auto-accept.js +0 -276
  548. package/dist/commands/improve/improve-profiles.js +0 -168
  549. package/dist/commands/improve/procedural.js +0 -398
  550. package/dist/commands/improve/recombine.js +0 -818
  551. package/dist/commands/improve/schema-similarity-gate.js +0 -168
  552. package/dist/commands/lint/agent-linter.js +0 -44
  553. package/dist/commands/lint/command-linter.js +0 -44
  554. package/dist/commands/lint/default-linter.js +0 -16
  555. package/dist/commands/lint/fact-linter.js +0 -39
  556. package/dist/commands/lint/knowledge-linter.js +0 -16
  557. package/dist/commands/lint/memory-linter.js +0 -61
  558. package/dist/commands/lint/registry.js +0 -41
  559. package/dist/commands/lint/skill-linter.js +0 -45
  560. package/dist/commands/lint/task-linter.js +0 -50
  561. package/dist/commands/lint/workflow-linter.js +0 -81
  562. package/dist/commands/proposal/legacy-import.js +0 -115
  563. package/dist/commands/sources/history.js +0 -196
  564. package/dist/commands/tasks/default-tasks.js +0 -186
  565. package/dist/commands/wiki-cli.js +0 -292
  566. package/dist/core/asset/asset-registry.js +0 -76
  567. package/dist/core/asset/asset-spec.js +0 -259
  568. package/dist/core/config/config-migration.js +0 -602
  569. package/dist/core/deep-merge.js +0 -38
  570. package/dist/core/eval/rank-metrics.js +0 -113
  571. package/dist/core/ripgrep/install.js +0 -163
  572. package/dist/core/ripgrep/resolve.js +0 -81
  573. package/dist/indexer/db/db.js +0 -1413
  574. package/dist/indexer/manifest.js +0 -170
  575. package/dist/indexer/passes/metadata-contributors.js +0 -31
  576. package/dist/indexer/usage/unmigrated-vaults-guard.js +0 -94
  577. package/dist/integrations/harnesses/opencode-sdk/index.js +0 -49
  578. package/dist/llm/call-ai.js +0 -62
  579. package/dist/llm/memory-infer-impl.js +0 -138
  580. package/dist/output/shapes/distill.js +0 -14
  581. package/dist/output/shapes/history.js +0 -11
  582. package/dist/output/text/distill.js +0 -6
  583. package/dist/output/text/enable-disable.js +0 -8
  584. package/dist/output/text/history.js +0 -6
  585. package/dist/output/text/wiki.js +0 -16
  586. package/dist/registry/build-index.js +0 -386
  587. package/dist/scripts/migrate-storage.js +0 -19108
  588. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +0 -9411
  589. package/dist/scripts/migrations/v16-to-v17.js +0 -141
  590. package/dist/setup/legacy-config.js +0 -106
  591. package/dist/storage/repositories/consolidation-repository.js +0 -38
  592. package/dist/storage/repositories/recombine-repository.js +0 -213
  593. package/dist/wiki/wiki-templates.js +0 -15
  594. package/dist/wiki/wiki.js +0 -1012
  595. package/dist/workflows/db.js +0 -215
  596. package/docs/data-and-telemetry.md +0 -226
  597. /package/dist/sources/{wiki-fetchers → snapshot-fetchers}/registry.js +0 -0
  598. /package/dist/sources/{wiki-fetchers → snapshot-fetchers}/youtube.js +0 -0
@@ -21,20 +21,12 @@
21
21
  * is a status flip, and the full audit trail (review outcome, reason, backup
22
22
  * content for revert) lives on the row.
23
23
  *
24
- * ## Legacy filesystem import
25
- *
26
- * Before 0.9.0 proposals lived as per-uuid JSON directories under
27
- * `<stashDir>/.akm/proposals/`. The first proposal operation against a stash
28
- * imports any legacy `proposal.json` files into the table — see
29
- * `./legacy-import.ts` (`importLegacyProposalFiles`), funnelled through
30
- * {@link withProposalsDb}.
31
- *
32
24
  * # Why the queue bypasses `writeAssetToSource`
33
25
  *
34
26
  * The architectural rule "all writes go through `writeAssetToSource`" applies
35
27
  * to *assets*. Proposals are **not** assets — they live outside the asset
36
28
  * tree (in state.db, parallel to how events do). Routing them through
37
- * `writeAssetToSource` would force them into a `TYPE_DIRS` slot, would commit
29
+ * `writeAssetToSource` would force them into a placement stash-subdir slot, would commit
38
30
  * them to git, and would leak unaccepted drafts through the normal indexer.
39
31
  * The {@link promoteProposal} step is the bridge: it routes the accepted
40
32
  * payload through `writeAssetToSource` so the actual asset write still
@@ -42,88 +34,84 @@
42
34
  */
43
35
  import { createHash, randomUUID } from "node:crypto";
44
36
  import fs from "node:fs";
37
+ import os from "node:os";
45
38
  import path from "node:path";
46
- import { makeAssetRef, parseAssetRef } from "../../core/asset/asset-ref.js";
47
- import { resolveAssetPathFromName, TYPE_DIRS } from "../../core/asset/asset-spec.js";
48
- import { NotFoundError, UsageError } from "../../core/errors.js";
39
+ import { parse as parseYaml } from "yaml";
40
+ import { ensureAkmMarkdownType } from "../../core/asset/akm-markdown.js";
41
+ import { assetPathForName, placementTypes, stashDirFor } from "../../core/asset/asset-placement.js";
42
+ import { isBundleSlug, parseBundleRef } from "../../core/asset/asset-ref.js";
43
+ import { assembleAsset, serializeFrontmatter } from "../../core/asset/asset-serialize.js";
44
+ import { parseFrontmatter } from "../../core/asset/frontmatter.js";
45
+ import { conceptIdFromTypeName, parseRefInput } from "../../core/asset/resolve-ref.js";
46
+ import { isWithin } from "../../core/common.js";
47
+ import { loadConfig } from "../../core/config/config.js";
48
+ import { ConfigError, NotFoundError, UsageError } from "../../core/errors.js";
49
49
  import { appendEvent } from "../../core/events.js";
50
+ import { proposalContent } from "../../core/file-change.js";
51
+ import { _setTxnMutationHookForTests, advanceTxn, beginTxn, canonicalTxnRoot, cleanupTxn, fsyncTxnDir, fsyncTxnFile, listTxnJournals, mintTxnId, registerTxnKind, sweepJournallessTxnDir, txnDirFor, txnMutationHook, txnNamespaceDir, } from "../../core/fs-txn.js";
52
+ import { canonicalBundleIdForTarget, resolveBundleWriteTarget } from "../../core/mutation-target.js";
50
53
  import { withImmediateTransaction, withStateDb } from "../../core/state-db.js";
51
54
  import { warn } from "../../core/warn.js";
52
- import { commitWriteTargetBoundary, formatRefForMessage, resolveWriteTarget, writeAssetToSource, } from "../../core/write-source.js";
53
- import { getStateProposal, listStateProposalIdsByPrefix, listStateProposals, upsertProposal, } from "../../storage/repositories/proposals-repository.js";
54
- import { importLegacyProposalFiles } from "./legacy-import.js";
55
+ import { assertAkmAssetWrite, assertWriteTargetPathsClean, captureGitPublication, captureWriteTargetPathSnapshot, prepareWriteTargetForMutation, publishWriteTargetTransaction, resolveWriteTarget, } from "../../core/write-source.js";
56
+ import { withAssetMutationLease } from "../../indexer/index-writer-lock.js";
57
+ import { indexWrittenAssets } from "../../indexer/index-written-assets.js";
58
+ import { deriveInstallations } from "../../indexer/installations.js";
59
+ import { resolveSourceEntries } from "../../indexer/search/search-source.js";
60
+ import { insertEventOnce } from "../../storage/repositories/events-repository.js";
61
+ import { getStateProposal, getStateProposalLenient, listStateProposalIdsByPrefix, listStateProposals, upsertProposal, } from "../../storage/repositories/proposals-repository.js";
62
+ import { pkgVersion } from "../../version.js";
63
+ import { runBaseChecks } from "../lint/base-linter.js";
64
+ import { formatNewAssetDiff, formatUnifiedDiff } from "./diff-format.js";
65
+ import { isAutomatedProposalSource, isValidProposalSource, PROPOSAL_SOURCES, } from "./proposal-types.js";
66
+ import { hasCanonicalProposalValidator } from "./validators/proposal-validators.js";
55
67
  import { repairProposalContent, validateProposal } from "./validators/proposals.js";
56
- // ── Source allow-list (F-4 / #385) ──────────────────────────────────────────
57
- /**
58
- * Curated allow-list of valid `source` values for proposals (F-4 / #385).
59
- *
60
- * Rationale (W3C PROV-DM 2013): Provenance records require typed, validated
61
- * sources for meaningful aggregation. Accept-rate-per-source is the core
62
- * self-measurement metric for recursive self-improvement: if reflect proposals
63
- * are accepted at 20% and distill proposals at 60%, that guides resource
64
- * allocation. Free-text typos (`"reflct"`) produce unaggregatable events.
65
- *
66
- * Automated sources (those in {@link AUTOMATED_PROPOSAL_SOURCES}) require a
67
- * `sourceRun` field for full PROV-DM traceability.
68
- */
69
- export const PROPOSAL_SOURCES = [
70
- // Automated sources — require sourceRun for traceability.
71
- "reflect",
72
- "distill",
73
- "consolidate",
74
- "extract",
75
- "improve",
76
- "recombine",
77
- "procedural",
78
- // Semi-automated / tool-driven.
79
- "feedback",
80
- // Human-initiated / CLI-driven.
81
- "propose",
82
- "remember",
83
- "import",
84
- // Internal / system.
85
- "distill_quality_rejected",
86
- "schema-repair",
87
- ];
88
- /** Automated sources that SHOULD include a `sourceRun` for PROV-DM traceability. */
89
- export const AUTOMATED_PROPOSAL_SOURCES = [
90
- "reflect",
91
- "distill",
92
- "consolidate",
93
- "extract",
94
- "improve",
95
- "recombine",
96
- "procedural",
97
- "schema-repair",
98
- ];
99
- /**
100
- * Check whether a string is a valid {@link ProposalSource}.
101
- * Unknown source values are accepted with a runtime warning rather than a hard
102
- * error, to allow extensions without breaking existing callers.
103
- */
104
- export function isValidProposalSource(source) {
105
- return PROPOSAL_SOURCES.includes(source);
106
- }
68
+ const PROMOTION_LINT_BLOCKERS = new Set(["unquoted-colon", "missing-ref", "stale-path"]);
69
+ // ── Proposal domain types (moved to ./proposal-types.ts, WI-9.8 KILL 1) ─────
70
+ //
71
+ // Proposal / ProposalStatus / ProposalPayload / ProposalReview /
72
+ // ProposalGateDecision(Outcome) / ProposalSource / PROPOSAL_SOURCES /
73
+ // AUTOMATED_PROPOSAL_SOURCES / isValidProposalSource / isAutomatedProposalSource
74
+ // moved to the dependency-free leaf so validators/proposals.ts,
75
+ // validators/proposal-validators.ts, storage/repositories/proposals-repository.ts,
76
+ // and storage repositories can import the `Proposal` type without importing
77
+ // this heavier transaction-engine module back. That back-edge was the
78
+ // repository↔validators import cycle (plan §10.7 D.3). Every symbol this
79
+ // module used to export directly is re-exported here verbatim so existing
80
+ // import sites (`from "../proposal/repository.js"`) are unchanged.
81
+ export { AUTOMATED_PROPOSAL_SOURCES, isAutomatedProposalSource, isValidProposalSource, PROPOSAL_SOURCES, } from "./proposal-types.js";
82
+ // The envelope's primary-content accessor lives in the dependency-free
83
+ // core/file-change module; re-exported here so proposal consumers get it
84
+ // alongside the repository API.
85
+ export { proposalContent };
107
86
  /**
108
- * Check whether a source value is an automated source requiring `sourceRun`.
87
+ * Copy of `p` with `content` replacing BOTH the payload's content and the
88
+ * primary change's `after` — every in-memory content mutation must keep the
89
+ * WI-6.2 invariant (`changes[0].after === payload.content`) intact.
109
90
  */
110
- export function isAutomatedProposalSource(source) {
111
- return AUTOMATED_PROPOSAL_SOURCES.includes(source);
91
+ function withProposalContent(p, content) {
92
+ return {
93
+ ...p,
94
+ payload: { ...p.payload, content },
95
+ changes: p.changes.map((c, i) =>
96
+ // A delete-op primary change carries no `after` (file-change.ts contract).
97
+ i === 0 && c.op !== "delete" ? { ...c, after: content } : c),
98
+ };
112
99
  }
113
100
  /** Type guard: true when createProposal returned a skipped record. */
114
101
  export function isProposalSkipped(result) {
115
102
  return result.skipped === true;
116
103
  }
117
- // ── Dedup / cooldown constants ───────────────────────────────────────────────
104
+ // ── Fingerprint / rejection-backoff constants ────────────────────────────────
118
105
  const MS_PER_DAY = 86_400_000;
119
106
  /**
120
- * Post-rejection cooldown windows by source. After a proposal is rejected,
121
- * `createProposal` silently skips new proposals for the same `ref+source`
122
- * until the window expires (unless `force: true` is passed).
107
+ * Post-rejection backoff windows by source (the RETAINED cooldown semantics,
108
+ * plan §4.5). After a proposal is rejected, `createProposal` silently skips
109
+ * new proposals for the same `ref+source` until the window expires (unless
110
+ * `force: true` is passed).
123
111
  *
124
112
  * Rationale (Settles 2009 active-learning survey; Argilla/Label Studio HITL):
125
- * Reviewer fatigue is a blocker for the human-in-the-loop guarantee. Cooldowns
126
- * prevent nightly improve runs from re-flooding the queue with near-identical
113
+ * Reviewer fatigue is a blocker for the human-in-the-loop guarantee. Backoff
114
+ * prevents nightly improve runs from re-flooding the queue with near-identical
127
115
  * proposals the reviewer just declined.
128
116
  *
129
117
  * - reflect: 14 days (agent-based; slower feedback loops)
@@ -152,49 +140,170 @@ function newId(ctx) {
152
140
  return fn();
153
141
  }
154
142
  /**
155
- * Open the state database (honouring the `ctx.dbPath` test seam), run the
156
- * legacy filesystem import for `stashDir` if it has not happened yet, hand the
143
+ * Open the state database (honouring the `ctx.dbPath` test seam), hand the
157
144
  * connection to `fn`, and close it in a `finally`. Every public function in
158
- * this module funnels its store access through here so the legacy import is
159
- * guaranteed to have run before any read or write.
145
+ * this module funnels its store access through here.
146
+ *
147
+ * `stashDir` is threaded through the public API for the store's per-stash
148
+ * partition.
160
149
  */
161
- function withProposalsDb(stashDir, ctx, fn) {
162
- return withStateDb((db) => {
163
- importLegacyProposalFiles(db, stashDir);
164
- return fn(db);
165
- }, { path: ctx?.dbPath });
150
+ function withProposalsDb(_stashDir, ctx, fn) {
151
+ return withStateDb(fn, { path: ctx?.dbPath });
152
+ }
153
+ function persistProposalUpdate(db, proposal, stashDir) {
154
+ // Only take the full re-serialize (upsertProposal -> proposalToRowValues)
155
+ // path when `changes` is well-formed enough to survive it — a non-empty
156
+ // path on every entry, matching proposalToRowValues' own validation. A
157
+ // legacy row with a present `proposedTarget` but an empty/missing
158
+ // `changes[].path` (e.g. a malformed 0.8-era row) would otherwise die in
159
+ // proposalToRowValues; falling through to the targeted UPDATE below keeps
160
+ // `changes`/`proposedTarget` untouched in metadata_json and only flips the
161
+ // status/review/etc. fields the terminal transition actually needs.
162
+ const changesAreWellFormed = proposal.changes.length > 0 && proposal.changes.every((change) => change.path.length > 0);
163
+ if (changesAreWellFormed && proposal.proposedTarget) {
164
+ upsertProposal(db, proposal, stashDir);
165
+ return;
166
+ }
167
+ const row = db
168
+ .prepare("SELECT metadata_json FROM proposals WHERE id = ? AND stash_dir = ?")
169
+ .get(proposal.id, stashDir);
170
+ if (!row)
171
+ throw new NotFoundError(`Proposal "${proposal.id}" not found.`, "FILE_NOT_FOUND");
172
+ const metadata = JSON.parse(row.metadata_json);
173
+ for (const field of [
174
+ "sourceRun",
175
+ "beforeHash",
176
+ "review",
177
+ "confidence",
178
+ "gateDecision",
179
+ "backupContent",
180
+ "acceptedTarget",
181
+ "eligibilitySource",
182
+ ]) {
183
+ const value = proposal[field];
184
+ if (value === undefined)
185
+ delete metadata[field];
186
+ else
187
+ metadata[field] = value;
188
+ }
189
+ db.prepare(`UPDATE proposals
190
+ SET ref = ?, status = ?, source = ?, updated_at = ?, content = ?, frontmatter_json = ?, metadata_json = ?
191
+ WHERE id = ? AND stash_dir = ?`).run(proposal.ref, proposal.status, proposal.source, proposal.updatedAt, proposal.payload.content, proposal.payload.frontmatter ? JSON.stringify(proposal.payload.frontmatter) : null, JSON.stringify(metadata), proposal.id, stashDir);
192
+ }
193
+ function proposalRefIdentity(ref) {
194
+ try {
195
+ const parsed = parseBundleRef(ref);
196
+ if (parsed.fragment !== undefined || isRetiredProposalConceptId(parsed.conceptId))
197
+ return undefined;
198
+ return {
199
+ conceptId: parsed.conceptId,
200
+ ...(parsed.bundle !== undefined ? { bundle: parsed.bundle } : {}),
201
+ };
202
+ }
203
+ catch {
204
+ return undefined;
205
+ }
206
+ }
207
+ function filterRefIdentity(ref) {
208
+ try {
209
+ const p = parseBundleRef(ref);
210
+ if (p.fragment !== undefined || isRetiredProposalConceptId(p.conceptId)) {
211
+ throw new Error("not a current proposal identity");
212
+ }
213
+ return {
214
+ conceptId: p.conceptId,
215
+ ...(p.bundle !== undefined ? { bundle: p.bundle } : {}),
216
+ };
217
+ }
218
+ catch {
219
+ throw new UsageError(`Invalid asset-ref filter "${ref}". Use the 0.9.0 grammar [bundle//]conceptId, e.g. knowledge/guide.md or lessons/deploy.`, "INVALID_FLAG_VALUE");
220
+ }
221
+ }
222
+ function isRetiredProposalConceptId(conceptId) {
223
+ const colon = conceptId.indexOf(":");
224
+ return colon > 0 && stashDirFor(conceptId.slice(0, colon)) !== undefined;
225
+ }
226
+ function proposalMatchesRef(proposalRef, filter) {
227
+ const proposal = proposalRefIdentity(proposalRef);
228
+ return (proposal !== undefined &&
229
+ proposal.conceptId === filter.conceptId &&
230
+ (filter.bundle === undefined || proposal.bundle === filter.bundle));
231
+ }
232
+ function proposalDurableRef(parsedRef, target) {
233
+ const conceptId = conceptIdFromTypeName(parsedRef.type, parsedRef.name);
234
+ const { origin } = parsedRef;
235
+ if (!isBundleSlug(target.source)) {
236
+ throw new UsageError(`Proposal target source "${target.source}" is not a valid bundle name.`, "INVALID_FLAG_VALUE");
237
+ }
238
+ if (origin !== undefined) {
239
+ if (target.source !== origin) {
240
+ throw new UsageError(`Proposal ref bundle "${origin}" conflicts with target source "${target.source}".`, "INVALID_FLAG_VALUE");
241
+ }
242
+ return `${origin}//${conceptId}`;
243
+ }
244
+ return `${target.source}//${conceptId}`;
245
+ }
246
+ function resolveCreateProposalTarget(stashDir, explicit, bundle) {
247
+ if (explicit) {
248
+ if (!isBundleSlug(explicit.source) || !explicit.root.trim()) {
249
+ throw new UsageError("Proposal targets require a current bundle name and materialized root.", "INVALID_FLAG_VALUE");
250
+ }
251
+ return { source: explicit.source, root: path.resolve(explicit.root) };
252
+ }
253
+ const config = loadConfig();
254
+ const local = resolveProposalQueueTarget(stashDir, config);
255
+ if (!bundle || bundle === local.source)
256
+ return local;
257
+ if (bundle) {
258
+ const target = resolveBundleWriteTarget(config, bundle);
259
+ return { source: target.source.name, root: target.source.path };
260
+ }
261
+ return local;
262
+ }
263
+ export function resolveProposalQueueTarget(stashDir, config = loadConfig()) {
264
+ const root = path.resolve(stashDir);
265
+ const sources = resolveSourceEntries(root, config);
266
+ const sourceIndex = sources.findIndex((source) => path.resolve(source.path) === root);
267
+ const source = sources[sourceIndex];
268
+ const bundleId = sourceIndex >= 0 ? deriveInstallations(sources)[sourceIndex]?.id : undefined;
269
+ if (!source || !bundleId) {
270
+ throw new ConfigError(`No bundle owns proposal queue ${root}.`, "INVALID_CONFIG_FILE");
271
+ }
272
+ if (!source.registryId && Object.keys(config.bundles ?? {}).length > 0) {
273
+ throw new ConfigError(`No configured bundle owns proposal queue ${root}.`, "INVALID_CONFIG_FILE");
274
+ }
275
+ if (source.writable !== true) {
276
+ throw new UsageError(`Proposal bundle "${bundleId}" is not writable.`, "INVALID_FLAG_VALUE");
277
+ }
278
+ return { source: bundleId, root };
166
279
  }
167
280
  // ── Public API ──────────────────────────────────────────────────────────────
168
281
  /**
169
282
  * Create a new pending proposal. The id is a stable random UUID, so two
170
283
  * proposals with the same `ref` never collide.
171
284
  *
172
- * **Dedup / cooldown guard** (F-2 / #363):
285
+ * **Input-fingerprint / rejection-backoff guard** (§23.6, WI-6.4):
173
286
  *
174
287
  * Before writing, this function checks:
175
- * 1. `duplicate_pending` — a pending proposal already exists for the same
176
- * `ref+source`. Pass `input.force = true` to bypass.
177
- * 2. `content_hash_match` — an identical content hash is already pending or
178
- * was recently rejected for this `ref+source`. Bypass with `force: true`.
179
- * 3. `cooldown` — a proposal for this `ref+source` was rejected within the
180
- * source-specific cooldown window (reflect: 14 d, distill: 30 d,
181
- * others: 7 d). Bypass with `force: true`.
288
+ * 1. `fingerprint_match` — the §23.6 input fingerprint (scheme version,
289
+ * source, ref, target before-hash, model id) was already processed.
290
+ * The row survives the proposal's lifecycle, so identical inputs stay
291
+ * deduplicated until the target, model, or scheme changes. Pass
292
+ * `input.force = true` to bypass.
293
+ * 2. `rejection_backoff` — a proposal for this `ref+source` was rejected
294
+ * within the source-specific backoff window (reflect: 14 d, distill:
295
+ * 30 d, others: 7 d). Bypass with `force: true`.
182
296
  *
183
297
  * When a guard fires the function returns a `CreateProposalSkipped` record
184
298
  * instead of writing. Use {@link isProposalSkipped} to detect it.
185
299
  */
186
300
  export function createProposal(stashDir, input, ctx) {
187
- // F-4 / #385: Validate source against the allow-list. Unknown values are
188
- // warned (not rejected) for backward compatibility — extension callers
189
- // that pass custom source strings must not break.
190
301
  if (!isValidProposalSource(input.source)) {
191
302
  warn(`[proposal] Unknown source "${input.source}". ` +
192
303
  `Expected one of: ${PROPOSAL_SOURCES.join(", ")}. ` +
193
304
  "Typos in source values produce unaggregatable accept-rate-per-source metrics.");
194
305
  }
195
306
  else if (isAutomatedProposalSource(input.source) && !input.sourceRun) {
196
- // Advisory warning: automated sources should include sourceRun for PROV-DM
197
- // traceability. This is not a hard error to avoid breaking existing callers.
198
307
  warn(`[proposal] Automated source "${input.source}" created a proposal without sourceRun. ` +
199
308
  "Add sourceRun to enable accept-rate-per-run aggregation (W3C PROV-DM).");
200
309
  }
@@ -212,17 +321,20 @@ export function createProposal(stashDir, input, ctx) {
212
321
  };
213
322
  let parsedRef;
214
323
  try {
215
- parsedRef = parseAssetRef(input.ref);
324
+ parsedRef = parseRefInput(input.ref);
216
325
  }
217
326
  catch (err) {
218
327
  return rejectProposal("invalid_ref", `Invalid proposal ref "${input.ref}": ${err instanceof Error ? err.message : String(err)}`);
219
328
  }
220
- if (!TYPE_DIRS[parsedRef.type]) {
221
- return rejectProposal("unknown_type", `Unknown asset type "${parsedRef.type}" in proposal ref "${input.ref}". Known types: ${Object.keys(TYPE_DIRS).sort().join(", ")}.`);
329
+ if (!stashDirFor(parsedRef.type)) {
330
+ return rejectProposal("unknown_type", `Unknown asset type "${parsedRef.type}" in proposal ref "${input.ref}". Known types: ${[...placementTypes()].sort().join(", ")}.`);
222
331
  }
223
332
  if (!input.payload.content.trim()) {
224
333
  return rejectProposal("empty_content", `Proposal for "${input.ref}" has empty content.`);
225
334
  }
335
+ if (input.target && parsedRef.origin && input.target.source !== parsedRef.origin) {
336
+ return rejectProposal("invalid_ref", `Qualified proposal ref bundle "${parsedRef.origin}" conflicts with queue target "${input.target.source}".`);
337
+ }
226
338
  // Description check is only enforced for `consolidate` source — that's the
227
339
  // automated pipeline that historically produced proposals with missing or
228
340
  // malformed frontmatter, polluting the queue with hundreds of unusable
@@ -234,18 +346,73 @@ export function createProposal(stashDir, input, ctx) {
234
346
  return rejectProposal("missing_description", `Proposal for "${input.ref}" (source=consolidate) has empty or missing frontmatter description.`);
235
347
  }
236
348
  }
237
- const normalizedRef = makeAssetRef(parsedRef.type, parsedRef.name, parsedRef.origin);
349
+ const proposalTarget = resolveCreateProposalTarget(stashDir, input.target, parsedRef.origin);
350
+ const normalizedRef = proposalDurableRef(parsedRef, proposalTarget);
351
+ const targetRoot = path.resolve(proposalTarget.root);
352
+ // WI-6.2: derive the FileChange[] envelope + mint-time beforeHash. The
353
+ // target is resolved against the proposal's OWN stash (a local snapshot —
354
+ // accept re-resolves the write target from config at apply time), and only
355
+ // the before-state's HASH is kept: the change's `before` body is a
356
+ // transaction-time capture that does not exist at mint time.
357
+ let targetRelPath;
358
+ let mintBeforeContent;
359
+ try {
360
+ const typeRoot = path.join(targetRoot, stashDirFor(parsedRef.type));
361
+ const targetAbs = assetPathForName(parsedRef.type, typeRoot, parsedRef.name);
362
+ targetRelPath = path.relative(targetRoot, targetAbs);
363
+ if (fs.existsSync(targetAbs))
364
+ mintBeforeContent = fs.readFileSync(targetAbs, "utf8");
365
+ }
366
+ catch {
367
+ // Resolution failure degrades to a best-effort create — never blocks the mint.
368
+ targetRelPath = path.join(stashDirFor(parsedRef.type), parsedRef.name);
369
+ }
370
+ const proposalContent = targetRelPath.toLowerCase().endsWith(".md")
371
+ ? ensureAkmMarkdownType(input.payload.content, parsedRef.type)
372
+ : input.payload.content;
373
+ const mintedChanges = [
374
+ {
375
+ path: targetRelPath,
376
+ after: proposalContent,
377
+ op: mintBeforeContent !== undefined ? "update" : "create",
378
+ },
379
+ ];
380
+ const mintedBeforeHash = mintBeforeContent !== undefined ? contentHash(mintBeforeContent) : undefined;
381
+ if (hasCanonicalProposalValidator(parsedRef.type)) {
382
+ const report = validateProposal({
383
+ id: "pending",
384
+ ref: normalizedRef,
385
+ status: "pending",
386
+ source: input.source,
387
+ createdAt: "",
388
+ updatedAt: "",
389
+ payload: { ...input.payload, content: proposalContent },
390
+ changes: mintedChanges,
391
+ proposedTarget: { source: proposalTarget.source, root: targetRoot },
392
+ });
393
+ if (!report.ok) {
394
+ return rejectProposal("invalid_canonical_structure", `Proposal for "${input.ref}" has invalid ${parsedRef.type} structure:\n${report.findings
395
+ .map((finding) => `[${finding.kind}] ${finding.message}`)
396
+ .join("\n")}`);
397
+ }
398
+ }
399
+ const fingerprint = computeProposalFingerprint({
400
+ ref: normalizedRef,
401
+ source: input.source,
402
+ ...(mintedBeforeHash !== undefined ? { beforeHash: mintedBeforeHash } : {}),
403
+ ...(input.modelId !== undefined ? { modelId: input.modelId } : {}),
404
+ });
238
405
  return withProposalsDb(stashDir, ctx, (db) => {
239
406
  return withImmediateTransaction(db, () => {
240
407
  if (!input.force) {
241
- const skip = checkDedupAndCooldown(db, stashDir, normalizedRef, input, ctx);
408
+ const skip = checkFingerprintAndBackoff(db, stashDir, normalizedRef, input, fingerprint, ctx);
242
409
  if (skip)
243
410
  return skip;
244
411
  }
245
412
  const created = nowIso(ctx);
246
413
  // Phase 6A: validate confidence is a finite number in [0, 1]. Anything else
247
414
  // is dropped silently — we never store NaN, Infinity, or out-of-range values.
248
- // Callers that mis-report confidence should not poison the auto-accept gate.
415
+ // Callers that mis-report confidence should not poison downstream readers.
249
416
  const sanitizedConfidence = typeof input.confidence === "number" &&
250
417
  Number.isFinite(input.confidence) &&
251
418
  input.confidence >= 0 &&
@@ -261,74 +428,98 @@ export function createProposal(stashDir, input, ctx) {
261
428
  createdAt: created,
262
429
  updatedAt: created,
263
430
  payload: {
264
- content: input.payload.content,
431
+ content: proposalContent,
265
432
  ...(input.payload.frontmatter !== undefined ? { frontmatter: input.payload.frontmatter } : {}),
266
433
  },
434
+ changes: mintedChanges,
435
+ proposedTarget: { source: proposalTarget.source, root: targetRoot },
436
+ ...(mintedBeforeHash !== undefined ? { beforeHash: mintedBeforeHash } : {}),
267
437
  ...(sanitizedConfidence !== undefined ? { confidence: sanitizedConfidence } : {}),
268
438
  // Attribution tagging: persist the eligibility lane so it survives to
269
439
  // accept/reject/revert time. See EligibilitySource.
270
440
  ...(input.eligibilitySource !== undefined ? { eligibilitySource: input.eligibilitySource } : {}),
271
441
  };
272
442
  upsertProposal(db, proposal, stashDir);
443
+ // Record the processed fingerprint (also on force — a forced enqueue is
444
+ // still "these inputs were processed"; future unforced identical inputs
445
+ // dedup against it).
446
+ recordProposalFingerprint(db, stashDir, fingerprint, normalizedRef, input, proposal.id, created);
273
447
  return proposal;
274
448
  });
275
449
  });
276
450
  }
451
+ /** Version stamp of the input-fingerprint scheme; bump when terms change. */
452
+ const PROPOSAL_FINGERPRINT_VERSION = 1;
277
453
  /**
278
- * Evaluate the F-2 dedup / cooldown guards against the store. Returns the
279
- * skip record when a guard fires, or undefined when the create may proceed.
454
+ * Compute the §23.6 input fingerprint for a proposal mint (+ the plan §4.5
455
+ * engine/model-id term). Terms, in order: scheme version, source (the recipe
456
+ * stand-in until Wave-2 recipes exist), target ref, target before-hash
457
+ * (empty for a create), evidence IDs/hashes (reserved — not yet modeled),
458
+ * guidance hashes (reserved), evaluator version (reserved), model id.
459
+ * Deliberately an INPUT fingerprint: the generated content is not a term —
460
+ * already-processed inputs skip re-processing regardless of what the model
461
+ * produced this time.
280
462
  */
281
- function checkDedupAndCooldown(db, stashDir, normalizedRef, input, ctx) {
282
- const newHash = contentHash(input.payload.content);
463
+ function computeProposalFingerprint(args) {
464
+ return contentHash([
465
+ `v${PROPOSAL_FINGERPRINT_VERSION}`,
466
+ args.source,
467
+ args.ref,
468
+ args.beforeHash ?? "",
469
+ "", // evidence IDs/hashes — reserved (Wave-2 recipes)
470
+ "", // guidance hashes — reserved
471
+ "", // evaluator version — reserved
472
+ args.modelId ?? "",
473
+ ].join("\0"));
474
+ }
475
+ /**
476
+ * Durably record a processed fingerprint (INSERT OR REPLACE — idempotent).
477
+ * `ref` must be the NORMALIZED ref — the same value the fingerprint was
478
+ * computed over — so future ref-keyed readers of the table never mismatch.
479
+ */
480
+ function recordProposalFingerprint(db, stashDir, fingerprint, ref, input, proposalId, createdAt) {
481
+ db.prepare(`INSERT OR REPLACE INTO proposal_fingerprints
482
+ (stash_dir, fingerprint, ref, source, model_id, proposal_id, created_at)
483
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(stashDir, fingerprint, ref, input.source, input.modelId ?? "", proposalId, createdAt);
484
+ }
485
+ /**
486
+ * Evaluate the fingerprint + rejection-backoff guards. Returns the skip
487
+ * record when a guard fires, or undefined when the create may proceed.
488
+ */
489
+ function checkFingerprintAndBackoff(db, stashDir, normalizedRef, input, fingerprint, ctx) {
283
490
  const nowMs = (ctx?.now ?? Date.now)();
284
- const cooldownMs = cooldownMsForSource(input.source);
285
- // Scan pending proposals for ref+source matches.
286
- const pending = listStateProposals(db, { stashDir, ref: normalizedRef, status: "pending" }).filter((p) => p.source === input.source);
287
- if (pending.length > 0) {
288
- // Check for identical content hash first (silent skip).
289
- const hashMatch = pending.find((p) => contentHash(p.payload.content) === newHash);
290
- if (hashMatch) {
291
- return {
292
- skipped: true,
293
- reason: "content_hash_match",
294
- message: `Identical proposal for ${normalizedRef} already pending (id: ${hashMatch.id}).`,
295
- existingProposalId: hashMatch.id,
296
- };
297
- }
298
- // Duplicate pending for same ref+source (different content).
299
- const firstPending = pending[0];
491
+ const backoffMs = cooldownMsForSource(input.source);
492
+ // §23.6: an already-processed fingerprint skips another model call's output
493
+ // unless explicitly forced. The row survives the proposal's lifecycle —
494
+ // identical inputs stay deduplicated until the target (before-hash), the
495
+ // model, or the scheme changes.
496
+ const existing = db
497
+ .prepare("SELECT proposal_id FROM proposal_fingerprints WHERE stash_dir = ? AND fingerprint = ?")
498
+ .get(stashDir, fingerprint);
499
+ if (existing) {
300
500
  return {
301
501
  skipped: true,
302
- reason: "duplicate_pending",
303
- message: `A pending proposal for ${normalizedRef} from source "${input.source}" already exists (id: ${firstPending?.id ?? "unknown"}). Pass force:true to enqueue alongside it.`,
304
- existingProposalId: firstPending?.id,
502
+ reason: "fingerprint_match",
503
+ message: `These inputs were already processed into a proposal for ${normalizedRef} (fingerprint match). Pass force:true to enqueue anyway.`,
504
+ ...(existing.proposal_id ? { existingProposalId: existing.proposal_id } : {}),
305
505
  };
306
506
  }
307
- // Check cooldown against recently rejected proposals.
507
+ // Rejection backoff (RETAINED cooldown semantics): a recent rejection for
508
+ // this ref+source suppresses new proposals until the window expires.
308
509
  const rejected = listStateProposals(db, { stashDir, ref: normalizedRef, status: "rejected" })
309
510
  .filter((p) => p.source === input.source)
310
511
  .sort((a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime());
311
512
  const mostRecent = rejected[0];
312
513
  if (mostRecent !== undefined) {
313
- // Check content hash against recently rejected.
314
- if (contentHash(mostRecent.payload.content) === newHash) {
315
- return {
316
- skipped: true,
317
- reason: "content_hash_match",
318
- message: `Identical proposal for ${normalizedRef} was already rejected (id: ${mostRecent.id}).`,
319
- existingProposalId: mostRecent.id,
320
- };
321
- }
322
- // Check cooldown window.
323
514
  const rejectedAt = new Date(mostRecent.updatedAt ?? 0).getTime();
324
- if (nowMs - rejectedAt < cooldownMs) {
325
- const cooldownDays = cooldownMs / MS_PER_DAY;
326
- const remainingDays = Math.ceil((cooldownMs - (nowMs - rejectedAt)) / MS_PER_DAY);
515
+ if (nowMs - rejectedAt < backoffMs) {
516
+ const backoffDays = backoffMs / MS_PER_DAY;
517
+ const remainingDays = Math.ceil((backoffMs - (nowMs - rejectedAt)) / MS_PER_DAY);
327
518
  return {
328
519
  skipped: true,
329
- reason: "cooldown",
330
- message: `Proposal for ${normalizedRef} from source "${input.source}" is in cooldown ` +
331
- `(${cooldownDays}d window, ~${remainingDays}d remaining). Pass force:true to bypass.`,
520
+ reason: "rejection_backoff",
521
+ message: `Proposal for ${normalizedRef} from source "${input.source}" is in rejection backoff ` +
522
+ `(${backoffDays}d window, ~${remainingDays}d remaining). Pass force:true to bypass.`,
332
523
  existingProposalId: mostRecent.id,
333
524
  };
334
525
  }
@@ -349,15 +540,21 @@ export function listProposals(stashDir, options = {}, ctx) {
349
540
  return [];
350
541
  }
351
542
  const status = options.includeArchive ? options.status : "pending";
543
+ // Short filters match by conceptId; qualified filters additionally retain
544
+ // bundle identity. Applied in JS because a short query does not equal the
545
+ // fully-qualified stored ref.
546
+ const wantRef = options.ref !== undefined ? filterRefIdentity(options.ref) : undefined;
352
547
  return listStateProposals(db, {
353
548
  stashDir,
354
549
  ...(status !== undefined ? { status } : {}),
355
- ...(options.ref !== undefined ? { ref: options.ref } : {}),
356
550
  }).filter((p) => {
551
+ if (wantRef !== undefined && !proposalMatchesRef(p.ref, wantRef)) {
552
+ return false;
553
+ }
357
554
  if (!options.type)
358
555
  return true;
359
556
  try {
360
- return parseAssetRef(p.ref).type === options.type;
557
+ return parseRefInput(p.ref).type === options.type;
361
558
  }
362
559
  catch {
363
560
  return false;
@@ -379,12 +576,31 @@ function requireProposal(db, stashDir, id) {
379
576
  }
380
577
  return proposal;
381
578
  }
579
+ /**
580
+ * Lenient counterpart to {@link requireProposal} / {@link getProposal}, used
581
+ * ONLY on the terminal-status (reject/archive) read paths — `archiveProposal`
582
+ * and the reject-transaction machinery (`rejectProposalDurably`,
583
+ * `finalizeRejectTransaction`). Those paths must succeed even when the row's
584
+ * metadata fails strict decoding (see {@link proposalRowToProposalLenient});
585
+ * every other reader (accept, revert, show, list's pending branch) keeps
586
+ * strict decoding via `getProposal`/`requireProposal` unchanged.
587
+ */
588
+ function requireProposalLenient(db, stashDir, id) {
589
+ const proposal = getStateProposalLenient(db, id, stashDir);
590
+ if (!proposal) {
591
+ throw new NotFoundError(`Proposal "${id}" not found.`, "FILE_NOT_FOUND");
592
+ }
593
+ return proposal;
594
+ }
595
+ function getProposalLenient(stashDir, id, ctx) {
596
+ return withProposalsDb(stashDir, ctx, (db) => requireProposalLenient(db, stashDir, id));
597
+ }
382
598
  /**
383
599
  * Resolve a proposal by full UUID, UUID prefix, or asset ref.
384
600
  *
385
601
  * Resolution order:
386
602
  * 1. Exact UUID match (existing behaviour).
387
- * 2. Asset ref (contains `:`) — finds the most-recent pending proposal for
603
+ * 2. Asset ref (contains `/`) — finds the most-recent pending proposal for
388
604
  * that ref; falls back to archived if nothing is pending.
389
605
  * 3. UUID prefix — matches any PENDING proposal whose id starts with the
390
606
  * given string; throws if ambiguous.
@@ -395,14 +611,16 @@ export function resolveProposalId(stashDir, idOrRef, ctx) {
395
611
  const exact = getStateProposal(db, idOrRef, stashDir);
396
612
  if (exact)
397
613
  return exact;
398
- // 2. Asset ref (e.g. "skill:akm-dream") — most recent pending, else most
399
- // recent archived.
400
- if (idOrRef.includes(":")) {
614
+ // 2. Asset ref — most recent pending, else most recent archived. Qualified
615
+ // refs retain bundle identity; short refs match by conceptId in this queue.
616
+ const wantRef = idOrRef.includes(":") || idOrRef.includes("/") ? filterRefIdentity(idOrRef) : undefined;
617
+ if (wantRef !== undefined) {
401
618
  const byRecency = (proposals) => proposals.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime())[0];
402
- const pending = byRecency(listStateProposals(db, { stashDir, ref: idOrRef, status: "pending" }));
619
+ const forConcept = (status) => listStateProposals(db, { stashDir, ...(status !== undefined ? { status } : {}) }).filter((p) => proposalMatchesRef(p.ref, wantRef));
620
+ const pending = byRecency(forConcept("pending"));
403
621
  if (pending)
404
622
  return pending;
405
- const archived = byRecency(listStateProposals(db, { stashDir, ref: idOrRef }));
623
+ const archived = byRecency(forConcept());
406
624
  if (archived)
407
625
  return archived;
408
626
  throw new NotFoundError(`No proposal found for ref "${idOrRef}".`, "FILE_NOT_FOUND");
@@ -417,15 +635,34 @@ export function resolveProposalId(stashDir, idOrRef, ctx) {
417
635
  throw new NotFoundError(`Proposal "${idOrRef}" not found.`, "FILE_NOT_FOUND");
418
636
  });
419
637
  }
638
+ /**
639
+ * Resolve a reject target's id, tolerating a legacy row whose metadata fails
640
+ * strict decoding. `akm proposal reject` only needs the id — but
641
+ * {@link resolveProposalId}'s exact-UUID fast path decodes the FULL proposal
642
+ * just to hand it back, which throws on those rows before the
643
+ * terminal-status machinery (which reads leniently — see
644
+ * `requireProposalLenient`) ever runs. Falls back to `resolveProposalId`
645
+ * unchanged for ref / uuid-prefix inputs, where existing resolution
646
+ * (including its error semantics) is preserved.
647
+ */
648
+ export function resolveProposalIdForReject(stashDir, idOrRef, ctx) {
649
+ const exact = withProposalsDb(stashDir, ctx, (db) => getStateProposalLenient(db, idOrRef, stashDir));
650
+ if (exact)
651
+ return exact.id;
652
+ return resolveProposalId(stashDir, idOrRef, ctx).id;
653
+ }
420
654
  /**
421
655
  * Archive a proposal: flip its status to `accepted` / `rejected`, bump
422
656
  * `updatedAt`, and record the review block. Used by both accept and reject
423
657
  * paths so the live queue only contains pending entries.
424
658
  */
425
- export function archiveProposal(stashDir, id, status, reason, ctx) {
659
+ export function archiveProposal(stashDir, id, status, reason, ctx, gateDecision) {
426
660
  return withProposalsDb(stashDir, ctx, (db) => {
427
661
  return withImmediateTransaction(db, () => {
428
- const existing = requireProposal(db, stashDir, id);
662
+ // Lenient: archiving (accept OR reject) a legacy row with malformed
663
+ // changes/proposedTarget metadata must still be able to flip status —
664
+ // see requireProposalLenient.
665
+ const existing = requireProposalLenient(db, stashDir, id);
429
666
  if (existing.status !== "pending") {
430
667
  throw new UsageError(`Proposal ${id} is not pending (current status: ${existing.status}). Only pending proposals can be ${status}.`, "INVALID_FLAG_VALUE");
431
668
  }
@@ -439,16 +676,18 @@ export function archiveProposal(stashDir, id, status, reason, ctx) {
439
676
  ...(reason !== undefined ? { reason } : {}),
440
677
  decidedAt,
441
678
  },
679
+ ...(gateDecision ? { gateDecision: { ...gateDecision, decidedAt: gateDecision.decidedAt ?? decidedAt } } : {}),
442
680
  };
443
- upsertProposal(db, updated, stashDir);
681
+ persistProposalUpdate(db, updated, stashDir);
444
682
  return updated;
445
683
  });
446
684
  });
447
685
  }
448
686
  /**
449
- * Record an automated gate's decision onto a proposal (#577).
687
+ * Record the drain/triage engine's decision onto a proposal (#577).
688
+ * Drain-owned audit machinery — the deterministic drain engine is the writer.
450
689
  *
451
- * Stamps `gateDecision` (decision / reason / confidence / thresholds) onto the
690
+ * Stamps `gateDecision` (decision / reason / measurement / thresholds) onto the
452
691
  * row so `akm proposal show` and `list` can explain why a proposal landed where
453
692
  * it did. The decision is metadata about the adjudication, so this does NOT
454
693
  * change `status` or bump `updatedAt` — a `deferred` proposal stays `pending`,
@@ -469,7 +708,7 @@ export function recordGateDecision(stashDir, id, decision, ctx) {
469
708
  ...existing,
470
709
  gateDecision: { ...decision, decidedAt: decision.decidedAt ?? nowIso(ctx) },
471
710
  };
472
- upsertProposal(db, updated, stashDir);
711
+ persistProposalUpdate(db, updated, stashDir);
473
712
  return updated;
474
713
  });
475
714
  });
@@ -493,7 +732,7 @@ export function purgeOrphanProposals(stashDir, sourceDirs, ctx) {
493
732
  for (const p of reflectPending) {
494
733
  let parsed;
495
734
  try {
496
- parsed = parseAssetRef(p.ref);
735
+ parsed = parseRefInput(p.ref);
497
736
  }
498
737
  catch {
499
738
  continue;
@@ -501,12 +740,12 @@ export function purgeOrphanProposals(stashDir, sourceDirs, ctx) {
501
740
  // Lessons are new-asset proposals by definition — they cannot be orphaned.
502
741
  if (parsed.type === "lesson")
503
742
  continue;
504
- const spec = TYPE_DIRS[parsed.type];
743
+ const spec = stashDirFor(parsed.type);
505
744
  if (!spec)
506
745
  continue;
507
746
  const exists = sourceDirs.some((root) => {
508
747
  const typeRoot = path.join(root, spec);
509
- const candidate = resolveAssetPathFromName(parsed.type, typeRoot, parsed.name);
748
+ const candidate = assetPathForName(parsed.type, typeRoot, parsed.name);
510
749
  return fs.existsSync(candidate);
511
750
  });
512
751
  if (!exists) {
@@ -596,6 +835,15 @@ export function expireStaleProposals(stashDir, config, ctx) {
596
835
  warn(`[proposals] expireStaleProposals: failed to expire ${p.id}: ${err instanceof Error ? err.message : String(err)}`);
597
836
  }
598
837
  }
838
+ // Prune fingerprint rows past the same retention window (best-effort):
839
+ // ISO created_at strings compare lexicographically.
840
+ try {
841
+ const cutoffIso = new Date(nowMs - retentionMs).toISOString();
842
+ withProposalsDb(stashDir, ctx, (db) => db.prepare("DELETE FROM proposal_fingerprints WHERE stash_dir = ? AND created_at < ?").run(stashDir, cutoffIso));
843
+ }
844
+ catch (err) {
845
+ warn(`[proposals] expireStaleProposals: fingerprint prune failed: ${err instanceof Error ? err.message : String(err)}`);
846
+ }
599
847
  return {
600
848
  checked: pending.length,
601
849
  expired: expiredProposals.length,
@@ -604,6 +852,676 @@ export function expireStaleProposals(stashDir, config, ctx) {
604
852
  expiredProposals,
605
853
  };
606
854
  }
855
+ const PROPOSAL_TXN_KIND = "proposal";
856
+ const PROPOSAL_TXN_PHASES = [
857
+ "prepared",
858
+ "asset-published",
859
+ "proposal-persisted",
860
+ "index-finalized",
861
+ "event-finalized",
862
+ "committed",
863
+ ];
864
+ /** TEST-ONLY crash-window hook used by subprocess recovery tests. */
865
+ export function _setProposalMutationHookForTests(hook) {
866
+ _setTxnMutationHookForTests(hook);
867
+ }
868
+ function proposalHash(content) {
869
+ return createHash("sha256").update(content).digest("hex");
870
+ }
871
+ function proposalFileHash(filePath) {
872
+ return proposalHash(fs.readFileSync(filePath));
873
+ }
874
+ function sameProposalFile(left, right) {
875
+ try {
876
+ const leftStat = fs.statSync(left);
877
+ const rightStat = fs.statSync(right);
878
+ return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino;
879
+ }
880
+ catch {
881
+ return false;
882
+ }
883
+ }
884
+ function cleanupProposalPublication(p) {
885
+ for (const filePath of [p.publishPath, p.displacedPath]) {
886
+ try {
887
+ fs.rmSync(filePath, { force: true });
888
+ }
889
+ catch (error) {
890
+ warn(`[proposals] transaction publication cleanup failed at ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
891
+ }
892
+ }
893
+ fsyncTxnDir(path.dirname(p.assetPath));
894
+ }
895
+ function rollbackPreparedProposalTransaction(txn) {
896
+ const p = txn.journal.payload;
897
+ const currentHash = fs.existsSync(p.assetPath) ? proposalFileHash(p.assetPath) : null;
898
+ if (!fs.existsSync(p.displacedPath)) {
899
+ if (p.originalHash === null) {
900
+ if (currentHash === p.publishedHash && sameProposalFile(p.assetPath, p.publishPath)) {
901
+ fs.unlinkSync(p.assetPath);
902
+ }
903
+ else if (currentHash !== null) {
904
+ throw new Error(`Cannot roll back proposal transaction: target was created externally.`);
905
+ }
906
+ }
907
+ else if (currentHash !== p.originalHash) {
908
+ throw new Error(`Cannot roll back proposal transaction: ${p.assetPath} diverged.`);
909
+ }
910
+ cleanupProposalPublication(p);
911
+ return;
912
+ }
913
+ if (currentHash === p.publishedHash)
914
+ fs.unlinkSync(p.assetPath);
915
+ else if (currentHash !== null && currentHash !== p.originalHash) {
916
+ throw new Error(`Cannot roll back proposal transaction: ${p.assetPath} diverged.`);
917
+ }
918
+ if (fs.existsSync(p.displacedPath)) {
919
+ if (fs.existsSync(p.assetPath)) {
920
+ throw new Error(`Cannot restore proposal backup: ${p.assetPath} is occupied.`);
921
+ }
922
+ fs.linkSync(p.displacedPath, p.assetPath);
923
+ }
924
+ cleanupProposalPublication(p);
925
+ }
926
+ function validatePublishedProposal(p) {
927
+ if (!fs.existsSync(p.assetPath) || proposalFileHash(p.assetPath) !== p.publishedHash) {
928
+ throw new Error(`Cannot recover proposal ${p.proposalId}: published asset diverged.`);
929
+ }
930
+ }
931
+ function persistProposalTransactionState(txn, proposal, ctx) {
932
+ const p = txn.journal.payload;
933
+ const decidedAt = txn.journal.decidedAt;
934
+ const backupContent = p.backupPath ? fs.readFileSync(p.backupPath, "utf8") : undefined;
935
+ const publishedContent = fs.readFileSync(p.contentPath, "utf8");
936
+ return withProposalsDb(p.stashDir, ctx, (db) => withImmediateTransaction(db, () => {
937
+ const current = requireProposal(db, p.stashDir, p.proposalId);
938
+ if (p.operation === "accept") {
939
+ if (current.status === "accepted") {
940
+ if (current.acceptedTarget?.contentHash !== p.publishedHash) {
941
+ throw new Error(`Accepted proposal ${p.proposalId} does not match its recovery journal.`);
942
+ }
943
+ return current;
944
+ }
945
+ if (current.status !== "pending") {
946
+ throw new Error(`Proposal ${p.proposalId} changed status during acceptance (${current.status}).`);
947
+ }
948
+ const persistedProposal = proposal.changes.length > 0 && proposal.changes.every((change) => change.path.length > 0)
949
+ ? withProposalContent(proposal, publishedContent)
950
+ : {
951
+ ...proposal,
952
+ payload: { ...proposal.payload, content: publishedContent },
953
+ changes: [
954
+ {
955
+ path: path.relative(txn.journal.root, p.assetPath),
956
+ op: p.originalHash === null ? "create" : "update",
957
+ after: publishedContent,
958
+ },
959
+ ],
960
+ proposedTarget: { source: p.targetSource, root: txn.journal.root },
961
+ };
962
+ const accepted = {
963
+ ...persistedProposal,
964
+ status: "accepted",
965
+ updatedAt: decidedAt,
966
+ review: { outcome: "accepted", decidedAt },
967
+ acceptedTarget: {
968
+ source: p.targetSource,
969
+ root: txn.journal.root,
970
+ path: p.assetPath,
971
+ contentHash: p.publishedHash,
972
+ },
973
+ ...(p.gateDecision
974
+ ? { gateDecision: { ...p.gateDecision, decidedAt: p.gateDecision.decidedAt ?? decidedAt } }
975
+ : {}),
976
+ ...(backupContent !== undefined ? { backupContent } : {}),
977
+ };
978
+ upsertProposal(db, accepted, p.stashDir);
979
+ return accepted;
980
+ }
981
+ if (current.status === "reverted")
982
+ return current;
983
+ if (current.status !== "accepted") {
984
+ throw new Error(`Proposal ${p.proposalId} changed status during reversion (${current.status}).`);
985
+ }
986
+ const reverted = {
987
+ ...current,
988
+ status: "reverted",
989
+ updatedAt: decidedAt,
990
+ review: {
991
+ outcome: "rejected",
992
+ reason: "reverted: prior content restored from backup",
993
+ decidedAt,
994
+ },
995
+ };
996
+ upsertProposal(db, reverted, p.stashDir);
997
+ return reverted;
998
+ }));
999
+ }
1000
+ function persistProposalEvent(txn, proposal, ctx) {
1001
+ const p = txn.journal.payload;
1002
+ withProposalsDb(p.stashDir, ctx, (db) => withImmediateTransaction(db, () => {
1003
+ const metadata = {
1004
+ proposalId: proposal.id,
1005
+ source: proposal.source,
1006
+ ...(proposal.sourceRun !== undefined ? { sourceRun: proposal.sourceRun } : {}),
1007
+ assetPath: p.assetPath,
1008
+ ...(proposal.eligibilitySource !== undefined ? { eligibilitySource: proposal.eligibilitySource } : {}),
1009
+ ...(p.eventMetadata ?? {}),
1010
+ proposalTransactionId: txn.journal.transactionId,
1011
+ };
1012
+ insertEventOnce(db, {
1013
+ eventType: p.operation === "accept" ? "promoted" : "proposal_reverted",
1014
+ ts: txn.journal.decidedAt,
1015
+ ref: p.ref,
1016
+ metadata,
1017
+ idempotencyKey: txn.journal.transactionId,
1018
+ });
1019
+ }));
1020
+ }
1021
+ async function finalizeProposalTransaction(txn, target, proposal, ctx) {
1022
+ const p = txn.journal.payload;
1023
+ validatePublishedProposal(p);
1024
+ cleanupProposalPublication(p);
1025
+ if (txn.journal.phase === "asset-published") {
1026
+ const commitRoot = target.source.repoPath ?? target.source.path;
1027
+ const commitPath = path.relative(commitRoot, p.assetPath).replaceAll(path.sep, "/");
1028
+ publishWriteTargetTransaction(target, p.gitPublication, {
1029
+ transactionId: txn.journal.transactionId,
1030
+ message: `${p.operation === "accept" ? "Update" : "Revert"} ${p.ref}`,
1031
+ paths: [commitPath],
1032
+ snapshots: p.gitSnapshots ?? {},
1033
+ onCommitRecorded: (commit) => {
1034
+ // biome-ignore lint/style/noNonNullAssertion: publishWriteTargetTransaction throws when absent
1035
+ const publication = p.gitPublication;
1036
+ if (publication.commit !== commit) {
1037
+ publication.commit = commit;
1038
+ advanceTxn(txn, "asset-published");
1039
+ }
1040
+ },
1041
+ });
1042
+ persistProposalTransactionState(txn, proposal, ctx);
1043
+ advanceTxn(txn, "proposal-persisted");
1044
+ }
1045
+ let accepted = getProposal(p.stashDir, p.proposalId, ctx);
1046
+ if (txn.journal.phase === "proposal-persisted") {
1047
+ if (!(await indexWrittenAssets(txn.journal.root, [p.assetPath], { bundleId: target.source.name }))) {
1048
+ throw new Error(`Proposal ${p.proposalId} index finalization failed.`);
1049
+ }
1050
+ advanceTxn(txn, "index-finalized");
1051
+ }
1052
+ if (txn.journal.phase === "index-finalized") {
1053
+ accepted = getProposal(p.stashDir, p.proposalId, ctx);
1054
+ persistProposalEvent(txn, accepted, ctx);
1055
+ txnMutationHook("event-persisted");
1056
+ advanceTxn(txn, "event-finalized");
1057
+ }
1058
+ if (txn.journal.phase === "event-finalized")
1059
+ advanceTxn(txn, "committed");
1060
+ return accepted;
1061
+ }
1062
+ /**
1063
+ * Kind-level safety fence for a `proposal` journal, run before any recovery
1064
+ * action. The engine fences root binding and the uniform changes[] separately.
1065
+ */
1066
+ function fenceProposalTxnJournal(journal, txnDir, root) {
1067
+ const p = journal.payload;
1068
+ const refIdentity = proposalRefIdentity(p.ref);
1069
+ if (!["accept", "revert"].includes(p.operation) ||
1070
+ !p.targetSource ||
1071
+ !p.targetKind ||
1072
+ refIdentity?.bundle === undefined ||
1073
+ !isWithin(p.assetPath, root) ||
1074
+ ![p.contentPath, p.backupPath]
1075
+ .filter((candidate) => candidate !== null)
1076
+ .every((candidate) => isWithin(candidate, txnDir)) ||
1077
+ ![p.publishPath, p.displacedPath].every((candidate) => isWithin(candidate, root) && path.dirname(candidate) === path.dirname(p.assetPath))) {
1078
+ throw new Error(`Refusing unsafe proposal transaction journal at ${path.join(txnDir, "journal.json")}.`);
1079
+ }
1080
+ }
1081
+ function resolveProposalRecoveryTarget(config, journal) {
1082
+ let target;
1083
+ try {
1084
+ target = resolveBundleWriteTarget(config, journal.payload.targetSource);
1085
+ }
1086
+ catch {
1087
+ throw new UsageError(`Proposal transaction ${journal.transactionId} target is no longer configured.`, "INVALID_FLAG_VALUE");
1088
+ }
1089
+ const bundleId = canonicalBundleIdForTarget(config, target);
1090
+ return { ...target, source: { ...target.source, name: bundleId } };
1091
+ }
1092
+ async function recoverProposalTransactions(target, stashDir, ctx) {
1093
+ const completed = new Map();
1094
+ const nsDir = txnNamespaceDir(target.source.path);
1095
+ if (!fs.existsSync(nsDir))
1096
+ return completed;
1097
+ for (const entry of fs.readdirSync(nsDir, { withFileTypes: true })) {
1098
+ if (!entry.isDirectory())
1099
+ continue;
1100
+ const transactionDir = path.join(nsDir, entry.name);
1101
+ const journalPath = path.join(transactionDir, "journal.json");
1102
+ if (!fs.existsSync(journalPath)) {
1103
+ // Journal-less dirs may be a SIBLING kind's beginTxn window (shared
1104
+ // per-root namespace) — sweep only when demonstrably stale.
1105
+ sweepJournallessTxnDir(transactionDir);
1106
+ continue;
1107
+ }
1108
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf8"));
1109
+ if (journal.kind !== PROPOSAL_TXN_KIND)
1110
+ continue;
1111
+ if (path.resolve(journal.payload.stashDir) !== path.resolve(stashDir))
1112
+ continue;
1113
+ if (journal.version !== 1 ||
1114
+ canonicalTxnRoot(journal.root) !== canonicalTxnRoot(target.source.path) ||
1115
+ journal.payload.targetSource !== target.source.name ||
1116
+ journal.payload.targetKind !== target.source.kind) {
1117
+ throw new Error(`Refusing unsafe proposal transaction journal at ${journalPath}.`);
1118
+ }
1119
+ fenceProposalTxnJournal(journal, transactionDir, target.source.path);
1120
+ const txn = { journal, journalPath, dir: transactionDir };
1121
+ if (journal.phase === "prepared") {
1122
+ rollbackPreparedProposalTransaction(txn);
1123
+ }
1124
+ else if (journal.phase !== "committed") {
1125
+ const proposal = getProposal(stashDir, journal.payload.proposalId, ctx);
1126
+ completed.set(journal.payload.proposalId, await finalizeProposalTransaction(txn, target, proposal, ctx));
1127
+ }
1128
+ else {
1129
+ completed.set(journal.payload.proposalId, getProposal(stashDir, journal.payload.proposalId, ctx));
1130
+ }
1131
+ cleanupProposalPublication(journal.payload);
1132
+ cleanupTxn(transactionDir);
1133
+ }
1134
+ return completed;
1135
+ }
1136
+ export async function recoverProposalTransactionsForStash(stashDir, config, ctx, proposalId) {
1137
+ const completed = new Map();
1138
+ const matches = listTxnJournals((j) => j.kind === PROPOSAL_TXN_KIND &&
1139
+ path.resolve(j.payload.stashDir) === path.resolve(stashDir) &&
1140
+ (proposalId === undefined || j.payload.proposalId === proposalId));
1141
+ const irreversible = matches.filter((journal) => journal.phase !== "prepared" && journal.phase !== "committed");
1142
+ if (proposalId !== undefined && irreversible.length > 1) {
1143
+ throw new Error(`Conflicting durable proposal transactions exist for ${proposalId}; refusing recovery.`);
1144
+ }
1145
+ const recoveredRoots = new Set();
1146
+ for (const journal of matches) {
1147
+ let target = resolveProposalRecoveryTarget(config, journal);
1148
+ const requiresGitPublication = matches.some((candidate) => candidate.phase === "asset-published" && canonicalTxnRoot(candidate.root) === canonicalTxnRoot(journal.root));
1149
+ if (requiresGitPublication)
1150
+ target = prepareWriteTargetForMutation(target, { allowAhead: true });
1151
+ if (canonicalTxnRoot(target.source.path) !== canonicalTxnRoot(journal.root) ||
1152
+ journal.payload.targetKind !== target.source.kind) {
1153
+ throw new Error(`Proposal transaction ${journal.transactionId} is bound to a different target root.`);
1154
+ }
1155
+ const key = path.resolve(target.source.path);
1156
+ if (recoveredRoots.has(key))
1157
+ continue;
1158
+ const recovered = await recoverProposalTransactions(target, stashDir, ctx);
1159
+ for (const [id, proposal] of recovered)
1160
+ completed.set(id, proposal);
1161
+ recoveredRoots.add(key);
1162
+ }
1163
+ return completed;
1164
+ }
1165
+ const REJECT_TXN_KIND = "proposal-reject";
1166
+ const REJECT_TXN_PHASES = ["prepared", "state-persisted", "event-finalized", "committed"];
1167
+ function finalizeRejectTransaction(txn, ctx) {
1168
+ const p = txn.journal.payload;
1169
+ const decidedAt = txn.journal.decidedAt;
1170
+ // Lenient: a reject transaction must run to completion even on a legacy
1171
+ // row whose metadata fails strict decoding — see requireProposalLenient.
1172
+ let proposal = getProposalLenient(p.stashDir, p.proposalId, ctx);
1173
+ if (txn.journal.phase === "prepared") {
1174
+ if (proposal.status === "pending") {
1175
+ proposal = archiveProposal(p.stashDir, p.proposalId, "rejected", p.reason, { ...ctx, now: () => Date.parse(decidedAt) }, p.gateDecision);
1176
+ }
1177
+ else if (proposal.status !== "rejected") {
1178
+ throw new Error(`Proposal ${p.proposalId} changed status during rejection (${proposal.status}).`);
1179
+ }
1180
+ advanceTxn(txn, "state-persisted");
1181
+ txnMutationHook("reject-state-persisted");
1182
+ }
1183
+ if (txn.journal.phase === "state-persisted") {
1184
+ proposal = getProposalLenient(p.stashDir, p.proposalId, ctx);
1185
+ const eventRef = proposal.ref;
1186
+ const eventMeta = {
1187
+ proposalId: proposal.id,
1188
+ source: proposal.source,
1189
+ ...(proposal.sourceRun !== undefined ? { sourceRun: proposal.sourceRun } : {}),
1190
+ ...(p.reason !== undefined ? { reason: p.reason } : {}),
1191
+ proposalTransactionId: txn.journal.transactionId,
1192
+ };
1193
+ withProposalsDb(p.stashDir, ctx, (db) => withImmediateTransaction(db, () => {
1194
+ insertEventOnce(db, {
1195
+ eventType: "rejected",
1196
+ ts: decidedAt,
1197
+ ref: eventRef,
1198
+ metadata: eventMeta,
1199
+ idempotencyKey: txn.journal.transactionId,
1200
+ });
1201
+ }));
1202
+ txnMutationHook("reject-event-persisted");
1203
+ advanceTxn(txn, "event-finalized");
1204
+ }
1205
+ if (txn.journal.phase === "event-finalized")
1206
+ advanceTxn(txn, "committed");
1207
+ return proposal;
1208
+ }
1209
+ function recoverRejectTransaction(stashDir, proposalId, ctx) {
1210
+ const nsDir = txnNamespaceDir(stashDir);
1211
+ if (!fs.existsSync(nsDir))
1212
+ return undefined;
1213
+ for (const entry of fs.readdirSync(nsDir, { withFileTypes: true })) {
1214
+ if (!entry.isDirectory())
1215
+ continue;
1216
+ const transactionDir = path.join(nsDir, entry.name);
1217
+ const journalPath = path.join(transactionDir, "journal.json");
1218
+ if (!fs.existsSync(journalPath))
1219
+ continue;
1220
+ const journal = JSON.parse(fs.readFileSync(journalPath, "utf8"));
1221
+ if (journal.kind !== REJECT_TXN_KIND)
1222
+ continue;
1223
+ if (journal.payload.proposalId !== proposalId)
1224
+ continue;
1225
+ if (journal.version !== 1 || path.resolve(journal.payload.stashDir) !== path.resolve(stashDir)) {
1226
+ throw new Error(`Refusing unsafe proposal rejection journal at ${journalPath}.`);
1227
+ }
1228
+ const proposal = finalizeRejectTransaction({ journal, journalPath, dir: transactionDir }, ctx);
1229
+ cleanupTxn(transactionDir);
1230
+ return proposal;
1231
+ }
1232
+ return undefined;
1233
+ }
1234
+ export function rejectProposalDurably(stashDir, proposalId, reason, ctx, gateDecision) {
1235
+ const recovered = recoverRejectTransaction(stashDir, proposalId, ctx);
1236
+ if (recovered)
1237
+ return recovered;
1238
+ // Lenient: see requireProposalLenient — the reject decision itself must not
1239
+ // be blocked by a legacy row's malformed changes/proposedTarget metadata.
1240
+ const proposal = getProposalLenient(stashDir, proposalId, ctx);
1241
+ if (proposal.status !== "pending") {
1242
+ throw new UsageError(`Proposal ${proposalId} is not pending (current status: ${proposal.status}). Only pending proposals can be rejected.`, "INVALID_FLAG_VALUE");
1243
+ }
1244
+ const txn = beginTxn({
1245
+ kind: REJECT_TXN_KIND,
1246
+ root: stashDir,
1247
+ changes: [],
1248
+ payload: {
1249
+ proposalId,
1250
+ stashDir,
1251
+ ...(reason !== undefined ? { reason } : {}),
1252
+ ...(gateDecision ? { gateDecision } : {}),
1253
+ },
1254
+ decidedAt: nowIso(ctx),
1255
+ });
1256
+ const rejected = finalizeRejectTransaction(txn, ctx);
1257
+ cleanupTxn(txn.dir);
1258
+ return rejected;
1259
+ }
1260
+ function prepareProposalTransaction(stashDir, target, proposal, ref, content, options, ctx) {
1261
+ if (options.operation === "accept")
1262
+ assertAkmAssetWrite(target.source);
1263
+ const assetPath = resolveAssetFilePathSafe(target.source, ref);
1264
+ if (!assetPath)
1265
+ throw new Error(`Cannot resolve proposal target ${proposal.ref}.`);
1266
+ fs.mkdirSync(path.dirname(assetPath), { recursive: true });
1267
+ const normalized = content.endsWith("\n") ? content : `${content}\n`;
1268
+ const publishedHash = proposalHash(normalized);
1269
+ // Mint the id first: the payload embeds paths under the transaction dir,
1270
+ // and the initial `prepared` journal must be written exactly ONCE with its
1271
+ // final contents (crash runners intercept the first rename per phase).
1272
+ const transactionId = mintTxnId();
1273
+ const gitPublication = captureGitPublication(target);
1274
+ const transactionDir = txnDirFor(target.source.path, transactionId);
1275
+ fs.mkdirSync(transactionDir, { recursive: true, mode: 0o700 });
1276
+ const contentPath = path.join(transactionDir, "published-content");
1277
+ const publishPath = path.join(path.dirname(assetPath), `.akm-proposal-${transactionId}.publish`);
1278
+ const displacedPath = path.join(path.dirname(assetPath), `.akm-proposal-${transactionId}.displaced`);
1279
+ fs.writeFileSync(contentPath, normalized, { encoding: "utf8", mode: 0o600 });
1280
+ fsyncTxnFile(contentPath);
1281
+ let persistedBackupPath = null;
1282
+ if (options.backup) {
1283
+ const backupPath = path.join(transactionDir, "backup-content");
1284
+ fs.writeFileSync(backupPath, options.backup, { mode: 0o600 });
1285
+ fsyncTxnFile(backupPath);
1286
+ persistedBackupPath = backupPath;
1287
+ }
1288
+ const txn = beginTxn({
1289
+ kind: PROPOSAL_TXN_KIND,
1290
+ root: target.source.path,
1291
+ transactionId,
1292
+ changes: [
1293
+ {
1294
+ path: assetPath,
1295
+ op: options.originalHash === null ? "create" : "update",
1296
+ beforeHash: options.originalHash,
1297
+ afterHash: publishedHash,
1298
+ },
1299
+ ],
1300
+ payload: {
1301
+ operation: options.operation,
1302
+ proposalId: proposal.id,
1303
+ stashDir,
1304
+ targetSource: target.source.name,
1305
+ targetKind: target.source.kind,
1306
+ assetPath,
1307
+ ref: proposal.ref,
1308
+ contentPath,
1309
+ publishPath,
1310
+ displacedPath,
1311
+ backupPath: persistedBackupPath,
1312
+ originalHash: options.originalHash,
1313
+ publishedHash,
1314
+ ...(gitPublication ? { gitPublication } : {}),
1315
+ ...(options.eventMetadata ? { eventMetadata: options.eventMetadata } : {}),
1316
+ ...(options.gateDecision ? { gateDecision: options.gateDecision } : {}),
1317
+ },
1318
+ decidedAt: nowIso(ctx),
1319
+ });
1320
+ try {
1321
+ const mode = fs.existsSync(assetPath) ? fs.statSync(assetPath).mode & 0o777 : 0o644;
1322
+ fs.writeFileSync(publishPath, normalized, { encoding: "utf8", flag: "wx", mode });
1323
+ fsyncTxnFile(publishPath);
1324
+ fsyncTxnDir(path.dirname(assetPath));
1325
+ }
1326
+ catch (error) {
1327
+ rollbackPreparedProposalTransaction(txn);
1328
+ cleanupTxn(txn.dir);
1329
+ throw error;
1330
+ }
1331
+ return txn;
1332
+ }
1333
+ function publishProposalAsset(txn, target) {
1334
+ const p = txn.journal.payload;
1335
+ try {
1336
+ if (p.originalHash !== null) {
1337
+ fs.renameSync(p.assetPath, p.displacedPath);
1338
+ if (proposalFileHash(p.displacedPath) !== p.originalHash) {
1339
+ fs.renameSync(p.displacedPath, p.assetPath);
1340
+ throw new Error(`Proposal target changed while its backup was being acquired.`);
1341
+ }
1342
+ }
1343
+ fs.linkSync(p.publishPath, p.assetPath);
1344
+ fsyncTxnDir(path.dirname(p.assetPath));
1345
+ const snapshot = captureWriteTargetPathSnapshot(target, p.assetPath);
1346
+ if (snapshot)
1347
+ p.gitSnapshots = { [snapshot.path]: snapshot.state };
1348
+ advanceTxn(txn, "asset-published");
1349
+ }
1350
+ catch (error) {
1351
+ rollbackPreparedProposalTransaction(txn);
1352
+ cleanupTxn(txn.dir);
1353
+ throw error;
1354
+ }
1355
+ }
1356
+ function resolveRecordedProposalTarget(config, proposalId, binding, explicitTarget) {
1357
+ let target;
1358
+ try {
1359
+ target = explicitTarget
1360
+ ? resolveWriteTarget(config, explicitTarget)
1361
+ : resolveBundleWriteTarget(config, binding.source);
1362
+ }
1363
+ catch {
1364
+ throw new UsageError(`Proposal ${proposalId} is bound to target "${binding.source}" at ${binding.root}, but that writable target is no longer configured.`, "INVALID_FLAG_VALUE");
1365
+ }
1366
+ const targetBundleId = canonicalBundleIdForTarget(config, target);
1367
+ if (targetBundleId !== binding.source || path.resolve(target.source.path) !== path.resolve(binding.root)) {
1368
+ throw new UsageError(`Proposal ${proposalId} is bound to target "${binding.source}" at ${binding.root}; ` +
1369
+ `--target "${explicitTarget}" resolves to "${targetBundleId}" at ${target.source.path}.`, "INVALID_FLAG_VALUE");
1370
+ }
1371
+ return { ...target, source: { ...target.source, name: targetBundleId } };
1372
+ }
1373
+ function resolveProposalWriteTarget(config, proposal, explicitTarget, queueTarget) {
1374
+ if (!proposal.proposedTarget) {
1375
+ const identity = proposalRefIdentity(proposal.ref);
1376
+ if (!identity)
1377
+ throw new UsageError(`Proposal ${proposal.id} has an invalid ref.`, "INVALID_PROPOSAL");
1378
+ if (identity.bundle !== undefined) {
1379
+ const target = resolveBundleWriteTarget(config, identity.bundle);
1380
+ const targetBundleId = canonicalBundleIdForTarget(config, target);
1381
+ if (explicitTarget !== undefined) {
1382
+ const explicit = resolveWriteTarget(config, explicitTarget);
1383
+ if (canonicalBundleIdForTarget(config, explicit) !== identity.bundle) {
1384
+ throw new UsageError(`Proposal ${proposal.id} ref is bound to bundle "${identity.bundle}", which conflicts with --target "${explicitTarget}".`, "INVALID_FLAG_VALUE");
1385
+ }
1386
+ }
1387
+ if (queueTarget && canonicalBundleIdForTarget(config, queueTarget) !== identity.bundle) {
1388
+ throw new UsageError(`Proposal ${proposal.id} is bound to a different queue target.`, "INVALID_FLAG_VALUE");
1389
+ }
1390
+ return { ...target, source: { ...target.source, name: targetBundleId } };
1391
+ }
1392
+ const target = explicitTarget ? resolveWriteTarget(config, explicitTarget) : queueTarget;
1393
+ if (!target) {
1394
+ throw new UsageError(`Unbound short proposal ${proposal.id} requires an explicit --target or authenticated --queue context.`, "INVALID_PROPOSAL");
1395
+ }
1396
+ const targetBundleId = canonicalBundleIdForTarget(config, target);
1397
+ return { ...target, source: { ...target.source, name: targetBundleId } };
1398
+ }
1399
+ if (queueTarget && explicitTarget === undefined) {
1400
+ const queueBundleId = canonicalBundleIdForTarget(config, queueTarget);
1401
+ if (queueBundleId !== proposal.proposedTarget.source ||
1402
+ path.resolve(queueTarget.source.path) !== path.resolve(proposal.proposedTarget.root)) {
1403
+ throw new UsageError(`Proposal ${proposal.id} is bound to a different queue target.`, "INVALID_FLAG_VALUE");
1404
+ }
1405
+ }
1406
+ return resolveRecordedProposalTarget(config, proposal.id, proposal.proposedTarget, explicitTarget);
1407
+ }
1408
+ // ── D2 (#730) — OKF v0.2 provenance stamping on promotion ───────────────────
1409
+ //
1410
+ // The proposals system already tracks exactly what OKF v0.2 wants on disk —
1411
+ // `source`/`sourceRun` (PROV-DM modeled, `proposal-types.ts` §80-120),
1412
+ // `gateDecision` (`:200-237`), `review` (`:170-174`) — but none of it leaves
1413
+ // state.db. This section projects it onto the written asset's frontmatter at
1414
+ // promotion time, AKM-native assets only (an OKF-adapter target never reaches
1415
+ // this function — `assertAkmAssetWrite` rejects it earlier in
1416
+ // `promoteProposalWithLease`, before any of this runs).
1417
+ //
1418
+ // Two DISTINCT actors, deliberately not conflated (documented judgment call —
1419
+ // see the PR body for the alternative considered and rejected):
1420
+ // - `generated.by` answers "what produced the CONTENT" — keyed on
1421
+ // `isAutomatedProposalSource(proposal.source)`: an automated pipeline
1422
+ // (reflect/distill/consolidate/extract/improve/schema-repair) stamps
1423
+ // `akm/<pkgVersion>`; a human-initiated source (propose/remember/import)
1424
+ // or the semi-automated `feedback` source stamps `human:<actorId>`.
1425
+ // - `verified[0].by` answers "what accepted/reviewed THIS promotion" —
1426
+ // keyed on whether a `gateDecision` was supplied to THIS call: present
1427
+ // (the automated drain/triage path decided) stamps `akm/<pkgVersion>`;
1428
+ // absent (a human explicitly ran `akm proposal accept`) stamps
1429
+ // `human:<actorId>`. Every promotion reaches this function via exactly
1430
+ // one of those two paths, so `verified` is always stamped — there is no
1431
+ // third, unreviewed path to disk.
1432
+ /** Resolve the `human:<id>` actor id — the OS account, the least-surprising stand-in for "the human at this keyboard" in a system with no multi-user identity. */
1433
+ function resolveActorId(ctx) {
1434
+ if (ctx?.actorId)
1435
+ return ctx.actorId();
1436
+ try {
1437
+ const username = os.userInfo().username?.trim();
1438
+ return username ? username : "local";
1439
+ }
1440
+ catch {
1441
+ return "local";
1442
+ }
1443
+ }
1444
+ /** `generated.by` — keyed on the SOURCE that produced the content (see file-header note above). */
1445
+ function generatedByActor(proposal, ctx) {
1446
+ return isAutomatedProposalSource(proposal.source) ? `akm/${pkgVersion}` : `human:${resolveActorId(ctx)}`;
1447
+ }
1448
+ /** `verified[0].by` — keyed on whether THIS promotion was gated (automated) or a direct human accept (see file-header note above). */
1449
+ function verifiedByActor(gateDecision, ctx) {
1450
+ return gateDecision !== undefined ? `akm/${pkgVersion}` : `human:${resolveActorId(ctx)}`;
1451
+ }
1452
+ /** True for a plain (non-null, non-array) object. */
1453
+ function isPlainRecord(value) {
1454
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1455
+ }
1456
+ /**
1457
+ * Stamp OKF v0.2 provenance onto ONE promoted asset's frontmatter (D2.1/D2.2/
1458
+ * D2.3), in the **hybrid** on-disk shape settled by the #730 review:
1459
+ *
1460
+ * - `generated: {by, at}` and `verified: [{by, at}]` are written **bare at the
1461
+ * top level**, exactly as OKF v0.2 spells them (SPEC §5.2/§5.3). Neither key
1462
+ * has any pre-existing AKM consumer, so spelling them the spec's way costs
1463
+ * nothing and makes `okf-support.md`'s "AKM Markdown is an OKF-compatible
1464
+ * superset" positioning actually true for trust metadata: a third-party OKF
1465
+ * v0.2 reader pointed at an AKM stash sees conformant provenance.
1466
+ * - `sources` stays namespaced under `provenance:`, because a bare top-level
1467
+ * `sources:` genuinely collides with the pre-existing AKM-native wiki
1468
+ * citation-**string** convention
1469
+ * (`indexer/passes/metadata.ts#applyWikiFrontmatter`, which silently drops
1470
+ * non-strings) on a promoted wiki page.
1471
+ *
1472
+ * The read side back into `IndexDocument.provenance` is
1473
+ * `metadata.ts#applyProvenanceFrontmatter` (which accepts both this shape and
1474
+ * the older fully-nested one), carried through `akm-adapter.ts`'s
1475
+ * `DOCUMENT_JSON_CARRIED_FIELDS` (D2.4).
1476
+ *
1477
+ * A no-op frontmatter mutation preserves the existing frontmatter block's raw
1478
+ * body bytes (mirrors `frontmatter.ts#mutateFrontmatter`'s documented
1479
+ * contract) rather than reshaping via `assembleAsset`, which would strip
1480
+ * leading body blank lines / force a trailing newline. A file with no
1481
+ * frontmatter block at all (non-conformant input) gains one via
1482
+ * `assembleAsset`, exactly as any other first-frontmatter write would.
1483
+ */
1484
+ function stampProposalProvenance(content, proposal, gateDecision, ctx, nowIsoStr) {
1485
+ const parsed = parseFrontmatter(content);
1486
+ const fm = { ...parsed.data };
1487
+ const existingProvenance = isPlainRecord(fm.provenance) ? fm.provenance : {};
1488
+ // Bare `generated:` — OKF v0.2's replacement for `timestamp` (SPEC §13).
1489
+ fm.generated = { by: generatedByActor(proposal, ctx), at: nowIsoStr };
1490
+ // Bare `verified:` — append, so independent confirmations accumulate rather
1491
+ // than the newest overwriting the record. Both the bare list and the older
1492
+ // nested spelling are absorbed, so a re-promotion never loses history.
1493
+ const priorVerified = Array.isArray(fm.verified)
1494
+ ? fm.verified
1495
+ : Array.isArray(existingProvenance.verified)
1496
+ ? existingProvenance.verified
1497
+ : [];
1498
+ fm.verified = [
1499
+ ...priorVerified,
1500
+ { by: verifiedByActor(gateDecision, ctx), at: gateDecision?.decidedAt ?? nowIsoStr },
1501
+ ];
1502
+ // `sources` alone stays namespaced — bare `sources:` is the wiki
1503
+ // citation-string convention. Drop the nested provenance block entirely when
1504
+ // it would otherwise be empty, so unrelated assets gain no dead key.
1505
+ const provenance = { ...existingProvenance };
1506
+ delete provenance.generatedBy;
1507
+ delete provenance.generatedAt;
1508
+ delete provenance.verified;
1509
+ const evidenceSources = fm.evidenceSources;
1510
+ if (Array.isArray(evidenceSources)) {
1511
+ const sources = evidenceSources
1512
+ .filter((s) => typeof s === "string" && s.trim().length > 0)
1513
+ .map((resource) => ({ resource: resource.trim() }));
1514
+ if (sources.length > 0)
1515
+ provenance.sources = sources;
1516
+ }
1517
+ if (Object.keys(provenance).length > 0)
1518
+ fm.provenance = provenance;
1519
+ else
1520
+ delete fm.provenance;
1521
+ return parsed.frontmatter !== null
1522
+ ? `---\n${serializeFrontmatter(fm)}\n---\n${parsed.content}`
1523
+ : assembleAsset(fm, parsed.content);
1524
+ }
607
1525
  /**
608
1526
  * Validate a proposal, then promote it through the canonical
609
1527
  * {@link writeAssetToSource} dispatch (the single place that branches on
@@ -617,20 +1535,53 @@ export function expireStaleProposals(stashDir, config, ctx) {
617
1535
  * Genuinely-new assets carry no backup.
618
1536
  */
619
1537
  export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
620
- const proposal = getProposal(stashDir, id, ctx);
621
- if (proposal.status !== "pending") {
622
- throw new UsageError(`Proposal ${id} is not pending (current status: ${proposal.status}). Only pending proposals can be accepted.`, "INVALID_FLAG_VALUE");
1538
+ return withAssetMutationLease("proposal-accept", () => promoteProposalWithLease(stashDir, config, id, options, ctx));
1539
+ }
1540
+ function promotionLintBlockers(raw, assetPath, targetRoot, refType, config) {
1541
+ let data;
1542
+ let body;
1543
+ let frontmatter;
1544
+ if (refType === "task") {
1545
+ try {
1546
+ const parsed = parseYaml(raw);
1547
+ data = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
1548
+ }
1549
+ catch {
1550
+ data = {};
1551
+ }
1552
+ body = raw;
1553
+ frontmatter = null;
1554
+ }
1555
+ else {
1556
+ ({ data, content: body, frontmatter } = parseFrontmatter(raw));
623
1557
  }
1558
+ const resolvedRoot = path.resolve(targetRoot);
1559
+ const extraStashRoots = resolveSourceEntries(targetRoot, config)
1560
+ .map((source) => source.path)
1561
+ .filter((sourcePath) => path.resolve(sourcePath) !== resolvedRoot);
1562
+ return runBaseChecks({
1563
+ filePath: assetPath,
1564
+ relPath: path.relative(targetRoot, assetPath),
1565
+ raw,
1566
+ data,
1567
+ body,
1568
+ frontmatter,
1569
+ fix: false,
1570
+ stashRoot: targetRoot,
1571
+ extraStashRoots,
1572
+ }).filter((finding) => PROMOTION_LINT_BLOCKERS.has(finding.issue));
1573
+ }
1574
+ async function promoteProposalWithLease(stashDir, config, id, options, ctx) {
1575
+ recoverRejectTransaction(stashDir, id, ctx);
1576
+ let proposal = getProposal(stashDir, id, ctx);
624
1577
  // Attempt bounded auto-repair of mechanically-fixable structural defects
625
1578
  // (pseudo-frontmatter-in-body, stray `---` fences, truncated description)
626
1579
  // BEFORE running validation. If the repair produces valid content, we
627
1580
  // promote the repaired version; if validation still fails, the original
628
1581
  // error path throws as before. The repair is content-preserving and
629
1582
  // deterministic — it never invents text.
630
- const repairedContent = repairProposalContent(proposal.payload.content);
631
- const proposalToValidate = repairedContent !== proposal.payload.content
632
- ? { ...proposal, payload: { ...proposal.payload, content: repairedContent } }
633
- : proposal;
1583
+ const repairedContent = repairProposalContent(proposalContent(proposal));
1584
+ const proposalToValidate = repairedContent !== proposalContent(proposal) ? withProposalContent(proposal, repairedContent) : proposal;
634
1585
  const report = validateProposal(proposalToValidate);
635
1586
  if (!report.ok) {
636
1587
  const message = report.findings.map((f) => `[${f.kind}] ${f.message}`).join("\n");
@@ -639,47 +1590,101 @@ export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
639
1590
  // Use the (possibly repaired) payload for the promotion write. Persist the
640
1591
  // repaired content back onto the DB row so the audit trail reflects the
641
1592
  // final promoted payload (not the defective original).
642
- if (repairedContent !== proposal.payload.content) {
1593
+ if (repairedContent !== proposalContent(proposal)) {
643
1594
  withProposalsDb(stashDir, ctx, (db) => {
644
- const updated = { ...proposal, payload: { ...proposal.payload, content: repairedContent } };
645
- upsertProposal(db, updated, stashDir);
1595
+ persistProposalUpdate(db, withProposalContent(proposal, repairedContent), stashDir);
646
1596
  });
647
1597
  }
648
- const ref = parseAssetRef(proposalToValidate.ref);
649
- if (!TYPE_DIRS[ref.type]) {
1598
+ const ref = parseRefInput(proposalToValidate.ref);
1599
+ if (!stashDirFor(ref.type)) {
650
1600
  throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
651
1601
  }
652
- const target = resolveWriteTarget(config, options.target);
653
- // Phase 6C: capture the prior content (if any) BEFORE writing the new
654
- // asset. We use the resolved write target to compute the exact path the
655
- // asset would land at — same resolver `writeAssetToSource` uses — so the
656
- // backup always mirrors what would be overwritten.
657
- let backupContent;
658
- try {
659
- const targetFilePath = resolveAssetFilePathSafe(target.source, ref);
660
- if (targetFilePath && fs.existsSync(targetFilePath)) {
661
- backupContent = fs.readFileSync(targetFilePath, "utf8");
1602
+ await recoverProposalTransactionsForStash(stashDir, config, ctx, id);
1603
+ proposal = getProposal(stashDir, id, ctx);
1604
+ const target = resolveProposalWriteTarget(config, proposal, options.target, options.queueTarget);
1605
+ if (proposal.status === "accepted") {
1606
+ if (!proposal.acceptedTarget) {
1607
+ throw new UsageError(`Accepted proposal ${id} has no recorded target.`, "INVALID_PROPOSAL");
1608
+ }
1609
+ const assetPath = resolveAssetFilePathSafe(target.source, ref);
1610
+ if (proposal.acceptedTarget.source !== target.source.name ||
1611
+ path.resolve(proposal.acceptedTarget.root) !== path.resolve(target.source.path) ||
1612
+ !assetPath ||
1613
+ path.resolve(proposal.acceptedTarget.path) !== path.resolve(assetPath)) {
1614
+ throw new UsageError(`proposal ${id} is bound to a different accepted target`, "INVALID_FLAG_VALUE");
1615
+ }
1616
+ if (!assetPath ||
1617
+ !fs.existsSync(assetPath) ||
1618
+ proposalFileHash(assetPath) !== proposal.acceptedTarget.contentHash) {
1619
+ throw new UsageError(`Accepted proposal ${id} does not match the current asset content.`, "INVALID_FLAG_VALUE");
662
1620
  }
1621
+ return { proposal, assetPath, ref: proposal.ref };
663
1622
  }
664
- catch (err) {
665
- // Backup capture is best-effort. A failure here must not block promotion
666
- // (the user explicitly asked to accept); we surface a warning so the
667
- // missing-revert path is visible.
668
- warn(`[proposals] promoteProposal: failed to capture backup for ${id}: ${err instanceof Error ? err.message : String(err)}`);
669
- }
670
- const written = await writeAssetToSource(target.source, target.config, ref, repairedContent);
671
- // 0.9.0 (issue #507): single batch commit at the write boundary for git
672
- // targets. No-op for filesystem/primary-stash targets.
673
- commitWriteTargetBoundary(target, `Update ${formatRefForMessage(ref)}`);
674
- const archived = archiveProposal(stashDir, id, "accepted", undefined, ctx);
675
- // Persist the backup content on the archived proposal record so the revert
676
- // flow can restore the prior asset state.
677
- if (backupContent !== undefined) {
678
- const withBackup = { ...archived, backupContent };
679
- withProposalsDb(stashDir, ctx, (db) => upsertProposal(db, withBackup, stashDir));
680
- return { proposal: withBackup, assetPath: written.path, ref: written.ref };
681
- }
682
- return { proposal: archived, assetPath: written.path, ref: written.ref };
1623
+ if (proposal.status !== "pending") {
1624
+ throw new UsageError(`Proposal ${id} is not pending (current status: ${proposal.status}). Only pending proposals can be accepted.`, "INVALID_FLAG_VALUE");
1625
+ }
1626
+ const mutationTarget = prepareWriteTargetForMutation(target);
1627
+ const assetPath = resolveAssetFilePathSafe(mutationTarget.source, ref);
1628
+ if (!assetPath)
1629
+ throw new UsageError(`Cannot resolve proposal target ${proposal.ref}.`, "INVALID_PROPOSAL");
1630
+ assertWriteTargetPathsClean(mutationTarget.source, [assetPath]);
1631
+ let backup;
1632
+ if (fs.existsSync(assetPath)) {
1633
+ try {
1634
+ backup = fs.readFileSync(assetPath);
1635
+ }
1636
+ catch (error) {
1637
+ throw new Error(`Proposal backup read failed for ${assetPath}: ${error instanceof Error ? error.message : String(error)}`);
1638
+ }
1639
+ }
1640
+ if (proposal.beforeHash !== undefined && (!backup || proposalHash(backup) !== proposal.beforeHash)) {
1641
+ throw new UsageError(`Proposal target changed after proposal ${id} was created; refusing to overwrite newer content.`, "INVALID_FLAG_VALUE");
1642
+ }
1643
+ if (proposal.beforeHash === undefined &&
1644
+ backup !== undefined &&
1645
+ proposal.changes.some((change) => change.op === "create")) {
1646
+ throw new UsageError(`Proposal target was created after proposal ${id}; refusing to overwrite newer content.`, "INVALID_FLAG_VALUE");
1647
+ }
1648
+ assertAkmAssetWrite(mutationTarget.source);
1649
+ // D2 (#730): stamp OKF v0.2 provenance onto the promoted content BEFORE
1650
+ // lint/write — reaching this point already proves the target is AKM-native
1651
+ // (assertAkmAssetWrite above rejects an OKF-adapter target first), so the
1652
+ // OKF write-rejection contract (runbook §10) is untouched: this code never
1653
+ // runs for it. Markdown-only: a task/env/script/other non-markdown target
1654
+ // has no frontmatter block to stamp into.
1655
+ //
1656
+ // workflow-format-unification removed the re-validation fallback that used
1657
+ // to live here: every AKM-native markdown type (workflow included) now
1658
+ // validates its frontmatter against a schema whose closed key set is
1659
+ // `envelope ∪ type-keys` (`schemas/akm-workflow.json` $ref's
1660
+ // `schemas/akm-asset-envelope.json`, which already admits `generated`/
1661
+ // `verified`/`provenance`/`status`/`stale_after`). A validator rejecting the
1662
+ // machine-stamped keys it is contractually required to admit is structurally
1663
+ // impossible now, so falling back to unstamped content on rejection would
1664
+ // only silently hide a real regression instead of promoting stamped content.
1665
+ const stampedContent = assetPath.toLowerCase().endsWith(".md")
1666
+ ? stampProposalProvenance(repairedContent, proposalToValidate, options.gateDecision, ctx, nowIso(ctx))
1667
+ : repairedContent;
1668
+ const lintBlockers = promotionLintBlockers(stampedContent, assetPath, mutationTarget.source.path, ref.type, config);
1669
+ if (lintBlockers.length > 0) {
1670
+ const message = lintBlockers.map((finding) => `[${finding.issue}] ${finding.detail}`).join("\n");
1671
+ throw new UsageError(`Proposal ${id} failed lint:\n${message}`, "INVALID_PROPOSAL", "Fix or explicitly suppress the reported lint findings, then retry.");
1672
+ }
1673
+ const refIdentity = proposalRefIdentity(proposalToValidate.ref);
1674
+ const proposalForMutation = refIdentity?.bundle === undefined
1675
+ ? { ...proposalToValidate, ref: `${target.source.name}//${refIdentity?.conceptId ?? ""}` }
1676
+ : proposalToValidate;
1677
+ const transaction = prepareProposalTransaction(stashDir, mutationTarget, proposalForMutation, ref, stampedContent, {
1678
+ operation: "accept",
1679
+ originalHash: backup ? proposalHash(backup) : null,
1680
+ backup,
1681
+ eventMetadata: options.eventMetadata,
1682
+ gateDecision: options.gateDecision,
1683
+ }, ctx);
1684
+ publishProposalAsset(transaction, mutationTarget);
1685
+ const accepted = await finalizeProposalTransaction(transaction, mutationTarget, proposalForMutation, ctx);
1686
+ cleanupTxn(transaction.dir);
1687
+ return { proposal: accepted, assetPath: transaction.journal.payload.assetPath, ref: accepted.ref };
683
1688
  }
684
1689
  /**
685
1690
  * Restore the prior content of an accepted proposal from the backup captured
@@ -702,37 +1707,64 @@ export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
702
1707
  * wrapper.
703
1708
  */
704
1709
  export async function revertProposal(stashDir, config, id, options = {}, ctx) {
705
- const proposal = getProposal(stashDir, id, ctx);
1710
+ return withAssetMutationLease("proposal-revert", () => revertProposalWithLease(stashDir, config, id, options, ctx));
1711
+ }
1712
+ async function revertProposalWithLease(stashDir, config, id, options, ctx) {
1713
+ let proposal = getProposal(stashDir, id, ctx);
1714
+ const ref = parseRefInput(proposal.ref);
1715
+ if (!stashDirFor(ref.type)) {
1716
+ throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
1717
+ }
1718
+ await recoverProposalTransactionsForStash(stashDir, config, ctx, id);
1719
+ proposal = getProposal(stashDir, id, ctx);
1720
+ if (proposal.status === "reverted") {
1721
+ if (!proposal.acceptedTarget) {
1722
+ throw new UsageError(`Reverted proposal ${id} has no recorded target.`, "INVALID_PROPOSAL");
1723
+ }
1724
+ const target = resolveRecordedProposalTarget(config, id, proposal.acceptedTarget, options.target);
1725
+ const requestedAssetPath = resolveAssetFilePathSafe(target.source, ref);
1726
+ if (!requestedAssetPath ||
1727
+ proposal.acceptedTarget.source !== target.source.name ||
1728
+ path.resolve(proposal.acceptedTarget.root) !== path.resolve(target.source.path) ||
1729
+ path.resolve(proposal.acceptedTarget.path) !== path.resolve(requestedAssetPath)) {
1730
+ throw new UsageError(`proposal ${id} is bound to a different accepted target`, "INVALID_FLAG_VALUE");
1731
+ }
1732
+ return {
1733
+ proposal,
1734
+ assetPath: requestedAssetPath,
1735
+ ref: proposal.ref,
1736
+ };
1737
+ }
706
1738
  if (proposal.status !== "accepted") {
707
1739
  throw new UsageError(`only accepted proposals can be reverted (proposal ${id} status: ${proposal.status})`, "INVALID_FLAG_VALUE");
708
1740
  }
709
- if (proposal.backupContent === undefined) {
1741
+ const backupContent = proposal.backupContent;
1742
+ if (backupContent === undefined) {
710
1743
  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.");
711
1744
  }
712
- const ref = parseAssetRef(proposal.ref);
713
- if (!TYPE_DIRS[ref.type]) {
714
- throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
1745
+ if (!proposal.acceptedTarget) {
1746
+ throw new UsageError(`Accepted proposal ${id} has no recorded target.`, "INVALID_PROPOSAL");
715
1747
  }
716
- const target = resolveWriteTarget(config, options.target);
717
- const written = await writeAssetToSource(target.source, target.config, ref, proposal.backupContent);
718
- // 0.9.0 (issue #507): single batch commit at the write boundary for git
719
- // targets. No-op for filesystem/primary-stash targets.
720
- commitWriteTargetBoundary(target, `Revert ${formatRefForMessage(ref)}`);
721
- // Update the proposal record to status: "reverted" and bump updatedAt +
722
- // review so the audit trail reflects the second decision.
723
- const now = nowIso(ctx);
724
- const reverted = {
725
- ...proposal,
726
- status: "reverted",
727
- updatedAt: now,
728
- review: {
729
- outcome: "rejected",
730
- reason: "reverted: prior content restored from backup",
731
- decidedAt: now,
732
- },
733
- };
734
- withProposalsDb(stashDir, ctx, (db) => upsertProposal(db, reverted, stashDir));
735
- return { proposal: reverted, assetPath: written.path, ref: written.ref };
1748
+ let target = resolveRecordedProposalTarget(config, id, proposal.acceptedTarget, options.target);
1749
+ const requestedAssetPath = resolveAssetFilePathSafe(target.source, ref);
1750
+ if (proposal.acceptedTarget.source !== target.source.name ||
1751
+ path.resolve(proposal.acceptedTarget.root) !== path.resolve(target.source.path) ||
1752
+ !requestedAssetPath ||
1753
+ path.resolve(proposal.acceptedTarget.path) !== path.resolve(requestedAssetPath)) {
1754
+ throw new UsageError(`proposal ${id} is bound to a different accepted target`, "INVALID_FLAG_VALUE");
1755
+ }
1756
+ const assetPath = requestedAssetPath;
1757
+ const acceptedHash = proposal.acceptedTarget.contentHash;
1758
+ target = prepareWriteTargetForMutation(target);
1759
+ if (!fs.existsSync(assetPath) || proposalFileHash(assetPath) !== acceptedHash) {
1760
+ throw new UsageError(`asset content changed after proposal ${id} was accepted; refusing to clobber the newer content`, "INVALID_FLAG_VALUE");
1761
+ }
1762
+ assertWriteTargetPathsClean(target.source, [assetPath]);
1763
+ const transaction = prepareProposalTransaction(stashDir, target, proposal, ref, backupContent, { operation: "revert", originalHash: acceptedHash }, ctx);
1764
+ publishProposalAsset(transaction, target);
1765
+ const reverted = await finalizeProposalTransaction(transaction, target, proposal, ctx);
1766
+ cleanupTxn(transaction.dir);
1767
+ return { proposal: reverted, assetPath, ref: proposal.ref };
736
1768
  }
737
1769
  /**
738
1770
  * Compute a diff between a proposal payload and the existing on-disk asset.
@@ -742,21 +1774,17 @@ export async function revertProposal(stashDir, config, id, options = {}, ctx) {
742
1774
  */
743
1775
  export function diffProposal(stashDir, config, id, options = {}, ctx) {
744
1776
  const proposal = getProposal(stashDir, id, ctx);
745
- const ref = parseAssetRef(proposal.ref);
1777
+ const ref = parseRefInput(proposal.ref);
746
1778
  let targetPath;
747
1779
  let existing = null;
748
- try {
749
- const target = resolveWriteTarget(config, options.target);
1780
+ const readTarget = (target) => {
750
1781
  targetPath = resolveAssetFilePathSafe(target.source, ref);
751
1782
  if (targetPath && fs.existsSync(targetPath)) {
752
1783
  existing = fs.readFileSync(targetPath, "utf8");
753
1784
  }
754
- }
755
- catch {
756
- // No writable target configured — still return a "new asset" diff so
757
- // callers can see the proposed payload without erroring out.
758
- }
759
- const proposed = proposal.payload.content;
1785
+ };
1786
+ readTarget(resolveProposalWriteTarget(config, proposal, options.target, options.queueTarget));
1787
+ const proposed = proposalContent(proposal);
760
1788
  if (existing === null) {
761
1789
  return {
762
1790
  existing: null,
@@ -775,55 +1803,52 @@ export function diffProposal(stashDir, config, id, options = {}, ctx) {
775
1803
  };
776
1804
  }
777
1805
  function resolveAssetFilePathSafe(source, ref) {
778
- const typeDir = TYPE_DIRS[ref.type];
1806
+ const typeDir = stashDirFor(ref.type);
779
1807
  if (!typeDir)
780
1808
  return undefined;
781
1809
  const typeRoot = path.join(source.path, typeDir);
782
1810
  try {
783
- return resolveAssetPathFromName(ref.type, typeRoot, ref.name);
1811
+ return assetPathForName(ref.type, typeRoot, ref.name);
784
1812
  }
785
1813
  catch {
786
1814
  return undefined;
787
1815
  }
788
1816
  }
789
- /**
790
- * Minimal unified-diff renderer. We deliberately avoid pulling a runtime
791
- * dependency just for this — proposals diffs are usually small (a single
792
- * lesson / skill file), so the LCS-free greedy renderer below is plenty for
793
- * humans to review. The output mirrors `git diff --no-index` for the first
794
- * `@@ … @@` hunk: enough to be familiar, not so detailed that we re-implement
795
- * a full LCS table.
796
- */
797
- export function formatUnifiedDiff(left, right, label) {
798
- if (left === right)
799
- return "";
800
- const leftLines = left.split("\n");
801
- const rightLines = right.split("\n");
802
- const lines = [`--- ${label} (existing)`, `+++ ${label} (proposed)`];
803
- // Pad to the longer side so alignment is one-to-one. Real diff tools use
804
- // LCS to align matching runs; we don't need that fidelity for a review
805
- // surface — both halves are visible regardless.
806
- const max = Math.max(leftLines.length, rightLines.length);
807
- lines.push(`@@ 1,${leftLines.length} 1,${rightLines.length} @@`);
808
- for (let i = 0; i < max; i += 1) {
809
- const l = leftLines[i];
810
- const r = rightLines[i];
811
- if (l === r && l !== undefined) {
812
- lines.push(` ${l}`);
813
- continue;
1817
+ // Register the proposal transaction kinds with the unified engine so ANY
1818
+ // recovery entry point (mv pre-flight, indexer, write-path indexer) can
1819
+ // finish or roll back an interrupted proposal mutation for a root it
1820
+ // touches. The proposal-owned entry points below keep their richer,
1821
+ // ctx-threaded recovery paths over the same journals.
1822
+ registerTxnKind(PROPOSAL_TXN_KIND, {
1823
+ phases: PROPOSAL_TXN_PHASES,
1824
+ commitPhase: "asset-published",
1825
+ validate: (journal, txnDir, root) => fenceProposalTxnJournal(journal, txnDir, root),
1826
+ rollback: (txn) => {
1827
+ rollbackPreparedProposalTransaction(txn);
1828
+ },
1829
+ finalize: async (txn) => {
1830
+ const p = txn.journal.payload;
1831
+ const config = loadConfig();
1832
+ let target = resolveProposalRecoveryTarget(config, txn.journal);
1833
+ if (txn.journal.phase === "asset-published") {
1834
+ target = prepareWriteTargetForMutation(target, { allowAhead: true });
814
1835
  }
815
- if (l !== undefined)
816
- lines.push(`-${l}`);
817
- if (r !== undefined)
818
- lines.push(`+${r}`);
819
- }
820
- return lines.join("\n");
821
- }
822
- function formatNewAssetDiff(ref, content) {
823
- const lines = [`--- /dev/null`, `+++ ${ref} (proposed, new asset)`];
824
- lines.push(`@@ 0,0 1,${content.split("\n").length} @@`);
825
- for (const line of content.split("\n")) {
826
- lines.push(`+${line}`);
827
- }
828
- return lines.join("\n");
829
- }
1836
+ if (canonicalTxnRoot(target.source.path) !== canonicalTxnRoot(txn.journal.root) ||
1837
+ p.targetKind !== target.source.kind) {
1838
+ throw new Error(`Proposal transaction ${txn.journal.transactionId} is bound to a different target root.`);
1839
+ }
1840
+ const proposal = getProposal(p.stashDir, p.proposalId);
1841
+ await finalizeProposalTransaction(txn, target, proposal);
1842
+ cleanupProposalPublication(p);
1843
+ },
1844
+ });
1845
+ registerTxnKind(REJECT_TXN_KIND, {
1846
+ phases: REJECT_TXN_PHASES,
1847
+ // A reject is roll-forward from its very first phase (DB-only; the archive
1848
+ // decision is durable the moment the journal exists).
1849
+ commitPhase: "prepared",
1850
+ rollback: () => { },
1851
+ finalize: (txn) => {
1852
+ finalizeRejectTransaction(txn);
1853
+ },
1854
+ });