akm-cli 0.9.16 → 0.9.17-alpha.10

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 (403) hide show
  1. package/CHANGELOG.md +2101 -0
  2. package/STABILITY.md +11 -10
  3. package/dist/akm +124 -193
  4. package/dist/akm-migrate +38 -19
  5. package/dist/assets/hints/cli-hints-full.md +6 -7
  6. package/dist/assets/improve-strategies/catchup.json +0 -3
  7. package/dist/assets/improve-strategies/consolidate.json +0 -1
  8. package/dist/assets/improve-strategies/default.json +1 -2
  9. package/dist/assets/improve-strategies/proactive-maintenance.json +1 -2
  10. package/dist/assets/improve-strategies/quick.json +1 -2
  11. package/dist/assets/improve-strategies/reflect-distill.json +1 -2
  12. package/dist/assets/improve-strategies/thorough.json +0 -3
  13. package/dist/assets/prompts/consolidate-pair.md +20 -0
  14. package/dist/assets/prompts/consolidate-system.md +4 -11
  15. package/dist/assets/prompts/retrieval-relevance-judge.md +6 -0
  16. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +20 -20
  17. package/dist/assets/stash-skeleton/facts/conventions/domains.md +2 -2
  18. package/dist/assets/templates/html/health.html +3 -5
  19. package/dist/cli/retired-commands.js +1 -1
  20. package/dist/cli/shared.js +6 -2
  21. package/dist/cli/unknown-flags.js +24 -1
  22. package/dist/cli.js +68 -10
  23. package/dist/commands/agent/agent-dispatch.js +1 -1
  24. package/dist/commands/command/command-execution.js +24 -62
  25. package/dist/commands/feedback-cli.js +0 -1
  26. package/dist/commands/health/accept-rate.js +6 -0
  27. package/dist/commands/health/archive-usage.js +92 -0
  28. package/dist/commands/health/checks.js +83 -74
  29. package/dist/commands/health/config-skew.js +38 -0
  30. package/dist/commands/health/data-dir-usage.js +25 -13
  31. package/dist/commands/health/egress.js +54 -0
  32. package/dist/commands/health/html-report.js +1 -42
  33. package/dist/commands/health/improve-metrics.js +136 -591
  34. package/dist/commands/health/md-report.js +1 -6
  35. package/dist/commands/health/plugin-staleness.js +53 -3
  36. package/dist/commands/health/renderers.js +12 -4
  37. package/dist/commands/health/report-view-model.js +14 -120
  38. package/dist/commands/health/types-improve.js +4 -19
  39. package/dist/commands/health/windows.js +64 -74
  40. package/dist/commands/health.js +145 -143
  41. package/dist/commands/improve/consolidate/chunking.js +26 -117
  42. package/dist/commands/improve/consolidate/continuity-check.js +137 -0
  43. package/dist/commands/improve/consolidate/pair-pass.js +791 -0
  44. package/dist/commands/improve/consolidate/sanitize.js +54 -149
  45. package/dist/commands/improve/consolidate.js +589 -1127
  46. package/dist/commands/improve/content-hash.js +16 -24
  47. package/dist/commands/improve/distill/content-repair.js +18 -100
  48. package/dist/commands/improve/distill-guards.js +20 -81
  49. package/dist/commands/improve/distill-promotion-policy.js +23 -243
  50. package/dist/commands/improve/distill.js +608 -1041
  51. package/dist/commands/improve/eligibility.js +126 -390
  52. package/dist/commands/improve/execution.js +8 -10
  53. package/dist/commands/improve/extract-prompt.js +1 -2
  54. package/dist/commands/improve/extract.js +487 -1046
  55. package/dist/commands/improve/feedback-valence.js +0 -25
  56. package/dist/commands/improve/improve-cli.js +75 -169
  57. package/dist/commands/improve/improve-result-file.js +10 -66
  58. package/dist/commands/improve/improve-strategies.js +52 -4
  59. package/dist/commands/improve/improve-usage-report.js +18 -64
  60. package/dist/commands/improve/improve.js +480 -1074
  61. package/dist/commands/improve/ledger.js +119 -0
  62. package/dist/commands/improve/locks.js +2 -8
  63. package/dist/commands/improve/loop-stages.js +415 -1073
  64. package/dist/commands/improve/memory/derived-ref.js +12 -77
  65. package/dist/commands/improve/memory/memory-belief.js +16 -118
  66. package/dist/commands/improve/memory/memory-improve.js +266 -14
  67. package/dist/commands/improve/outcome-loop.js +28 -156
  68. package/dist/commands/improve/planner.js +5 -15
  69. package/dist/commands/improve/preparation.js +779 -2319
  70. package/dist/commands/improve/proactive-maintenance.js +34 -101
  71. package/dist/commands/improve/reflect-noise.js +104 -280
  72. package/dist/commands/improve/reflect.js +642 -1353
  73. package/dist/commands/improve/retrieval-gate.js +127 -0
  74. package/dist/commands/improve/retrieval-scope.js +92 -0
  75. package/dist/commands/improve/salience.js +41 -240
  76. package/dist/commands/improve/session-asset.js +19 -100
  77. package/dist/commands/improve/stage.js +322 -0
  78. package/dist/commands/lint/base-linter.js +37 -15
  79. package/dist/commands/proposal/drain.js +261 -578
  80. package/dist/commands/proposal/proposal-cli.js +19 -20
  81. package/dist/commands/proposal/proposal-types.js +31 -24
  82. package/dist/commands/proposal/proposal.js +38 -8
  83. package/dist/commands/proposal/propose.js +134 -160
  84. package/dist/commands/proposal/repository.js +1097 -1394
  85. package/dist/commands/proposal/validators/proposal-quality-validators.js +71 -174
  86. package/dist/commands/proposal/validators/proposal-validators.js +1 -1
  87. package/dist/commands/proposal/validators/proposals.js +22 -89
  88. package/dist/commands/read/curate.js +105 -462
  89. package/dist/commands/read/knowledge.js +3 -2
  90. package/dist/commands/read/search-cli.js +16 -33
  91. package/dist/commands/read/search.js +17 -23
  92. package/dist/commands/read/show.js +57 -108
  93. package/dist/commands/sources/bundle-cli.js +25 -2
  94. package/dist/commands/sources/bundle-config-ops.js +4 -0
  95. package/dist/commands/sources/dangerous-env-audit.js +1 -2
  96. package/dist/commands/sources/info.js +127 -29
  97. package/dist/commands/sources/installed-stashes.js +197 -746
  98. package/dist/commands/sources/schema-repair.js +98 -129
  99. package/dist/commands/sources/source-add.js +62 -12
  100. package/dist/commands/sources/source-manage.js +9 -2
  101. package/dist/commands/sources/stash-cli.js +24 -4
  102. package/dist/commands/tasks/explain.js +10 -13
  103. package/dist/commands/tasks/tasks-cli.js +12 -13
  104. package/dist/commands/tasks/tasks.js +350 -936
  105. package/dist/commands/tasks/validate.js +26 -24
  106. package/dist/commands/workflow/plan.js +22 -29
  107. package/dist/commands/workflow-cli.js +4 -4
  108. package/dist/core/adapter/adapters/akm-adapter.js +2 -1
  109. package/dist/core/adapter/adapters/akm-lint.js +2 -3
  110. package/dist/core/adapter/adapters/akm-metadata.js +42 -12
  111. package/dist/core/adapter/adapters/akm-task-adapter.js +29 -8
  112. package/dist/core/adapter/adapters/akm-workflow-adapter.js +1 -1
  113. package/dist/core/adapter/execution-source.js +17 -29
  114. package/dist/core/asset/asset-placement.js +4 -13
  115. package/dist/core/asset/frontmatter.js +106 -1
  116. package/dist/core/asset/resolve-ref.js +1 -1
  117. package/dist/core/bundle-id.js +42 -5
  118. package/dist/core/bundle-rename.js +285 -0
  119. package/dist/core/config/config-io.js +1 -2
  120. package/dist/core/config/config-schema.js +9 -34
  121. package/dist/core/config/config-walker.js +1 -1
  122. package/dist/core/config/config.js +184 -111
  123. package/dist/core/config/engine-semantics.js +0 -2
  124. package/dist/core/config/legacy-source-shape-shim.js +38 -9
  125. package/dist/core/config/schema/embedding.js +20 -5
  126. package/dist/core/config/schema/engines.js +5 -0
  127. package/dist/core/config/schema/execution.js +1 -1
  128. package/dist/core/config/schema/experimental.js +1 -1
  129. package/dist/core/config/schema/improve-processes.js +54 -125
  130. package/dist/core/config/schema/improve.js +4 -42
  131. package/dist/core/config/schema/index-config.js +9 -48
  132. package/dist/core/config/schema/scheduler.js +12 -12
  133. package/dist/core/config/schema/search.js +6 -22
  134. package/dist/core/env-secret-ref.js +0 -1
  135. package/dist/core/errors.js +8 -9
  136. package/dist/core/file-change.js +13 -5
  137. package/dist/core/file-lock.js +76 -173
  138. package/dist/core/improve-result.js +35 -7
  139. package/dist/core/improve-types.js +0 -1
  140. package/dist/core/logs-db.js +2 -2
  141. package/dist/core/loopback.js +7 -12
  142. package/dist/core/non-task-input.js +20 -0
  143. package/dist/core/parse.js +13 -16
  144. package/dist/core/paths.js +0 -24
  145. package/dist/core/redaction.js +109 -2
  146. package/dist/core/run-lock.js +2 -5
  147. package/dist/core/spawn-env.js +1 -1
  148. package/dist/core/state/migrations.js +123 -61
  149. package/dist/core/state-db-scope.js +2 -4
  150. package/dist/core/state-db.js +126 -692
  151. package/dist/core/time.js +0 -20
  152. package/dist/core/type-presentation.js +1 -9
  153. package/dist/core/write-source.js +294 -1005
  154. package/dist/execution/input-contract.js +1 -1
  155. package/dist/execution/resolved-request.js +135 -689
  156. package/dist/execution/source.js +63 -257
  157. package/dist/execution/target-ref.js +1 -1
  158. package/dist/indexer/bundle-identity-guard.js +2 -2
  159. package/dist/indexer/db/llm-cache.js +2 -2
  160. package/dist/indexer/ensure-index.js +77 -73
  161. package/dist/indexer/index-rebuild-lock.js +3 -11
  162. package/dist/indexer/index-writer-lock.js +8 -17
  163. package/dist/indexer/index-written-assets.js +141 -154
  164. package/dist/indexer/indexer.js +400 -1124
  165. package/dist/indexer/links/declared-links.js +90 -0
  166. package/dist/indexer/materialize-embeddings.js +60 -397
  167. package/dist/indexer/passes/memory-inference.js +96 -90
  168. package/dist/indexer/passes/metadata.js +132 -219
  169. package/dist/indexer/read-preflight.js +0 -7
  170. package/dist/indexer/scan/doc-to-entry.js +2 -3
  171. package/dist/indexer/scan/drain-dir.js +1 -1
  172. package/dist/indexer/search/db-search.js +190 -590
  173. package/dist/indexer/search/fts-query.js +30 -41
  174. package/dist/indexer/search/ranking.js +28 -154
  175. package/dist/indexer/search/search-attribution.js +12 -32
  176. package/dist/indexer/search/search-fields.js +11 -15
  177. package/dist/indexer/search/search-hit-enrichers.js +54 -85
  178. package/dist/indexer/search/search-source.js +1 -4
  179. package/dist/indexer/usage/usage-events.js +36 -7
  180. package/dist/indexer/walk/walker.js +3 -4
  181. package/dist/integrations/agent/engine-fallback.js +23 -40
  182. package/dist/integrations/agent/engine-resolution.js +93 -183
  183. package/dist/integrations/agent/execution.js +507 -0
  184. package/dist/integrations/agent/model-map.js +28 -156
  185. package/dist/integrations/agent/request-lowering.js +66 -141
  186. package/dist/integrations/agent/runner-dispatch.js +143 -321
  187. package/dist/integrations/agent/runner.js +54 -14
  188. package/dist/integrations/lockfile.js +53 -101
  189. package/dist/llm/client.js +18 -6
  190. package/dist/llm/embedders/deterministic.js +2 -3
  191. package/dist/llm/embedders/profile.js +71 -0
  192. package/dist/llm/embedders/remote.js +11 -17
  193. package/dist/llm/feature-gate.js +0 -8
  194. package/dist/llm/index-passes.js +3 -5
  195. package/dist/llm/memory-infer.js +1 -2
  196. package/dist/llm/structured-call.js +5 -24
  197. package/dist/output/generic-render.js +23 -11
  198. package/dist/output/html-render.js +13 -10
  199. package/dist/output/render-registry.js +3 -32
  200. package/dist/output/shapes/helpers.js +25 -38
  201. package/dist/output/shapes/passthrough.js +1 -9
  202. package/dist/{indexer/graph/graph-types.js → output/text/bundle-rename.js} +4 -1
  203. package/dist/output/text/command-format.js +69 -31
  204. package/dist/output/text/helpers.js +1 -1
  205. package/dist/output/text/migrate.js +5 -14
  206. package/dist/output/text/proposal-format.js +48 -3
  207. package/dist/output/text/show-format.js +13 -17
  208. package/dist/output/text/workflow-format.js +0 -32
  209. package/dist/output/text.js +2 -0
  210. package/dist/registry/factory.js +4 -19
  211. package/dist/registry/network.js +66 -220
  212. package/dist/registry/providers/index.js +0 -2
  213. package/dist/registry/providers/skills-sh.js +3 -14
  214. package/dist/registry/providers/static-index.js +24 -26
  215. package/dist/registry/resolve.js +55 -131
  216. package/dist/scripts/akm-migrate-node.js +42948 -92369
  217. package/dist/scripts/akm-migrate.js +42935 -92354
  218. package/dist/setup/registry-stash-loader.js +4 -13
  219. package/dist/setup/semantic-assets.js +3 -44
  220. package/dist/setup/setup.js +1 -1
  221. package/dist/setup/steps/connection.js +5 -6
  222. package/dist/setup/steps/platforms.js +2 -2
  223. package/dist/setup/steps/tasks.js +25 -15
  224. package/dist/sources/provider-factory.js +17 -18
  225. package/dist/sources/providers/filesystem.js +2 -3
  226. package/dist/sources/providers/git-install.js +7 -1
  227. package/dist/sources/providers/git-provider.js +0 -3
  228. package/dist/sources/providers/git-stash.js +83 -21
  229. package/dist/sources/providers/npm.js +2 -4
  230. package/dist/sources/providers/provider-utils.js +5 -10
  231. package/dist/sources/providers/website.js +0 -2
  232. package/dist/sources/snapshot-fetchers/website-ingest.js +1 -1
  233. package/dist/sources/website-url.js +2 -2
  234. package/dist/storage/database.js +9 -35
  235. package/dist/storage/repositories/improve-ledger-repository.js +209 -0
  236. package/dist/storage/repositories/index-connection.js +39 -72
  237. package/dist/storage/repositories/index-entries-repository.js +131 -129
  238. package/dist/storage/repositories/index-entry-mapper.js +1 -2
  239. package/dist/storage/repositories/index-entry-schema.js +101 -268
  240. package/dist/storage/repositories/index-fts-repository.js +86 -256
  241. package/dist/storage/repositories/index-links-repository.js +143 -0
  242. package/dist/storage/repositories/index-llm-cache-repository.js +7 -9
  243. package/dist/storage/repositories/index-meta-repository.js +6 -4
  244. package/dist/storage/repositories/index-schema.js +257 -325
  245. package/dist/storage/repositories/index-utility-repository.js +8 -29
  246. package/dist/storage/repositories/index-vec-repository.js +133 -414
  247. package/dist/storage/repositories/outcome-repository.js +2 -1
  248. package/dist/storage/repositories/proposals-repository.js +104 -1
  249. package/dist/storage/repositories/registry-index-cache-repository.js +100 -0
  250. package/dist/storage/repositories/salience-repository.js +1 -19
  251. package/dist/storage/repositories/task-history-repository.js +26 -4
  252. package/dist/storage/repositories/workflow-runs-repository.js +53 -244
  253. package/dist/storage/sqlite-migrations.js +136 -0
  254. package/dist/storage/sqlite-pragmas.js +11 -9
  255. package/dist/storage/sqlite-transaction.js +170 -0
  256. package/dist/storage/state-db-integrity.js +130 -0
  257. package/dist/tasks/activation-config.js +134 -62
  258. package/dist/tasks/backends/cron.js +191 -302
  259. package/dist/tasks/backends/exec-utils.js +2 -5
  260. package/dist/tasks/backends/launchd.js +141 -748
  261. package/dist/tasks/backends/schtasks.js +119 -623
  262. package/dist/tasks/prepare/prepare-support.js +5 -15
  263. package/dist/tasks/prepare/prepare.js +0 -2
  264. package/dist/tasks/resolve-akm-bin.js +20 -79
  265. package/dist/tasks/run/attempt-lifecycle.js +0 -1
  266. package/dist/tasks/run/load-task.js +1 -1
  267. package/dist/tasks/scheduler-binding.js +20 -238
  268. package/dist/tasks/scheduler-invocation.js +136 -244
  269. package/dist/tasks/scheduler-lock.js +53 -0
  270. package/dist/tasks/scheduler-sync.js +368 -679
  271. package/dist/tasks/source/parse-task-source.js +55 -9
  272. package/dist/tasks/source/task-source-v3-frozen.js +3 -4
  273. package/dist/tasks/source/task-to-v4.js +464 -88
  274. package/dist/workflows/authoring/authoring.js +3 -12
  275. package/dist/workflows/compile.js +211 -0
  276. package/dist/workflows/concurrency-policy.js +13 -74
  277. package/dist/workflows/exec/child-invocation.js +3 -17
  278. package/dist/workflows/exec/child-workflow.js +32 -141
  279. package/dist/workflows/exec/dispatch-redaction.js +13 -53
  280. package/dist/workflows/exec/environment.js +98 -0
  281. package/dist/workflows/exec/exec-unit.js +33 -140
  282. package/dist/workflows/exec/frozen-judge.js +7 -59
  283. package/dist/workflows/exec/native-executor.js +82 -341
  284. package/dist/workflows/exec/param-secrets.js +29 -47
  285. package/dist/workflows/exec/run-workflow.js +154 -387
  286. package/dist/workflows/exec/scheduler.js +9 -36
  287. package/dist/workflows/exec/step-work.js +127 -430
  288. package/dist/workflows/exec/unit-dispatch.js +11 -63
  289. package/dist/workflows/exec/unit-writer.js +8 -52
  290. package/dist/workflows/exec/worktree.js +39 -273
  291. package/dist/workflows/freeze/child-output-references.js +4 -15
  292. package/dist/workflows/freeze/environment.js +99 -92
  293. package/dist/workflows/freeze/freeze.js +172 -0
  294. package/dist/workflows/freeze/step-values.js +19 -21
  295. package/dist/workflows/freeze/targets/child-workflow.js +23 -92
  296. package/dist/workflows/freeze/targets/command.js +10 -33
  297. package/dist/workflows/freeze/targets/script.js +5 -12
  298. package/dist/workflows/freeze/targets/shell.js +3 -6
  299. package/dist/workflows/freeze/targets/task.js +25 -80
  300. package/dist/workflows/freeze/task-bindings.js +20 -67
  301. package/dist/workflows/{source-ir/github-yaml.js → github-yaml.js} +88 -206
  302. package/dist/workflows/ir/params.js +6 -51
  303. package/dist/workflows/ir/plan-hash.js +2 -34
  304. package/dist/workflows/parser.js +140 -43
  305. package/dist/{commands/improve/consolidate/types.js → workflows/plan.js} +2 -1
  306. package/dist/workflows/renderer.js +36 -69
  307. package/dist/workflows/resource-limits.js +12 -120
  308. package/dist/workflows/runtime/agent-identity.js +8 -40
  309. package/dist/workflows/runtime/run-outputs.js +3 -6
  310. package/dist/workflows/runtime/run-plan.js +316 -0
  311. package/dist/workflows/runtime/runs.js +48 -200
  312. package/dist/workflows/runtime/workflow-asset-loader.js +24 -57
  313. package/dist/workflows/{source-ir/semantics.js → source-semantics.js} +16 -20
  314. package/dist/workflows/validate-summary.js +2 -7
  315. package/docs/integration/bundling-akm.md +49 -42
  316. package/docs/migration/README.md +1 -0
  317. package/docs/migration/release-notes/0.9.17.md +43 -0
  318. package/docs/migration/v0.9.1-to-v0.9.2.md +23 -7
  319. package/docs/reference/cli.md +232 -135
  320. package/docs/reference/configuration.md +71 -57
  321. package/docs/reference/data-and-telemetry.md +20 -21
  322. package/docs/reference/tasks.md +105 -39
  323. package/docs/reference/workflow-schema.md +14 -18
  324. package/docs/reference/workflows.md +6 -9
  325. package/package.json +1 -1
  326. package/schemas/akm-config.json +115 -738
  327. package/schemas/akm-workflow.json +1 -0
  328. package/dist/assets/improve-strategies/graph-refresh.json +0 -15
  329. package/dist/assets/prompts/contradiction-judge.md +0 -33
  330. package/dist/assets/prompts/graph-extract-system.md +0 -1
  331. package/dist/assets/prompts/graph-extract-user-prompt.md +0 -35
  332. package/dist/assets/prompts/metadata-enhance-system.md +0 -1
  333. package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +0 -4
  334. package/dist/commands/health/advisories.js +0 -150
  335. package/dist/commands/health/metrics.js +0 -329
  336. package/dist/commands/health/surfaces.js +0 -102
  337. package/dist/commands/improve/anti-collapse.js +0 -83
  338. package/dist/commands/improve/collapse-detector.js +0 -432
  339. package/dist/commands/improve/consolidate/eligibility.js +0 -48
  340. package/dist/commands/improve/consolidate/merge.js +0 -149
  341. package/dist/commands/improve/distill/promote-memory.js +0 -291
  342. package/dist/commands/improve/distill/quality-gate.js +0 -337
  343. package/dist/commands/improve/eval-cases.js +0 -52
  344. package/dist/commands/improve/memory/memory-contradiction-detect.js +0 -291
  345. package/dist/commands/improve/proposal-envelope.js +0 -31
  346. package/dist/commands/improve/run-context.js +0 -123
  347. package/dist/commands/improve/shared.js +0 -31
  348. package/dist/commands/improve/source-identity.js +0 -28
  349. package/dist/commands/improve/triage.js +0 -96
  350. package/dist/commands/proposal/drain-policies.js +0 -151
  351. package/dist/commands/sources/update-transaction.js +0 -220
  352. package/dist/core/action-contributors.js +0 -28
  353. package/dist/core/config/config-version-shim.js +0 -101
  354. package/dist/core/fs-txn.js +0 -405
  355. package/dist/core/lexical-score.js +0 -25
  356. package/dist/core/maintenance-barrier.js +0 -167
  357. package/dist/execution/executable-identity.js +0 -105
  358. package/dist/execution/guarded-source.js +0 -427
  359. package/dist/indexer/db/graph-db.js +0 -444
  360. package/dist/indexer/graph/graph-boost.js +0 -427
  361. package/dist/indexer/graph/graph-dedup.js +0 -95
  362. package/dist/indexer/graph/graph-extraction.js +0 -1108
  363. package/dist/indexer/search/name-match.js +0 -35
  364. package/dist/indexer/search/ranking-contributors.js +0 -515
  365. package/dist/indexer/search/ranking-types.js +0 -4
  366. package/dist/indexer/walk/project-context.js +0 -192
  367. package/dist/integrations/agent/execution-cascade.js +0 -566
  368. package/dist/integrations/agent/execution-definitions.js +0 -202
  369. package/dist/integrations/agent/execution-lowering.js +0 -841
  370. package/dist/integrations/agent/execution-preparation.js +0 -98
  371. package/dist/integrations/agent/inline-execution.js +0 -74
  372. package/dist/llm/graph-extract.js +0 -728
  373. package/dist/llm/metadata-enhance.js +0 -96
  374. package/dist/registry/create-provider-registry.js +0 -29
  375. package/dist/registry/pinned-request-helper.js +0 -247
  376. package/dist/registry/pinned-transport.js +0 -717
  377. package/dist/sources/providers/index.js +0 -14
  378. package/dist/storage/engines/sqlite-migrations.js +0 -271
  379. package/dist/storage/repositories/canaries-repository.js +0 -107
  380. package/dist/storage/repositories/embedding-salvage-repository.js +0 -184
  381. package/dist/storage/repositories/registry-cache.js +0 -113
  382. package/dist/tasks/scheduler-sync-preview.js +0 -52
  383. package/dist/tasks/source/task-to-v3.js +0 -507
  384. package/dist/workflows/freeze/resolve-steps.js +0 -86
  385. package/dist/workflows/freeze/source-freeze.js +0 -64
  386. package/dist/workflows/ir/compile.js +0 -321
  387. package/dist/workflows/ir/environment-v4.js +0 -330
  388. package/dist/workflows/ir/freeze-v4.js +0 -153
  389. package/dist/workflows/ir/schema-v4.js +0 -745
  390. package/dist/workflows/ir/schema.js +0 -354
  391. package/dist/workflows/program/schema.js +0 -77
  392. package/dist/workflows/runtime/checkin.js +0 -57
  393. package/dist/workflows/runtime/plan-classifier.js +0 -196
  394. package/dist/workflows/runtime/unit-checkin.js +0 -45
  395. package/dist/workflows/runtime/unit-phases.js +0 -20
  396. package/dist/workflows/schema.js +0 -4
  397. package/dist/workflows/source-ir/compile.js +0 -200
  398. package/dist/workflows/source-ir/program.js +0 -50
  399. package/dist/workflows/source-ir/result.js +0 -26
  400. package/dist/workflows/source-ir/schema.js +0 -786
  401. package/dist/workflows/source-ir/triggers.js +0 -79
  402. package/dist/workflows/source-ir/uses.js +0 -40
  403. package/dist/workflows/validator.js +0 -60
@@ -2,190 +2,91 @@
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  /**
5
- * Proposal substrate (#225, storage consolidated in #578).
6
- *
7
- * One durable proposal store for every future reflection / generation flow
8
- * (`akm reflect`, `akm propose`, `akm distill`, lesson distillation, …).
9
- * Proposals are *queue state*, not source-of-truth assets — they sit in the
10
- * queue waiting for human (or automated) review and only become assets after
11
- * `akm proposal accept` validates and promotes them via
12
- * {@link writeAssetToSource}.
13
- *
14
- * # Storage
15
- *
16
- * The canonical store is the `proposals` table in `state.db` (SQLite, WAL
17
- * mode — see `src/core/state-db.ts`). Rows are partitioned by `stash_dir` so
18
- * multi-stash installs keep independent queues, and the `status` column
19
- * distinguishes the live queue (`pending`) from the archive (`accepted` /
20
- * `rejected` / `reverted`). There is no separate archive location — archival
21
- * is a status flip, and the full audit trail (review outcome, reason, backup
22
- * content for revert) lives on the row.
23
- *
24
- * # Why the queue bypasses `writeAssetToSource`
25
- *
26
- * The architectural rule "all writes go through `writeAssetToSource`" applies
27
- * to *assets*. Proposals are **not** assets — they live outside the asset
28
- * tree (in state.db, parallel to how events do). Routing them through
29
- * `writeAssetToSource` would force them into a placement stash-subdir slot, would commit
30
- * them to git, and would leak unaccepted drafts through the normal indexer.
31
- * The {@link promoteProposal} step is the bridge: it routes the accepted
32
- * payload through `writeAssetToSource` so the actual asset write still
33
- * funnels through the single dispatch point in `src/core/write-source.ts`.
5
+ * The proposal queue (#225; storage in state.db since #578). Proposals are
6
+ * queue state, not assets: rows in the `proposals` table partitioned by
7
+ * `stash_dir`, where archival is a status flip that keeps the full audit trail
8
+ * (review, reason, backup for revert). They never go through the asset writer
9
+ * until accepted — {@link promoteProposal} is the bridge that writes the
10
+ * accepted payload into the bundle.
34
11
  */
35
- import { createHash, randomUUID } from "node:crypto";
12
+ import { randomUUID } from "node:crypto";
36
13
  import fs from "node:fs";
37
14
  import os from "node:os";
38
15
  import path from "node:path";
39
16
  import { parse as parseYaml } from "yaml";
40
- import { adapterForId } from "../../core/adapter/registry.js";
41
- import { createValidateContext } from "../../core/adapter/validate-context.js";
42
17
  import { ensureAkmMarkdownType } from "../../core/asset/akm-markdown.js";
43
18
  import { assetPathForName, placementTypes, stashDirFor } from "../../core/asset/asset-placement.js";
44
19
  import { isBundleSlug, parseBundleRef } from "../../core/asset/asset-ref.js";
45
20
  import { assembleAsset, serializeFrontmatter } from "../../core/asset/asset-serialize.js";
46
- import { parseFrontmatter } from "../../core/asset/frontmatter.js";
21
+ import { carryForwardBookkeepingFrontmatter, parseFrontmatter } from "../../core/asset/frontmatter.js";
47
22
  import { conceptIdFromTypeName, parseRefInput } from "../../core/asset/resolve-ref.js";
48
- import { isWithin } from "../../core/common.js";
49
23
  import { loadConfig } from "../../core/config/config.js";
50
24
  import { ConfigError, NotFoundError, UsageError } from "../../core/errors.js";
51
25
  import { appendEvent } from "../../core/events.js";
52
26
  import { proposalContent } from "../../core/file-change.js";
53
- import { _setTxnMutationHookForTests, advanceTxn, beginTxn, canonicalTxnRoot, cleanupTxn, fsyncTxnDir, fsyncTxnFile, listTxnJournals, mintTxnId, registerTxnKind, sweepJournallessTxnDir, txnDirFor, txnMutationHook, txnNamespaceDir, } from "../../core/fs-txn.js";
54
27
  import { canonicalBundleIdForTarget, resolveBundleWriteTarget } from "../../core/mutation-target.js";
55
28
  import { getStateDbPath, withImmediateTransaction, withStateDb } from "../../core/state-db.js";
56
29
  import { warn } from "../../core/warn.js";
57
30
  import { recordWrittenPath } from "../../core/write-provenance.js";
58
- import { assertAkmAssetWrite, assertWriteTargetPathsClean, captureGitPublication, captureWriteTargetPathSnapshot, prepareWriteTargetForMutation, publishWriteTargetTransaction, resolveWriteTarget, } from "../../core/write-source.js";
31
+ import { assertAkmAssetWrite, commitWriteTargetBoundary, prepareWriteTargetForMutation, resolveWriteTarget, } from "../../core/write-source.js";
59
32
  import { withAssetMutationLease } from "../../indexer/index-writer-lock.js";
60
33
  import { indexWrittenAssets } from "../../indexer/index-written-assets.js";
61
34
  import { deriveInstallations } from "../../indexer/installations.js";
62
35
  import { resolveSourceEntries } from "../../indexer/search/search-source.js";
63
36
  import { insertEventOnce } from "../../storage/repositories/events-repository.js";
37
+ import { recordImproveLedger, recordImproveLedgerDecision, } from "../../storage/repositories/improve-ledger-repository.js";
64
38
  import { getStateProposal, listStateProposalIdsByPrefix, listStateProposals, upsertProposal, } from "../../storage/repositories/proposals-repository.js";
65
39
  import { openSqliteReadSnapshot } from "../../storage/sqlite-read-snapshot.js";
66
40
  import { pkgVersion } from "../../version.js";
41
+ import { contentHash } from "../improve/content-hash.js";
42
+ import { writeSupersededEdge } from "../improve/memory/memory-belief.js";
43
+ import { archiveCleanupCandidate, derivedTwinPath } from "../improve/memory/memory-improve.js";
67
44
  import { runBaseChecks } from "../lint/base-linter.js";
68
45
  import { formatNewAssetDiff, formatUnifiedDiff } from "./diff-format.js";
69
- import { isAutomatedProposalSource, isValidProposalSource, PROPOSAL_SOURCES, } from "./proposal-types.js";
46
+ import { ASSET_MISSING_GATE_REASON, EXPIRED_GATE_REASON, isAutomatedProposalSource, isRetireProposal, isValidProposalSource, PROPOSAL_SOURCES, STALE_TARGET_GATE_REASON, } from "./proposal-types.js";
70
47
  import { canonicalOnlyProposalValidators, hasCanonicalProposalValidator, runProposalValidators, } from "./validators/proposal-validators.js";
71
48
  import { repairProposalContent, validateProposal } from "./validators/proposals.js";
72
- const PROMOTION_LINT_ISSUE_TYPES = new Set(["unquoted-colon", "missing-ref", "stale-path"]);
73
- // ── Proposal domain types (moved to ./proposal-types.ts, WI-9.8 KILL 1) ─────
74
- //
75
- // Proposal / ProposalStatus / ProposalPayload / ProposalReview /
76
- // ProposalGateDecision(Outcome) / ProposalSource / PROPOSAL_SOURCES /
77
- // AUTOMATED_PROPOSAL_SOURCES / isValidProposalSource / isAutomatedProposalSource
78
- // moved to the dependency-free leaf so validators/proposals.ts,
79
- // validators/proposal-validators.ts, storage/repositories/proposals-repository.ts,
80
- // and storage repositories can import the `Proposal` type without importing
81
- // this heavier transaction-engine module back. That back-edge was the
82
- // repository↔validators import cycle (plan §10.7 D.3). Every symbol this
83
- // module used to export directly is re-exported here verbatim so existing
84
- // import sites (`from "../proposal/repository.js"`) are unchanged.
85
49
  export { AUTOMATED_PROPOSAL_SOURCES, isAutomatedProposalSource, isValidProposalSource, PROPOSAL_SOURCES, } from "./proposal-types.js";
86
- // The envelope's primary-content accessor lives in the dependency-free
87
- // core/file-change module; re-exported here so proposal consumers get it
88
- // alongside the repository API.
89
50
  export { proposalContent };
90
- /**
91
- * Copy of `p` with `content` replacing BOTH the payload's content and the
92
- * primary change's `after` — every in-memory content mutation must keep the
93
- * WI-6.2 invariant (`changes[0].after === payload.content`) intact.
94
- */
51
+ const PROMOTION_LINT_ISSUE_TYPES = new Set(["unquoted-colon", "missing-ref", "stale-path"]);
52
+ const MS_PER_DAY = 86_400_000;
53
+ /** `p` with `content` as both the payload and the primary change's `after` (they must agree). */
95
54
  function withProposalContent(p, content) {
96
55
  return {
97
56
  ...p,
98
57
  payload: { ...p.payload, content },
99
- changes: p.changes.map((c, i) =>
100
- // A delete-op primary change carries no `after` (file-change.ts contract).
101
- i === 0 && c.op !== "delete" ? { ...c, after: content } : c),
58
+ // A delete-op primary change carries no `after`.
59
+ changes: p.changes.map((c, i) => (i === 0 && c.op !== "delete" ? { ...c, after: content } : c)),
102
60
  };
103
61
  }
104
- /** Type guard: true when createProposal returned a skipped record. */
105
- export function isProposalSkipped(result) {
106
- return result.skipped === true;
107
- }
108
- // ── Fingerprint / rejection-backoff constants ────────────────────────────────
109
- const MS_PER_DAY = 86_400_000;
110
- /**
111
- * Post-rejection backoff windows by source (the RETAINED cooldown semantics,
112
- * plan §4.5). After a proposal is rejected, `createProposal` silently skips
113
- * new proposals for the same `ref+source` until the window expires (unless
114
- * `force: true` is passed).
115
- *
116
- * Rationale (Settles 2009 active-learning survey; Argilla/Label Studio HITL):
117
- * Reviewer fatigue is a blocker for the human-in-the-loop guarantee. Backoff
118
- * prevents nightly improve runs from re-flooding the queue with near-identical
119
- * proposals the reviewer just declined.
120
- *
121
- * - reflect: 14 days (agent-based; slower feedback loops)
122
- * - distill: 30 days (LLM-based; even more prone to regeneration loops)
123
- * - default: 7 days (conservative fallback for other sources)
124
- */
125
- const COOLDOWN_MS = {
126
- reflect: 14 * MS_PER_DAY,
127
- distill: 30 * MS_PER_DAY,
128
- };
129
- const DEFAULT_COOLDOWN_MS = 7 * MS_PER_DAY;
130
- function cooldownMsForSource(source) {
131
- return COOLDOWN_MS[source] ?? DEFAULT_COOLDOWN_MS;
132
- }
133
- /** Compute a stable SHA-256 hex digest of a proposal's content string. */
134
- function contentHash(content) {
135
- return createHash("sha256").update(content, "utf8").digest("hex");
136
- }
137
- // ── Store access ─────────────────────────────────────────────────────────────
138
62
  function nowIso(ctx) {
139
- const fn = ctx?.now ?? Date.now;
140
- return new Date(fn()).toISOString();
141
- }
142
- function newId(ctx) {
143
- const fn = ctx?.randomUUID ?? randomUUID;
144
- return fn();
63
+ return new Date((ctx?.now ?? Date.now)()).toISOString();
145
64
  }
146
- /**
147
- * Open the state database (honouring the `ctx.dbPath` test seam), hand the
148
- * connection to `fn`, and close it in a `finally`. Every public function in
149
- * this module funnels its store access through here.
150
- *
151
- * `stashDir` is threaded through the public API for the store's per-stash
152
- * partition.
153
- */
154
- function withProposalsDb(_stashDir, ctx, fn) {
65
+ function withProposalsDb(ctx, fn) {
155
66
  return withStateDb(fn, { path: ctx?.dbPath });
156
67
  }
68
+ function isRetiredProposalConceptId(conceptId) {
69
+ const colon = conceptId.indexOf(":");
70
+ return colon > 0 && stashDirFor(conceptId.slice(0, colon)) !== undefined;
71
+ }
157
72
  function proposalRefIdentity(ref) {
158
73
  try {
159
74
  const parsed = parseBundleRef(ref);
160
75
  if (parsed.fragment !== undefined || isRetiredProposalConceptId(parsed.conceptId))
161
76
  return undefined;
162
- return {
163
- conceptId: parsed.conceptId,
164
- ...(parsed.bundle !== undefined ? { bundle: parsed.bundle } : {}),
165
- };
77
+ return { conceptId: parsed.conceptId, ...(parsed.bundle !== undefined ? { bundle: parsed.bundle } : {}) };
166
78
  }
167
79
  catch {
168
80
  return undefined;
169
81
  }
170
82
  }
83
+ /** A `--ref` filter: a short ref matches its concept in the queue, a qualified one also its bundle. */
171
84
  function filterRefIdentity(ref) {
172
- try {
173
- const p = parseBundleRef(ref);
174
- if (p.fragment !== undefined || isRetiredProposalConceptId(p.conceptId)) {
175
- throw new Error("not a current proposal identity");
176
- }
177
- return {
178
- conceptId: p.conceptId,
179
- ...(p.bundle !== undefined ? { bundle: p.bundle } : {}),
180
- };
181
- }
182
- catch {
85
+ const identity = proposalRefIdentity(ref);
86
+ if (!identity) {
183
87
  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");
184
88
  }
185
- }
186
- function isRetiredProposalConceptId(conceptId) {
187
- const colon = conceptId.indexOf(":");
188
- return colon > 0 && stashDirFor(conceptId.slice(0, colon)) !== undefined;
89
+ return identity;
189
90
  }
190
91
  function proposalMatchesRef(proposalRef, filter) {
191
92
  const proposal = proposalRefIdentity(proposalRef);
@@ -193,17 +94,19 @@ function proposalMatchesRef(proposalRef, filter) {
193
94
  proposal.conceptId === filter.conceptId &&
194
95
  (filter.bundle === undefined || proposal.bundle === filter.bundle));
195
96
  }
97
+ /**
98
+ * The durable `proposals.ref`: `bundle//conceptId`, the conceptId built from
99
+ * the type table (so a not-yet-existing asset keys onto its final spelling) and
100
+ * the bundle the queue writes to — byte-identical to the item_ref the indexer
101
+ * mints for the accepted asset.
102
+ */
196
103
  function proposalDurableRef(parsedRef, target) {
197
104
  const conceptId = conceptIdFromTypeName(parsedRef.type, parsedRef.name);
198
- const { origin } = parsedRef;
199
105
  if (!isBundleSlug(target.source)) {
200
106
  throw new UsageError(`Proposal target source "${target.source}" is not a valid bundle name.`, "INVALID_FLAG_VALUE");
201
107
  }
202
- if (origin !== undefined) {
203
- if (target.source !== origin) {
204
- throw new UsageError(`Proposal ref bundle "${origin}" conflicts with target source "${target.source}".`, "INVALID_FLAG_VALUE");
205
- }
206
- return `${origin}//${conceptId}`;
108
+ if (parsedRef.origin !== undefined && target.source !== parsedRef.origin) {
109
+ throw new UsageError(`Proposal ref bundle "${parsedRef.origin}" conflicts with target source "${target.source}".`, "INVALID_FLAG_VALUE");
207
110
  }
208
111
  return `${target.source}//${conceptId}`;
209
112
  }
@@ -218,11 +121,8 @@ function resolveCreateProposalTarget(stashDir, explicit, bundle) {
218
121
  const local = resolveProposalQueueTarget(stashDir, config);
219
122
  if (!bundle || bundle === local.source)
220
123
  return local;
221
- if (bundle) {
222
- const target = resolveBundleWriteTarget(config, bundle);
223
- return { source: target.source.name, root: target.source.path };
224
- }
225
- return local;
124
+ const target = resolveBundleWriteTarget(config, bundle);
125
+ return { source: target.source.name, root: target.source.path };
226
126
  }
227
127
  export function resolveProposalQueueTarget(stashDir, config = loadConfig()) {
228
128
  const root = path.resolve(stashDir);
@@ -241,25 +141,11 @@ export function resolveProposalQueueTarget(stashDir, config = loadConfig()) {
241
141
  }
242
142
  return { source: bundleId, root };
243
143
  }
244
- // ── Public API ──────────────────────────────────────────────────────────────
245
144
  /**
246
- * Create a new pending proposal. The id is a stable random UUID, so two
247
- * proposals with the same `ref` never collide.
248
- *
249
- * **Input-fingerprint / rejection-backoff guard** (§23.6, WI-6.4):
250
- *
251
- * Before writing, this function checks:
252
- * 1. `fingerprint_match` — the §23.6 input fingerprint (scheme version,
253
- * source, ref, target before-hash, model id) was already processed.
254
- * The row survives the proposal's lifecycle, so identical inputs stay
255
- * deduplicated until the target, model, or scheme changes. Pass
256
- * `input.force = true` to bypass.
257
- * 2. `rejection_backoff` — a proposal for this `ref+source` was rejected
258
- * within the source-specific backoff window (reflect: 14 d, distill:
259
- * 30 d, others: 7 d). Bypass with `force: true`.
260
- *
261
- * When a guard fires the function returns a `CreateProposalSkipped` record
262
- * instead of writing. Use {@link isProposalSkipped} to detect it.
145
+ * Create a pending proposal (a random UUID id). Obviously invalid input is
146
+ * refused with a typed `proposal_creation_rejected` event. The mint and its
147
+ * `proposed` ledger rows commit in one transaction; whether a ref may be
148
+ * proposed again is decided earlier, by the stage's candidate selection.
263
149
  */
264
150
  export function createProposal(stashDir, input, ctx) {
265
151
  if (!isValidProposalSource(input.source)) {
@@ -271,10 +157,6 @@ export function createProposal(stashDir, input, ctx) {
271
157
  warn(`[proposal] Automated source "${input.source}" created a proposal without sourceRun. ` +
272
158
  "Add sourceRun to enable accept-rate-per-run aggregation (W3C PROV-DM).");
273
159
  }
274
- // Deterministic input validation. Reject obviously-invalid proposals at
275
- // the source rather than letting them enter the queue and waste reviewer
276
- // time. Each rejection emits `proposal_creation_rejected` with a typed
277
- // reason so we can see *which* check is firing in the event stream.
278
160
  const rejectProposal = (reason, message) => {
279
161
  appendEvent({
280
162
  eventType: "proposal_creation_rejected",
@@ -290,7 +172,8 @@ export function createProposal(stashDir, input, ctx) {
290
172
  catch (err) {
291
173
  return rejectProposal("invalid_ref", `Invalid proposal ref "${input.ref}": ${err instanceof Error ? err.message : String(err)}`);
292
174
  }
293
- if (!stashDirFor(parsedRef.type)) {
175
+ const typeDir = stashDirFor(parsedRef.type);
176
+ if (!typeDir) {
294
177
  return rejectProposal("unknown_type", `Unknown asset type "${parsedRef.type}" in proposal ref "${input.ref}". Known types: ${[...placementTypes()].sort().join(", ")}.`);
295
178
  }
296
179
  if (!input.payload.content.trim()) {
@@ -299,55 +182,39 @@ export function createProposal(stashDir, input, ctx) {
299
182
  if (input.target && parsedRef.origin && input.target.source !== parsedRef.origin) {
300
183
  return rejectProposal("invalid_ref", `Qualified proposal ref bundle "${parsedRef.origin}" conflicts with queue target "${input.target.source}".`);
301
184
  }
302
- // Description check is only enforced for `consolidate` source — that's the
303
- // automated pipeline that historically produced proposals with missing or
304
- // malformed frontmatter, polluting the queue with hundreds of unusable
305
- // entries. Reflect / distill / propose proposals have varied legitimate
306
- // shapes and should not be rejected here for missing description.
185
+ // Only consolidate — the pipeline that historically flooded the queue with
186
+ // frontmatter-less proposals — must carry a description.
307
187
  if (input.source === "consolidate") {
308
188
  const desc = input.payload.frontmatter?.description;
309
189
  if (typeof desc !== "string" || desc.trim() === "") {
310
190
  return rejectProposal("missing_description", `Proposal for "${input.ref}" (source=consolidate) has empty or missing frontmatter description.`);
311
191
  }
312
192
  }
193
+ // The FileChange envelope, and the target's before-hashes as of mint (the
194
+ // freshness check at accept compares against them).
313
195
  const proposalTarget = resolveCreateProposalTarget(stashDir, input.target, parsedRef.origin);
314
196
  const normalizedRef = proposalDurableRef(parsedRef, proposalTarget);
315
197
  const targetRoot = path.resolve(proposalTarget.root);
316
- // WI-6.2: derive the FileChange[] envelope + mint-time beforeHash. The
317
- // target is resolved against the proposal's OWN stash (a local snapshot —
318
- // accept re-resolves the write target from config at apply time), and only
319
- // the before-state's HASH is kept: the change's `before` body is a
320
- // transaction-time capture that does not exist at mint time.
321
198
  let targetRelPath;
322
- let mintBeforeContent;
199
+ let beforeContent;
323
200
  try {
324
- const typeRoot = path.join(targetRoot, stashDirFor(parsedRef.type));
325
- const targetAbs = assetPathForName(parsedRef.type, typeRoot, parsedRef.name);
201
+ const targetAbs = assetPathForName(parsedRef.type, path.join(targetRoot, typeDir), parsedRef.name);
326
202
  targetRelPath = path.relative(targetRoot, targetAbs);
327
203
  if (fs.existsSync(targetAbs))
328
- mintBeforeContent = fs.readFileSync(targetAbs, "utf8");
204
+ beforeContent = fs.readFileSync(targetAbs, "utf8");
329
205
  }
330
206
  catch {
331
- // Resolution failure degrades to a best-effort create — never blocks the mint.
332
- targetRelPath = path.join(stashDirFor(parsedRef.type), parsedRef.name);
207
+ targetRelPath = path.join(typeDir, parsedRef.name);
333
208
  }
334
- const proposalContent = targetRelPath.toLowerCase().endsWith(".md")
209
+ const content = targetRelPath.toLowerCase().endsWith(".md")
335
210
  ? ensureAkmMarkdownType(input.payload.content, parsedRef.type)
336
211
  : input.payload.content;
337
- const mintedChanges = [
338
- {
339
- path: targetRelPath,
340
- after: proposalContent,
341
- op: mintBeforeContent !== undefined ? "update" : "create",
342
- },
212
+ const changes = [
213
+ { path: targetRelPath, after: content, op: beforeContent !== undefined ? "update" : "create" },
343
214
  ];
344
- const mintedBeforeHash = mintBeforeContent !== undefined ? contentHash(mintBeforeContent) : undefined;
215
+ const proposedTarget = { source: proposalTarget.source, root: targetRoot };
345
216
  if (hasCanonicalProposalValidator(parsedRef.type)) {
346
- // Mint-time gate: structural shape only (generic + canonical-per-type),
347
- // NOT the full quality-validator list — see canonicalOnlyProposalValidators'
348
- // doc comment (#952 review round 2). Quality validators (including the
349
- // blocking reflect-truncation-marker guard) run at `proposal accept` /
350
- // drain-promotion time via validateProposal instead.
217
+ // Mint checks structure only; the quality validators run at accept.
351
218
  const report = runProposalValidators({
352
219
  id: "pending",
353
220
  ref: normalizedRef,
@@ -355,9 +222,9 @@ export function createProposal(stashDir, input, ctx) {
355
222
  source: input.source,
356
223
  createdAt: "",
357
224
  updatedAt: "",
358
- payload: { ...input.payload, content: proposalContent },
359
- changes: mintedChanges,
360
- proposedTarget: { source: proposalTarget.source, root: targetRoot },
225
+ payload: { ...input.payload, content },
226
+ changes,
227
+ proposedTarget,
361
228
  }, canonicalOnlyProposalValidators);
362
229
  if (!report.ok) {
363
230
  return rejectProposal("invalid_canonical_structure", `Proposal for "${input.ref}" has invalid ${parsedRef.type} structure:\n${report.findings
@@ -365,258 +232,198 @@ export function createProposal(stashDir, input, ctx) {
365
232
  .join("\n")}`);
366
233
  }
367
234
  }
368
- const fingerprint = computeProposalFingerprint({
369
- ref: normalizedRef,
370
- source: input.source,
371
- ...(mintedBeforeHash !== undefined ? { beforeHash: mintedBeforeHash } : {}),
372
- ...(input.modelId !== undefined ? { modelId: input.modelId } : {}),
373
- });
374
- return withProposalsDb(stashDir, ctx, (db) => {
375
- return withImmediateTransaction(db, () => {
376
- if (!input.force) {
377
- const skip = checkFingerprintAndBackoff(db, stashDir, normalizedRef, input, fingerprint, ctx);
378
- if (skip)
379
- return skip;
380
- }
381
- const created = nowIso(ctx);
382
- // Phase 6A: validate confidence is a finite number in [0, 1]. Anything else
383
- // is dropped silently — we never store NaN, Infinity, or out-of-range values.
384
- // Callers that mis-report confidence should not poison downstream readers.
385
- const sanitizedConfidence = typeof input.confidence === "number" &&
386
- Number.isFinite(input.confidence) &&
387
- input.confidence >= 0 &&
388
- input.confidence <= 1
389
- ? input.confidence
390
- : undefined;
391
- const proposal = {
392
- id: newId(ctx),
393
- ref: normalizedRef,
394
- status: "pending",
235
+ const confidence = typeof input.confidence === "number" &&
236
+ Number.isFinite(input.confidence) &&
237
+ input.confidence >= 0 &&
238
+ input.confidence <= 1
239
+ ? input.confidence
240
+ : undefined;
241
+ return withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
242
+ const created = nowIso(ctx);
243
+ const proposal = {
244
+ id: (ctx?.randomUUID ?? randomUUID)(),
245
+ ref: normalizedRef,
246
+ status: "pending",
247
+ source: input.source,
248
+ ...(input.sourceRun !== undefined ? { sourceRun: input.sourceRun } : {}),
249
+ createdAt: created,
250
+ updatedAt: created,
251
+ payload: {
252
+ content,
253
+ ...(input.payload.frontmatter !== undefined ? { frontmatter: input.payload.frontmatter } : {}),
254
+ },
255
+ changes,
256
+ proposedTarget,
257
+ ...(beforeContent !== undefined
258
+ ? { beforeHash: contentHash(beforeContent), beforeHashNormalized: contentHash(beforeContent, "normalized") }
259
+ : {}),
260
+ ...(confidence !== undefined ? { confidence } : {}),
261
+ ...(input.eligibilitySource !== undefined ? { eligibilitySource: input.eligibilitySource } : {}),
262
+ ...(input.promotionSource !== undefined ? { promotionSource: input.promotionSource } : {}),
263
+ ...(input.promotionSourceHash !== undefined ? { promotionSourceHash: input.promotionSourceHash } : {}),
264
+ };
265
+ upsertProposal(db, proposal, stashDir);
266
+ for (const ref of input.attemptedRefs ?? [normalizedRef]) {
267
+ recordImproveLedger(db, {
268
+ stashDir,
269
+ ref,
395
270
  source: input.source,
396
- ...(input.sourceRun !== undefined ? { sourceRun: input.sourceRun } : {}),
397
- createdAt: created,
398
- updatedAt: created,
399
- payload: {
400
- content: proposalContent,
401
- ...(input.payload.frontmatter !== undefined ? { frontmatter: input.payload.frontmatter } : {}),
402
- },
403
- changes: mintedChanges,
404
- proposedTarget: { source: proposalTarget.source, root: targetRoot },
405
- ...(mintedBeforeHash !== undefined ? { beforeHash: mintedBeforeHash } : {}),
406
- ...(sanitizedConfidence !== undefined ? { confidence: sanitizedConfidence } : {}),
407
- // Attribution tagging: persist the eligibility lane so it survives to
408
- // accept/reject/revert time. See EligibilitySource.
409
- ...(input.eligibilitySource !== undefined ? { eligibilitySource: input.eligibilitySource } : {}),
410
- };
411
- upsertProposal(db, proposal, stashDir);
412
- // Record the processed fingerprint (also on force — a forced enqueue is
413
- // still "these inputs were processed"; future unforced identical inputs
414
- // dedup against it).
415
- recordProposalFingerprint(db, stashDir, fingerprint, normalizedRef, input, proposal.id, created);
416
- return proposal;
417
- });
418
- });
419
- }
420
- /** Version stamp of the input-fingerprint scheme; bump when terms change. */
421
- const PROPOSAL_FINGERPRINT_VERSION = 1;
422
- /**
423
- * Compute the §23.6 input fingerprint for a proposal mint (+ the plan §4.5
424
- * engine/model-id term). Terms, in order: scheme version, source (the recipe
425
- * stand-in until Wave-2 recipes exist), target ref, target before-hash
426
- * (empty for a create), evidence IDs/hashes (reserved — not yet modeled),
427
- * guidance hashes (reserved), evaluator version (reserved), model id.
428
- * Deliberately an INPUT fingerprint: the generated content is not a term —
429
- * already-processed inputs skip re-processing regardless of what the model
430
- * produced this time.
431
- */
432
- function computeProposalFingerprint(args) {
433
- return contentHash([
434
- `v${PROPOSAL_FINGERPRINT_VERSION}`,
435
- args.source,
436
- args.ref,
437
- args.beforeHash ?? "",
438
- "", // evidence IDs/hashes — reserved (Wave-2 recipes)
439
- "", // guidance hashes — reserved
440
- "", // evaluator version — reserved
441
- args.modelId ?? "",
442
- ].join("\0"));
443
- }
444
- /**
445
- * Durably record a processed fingerprint (INSERT OR REPLACE — idempotent).
446
- * `ref` must be the NORMALIZED ref — the same value the fingerprint was
447
- * computed over — so future ref-keyed readers of the table never mismatch.
448
- */
449
- function recordProposalFingerprint(db, stashDir, fingerprint, ref, input, proposalId, createdAt) {
450
- db.prepare(`INSERT OR REPLACE INTO proposal_fingerprints
451
- (stash_dir, fingerprint, ref, source, model_id, proposal_id, created_at)
452
- VALUES (?, ?, ?, ?, ?, ?, ?)`).run(stashDir, fingerprint, ref, input.source, input.modelId ?? "", proposalId, createdAt);
271
+ outcome: "proposed",
272
+ at: created,
273
+ proposalId: proposal.id,
274
+ });
275
+ }
276
+ return proposal;
277
+ }));
453
278
  }
454
279
  /**
455
- * Evaluate the fingerprint + rejection-backoff guards. Returns the skip
456
- * record when a guard fires, or undefined when the create may proceed.
280
+ * Mint a `retire` proposal (0.9.17-alpha.9, the consolidate pair pass): its
281
+ * primary `FileChange` deletes `ref`'s own file rather than writing new
282
+ * content, so this does not reuse {@link createProposal} (which always
283
+ * builds a create/update change and enforces content/description rules that
284
+ * do not apply to a delete). `ref` must already exist on disk — retiring a
285
+ * phantom is a caller bug, refused rather than silently accepted.
286
+ *
287
+ * Deliberately does NOT record an `improve_ledger` "proposed" row: the pair
288
+ * pass keys its own ledger cadence per INITIATOR (source `consolidate-pair`,
289
+ * `pair-pass.ts`'s own end-of-run write), which may differ from this
290
+ * proposal's `ref` — the initiator and the retired side are not always the
291
+ * same asset (see `runConsolidatePairPass`). Recording one here, keyed by
292
+ * the retired ref instead, would be a second, competing row for whichever
293
+ * asset happens to be both.
457
294
  */
458
- function checkFingerprintAndBackoff(db, stashDir, normalizedRef, input, fingerprint, ctx) {
459
- const nowMs = (ctx?.now ?? Date.now)();
460
- const backoffMs = cooldownMsForSource(input.source);
461
- // §23.6: an already-processed fingerprint skips another model call's output
462
- // unless explicitly forced. The row survives the proposal's lifecycle —
463
- // identical inputs stay deduplicated until the target (before-hash), the
464
- // model, or the scheme changes.
465
- const existing = db
466
- .prepare("SELECT proposal_id FROM proposal_fingerprints WHERE stash_dir = ? AND fingerprint = ?")
467
- .get(stashDir, fingerprint);
468
- if (existing) {
469
- return {
470
- skipped: true,
471
- reason: "fingerprint_match",
472
- message: `These inputs were already processed into a proposal for ${normalizedRef} (fingerprint match). Pass force:true to enqueue anyway.`,
473
- ...(existing.proposal_id ? { existingProposalId: existing.proposal_id } : {}),
474
- };
295
+ export function createRetireProposal(stashDir, input, ctx) {
296
+ if (!isValidProposalSource(input.source)) {
297
+ warn(`[proposal] Unknown source "${input.source}" for a retire proposal. Expected one of: ${PROPOSAL_SOURCES.join(", ")}.`);
475
298
  }
476
- // Rejection backoff (RETAINED cooldown semantics): a recent rejection for
477
- // this ref+source suppresses new proposals until the window expires.
478
- const rejected = listStateProposals(db, { stashDir, ref: normalizedRef, status: "rejected" })
479
- .filter((p) => p.source === input.source)
480
- .sort((a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime());
481
- const mostRecent = rejected[0];
482
- if (mostRecent !== undefined) {
483
- const rejectedAt = new Date(mostRecent.updatedAt ?? 0).getTime();
484
- if (nowMs - rejectedAt < backoffMs) {
485
- const backoffDays = backoffMs / MS_PER_DAY;
486
- const remainingDays = Math.ceil((backoffMs - (nowMs - rejectedAt)) / MS_PER_DAY);
487
- return {
488
- skipped: true,
489
- reason: "rejection_backoff",
490
- message: `Proposal for ${normalizedRef} from source "${input.source}" is in rejection backoff ` +
491
- `(${backoffDays}d window, ~${remainingDays}d remaining). Pass force:true to bypass.`,
492
- existingProposalId: mostRecent.id,
493
- };
494
- }
299
+ let parsedRef;
300
+ try {
301
+ parsedRef = parseRefInput(input.ref);
302
+ }
303
+ catch (err) {
304
+ throw new UsageError(`Invalid retire proposal ref "${input.ref}": ${err instanceof Error ? err.message : String(err)}`, "INVALID_PROPOSAL");
305
+ }
306
+ const typeDir = stashDirFor(parsedRef.type);
307
+ if (!typeDir) {
308
+ throw new UsageError(`Unknown asset type "${parsedRef.type}" in retire proposal ref "${input.ref}". Known types: ${[...placementTypes()].sort().join(", ")}.`, "INVALID_PROPOSAL");
495
309
  }
496
- return undefined;
310
+ const proposalTarget = resolveCreateProposalTarget(stashDir, input.target, parsedRef.origin);
311
+ const normalizedRef = proposalDurableRef(parsedRef, proposalTarget);
312
+ const targetRoot = path.resolve(proposalTarget.root);
313
+ const targetAbs = assetPathForName(parsedRef.type, path.join(targetRoot, typeDir), parsedRef.name);
314
+ if (!fs.existsSync(targetAbs)) {
315
+ throw new UsageError(`Retire proposal target "${input.ref}" does not exist at ${targetAbs}.`, "INVALID_PROPOSAL");
316
+ }
317
+ const targetRelPath = path.relative(targetRoot, targetAbs);
318
+ const beforeContent = fs.readFileSync(targetAbs, "utf8");
319
+ const changes = [{ path: targetRelPath, op: "delete" }];
320
+ const proposedTarget = { source: proposalTarget.source, root: targetRoot };
321
+ const confidence = typeof input.confidence === "number" &&
322
+ Number.isFinite(input.confidence) &&
323
+ input.confidence >= 0 &&
324
+ input.confidence <= 1
325
+ ? input.confidence
326
+ : undefined;
327
+ return withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
328
+ const created = nowIso(ctx);
329
+ const proposal = {
330
+ id: (ctx?.randomUUID ?? randomUUID)(),
331
+ ref: normalizedRef,
332
+ status: "pending",
333
+ source: input.source,
334
+ ...(input.sourceRun !== undefined ? { sourceRun: input.sourceRun } : {}),
335
+ createdAt: created,
336
+ updatedAt: created,
337
+ payload: { content: "" },
338
+ changes,
339
+ proposedTarget,
340
+ beforeHash: contentHash(beforeContent),
341
+ beforeHashNormalized: contentHash(beforeContent, "normalized"),
342
+ ...(confidence !== undefined ? { confidence } : {}),
343
+ retirement: input.retirement,
344
+ };
345
+ upsertProposal(db, proposal, stashDir);
346
+ return proposal;
347
+ }));
497
348
  }
498
- /**
499
- * List proposals for one stash. By default returns only the live (pending)
500
- * queue; pass `{ includeArchive: true }` to include accepted / rejected /
501
- * reverted entries as well.
502
- */
503
- export function listProposals(stashDir, options = {}, ctx) {
504
- return withProposalsDb(stashDir, ctx, (db) => {
505
- // Without includeArchive, only the live queue is visible — an explicit
506
- // non-pending status filter therefore matches nothing (mirrors the
507
- // historical live-directory scan).
508
- if (!options.includeArchive && options.status !== undefined && options.status !== "pending") {
509
- return [];
349
+ function queryProposals(db, stashDir, options) {
350
+ // The live queue holds only pending proposals, so without the archive a
351
+ // non-pending status matches nothing.
352
+ if (!options.includeArchive && options.status !== undefined && options.status !== "pending")
353
+ return [];
354
+ const status = options.includeArchive ? options.status : "pending";
355
+ const wantRef = options.ref !== undefined ? filterRefIdentity(options.ref) : undefined;
356
+ return listStateProposals(db, { stashDir, ...(status !== undefined ? { status } : {}) }).filter((p) => {
357
+ if (wantRef !== undefined && !proposalMatchesRef(p.ref, wantRef))
358
+ return false;
359
+ if (!options.type)
360
+ return true;
361
+ try {
362
+ return parseRefInput(p.ref).type === options.type;
363
+ }
364
+ catch {
365
+ return false;
510
366
  }
511
- const status = options.includeArchive ? options.status : "pending";
512
- // Short filters match by conceptId; qualified filters additionally retain
513
- // bundle identity. Applied in JS because a short query does not equal the
514
- // fully-qualified stored ref.
515
- const wantRef = options.ref !== undefined ? filterRefIdentity(options.ref) : undefined;
516
- return listStateProposals(db, {
517
- stashDir,
518
- ...(status !== undefined ? { status } : {}),
519
- }).filter((p) => {
520
- if (wantRef !== undefined && !proposalMatchesRef(p.ref, wantRef)) {
521
- return false;
522
- }
523
- if (!options.type)
524
- return true;
525
- try {
526
- return parseRefInput(p.ref).type === options.type;
527
- }
528
- catch {
529
- return false;
530
- }
531
- });
532
367
  });
533
368
  }
369
+ /** One stash's proposals: the pending queue, or with `includeArchive` the decided ones too. */
370
+ export function listProposals(stashDir, options = {}, ctx) {
371
+ return withProposalsDb(ctx, (db) => queryProposals(db, stashDir, options));
372
+ }
534
373
  /**
535
- * Read proposal context without creating or migrating state.db.
536
- *
537
- * Prompt-building consumers call this before their first model dispatch. A
538
- * missing proposal store is therefore an empty snapshot, not a reason to
539
- * create durable state before a required symbolic credential is validated at
540
- * the dispatch boundary.
374
+ * {@link listProposals} on a read snapshot that never creates or migrates
375
+ * state.db: prompt building runs before the first dispatch has validated its
376
+ * credentials, and a missing store is simply empty.
541
377
  */
542
378
  export function listProposalsReadOnly(stashDir, options = {}, ctx) {
543
379
  const dbPath = ctx?.dbPath ?? getStateDbPath();
544
380
  if (!fs.existsSync(dbPath))
545
381
  return [];
546
- let db;
382
+ const db = openSqliteReadSnapshot(dbPath);
383
+ if (!db)
384
+ return [];
547
385
  try {
548
- db = openSqliteReadSnapshot(dbPath);
549
- if (!db)
550
- return [];
551
- if (!options.includeArchive && options.status !== undefined && options.status !== "pending")
552
- return [];
553
- const status = options.includeArchive ? options.status : "pending";
554
- const wantRef = options.ref !== undefined ? filterRefIdentity(options.ref) : undefined;
555
- return listStateProposals(db, {
556
- stashDir,
557
- ...(status !== undefined ? { status } : {}),
558
- }).filter((proposal) => {
559
- if (wantRef !== undefined && !proposalMatchesRef(proposal.ref, wantRef))
560
- return false;
561
- if (!options.type)
562
- return true;
563
- try {
564
- return parseRefInput(proposal.ref).type === options.type;
565
- }
566
- catch {
567
- return false;
568
- }
569
- });
386
+ return queryProposals(db, stashDir, options);
570
387
  }
571
388
  finally {
572
- db?.close();
389
+ db.close();
573
390
  }
574
391
  }
575
- /**
576
- * Look up a proposal by id (live or archived).
577
- * Throws `NotFoundError` when no match exists in this stash.
578
- */
392
+ /** A proposal by id, live or archived. */
579
393
  export function getProposal(stashDir, id, ctx) {
580
- return withProposalsDb(stashDir, ctx, (db) => requireProposal(db, stashDir, id));
394
+ return withProposalsDb(ctx, (db) => requireProposal(db, stashDir, id));
581
395
  }
582
396
  function requireProposal(db, stashDir, id) {
583
397
  const proposal = getStateProposal(db, id, stashDir);
584
- if (!proposal) {
398
+ if (!proposal)
585
399
  throw new NotFoundError(`Proposal "${id}" not found.`, "PROPOSAL_NOT_FOUND");
586
- }
587
400
  return proposal;
588
401
  }
589
402
  /**
590
- * Resolve a proposal by full UUID, UUID prefix, or asset ref.
591
- *
592
- * Resolution order:
593
- * 1. Exact UUID match (existing behaviour).
594
- * 2. Asset ref (contains `/`) — finds the most-recent pending proposal for
595
- * that ref; falls back to archived if nothing is pending.
596
- * 3. UUID prefix — matches any PENDING proposal whose id starts with the
597
- * given string; throws if ambiguous.
403
+ * A proposal by exact id; else, for an asset ref, its most recent pending
404
+ * proposal (then most recent archived); else by a unique pending id prefix.
598
405
  */
599
406
  export function resolveProposalId(stashDir, idOrRef, ctx) {
600
- return withProposalsDb(stashDir, ctx, (db) => {
601
- // 1. Exact UUID.
407
+ return withProposalsDb(ctx, (db) => {
602
408
  const exact = getStateProposal(db, idOrRef, stashDir);
603
409
  if (exact)
604
410
  return exact;
605
- // 2. Asset ref — most recent pending, else most recent archived. Qualified
606
- // refs retain bundle identity; short refs match by conceptId in this queue.
607
- const wantRef = idOrRef.includes(":") || idOrRef.includes("/") ? filterRefIdentity(idOrRef) : undefined;
608
- if (wantRef !== undefined) {
609
- const byRecency = (proposals) => proposals.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime())[0];
610
- const forConcept = (status) => listStateProposals(db, { stashDir, ...(status !== undefined ? { status } : {}) }).filter((p) => proposalMatchesRef(p.ref, wantRef));
611
- const pending = byRecency(forConcept("pending"));
612
- if (pending)
613
- return pending;
614
- const archived = byRecency(forConcept());
615
- if (archived)
616
- return archived;
411
+ if (idOrRef.includes(":") || idOrRef.includes("/")) {
412
+ const wantRef = filterRefIdentity(idOrRef);
413
+ // Should-fix 7: by-ref resolution never picks a retire proposal — the
414
+ // newest pending proposal for a ref could be a `consolidate-pair`
415
+ // retirement rather than the reflect/distill edit a person typed the
416
+ // ref to accept, and accepting it archives the asset instead. A retire
417
+ // is reached by its own proposal id, or by the explicit generator
418
+ // `consolidate-pair` (bulk accept/reject).
419
+ const newest = (status) => listStateProposals(db, { stashDir, ...(status !== undefined ? { status } : {}) })
420
+ .filter((p) => proposalMatchesRef(p.ref, wantRef) && !isRetireProposal(p))
421
+ .sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime())[0];
422
+ const found = newest("pending") ?? newest();
423
+ if (found)
424
+ return found;
617
425
  throw new NotFoundError(`No proposal found for ref "${idOrRef}".`, "PROPOSAL_NOT_FOUND");
618
426
  }
619
- // 3. UUID prefix (pending queue only).
620
427
  const prefixMatches = listStateProposalIdsByPrefix(db, stashDir, idOrRef);
621
428
  if (prefixMatches.length === 1)
622
429
  return requireProposal(db, stashDir, prefixMatches[0]);
@@ -627,80 +434,88 @@ export function resolveProposalId(stashDir, idOrRef, ctx) {
627
434
  });
628
435
  }
629
436
  /**
630
- * Archive a proposal: flip its status to `accepted` / `rejected`, bump
631
- * `updatedAt`, and record the review block. Used by both accept and reject
632
- * paths so the live queue only contains pending entries.
437
+ * The ledger outcome of a decision. A procedural refusal (retention expiry, a
438
+ * stale target, a missing asset) judged nothing, so it never carries the
439
+ * rejection window.
633
440
  */
441
+ function ledgerOutcomeForDecision(status, gateDecision) {
442
+ if (status === "accepted")
443
+ return "accepted";
444
+ if (gateDecision?.outcome === "auto-rejected") {
445
+ if (gateDecision.reason === EXPIRED_GATE_REASON)
446
+ return "expired";
447
+ if (gateDecision.reason === STALE_TARGET_GATE_REASON || gateDecision.reason === ASSET_MISSING_GATE_REASON) {
448
+ return "failed";
449
+ }
450
+ }
451
+ return "rejected";
452
+ }
453
+ /** Archive a pending proposal as accepted/rejected, recording the decision in the ledger in the same transaction. */
634
454
  export function archiveProposal(stashDir, id, status, reason, ctx, gateDecision) {
635
- return withProposalsDb(stashDir, ctx, (db) => {
636
- return withImmediateTransaction(db, () => {
637
- const existing = requireProposal(db, stashDir, id);
638
- if (existing.status !== "pending") {
639
- throw new UsageError(`Proposal ${id} is not pending (current status: ${existing.status}). Only pending proposals can be ${status}.`, "INVALID_FLAG_VALUE");
640
- }
641
- const decidedAt = nowIso(ctx);
642
- const updated = {
643
- ...existing,
644
- status,
645
- updatedAt: decidedAt,
646
- review: {
647
- outcome: status,
648
- ...(reason !== undefined ? { reason } : {}),
649
- decidedAt,
650
- },
651
- ...(gateDecision ? { gateDecision: { ...gateDecision, decidedAt: gateDecision.decidedAt ?? decidedAt } } : {}),
652
- };
653
- upsertProposal(db, updated, stashDir);
654
- return updated;
455
+ return withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
456
+ const existing = requireProposal(db, stashDir, id);
457
+ if (existing.status !== "pending") {
458
+ throw new UsageError(`Proposal ${id} is not pending (current status: ${existing.status}). Only pending proposals can be ${status}.`, "INVALID_FLAG_VALUE");
459
+ }
460
+ const decidedAt = nowIso(ctx);
461
+ const updated = {
462
+ ...existing,
463
+ status,
464
+ updatedAt: decidedAt,
465
+ review: { outcome: status, ...(reason !== undefined ? { reason } : {}), decidedAt },
466
+ ...(gateDecision ? { gateDecision: { ...gateDecision, decidedAt: gateDecision.decidedAt ?? decidedAt } } : {}),
467
+ };
468
+ upsertProposal(db, updated, stashDir);
469
+ recordImproveLedgerDecision(db, {
470
+ proposalId: updated.id,
471
+ stashDir,
472
+ ref: updated.ref,
473
+ source: updated.source,
474
+ outcome: ledgerOutcomeForDecision(status, gateDecision),
475
+ at: decidedAt,
476
+ ...(reason !== undefined ? { detail: reason } : {}),
655
477
  });
656
- });
478
+ return updated;
479
+ }));
657
480
  }
658
481
  /**
659
- * Record the drain/triage engine's decision onto a proposal (#577).
660
- * Drain-owned audit machinery — the deterministic drain engine is the writer.
661
- *
662
- * Stamps `gateDecision` (decision / reason / measurement / thresholds) onto the
663
- * row so `akm proposal show` and `list` can explain why a proposal landed where
664
- * it did. The decision is metadata about the adjudication, so this does NOT
665
- * change `status` or bump `updatedAt` — a `deferred` proposal stays `pending`,
666
- * and the accept / reject status flips are owned by {@link promoteProposal} /
667
- * {@link archiveProposal}. `decidedAt` defaults to now when the caller omits it.
668
- *
669
- * Best-effort: a proposal that no longer exists (e.g. concurrently archived) is
670
- * skipped silently rather than throwing, so a gate run never aborts mid-batch.
671
- * Returns the updated proposal, or undefined when no matching row exists.
482
+ * Stamp a gate's decision (#577) on a pending proposal without changing its
483
+ * status: the drain's verdict, or the generating stage's judge pass or review
484
+ * deferral (`deferred` records `review_needed` in the ledger). A proposal no
485
+ * longer pending is skipped (`undefined`), so a batch never aborts.
672
486
  */
673
487
  export function recordGateDecision(stashDir, id, decision, ctx) {
674
- return withProposalsDb(stashDir, ctx, (db) => {
675
- return withImmediateTransaction(db, () => {
676
- const existing = getStateProposal(db, id, stashDir);
677
- if (!existing || existing.status !== "pending")
678
- return undefined;
679
- const updated = {
680
- ...existing,
681
- gateDecision: { ...decision, decidedAt: decision.decidedAt ?? nowIso(ctx) },
682
- };
683
- upsertProposal(db, updated, stashDir);
684
- return updated;
685
- });
686
- });
488
+ return withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
489
+ const existing = getStateProposal(db, id, stashDir);
490
+ if (!existing || existing.status !== "pending")
491
+ return undefined;
492
+ const decidedAt = decision.decidedAt ?? nowIso(ctx);
493
+ const updated = { ...existing, gateDecision: { ...decision, decidedAt } };
494
+ upsertProposal(db, updated, stashDir);
495
+ if (decision.outcome === "deferred") {
496
+ recordImproveLedgerDecision(db, {
497
+ proposalId: updated.id,
498
+ stashDir,
499
+ ref: updated.ref,
500
+ source: updated.source,
501
+ outcome: "review_needed",
502
+ at: decidedAt,
503
+ detail: decision.reason,
504
+ });
505
+ }
506
+ return updated;
507
+ }));
687
508
  }
688
509
  /**
689
- * Scan all pending proposals and reject those whose target asset no longer
690
- * exists on disk across any of `sourceDirs`. Intended to run as a periodic
691
- * maintenance pass (see `runImproveMaintenancePasses`) — it keeps the queue
692
- * from accumulating stale reviewer work after large refactors or deletes.
693
- *
694
- * Scope rule: only `source=reflect` proposals are subject to orphan rejection.
695
- * Lessons, propose, distill, and consolidate proposals legitimately target
696
- * assets that don't exist yet and must never be purged.
510
+ * Reject pending `reflect` proposals whose target no longer exists in any of
511
+ * `sourceDirs` (a maintenance pass). Other sources — lessons above all — may
512
+ * legitimately target assets that do not exist yet.
697
513
  */
698
514
  export function purgeOrphanProposals(stashDir, sourceDirs, ctx) {
699
515
  const t0 = Date.now();
700
516
  const orphans = [];
701
517
  const byType = {};
702
- const pending = listProposals(stashDir, { status: "pending" }, ctx);
703
- const reflectPending = pending.filter((p) => p.source === "reflect");
518
+ const reflectPending = listProposals(stashDir, { status: "pending" }, ctx).filter((p) => p.source === "reflect");
704
519
  for (const p of reflectPending) {
705
520
  let parsed;
706
521
  try {
@@ -709,86 +524,60 @@ export function purgeOrphanProposals(stashDir, sourceDirs, ctx) {
709
524
  catch {
710
525
  continue;
711
526
  }
712
- // Lessons are new-asset proposals by definition — they cannot be orphaned.
713
- if (parsed.type === "lesson")
714
- continue;
715
527
  const spec = stashDirFor(parsed.type);
716
- if (!spec)
528
+ if (parsed.type === "lesson" || !spec)
717
529
  continue;
718
- const exists = sourceDirs.some((root) => {
719
- const typeRoot = path.join(root, spec);
720
- const candidate = assetPathForName(parsed.type, typeRoot, parsed.name);
721
- return fs.existsSync(candidate);
722
- });
723
- if (!exists) {
724
- try {
725
- archiveProposal(stashDir, p.id, "rejected", "Asset no longer exists on disk", ctx);
726
- orphans.push({ id: p.id, ref: p.ref, reason: "asset_missing" });
727
- byType[parsed.type] = (byType[parsed.type] ?? 0) + 1;
728
- }
729
- catch (err) {
730
- // Best-effort — the purge is non-fatal. Log and continue.
731
- warn(`[proposals] purgeOrphanProposals: failed to reject ${p.id}: ${err instanceof Error ? err.message : String(err)}`);
732
- }
530
+ const exists = sourceDirs.some((root) => fs.existsSync(assetPathForName(parsed.type, path.join(root, spec), parsed.name)));
531
+ if (exists)
532
+ continue;
533
+ try {
534
+ archiveProposal(stashDir, p.id, "rejected", "Asset no longer exists on disk", ctx, {
535
+ outcome: "auto-rejected",
536
+ reason: ASSET_MISSING_GATE_REASON,
537
+ gate: "orphan-purge",
538
+ });
539
+ orphans.push({ id: p.id, ref: p.ref, reason: "asset_missing" });
540
+ byType[parsed.type] = (byType[parsed.type] ?? 0) + 1;
541
+ }
542
+ catch (err) {
543
+ warn(`[proposals] purgeOrphanProposals: failed to reject ${p.id}: ${err instanceof Error ? err.message : String(err)}`);
733
544
  }
734
545
  }
735
- return {
736
- checked: reflectPending.length,
737
- rejected: orphans.length,
738
- durationMs: Date.now() - t0,
739
- byType,
740
- orphans,
741
- };
546
+ return { checked: reflectPending.length, rejected: orphans.length, durationMs: Date.now() - t0, byType, orphans };
742
547
  }
743
548
  /**
744
- * Archive pending proposals older than `config.archiveRetentionDays` (Advantage
745
- * D6b / Phase 6B).
746
- *
747
- * Reviewer fatigue and queue rot are the dominant failure modes of any
748
- * human-in-the-loop pipeline (Settles 2009 active-learning survey). Pending
749
- * proposals that have aged past the retention window are very rarely accepted
750
- * — the reviewer either intentionally declined to act on them, or the asset
751
- * they target has drifted enough that the proposal is no longer relevant.
752
- * Auto-expiring them keeps the live queue focused on actionable work; the
753
- * archive preserves the full audit trail.
754
- *
755
- * Each expired proposal is archived with status `rejected` and reason
756
- * `"expired: no action within retention window"`. A `proposal_expired` event
757
- * is appended for each expired proposal so downstream observability (events
758
- * dashboards, source-acceptance-rate aggregations) can see expiry separately
759
- * from explicit rejections.
760
- *
761
- * Idempotent: a second call within the same retention window finds nothing
762
- * to expire (the archived entries are no longer in the pending queue).
549
+ * Archive pending proposals older than `archiveRetentionDays` (default 90;
550
+ * 0 disables) as rejected with an `expired` gate decision and a
551
+ * `proposal_expired` event. The ledger records `expired` — a short grace, not
552
+ * the rejection window, since nobody judged the content.
763
553
  */
764
554
  export function expireStaleProposals(stashDir, config, ctx) {
765
555
  const t0 = Date.now();
766
556
  const retentionDays = config.archiveRetentionDays ?? 90;
767
557
  const expiredProposals = [];
768
- // retentionDays === 0 disables TTL cleanup globally (mirrors how
769
- // consolidate.ts interprets the same config value).
770
- if (retentionDays <= 0) {
771
- return {
772
- checked: 0,
773
- expired: 0,
774
- durationMs: Date.now() - t0,
775
- retentionDays,
776
- expiredProposals,
777
- };
778
- }
779
- const retentionMs = retentionDays * MS_PER_DAY;
558
+ if (retentionDays <= 0)
559
+ return { checked: 0, expired: 0, durationMs: Date.now() - t0, retentionDays, expiredProposals };
780
560
  const nowMs = (ctx?.now ?? Date.now)();
781
561
  const pending = listProposals(stashDir, { status: "pending" }, ctx);
782
562
  for (const p of pending) {
783
- const createdMs = new Date(p.createdAt).getTime();
784
- if (!Number.isFinite(createdMs))
563
+ // Should-fix 8: a retire proposal never expires by age. B2's accept-time
564
+ // hash check already refuses it once it goes stale, and the
565
+ // one-pending-retire-per-asset rule (pair-pass.ts's pendingRetireRefs)
566
+ // bounds how many can queue up — retention expiry would instead
567
+ // permanently drop a still-fresh pair nobody has reviewed yet, with no
568
+ // way back short of the pair pass finding it again from scratch.
569
+ if (isRetireProposal(p))
785
570
  continue;
786
- const ageMs = nowMs - createdMs;
787
- if (ageMs < retentionMs)
571
+ const createdMs = new Date(p.createdAt).getTime();
572
+ if (!Number.isFinite(createdMs) || nowMs - createdMs < retentionDays * MS_PER_DAY)
788
573
  continue;
789
574
  try {
790
- archiveProposal(stashDir, p.id, "rejected", "expired: no action within retention window", ctx);
791
- const ageDays = Math.floor(ageMs / MS_PER_DAY);
575
+ archiveProposal(stashDir, p.id, "rejected", "expired: no action within retention window", ctx, {
576
+ outcome: "auto-rejected",
577
+ reason: EXPIRED_GATE_REASON,
578
+ gate: "retention",
579
+ });
580
+ const ageDays = Math.floor((nowMs - createdMs) / MS_PER_DAY);
792
581
  expiredProposals.push({ id: p.id, ref: p.ref, ageDays });
793
582
  appendEvent({
794
583
  eventType: "proposal_expired",
@@ -803,19 +592,9 @@ export function expireStaleProposals(stashDir, config, ctx) {
803
592
  });
804
593
  }
805
594
  catch (err) {
806
- // Best-effort — a single failure must not block the pass.
807
595
  warn(`[proposals] expireStaleProposals: failed to expire ${p.id}: ${err instanceof Error ? err.message : String(err)}`);
808
596
  }
809
597
  }
810
- // Prune fingerprint rows past the same retention window (best-effort):
811
- // ISO created_at strings compare lexicographically.
812
- try {
813
- const cutoffIso = new Date(nowMs - retentionMs).toISOString();
814
- withProposalsDb(stashDir, ctx, (db) => db.prepare("DELETE FROM proposal_fingerprints WHERE stash_dir = ? AND created_at < ?").run(stashDir, cutoffIso));
815
- }
816
- catch (err) {
817
- warn(`[proposals] expireStaleProposals: fingerprint prune failed: ${err instanceof Error ? err.message : String(err)}`);
818
- }
819
598
  return {
820
599
  checked: pending.length,
821
600
  expired: expiredProposals.length,
@@ -824,515 +603,155 @@ export function expireStaleProposals(stashDir, config, ctx) {
824
603
  expiredProposals,
825
604
  };
826
605
  }
827
- const PROPOSAL_TXN_KIND = "proposal";
828
- const PROPOSAL_TXN_PHASES = [
829
- "prepared",
830
- "asset-published",
831
- "proposal-persisted",
832
- "index-finalized",
833
- "event-finalized",
834
- "committed",
835
- ];
836
- /** TEST-ONLY crash-window hook used by subprocess recovery tests. */
837
- export function _setProposalMutationHookForTests(hook) {
838
- _setTxnMutationHookForTests(hook);
839
- }
840
- function proposalHash(content) {
841
- return createHash("sha256").update(content).digest("hex");
842
- }
843
- function proposalFileHash(filePath) {
844
- return proposalHash(fs.readFileSync(filePath));
606
+ /** The hash a `staged` gate decision records, so a reader can tell the judged bytes from an edit. */
607
+ export function proposalContentHash(proposal) {
608
+ return contentHash(proposalContent(proposal));
845
609
  }
846
- function sameProposalFile(left, right) {
847
- try {
848
- const leftStat = fs.statSync(left);
849
- const rightStat = fs.statSync(right);
850
- return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino;
851
- }
852
- catch {
853
- return false;
854
- }
855
- }
856
- function cleanupProposalPublication(p) {
857
- for (const filePath of [p.publishPath, p.displacedPath]) {
858
- try {
859
- fs.rmSync(filePath, { force: true });
860
- }
861
- catch (error) {
862
- warn(`[proposals] transaction publication cleanup failed at ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
863
- }
864
- }
865
- fsyncTxnDir(path.dirname(p.assetPath));
866
- }
867
- function rollbackPreparedProposalTransaction(txn) {
868
- const p = txn.journal.payload;
869
- const currentHash = fs.existsSync(p.assetPath) ? proposalFileHash(p.assetPath) : null;
870
- if (!fs.existsSync(p.displacedPath)) {
871
- if (p.originalHash === null) {
872
- if (currentHash === p.publishedHash && sameProposalFile(p.assetPath, p.publishPath)) {
873
- fs.unlinkSync(p.assetPath);
874
- // #652: un-publishing is a mutation of this run's own write — journal
875
- // it so the sync stages the FINAL state of a written-then-reverted path.
876
- recordWrittenPath(p.assetPath);
877
- }
878
- else if (currentHash !== null) {
879
- throw new Error(`Cannot roll back proposal transaction: target was created externally.`);
880
- }
881
- }
882
- else if (currentHash !== p.originalHash) {
883
- throw new Error(`Cannot roll back proposal transaction: ${p.assetPath} diverged.`);
884
- }
885
- cleanupProposalPublication(p);
886
- return;
887
- }
888
- if (currentHash === p.publishedHash) {
889
- fs.unlinkSync(p.assetPath);
890
- recordWrittenPath(p.assetPath);
891
- }
892
- else if (currentHash !== null && currentHash !== p.originalHash) {
893
- throw new Error(`Cannot roll back proposal transaction: ${p.assetPath} diverged.`);
894
- }
895
- if (fs.existsSync(p.displacedPath)) {
896
- if (fs.existsSync(p.assetPath)) {
897
- throw new Error(`Cannot restore proposal backup: ${p.assetPath} is occupied.`);
898
- }
899
- fs.linkSync(p.displacedPath, p.assetPath);
900
- // #652: restoring the displaced original still leaves the path in a state
901
- // this run produced; journal it so the final on-disk bytes are staged.
902
- recordWrittenPath(p.assetPath);
903
- }
904
- cleanupProposalPublication(p);
905
- }
906
- function validatePublishedProposal(p) {
907
- if (!fs.existsSync(p.assetPath) || proposalFileHash(p.assetPath) !== p.publishedHash) {
908
- throw new Error(`Cannot recover proposal ${p.proposalId}: published asset diverged.`);
909
- }
910
- }
911
- function persistProposalTransactionState(txn, proposal, ctx) {
912
- const p = txn.journal.payload;
913
- const decidedAt = txn.journal.decidedAt;
914
- const backupContent = p.backupPath ? fs.readFileSync(p.backupPath, "utf8") : undefined;
915
- const publishedContent = fs.readFileSync(p.contentPath, "utf8");
916
- return withProposalsDb(p.stashDir, ctx, (db) => withImmediateTransaction(db, () => {
917
- const current = requireProposal(db, p.stashDir, p.proposalId);
918
- if (p.operation === "accept") {
610
+ /**
611
+ * Record an accept or revert — the row, its ledger decision and its event in
612
+ * one transaction — after the asset file is on disk. A proposal already in the
613
+ * requested state is returned unchanged.
614
+ */
615
+ function persistProposalDecision(stashDir, proposal, decision, ctx) {
616
+ return withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
617
+ const current = requireProposal(db, stashDir, proposal.id);
618
+ let next;
619
+ if (decision.operation === "accept") {
620
+ const publishedHash = contentHash(decision.content);
919
621
  if (current.status === "accepted") {
920
- if (current.acceptedTarget?.contentHash !== p.publishedHash) {
921
- throw new Error(`Accepted proposal ${p.proposalId} does not match its recovery journal.`);
622
+ if (current.acceptedTarget?.contentHash !== publishedHash) {
623
+ throw new Error(`Accepted proposal ${proposal.id} does not match the published content.`);
922
624
  }
923
625
  return current;
924
626
  }
925
627
  if (current.status !== "pending") {
926
- throw new Error(`Proposal ${p.proposalId} changed status during acceptance (${current.status}).`);
628
+ throw new Error(`Proposal ${proposal.id} changed status during acceptance (${current.status}).`);
927
629
  }
928
- const persistedProposal = proposal.changes.length > 0 && proposal.changes.every((change) => change.path.length > 0)
929
- ? withProposalContent(proposal, publishedContent)
630
+ const root = decision.target.source.path;
631
+ const persisted = proposal.changes.length > 0 && proposal.changes.every((change) => change.path.length > 0)
632
+ ? withProposalContent(proposal, decision.content)
930
633
  : {
931
634
  ...proposal,
932
- payload: { ...proposal.payload, content: publishedContent },
635
+ payload: { ...proposal.payload, content: decision.content },
933
636
  changes: [
934
637
  {
935
- path: path.relative(txn.journal.root, p.assetPath),
936
- op: p.originalHash === null ? "create" : "update",
937
- after: publishedContent,
638
+ path: path.relative(root, decision.assetPath),
639
+ op: decision.existed ? "update" : "create",
640
+ after: decision.content,
938
641
  },
939
642
  ],
940
- proposedTarget: { source: p.targetSource, root: txn.journal.root },
643
+ proposedTarget: { source: decision.target.source.name, root },
941
644
  };
942
- const accepted = {
943
- ...persistedProposal,
645
+ next = {
646
+ ...persisted,
944
647
  status: "accepted",
945
- updatedAt: decidedAt,
946
- review: { outcome: "accepted", decidedAt },
648
+ updatedAt: decision.decidedAt,
649
+ review: { outcome: "accepted", decidedAt: decision.decidedAt },
947
650
  acceptedTarget: {
948
- source: p.targetSource,
949
- root: txn.journal.root,
950
- path: p.assetPath,
951
- contentHash: p.publishedHash,
651
+ source: decision.target.source.name,
652
+ root,
653
+ path: decision.assetPath,
654
+ contentHash: publishedHash,
952
655
  },
953
- ...(p.gateDecision
954
- ? { gateDecision: { ...p.gateDecision, decidedAt: p.gateDecision.decidedAt ?? decidedAt } }
656
+ ...(decision.gateDecision
657
+ ? {
658
+ gateDecision: {
659
+ ...decision.gateDecision,
660
+ decidedAt: decision.gateDecision.decidedAt ?? decision.decidedAt,
661
+ },
662
+ }
955
663
  : {}),
956
- ...(backupContent !== undefined ? { backupContent } : {}),
664
+ ...(decision.backupContent !== undefined ? { backupContent: decision.backupContent } : {}),
957
665
  };
958
- upsertProposal(db, accepted, p.stashDir);
959
- return accepted;
960
666
  }
961
- if (current.status === "reverted")
962
- return current;
963
- if (current.status !== "accepted") {
964
- throw new Error(`Proposal ${p.proposalId} changed status during reversion (${current.status}).`);
667
+ else {
668
+ if (current.status === "reverted")
669
+ return current;
670
+ if (current.status !== "accepted") {
671
+ throw new Error(`Proposal ${proposal.id} changed status during reversion (${current.status}).`);
672
+ }
673
+ next = {
674
+ ...current,
675
+ status: "reverted",
676
+ updatedAt: decision.decidedAt,
677
+ review: {
678
+ outcome: "rejected",
679
+ reason: "reverted: prior content restored from backup",
680
+ decidedAt: decision.decidedAt,
681
+ },
682
+ };
965
683
  }
966
- const reverted = {
967
- ...current,
968
- status: "reverted",
969
- updatedAt: decidedAt,
970
- review: {
971
- outcome: "rejected",
972
- reason: "reverted: prior content restored from backup",
973
- decidedAt,
974
- },
975
- };
976
- upsertProposal(db, reverted, p.stashDir);
977
- return reverted;
978
- }));
979
- }
980
- function persistProposalEvent(txn, proposal, ctx) {
981
- const p = txn.journal.payload;
982
- withProposalsDb(p.stashDir, ctx, (db) => withImmediateTransaction(db, () => {
983
- const metadata = {
984
- proposalId: proposal.id,
985
- source: proposal.source,
986
- ...(proposal.sourceRun !== undefined ? { sourceRun: proposal.sourceRun } : {}),
987
- assetPath: p.assetPath,
988
- ...(proposal.eligibilitySource !== undefined ? { eligibilitySource: proposal.eligibilitySource } : {}),
989
- ...(p.eventMetadata ?? {}),
990
- proposalTransactionId: txn.journal.transactionId,
991
- };
684
+ const accept = decision.operation === "accept";
685
+ upsertProposal(db, next, stashDir);
686
+ recordImproveLedgerDecision(db, {
687
+ proposalId: next.id,
688
+ stashDir,
689
+ ref: next.ref,
690
+ source: next.source,
691
+ outcome: ledgerOutcomeForDecision(accept ? "accepted" : "reverted"),
692
+ at: decision.decidedAt,
693
+ ...(accept ? {} : { detail: "reverted" }),
694
+ });
992
695
  insertEventOnce(db, {
993
- eventType: p.operation === "accept" ? "promoted" : "proposal_reverted",
994
- ts: txn.journal.decidedAt,
995
- ref: p.ref,
996
- metadata,
997
- idempotencyKey: txn.journal.transactionId,
696
+ eventType: accept ? "promoted" : "proposal_reverted",
697
+ ts: decision.decidedAt,
698
+ ref: next.ref,
699
+ metadata: {
700
+ proposalId: next.id,
701
+ source: next.source,
702
+ ...(next.sourceRun !== undefined ? { sourceRun: next.sourceRun } : {}),
703
+ assetPath: decision.assetPath,
704
+ ...(next.eligibilitySource !== undefined ? { eligibilitySource: next.eligibilitySource } : {}),
705
+ ...(decision.operation === "accept" && decision.eventMetadata ? decision.eventMetadata : {}),
706
+ },
707
+ idempotencyKey: `${next.id}:${accept ? "promoted" : "reverted"}`,
998
708
  });
709
+ return next;
999
710
  }));
1000
711
  }
1001
- async function finalizeProposalTransaction(txn, target, proposal, ctx) {
1002
- const p = txn.journal.payload;
1003
- validatePublishedProposal(p);
1004
- // #652: finalizing an `asset-published` transaction that a CRASHED earlier
1005
- // run left behind is this run adopting that write — journal the asset so the
1006
- // adopting run's auto-sync commits it instead of leaving it stranded.
1007
- recordWrittenPath(p.assetPath);
1008
- cleanupProposalPublication(p);
1009
- if (txn.journal.phase === "asset-published") {
1010
- const commitRoot = target.source.repoPath ?? target.source.path;
1011
- const commitPath = path.relative(commitRoot, p.assetPath).replaceAll(path.sep, "/");
1012
- publishWriteTargetTransaction(target, p.gitPublication, {
1013
- transactionId: txn.journal.transactionId,
1014
- message: `${p.operation === "accept" ? "Update" : "Revert"} ${p.ref}`,
1015
- paths: [commitPath],
1016
- snapshots: p.gitSnapshots ?? {},
1017
- onCommitRecorded: (commit) => {
1018
- const publication = p.gitPublication;
1019
- if (publication.commit !== commit) {
1020
- publication.commit = commit;
1021
- advanceTxn(txn, "asset-published");
1022
- }
712
+ export function rejectProposalDurably(stashDir, proposalId, reason, ctx, gateDecision) {
713
+ const decidedAt = nowIso(ctx);
714
+ const rejected = archiveProposal(stashDir, proposalId, "rejected", reason, { ...ctx, now: () => Date.parse(decidedAt) }, gateDecision);
715
+ withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
716
+ insertEventOnce(db, {
717
+ eventType: "rejected",
718
+ ts: decidedAt,
719
+ ref: rejected.ref,
720
+ metadata: {
721
+ proposalId: rejected.id,
722
+ source: rejected.source,
723
+ ...(rejected.sourceRun !== undefined ? { sourceRun: rejected.sourceRun } : {}),
724
+ ...(reason !== undefined ? { reason } : {}),
1023
725
  },
726
+ idempotencyKey: `${rejected.id}:rejected`,
1024
727
  });
1025
- persistProposalTransactionState(txn, proposal, ctx);
1026
- advanceTxn(txn, "proposal-persisted");
1027
- }
1028
- let accepted = getProposal(p.stashDir, p.proposalId, ctx);
1029
- if (txn.journal.phase === "proposal-persisted") {
1030
- if (!(await indexWrittenAssets(txn.journal.root, [p.assetPath], { bundleId: target.source.name }))) {
1031
- throw new Error(`Proposal ${p.proposalId} index finalization failed.`);
1032
- }
1033
- advanceTxn(txn, "index-finalized");
1034
- }
1035
- if (txn.journal.phase === "index-finalized") {
1036
- accepted = getProposal(p.stashDir, p.proposalId, ctx);
1037
- persistProposalEvent(txn, accepted, ctx);
1038
- txnMutationHook("event-persisted");
1039
- advanceTxn(txn, "event-finalized");
1040
- }
1041
- if (txn.journal.phase === "event-finalized")
1042
- advanceTxn(txn, "committed");
1043
- return accepted;
1044
- }
1045
- /**
1046
- * Kind-level safety fence for a `proposal` journal, run before any recovery
1047
- * action. The engine fences root binding and the uniform changes[] separately.
1048
- */
1049
- function fenceProposalTxnJournal(journal, txnDir, root) {
1050
- const p = journal.payload;
1051
- const refIdentity = proposalRefIdentity(p.ref);
1052
- if (!["accept", "revert"].includes(p.operation) ||
1053
- !p.targetSource ||
1054
- !p.targetKind ||
1055
- refIdentity?.bundle === undefined ||
1056
- !isWithin(p.assetPath, root) ||
1057
- ![p.contentPath, p.backupPath]
1058
- .filter((candidate) => candidate !== null)
1059
- .every((candidate) => isWithin(candidate, txnDir)) ||
1060
- ![p.publishPath, p.displacedPath].every((candidate) => isWithin(candidate, root) && path.dirname(candidate) === path.dirname(p.assetPath))) {
1061
- throw new Error(`Refusing unsafe proposal transaction journal at ${path.join(txnDir, "journal.json")}.`);
1062
- }
1063
- }
1064
- function resolveProposalRecoveryTarget(config, journal) {
1065
- let target;
1066
- try {
1067
- target = resolveBundleWriteTarget(config, journal.payload.targetSource);
1068
- }
1069
- catch {
1070
- throw new UsageError(`Proposal transaction ${journal.transactionId} target is no longer configured.`, "INVALID_FLAG_VALUE");
1071
- }
1072
- const bundleId = canonicalBundleIdForTarget(config, target);
1073
- return { ...target, source: { ...target.source, name: bundleId } };
1074
- }
1075
- async function recoverProposalTransactions(target, stashDir, ctx) {
1076
- const completed = new Map();
1077
- const nsDir = txnNamespaceDir(target.source.path);
1078
- if (!fs.existsSync(nsDir))
1079
- return completed;
1080
- for (const entry of fs.readdirSync(nsDir, { withFileTypes: true })) {
1081
- if (!entry.isDirectory())
1082
- continue;
1083
- const transactionDir = path.join(nsDir, entry.name);
1084
- const journalPath = path.join(transactionDir, "journal.json");
1085
- if (!fs.existsSync(journalPath)) {
1086
- // Journal-less dirs may be a SIBLING kind's beginTxn window (shared
1087
- // per-root namespace) — sweep only when demonstrably stale.
1088
- sweepJournallessTxnDir(transactionDir);
1089
- continue;
1090
- }
1091
- const journal = JSON.parse(fs.readFileSync(journalPath, "utf8"));
1092
- if (journal.kind !== PROPOSAL_TXN_KIND)
1093
- continue;
1094
- if (path.resolve(journal.payload.stashDir) !== path.resolve(stashDir))
1095
- continue;
1096
- if (journal.version !== 1 ||
1097
- canonicalTxnRoot(journal.root) !== canonicalTxnRoot(target.source.path) ||
1098
- journal.payload.targetSource !== target.source.name ||
1099
- journal.payload.targetKind !== target.source.kind) {
1100
- throw new Error(`Refusing unsafe proposal transaction journal at ${journalPath}.`);
1101
- }
1102
- fenceProposalTxnJournal(journal, transactionDir, target.source.path);
1103
- const txn = { journal, journalPath, dir: transactionDir };
1104
- if (journal.phase === "prepared") {
1105
- rollbackPreparedProposalTransaction(txn);
1106
- }
1107
- else if (journal.phase !== "committed") {
1108
- const proposal = getProposal(stashDir, journal.payload.proposalId, ctx);
1109
- completed.set(journal.payload.proposalId, await finalizeProposalTransaction(txn, target, proposal, ctx));
1110
- }
1111
- else {
1112
- completed.set(journal.payload.proposalId, getProposal(stashDir, journal.payload.proposalId, ctx));
1113
- }
1114
- cleanupProposalPublication(journal.payload);
1115
- cleanupTxn(transactionDir);
1116
- }
1117
- return completed;
1118
- }
1119
- export async function recoverProposalTransactionsForStash(stashDir, config, ctx, proposalId) {
1120
- const completed = new Map();
1121
- const matches = listTxnJournals((j) => j.kind === PROPOSAL_TXN_KIND &&
1122
- path.resolve(j.payload.stashDir) === path.resolve(stashDir) &&
1123
- (proposalId === undefined || j.payload.proposalId === proposalId));
1124
- const irreversible = matches.filter((journal) => journal.phase !== "prepared" && journal.phase !== "committed");
1125
- if (proposalId !== undefined && irreversible.length > 1) {
1126
- throw new Error(`Conflicting durable proposal transactions exist for ${proposalId}; refusing recovery.`);
1127
- }
1128
- const recoveredRoots = new Set();
1129
- for (const journal of matches) {
1130
- let target = resolveProposalRecoveryTarget(config, journal);
1131
- const requiresGitPublication = matches.some((candidate) => candidate.phase === "asset-published" && canonicalTxnRoot(candidate.root) === canonicalTxnRoot(journal.root));
1132
- if (requiresGitPublication)
1133
- target = prepareWriteTargetForMutation(target, { allowAhead: true });
1134
- if (canonicalTxnRoot(target.source.path) !== canonicalTxnRoot(journal.root) ||
1135
- journal.payload.targetKind !== target.source.kind) {
1136
- throw new Error(`Proposal transaction ${journal.transactionId} is bound to a different target root.`);
1137
- }
1138
- const key = path.resolve(target.source.path);
1139
- if (recoveredRoots.has(key))
1140
- continue;
1141
- const recovered = await recoverProposalTransactions(target, stashDir, ctx);
1142
- for (const [id, proposal] of recovered)
1143
- completed.set(id, proposal);
1144
- recoveredRoots.add(key);
1145
- }
1146
- return completed;
1147
- }
1148
- const REJECT_TXN_KIND = "proposal-reject";
1149
- const REJECT_TXN_PHASES = ["prepared", "state-persisted", "event-finalized", "committed"];
1150
- function finalizeRejectTransaction(txn, ctx) {
1151
- const p = txn.journal.payload;
1152
- const decidedAt = txn.journal.decidedAt;
1153
- let proposal = getProposal(p.stashDir, p.proposalId, ctx);
1154
- if (txn.journal.phase === "prepared") {
1155
- if (proposal.status === "pending") {
1156
- proposal = archiveProposal(p.stashDir, p.proposalId, "rejected", p.reason, { ...ctx, now: () => Date.parse(decidedAt) }, p.gateDecision);
1157
- }
1158
- else if (proposal.status !== "rejected") {
1159
- throw new Error(`Proposal ${p.proposalId} changed status during rejection (${proposal.status}).`);
1160
- }
1161
- advanceTxn(txn, "state-persisted");
1162
- txnMutationHook("reject-state-persisted");
1163
- }
1164
- if (txn.journal.phase === "state-persisted") {
1165
- proposal = getProposal(p.stashDir, p.proposalId, ctx);
1166
- const eventRef = proposal.ref;
1167
- const eventMeta = {
1168
- proposalId: proposal.id,
1169
- source: proposal.source,
1170
- ...(proposal.sourceRun !== undefined ? { sourceRun: proposal.sourceRun } : {}),
1171
- ...(p.reason !== undefined ? { reason: p.reason } : {}),
1172
- proposalTransactionId: txn.journal.transactionId,
1173
- };
1174
- withProposalsDb(p.stashDir, ctx, (db) => withImmediateTransaction(db, () => {
1175
- insertEventOnce(db, {
1176
- eventType: "rejected",
1177
- ts: decidedAt,
1178
- ref: eventRef,
1179
- metadata: eventMeta,
1180
- idempotencyKey: txn.journal.transactionId,
1181
- });
1182
- }));
1183
- txnMutationHook("reject-event-persisted");
1184
- advanceTxn(txn, "event-finalized");
1185
- }
1186
- if (txn.journal.phase === "event-finalized")
1187
- advanceTxn(txn, "committed");
1188
- return proposal;
1189
- }
1190
- function recoverRejectTransaction(stashDir, proposalId, ctx) {
1191
- const nsDir = txnNamespaceDir(stashDir);
1192
- if (!fs.existsSync(nsDir))
1193
- return undefined;
1194
- for (const entry of fs.readdirSync(nsDir, { withFileTypes: true })) {
1195
- if (!entry.isDirectory())
1196
- continue;
1197
- const transactionDir = path.join(nsDir, entry.name);
1198
- const journalPath = path.join(transactionDir, "journal.json");
1199
- if (!fs.existsSync(journalPath))
1200
- continue;
1201
- const journal = JSON.parse(fs.readFileSync(journalPath, "utf8"));
1202
- if (journal.kind !== REJECT_TXN_KIND)
1203
- continue;
1204
- if (journal.payload.proposalId !== proposalId)
1205
- continue;
1206
- if (journal.version !== 1 || path.resolve(journal.payload.stashDir) !== path.resolve(stashDir)) {
1207
- throw new Error(`Refusing unsafe proposal rejection journal at ${journalPath}.`);
1208
- }
1209
- const proposal = finalizeRejectTransaction({ journal, journalPath, dir: transactionDir }, ctx);
1210
- cleanupTxn(transactionDir);
1211
- return proposal;
1212
- }
1213
- return undefined;
1214
- }
1215
- export function rejectProposalDurably(stashDir, proposalId, reason, ctx, gateDecision) {
1216
- const recovered = recoverRejectTransaction(stashDir, proposalId, ctx);
1217
- if (recovered)
1218
- return recovered;
1219
- const proposal = getProposal(stashDir, proposalId, ctx);
1220
- if (proposal.status !== "pending") {
1221
- throw new UsageError(`Proposal ${proposalId} is not pending (current status: ${proposal.status}). Only pending proposals can be rejected.`, "INVALID_FLAG_VALUE");
1222
- }
1223
- const txn = beginTxn({
1224
- kind: REJECT_TXN_KIND,
1225
- root: stashDir,
1226
- changes: [],
1227
- payload: {
1228
- proposalId,
1229
- stashDir,
1230
- ...(reason !== undefined ? { reason } : {}),
1231
- ...(gateDecision ? { gateDecision } : {}),
1232
- },
1233
- decidedAt: nowIso(ctx),
1234
- });
1235
- const rejected = finalizeRejectTransaction(txn, ctx);
1236
- cleanupTxn(txn.dir);
728
+ }));
1237
729
  return rejected;
1238
730
  }
1239
- function prepareProposalTransaction(stashDir, target, proposal, ref, content, options, ctx) {
1240
- if (options.operation === "accept")
1241
- assertAkmAssetWrite(target.source);
1242
- const assetPath = resolveAssetFilePathSafe(target.source, ref);
1243
- if (!assetPath)
1244
- throw new Error(`Cannot resolve proposal target ${proposal.ref}.`);
731
+ /** Write `content` atomically (temp file + rename), keeping an existing file's mode. */
732
+ function writeProposalAssetFile(assetPath, content) {
1245
733
  fs.mkdirSync(path.dirname(assetPath), { recursive: true });
1246
- const normalized = content.endsWith("\n") ? content : `${content}\n`;
1247
- const publishedHash = proposalHash(normalized);
1248
- // Mint the id first: the payload embeds paths under the transaction dir,
1249
- // and the initial `prepared` journal must be written exactly ONCE with its
1250
- // final contents (crash runners intercept the first rename per phase).
1251
- const transactionId = mintTxnId();
1252
- const gitPublication = captureGitPublication(target);
1253
- const transactionDir = txnDirFor(target.source.path, transactionId);
1254
- fs.mkdirSync(transactionDir, { recursive: true, mode: 0o700 });
1255
- const contentPath = path.join(transactionDir, "published-content");
1256
- const publishPath = path.join(path.dirname(assetPath), `.akm-proposal-${transactionId}.publish`);
1257
- const displacedPath = path.join(path.dirname(assetPath), `.akm-proposal-${transactionId}.displaced`);
1258
- fs.writeFileSync(contentPath, normalized, { encoding: "utf8", mode: 0o600 });
1259
- fsyncTxnFile(contentPath);
1260
- let persistedBackupPath = null;
1261
- if (options.backup) {
1262
- const backupPath = path.join(transactionDir, "backup-content");
1263
- fs.writeFileSync(backupPath, options.backup, { mode: 0o600 });
1264
- fsyncTxnFile(backupPath);
1265
- persistedBackupPath = backupPath;
1266
- }
1267
- const txn = beginTxn({
1268
- kind: PROPOSAL_TXN_KIND,
1269
- root: target.source.path,
1270
- transactionId,
1271
- changes: [
1272
- {
1273
- path: assetPath,
1274
- op: options.originalHash === null ? "create" : "update",
1275
- beforeHash: options.originalHash,
1276
- afterHash: publishedHash,
1277
- },
1278
- ],
1279
- payload: {
1280
- operation: options.operation,
1281
- proposalId: proposal.id,
1282
- stashDir,
1283
- targetSource: target.source.name,
1284
- targetKind: target.source.kind,
1285
- assetPath,
1286
- ref: proposal.ref,
1287
- contentPath,
1288
- publishPath,
1289
- displacedPath,
1290
- backupPath: persistedBackupPath,
1291
- originalHash: options.originalHash,
1292
- publishedHash,
1293
- ...(gitPublication ? { gitPublication } : {}),
1294
- ...(options.eventMetadata ? { eventMetadata: options.eventMetadata } : {}),
1295
- ...(options.gateDecision ? { gateDecision: options.gateDecision } : {}),
1296
- },
1297
- decidedAt: nowIso(ctx),
1298
- });
734
+ const mode = fs.existsSync(assetPath) ? fs.statSync(assetPath).mode & 0o777 : 0o644;
735
+ const tempPath = path.join(path.dirname(assetPath), `.akm-proposal-${process.pid}-${randomUUID()}.tmp`);
736
+ fs.writeFileSync(tempPath, content, { encoding: "utf8", mode });
1299
737
  try {
1300
- const mode = fs.existsSync(assetPath) ? fs.statSync(assetPath).mode & 0o777 : 0o644;
1301
- fs.writeFileSync(publishPath, normalized, { encoding: "utf8", flag: "wx", mode });
1302
- fsyncTxnFile(publishPath);
1303
- fsyncTxnDir(path.dirname(assetPath));
738
+ fs.renameSync(tempPath, assetPath);
1304
739
  }
1305
740
  catch (error) {
1306
- rollbackPreparedProposalTransaction(txn);
1307
- cleanupTxn(txn.dir);
741
+ fs.rmSync(tempPath, { force: true });
1308
742
  throw error;
1309
743
  }
1310
- return txn;
744
+ recordWrittenPath(assetPath);
1311
745
  }
1312
- function publishProposalAsset(txn, target) {
1313
- const p = txn.journal.payload;
746
+ /** Index the file just written; the next `akm index` catches up when this cannot. */
747
+ async function indexWrittenProposalAsset(target, assetPath) {
1314
748
  try {
1315
- if (p.originalHash !== null) {
1316
- fs.renameSync(p.assetPath, p.displacedPath);
1317
- if (proposalFileHash(p.displacedPath) !== p.originalHash) {
1318
- fs.renameSync(p.displacedPath, p.assetPath);
1319
- throw new Error(`Proposal target changed while its backup was being acquired.`);
1320
- }
749
+ if (!(await indexWrittenAssets(target.source.path, [assetPath], { bundleId: target.source.name }))) {
750
+ warn(`[proposals] ${assetPath} was written but not indexed; run \`akm index\`.`);
1321
751
  }
1322
- fs.linkSync(p.publishPath, p.assetPath);
1323
- // #652: the accepted-proposal (and revert) target is the run's headline
1324
- // write — journal it the instant the asset lands, before the txn advances.
1325
- recordWrittenPath(p.assetPath);
1326
- fsyncTxnDir(path.dirname(p.assetPath));
1327
- const snapshot = captureWriteTargetPathSnapshot(target, p.assetPath);
1328
- if (snapshot)
1329
- p.gitSnapshots = { [snapshot.path]: snapshot.state };
1330
- advanceTxn(txn, "asset-published");
1331
752
  }
1332
753
  catch (error) {
1333
- rollbackPreparedProposalTransaction(txn);
1334
- cleanupTxn(txn.dir);
1335
- throw error;
754
+ warn(`[proposals] ${assetPath} was written but not indexed (${error instanceof Error ? error.message : String(error)}); run \`akm index\`.`);
1336
755
  }
1337
756
  }
1338
757
  function resolveRecordedProposalTarget(config, proposalId, binding, explicitTarget) {
@@ -1353,125 +772,71 @@ function resolveRecordedProposalTarget(config, proposalId, binding, explicitTarg
1353
772
  return { ...target, source: { ...target.source, name: targetBundleId } };
1354
773
  }
1355
774
  function resolveProposalWriteTarget(config, proposal, explicitTarget, queueTarget) {
775
+ const named = (target) => ({
776
+ ...target,
777
+ source: { ...target.source, name: canonicalBundleIdForTarget(config, target) },
778
+ });
1356
779
  if (!proposal.proposedTarget) {
1357
780
  const identity = proposalRefIdentity(proposal.ref);
1358
781
  if (!identity)
1359
782
  throw new UsageError(`Proposal ${proposal.id} has an invalid ref.`, "INVALID_PROPOSAL");
1360
783
  if (identity.bundle !== undefined) {
1361
- const target = resolveBundleWriteTarget(config, identity.bundle);
1362
- const targetBundleId = canonicalBundleIdForTarget(config, target);
1363
- if (explicitTarget !== undefined) {
1364
- const explicit = resolveWriteTarget(config, explicitTarget);
1365
- if (canonicalBundleIdForTarget(config, explicit) !== identity.bundle) {
1366
- throw new UsageError(`Proposal ${proposal.id} ref is bound to bundle "${identity.bundle}", which conflicts with --target "${explicitTarget}".`, "INVALID_FLAG_VALUE");
1367
- }
784
+ const target = named(resolveBundleWriteTarget(config, identity.bundle));
785
+ if (explicitTarget !== undefined &&
786
+ canonicalBundleIdForTarget(config, resolveWriteTarget(config, explicitTarget)) !== identity.bundle) {
787
+ throw new UsageError(`Proposal ${proposal.id} ref is bound to bundle "${identity.bundle}", which conflicts with --target "${explicitTarget}".`, "INVALID_FLAG_VALUE");
1368
788
  }
1369
789
  if (queueTarget && canonicalBundleIdForTarget(config, queueTarget) !== identity.bundle) {
1370
790
  throw new UsageError(`Proposal ${proposal.id} is bound to a different queue target.`, "INVALID_FLAG_VALUE");
1371
791
  }
1372
- return { ...target, source: { ...target.source, name: targetBundleId } };
792
+ return target;
1373
793
  }
1374
794
  const target = explicitTarget ? resolveWriteTarget(config, explicitTarget) : queueTarget;
1375
795
  if (!target) {
1376
796
  throw new UsageError(`Unbound short proposal ${proposal.id} requires an explicit --target or authenticated --queue context.`, "INVALID_PROPOSAL");
1377
797
  }
1378
- const targetBundleId = canonicalBundleIdForTarget(config, target);
1379
- return { ...target, source: { ...target.source, name: targetBundleId } };
1380
- }
1381
- if (queueTarget && explicitTarget === undefined) {
1382
- const queueBundleId = canonicalBundleIdForTarget(config, queueTarget);
1383
- if (queueBundleId !== proposal.proposedTarget.source ||
1384
- path.resolve(queueTarget.source.path) !== path.resolve(proposal.proposedTarget.root)) {
1385
- throw new UsageError(`Proposal ${proposal.id} is bound to a different queue target.`, "INVALID_FLAG_VALUE");
1386
- }
798
+ return named(target);
799
+ }
800
+ if (queueTarget &&
801
+ explicitTarget === undefined &&
802
+ (canonicalBundleIdForTarget(config, queueTarget) !== proposal.proposedTarget.source ||
803
+ path.resolve(queueTarget.source.path) !== path.resolve(proposal.proposedTarget.root))) {
804
+ throw new UsageError(`Proposal ${proposal.id} is bound to a different queue target.`, "INVALID_FLAG_VALUE");
1387
805
  }
1388
806
  return resolveRecordedProposalTarget(config, proposal.id, proposal.proposedTarget, explicitTarget);
1389
807
  }
1390
- // ── D2 (#730) — OKF v0.2 provenance stamping on promotion ───────────────────
1391
- //
1392
- // The proposals system already tracks exactly what OKF v0.2 wants on disk —
1393
- // `source`/`sourceRun` (PROV-DM modeled, `proposal-types.ts` §80-120),
1394
- // `gateDecision` (`:200-237`), `review` (`:170-174`) — but none of it leaves
1395
- // state.db. This section projects it onto the written asset's frontmatter at
1396
- // promotion time, AKM-native assets only (an OKF-adapter target never reaches
1397
- // this function — `assertAkmAssetWrite` rejects it earlier in
1398
- // `promoteProposalWithLease`, before any of this runs).
1399
- //
1400
- // Two DISTINCT actors, deliberately not conflated (documented judgment call —
1401
- // see the PR body for the alternative considered and rejected):
1402
- // - `generated.by` answers "what produced the CONTENT" — keyed on
1403
- // `isAutomatedProposalSource(proposal.source)`: an automated pipeline
1404
- // (reflect/distill/consolidate/extract/improve/schema-repair) stamps
1405
- // `akm/<pkgVersion>`; a human-initiated source (propose/remember/import)
1406
- // or the semi-automated `feedback` source stamps `human:<actorId>`.
1407
- // - `verified[0].by` answers "what accepted/reviewed THIS promotion" —
1408
- // keyed on whether a `gateDecision` was supplied to THIS call: present
1409
- // (the automated drain/triage path decided) stamps `akm/<pkgVersion>`;
1410
- // absent (a human explicitly ran `akm proposal accept`) stamps
1411
- // `human:<actorId>`. Every promotion reaches this function via exactly
1412
- // one of those two paths, so `verified` is always stamped — there is no
1413
- // third, unreviewed path to disk.
1414
- /** 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. */
808
+ // ── OKF v0.2 provenance on promotion (#730) ─────────────────────────────────
809
+ // `generated.by` names what produced the content (an automated source is
810
+ // `akm/<version>`, anything else `human:<actor>`); `verified[].by` names what
811
+ // accepted this promotion (a gate decision is `akm/<version>`, a direct
812
+ // `akm proposal accept` is `human:<actor>`). Only AKM-native targets get here.
1415
813
  function resolveActorId(ctx) {
1416
814
  if (ctx?.actorId)
1417
815
  return ctx.actorId();
1418
816
  try {
1419
- const username = os.userInfo().username?.trim();
1420
- return username ? username : "local";
817
+ return os.userInfo().username?.trim() || "local";
1421
818
  }
1422
819
  catch {
1423
820
  return "local";
1424
821
  }
1425
822
  }
1426
- /** `generated.by` — keyed on the SOURCE that produced the content (see file-header note above). */
1427
- function generatedByActor(proposal, ctx) {
1428
- return isAutomatedProposalSource(proposal.source) ? `akm/${pkgVersion}` : `human:${resolveActorId(ctx)}`;
1429
- }
1430
- /** `verified[0].by` — keyed on whether THIS promotion was gated (automated) or a direct human accept (see file-header note above). */
1431
- function verifiedByActor(gateDecision, ctx) {
1432
- return gateDecision !== undefined ? `akm/${pkgVersion}` : `human:${resolveActorId(ctx)}`;
1433
- }
1434
- /** True for a plain (non-null, non-array) object. */
1435
823
  function isPlainRecord(value) {
1436
824
  return value !== null && typeof value === "object" && !Array.isArray(value);
1437
825
  }
1438
826
  /**
1439
- * Stamp OKF v0.2 provenance onto ONE promoted asset's frontmatter (D2.1/D2.2/
1440
- * D2.3), in the **hybrid** on-disk shape settled by the #730 review:
1441
- *
1442
- * - `generated: {by, at}` and `verified: [{by, at}]` are written **bare at the
1443
- * top level**, exactly as OKF v0.2 spells them (SPEC §5.2/§5.3). Neither key
1444
- * has any pre-existing AKM consumer, so spelling them the spec's way costs
1445
- * nothing and makes `okf-support.md`'s "AKM Markdown is an OKF-compatible
1446
- * superset" positioning actually true for trust metadata: a third-party OKF
1447
- * v0.2 reader pointed at an AKM stash sees conformant provenance.
1448
- * - `sources` stays namespaced under `provenance:`, because a bare top-level
1449
- * `sources:` genuinely collides with the pre-existing AKM-native wiki
1450
- * citation-**string** convention
1451
- * (`indexer/passes/metadata.ts#applyWikiFrontmatter`, which silently drops
1452
- * non-strings) on a promoted wiki page.
1453
- *
1454
- * The read side back into `IndexDocument.provenance` is
1455
- * `metadata.ts#applyProvenanceFrontmatter` (which accepts both this shape and
1456
- * the older fully-nested one), carried through `akm-adapter.ts`'s
1457
- * `DOCUMENT_JSON_CARRIED_FIELDS` (D2.4).
1458
- *
1459
- * A no-op frontmatter mutation preserves the existing frontmatter block's raw
1460
- * body bytes (mirrors `frontmatter.ts#mutateFrontmatter`'s documented
1461
- * contract) rather than reshaping via `assembleAsset`, which would strip
1462
- * leading body blank lines / force a trailing newline. A file with no
1463
- * frontmatter block at all (non-conformant input) gains one via
1464
- * `assembleAsset`, exactly as any other first-frontmatter write would.
827
+ * Stamp provenance onto a promoted asset's frontmatter: bare top-level
828
+ * `generated` and `verified` (as OKF v0.2 spells them; `verified` accumulates),
829
+ * and `sources` under `provenance:`, since a bare `sources:` is the wiki
830
+ * citation-string convention. An existing frontmatter block keeps its raw body
831
+ * bytes.
1465
832
  */
1466
833
  function stampProposalProvenance(content, proposal, gateDecision, ctx, nowIsoStr) {
1467
834
  const parsed = parseFrontmatter(content);
1468
835
  const fm = { ...parsed.data };
1469
836
  const existingProvenance = isPlainRecord(fm.provenance) ? fm.provenance : {};
1470
- // Bare `generated:` — OKF v0.2's replacement for `timestamp` (SPEC §13).
1471
- fm.generated = { by: generatedByActor(proposal, ctx), at: nowIsoStr };
1472
- // Bare `verified:` — append, so independent confirmations accumulate rather
1473
- // than the newest overwriting the record. Both the bare list and the older
1474
- // nested spelling are absorbed, so a re-promotion never loses history.
837
+ const human = () => `human:${resolveActorId(ctx)}`;
838
+ fm.generated = { by: isAutomatedProposalSource(proposal.source) ? `akm/${pkgVersion}` : human(), at: nowIsoStr };
839
+ // The older nested `provenance.verified` spelling is absorbed too, so history is never lost.
1475
840
  const priorVerified = Array.isArray(fm.verified)
1476
841
  ? fm.verified
1477
842
  : Array.isArray(existingProvenance.verified)
@@ -1479,18 +844,14 @@ function stampProposalProvenance(content, proposal, gateDecision, ctx, nowIsoStr
1479
844
  : [];
1480
845
  fm.verified = [
1481
846
  ...priorVerified,
1482
- { by: verifiedByActor(gateDecision, ctx), at: gateDecision?.decidedAt ?? nowIsoStr },
847
+ { by: gateDecision !== undefined ? `akm/${pkgVersion}` : human(), at: gateDecision?.decidedAt ?? nowIsoStr },
1483
848
  ];
1484
- // `sources` alone stays namespaced — bare `sources:` is the wiki
1485
- // citation-string convention. Drop the nested provenance block entirely when
1486
- // it would otherwise be empty, so unrelated assets gain no dead key.
1487
849
  const provenance = { ...existingProvenance };
1488
850
  delete provenance.generatedBy;
1489
851
  delete provenance.generatedAt;
1490
852
  delete provenance.verified;
1491
- const evidenceSources = fm.evidenceSources;
1492
- if (Array.isArray(evidenceSources)) {
1493
- const sources = evidenceSources
853
+ if (Array.isArray(fm.evidenceSources)) {
854
+ const sources = fm.evidenceSources
1494
855
  .filter((s) => typeof s === "string" && s.trim().length > 0)
1495
856
  .map((resource) => ({ resource: resource.trim() }));
1496
857
  if (sources.length > 0)
@@ -1505,16 +866,9 @@ function stampProposalProvenance(content, proposal, gateDecision, ctx, nowIsoStr
1505
866
  : assembleAsset(fm, parsed.content);
1506
867
  }
1507
868
  /**
1508
- * Validate a proposal, then promote it through the canonical
1509
- * {@link writeAssetToSource} dispatch (the single place that branches on
1510
- * `source.kind`). On success the proposal is archived with status `accepted`.
1511
- * Validation failures throw a `UsageError` carrying every finding so the CLI
1512
- * can render a single clear error envelope.
1513
- *
1514
- * Phase 6C: when the target asset already exists at the resolved write path,
1515
- * its prior content is captured BEFORE the write and stored on the archived
1516
- * proposal record (`backupContent`) so `akm proposal revert` can restore it.
1517
- * Genuinely-new assets carry no backup.
869
+ * Validate, stamp and write an accepted proposal into its bound target, then
870
+ * archive it as accepted. Overwriting an existing asset keeps its prior content
871
+ * on the row (`backupContent`) for `akm proposal revert`.
1518
872
  */
1519
873
  export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
1520
874
  return withAssetMutationLease("proposal-accept", () => promoteProposalWithLease(stashDir, config, id, options, ctx));
@@ -1526,7 +880,7 @@ function promotionLintBlockers(raw, assetPath, targetRoot, refType, config) {
1526
880
  if (refType === "task") {
1527
881
  try {
1528
882
  const parsed = parseYaml(raw);
1529
- data = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
883
+ data = isPlainRecord(parsed) ? parsed : {};
1530
884
  }
1531
885
  catch {
1532
886
  data = {};
@@ -1538,9 +892,6 @@ function promotionLintBlockers(raw, assetPath, targetRoot, refType, config) {
1538
892
  ({ data, content: body, frontmatter } = parseFrontmatter(raw));
1539
893
  }
1540
894
  const resolvedRoot = path.resolve(targetRoot);
1541
- const extraStashRoots = resolveSourceEntries(targetRoot, config)
1542
- .map((source) => source.path)
1543
- .filter((sourcePath) => path.resolve(sourcePath) !== resolvedRoot);
1544
895
  return runBaseChecks({
1545
896
  filePath: assetPath,
1546
897
  relPath: path.relative(targetRoot, assetPath),
@@ -1550,140 +901,165 @@ function promotionLintBlockers(raw, assetPath, targetRoot, refType, config) {
1550
901
  frontmatter,
1551
902
  fix: false,
1552
903
  stashRoot: targetRoot,
1553
- extraStashRoots,
904
+ extraStashRoots: resolveSourceEntries(targetRoot, config)
905
+ .map((source) => source.path)
906
+ .filter((sourcePath) => path.resolve(sourcePath) !== resolvedRoot),
1554
907
  }).filter((finding) => PROMOTION_LINT_ISSUE_TYPES.has(finding.issue));
1555
908
  }
1556
- /** Build and validate the exact stamped bytes promotion would publish, without writing. */
909
+ /** The exact stamped bytes a promotion would publish, validated, without writing. */
1557
910
  export function preflightProposalPromotion(config, proposal, options = {}, ctx) {
1558
911
  const repairedContent = repairProposalContent(proposalContent(proposal));
1559
- const preparedProposal = repairedContent === proposalContent(proposal) ? proposal : withProposalContent(proposal, repairedContent);
1560
- const report = validateProposal(preparedProposal);
912
+ const prepared = repairedContent === proposalContent(proposal) ? proposal : withProposalContent(proposal, repairedContent);
913
+ const report = validateProposal(prepared);
1561
914
  if (!report.ok) {
1562
- const message = report.findings.map((finding) => `[${finding.kind}] ${finding.message}`).join("\n");
1563
- throw new UsageError(`Proposal ${proposal.id} failed validation:\n${message}`, "MISSING_REQUIRED_ARGUMENT", "Fix the proposal payload (frontmatter / content) and try again, or reject the proposal with a reason.");
915
+ throw new UsageError(`Proposal ${proposal.id} failed validation:\n${report.findings.map((f) => `[${f.kind}] ${f.message}`).join("\n")}`, "MISSING_REQUIRED_ARGUMENT", "Fix the proposal payload (frontmatter / content) and try again, or reject the proposal with a reason.");
1564
916
  }
1565
- const ref = parseRefInput(preparedProposal.ref);
917
+ const ref = parseRefInput(prepared.ref);
1566
918
  if (!stashDirFor(ref.type)) {
1567
919
  throw new UsageError(`Proposal ${proposal.id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
1568
920
  }
1569
- const target = resolveProposalWriteTarget(config, preparedProposal, options.target, options.queueTarget);
921
+ const target = resolveProposalWriteTarget(config, prepared, options.target, options.queueTarget);
1570
922
  const assetPath = resolveAssetFilePathSafe(target.source, ref);
1571
923
  if (!assetPath)
1572
- throw new UsageError(`Cannot resolve proposal target ${preparedProposal.ref}.`, "INVALID_PROPOSAL");
924
+ throw new UsageError(`Cannot resolve proposal target ${prepared.ref}.`, "INVALID_PROPOSAL");
1573
925
  assertAkmAssetWrite(target.source);
1574
- const stampedContent = assetPath.toLowerCase().endsWith(".md")
1575
- ? stampProposalProvenance(repairedContent, preparedProposal, options.gateDecision, ctx, nowIso(ctx))
926
+ const markdown = assetPath.toLowerCase().endsWith(".md");
927
+ let stampedContent = markdown
928
+ ? stampProposalProvenance(repairedContent, prepared, options.gateDecision, ctx, nowIso(ctx))
1576
929
  : repairedContent;
930
+ if (markdown && fs.existsSync(assetPath)) {
931
+ // Keep the live target's bookkeeping frontmatter the proposal doesn't set
932
+ // (STALE, R20) — dropping `inferenceProcessed` would re-run inference.
933
+ try {
934
+ stampedContent = carryForwardBookkeepingFrontmatter(stampedContent, fs.readFileSync(assetPath, "utf8"));
935
+ }
936
+ catch {
937
+ // best-effort; the freshness check is the real staleness gate
938
+ }
939
+ }
1577
940
  const lintBlockers = promotionLintBlockers(stampedContent, assetPath, target.source.path, ref.type, config);
1578
941
  if (lintBlockers.length > 0) {
1579
942
  const summary = lintBlockers.map((finding) => `[${finding.issue}] ${finding.detail}`).join("; ");
1580
943
  warn(`[proposal] promotion lint for ${proposal.id} found (non-blocking): ${summary}`);
1581
944
  }
1582
- return { proposal: preparedProposal, repairedContent, ref, target, assetPath, stampedContent };
945
+ return { proposal: prepared, repairedContent, ref, target, assetPath, stampedContent };
1583
946
  }
1584
947
  /**
1585
- * The change-transaction pre-commit gate — the `BundleAdapter.validate()`
1586
- * interface contract's OTHER stated consumer (`core/adapter/bundle-adapter.ts`
1587
- * doc comment, alongside `lint --fix`). Runs the target's OWN adapter's
1588
- * `validate()` over the ONE pending write `preflight` describes, with a
1589
- * {@link createValidateContext} overlay carrying the proposal's about-to-be-
1590
- * written bytes — so the adapter sees the bundle AS IT WOULD LOOK the instant
1591
- * after this transaction commits, without ever touching disk.
948
+ * The target's current bytes, provided it is still the one the proposal was
949
+ * minted against (STALE, R20): compared bookkeeping-insensitively when the
950
+ * proposal carries a normalized before-hash, exactly otherwise. A target that
951
+ * already holds this proposal's content is a promotion that wrote but did not
952
+ * record — finishing it is allowed. Never overwrites newer content.
953
+ */
954
+ export function readFreshProposalTarget(proposal, assetPath, stampedContent) {
955
+ const current = fs.existsSync(assetPath) ? fs.readFileSync(assetPath) : undefined;
956
+ if (proposal.beforeHash !== undefined) {
957
+ const fresh = current !== undefined &&
958
+ (proposal.beforeHashNormalized !== undefined
959
+ ? contentHash(current, "normalized") === proposal.beforeHashNormalized
960
+ : contentHash(current) === proposal.beforeHash);
961
+ const alreadyPublished = current !== undefined && contentHash(current, "normalized") === contentHash(stampedContent, "normalized");
962
+ if (!fresh && !alreadyPublished) {
963
+ throw new UsageError(`Proposal target changed after proposal ${proposal.id} was created; refusing to overwrite newer content.`, "INVALID_FLAG_VALUE");
964
+ }
965
+ }
966
+ else if (current !== undefined && proposal.changes.some((change) => change.op === "create")) {
967
+ throw new UsageError(`Proposal target was created after proposal ${proposal.id}; refusing to overwrite newer content.`, "INVALID_FLAG_VALUE");
968
+ }
969
+ return current;
970
+ }
971
+ /** The recorded asset path of an accepted/reverted proposal, provided `target` is the same binding. */
972
+ function acceptedAssetPath(proposal, target, ref) {
973
+ const accepted = proposal.acceptedTarget;
974
+ const assetPath = resolveAssetFilePathSafe(target.source, ref);
975
+ if (!accepted ||
976
+ !assetPath ||
977
+ accepted.source !== target.source.name ||
978
+ path.resolve(accepted.root) !== path.resolve(target.source.path) ||
979
+ path.resolve(accepted.path) !== path.resolve(assetPath)) {
980
+ throw new UsageError(`proposal ${proposal.id} is bound to a different accepted target`, "INVALID_FLAG_VALUE");
981
+ }
982
+ return assetPath;
983
+ }
984
+ function requireAcceptedTarget(proposal) {
985
+ if (!proposal.acceptedTarget) {
986
+ const label = proposal.status === "reverted" ? "Reverted" : "Accepted";
987
+ throw new UsageError(`${label} proposal ${proposal.id} has no recorded target.`, "INVALID_PROPOSAL");
988
+ }
989
+ return proposal.acceptedTarget;
990
+ }
991
+ /**
992
+ * O1 (alpha.9): an accepted consolidate PROMOTION retires its source memory
993
+ * (and its `.derived` twin), so promotion no longer leaves a memory/
994
+ * knowledge duplicate behind. Runs whether a person accepted the promotion
995
+ * or triage auto-promotion did — this is called from inside
996
+ * `promoteProposalWithLease`'s ordinary accept path, below the drain/CLI
997
+ * layer, so both routes hit it the same way. Best-effort: a failure here
998
+ * only warns — the promotion itself already succeeded and is not undone —
999
+ * and a source already gone (raced with something else, or never existed)
1000
+ * is silently skipped, not an error.
1592
1001
  *
1593
- * DELIBERATELY ADVISORY, not blocking (see the report for the full
1594
- * rationale): the akm adapter's `missing-ref` check resolves prose refs
1595
- * through the SAME core overlay `resolveRef` that closes the OKF/llm-wiki
1596
- * lint gaps — and that resolver is proven to disagree with the legacy
1597
- * `commands/lint/base-linter.ts#checkMissingRefs` resolver in one specific,
1598
- * real case: a fully-qualified `bundle//conceptId` prose ref (or a bare
1599
- * frontmatter xref) whose leading segment does NOT name a registered AKM
1600
- * placement type. The legacy resolver treats an unrecognized type prefix as
1601
- * "not a locally-checkable ref, skip it, never flag missing" (whole-hearted
1602
- * leniency for cross-bundle / foreign-format refs); this module's core
1603
- * resolver additionally tries the ref as a literal on-disk path — the
1604
- * resolution non-akm adapters (OKF, llm-wiki) actually NEED for their own
1605
- * same-component conceptIds — which means it CAN report `missing-ref` for a
1606
- * foreign-typed prose ref the legacy checker always let through. Promoting a
1607
- * proposal is a live, user-facing write path; blocking it on a diagnostic
1608
- * that can disagree with the existing (already-tested, already-run)
1609
- * `promotionLintBlockers` gate a few lines above is not a change to make
1610
- * without a dedicated equivalence pass first. So: this computes and surfaces
1611
- * the finding (visible via `warn`, and never thrown) without changing whether
1612
- * ANY promotion succeeds or fails — proving the wiring end-to-end on real
1613
- * proposal data while leaving today's blocking behavior completely
1614
- * untouched. Never throws: a validate() failure here must not corrupt or
1615
- * half-apply the transaction that follows.
1002
+ * B3: the promotion was queued against the source's content as it stood at
1003
+ * mint time (`accepted.promotionSourceHash`). If the source was edited since
1004
+ * — the freshest edit is exactly what a person would not want silently
1005
+ * discarded into the archive — this only warns and leaves the source alone;
1006
+ * the promotion itself still stands. A proposal minted before this field
1007
+ * existed carries no hash at all, so it is treated the same way: never
1008
+ * archived, not verified against a hash that was never recorded.
1616
1009
  */
1617
- async function runAdapterPreCommitCheck(config, preflight) {
1010
+ function retirePromotionSource(mutationTarget, accepted) {
1011
+ if (!accepted.promotionSource)
1012
+ return;
1618
1013
  try {
1619
- const adapterId = preflight.target.source.adapterId ?? "akm";
1620
- const adapter = adapterForId(adapterId);
1621
- if (!adapter)
1014
+ const sourceRef = parseRefInput(accepted.promotionSource);
1015
+ const typeDir = stashDirFor(sourceRef.type);
1016
+ if (!typeDir)
1017
+ return;
1018
+ const sourcePath = assetPathForName(sourceRef.type, path.join(mutationTarget.source.path, typeDir), sourceRef.name);
1019
+ if (!fs.existsSync(sourcePath))
1020
+ return;
1021
+ if (!accepted.promotionSourceHash) {
1022
+ warn(`[proposal] O1: ${accepted.id} has no recorded source hash (minted by an older release) — leaving its source ${accepted.promotionSource} unarchived.`);
1622
1023
  return;
1623
- const root = preflight.target.source.path;
1624
- const relPath = path.relative(root, preflight.assetPath).replace(/\\/g, "/");
1625
- if (!relPath || relPath.startsWith(".."))
1626
- return; // resolved outside its own bundle root — nothing to check
1627
- const change = {
1628
- path: relPath,
1629
- after: preflight.stampedContent,
1630
- op: fs.existsSync(preflight.assetPath) ? "update" : "create",
1024
+ }
1025
+ const currentHash = contentHash(fs.readFileSync(sourcePath, "utf8"), "body");
1026
+ if (currentHash !== accepted.promotionSourceHash) {
1027
+ warn(`[proposal] O1: source ${accepted.promotionSource} for ${accepted.id} changed since the promotion was queued — leaving it unarchived.`);
1028
+ return;
1029
+ }
1030
+ const candidate = {
1031
+ ref: accepted.promotionSource,
1032
+ reason: "promoted",
1033
+ proposalId: accepted.id,
1034
+ successorRefs: [accepted.ref],
1631
1035
  };
1632
- const extraRoots = resolveSourceEntries(root, config)
1633
- .map((source) => source.path)
1634
- .filter((sourcePath) => path.resolve(sourcePath) !== path.resolve(root));
1635
- const componentCtx = createValidateContext({ root, extraRoots, changes: [change] });
1636
- const diagnostics = await adapter.validate({ id: preflight.target.selector ?? adapterId, adapter: adapterId, root, writable: true }, [change], componentCtx);
1637
- if (diagnostics.length > 0) {
1638
- const summary = diagnostics.map((d) => `[${d.issue}] ${d.detail}`).join("; ");
1639
- warn(`[proposal] pre-commit adapter check for ${preflight.proposal.id} found (non-blocking): ${summary}`);
1036
+ const record = archiveCleanupCandidate(mutationTarget.source.path, candidate, sourcePath);
1037
+ const paths = [
1038
+ sourcePath,
1039
+ path.join(mutationTarget.source.path, record.archivedPath),
1040
+ path.join(mutationTarget.source.path, record.auditPath),
1041
+ ];
1042
+ const twin = derivedTwinPath(sourcePath, sourceRef.type);
1043
+ if (twin) {
1044
+ const twinRecord = archiveCleanupCandidate(mutationTarget.source.path, candidate, twin);
1045
+ paths.push(twin, path.join(mutationTarget.source.path, twinRecord.archivedPath), path.join(mutationTarget.source.path, twinRecord.auditPath));
1640
1046
  }
1047
+ commitWriteTargetBoundary(mutationTarget, `Retire promoted source ${accepted.promotionSource}`, { paths });
1641
1048
  }
1642
1049
  catch (error) {
1643
- // Advisory only — never let a validate() failure interrupt or corrupt the
1644
- // promotion transaction that follows.
1645
- warn(`[proposal] pre-commit adapter check for ${preflight.proposal.id} threw (ignored, non-blocking): ${error instanceof Error ? error.message : String(error)}`);
1050
+ warn(`[proposal] O1: failed to retire promotion source ${accepted.promotionSource} for ${accepted.id}: ${error instanceof Error ? error.message : String(error)}`);
1646
1051
  }
1647
1052
  }
1648
1053
  async function promoteProposalWithLease(stashDir, config, id, options, ctx) {
1649
- recoverRejectTransaction(stashDir, id, ctx);
1650
- let proposal = getProposal(stashDir, id, ctx);
1651
- const repairedContent = repairProposalContent(proposalContent(proposal));
1652
- const proposalToValidate = repairedContent === proposalContent(proposal) ? proposal : withProposalContent(proposal, repairedContent);
1653
- const report = validateProposal(proposalToValidate);
1654
- if (!report.ok) {
1655
- const message = report.findings.map((finding) => `[${finding.kind}] ${finding.message}`).join("\n");
1656
- throw new UsageError(`Proposal ${id} failed validation:\n${message}`, "MISSING_REQUIRED_ARGUMENT", "Fix the proposal payload (frontmatter / content) and try again, or reject the proposal with a reason.");
1657
- }
1658
- const ref = parseRefInput(proposalToValidate.ref);
1659
- if (!stashDirFor(ref.type)) {
1660
- throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
1661
- }
1662
- // Use the (possibly repaired) payload for the promotion write. Persist the
1663
- // repaired content back onto the DB row so the audit trail reflects the
1664
- // final promoted payload (not the defective original).
1665
- if (repairedContent !== proposalContent(proposal)) {
1666
- withProposalsDb(stashDir, ctx, (db) => {
1667
- upsertProposal(db, proposalToValidate, stashDir);
1668
- });
1669
- }
1670
- await recoverProposalTransactionsForStash(stashDir, config, ctx, id);
1671
- proposal = getProposal(stashDir, id, ctx);
1054
+ const proposal = getProposal(stashDir, id, ctx);
1055
+ if (isRetireProposal(proposal))
1056
+ return retireProposalWithLease(stashDir, config, proposal, options, ctx);
1672
1057
  const target = resolveProposalWriteTarget(config, proposal, options.target, options.queueTarget);
1673
1058
  if (proposal.status === "accepted") {
1674
- if (!proposal.acceptedTarget) {
1675
- throw new UsageError(`Accepted proposal ${id} has no recorded target.`, "INVALID_PROPOSAL");
1676
- }
1677
- const assetPath = resolveAssetFilePathSafe(target.source, ref);
1678
- if (proposal.acceptedTarget.source !== target.source.name ||
1679
- path.resolve(proposal.acceptedTarget.root) !== path.resolve(target.source.path) ||
1680
- !assetPath ||
1681
- path.resolve(proposal.acceptedTarget.path) !== path.resolve(assetPath)) {
1682
- throw new UsageError(`proposal ${id} is bound to a different accepted target`, "INVALID_FLAG_VALUE");
1683
- }
1684
- if (!assetPath ||
1685
- !fs.existsSync(assetPath) ||
1686
- proposalFileHash(assetPath) !== proposal.acceptedTarget.contentHash) {
1059
+ // Accepting again is a no-op, provided the published bytes are still there.
1060
+ const recorded = requireAcceptedTarget(proposal);
1061
+ const assetPath = acceptedAssetPath(proposal, target, parseRefInput(proposal.ref));
1062
+ if (!fs.existsSync(assetPath) || contentHash(fs.readFileSync(assetPath)) !== recorded.contentHash) {
1687
1063
  throw new UsageError(`Accepted proposal ${id} does not match the current asset content.`, "INVALID_FLAG_VALUE");
1688
1064
  }
1689
1065
  return { proposal, assetPath, ref: proposal.ref };
@@ -1692,177 +1068,543 @@ async function promoteProposalWithLease(stashDir, config, id, options, ctx) {
1692
1068
  throw new UsageError(`Proposal ${id} is not pending (current status: ${proposal.status}). Only pending proposals can be accepted.`, "INVALID_FLAG_VALUE");
1693
1069
  }
1694
1070
  const preflight = preflightProposalPromotion(config, proposal, { ...options, queueTarget: target }, ctx);
1695
- await runAdapterPreCommitCheck(config, preflight);
1696
1071
  const mutationTarget = prepareWriteTargetForMutation(target);
1697
- const assetPath = resolveAssetFilePathSafe(mutationTarget.source, ref);
1072
+ const assetPath = resolveAssetFilePathSafe(mutationTarget.source, preflight.ref);
1698
1073
  if (!assetPath)
1699
1074
  throw new UsageError(`Cannot resolve proposal target ${proposal.ref}.`, "INVALID_PROPOSAL");
1700
- assertWriteTargetPathsClean(mutationTarget.source, [assetPath]);
1701
- let backup;
1702
- if (fs.existsSync(assetPath)) {
1075
+ const backup = readFreshProposalTarget(proposal, assetPath, preflight.stampedContent);
1076
+ assertAkmAssetWrite(mutationTarget.source);
1077
+ const refIdentity = proposalRefIdentity(preflight.proposal.ref);
1078
+ const proposalForMutation = refIdentity?.bundle === undefined
1079
+ ? { ...preflight.proposal, ref: `${target.source.name}//${refIdentity?.conceptId ?? ""}` }
1080
+ : preflight.proposal;
1081
+ const decidedAt = nowIso(ctx);
1082
+ const content = preflight.stampedContent.endsWith("\n") ? preflight.stampedContent : `${preflight.stampedContent}\n`;
1083
+ writeProposalAssetFile(assetPath, content);
1084
+ commitWriteTargetBoundary(mutationTarget, `Update ${proposalForMutation.ref}`, { paths: [assetPath] });
1085
+ const accepted = persistProposalDecision(stashDir, proposalForMutation, {
1086
+ operation: "accept",
1087
+ target: mutationTarget,
1088
+ assetPath,
1089
+ content,
1090
+ existed: backup !== undefined,
1091
+ ...(backup !== undefined ? { backupContent: backup.toString("utf8") } : {}),
1092
+ ...(options.eventMetadata ? { eventMetadata: options.eventMetadata } : {}),
1093
+ ...(options.gateDecision ? { gateDecision: options.gateDecision } : {}),
1094
+ decidedAt,
1095
+ }, ctx);
1096
+ await indexWrittenProposalAsset(mutationTarget, assetPath);
1097
+ if (accepted.status === "accepted" && accepted.source === "consolidate")
1098
+ retirePromotionSource(mutationTarget, accepted);
1099
+ return { proposal: accepted, assetPath, ref: accepted.ref };
1100
+ }
1101
+ /**
1102
+ * Every archive dir a tombstone under `.akm/memory-cleanup/archive/` claims
1103
+ * for `proposalId` — the primary asset's, and its `.derived` twin's if one
1104
+ * was archived alongside it. Used to detect what a resumed retire accept
1105
+ * (should-fix 5) has already moved.
1106
+ */
1107
+ function findRetireArchiveDirsByProposalId(stashRoot, proposalId) {
1108
+ const archiveRoot = path.join(stashRoot, ".akm", "memory-cleanup", "archive");
1109
+ let entries;
1110
+ try {
1111
+ entries = fs.readdirSync(archiveRoot);
1112
+ }
1113
+ catch {
1114
+ return undefined;
1115
+ }
1116
+ const dirs = [];
1117
+ for (const name of entries) {
1118
+ let data;
1703
1119
  try {
1704
- backup = fs.readFileSync(assetPath);
1120
+ data = parseFrontmatter(fs.readFileSync(path.join(archiveRoot, name, "cleanup.md"), "utf8")).data;
1705
1121
  }
1706
- catch (error) {
1707
- throw new Error(`Proposal backup read failed for ${assetPath}: ${error instanceof Error ? error.message : String(error)}`);
1122
+ catch {
1123
+ continue;
1708
1124
  }
1125
+ if (data.proposalId === proposalId)
1126
+ dirs.push(path.relative(stashRoot, path.join(archiveRoot, name)));
1709
1127
  }
1710
- if (proposal.beforeHash !== undefined && (!backup || proposalHash(backup) !== proposal.beforeHash)) {
1711
- throw new UsageError(`Proposal target changed after proposal ${id} was created; refusing to overwrite newer content.`, "INVALID_FLAG_VALUE");
1128
+ return dirs.length > 0 ? dirs : undefined;
1129
+ }
1130
+ /** The absolute original paths a set of archive dirs' own tombstones claim — for resume detection. */
1131
+ function alreadyArchivedOriginalPaths(stashRoot, dirs) {
1132
+ const paths = new Set();
1133
+ for (const dirRel of dirs) {
1134
+ try {
1135
+ const data = parseFrontmatter(fs.readFileSync(path.join(stashRoot, dirRel, "cleanup.md"), "utf8")).data;
1136
+ if (typeof data.originalPath === "string")
1137
+ paths.add(path.resolve(stashRoot, data.originalPath));
1138
+ }
1139
+ catch {
1140
+ // An unreadable tombstone just is not counted "already done" — the move below re-attempts that file.
1141
+ }
1712
1142
  }
1713
- if (proposal.beforeHash === undefined &&
1714
- backup !== undefined &&
1715
- proposal.changes.some((change) => change.op === "create")) {
1716
- throw new UsageError(`Proposal target was created after proposal ${id}; refusing to overwrite newer content.`, "INVALID_FLAG_VALUE");
1143
+ return paths;
1144
+ }
1145
+ /**
1146
+ * Should-fix 5: record a retire accept's intent — `backupContent` and which
1147
+ * files (asset, `.derived` twin) are about to move — on the still-pending
1148
+ * proposal BEFORE any file is moved. A crash after this point resumes from
1149
+ * exactly this record instead of re-deriving `backupContent` from whatever
1150
+ * is on disk afterward, or from the archived copy, which for a `supersedes`
1151
+ * judgement already carries the edge the accept itself is about to write.
1152
+ */
1153
+ function recordRetireAcceptIntent(stashDir, proposalId, intent, ctx) {
1154
+ return withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
1155
+ const current = requireProposal(db, stashDir, proposalId);
1156
+ if (current.retireAcceptIntent)
1157
+ return current;
1158
+ const next = { ...current, retireAcceptIntent: intent };
1159
+ upsertProposal(db, next, stashDir);
1160
+ return next;
1161
+ }));
1162
+ }
1163
+ /**
1164
+ * Persist a retire's "accepted" decision — the row, its ledger decision and
1165
+ * its event — the one finalize step a fresh accept and one resumed after a
1166
+ * crash (should-fix 5) share: by the time either calls it, every file move
1167
+ * is already confirmed done. Mirrors the accept branch of
1168
+ * {@link persistProposalDecision}, kept separate since a retire's
1169
+ * accepted-shape fields (`retiredArchive`, no published `content`) do not
1170
+ * fit that function's create/update-shaped `decision` union.
1171
+ */
1172
+ function persistRetireAcceptance(stashDir, proposal, info, ctx) {
1173
+ const decidedAt = nowIso(ctx);
1174
+ return withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
1175
+ const current = requireProposal(db, stashDir, proposal.id);
1176
+ if (current.status === "accepted")
1177
+ return current;
1178
+ if (current.status !== "pending") {
1179
+ throw new Error(`Proposal ${proposal.id} changed status during acceptance (${current.status}).`);
1180
+ }
1181
+ const next = {
1182
+ ...proposal,
1183
+ retireAcceptIntent: undefined, // finalized — the intent only matters while still pending
1184
+ status: "accepted",
1185
+ updatedAt: decidedAt,
1186
+ review: { outcome: "accepted", decidedAt },
1187
+ acceptedTarget: {
1188
+ source: info.targetName,
1189
+ root: info.targetRoot,
1190
+ path: info.assetPath,
1191
+ contentHash: info.contentHash,
1192
+ },
1193
+ retiredArchive: { dirs: info.archiveDirs },
1194
+ backupContent: info.backupContent,
1195
+ ...(info.gateDecision
1196
+ ? { gateDecision: { ...info.gateDecision, decidedAt: info.gateDecision.decidedAt ?? decidedAt } }
1197
+ : {}),
1198
+ };
1199
+ upsertProposal(db, next, stashDir);
1200
+ recordImproveLedgerDecision(db, {
1201
+ proposalId: next.id,
1202
+ stashDir,
1203
+ ref: next.ref,
1204
+ source: next.source,
1205
+ outcome: "accepted",
1206
+ at: decidedAt,
1207
+ });
1208
+ insertEventOnce(db, {
1209
+ eventType: "promoted",
1210
+ ts: decidedAt,
1211
+ ref: next.ref,
1212
+ metadata: {
1213
+ proposalId: next.id,
1214
+ source: next.source,
1215
+ ...(next.sourceRun !== undefined ? { sourceRun: next.sourceRun } : {}),
1216
+ assetPath: info.assetPath,
1217
+ retired: true,
1218
+ ...(info.eventMetadata ? info.eventMetadata : {}),
1219
+ },
1220
+ idempotencyKey: `${next.id}:promoted`,
1221
+ });
1222
+ return next;
1223
+ }));
1224
+ }
1225
+ /**
1226
+ * B2: throws a stale-retire `UsageError` unless the successor still exists
1227
+ * and both sides' recorded body hashes still match their current files — the
1228
+ * durable half of the chain guard. Used for a fresh accept, and (4b, third
1229
+ * review round) to re-check a resumed accept whose intent was recorded but
1230
+ * nothing has moved yet: a separate proposal accepted in between (e.g. this
1231
+ * one's successor itself retired by a B->C accept) can make the decision
1232
+ * stale even though nothing about the retired side's own file changed.
1233
+ */
1234
+ function assertRetirementStillFresh(proposalId, proposalRef, retirement, targetSource, retiredCurrentBytes) {
1235
+ const successorPath = resolveAssetFilePathSafe(targetSource, parseRefInput(retirement.successorRef));
1236
+ const successorBytes = successorPath && fs.existsSync(successorPath) ? fs.readFileSync(successorPath) : undefined;
1237
+ const retiredFresh = contentHash(retiredCurrentBytes, "body") === retirement.retiredContentHash;
1238
+ const successorFresh = successorBytes !== undefined && contentHash(successorBytes, "body") === retirement.successorContentHash;
1239
+ if (!successorBytes || !retiredFresh || !successorFresh) {
1240
+ throw new UsageError(`Retire proposal ${proposalId} is stale — successor ${retirement.successorRef} ` +
1241
+ `${successorBytes === undefined ? "no longer exists" : !successorFresh ? "changed" : `and ${proposalRef} changed`} ` +
1242
+ "since judging; refusing to retire.", "INVALID_FLAG_VALUE");
1717
1243
  }
1718
- assertAkmAssetWrite(mutationTarget.source);
1719
- // D2 (#730): stamp OKF v0.2 provenance onto the promoted content BEFORE
1720
- // lint/write — reaching this point already proves the target is AKM-native
1721
- // (assertAkmAssetWrite above rejects an OKF-adapter target first), so the
1722
- // OKF write-rejection contract (runbook §10) is untouched: this code never
1723
- // runs for it. Markdown-only: a task/env/script/other non-markdown target
1724
- // has no frontmatter block to stamp into.
1725
- //
1726
- // workflow-format-unification removed the re-validation fallback that used
1727
- // to live here: every AKM-native markdown type (workflow included) now
1728
- // validates its frontmatter against a schema whose closed key set is
1729
- // `envelope ∪ type-keys` (`schemas/akm-workflow.json` $ref's
1730
- // `schemas/akm-asset-envelope.json`, which already admits `generated`/
1731
- // `verified`/`provenance`/`status`/`stale_after`). A validator rejecting the
1732
- // machine-stamped keys it is contractually required to admit is structurally
1733
- // impossible now, so falling back to unstamped content on rejection would
1734
- // only silently hide a real regression instead of promoting stamped content.
1735
- const stampedContent = preflight.stampedContent;
1736
- const proposalForPreflight = preflight.proposal;
1737
- const refIdentity = proposalRefIdentity(proposalForPreflight.ref);
1738
- const proposalForMutation = refIdentity?.bundle === undefined
1739
- ? { ...proposalForPreflight, ref: `${target.source.name}//${refIdentity?.conceptId ?? ""}` }
1740
- : proposalForPreflight;
1741
- const transaction = prepareProposalTransaction(stashDir, mutationTarget, proposalForMutation, ref, stampedContent, {
1742
- operation: "accept",
1743
- originalHash: backup ? proposalHash(backup) : null,
1744
- backup,
1745
- eventMetadata: options.eventMetadata,
1746
- gateDecision: options.gateDecision,
1244
+ }
1245
+ /**
1246
+ * Accept a `retire` proposal (0.9.17-alpha.9, the consolidate pair pass): no
1247
+ * new content is written. Should-fix 5 (second review round) makes this a
1248
+ * three-phase, resume-safe sequence: (1) record intent — `backupContent`
1249
+ * and the exact files about to move — on the still-pending proposal; (2)
1250
+ * move each file into the recoverable cleanup archive
1251
+ * (`archiveCleanupCandidate`), skipping any the tombstone scan shows a prior,
1252
+ * crashed attempt already moved; (3) finalize via
1253
+ * {@link persistRetireAcceptance}. A `supersedes` judgement writes the
1254
+ * supersede edge on the retired (older) side AFTER intent is recorded (4a,
1255
+ * third review round — recording it first means a crash before the edge
1256
+ * write can never cause a resume to re-read the file and capture its own
1257
+ * edge into `backupContent`), so the archived copy still preserves it. A
1258
+ * target already gone with no recorded intent (raced with something else)
1259
+ * fails cleanly with a `UsageError`, the same clean-error idiom every other
1260
+ * staleness check in this file uses — never an unhandled throw.
1261
+ */
1262
+ async function retireProposalWithLease(stashDir, config, proposal, options, ctx) {
1263
+ const target = resolveProposalWriteTarget(config, proposal, options.target, options.queueTarget);
1264
+ const ref = parseRefInput(proposal.ref);
1265
+ if (proposal.status === "accepted") {
1266
+ // Accepting again is a no-op, provided the target is still gone (retired) and its archive is still there.
1267
+ const recorded = requireAcceptedTarget(proposal);
1268
+ const assetPath = acceptedAssetPath(proposal, target, ref);
1269
+ const archive = proposal.retiredArchive;
1270
+ const archivedStill = archive?.dirs.every((dir) => fs.existsSync(path.join(target.source.path, dir))) === true;
1271
+ if (fs.existsSync(assetPath) || !archivedStill) {
1272
+ throw new UsageError(`Accepted retire proposal ${proposal.id} no longer matches its archive.`, "INVALID_FLAG_VALUE");
1273
+ }
1274
+ return { proposal, assetPath: recorded.path, ref: proposal.ref };
1275
+ }
1276
+ if (proposal.status !== "pending") {
1277
+ throw new UsageError(`Proposal ${proposal.id} is not pending (current status: ${proposal.status}). Only pending proposals can be accepted.`, "INVALID_FLAG_VALUE");
1278
+ }
1279
+ const assetPath = resolveAssetFilePathSafe(target.source, ref);
1280
+ if (!assetPath)
1281
+ throw new UsageError(`Cannot resolve proposal target ${proposal.ref}.`, "INVALID_PROPOSAL");
1282
+ let working = proposal;
1283
+ let intent = proposal.retireAcceptIntent;
1284
+ if (!intent) {
1285
+ // Fresh accept — no recorded intent yet, so the target must still be there.
1286
+ if (!fs.existsSync(assetPath)) {
1287
+ throw new UsageError(`Retire proposal ${proposal.id} target (${proposal.ref}) no longer exists — it may already have been retired, promoted away, or removed by another proposal.`, "INVALID_FLAG_VALUE");
1288
+ }
1289
+ const currentBytes = fs.readFileSync(assetPath);
1290
+ const retirement = proposal.retirement;
1291
+ if (!retirement) {
1292
+ // createRetireProposal always sets this — a row without one is corrupt, not merely stale.
1293
+ throw new Error(`Retire proposal ${proposal.id} has no retirement metadata.`);
1294
+ }
1295
+ // B2 (this is the durable half of the chain guard; the same-run half is
1296
+ // `retiredThisRun` in pair-pass.ts):
1297
+ assertRetirementStillFresh(proposal.id, proposal.ref, retirement, target.source, currentBytes);
1298
+ assertAkmAssetWrite(target.source);
1299
+ // Phase 1: record intent BEFORE any move, and BEFORE the supersede edge
1300
+ // (4a, third review round) — `backupContent` is `currentBytes`, read
1301
+ // above, before any mutation of this file. Recording first means a crash
1302
+ // between here and the edge write below can never cause a resume to
1303
+ // re-read the file and capture the edge INTO backupContent as if it were
1304
+ // the original.
1305
+ intent = { assetPath, backupContent: currentBytes.toString("utf8") };
1306
+ working = recordRetireAcceptIntent(stashDir, proposal.id, intent, ctx);
1307
+ if (retirement.judgeLabel === "supersedes") {
1308
+ try {
1309
+ writeSupersededEdge(assetPath, retirement.successorRef);
1310
+ }
1311
+ catch (error) {
1312
+ warn(`[proposal] failed to write the supersede edge for ${proposal.id} (continuing with the retire): ${error instanceof Error ? error.message : String(error)}`);
1313
+ }
1314
+ }
1315
+ }
1316
+ else {
1317
+ assertAkmAssetWrite(target.source);
1318
+ // 4b (third review round): intent was recorded but Phase 2 never moved
1319
+ // anything yet — re-run the B2 freshness check before resuming. A
1320
+ // separate proposal accepted in the meantime (this one's successor
1321
+ // itself retired by a B->C accept) can make the decision stale even
1322
+ // though nothing here changed. Once something has moved, it is too late
1323
+ // to cleanly refuse — Phase 2 below already tolerates a partial move.
1324
+ if (working.retirement && fs.existsSync(intent.assetPath)) {
1325
+ assertRetirementStillFresh(proposal.id, proposal.ref, working.retirement, target.source, fs.readFileSync(intent.assetPath));
1326
+ }
1327
+ }
1328
+ // Phase 2: move, idempotently — a resumed call skips whichever file a
1329
+ // tombstone under this proposal's own id already claims.
1330
+ const mutationTarget = prepareWriteTargetForMutation(target);
1331
+ const already = findRetireArchiveDirsByProposalId(mutationTarget.source.path, proposal.id) ?? [];
1332
+ const alreadyDone = alreadyArchivedOriginalPaths(mutationTarget.source.path, already);
1333
+ const archiveDirs = [...already];
1334
+ const paths = [];
1335
+ const candidate = {
1336
+ ref: proposal.ref,
1337
+ reason: working.retirement?.reason ?? "duplicate",
1338
+ proposalId: proposal.id,
1339
+ ...(working.retirement?.successorRef ? { successorRefs: [working.retirement.successorRef] } : {}),
1340
+ };
1341
+ if (!alreadyDone.has(path.resolve(intent.assetPath)) && fs.existsSync(intent.assetPath)) {
1342
+ const record = archiveCleanupCandidate(mutationTarget.source.path, candidate, intent.assetPath);
1343
+ archiveDirs.push(path.dirname(record.auditPath));
1344
+ paths.push(intent.assetPath, path.join(mutationTarget.source.path, record.archivedPath), path.join(mutationTarget.source.path, record.auditPath));
1345
+ }
1346
+ // 4d (third review round): the twin path is re-derived, not carried on the
1347
+ // intent — it is a pure function of assetPath and the ref's type, and the
1348
+ // tombstone scan above (`alreadyDone`) already finds one archived earlier,
1349
+ // so storing it was redundant persisted state.
1350
+ const twinPath = derivedTwinPath(intent.assetPath, ref.type);
1351
+ if (twinPath && !alreadyDone.has(path.resolve(twinPath)) && fs.existsSync(twinPath)) {
1352
+ const twinRecord = archiveCleanupCandidate(mutationTarget.source.path, candidate, twinPath);
1353
+ archiveDirs.push(path.dirname(twinRecord.auditPath));
1354
+ paths.push(twinPath, path.join(mutationTarget.source.path, twinRecord.archivedPath), path.join(mutationTarget.source.path, twinRecord.auditPath));
1355
+ }
1356
+ if (paths.length > 0)
1357
+ commitWriteTargetBoundary(mutationTarget, `Retire ${proposal.ref}`, { paths });
1358
+ if (archiveDirs.length === 0) {
1359
+ // Recorded intent, but neither file is at its original location NOR
1360
+ // archived under this proposal's id: something else removed the target
1361
+ // between intent and move. Refuse cleanly rather than finalize on
1362
+ // nothing.
1363
+ throw new UsageError(`Retire proposal ${proposal.id} target (${proposal.ref}) no longer exists and was not archived by this proposal — refusing to accept.`, "INVALID_FLAG_VALUE");
1364
+ }
1365
+ // Phase 3: finalize — always from the recorded intent's own backupContent,
1366
+ // never a hash guessed from the archived copy.
1367
+ const accepted = persistRetireAcceptance(stashDir, working, {
1368
+ targetName: mutationTarget.source.name,
1369
+ targetRoot: mutationTarget.source.path,
1370
+ assetPath: intent.assetPath,
1371
+ contentHash: contentHash(intent.backupContent),
1372
+ archiveDirs,
1373
+ backupContent: intent.backupContent,
1374
+ ...(options.gateDecision ? { gateDecision: options.gateDecision } : {}),
1375
+ ...(options.eventMetadata ? { eventMetadata: options.eventMetadata } : {}),
1747
1376
  }, ctx);
1748
- publishProposalAsset(transaction, mutationTarget);
1749
- const accepted = await finalizeProposalTransaction(transaction, mutationTarget, proposalForMutation, ctx);
1750
- cleanupTxn(transaction.dir);
1751
- return { proposal: accepted, assetPath: transaction.journal.payload.assetPath, ref: accepted.ref };
1377
+ return { proposal: accepted, assetPath: intent.assetPath, ref: accepted.ref };
1752
1378
  }
1753
1379
  /**
1754
- * Restore the prior content of an accepted proposal from the backup captured
1755
- * at promotion time (Advantage D6c / Phase 6C).
1756
- *
1757
- * Pre-conditions:
1758
- * - `id` resolves to a proposal with `status === "accepted"`.
1759
- * - The proposal carries `backupContent` (captured by promoteProposal when
1760
- * the target asset existed before the write).
1761
- *
1762
- * On success:
1763
- * - The backup content is written back through {@link writeAssetToSource},
1764
- * so the canonical write-dispatch invariant is preserved.
1765
- * - The proposal record is updated to `status: "reverted"`.
1766
- * - Caller emits a `proposal_reverted` event in the CLI layer (mirrors how
1767
- * `promoted` / `rejected` are emitted by the CLI command, not the core).
1768
- *
1769
- * Errors are thrown as `UsageError` / `NotFoundError` so the CLI can map them
1770
- * cleanly to exit codes — see `src/commands/proposal/proposal.ts` for the
1771
- * wrapper.
1380
+ * Restore an accepted proposal's target from the backup taken at promotion.
1381
+ * New-asset proposals have no backup; a target edited since acceptance is
1382
+ * never clobbered. Reverting twice is a no-op.
1772
1383
  */
1773
1384
  export async function revertProposal(stashDir, config, id, options = {}, ctx) {
1774
1385
  return withAssetMutationLease("proposal-revert", () => revertProposalWithLease(stashDir, config, id, options, ctx));
1775
1386
  }
1776
- async function revertProposalWithLease(stashDir, config, id, options, ctx) {
1777
- let proposal = getProposal(stashDir, id, ctx);
1387
+ /**
1388
+ * Revert an accepted `retire` proposal (0.9.17-alpha.9): move the archived
1389
+ * file(s) — the retired asset, and its `.derived` twin when one was archived
1390
+ * alongside it — back to where they lived, then overwrite the primary with
1391
+ * the exact pre-retire bytes `backupContent` recorded at accept (S4) —
1392
+ * byte-exact, so it also undoes any supersede edge accept wrote without
1393
+ * touching one a person had already written, and without appending a
1394
+ * trailing newline the original never had. Each archive dir is located from
1395
+ * `retiredArchive.dirs` (set at accept time) and its own `cleanup.md`
1396
+ * tombstone names the exact paths to restore — no re-scan of every
1397
+ * tombstone in the archive.
1398
+ *
1399
+ * Should-fix 5 (second review round): every archive dir is resolved and
1400
+ * validated before any of them are moved, AND that validation tells "not
1401
+ * yet moved" apart from "already moved by an earlier, crashed attempt of
1402
+ * our own" (original present, archived copy gone) rather than treating the
1403
+ * latter as a conflict — so a retry of a crashed revert resumes instead of
1404
+ * erroring on its own prior work. The archive dirs (tombstones) are removed
1405
+ * only after the "reverted" decision is durably recorded, not interleaved
1406
+ * with the moves — a crash between moving a file and recording the
1407
+ * decision used to delete that file's tombstone first, leaving an
1408
+ * "accepted" proposal a retry could neither finish nor re-validate.
1409
+ */
1410
+ async function unretireProposalWithLease(stashDir, config, proposal, options, ctx) {
1778
1411
  const ref = parseRefInput(proposal.ref);
1779
1412
  if (!stashDirFor(ref.type)) {
1780
- throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
1413
+ throw new UsageError(`Proposal ${proposal.id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
1414
+ }
1415
+ if (proposal.status !== "accepted" && proposal.status !== "reverted") {
1416
+ throw new UsageError(`only accepted proposals can be reverted (proposal ${proposal.id} status: ${proposal.status})`, "INVALID_FLAG_VALUE");
1781
1417
  }
1782
- await recoverProposalTransactionsForStash(stashDir, config, ctx, id);
1783
- proposal = getProposal(stashDir, id, ctx);
1418
+ const recorded = requireAcceptedTarget(proposal);
1419
+ const boundTarget = resolveRecordedProposalTarget(config, proposal.id, recorded, options.target);
1420
+ const assetPath = acceptedAssetPath(proposal, boundTarget, ref);
1784
1421
  if (proposal.status === "reverted") {
1785
- if (!proposal.acceptedTarget) {
1786
- throw new UsageError(`Reverted proposal ${id} has no recorded target.`, "INVALID_PROPOSAL");
1422
+ return { proposal, assetPath, ref: proposal.ref };
1423
+ }
1424
+ const archive = proposal.retiredArchive;
1425
+ if (!archive || archive.dirs.length === 0) {
1426
+ throw new UsageError(`no archive recorded for this retire proposal (id: ${proposal.id})`, "MISSING_REQUIRED_ARGUMENT", "A retire proposal's archive is recorded at accept time; a proposal missing it cannot be reverted through this path.");
1427
+ }
1428
+ const target = prepareWriteTargetForMutation(boundTarget);
1429
+ const pending = [];
1430
+ for (const dirRel of archive.dirs) {
1431
+ const dirAbs = path.join(target.source.path, dirRel);
1432
+ const auditPath = path.join(dirAbs, "cleanup.md");
1433
+ let tombstoneData;
1434
+ try {
1435
+ tombstoneData = parseFrontmatter(fs.readFileSync(auditPath, "utf8")).data;
1436
+ }
1437
+ catch (error) {
1438
+ throw new UsageError(`Archive for proposal ${proposal.id} is missing its tombstone (${dirRel}); cannot revert: ${error instanceof Error ? error.message : String(error)}`, "INVALID_FLAG_VALUE");
1439
+ }
1440
+ const originalRel = typeof tombstoneData.originalPath === "string" ? tombstoneData.originalPath : undefined;
1441
+ const archivedRel = typeof tombstoneData.archivedPath === "string" ? tombstoneData.archivedPath : undefined;
1442
+ if (!originalRel || !archivedRel) {
1443
+ throw new UsageError(`Archive tombstone for proposal ${proposal.id} (${dirRel}) is malformed.`, "INVALID_FLAG_VALUE");
1444
+ }
1445
+ const originalAbs = path.join(target.source.path, originalRel);
1446
+ const archivedAbs = path.join(target.source.path, archivedRel);
1447
+ const originalExists = fs.existsSync(originalAbs);
1448
+ const archivedExists = fs.existsSync(archivedAbs);
1449
+ if (originalExists && archivedExists) {
1450
+ throw new UsageError(`Cannot revert proposal ${proposal.id}: ${originalRel} already exists (created since retirement); refusing to overwrite it.`, "INVALID_FLAG_VALUE");
1451
+ }
1452
+ if (!originalExists && !archivedExists) {
1453
+ throw new UsageError(`Cannot revert proposal ${proposal.id}: archived copy ${archivedRel} is missing.`, "INVALID_FLAG_VALUE");
1454
+ }
1455
+ // Must-fix 2 (third review round): "original present, archived copy
1456
+ // missing" is not necessarily our own earlier, crashed revert — once a
1457
+ // purge can delete an archived copy on its own, a LATER, unrelated file
1458
+ // can occupy this same path (a new memory reusing a retired one's name),
1459
+ // and the write step below would overwrite it with the retired asset's
1460
+ // pre-retire bytes. Only the primary has a recorded pre-retire hash
1461
+ // (`retirement.retiredContentHash`) to tell the two apart; resume only
1462
+ // when it matches the file actually sitting there, otherwise refuse
1463
+ // exactly as the conflict case above does.
1464
+ if (originalExists && !archivedExists && path.resolve(originalAbs) === path.resolve(assetPath)) {
1465
+ const expectedHash = proposal.retirement?.retiredContentHash;
1466
+ let currentHash;
1467
+ try {
1468
+ currentHash = contentHash(fs.readFileSync(originalAbs, "utf8"), "body");
1469
+ }
1470
+ catch {
1471
+ currentHash = undefined;
1472
+ }
1473
+ if (!expectedHash || currentHash !== expectedHash) {
1474
+ throw new UsageError(`Cannot revert proposal ${proposal.id}: ${originalRel} exists but its content does not match what was retired (its path may have been reused since); refusing to overwrite it.`, "INVALID_FLAG_VALUE");
1475
+ }
1787
1476
  }
1788
- const target = resolveRecordedProposalTarget(config, id, proposal.acceptedTarget, options.target);
1789
- const requestedAssetPath = resolveAssetFilePathSafe(target.source, ref);
1790
- if (!requestedAssetPath ||
1791
- proposal.acceptedTarget.source !== target.source.name ||
1792
- path.resolve(proposal.acceptedTarget.root) !== path.resolve(target.source.path) ||
1793
- path.resolve(proposal.acceptedTarget.path) !== path.resolve(requestedAssetPath)) {
1794
- throw new UsageError(`proposal ${id} is bound to a different accepted target`, "INVALID_FLAG_VALUE");
1477
+ pending.push({ dirAbs, auditPath, originalAbs, archivedAbs, alreadyDone: originalExists });
1478
+ }
1479
+ const restoredPaths = [];
1480
+ let primaryOriginalAbs;
1481
+ for (const p of pending) {
1482
+ if (!p.alreadyDone) {
1483
+ fs.mkdirSync(path.dirname(p.originalAbs), { recursive: true });
1484
+ fs.renameSync(p.archivedAbs, p.originalAbs);
1485
+ recordWrittenPath(p.archivedAbs);
1486
+ recordWrittenPath(p.originalAbs);
1795
1487
  }
1796
- return {
1797
- proposal,
1798
- assetPath: requestedAssetPath,
1799
- ref: proposal.ref,
1488
+ restoredPaths.push(p.originalAbs, p.archivedAbs, p.auditPath);
1489
+ if (path.resolve(p.originalAbs) === path.resolve(assetPath))
1490
+ primaryOriginalAbs = p.originalAbs;
1491
+ }
1492
+ // S4 / nit: overwrite the primary with the EXACT pre-retire bytes recorded
1493
+ // at accept (`backupContent`) — no appended trailing newline either, so
1494
+ // YAML comments, key order, a pre-existing human `supersededBy` edge, and
1495
+ // even the exact absence of a final newline all survive the round trip.
1496
+ // The archived copy just moved back may carry a `supersededBy` edge THIS
1497
+ // accept wrote (a `supersedes` judgement); restoring the recorded original
1498
+ // bytes already removes exactly that edge, so no separate
1499
+ // removeSupersededEdge mutation runs here — one that could not tell "the
1500
+ // edge accept wrote" from "an edge a person had already written" apart,
1501
+ // and would delete either.
1502
+ if (primaryOriginalAbs && proposal.backupContent !== undefined) {
1503
+ writeProposalAssetFile(primaryOriginalAbs, proposal.backupContent);
1504
+ }
1505
+ commitWriteTargetBoundary(target, `Revert ${proposal.ref}`, { paths: restoredPaths });
1506
+ const decidedAt = nowIso(ctx);
1507
+ const reverted = withProposalsDb(ctx, (db) => withImmediateTransaction(db, () => {
1508
+ const current = requireProposal(db, stashDir, proposal.id);
1509
+ if (current.status === "reverted")
1510
+ return current;
1511
+ const next = {
1512
+ ...current,
1513
+ status: "reverted",
1514
+ updatedAt: decidedAt,
1515
+ review: { outcome: "rejected", reason: "reverted: archived asset restored", decidedAt },
1800
1516
  };
1517
+ upsertProposal(db, next, stashDir);
1518
+ recordImproveLedgerDecision(db, {
1519
+ proposalId: next.id,
1520
+ stashDir,
1521
+ ref: next.ref,
1522
+ source: next.source,
1523
+ outcome: "rejected",
1524
+ at: decidedAt,
1525
+ detail: "reverted",
1526
+ });
1527
+ insertEventOnce(db, {
1528
+ eventType: "proposal_reverted",
1529
+ ts: decidedAt,
1530
+ ref: next.ref,
1531
+ metadata: { proposalId: next.id, source: next.source, assetPath },
1532
+ idempotencyKey: `${next.id}:reverted`,
1533
+ });
1534
+ return next;
1535
+ }));
1536
+ // Only now — after the decision is durably recorded — remove the archive
1537
+ // dirs (tombstones). See the function doc comment for why this ordering
1538
+ // matters.
1539
+ for (const p of pending) {
1540
+ fs.rmSync(p.dirAbs, { recursive: true, force: true });
1541
+ }
1542
+ try {
1543
+ if (!(await indexWrittenAssets(target.source.path, restoredPaths, { bundleId: target.source.name }))) {
1544
+ warn(`[proposals] ${restoredPaths.join(", ")} were restored but not indexed; run \`akm index\`.`);
1545
+ }
1546
+ }
1547
+ catch (error) {
1548
+ warn(`[proposals] restored paths were not indexed (${error instanceof Error ? error.message : String(error)}); run \`akm index\`.`);
1549
+ }
1550
+ return { proposal: reverted, assetPath, ref: proposal.ref };
1551
+ }
1552
+ async function revertProposalWithLease(stashDir, config, id, options, ctx) {
1553
+ const proposal = getProposal(stashDir, id, ctx);
1554
+ if (isRetireProposal(proposal))
1555
+ return unretireProposalWithLease(stashDir, config, proposal, options, ctx);
1556
+ const ref = parseRefInput(proposal.ref);
1557
+ if (!stashDirFor(ref.type)) {
1558
+ throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
1801
1559
  }
1802
- if (proposal.status !== "accepted") {
1560
+ if (proposal.status !== "accepted" && proposal.status !== "reverted") {
1803
1561
  throw new UsageError(`only accepted proposals can be reverted (proposal ${id} status: ${proposal.status})`, "INVALID_FLAG_VALUE");
1804
1562
  }
1805
1563
  const backupContent = proposal.backupContent;
1806
- if (backupContent === undefined) {
1564
+ if (proposal.status === "accepted" && backupContent === undefined) {
1807
1565
  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.");
1808
1566
  }
1809
- if (!proposal.acceptedTarget) {
1810
- throw new UsageError(`Accepted proposal ${id} has no recorded target.`, "INVALID_PROPOSAL");
1811
- }
1812
- let target = resolveRecordedProposalTarget(config, id, proposal.acceptedTarget, options.target);
1813
- const requestedAssetPath = resolveAssetFilePathSafe(target.source, ref);
1814
- if (proposal.acceptedTarget.source !== target.source.name ||
1815
- path.resolve(proposal.acceptedTarget.root) !== path.resolve(target.source.path) ||
1816
- !requestedAssetPath ||
1817
- path.resolve(proposal.acceptedTarget.path) !== path.resolve(requestedAssetPath)) {
1818
- throw new UsageError(`proposal ${id} is bound to a different accepted target`, "INVALID_FLAG_VALUE");
1819
- }
1820
- const assetPath = requestedAssetPath;
1821
- const acceptedHash = proposal.acceptedTarget.contentHash;
1822
- target = prepareWriteTargetForMutation(target);
1823
- if (!fs.existsSync(assetPath) || proposalFileHash(assetPath) !== acceptedHash) {
1824
- throw new UsageError(`asset content changed after proposal ${id} was accepted; refusing to clobber the newer content`, "INVALID_FLAG_VALUE");
1825
- }
1826
- assertWriteTargetPathsClean(target.source, [assetPath]);
1827
- const transaction = prepareProposalTransaction(stashDir, target, proposal, ref, backupContent, { operation: "revert", originalHash: acceptedHash }, ctx);
1828
- publishProposalAsset(transaction, target);
1829
- const reverted = await finalizeProposalTransaction(transaction, target, proposal, ctx);
1830
- cleanupTxn(transaction.dir);
1567
+ const recorded = requireAcceptedTarget(proposal);
1568
+ const boundTarget = resolveRecordedProposalTarget(config, id, recorded, options.target);
1569
+ const assetPath = acceptedAssetPath(proposal, boundTarget, ref);
1570
+ if (proposal.status === "reverted" || backupContent === undefined) {
1571
+ return { proposal, assetPath, ref: proposal.ref };
1572
+ }
1573
+ const target = prepareWriteTargetForMutation(boundTarget);
1574
+ if (!fs.existsSync(assetPath) || contentHash(fs.readFileSync(assetPath)) !== recorded.contentHash) {
1575
+ // Nit (third review round): by-ref resolution skips retire proposals
1576
+ // (should-fix 7), so reverting by ref when a SEPARATE retire proposal
1577
+ // for the same ref exists (pending or already accepted) lands here with
1578
+ // no clue that proposal is the real story — name it when one does.
1579
+ const siblingRetire = listProposalsReadOnly(stashDir, { ref: proposal.ref, includeArchive: true }, ctx).find((p) => isRetireProposal(p) && (p.status === "pending" || p.status === "accepted"));
1580
+ throw new UsageError(`asset content changed after proposal ${id} was accepted; refusing to clobber the newer content` +
1581
+ (siblingRetire ? ` (a retire proposal for this ref exists: ${siblingRetire.id})` : ""), "INVALID_FLAG_VALUE");
1582
+ }
1583
+ const decidedAt = nowIso(ctx);
1584
+ writeProposalAssetFile(assetPath, backupContent.endsWith("\n") ? backupContent : `${backupContent}\n`);
1585
+ commitWriteTargetBoundary(target, `Revert ${proposal.ref}`, { paths: [assetPath] });
1586
+ const reverted = persistProposalDecision(stashDir, proposal, { operation: "revert", assetPath, decidedAt }, ctx);
1587
+ await indexWrittenProposalAsset(target, assetPath);
1831
1588
  return { proposal: reverted, assetPath, ref: proposal.ref };
1832
1589
  }
1833
- /**
1834
- * Compute a diff between a proposal payload and the existing on-disk asset.
1835
- * Uses {@link resolveWriteTarget} to find where the asset would land — so the
1836
- * diff matches exactly what `accept` will write. Falls back to "new asset"
1837
- * when no asset is currently materialised at the target ref.
1838
- */
1590
+ /** The proposal against the asset its accept would overwrite (same target resolution as accept). */
1839
1591
  export function diffProposal(stashDir, config, id, options = {}, ctx) {
1840
1592
  const proposal = getProposal(stashDir, id, ctx);
1841
- const ref = parseRefInput(proposal.ref);
1842
- let targetPath;
1843
- let existing = null;
1844
- const readTarget = (target) => {
1845
- targetPath = resolveAssetFilePathSafe(target.source, ref);
1846
- if (targetPath && fs.existsSync(targetPath)) {
1847
- existing = fs.readFileSync(targetPath, "utf8");
1848
- }
1849
- };
1850
- readTarget(resolveProposalWriteTarget(config, proposal, options.target, options.queueTarget));
1851
- const proposed = proposalContent(proposal);
1852
- if (existing === null) {
1853
- return {
1854
- existing: null,
1855
- proposed,
1856
- unified: formatNewAssetDiff(proposal.ref, proposed),
1857
- isNew: true,
1858
- ...(targetPath ? { targetPath } : {}),
1859
- };
1860
- }
1593
+ const target = resolveProposalWriteTarget(config, proposal, options.target, options.queueTarget);
1594
+ const targetPath = resolveAssetFilePathSafe(target.source, parseRefInput(proposal.ref));
1595
+ const existing = targetPath && fs.existsSync(targetPath) ? fs.readFileSync(targetPath, "utf8") : null;
1596
+ // A retire proposal's primary change deletes its target rather than
1597
+ // writing content: "proposed" is empty and the diff shows the whole body
1598
+ // being removed, reusing the ordinary unified-diff formatter instead of
1599
+ // proposalContent() (which has nothing to read for a delete).
1600
+ const proposed = isRetireProposal(proposal) ? "" : proposalContent(proposal);
1861
1601
  return {
1862
1602
  existing,
1863
1603
  proposed,
1864
- unified: formatUnifiedDiff(existing, proposed, proposal.ref),
1865
- isNew: false,
1604
+ unified: existing === null
1605
+ ? formatNewAssetDiff(proposal.ref, proposed)
1606
+ : formatUnifiedDiff(existing, proposed, proposal.ref),
1607
+ isNew: existing === null,
1866
1608
  ...(targetPath ? { targetPath } : {}),
1867
1609
  };
1868
1610
  }
@@ -1870,49 +1612,10 @@ function resolveAssetFilePathSafe(source, ref) {
1870
1612
  const typeDir = stashDirFor(ref.type);
1871
1613
  if (!typeDir)
1872
1614
  return undefined;
1873
- const typeRoot = path.join(source.path, typeDir);
1874
1615
  try {
1875
- return assetPathForName(ref.type, typeRoot, ref.name);
1616
+ return assetPathForName(ref.type, path.join(source.path, typeDir), ref.name);
1876
1617
  }
1877
1618
  catch {
1878
1619
  return undefined;
1879
1620
  }
1880
1621
  }
1881
- // Register the proposal transaction kinds with the unified engine so ANY
1882
- // recovery entry point (mv pre-flight, indexer, write-path indexer) can
1883
- // finish or roll back an interrupted proposal mutation for a root it
1884
- // touches. The proposal-owned entry points below keep their richer,
1885
- // ctx-threaded recovery paths over the same journals.
1886
- registerTxnKind(PROPOSAL_TXN_KIND, {
1887
- phases: PROPOSAL_TXN_PHASES,
1888
- commitPhase: "asset-published",
1889
- validate: (journal, txnDir, root) => fenceProposalTxnJournal(journal, txnDir, root),
1890
- rollback: (txn) => {
1891
- rollbackPreparedProposalTransaction(txn);
1892
- },
1893
- finalize: async (txn) => {
1894
- const p = txn.journal.payload;
1895
- const config = loadConfig();
1896
- let target = resolveProposalRecoveryTarget(config, txn.journal);
1897
- if (txn.journal.phase === "asset-published") {
1898
- target = prepareWriteTargetForMutation(target, { allowAhead: true });
1899
- }
1900
- if (canonicalTxnRoot(target.source.path) !== canonicalTxnRoot(txn.journal.root) ||
1901
- p.targetKind !== target.source.kind) {
1902
- throw new Error(`Proposal transaction ${txn.journal.transactionId} is bound to a different target root.`);
1903
- }
1904
- const proposal = getProposal(p.stashDir, p.proposalId);
1905
- await finalizeProposalTransaction(txn, target, proposal);
1906
- cleanupProposalPublication(p);
1907
- },
1908
- });
1909
- registerTxnKind(REJECT_TXN_KIND, {
1910
- phases: REJECT_TXN_PHASES,
1911
- // A reject is roll-forward from its very first phase (DB-only; the archive
1912
- // decision is durable the moment the journal exists).
1913
- commitPhase: "prepared",
1914
- rollback: () => { },
1915
- finalize: (txn) => {
1916
- finalizeRejectTransaction(txn);
1917
- },
1918
- });