akm-cli 0.9.17-alpha.2 → 0.9.17-alpha.4

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