akm-cli 0.9.0-rc.8 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (450) hide show
  1. package/CHANGELOG.md +1063 -44
  2. package/README.md +51 -25
  3. package/SECURITY.md +14 -1
  4. package/STABILITY.md +497 -0
  5. package/dist/akm +148 -35
  6. package/dist/{akm-migrate-storage → akm-migrate} +6 -9
  7. package/dist/assets/hints/cli-hints-full.md +223 -95
  8. package/dist/assets/hints/cli-hints-short.md +85 -22
  9. package/dist/assets/improve-strategies/default.json +1 -1
  10. package/dist/assets/improve-strategies/reflect-distill.json +1 -1
  11. package/dist/assets/prompts/memory-infer-user.md +2 -3
  12. package/dist/assets/stash-skeleton/README.md +6 -5
  13. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +2 -0
  14. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +2 -0
  15. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +2 -0
  16. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +2 -0
  17. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +2 -0
  18. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +2 -0
  19. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +2 -0
  20. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +2 -0
  21. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +2 -0
  22. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +2 -0
  23. package/dist/assets/stash-skeleton/facts/conventions/domains.md +2 -0
  24. package/dist/assets/stash-skeleton/facts/conventions/organization.md +20 -9
  25. package/dist/assets/tasks/core/extract.yml +1 -1
  26. package/dist/assets/tasks/core/version-check.yml +1 -1
  27. package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +5 -0
  28. package/dist/assets/tasks/improve/akm-improve-catchup.yml +8 -0
  29. package/dist/assets/tasks/improve/akm-improve-consolidate.yml +5 -0
  30. package/dist/assets/tasks/improve/akm-improve-frequent.yml +5 -0
  31. package/dist/assets/tasks/improve/akm-improve-nightly.yml +5 -0
  32. package/dist/assets/templates/html/health.html +1 -3
  33. package/dist/assets/workflows/workflow-template.md +32 -15
  34. package/dist/cli/invocation.js +40 -15
  35. package/dist/cli/parse-args.js +0 -22
  36. package/dist/cli/retired-commands.js +121 -0
  37. package/dist/cli/shared.js +154 -22
  38. package/dist/cli/unknown-flags.js +236 -0
  39. package/dist/cli-node.mjs +2 -1
  40. package/dist/cli.js +696 -258
  41. package/dist/commands/agent/agent-dispatch.js +14 -3
  42. package/dist/commands/agent/contribute-cli.js +73 -88
  43. package/dist/commands/completions.js +79 -22
  44. package/dist/commands/config-cli.js +17 -150
  45. package/dist/commands/env/env-cli.js +59 -143
  46. package/dist/commands/env/env.js +12 -163
  47. package/dist/commands/env/marker-path.js +6 -0
  48. package/dist/commands/env/secret-cli.js +36 -66
  49. package/dist/commands/env/secret.js +24 -57
  50. package/dist/commands/feedback-cli.js +141 -87
  51. package/dist/commands/health/accept-rate.js +58 -0
  52. package/dist/commands/health/advisories.js +3 -4
  53. package/dist/commands/health/checks.js +85 -23
  54. package/dist/commands/health/html-report.js +7 -10
  55. package/dist/commands/health/improve-metrics.js +25 -83
  56. package/dist/commands/health/md-report.js +5 -9
  57. package/dist/commands/health/metrics.js +62 -20
  58. package/dist/commands/health/renderers.js +47 -0
  59. package/dist/commands/health/report-view-model.js +4 -5
  60. package/dist/commands/health/stash-exposure.js +1 -1
  61. package/dist/commands/health/surfaces.js +3 -48
  62. package/dist/commands/health/task-runs.js +3 -67
  63. package/dist/commands/health/types-improve.js +7 -0
  64. package/dist/commands/health.js +99 -28
  65. package/dist/commands/improve/anti-collapse.js +2 -2
  66. package/dist/commands/improve/autonomy-gate.js +68 -0
  67. package/dist/commands/improve/collapse-detector.js +41 -40
  68. package/dist/commands/improve/consolidate/eligibility.js +1 -23
  69. package/dist/commands/improve/consolidate/merge.js +4 -0
  70. package/dist/commands/improve/consolidate.js +140 -1000
  71. package/dist/commands/improve/distill/promote-memory.js +12 -12
  72. package/dist/commands/improve/distill/quality-gate.js +6 -6
  73. package/dist/commands/improve/distill.js +58 -69
  74. package/dist/commands/improve/eligibility.js +105 -57
  75. package/dist/commands/improve/extract-cli.js +14 -133
  76. package/dist/commands/improve/improve-cli.js +98 -114
  77. package/dist/commands/improve/improve-result-file.js +1 -28
  78. package/dist/commands/improve/improve-strategies.js +8 -5
  79. package/dist/commands/improve/improve.js +128 -91
  80. package/dist/commands/improve/loop-stages.js +182 -20
  81. package/dist/commands/improve/memory/derived-ref.js +45 -43
  82. package/dist/commands/improve/memory/memory-belief.js +1 -1
  83. package/dist/commands/improve/memory/memory-contradiction-detect.js +4 -12
  84. package/dist/commands/improve/memory/memory-improve.js +6 -5
  85. package/dist/commands/improve/outcome-loop.js +22 -65
  86. package/dist/commands/improve/preparation.js +114 -123
  87. package/dist/commands/improve/proactive-maintenance.js +2 -5
  88. package/dist/commands/improve/reflect.js +56 -160
  89. package/dist/commands/improve/salience.js +11 -122
  90. package/dist/commands/improve/source-identity.js +10 -38
  91. package/dist/commands/lint/base-linter.js +20 -124
  92. package/dist/commands/lint/env-key-rules.js +31 -47
  93. package/dist/commands/lint/index.js +249 -43
  94. package/dist/commands/{events.js → log.js} +33 -38
  95. package/dist/commands/migrate-cli.js +92 -12
  96. package/dist/commands/migration-tool.js +46 -0
  97. package/dist/commands/observability-cli.js +70 -209
  98. package/dist/commands/proposal/drain.js +101 -29
  99. package/dist/commands/proposal/proposal-cli.js +76 -48
  100. package/dist/commands/proposal/proposal.js +54 -18
  101. package/dist/commands/proposal/propose-cli.js +88 -0
  102. package/dist/commands/proposal/propose.js +23 -15
  103. package/dist/commands/proposal/repository.js +701 -278
  104. package/dist/commands/proposal/validators/proposal-quality-validators.js +2 -8
  105. package/dist/commands/proposal/validators/proposal-validators.js +55 -7
  106. package/dist/commands/proposal/validators/proposals.js +4 -7
  107. package/dist/commands/read/curate.js +34 -53
  108. package/dist/commands/read/knowledge.js +150 -95
  109. package/dist/commands/read/registry-search.js +2 -2
  110. package/dist/commands/read/remember-cli.js +42 -15
  111. package/dist/commands/read/search-cli.js +180 -78
  112. package/dist/commands/read/search.js +58 -43
  113. package/dist/commands/read/show.js +197 -141
  114. package/dist/commands/registry-cli.js +12 -51
  115. package/dist/commands/remember.js +14 -57
  116. package/dist/commands/sources/add-cli.js +100 -31
  117. package/dist/commands/sources/bundle-cli.js +166 -0
  118. package/dist/commands/sources/bundle-config-ops.js +7 -2
  119. package/dist/commands/sources/info.js +18 -5
  120. package/dist/commands/sources/init.js +12 -12
  121. package/dist/commands/sources/installed-stashes.js +382 -98
  122. package/dist/commands/sources/schema-repair.js +3 -2
  123. package/dist/commands/sources/self-update.js +131 -38
  124. package/dist/commands/sources/source-add.js +72 -17
  125. package/dist/commands/sources/source-clone.js +129 -45
  126. package/dist/commands/sources/source-manage.js +43 -23
  127. package/dist/commands/sources/sources-cli.js +57 -208
  128. package/dist/commands/sources/stash-cli.js +46 -53
  129. package/dist/commands/tasks/tasks-cli.js +91 -97
  130. package/dist/commands/tasks/tasks.js +276 -421
  131. package/dist/commands/workflow-cli.js +175 -450
  132. package/dist/core/adapter/adapters/akm-adapter.js +47 -28
  133. package/dist/core/adapter/adapters/akm-lint.js +42 -27
  134. package/dist/core/adapter/adapters/akm-metadata.js +15 -44
  135. package/dist/core/adapter/adapters/akm-task-adapter.js +15 -13
  136. package/dist/core/adapter/adapters/akm-workflow-adapter.js +55 -71
  137. package/dist/core/adapter/adapters/dotenv-adapter.js +1 -1
  138. package/dist/core/adapter/adapters/generic-files-adapter.js +2 -0
  139. package/dist/core/adapter/adapters/index.js +6 -6
  140. package/dist/core/adapter/adapters/llm-wiki-adapter.js +14 -8
  141. package/dist/core/adapter/adapters/okf-adapter.js +187 -19
  142. package/dist/core/adapter/adapters/shared.js +3 -19
  143. package/dist/core/adapter/adapters/tool-dir-shared.js +8 -3
  144. package/dist/core/adapter/adapters/website-snapshot-adapter.js +1 -0
  145. package/dist/core/adapter/detect-adapter.js +17 -0
  146. package/dist/core/adapter/recognize-match.js +6 -4
  147. package/dist/core/adapter/validate-context.js +214 -0
  148. package/dist/core/asset/akm-markdown.js +63 -0
  149. package/dist/core/asset/asset-placement.js +20 -6
  150. package/dist/core/asset/asset-ref.js +11 -9
  151. package/dist/core/asset/frontmatter-lint.js +30 -0
  152. package/dist/core/asset/frontmatter.js +37 -9
  153. package/dist/core/asset/markdown.js +40 -51
  154. package/dist/core/asset/resolve-ref.js +89 -18
  155. package/dist/core/asset/stash-meta.js +1 -1
  156. package/dist/core/bundle-id.js +51 -0
  157. package/dist/core/common.js +152 -38
  158. package/dist/core/config/config-io.js +12 -1
  159. package/dist/core/config/config-schema.js +35 -8
  160. package/dist/core/config/config-sources.js +55 -11
  161. package/dist/core/config/config-walker.js +25 -9
  162. package/dist/core/config/config.js +9 -48
  163. package/dist/core/config/experimental.js +21 -0
  164. package/dist/core/config/schema/embedding.js +5 -1
  165. package/dist/core/config/schema/experimental.js +30 -0
  166. package/dist/core/config/schema/improve-processes.js +0 -6
  167. package/dist/core/config/schema/improve.js +21 -3
  168. package/dist/core/config/schema/index-config.js +8 -15
  169. package/dist/core/config/schema/output.js +4 -1
  170. package/dist/core/config/schema/setup.js +9 -18
  171. package/dist/core/config/schema/sources-bundles.js +49 -33
  172. package/dist/core/config/schema/workflow.js +3 -3
  173. package/dist/core/env-secret-ref.js +76 -46
  174. package/dist/core/errors.js +18 -12
  175. package/dist/core/events.js +46 -128
  176. package/dist/core/file-change.js +6 -5
  177. package/dist/core/fs-txn.js +83 -7
  178. package/dist/core/git-message.js +2 -2
  179. package/dist/core/improve-result.js +1 -100
  180. package/dist/core/lesson-lint.js +1 -17
  181. package/dist/core/logs-db.js +2 -1
  182. package/dist/core/migration-operation.js +16 -0
  183. package/dist/core/mutation-target.js +78 -0
  184. package/dist/core/parse.js +4 -1
  185. package/dist/core/paths.js +17 -20
  186. package/dist/core/recognition-util.js +12 -14
  187. package/dist/core/redaction.js +34 -0
  188. package/dist/core/standards/resolve-standards-context.js +2 -14
  189. package/dist/core/standards/resolve-stash-standards.js +2 -2
  190. package/dist/core/standards/resolve-type-conventions.js +2 -2
  191. package/dist/core/state/migrations.js +41 -18
  192. package/dist/core/state-db.js +5 -14
  193. package/dist/core/structured.js +1 -1
  194. package/dist/core/subprocess.js +6 -4
  195. package/dist/core/text-truncation.js +9 -5
  196. package/dist/core/type-presentation.js +3 -3
  197. package/dist/core/warn.js +0 -3
  198. package/dist/core/write-source.js +771 -95
  199. package/dist/indexer/bundle-identity-guard.js +3 -2
  200. package/dist/indexer/db/graph-db.js +0 -24
  201. package/dist/indexer/ensure-index.js +1 -0
  202. package/dist/indexer/graph/graph-boost.js +9 -34
  203. package/dist/indexer/graph/graph-extraction.js +8 -5
  204. package/dist/indexer/index-writer-lock.js +53 -17
  205. package/dist/indexer/index-written-assets.js +16 -22
  206. package/dist/indexer/indexer.js +497 -239
  207. package/dist/indexer/installations.js +14 -96
  208. package/dist/indexer/passes/dir-staleness.js +16 -9
  209. package/dist/indexer/passes/memory-inference.js +11 -9
  210. package/dist/indexer/passes/metadata.js +113 -47
  211. package/dist/indexer/scan/doc-to-entry.js +38 -1
  212. package/dist/indexer/scan/drain-dir.js +13 -23
  213. package/dist/indexer/search/db-search.js +99 -54
  214. package/dist/indexer/search/fts-query.js +47 -24
  215. package/dist/indexer/search/ranking-contributors.js +42 -20
  216. package/dist/indexer/search/ranking.js +18 -99
  217. package/dist/indexer/search/search-fields.js +7 -2
  218. package/dist/indexer/search/search-source.js +82 -93
  219. package/dist/indexer/usage/usage-events.js +0 -89
  220. package/dist/indexer/walk/file-context.js +2 -1
  221. package/dist/indexer/walk/matchers.js +30 -43
  222. package/dist/indexer/walk/path-resolver.js +7 -2
  223. package/dist/indexer/walk/walker.js +38 -12
  224. package/dist/integrations/agent/builders.js +0 -6
  225. package/dist/integrations/agent/config.js +2 -2
  226. package/dist/integrations/agent/detect.js +49 -19
  227. package/dist/integrations/agent/engine-fallback.js +76 -0
  228. package/dist/integrations/agent/profiles.js +14 -0
  229. package/dist/integrations/agent/prompts.js +12 -8
  230. package/dist/integrations/agent/runner-dispatch.js +4 -2
  231. package/dist/integrations/agent/runner.js +0 -1
  232. package/dist/integrations/agent/spawn.js +5 -6
  233. package/dist/integrations/github.js +1 -1
  234. package/dist/integrations/harnesses/aider/agent-builder.js +6 -4
  235. package/dist/integrations/harnesses/amazonq/agent-builder.js +7 -4
  236. package/dist/integrations/harnesses/claude/session-log.js +0 -10
  237. package/dist/integrations/harnesses/codex/agent-builder.js +5 -2
  238. package/dist/integrations/harnesses/copilot/agent-builder.js +5 -3
  239. package/dist/integrations/harnesses/gemini/agent-builder.js +5 -3
  240. package/dist/integrations/harnesses/index.js +3 -7
  241. package/dist/integrations/harnesses/opencode/agent-builder.js +21 -2
  242. package/dist/integrations/harnesses/opencode/session-log.js +0 -15
  243. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +13 -4
  244. package/dist/integrations/harnesses/openhands/agent-builder.js +9 -6
  245. package/dist/integrations/harnesses/pi/agent-builder.js +6 -4
  246. package/dist/integrations/lockfile.js +101 -6
  247. package/dist/integrations/session-logs/index.js +3 -28
  248. package/dist/llm/client.js +136 -100
  249. package/dist/llm/embedders/remote.js +13 -5
  250. package/dist/llm/feature-gate.js +4 -12
  251. package/dist/llm/graph-extract.js +5 -11
  252. package/dist/llm/memory-infer.js +144 -1
  253. package/dist/llm/metadata-enhance.js +5 -7
  254. package/dist/llm/structured-call.js +1 -1
  255. package/dist/llm/usage-persist.js +26 -5
  256. package/dist/llm/usage-telemetry.js +25 -2
  257. package/dist/output/cli-hints.js +1 -2
  258. package/dist/output/context.js +22 -7
  259. package/dist/output/format-exempt.js +80 -0
  260. package/dist/output/generic-render.js +259 -0
  261. package/dist/output/render-registry.js +57 -0
  262. package/dist/output/renderers.js +14 -36
  263. package/dist/output/shapes/curate.js +10 -1
  264. package/dist/output/shapes/events.js +12 -7
  265. package/dist/output/shapes/helpers.js +56 -83
  266. package/dist/output/shapes/migrate.js +8 -0
  267. package/dist/output/shapes/passthrough.js +7 -41
  268. package/dist/output/shapes/proposal/producer.js +15 -7
  269. package/dist/output/shapes.js +2 -9
  270. package/dist/output/text/{init.js → bundle-create.js} +3 -1
  271. package/dist/output/text/bundle-show.js +7 -0
  272. package/dist/output/text/command-format.js +164 -96
  273. package/dist/output/text/env.js +1 -3
  274. package/dist/output/text/events.js +8 -7
  275. package/dist/output/text/health-format.js +103 -0
  276. package/dist/output/text/health.js +7 -0
  277. package/dist/output/text/helpers.js +10 -8
  278. package/dist/output/text/lint-format.js +43 -0
  279. package/dist/output/text/{save.js → lint.js} +2 -2
  280. package/dist/output/text/migrate.js +88 -0
  281. package/dist/output/text/proposal/producer.js +4 -2
  282. package/dist/output/text/proposal-format.js +44 -72
  283. package/dist/output/text/registry-commands.js +1 -2
  284. package/dist/output/text/show-directives.js +15 -7
  285. package/dist/output/text/status-list.js +32 -0
  286. package/dist/output/text/sync.js +5 -0
  287. package/dist/output/text/workflow-format.js +24 -203
  288. package/dist/output/text/workflow.js +1 -7
  289. package/dist/output/text.js +16 -17
  290. package/dist/registry/factory.js +4 -6
  291. package/dist/registry/origin-resolve.js +16 -27
  292. package/dist/registry/providers/skills-sh.js +3 -3
  293. package/dist/registry/providers/static-index.js +13 -23
  294. package/dist/registry/resolve.js +42 -7
  295. package/dist/registry/semver.js +34 -84
  296. package/dist/runtime.js +2 -23
  297. package/dist/scripts/akm-migrate-node.js +60290 -0
  298. package/dist/scripts/akm-migrate.js +59628 -0
  299. package/dist/setup/detect.js +42 -15
  300. package/dist/setup/registry-stash-loader.js +2 -2
  301. package/dist/setup/setup.js +236 -136
  302. package/dist/setup/steps/connection.js +7 -9
  303. package/dist/setup/steps/platforms.js +9 -9
  304. package/dist/setup/steps/semantic.js +15 -3
  305. package/dist/setup/steps/sources.js +12 -13
  306. package/dist/setup/steps/stashdir.js +2 -3
  307. package/dist/setup/steps/tasks.js +237 -120
  308. package/dist/sources/freshness.js +1 -1
  309. package/dist/sources/provider-factory.js +11 -17
  310. package/dist/sources/providers/filesystem.js +2 -3
  311. package/dist/sources/providers/git-install.js +278 -34
  312. package/dist/sources/providers/git-provider.js +25 -23
  313. package/dist/sources/providers/git-stash.js +395 -106
  314. package/dist/sources/providers/git.js +2 -2
  315. package/dist/sources/providers/npm.js +16 -19
  316. package/dist/sources/providers/provider-utils.js +7 -4
  317. package/dist/sources/providers/sync-from-ref.js +3 -9
  318. package/dist/sources/providers/website.js +6 -1
  319. package/dist/sources/resolve.js +6 -5
  320. package/dist/sources/snapshot-fetchers/bluesky.js +146 -0
  321. package/dist/sources/snapshot-fetchers/content-extract.js +566 -0
  322. package/dist/sources/snapshot-fetchers/fetcher-util.js +41 -0
  323. package/dist/sources/snapshot-fetchers/github.js +100 -0
  324. package/dist/sources/snapshot-fetchers/host-guard.js +291 -0
  325. package/dist/sources/snapshot-fetchers/registry.js +17 -1
  326. package/dist/sources/snapshot-fetchers/robots.js +348 -0
  327. package/dist/sources/snapshot-fetchers/rss.js +282 -0
  328. package/dist/sources/snapshot-fetchers/secret-seam.js +42 -0
  329. package/dist/sources/snapshot-fetchers/website-ingest.js +566 -268
  330. package/dist/sources/snapshot-fetchers/x.js +910 -0
  331. package/dist/storage/database.js +7 -0
  332. package/dist/storage/engines/sqlite-migrations.js +23 -111
  333. package/dist/storage/managed-db.js +2 -2
  334. package/dist/storage/repositories/canaries-repository.js +1 -1
  335. package/dist/storage/repositories/events-repository.js +27 -11
  336. package/dist/storage/repositories/improve-runs-repository.js +6 -12
  337. package/dist/storage/repositories/index-connection.js +17 -6
  338. package/dist/storage/repositories/index-entries-repository.js +151 -240
  339. package/dist/storage/repositories/index-entry-mapper.js +15 -11
  340. package/dist/storage/repositories/index-fts-repository.js +5 -2
  341. package/dist/storage/repositories/index-llm-cache-repository.js +0 -1
  342. package/dist/storage/repositories/index-meta-repository.js +2 -3
  343. package/dist/storage/repositories/index-schema.js +10 -25
  344. package/dist/storage/repositories/index-utility-repository.js +15 -28
  345. package/dist/storage/repositories/index-vec-repository.js +6 -1
  346. package/dist/storage/repositories/outcome-repository.js +119 -0
  347. package/dist/storage/repositories/proposals-repository.js +296 -59
  348. package/dist/storage/repositories/registry-cache.js +19 -0
  349. package/dist/storage/repositories/salience-repository.js +172 -0
  350. package/dist/storage/repositories/task-history-repository.js +15 -13
  351. package/dist/storage/repositories/workflow-runs-repository.js +52 -40
  352. package/dist/tasks/backends/cron.js +105 -15
  353. package/dist/tasks/backends/index.js +1 -1
  354. package/dist/tasks/backends/launchd.js +85 -38
  355. package/dist/tasks/backends/schtasks.js +135 -15
  356. package/dist/tasks/embedded.js +56 -40
  357. package/dist/tasks/parser.js +7 -157
  358. package/dist/tasks/resolve-akm-bin.js +137 -59
  359. package/dist/tasks/runner.js +79 -42
  360. package/dist/tasks/scheduler-invocation.js +220 -10
  361. package/dist/tasks/schema.js +24 -1
  362. package/dist/tasks/task-id.js +1 -3
  363. package/dist/tasks/validator.js +20 -6
  364. package/dist/workflows/authoring/authoring.js +94 -143
  365. package/dist/workflows/authoring/scope-key.js +1 -1
  366. package/dist/workflows/exec/frozen-judge.js +28 -2
  367. package/dist/workflows/exec/native-executor.js +77 -57
  368. package/dist/workflows/exec/param-secrets.js +9 -9
  369. package/dist/workflows/exec/run-workflow.js +133 -79
  370. package/dist/workflows/exec/step-work.js +219 -346
  371. package/dist/{migrate-storage-node.mjs → workflows/exec/unit-dispatch.js} +1 -5
  372. package/dist/workflows/ir/compile.js +141 -270
  373. package/dist/workflows/ir/freeze.js +40 -30
  374. package/dist/workflows/ir/params.js +135 -11
  375. package/dist/workflows/ir/plan-hash.js +1 -1
  376. package/dist/workflows/ir/schema.js +25 -26
  377. package/dist/workflows/parser.js +872 -307
  378. package/dist/workflows/program/expressions.js +20 -208
  379. package/dist/workflows/program/schema.js +7 -10
  380. package/dist/workflows/renderer.js +95 -68
  381. package/dist/workflows/resource-limits.js +2 -0
  382. package/dist/workflows/runtime/checkin.js +3 -3
  383. package/dist/workflows/runtime/plan-classifier.js +16 -75
  384. package/dist/workflows/runtime/runs.js +186 -127
  385. package/dist/workflows/runtime/unit-checkin.js +1 -1
  386. package/dist/workflows/runtime/unit-phases.js +2 -2
  387. package/dist/workflows/runtime/workflow-asset-loader.js +232 -83
  388. package/dist/workflows/schema.js +1 -11
  389. package/dist/workflows/validate-summary.js +30 -36
  390. package/dist/workflows/validator.js +21 -62
  391. package/docs/README.md +68 -0
  392. package/docs/migration/README.md +8 -0
  393. package/docs/migration/release-notes/0.7.0.md +11 -11
  394. package/docs/migration/release-notes/0.9.0.md +208 -27
  395. package/docs/migration/v0.7-to-v0.8.md +46 -47
  396. package/docs/migration/v0.8-to-v0.9.md +564 -208
  397. package/docs/migration/v0.9.0-troubleshooting.md +561 -0
  398. package/docs/reference/README.md +12 -0
  399. package/docs/reference/cli.md +2253 -0
  400. package/docs/reference/configuration.md +358 -0
  401. package/docs/reference/data-and-telemetry.md +105 -42
  402. package/docs/reference/workflows.md +647 -0
  403. package/package.json +22 -11
  404. package/schemas/akm-asset-envelope.json +93 -0
  405. package/schemas/akm-config.json +81 -128
  406. package/schemas/akm-workflow.json +74 -73
  407. package/dist/assets/tasks/core/backup.yml +0 -5
  408. package/dist/assets/tasks/graph-refresh-weekly.yml +0 -10
  409. package/dist/cli/config-migrate.js +0 -1806
  410. package/dist/cli/config-validate.js +0 -41
  411. package/dist/commands/backup-cli.js +0 -56
  412. package/dist/commands/bundle/bundle-cli.js +0 -68
  413. package/dist/commands/bundle/bundle.js +0 -219
  414. package/dist/commands/graph/graph-cli.js +0 -124
  415. package/dist/commands/graph/graph.js +0 -489
  416. package/dist/commands/improve/extract-watch.js +0 -140
  417. package/dist/commands/mv-cli.js +0 -1221
  418. package/dist/commands/sources/history.js +0 -201
  419. package/dist/commands/tasks/default-tasks.js +0 -186
  420. package/dist/core/migration-backup.js +0 -1234
  421. package/dist/indexer/usage/unmigrated-vaults-guard.js +0 -95
  422. package/dist/llm/memory-infer-impl.js +0 -138
  423. package/dist/migrate/legacy/config-source-migration.js +0 -223
  424. package/dist/migrate/legacy/content-migration.js +0 -305
  425. package/dist/migrate/legacy/legacy-layout.js +0 -779
  426. package/dist/migrate/legacy/legacy-paths.js +0 -25
  427. package/dist/migrate/legacy/legacy-stash-json.js +0 -72
  428. package/dist/migrate/legacy/proposal-fs-import.js +0 -168
  429. package/dist/migrate/legacy/task-target-ref-migration.js +0 -272
  430. package/dist/migrate/legacy/three-db-cutover.js +0 -841
  431. package/dist/migrate/legacy/workflow-migrations-bodies.js +0 -52
  432. package/dist/migrate/legacy/workflow-migrations-frozen.js +0 -21
  433. package/dist/migrate/legacy-ref-grammar.js +0 -214
  434. package/dist/output/shapes/distill.js +0 -14
  435. package/dist/output/shapes/history.js +0 -11
  436. package/dist/output/text/distill.js +0 -6
  437. package/dist/output/text/enable-disable.js +0 -8
  438. package/dist/output/text/history.js +0 -6
  439. package/dist/registry/build-index.js +0 -382
  440. package/dist/schemas/akm-config.json +0 -4704
  441. package/dist/schemas/akm-task.json +0 -87
  442. package/dist/schemas/akm-workflow.json +0 -372
  443. package/dist/scripts/migrate-storage.js +0 -3816
  444. package/dist/workflows/authoring/workflow-program-template.yaml +0 -31
  445. package/dist/workflows/cli.js +0 -53
  446. package/dist/workflows/exec/brief.js +0 -481
  447. package/dist/workflows/exec/report.js +0 -1460
  448. package/dist/workflows/exec/watch.js +0 -116
  449. package/dist/workflows/program/parser.js +0 -813
  450. package/dist/workflows/program/project.js +0 -104
@@ -21,17 +21,6 @@
21
21
  * is a status flip, and the full audit trail (review outcome, reason, backup
22
22
  * content for revert) lives on the row.
23
23
  *
24
- * ## Legacy filesystem import
25
- *
26
- * Before 0.9.0 proposals lived as per-uuid JSON directories under
27
- * `<stashDir>/.akm/proposals/`. That import used to run on EVERY proposal
28
- * operation here (through {@link withProposalsDb}, disk-probing the legacy tree
29
- * forever). It has been FOLDED OUT of the live path into the one-time migrator:
30
- * `akm migrate apply` runs it once as an additive step (see
31
- * `src/migrate/legacy/proposal-fs-import.ts`). Idempotency is now INSERT OR
32
- * IGNORE on the proposal UUID plus migrate-apply's own journal — no live-path
33
- * probe, no `proposal_fs_imports` ledger.
34
- *
35
24
  * # Why the queue bypasses `writeAssetToSource`
36
25
  *
37
26
  * The architectural rule "all writes go through `writeAssetToSource`" applies
@@ -45,27 +34,40 @@
45
34
  */
46
35
  import { createHash, randomUUID } from "node:crypto";
47
36
  import fs from "node:fs";
37
+ import os from "node:os";
48
38
  import path from "node:path";
39
+ import { parse as parseYaml } from "yaml";
40
+ import { adapterForId } from "../../core/adapter/registry.js";
41
+ import { createValidateContext } from "../../core/adapter/validate-context.js";
42
+ import { ensureAkmMarkdownType } from "../../core/asset/akm-markdown.js";
49
43
  import { assetPathForName, placementTypes, stashDirFor } from "../../core/asset/asset-placement.js";
50
- import { isBundleSlug } from "../../core/asset/asset-ref.js";
44
+ import { isBundleSlug, parseBundleRef } from "../../core/asset/asset-ref.js";
45
+ import { assembleAsset, serializeFrontmatter } from "../../core/asset/asset-serialize.js";
46
+ import { parseFrontmatter } from "../../core/asset/frontmatter.js";
51
47
  import { conceptIdFromTypeName, parseRefInput } from "../../core/asset/resolve-ref.js";
52
48
  import { isWithin } from "../../core/common.js";
53
49
  import { loadConfig } from "../../core/config/config.js";
54
- import { NotFoundError, UsageError } from "../../core/errors.js";
50
+ import { ConfigError, NotFoundError, UsageError } from "../../core/errors.js";
55
51
  import { appendEvent } from "../../core/events.js";
56
52
  import { proposalContent } from "../../core/file-change.js";
57
- import { _setTxnMutationHookForTests, advanceTxn, beginTxn, canonicalTxnRoot, cleanupTxn, fsyncTxnDir, fsyncTxnFile, listTxnJournals, mintTxnId, recoverTxnsForRoot, registerTxnKind, sweepJournallessTxnDir, txnDirFor, txnMutationHook, txnNamespaceDir, } from "../../core/fs-txn.js";
53
+ import { _setTxnMutationHookForTests, advanceTxn, beginTxn, canonicalTxnRoot, cleanupTxn, fsyncTxnDir, fsyncTxnFile, listTxnJournals, mintTxnId, registerTxnKind, sweepJournallessTxnDir, txnDirFor, txnMutationHook, txnNamespaceDir, } from "../../core/fs-txn.js";
54
+ import { canonicalBundleIdForTarget, resolveBundleWriteTarget } from "../../core/mutation-target.js";
58
55
  import { withImmediateTransaction, withStateDb } from "../../core/state-db.js";
59
56
  import { warn } from "../../core/warn.js";
60
- import { commitWriteTargetBoundary, resolveWritableTargets, resolveWriteTarget, } from "../../core/write-source.js";
57
+ import { assertAkmAssetWrite, assertWriteTargetPathsClean, captureGitPublication, captureWriteTargetPathSnapshot, prepareWriteTargetForMutation, publishWriteTargetTransaction, resolveWriteTarget, } from "../../core/write-source.js";
61
58
  import { withAssetMutationLease } from "../../indexer/index-writer-lock.js";
62
59
  import { indexWrittenAssets } from "../../indexer/index-written-assets.js";
63
- import { deriveInstallations, slugForPath } from "../../indexer/installations.js";
60
+ import { deriveInstallations } from "../../indexer/installations.js";
61
+ import { resolveSourceEntries } from "../../indexer/search/search-source.js";
64
62
  import { insertEventOnce } from "../../storage/repositories/events-repository.js";
65
- import { getStateProposal, listStateProposalIdsByPrefix, listStateProposals, upsertProposal, } from "../../storage/repositories/proposals-repository.js";
63
+ import { getStateProposal, getStateProposalLenient, listStateProposalIdsByPrefix, listStateProposals, upsertProposal, } from "../../storage/repositories/proposals-repository.js";
64
+ import { pkgVersion } from "../../version.js";
65
+ import { runBaseChecks } from "../lint/base-linter.js";
66
66
  import { formatNewAssetDiff, formatUnifiedDiff } from "./diff-format.js";
67
67
  import { isAutomatedProposalSource, isValidProposalSource, PROPOSAL_SOURCES, } from "./proposal-types.js";
68
+ import { hasCanonicalProposalValidator } from "./validators/proposal-validators.js";
68
69
  import { repairProposalContent, validateProposal } from "./validators/proposals.js";
70
+ const PROMOTION_LINT_BLOCKERS = new Set(["unquoted-colon", "missing-ref", "stale-path"]);
69
71
  // ── Proposal domain types (moved to ./proposal-types.ts, WI-9.8 KILL 1) ─────
70
72
  //
71
73
  // Proposal / ProposalStatus / ProposalPayload / ProposalReview /
@@ -73,8 +75,8 @@ import { repairProposalContent, validateProposal } from "./validators/proposals.
73
75
  // AUTOMATED_PROPOSAL_SOURCES / isValidProposalSource / isAutomatedProposalSource
74
76
  // moved to the dependency-free leaf so validators/proposals.ts,
75
77
  // validators/proposal-validators.ts, storage/repositories/proposals-repository.ts,
76
- // and legacy-import.ts can import the `Proposal` type without importing this
77
- // (much heavier) txn-engine module back that back-edge was the
78
+ // and storage repositories can import the `Proposal` type without importing
79
+ // this heavier transaction-engine module back. That back-edge was the
78
80
  // repository↔validators import cycle (plan §10.7 D.3). Every symbol this
79
81
  // module used to export directly is re-exported here verbatim so existing
80
82
  // import sites (`from "../proposal/repository.js"`) are unchanged.
@@ -92,7 +94,7 @@ function withProposalContent(p, content) {
92
94
  return {
93
95
  ...p,
94
96
  payload: { ...p.payload, content },
95
- changes: (p.changes ?? [{ path: "", op: "update" }]).map((c, i) =>
97
+ changes: p.changes.map((c, i) =>
96
98
  // A delete-op primary change carries no `after` (file-change.ts contract).
97
99
  i === 0 && c.op !== "delete" ? { ...c, after: content } : c),
98
100
  };
@@ -144,81 +146,138 @@ function newId(ctx) {
144
146
  * connection to `fn`, and close it in a `finally`. Every public function in
145
147
  * this module funnels its store access through here.
146
148
  *
147
- * The pre-0.9 filesystem-proposal import no longer runs here it was a per-op
148
- * disk probe of `<stashDir>/.akm/proposals/` and now runs once inside
149
- * `akm migrate apply` (`src/migrate/legacy/proposal-fs-import.ts`). `stashDir`
150
- * is still threaded through the public API for the store's per-stash partition.
149
+ * `stashDir` is threaded through the public API for the store's per-stash
150
+ * partition.
151
151
  */
152
152
  function withProposalsDb(_stashDir, ctx, fn) {
153
153
  return withStateDb(fn, { path: ctx?.dbPath });
154
154
  }
155
- /**
156
- * WI-8.5a the durable `proposals.ref` key in the final `bundle//conceptId`
157
- * item_ref grammar (D-R3). The conceptId is BUILT from the D-R2 static table
158
- * ({@link conceptIdFromTypeName} = `<stash-subdir>/<name>`), never looked up, so a
159
- * proposal targeting a not-yet-existing asset (no index entry) still keys onto
160
- * its final spelling. The bundle is the write-target stash's installation id —
161
- * the SAME `deriveInstallations` derivation the index write path uses
162
- * (`index-written-assets.ts`), so a proposal's ref matches the item_ref the
163
- * indexer would mint for the accepted asset byte-for-byte.
164
- *
165
- * A proposal carrying a slug-clean registry origin re-keys onto that bundle; a
166
- * non-slug registry origin (`github:owner/repo`, `npm:@scope/pkg`) cannot be
167
- * re-keyed without inventing a slug (D-R5) and keeps its legacy
168
- * `origin//type:name` spelling until the config `bundles` key lands. The
169
- * `local`/`stash` primary-stash sentinels resolve to the write-target bundle.
170
- */
171
- /**
172
- * The conceptId (`<stash-subdir>/<name>`) a STORED proposal ref maps to
173
- * (`undefined` when unparseable). TOLERANT by design: a single malformed durable
174
- * row must never crash a listing, so a parse failure here degrades to "no match"
175
- * rather than throwing. WI-8.5a stores `proposals.ref` as the item_ref, so a
176
- * user query ref (`lessons/x` / `bundle//lessons/x`) can no longer exact-match
177
- * the stored `bundle//lessons/x`; matching on the shared conceptId is the durable
178
- * read for the user-facing filter paths (`proposal list --ref`,
179
- * `resolveProposalId`). The internal fingerprint/backoff paths keep exact
180
- * `ref`-column matching (they compare the already-final `normalizedRef`), so old
181
- * legacy rejected rows aging out is the documented dedup-window reset, not a
182
- * lookup regression.
183
- *
184
- * USER-SUPPLIED filter refs go through {@link filterConceptId} instead — an
185
- * unparseable filter is a loud usage error, never a silent empty result.
186
- */
187
- function proposalConceptId(ref) {
155
+ function persistProposalUpdate(db, proposal, stashDir) {
156
+ // Only take the full re-serialize (upsertProposal -> proposalToRowValues)
157
+ // path when `changes` is well-formed enough to survive it a non-empty
158
+ // path on every entry, matching proposalToRowValues' own validation. A
159
+ // legacy row with a present `proposedTarget` but an empty/missing
160
+ // `changes[].path` (e.g. a malformed 0.8-era row) would otherwise die in
161
+ // proposalToRowValues; falling through to the targeted UPDATE below keeps
162
+ // `changes`/`proposedTarget` untouched in metadata_json and only flips the
163
+ // status/review/etc. fields the terminal transition actually needs.
164
+ const changesAreWellFormed = proposal.changes.length > 0 && proposal.changes.every((change) => change.path.length > 0);
165
+ if (changesAreWellFormed && proposal.proposedTarget) {
166
+ upsertProposal(db, proposal, stashDir);
167
+ return;
168
+ }
169
+ const row = db
170
+ .prepare("SELECT metadata_json FROM proposals WHERE id = ? AND stash_dir = ?")
171
+ .get(proposal.id, stashDir);
172
+ if (!row)
173
+ throw new NotFoundError(`Proposal "${proposal.id}" not found.`, "PROPOSAL_NOT_FOUND");
174
+ const metadata = JSON.parse(row.metadata_json);
175
+ for (const field of [
176
+ "sourceRun",
177
+ "beforeHash",
178
+ "review",
179
+ "confidence",
180
+ "gateDecision",
181
+ "backupContent",
182
+ "acceptedTarget",
183
+ "eligibilitySource",
184
+ ]) {
185
+ const value = proposal[field];
186
+ if (value === undefined)
187
+ delete metadata[field];
188
+ else
189
+ metadata[field] = value;
190
+ }
191
+ db.prepare(`UPDATE proposals
192
+ SET ref = ?, status = ?, source = ?, updated_at = ?, content = ?, frontmatter_json = ?, metadata_json = ?
193
+ WHERE id = ? AND stash_dir = ?`).run(proposal.ref, proposal.status, proposal.source, proposal.updatedAt, proposal.payload.content, proposal.payload.frontmatter ? JSON.stringify(proposal.payload.frontmatter) : null, JSON.stringify(metadata), proposal.id, stashDir);
194
+ }
195
+ function proposalRefIdentity(ref) {
188
196
  try {
189
- const p = parseRefInput(ref);
190
- return conceptIdFromTypeName(p.type, p.name);
197
+ const parsed = parseBundleRef(ref);
198
+ if (parsed.fragment !== undefined || isRetiredProposalConceptId(parsed.conceptId))
199
+ return undefined;
200
+ return {
201
+ conceptId: parsed.conceptId,
202
+ ...(parsed.bundle !== undefined ? { bundle: parsed.bundle } : {}),
203
+ };
191
204
  }
192
205
  catch {
193
206
  return undefined;
194
207
  }
195
208
  }
196
- /**
197
- * The conceptId a USER-SUPPLIED `--ref` / `idOrRef` filter maps to. Unlike the
198
- * tolerant {@link proposalConceptId} (which reads STORED rows), an unparseable
199
- * filter throws a typed {@link UsageError} rather than resolving to `undefined`
200
- * and silently matching nothing — an invalid filter should fail loudly, naming
201
- * the 0.9.0 grammar (D-R3: the legacy `type:name` grammar is gone). Delegates the
202
- * grammar to `parseRefInput`, so a legacy `skill:x` input surfaces the same loud
203
- * error as any other unparseable filter.
204
- */
205
- function filterConceptId(ref) {
209
+ function filterRefIdentity(ref) {
206
210
  try {
207
- const p = parseRefInput(ref);
208
- return conceptIdFromTypeName(p.type, p.name);
211
+ const p = parseBundleRef(ref);
212
+ if (p.fragment !== undefined || isRetiredProposalConceptId(p.conceptId)) {
213
+ throw new Error("not a current proposal identity");
214
+ }
215
+ return {
216
+ conceptId: p.conceptId,
217
+ ...(p.bundle !== undefined ? { bundle: p.bundle } : {}),
218
+ };
209
219
  }
210
220
  catch {
211
221
  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");
212
222
  }
213
223
  }
214
- function proposalDurableRef(parsedRef, stashDir) {
224
+ function isRetiredProposalConceptId(conceptId) {
225
+ const colon = conceptId.indexOf(":");
226
+ return colon > 0 && stashDirFor(conceptId.slice(0, colon)) !== undefined;
227
+ }
228
+ function proposalMatchesRef(proposalRef, filter) {
229
+ const proposal = proposalRefIdentity(proposalRef);
230
+ return (proposal !== undefined &&
231
+ proposal.conceptId === filter.conceptId &&
232
+ (filter.bundle === undefined || proposal.bundle === filter.bundle));
233
+ }
234
+ function proposalDurableRef(parsedRef, target) {
215
235
  const conceptId = conceptIdFromTypeName(parsedRef.type, parsedRef.name);
216
236
  const { origin } = parsedRef;
217
- if (origin !== undefined && origin !== "local" && origin !== "stash") {
218
- return isBundleSlug(origin) ? `${origin}//${conceptId}` : `${origin}//${parsedRef.type}:${parsedRef.name}`; // WI-8.5b: collapse (non-slug registry origin)
237
+ if (!isBundleSlug(target.source)) {
238
+ throw new UsageError(`Proposal target source "${target.source}" is not a valid bundle name.`, "INVALID_FLAG_VALUE");
219
239
  }
220
- const bundleId = deriveInstallations([{ path: stashDir, writable: true }])[0]?.id ?? slugForPath(stashDir);
221
- return `${bundleId}//${conceptId}`;
240
+ if (origin !== undefined) {
241
+ if (target.source !== origin) {
242
+ throw new UsageError(`Proposal ref bundle "${origin}" conflicts with target source "${target.source}".`, "INVALID_FLAG_VALUE");
243
+ }
244
+ return `${origin}//${conceptId}`;
245
+ }
246
+ return `${target.source}//${conceptId}`;
247
+ }
248
+ function resolveCreateProposalTarget(stashDir, explicit, bundle) {
249
+ if (explicit) {
250
+ if (!isBundleSlug(explicit.source) || !explicit.root.trim()) {
251
+ throw new UsageError("Proposal targets require a current bundle name and materialized root.", "INVALID_FLAG_VALUE");
252
+ }
253
+ return { source: explicit.source, root: path.resolve(explicit.root) };
254
+ }
255
+ const config = loadConfig();
256
+ const local = resolveProposalQueueTarget(stashDir, config);
257
+ if (!bundle || bundle === local.source)
258
+ return local;
259
+ if (bundle) {
260
+ const target = resolveBundleWriteTarget(config, bundle);
261
+ return { source: target.source.name, root: target.source.path };
262
+ }
263
+ return local;
264
+ }
265
+ export function resolveProposalQueueTarget(stashDir, config = loadConfig()) {
266
+ const root = path.resolve(stashDir);
267
+ const sources = resolveSourceEntries(root, config);
268
+ const sourceIndex = sources.findIndex((source) => path.resolve(source.path) === root);
269
+ const source = sources[sourceIndex];
270
+ const bundleId = sourceIndex >= 0 ? deriveInstallations(sources)[sourceIndex]?.id : undefined;
271
+ if (!source || !bundleId) {
272
+ throw new ConfigError(`No bundle owns proposal queue ${root}.`, "INVALID_CONFIG_FILE");
273
+ }
274
+ if (!source.registryId && Object.keys(config.bundles ?? {}).length > 0) {
275
+ throw new ConfigError(`No configured bundle owns proposal queue ${root}.`, "INVALID_CONFIG_FILE");
276
+ }
277
+ if (source.writable !== true) {
278
+ throw new UsageError(`Proposal bundle "${bundleId}" is not writable.`, "INVALID_FLAG_VALUE");
279
+ }
280
+ return { source: bundleId, root };
222
281
  }
223
282
  // ── Public API ──────────────────────────────────────────────────────────────
224
283
  /**
@@ -241,17 +300,12 @@ function proposalDurableRef(parsedRef, stashDir) {
241
300
  * instead of writing. Use {@link isProposalSkipped} to detect it.
242
301
  */
243
302
  export function createProposal(stashDir, input, ctx) {
244
- // F-4 / #385: Validate source against the allow-list. Unknown values are
245
- // warned (not rejected) for backward compatibility — extension callers
246
- // that pass custom source strings must not break.
247
303
  if (!isValidProposalSource(input.source)) {
248
304
  warn(`[proposal] Unknown source "${input.source}". ` +
249
305
  `Expected one of: ${PROPOSAL_SOURCES.join(", ")}. ` +
250
306
  "Typos in source values produce unaggregatable accept-rate-per-source metrics.");
251
307
  }
252
308
  else if (isAutomatedProposalSource(input.source) && !input.sourceRun) {
253
- // Advisory warning: automated sources should include sourceRun for PROV-DM
254
- // traceability. This is not a hard error to avoid breaking existing callers.
255
309
  warn(`[proposal] Automated source "${input.source}" created a proposal without sourceRun. ` +
256
310
  "Add sourceRun to enable accept-rate-per-run aggregation (W3C PROV-DM).");
257
311
  }
@@ -280,6 +334,9 @@ export function createProposal(stashDir, input, ctx) {
280
334
  if (!input.payload.content.trim()) {
281
335
  return rejectProposal("empty_content", `Proposal for "${input.ref}" has empty content.`);
282
336
  }
337
+ if (input.target && parsedRef.origin && input.target.source !== parsedRef.origin) {
338
+ return rejectProposal("invalid_ref", `Qualified proposal ref bundle "${parsedRef.origin}" conflicts with queue target "${input.target.source}".`);
339
+ }
283
340
  // Description check is only enforced for `consolidate` source — that's the
284
341
  // automated pipeline that historically produced proposals with missing or
285
342
  // malformed frontmatter, polluting the queue with hundreds of unusable
@@ -291,7 +348,9 @@ export function createProposal(stashDir, input, ctx) {
291
348
  return rejectProposal("missing_description", `Proposal for "${input.ref}" (source=consolidate) has empty or missing frontmatter description.`);
292
349
  }
293
350
  }
294
- const normalizedRef = proposalDurableRef(parsedRef, stashDir); // durable proposal.ref (WI-8.5a item_ref flip)
351
+ const proposalTarget = resolveCreateProposalTarget(stashDir, input.target, parsedRef.origin);
352
+ const normalizedRef = proposalDurableRef(parsedRef, proposalTarget);
353
+ const targetRoot = path.resolve(proposalTarget.root);
295
354
  // WI-6.2: derive the FileChange[] envelope + mint-time beforeHash. The
296
355
  // target is resolved against the proposal's OWN stash (a local snapshot —
297
356
  // accept re-resolves the write target from config at apply time), and only
@@ -300,9 +359,9 @@ export function createProposal(stashDir, input, ctx) {
300
359
  let targetRelPath;
301
360
  let mintBeforeContent;
302
361
  try {
303
- const typeRoot = path.join(stashDir, stashDirFor(parsedRef.type));
362
+ const typeRoot = path.join(targetRoot, stashDirFor(parsedRef.type));
304
363
  const targetAbs = assetPathForName(parsedRef.type, typeRoot, parsedRef.name);
305
- targetRelPath = path.relative(stashDir, targetAbs);
364
+ targetRelPath = path.relative(targetRoot, targetAbs);
306
365
  if (fs.existsSync(targetAbs))
307
366
  mintBeforeContent = fs.readFileSync(targetAbs, "utf8");
308
367
  }
@@ -310,14 +369,35 @@ export function createProposal(stashDir, input, ctx) {
310
369
  // Resolution failure degrades to a best-effort create — never blocks the mint.
311
370
  targetRelPath = path.join(stashDirFor(parsedRef.type), parsedRef.name);
312
371
  }
372
+ const proposalContent = targetRelPath.toLowerCase().endsWith(".md")
373
+ ? ensureAkmMarkdownType(input.payload.content, parsedRef.type)
374
+ : input.payload.content;
313
375
  const mintedChanges = [
314
376
  {
315
377
  path: targetRelPath,
316
- after: input.payload.content,
378
+ after: proposalContent,
317
379
  op: mintBeforeContent !== undefined ? "update" : "create",
318
380
  },
319
381
  ];
320
382
  const mintedBeforeHash = mintBeforeContent !== undefined ? contentHash(mintBeforeContent) : undefined;
383
+ if (hasCanonicalProposalValidator(parsedRef.type)) {
384
+ const report = validateProposal({
385
+ id: "pending",
386
+ ref: normalizedRef,
387
+ status: "pending",
388
+ source: input.source,
389
+ createdAt: "",
390
+ updatedAt: "",
391
+ payload: { ...input.payload, content: proposalContent },
392
+ changes: mintedChanges,
393
+ proposedTarget: { source: proposalTarget.source, root: targetRoot },
394
+ });
395
+ if (!report.ok) {
396
+ return rejectProposal("invalid_canonical_structure", `Proposal for "${input.ref}" has invalid ${parsedRef.type} structure:\n${report.findings
397
+ .map((finding) => `[${finding.kind}] ${finding.message}`)
398
+ .join("\n")}`);
399
+ }
400
+ }
321
401
  const fingerprint = computeProposalFingerprint({
322
402
  ref: normalizedRef,
323
403
  source: input.source,
@@ -350,10 +430,11 @@ export function createProposal(stashDir, input, ctx) {
350
430
  createdAt: created,
351
431
  updatedAt: created,
352
432
  payload: {
353
- content: input.payload.content,
433
+ content: proposalContent,
354
434
  ...(input.payload.frontmatter !== undefined ? { frontmatter: input.payload.frontmatter } : {}),
355
435
  },
356
436
  changes: mintedChanges,
437
+ proposedTarget: { source: proposalTarget.source, root: targetRoot },
357
438
  ...(mintedBeforeHash !== undefined ? { beforeHash: mintedBeforeHash } : {}),
358
439
  ...(sanitizedConfidence !== undefined ? { confidence: sanitizedConfidence } : {}),
359
440
  // Attribution tagging: persist the eligibility lane so it survives to
@@ -461,15 +542,15 @@ export function listProposals(stashDir, options = {}, ctx) {
461
542
  return [];
462
543
  }
463
544
  const status = options.includeArchive ? options.status : "pending";
464
- // WI-8.5a: the `ref` filter matches by conceptId (grammar-independent) so a
465
- // display/legacy query ref finds the item_ref-spelled row. Applied in JS, not
466
- // as a SQL `ref = ?`, since the stored spelling no longer equals the query ref.
467
- const wantConceptId = options.ref !== undefined ? filterConceptId(options.ref) : undefined;
545
+ // Short filters match by conceptId; qualified filters additionally retain
546
+ // bundle identity. Applied in JS because a short query does not equal the
547
+ // fully-qualified stored ref.
548
+ const wantRef = options.ref !== undefined ? filterRefIdentity(options.ref) : undefined;
468
549
  return listStateProposals(db, {
469
550
  stashDir,
470
551
  ...(status !== undefined ? { status } : {}),
471
552
  }).filter((p) => {
472
- if (options.ref !== undefined && (wantConceptId === undefined || proposalConceptId(p.ref) !== wantConceptId)) {
553
+ if (wantRef !== undefined && !proposalMatchesRef(p.ref, wantRef)) {
473
554
  return false;
474
555
  }
475
556
  if (!options.type)
@@ -493,16 +574,35 @@ export function getProposal(stashDir, id, ctx) {
493
574
  function requireProposal(db, stashDir, id) {
494
575
  const proposal = getStateProposal(db, id, stashDir);
495
576
  if (!proposal) {
496
- throw new NotFoundError(`Proposal "${id}" not found.`, "FILE_NOT_FOUND");
577
+ throw new NotFoundError(`Proposal "${id}" not found.`, "PROPOSAL_NOT_FOUND");
497
578
  }
498
579
  return proposal;
499
580
  }
581
+ /**
582
+ * Lenient counterpart to {@link requireProposal} / {@link getProposal}, used
583
+ * ONLY on the terminal-status (reject/archive) read paths — `archiveProposal`
584
+ * and the reject-transaction machinery (`rejectProposalDurably`,
585
+ * `finalizeRejectTransaction`). Those paths must succeed even when the row's
586
+ * metadata fails strict decoding (see {@link proposalRowToProposalLenient});
587
+ * every other reader (accept, revert, show, list's pending branch) keeps
588
+ * strict decoding via `getProposal`/`requireProposal` unchanged.
589
+ */
590
+ function requireProposalLenient(db, stashDir, id) {
591
+ const proposal = getStateProposalLenient(db, id, stashDir);
592
+ if (!proposal) {
593
+ throw new NotFoundError(`Proposal "${id}" not found.`, "PROPOSAL_NOT_FOUND");
594
+ }
595
+ return proposal;
596
+ }
597
+ function getProposalLenient(stashDir, id, ctx) {
598
+ return withProposalsDb(stashDir, ctx, (db) => requireProposalLenient(db, stashDir, id));
599
+ }
500
600
  /**
501
601
  * Resolve a proposal by full UUID, UUID prefix, or asset ref.
502
602
  *
503
603
  * Resolution order:
504
604
  * 1. Exact UUID match (existing behaviour).
505
- * 2. Asset ref (contains `:`) — finds the most-recent pending proposal for
605
+ * 2. Asset ref (contains `/`) — finds the most-recent pending proposal for
506
606
  * that ref; falls back to archived if nothing is pending.
507
607
  * 3. UUID prefix — matches any PENDING proposal whose id starts with the
508
608
  * given string; throws if ambiguous.
@@ -513,21 +613,19 @@ export function resolveProposalId(stashDir, idOrRef, ctx) {
513
613
  const exact = getStateProposal(db, idOrRef, stashDir);
514
614
  if (exact)
515
615
  return exact;
516
- // 2. Asset ref in EITHER grammar — most recent pending, else most recent
517
- // archived. WI-8.5a: match by conceptId (a UUID carries neither `:` nor `/`,
518
- // so both grammars legacy `skill:x` and new `skills/x` / `bundle//skills/x`
519
- // route here and match the item_ref-spelled stored row).
520
- const wantConceptId = idOrRef.includes(":") || idOrRef.includes("/") ? filterConceptId(idOrRef) : undefined;
521
- if (wantConceptId !== undefined) {
616
+ // 2. Asset ref — most recent pending, else most recent archived. Qualified
617
+ // refs retain bundle identity; short refs match by conceptId in this queue.
618
+ const wantRef = idOrRef.includes(":") || idOrRef.includes("/") ? filterRefIdentity(idOrRef) : undefined;
619
+ if (wantRef !== undefined) {
522
620
  const byRecency = (proposals) => proposals.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime())[0];
523
- const forConcept = (status) => listStateProposals(db, { stashDir, ...(status !== undefined ? { status } : {}) }).filter((p) => proposalConceptId(p.ref) === wantConceptId);
621
+ const forConcept = (status) => listStateProposals(db, { stashDir, ...(status !== undefined ? { status } : {}) }).filter((p) => proposalMatchesRef(p.ref, wantRef));
524
622
  const pending = byRecency(forConcept("pending"));
525
623
  if (pending)
526
624
  return pending;
527
625
  const archived = byRecency(forConcept());
528
626
  if (archived)
529
627
  return archived;
530
- throw new NotFoundError(`No proposal found for ref "${idOrRef}".`, "FILE_NOT_FOUND");
628
+ throw new NotFoundError(`No proposal found for ref "${idOrRef}".`, "PROPOSAL_NOT_FOUND");
531
629
  }
532
630
  // 3. UUID prefix (pending queue only).
533
631
  const prefixMatches = listStateProposalIdsByPrefix(db, stashDir, idOrRef);
@@ -536,18 +634,37 @@ export function resolveProposalId(stashDir, idOrRef, ctx) {
536
634
  if (prefixMatches.length > 1) {
537
635
  throw new UsageError(`Ambiguous prefix "${idOrRef}" — matches: ${prefixMatches.join(", ")}`, "INVALID_FLAG_VALUE");
538
636
  }
539
- throw new NotFoundError(`Proposal "${idOrRef}" not found.`, "FILE_NOT_FOUND");
637
+ throw new NotFoundError(`Proposal "${idOrRef}" not found.`, "PROPOSAL_NOT_FOUND");
540
638
  });
541
639
  }
640
+ /**
641
+ * Resolve a reject target's id, tolerating a legacy row whose metadata fails
642
+ * strict decoding. `akm proposal reject` only needs the id — but
643
+ * {@link resolveProposalId}'s exact-UUID fast path decodes the FULL proposal
644
+ * just to hand it back, which throws on those rows before the
645
+ * terminal-status machinery (which reads leniently — see
646
+ * `requireProposalLenient`) ever runs. Falls back to `resolveProposalId`
647
+ * unchanged for ref / uuid-prefix inputs, where existing resolution
648
+ * (including its error semantics) is preserved.
649
+ */
650
+ export function resolveProposalIdForReject(stashDir, idOrRef, ctx) {
651
+ const exact = withProposalsDb(stashDir, ctx, (db) => getStateProposalLenient(db, idOrRef, stashDir));
652
+ if (exact)
653
+ return exact.id;
654
+ return resolveProposalId(stashDir, idOrRef, ctx).id;
655
+ }
542
656
  /**
543
657
  * Archive a proposal: flip its status to `accepted` / `rejected`, bump
544
658
  * `updatedAt`, and record the review block. Used by both accept and reject
545
659
  * paths so the live queue only contains pending entries.
546
660
  */
547
- export function archiveProposal(stashDir, id, status, reason, ctx) {
661
+ export function archiveProposal(stashDir, id, status, reason, ctx, gateDecision) {
548
662
  return withProposalsDb(stashDir, ctx, (db) => {
549
663
  return withImmediateTransaction(db, () => {
550
- const existing = requireProposal(db, stashDir, id);
664
+ // Lenient: archiving (accept OR reject) a legacy row with malformed
665
+ // changes/proposedTarget metadata must still be able to flip status —
666
+ // see requireProposalLenient.
667
+ const existing = requireProposalLenient(db, stashDir, id);
551
668
  if (existing.status !== "pending") {
552
669
  throw new UsageError(`Proposal ${id} is not pending (current status: ${existing.status}). Only pending proposals can be ${status}.`, "INVALID_FLAG_VALUE");
553
670
  }
@@ -561,18 +678,18 @@ export function archiveProposal(stashDir, id, status, reason, ctx) {
561
678
  ...(reason !== undefined ? { reason } : {}),
562
679
  decidedAt,
563
680
  },
681
+ ...(gateDecision ? { gateDecision: { ...gateDecision, decidedAt: gateDecision.decidedAt ?? decidedAt } } : {}),
564
682
  };
565
- upsertProposal(db, updated, stashDir);
683
+ persistProposalUpdate(db, updated, stashDir);
566
684
  return updated;
567
685
  });
568
686
  });
569
687
  }
570
688
  /**
571
689
  * Record the drain/triage engine's decision onto a proposal (#577).
572
- * Drain-owned audit machinery — the deterministic drain engine is the only
573
- * live writer since the 0.9.0 confidence-gate deletion.
690
+ * Drain-owned audit machinery — the deterministic drain engine is the writer.
574
691
  *
575
- * Stamps `gateDecision` (decision / reason / confidence / thresholds) onto the
692
+ * Stamps `gateDecision` (decision / reason / measurement / thresholds) onto the
576
693
  * row so `akm proposal show` and `list` can explain why a proposal landed where
577
694
  * it did. The decision is metadata about the adjudication, so this does NOT
578
695
  * change `status` or bump `updatedAt` — a `deferred` proposal stays `pending`,
@@ -593,7 +710,7 @@ export function recordGateDecision(stashDir, id, decision, ctx) {
593
710
  ...existing,
594
711
  gateDecision: { ...decision, decidedAt: decision.decidedAt ?? nowIso(ctx) },
595
712
  };
596
- upsertProposal(db, updated, stashDir);
713
+ persistProposalUpdate(db, updated, stashDir);
597
714
  return updated;
598
715
  });
599
716
  });
@@ -822,7 +939,7 @@ function persistProposalTransactionState(txn, proposal, ctx) {
822
939
  const current = requireProposal(db, p.stashDir, p.proposalId);
823
940
  if (p.operation === "accept") {
824
941
  if (current.status === "accepted") {
825
- if (current.acceptedContentHash !== p.publishedHash) {
942
+ if (current.acceptedTarget?.contentHash !== p.publishedHash) {
826
943
  throw new Error(`Accepted proposal ${p.proposalId} does not match its recovery journal.`);
827
944
  }
828
945
  return current;
@@ -830,18 +947,34 @@ function persistProposalTransactionState(txn, proposal, ctx) {
830
947
  if (current.status !== "pending") {
831
948
  throw new Error(`Proposal ${p.proposalId} changed status during acceptance (${current.status}).`);
832
949
  }
950
+ const persistedProposal = proposal.changes.length > 0 && proposal.changes.every((change) => change.path.length > 0)
951
+ ? withProposalContent(proposal, publishedContent)
952
+ : {
953
+ ...proposal,
954
+ payload: { ...proposal.payload, content: publishedContent },
955
+ changes: [
956
+ {
957
+ path: path.relative(txn.journal.root, p.assetPath),
958
+ op: p.originalHash === null ? "create" : "update",
959
+ after: publishedContent,
960
+ },
961
+ ],
962
+ proposedTarget: { source: p.targetSource, root: txn.journal.root },
963
+ };
833
964
  const accepted = {
834
- ...withProposalContent(proposal, publishedContent),
965
+ ...persistedProposal,
835
966
  status: "accepted",
836
967
  updatedAt: decidedAt,
837
968
  review: { outcome: "accepted", decidedAt },
838
- acceptedContentHash: p.publishedHash,
839
969
  acceptedTarget: {
840
970
  source: p.targetSource,
841
971
  root: txn.journal.root,
842
972
  path: p.assetPath,
843
973
  contentHash: p.publishedHash,
844
974
  },
975
+ ...(p.gateDecision
976
+ ? { gateDecision: { ...p.gateDecision, decidedAt: p.gateDecision.decidedAt ?? decidedAt } }
977
+ : {}),
845
978
  ...(backupContent !== undefined ? { backupContent } : {}),
846
979
  };
847
980
  upsertProposal(db, accepted, p.stashDir);
@@ -894,15 +1027,26 @@ async function finalizeProposalTransaction(txn, target, proposal, ctx) {
894
1027
  if (txn.journal.phase === "asset-published") {
895
1028
  const commitRoot = target.source.repoPath ?? target.source.path;
896
1029
  const commitPath = path.relative(commitRoot, p.assetPath).replaceAll(path.sep, "/");
897
- commitWriteTargetBoundary(target, `${p.operation === "accept" ? "Update" : "Revert"} ${p.ref}`, {
1030
+ publishWriteTargetTransaction(target, p.gitPublication, {
1031
+ transactionId: txn.journal.transactionId,
1032
+ message: `${p.operation === "accept" ? "Update" : "Revert"} ${p.ref}`,
898
1033
  paths: [commitPath],
1034
+ snapshots: p.gitSnapshots ?? {},
1035
+ onCommitRecorded: (commit) => {
1036
+ // biome-ignore lint/style/noNonNullAssertion: publishWriteTargetTransaction throws when absent
1037
+ const publication = p.gitPublication;
1038
+ if (publication.commit !== commit) {
1039
+ publication.commit = commit;
1040
+ advanceTxn(txn, "asset-published");
1041
+ }
1042
+ },
899
1043
  });
900
1044
  persistProposalTransactionState(txn, proposal, ctx);
901
1045
  advanceTxn(txn, "proposal-persisted");
902
1046
  }
903
1047
  let accepted = getProposal(p.stashDir, p.proposalId, ctx);
904
1048
  if (txn.journal.phase === "proposal-persisted") {
905
- if (!(await indexWrittenAssets(txn.journal.root, [p.assetPath]))) {
1049
+ if (!(await indexWrittenAssets(txn.journal.root, [p.assetPath], { bundleId: target.source.name }))) {
906
1050
  throw new Error(`Proposal ${p.proposalId} index finalization failed.`);
907
1051
  }
908
1052
  advanceTxn(txn, "index-finalized");
@@ -919,12 +1063,15 @@ async function finalizeProposalTransaction(txn, target, proposal, ctx) {
919
1063
  }
920
1064
  /**
921
1065
  * Kind-level safety fence for a `proposal` journal, run before any recovery
922
- * action (mirrors the legacy per-engine fence; the engine fences root binding
923
- * and the uniform changes[] separately).
1066
+ * action. The engine fences root binding and the uniform changes[] separately.
924
1067
  */
925
1068
  function fenceProposalTxnJournal(journal, txnDir, root) {
926
1069
  const p = journal.payload;
1070
+ const refIdentity = proposalRefIdentity(p.ref);
927
1071
  if (!["accept", "revert"].includes(p.operation) ||
1072
+ !p.targetSource ||
1073
+ !p.targetKind ||
1074
+ refIdentity?.bundle === undefined ||
928
1075
  !isWithin(p.assetPath, root) ||
929
1076
  ![p.contentPath, p.backupPath]
930
1077
  .filter((candidate) => candidate !== null)
@@ -933,6 +1080,17 @@ function fenceProposalTxnJournal(journal, txnDir, root) {
933
1080
  throw new Error(`Refusing unsafe proposal transaction journal at ${path.join(txnDir, "journal.json")}.`);
934
1081
  }
935
1082
  }
1083
+ function resolveProposalRecoveryTarget(config, journal) {
1084
+ let target;
1085
+ try {
1086
+ target = resolveBundleWriteTarget(config, journal.payload.targetSource);
1087
+ }
1088
+ catch {
1089
+ throw new UsageError(`Proposal transaction ${journal.transactionId} target is no longer configured.`, "INVALID_FLAG_VALUE");
1090
+ }
1091
+ const bundleId = canonicalBundleIdForTarget(config, target);
1092
+ return { ...target, source: { ...target.source, name: bundleId } };
1093
+ }
936
1094
  async function recoverProposalTransactions(target, stashDir, ctx) {
937
1095
  const completed = new Map();
938
1096
  const nsDir = txnNamespaceDir(target.source.path);
@@ -954,13 +1112,17 @@ async function recoverProposalTransactions(target, stashDir, ctx) {
954
1112
  continue;
955
1113
  if (path.resolve(journal.payload.stashDir) !== path.resolve(stashDir))
956
1114
  continue;
957
- if (journal.version !== 1 || canonicalTxnRoot(journal.root) !== canonicalTxnRoot(target.source.path)) {
1115
+ if (journal.version !== 1 ||
1116
+ canonicalTxnRoot(journal.root) !== canonicalTxnRoot(target.source.path) ||
1117
+ journal.payload.targetSource !== target.source.name ||
1118
+ journal.payload.targetKind !== target.source.kind) {
958
1119
  throw new Error(`Refusing unsafe proposal transaction journal at ${journalPath}.`);
959
1120
  }
960
1121
  fenceProposalTxnJournal(journal, transactionDir, target.source.path);
961
1122
  const txn = { journal, journalPath, dir: transactionDir };
962
- if (journal.phase === "prepared")
1123
+ if (journal.phase === "prepared") {
963
1124
  rollbackPreparedProposalTransaction(txn);
1125
+ }
964
1126
  else if (journal.phase !== "committed") {
965
1127
  const proposal = getProposal(stashDir, journal.payload.proposalId, ctx);
966
1128
  completed.set(journal.payload.proposalId, await finalizeProposalTransaction(txn, target, proposal, ctx));
@@ -984,20 +1146,17 @@ export async function recoverProposalTransactionsForStash(stashDir, config, ctx,
984
1146
  }
985
1147
  const recoveredRoots = new Set();
986
1148
  for (const journal of matches) {
987
- let target;
988
- try {
989
- target = resolveWriteTarget(config, journal.payload.targetSource);
990
- }
991
- catch {
992
- target = resolveWriteTarget(config);
993
- }
994
- if (canonicalTxnRoot(target.source.path) !== canonicalTxnRoot(journal.root)) {
1149
+ let target = resolveProposalRecoveryTarget(config, journal);
1150
+ const requiresGitPublication = matches.some((candidate) => candidate.phase === "asset-published" && canonicalTxnRoot(candidate.root) === canonicalTxnRoot(journal.root));
1151
+ if (requiresGitPublication)
1152
+ target = prepareWriteTargetForMutation(target, { allowAhead: true });
1153
+ if (canonicalTxnRoot(target.source.path) !== canonicalTxnRoot(journal.root) ||
1154
+ journal.payload.targetKind !== target.source.kind) {
995
1155
  throw new Error(`Proposal transaction ${journal.transactionId} is bound to a different target root.`);
996
1156
  }
997
1157
  const key = path.resolve(target.source.path);
998
1158
  if (recoveredRoots.has(key))
999
1159
  continue;
1000
- await recoverTxnsForRoot(target.source.path, (journal) => journal.kind === "mv");
1001
1160
  const recovered = await recoverProposalTransactions(target, stashDir, ctx);
1002
1161
  for (const [id, proposal] of recovered)
1003
1162
  completed.set(id, proposal);
@@ -1010,13 +1169,12 @@ const REJECT_TXN_PHASES = ["prepared", "state-persisted", "event-finalized", "co
1010
1169
  function finalizeRejectTransaction(txn, ctx) {
1011
1170
  const p = txn.journal.payload;
1012
1171
  const decidedAt = txn.journal.decidedAt;
1013
- let proposal = getProposal(p.stashDir, p.proposalId, ctx);
1172
+ // Lenient: a reject transaction must run to completion even on a legacy
1173
+ // row whose metadata fails strict decoding — see requireProposalLenient.
1174
+ let proposal = getProposalLenient(p.stashDir, p.proposalId, ctx);
1014
1175
  if (txn.journal.phase === "prepared") {
1015
1176
  if (proposal.status === "pending") {
1016
- proposal = archiveProposal(p.stashDir, p.proposalId, "rejected", p.reason, {
1017
- ...ctx,
1018
- now: () => Date.parse(decidedAt),
1019
- });
1177
+ proposal = archiveProposal(p.stashDir, p.proposalId, "rejected", p.reason, { ...ctx, now: () => Date.parse(decidedAt) }, p.gateDecision);
1020
1178
  }
1021
1179
  else if (proposal.status !== "rejected") {
1022
1180
  throw new Error(`Proposal ${p.proposalId} changed status during rejection (${proposal.status}).`);
@@ -1025,7 +1183,7 @@ function finalizeRejectTransaction(txn, ctx) {
1025
1183
  txnMutationHook("reject-state-persisted");
1026
1184
  }
1027
1185
  if (txn.journal.phase === "state-persisted") {
1028
- proposal = getProposal(p.stashDir, p.proposalId, ctx);
1186
+ proposal = getProposalLenient(p.stashDir, p.proposalId, ctx);
1029
1187
  const eventRef = proposal.ref;
1030
1188
  const eventMeta = {
1031
1189
  proposalId: proposal.id,
@@ -1075,11 +1233,13 @@ function recoverRejectTransaction(stashDir, proposalId, ctx) {
1075
1233
  }
1076
1234
  return undefined;
1077
1235
  }
1078
- export function rejectProposalDurably(stashDir, proposalId, reason, ctx) {
1236
+ export function rejectProposalDurably(stashDir, proposalId, reason, ctx, gateDecision) {
1079
1237
  const recovered = recoverRejectTransaction(stashDir, proposalId, ctx);
1080
1238
  if (recovered)
1081
1239
  return recovered;
1082
- const proposal = getProposal(stashDir, proposalId, ctx);
1240
+ // Lenient: see requireProposalLenient the reject decision itself must not
1241
+ // be blocked by a legacy row's malformed changes/proposedTarget metadata.
1242
+ const proposal = getProposalLenient(stashDir, proposalId, ctx);
1083
1243
  if (proposal.status !== "pending") {
1084
1244
  throw new UsageError(`Proposal ${proposalId} is not pending (current status: ${proposal.status}). Only pending proposals can be rejected.`, "INVALID_FLAG_VALUE");
1085
1245
  }
@@ -1087,7 +1247,12 @@ export function rejectProposalDurably(stashDir, proposalId, reason, ctx) {
1087
1247
  kind: REJECT_TXN_KIND,
1088
1248
  root: stashDir,
1089
1249
  changes: [],
1090
- payload: { proposalId, stashDir, ...(reason !== undefined ? { reason } : {}) },
1250
+ payload: {
1251
+ proposalId,
1252
+ stashDir,
1253
+ ...(reason !== undefined ? { reason } : {}),
1254
+ ...(gateDecision ? { gateDecision } : {}),
1255
+ },
1091
1256
  decidedAt: nowIso(ctx),
1092
1257
  });
1093
1258
  const rejected = finalizeRejectTransaction(txn, ctx);
@@ -1095,6 +1260,8 @@ export function rejectProposalDurably(stashDir, proposalId, reason, ctx) {
1095
1260
  return rejected;
1096
1261
  }
1097
1262
  function prepareProposalTransaction(stashDir, target, proposal, ref, content, options, ctx) {
1263
+ if (options.operation === "accept")
1264
+ assertAkmAssetWrite(target.source);
1098
1265
  const assetPath = resolveAssetFilePathSafe(target.source, ref);
1099
1266
  if (!assetPath)
1100
1267
  throw new Error(`Cannot resolve proposal target ${proposal.ref}.`);
@@ -1105,6 +1272,7 @@ function prepareProposalTransaction(stashDir, target, proposal, ref, content, op
1105
1272
  // and the initial `prepared` journal must be written exactly ONCE with its
1106
1273
  // final contents (crash runners intercept the first rename per phase).
1107
1274
  const transactionId = mintTxnId();
1275
+ const gitPublication = captureGitPublication(target);
1108
1276
  const transactionDir = txnDirFor(target.source.path, transactionId);
1109
1277
  fs.mkdirSync(transactionDir, { recursive: true, mode: 0o700 });
1110
1278
  const contentPath = path.join(transactionDir, "published-content");
@@ -1136,6 +1304,7 @@ function prepareProposalTransaction(stashDir, target, proposal, ref, content, op
1136
1304
  proposalId: proposal.id,
1137
1305
  stashDir,
1138
1306
  targetSource: target.source.name,
1307
+ targetKind: target.source.kind,
1139
1308
  assetPath,
1140
1309
  ref: proposal.ref,
1141
1310
  contentPath,
@@ -1144,7 +1313,9 @@ function prepareProposalTransaction(stashDir, target, proposal, ref, content, op
1144
1313
  backupPath: persistedBackupPath,
1145
1314
  originalHash: options.originalHash,
1146
1315
  publishedHash,
1316
+ ...(gitPublication ? { gitPublication } : {}),
1147
1317
  ...(options.eventMetadata ? { eventMetadata: options.eventMetadata } : {}),
1318
+ ...(options.gateDecision ? { gateDecision: options.gateDecision } : {}),
1148
1319
  },
1149
1320
  decidedAt: nowIso(ctx),
1150
1321
  });
@@ -1161,7 +1332,7 @@ function prepareProposalTransaction(stashDir, target, proposal, ref, content, op
1161
1332
  }
1162
1333
  return txn;
1163
1334
  }
1164
- function publishProposalAsset(txn) {
1335
+ function publishProposalAsset(txn, target) {
1165
1336
  const p = txn.journal.payload;
1166
1337
  try {
1167
1338
  if (p.originalHash !== null) {
@@ -1173,6 +1344,9 @@ function publishProposalAsset(txn) {
1173
1344
  }
1174
1345
  fs.linkSync(p.publishPath, p.assetPath);
1175
1346
  fsyncTxnDir(path.dirname(p.assetPath));
1347
+ const snapshot = captureWriteTargetPathSnapshot(target, p.assetPath);
1348
+ if (snapshot)
1349
+ p.gitSnapshots = { [snapshot.path]: snapshot.state };
1176
1350
  advanceTxn(txn, "asset-published");
1177
1351
  }
1178
1352
  catch (error) {
@@ -1181,6 +1355,175 @@ function publishProposalAsset(txn) {
1181
1355
  throw error;
1182
1356
  }
1183
1357
  }
1358
+ function resolveRecordedProposalTarget(config, proposalId, binding, explicitTarget) {
1359
+ let target;
1360
+ try {
1361
+ target = explicitTarget
1362
+ ? resolveWriteTarget(config, explicitTarget)
1363
+ : resolveBundleWriteTarget(config, binding.source);
1364
+ }
1365
+ catch {
1366
+ throw new UsageError(`Proposal ${proposalId} is bound to target "${binding.source}" at ${binding.root}, but that writable target is no longer configured.`, "INVALID_FLAG_VALUE");
1367
+ }
1368
+ const targetBundleId = canonicalBundleIdForTarget(config, target);
1369
+ if (targetBundleId !== binding.source || path.resolve(target.source.path) !== path.resolve(binding.root)) {
1370
+ throw new UsageError(`Proposal ${proposalId} is bound to target "${binding.source}" at ${binding.root}; ` +
1371
+ `--target "${explicitTarget}" resolves to "${targetBundleId}" at ${target.source.path}.`, "INVALID_FLAG_VALUE");
1372
+ }
1373
+ return { ...target, source: { ...target.source, name: targetBundleId } };
1374
+ }
1375
+ function resolveProposalWriteTarget(config, proposal, explicitTarget, queueTarget) {
1376
+ if (!proposal.proposedTarget) {
1377
+ const identity = proposalRefIdentity(proposal.ref);
1378
+ if (!identity)
1379
+ throw new UsageError(`Proposal ${proposal.id} has an invalid ref.`, "INVALID_PROPOSAL");
1380
+ if (identity.bundle !== undefined) {
1381
+ const target = resolveBundleWriteTarget(config, identity.bundle);
1382
+ const targetBundleId = canonicalBundleIdForTarget(config, target);
1383
+ if (explicitTarget !== undefined) {
1384
+ const explicit = resolveWriteTarget(config, explicitTarget);
1385
+ if (canonicalBundleIdForTarget(config, explicit) !== identity.bundle) {
1386
+ throw new UsageError(`Proposal ${proposal.id} ref is bound to bundle "${identity.bundle}", which conflicts with --target "${explicitTarget}".`, "INVALID_FLAG_VALUE");
1387
+ }
1388
+ }
1389
+ if (queueTarget && canonicalBundleIdForTarget(config, queueTarget) !== identity.bundle) {
1390
+ throw new UsageError(`Proposal ${proposal.id} is bound to a different queue target.`, "INVALID_FLAG_VALUE");
1391
+ }
1392
+ return { ...target, source: { ...target.source, name: targetBundleId } };
1393
+ }
1394
+ const target = explicitTarget ? resolveWriteTarget(config, explicitTarget) : queueTarget;
1395
+ if (!target) {
1396
+ throw new UsageError(`Unbound short proposal ${proposal.id} requires an explicit --target or authenticated --queue context.`, "INVALID_PROPOSAL");
1397
+ }
1398
+ const targetBundleId = canonicalBundleIdForTarget(config, target);
1399
+ return { ...target, source: { ...target.source, name: targetBundleId } };
1400
+ }
1401
+ if (queueTarget && explicitTarget === undefined) {
1402
+ const queueBundleId = canonicalBundleIdForTarget(config, queueTarget);
1403
+ if (queueBundleId !== proposal.proposedTarget.source ||
1404
+ path.resolve(queueTarget.source.path) !== path.resolve(proposal.proposedTarget.root)) {
1405
+ throw new UsageError(`Proposal ${proposal.id} is bound to a different queue target.`, "INVALID_FLAG_VALUE");
1406
+ }
1407
+ }
1408
+ return resolveRecordedProposalTarget(config, proposal.id, proposal.proposedTarget, explicitTarget);
1409
+ }
1410
+ // ── D2 (#730) — OKF v0.2 provenance stamping on promotion ───────────────────
1411
+ //
1412
+ // The proposals system already tracks exactly what OKF v0.2 wants on disk —
1413
+ // `source`/`sourceRun` (PROV-DM modeled, `proposal-types.ts` §80-120),
1414
+ // `gateDecision` (`:200-237`), `review` (`:170-174`) — but none of it leaves
1415
+ // state.db. This section projects it onto the written asset's frontmatter at
1416
+ // promotion time, AKM-native assets only (an OKF-adapter target never reaches
1417
+ // this function — `assertAkmAssetWrite` rejects it earlier in
1418
+ // `promoteProposalWithLease`, before any of this runs).
1419
+ //
1420
+ // Two DISTINCT actors, deliberately not conflated (documented judgment call —
1421
+ // see the PR body for the alternative considered and rejected):
1422
+ // - `generated.by` answers "what produced the CONTENT" — keyed on
1423
+ // `isAutomatedProposalSource(proposal.source)`: an automated pipeline
1424
+ // (reflect/distill/consolidate/extract/improve/schema-repair) stamps
1425
+ // `akm/<pkgVersion>`; a human-initiated source (propose/remember/import)
1426
+ // or the semi-automated `feedback` source stamps `human:<actorId>`.
1427
+ // - `verified[0].by` answers "what accepted/reviewed THIS promotion" —
1428
+ // keyed on whether a `gateDecision` was supplied to THIS call: present
1429
+ // (the automated drain/triage path decided) stamps `akm/<pkgVersion>`;
1430
+ // absent (a human explicitly ran `akm proposal accept`) stamps
1431
+ // `human:<actorId>`. Every promotion reaches this function via exactly
1432
+ // one of those two paths, so `verified` is always stamped — there is no
1433
+ // third, unreviewed path to disk.
1434
+ /** 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. */
1435
+ function resolveActorId(ctx) {
1436
+ if (ctx?.actorId)
1437
+ return ctx.actorId();
1438
+ try {
1439
+ const username = os.userInfo().username?.trim();
1440
+ return username ? username : "local";
1441
+ }
1442
+ catch {
1443
+ return "local";
1444
+ }
1445
+ }
1446
+ /** `generated.by` — keyed on the SOURCE that produced the content (see file-header note above). */
1447
+ function generatedByActor(proposal, ctx) {
1448
+ return isAutomatedProposalSource(proposal.source) ? `akm/${pkgVersion}` : `human:${resolveActorId(ctx)}`;
1449
+ }
1450
+ /** `verified[0].by` — keyed on whether THIS promotion was gated (automated) or a direct human accept (see file-header note above). */
1451
+ function verifiedByActor(gateDecision, ctx) {
1452
+ return gateDecision !== undefined ? `akm/${pkgVersion}` : `human:${resolveActorId(ctx)}`;
1453
+ }
1454
+ /** True for a plain (non-null, non-array) object. */
1455
+ function isPlainRecord(value) {
1456
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1457
+ }
1458
+ /**
1459
+ * Stamp OKF v0.2 provenance onto ONE promoted asset's frontmatter (D2.1/D2.2/
1460
+ * D2.3), in the **hybrid** on-disk shape settled by the #730 review:
1461
+ *
1462
+ * - `generated: {by, at}` and `verified: [{by, at}]` are written **bare at the
1463
+ * top level**, exactly as OKF v0.2 spells them (SPEC §5.2/§5.3). Neither key
1464
+ * has any pre-existing AKM consumer, so spelling them the spec's way costs
1465
+ * nothing and makes `okf-support.md`'s "AKM Markdown is an OKF-compatible
1466
+ * superset" positioning actually true for trust metadata: a third-party OKF
1467
+ * v0.2 reader pointed at an AKM stash sees conformant provenance.
1468
+ * - `sources` stays namespaced under `provenance:`, because a bare top-level
1469
+ * `sources:` genuinely collides with the pre-existing AKM-native wiki
1470
+ * citation-**string** convention
1471
+ * (`indexer/passes/metadata.ts#applyWikiFrontmatter`, which silently drops
1472
+ * non-strings) on a promoted wiki page.
1473
+ *
1474
+ * The read side back into `IndexDocument.provenance` is
1475
+ * `metadata.ts#applyProvenanceFrontmatter` (which accepts both this shape and
1476
+ * the older fully-nested one), carried through `akm-adapter.ts`'s
1477
+ * `DOCUMENT_JSON_CARRIED_FIELDS` (D2.4).
1478
+ *
1479
+ * A no-op frontmatter mutation preserves the existing frontmatter block's raw
1480
+ * body bytes (mirrors `frontmatter.ts#mutateFrontmatter`'s documented
1481
+ * contract) rather than reshaping via `assembleAsset`, which would strip
1482
+ * leading body blank lines / force a trailing newline. A file with no
1483
+ * frontmatter block at all (non-conformant input) gains one via
1484
+ * `assembleAsset`, exactly as any other first-frontmatter write would.
1485
+ */
1486
+ function stampProposalProvenance(content, proposal, gateDecision, ctx, nowIsoStr) {
1487
+ const parsed = parseFrontmatter(content);
1488
+ const fm = { ...parsed.data };
1489
+ const existingProvenance = isPlainRecord(fm.provenance) ? fm.provenance : {};
1490
+ // Bare `generated:` — OKF v0.2's replacement for `timestamp` (SPEC §13).
1491
+ fm.generated = { by: generatedByActor(proposal, ctx), at: nowIsoStr };
1492
+ // Bare `verified:` — append, so independent confirmations accumulate rather
1493
+ // than the newest overwriting the record. Both the bare list and the older
1494
+ // nested spelling are absorbed, so a re-promotion never loses history.
1495
+ const priorVerified = Array.isArray(fm.verified)
1496
+ ? fm.verified
1497
+ : Array.isArray(existingProvenance.verified)
1498
+ ? existingProvenance.verified
1499
+ : [];
1500
+ fm.verified = [
1501
+ ...priorVerified,
1502
+ { by: verifiedByActor(gateDecision, ctx), at: gateDecision?.decidedAt ?? nowIsoStr },
1503
+ ];
1504
+ // `sources` alone stays namespaced — bare `sources:` is the wiki
1505
+ // citation-string convention. Drop the nested provenance block entirely when
1506
+ // it would otherwise be empty, so unrelated assets gain no dead key.
1507
+ const provenance = { ...existingProvenance };
1508
+ delete provenance.generatedBy;
1509
+ delete provenance.generatedAt;
1510
+ delete provenance.verified;
1511
+ const evidenceSources = fm.evidenceSources;
1512
+ if (Array.isArray(evidenceSources)) {
1513
+ const sources = evidenceSources
1514
+ .filter((s) => typeof s === "string" && s.trim().length > 0)
1515
+ .map((resource) => ({ resource: resource.trim() }));
1516
+ if (sources.length > 0)
1517
+ provenance.sources = sources;
1518
+ }
1519
+ if (Object.keys(provenance).length > 0)
1520
+ fm.provenance = provenance;
1521
+ else
1522
+ delete fm.provenance;
1523
+ return parsed.frontmatter !== null
1524
+ ? `---\n${serializeFrontmatter(fm)}\n---\n${parsed.content}`
1525
+ : assembleAsset(fm, parsed.content);
1526
+ }
1184
1527
  /**
1185
1528
  * Validate a proposal, then promote it through the canonical
1186
1529
  * {@link writeAssetToSource} dispatch (the single place that branches on
@@ -1196,47 +1539,171 @@ function publishProposalAsset(txn) {
1196
1539
  export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
1197
1540
  return withAssetMutationLease("proposal-accept", () => promoteProposalWithLease(stashDir, config, id, options, ctx));
1198
1541
  }
1542
+ function promotionLintBlockers(raw, assetPath, targetRoot, refType, config) {
1543
+ let data;
1544
+ let body;
1545
+ let frontmatter;
1546
+ if (refType === "task") {
1547
+ try {
1548
+ const parsed = parseYaml(raw);
1549
+ data = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
1550
+ }
1551
+ catch {
1552
+ data = {};
1553
+ }
1554
+ body = raw;
1555
+ frontmatter = null;
1556
+ }
1557
+ else {
1558
+ ({ data, content: body, frontmatter } = parseFrontmatter(raw));
1559
+ }
1560
+ const resolvedRoot = path.resolve(targetRoot);
1561
+ const extraStashRoots = resolveSourceEntries(targetRoot, config)
1562
+ .map((source) => source.path)
1563
+ .filter((sourcePath) => path.resolve(sourcePath) !== resolvedRoot);
1564
+ return runBaseChecks({
1565
+ filePath: assetPath,
1566
+ relPath: path.relative(targetRoot, assetPath),
1567
+ raw,
1568
+ data,
1569
+ body,
1570
+ frontmatter,
1571
+ fix: false,
1572
+ stashRoot: targetRoot,
1573
+ extraStashRoots,
1574
+ }).filter((finding) => PROMOTION_LINT_BLOCKERS.has(finding.issue));
1575
+ }
1576
+ /** Build and validate the exact stamped bytes promotion would publish, without writing. */
1577
+ export function preflightProposalPromotion(config, proposal, options = {}, ctx) {
1578
+ const repairedContent = repairProposalContent(proposalContent(proposal));
1579
+ const preparedProposal = repairedContent === proposalContent(proposal) ? proposal : withProposalContent(proposal, repairedContent);
1580
+ const report = validateProposal(preparedProposal);
1581
+ if (!report.ok) {
1582
+ const message = report.findings.map((finding) => `[${finding.kind}] ${finding.message}`).join("\n");
1583
+ 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.");
1584
+ }
1585
+ const ref = parseRefInput(preparedProposal.ref);
1586
+ if (!stashDirFor(ref.type)) {
1587
+ throw new UsageError(`Proposal ${proposal.id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
1588
+ }
1589
+ const target = resolveProposalWriteTarget(config, preparedProposal, options.target, options.queueTarget);
1590
+ const assetPath = resolveAssetFilePathSafe(target.source, ref);
1591
+ if (!assetPath)
1592
+ throw new UsageError(`Cannot resolve proposal target ${preparedProposal.ref}.`, "INVALID_PROPOSAL");
1593
+ assertAkmAssetWrite(target.source);
1594
+ const stampedContent = assetPath.toLowerCase().endsWith(".md")
1595
+ ? stampProposalProvenance(repairedContent, preparedProposal, options.gateDecision, ctx, nowIso(ctx))
1596
+ : repairedContent;
1597
+ const lintBlockers = promotionLintBlockers(stampedContent, assetPath, target.source.path, ref.type, config);
1598
+ if (lintBlockers.length > 0) {
1599
+ const message = lintBlockers.map((finding) => `[${finding.issue}] ${finding.detail}`).join("\n");
1600
+ throw new UsageError(`Proposal ${proposal.id} failed lint:\n${message}`, "INVALID_PROPOSAL", "Fix or explicitly suppress the reported lint findings, then retry.");
1601
+ }
1602
+ return { proposal: preparedProposal, repairedContent, ref, target, assetPath, stampedContent };
1603
+ }
1604
+ /**
1605
+ * The change-transaction pre-commit gate — the `BundleAdapter.validate()`
1606
+ * interface contract's OTHER stated consumer (`core/adapter/bundle-adapter.ts`
1607
+ * doc comment, alongside `lint --fix`). Runs the target's OWN adapter's
1608
+ * `validate()` over the ONE pending write `preflight` describes, with a
1609
+ * {@link createValidateContext} overlay carrying the proposal's about-to-be-
1610
+ * written bytes — so the adapter sees the bundle AS IT WOULD LOOK the instant
1611
+ * after this transaction commits, without ever touching disk.
1612
+ *
1613
+ * DELIBERATELY ADVISORY, not blocking (see the report for the full
1614
+ * rationale): the akm adapter's `missing-ref` check resolves prose refs
1615
+ * through the SAME core overlay `resolveRef` that closes the OKF/llm-wiki
1616
+ * lint gaps — and that resolver is proven to disagree with the legacy
1617
+ * `commands/lint/base-linter.ts#checkMissingRefs` resolver in one specific,
1618
+ * real case: a fully-qualified `bundle//conceptId` prose ref (or a bare
1619
+ * frontmatter xref) whose leading segment does NOT name a registered AKM
1620
+ * placement type. The legacy resolver treats an unrecognized type prefix as
1621
+ * "not a locally-checkable ref, skip it, never flag missing" (whole-hearted
1622
+ * leniency for cross-bundle / foreign-format refs); this module's core
1623
+ * resolver additionally tries the ref as a literal on-disk path — the
1624
+ * resolution non-akm adapters (OKF, llm-wiki) actually NEED for their own
1625
+ * same-component conceptIds — which means it CAN report `missing-ref` for a
1626
+ * foreign-typed prose ref the legacy checker always let through. Promoting a
1627
+ * proposal is a live, user-facing write path; blocking it on a diagnostic
1628
+ * that can disagree with the existing (already-tested, already-run)
1629
+ * `promotionLintBlockers` gate a few lines above is not a change to make
1630
+ * without a dedicated equivalence pass first. So: this computes and surfaces
1631
+ * the finding (visible via `warn`, and never thrown) without changing whether
1632
+ * ANY promotion succeeds or fails — proving the wiring end-to-end on real
1633
+ * proposal data while leaving today's blocking behavior completely
1634
+ * untouched. Never throws: a validate() failure here must not corrupt or
1635
+ * half-apply the transaction that follows.
1636
+ */
1637
+ async function runAdapterPreCommitCheck(config, preflight) {
1638
+ try {
1639
+ const adapterId = preflight.target.source.adapterId ?? "akm";
1640
+ const adapter = adapterForId(adapterId);
1641
+ if (!adapter)
1642
+ return;
1643
+ const root = preflight.target.source.path;
1644
+ const relPath = path.relative(root, preflight.assetPath).replace(/\\/g, "/");
1645
+ if (!relPath || relPath.startsWith(".."))
1646
+ return; // resolved outside its own bundle root — nothing to check
1647
+ const change = {
1648
+ path: relPath,
1649
+ after: preflight.stampedContent,
1650
+ op: fs.existsSync(preflight.assetPath) ? "update" : "create",
1651
+ };
1652
+ const extraRoots = resolveSourceEntries(root, config)
1653
+ .map((source) => source.path)
1654
+ .filter((sourcePath) => path.resolve(sourcePath) !== path.resolve(root));
1655
+ const componentCtx = createValidateContext({ root, extraRoots, changes: [change] });
1656
+ const diagnostics = await adapter.validate({ id: preflight.target.selector ?? adapterId, adapter: adapterId, root, writable: true }, [change], componentCtx);
1657
+ if (diagnostics.length > 0) {
1658
+ const summary = diagnostics.map((d) => `[${d.issue}] ${d.detail}`).join("; ");
1659
+ warn(`[proposal] pre-commit adapter check for ${preflight.proposal.id} found (non-blocking): ${summary}`);
1660
+ }
1661
+ }
1662
+ catch (error) {
1663
+ // Advisory only — never let a validate() failure interrupt or corrupt the
1664
+ // promotion transaction that follows.
1665
+ warn(`[proposal] pre-commit adapter check for ${preflight.proposal.id} threw (ignored, non-blocking): ${error instanceof Error ? error.message : String(error)}`);
1666
+ }
1667
+ }
1199
1668
  async function promoteProposalWithLease(stashDir, config, id, options, ctx) {
1669
+ recoverRejectTransaction(stashDir, id, ctx);
1200
1670
  let proposal = getProposal(stashDir, id, ctx);
1201
- // Attempt bounded auto-repair of mechanically-fixable structural defects
1202
- // (pseudo-frontmatter-in-body, stray `---` fences, truncated description)
1203
- // BEFORE running validation. If the repair produces valid content, we
1204
- // promote the repaired version; if validation still fails, the original
1205
- // error path throws as before. The repair is content-preserving and
1206
- // deterministic — it never invents text.
1207
1671
  const repairedContent = repairProposalContent(proposalContent(proposal));
1208
- const proposalToValidate = repairedContent !== proposalContent(proposal) ? withProposalContent(proposal, repairedContent) : proposal;
1672
+ const proposalToValidate = repairedContent === proposalContent(proposal) ? proposal : withProposalContent(proposal, repairedContent);
1209
1673
  const report = validateProposal(proposalToValidate);
1210
1674
  if (!report.ok) {
1211
- const message = report.findings.map((f) => `[${f.kind}] ${f.message}`).join("\n");
1675
+ const message = report.findings.map((finding) => `[${finding.kind}] ${finding.message}`).join("\n");
1212
1676
  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.");
1213
1677
  }
1678
+ const ref = parseRefInput(proposalToValidate.ref);
1679
+ if (!stashDirFor(ref.type)) {
1680
+ throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
1681
+ }
1214
1682
  // Use the (possibly repaired) payload for the promotion write. Persist the
1215
1683
  // repaired content back onto the DB row so the audit trail reflects the
1216
1684
  // final promoted payload (not the defective original).
1217
1685
  if (repairedContent !== proposalContent(proposal)) {
1218
1686
  withProposalsDb(stashDir, ctx, (db) => {
1219
- upsertProposal(db, withProposalContent(proposal, repairedContent), stashDir);
1687
+ persistProposalUpdate(db, proposalToValidate, stashDir);
1220
1688
  });
1221
1689
  }
1222
- const ref = parseRefInput(proposalToValidate.ref);
1223
- if (!stashDirFor(ref.type)) {
1224
- throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
1225
- }
1226
1690
  await recoverProposalTransactionsForStash(stashDir, config, ctx, id);
1227
1691
  proposal = getProposal(stashDir, id, ctx);
1228
- const target = resolveWriteTarget(config, options.target);
1229
- await recoverTxnsForRoot(target.source.path, (journal) => journal.kind === "mv");
1230
- if (proposal.status === "accepted" && proposal.acceptedContentHash) {
1692
+ const target = resolveProposalWriteTarget(config, proposal, options.target, options.queueTarget);
1693
+ if (proposal.status === "accepted") {
1694
+ if (!proposal.acceptedTarget) {
1695
+ throw new UsageError(`Accepted proposal ${id} has no recorded target.`, "INVALID_PROPOSAL");
1696
+ }
1231
1697
  const assetPath = resolveAssetFilePathSafe(target.source, ref);
1232
- if (proposal.acceptedTarget &&
1233
- (proposal.acceptedTarget.source !== target.source.name ||
1234
- path.resolve(proposal.acceptedTarget.root) !== path.resolve(target.source.path) ||
1235
- !assetPath ||
1236
- path.resolve(proposal.acceptedTarget.path) !== path.resolve(assetPath))) {
1698
+ if (proposal.acceptedTarget.source !== target.source.name ||
1699
+ path.resolve(proposal.acceptedTarget.root) !== path.resolve(target.source.path) ||
1700
+ !assetPath ||
1701
+ path.resolve(proposal.acceptedTarget.path) !== path.resolve(assetPath)) {
1237
1702
  throw new UsageError(`proposal ${id} is bound to a different accepted target`, "INVALID_FLAG_VALUE");
1238
1703
  }
1239
- if (!assetPath || !fs.existsSync(assetPath) || proposalFileHash(assetPath) !== proposal.acceptedContentHash) {
1704
+ if (!assetPath ||
1705
+ !fs.existsSync(assetPath) ||
1706
+ proposalFileHash(assetPath) !== proposal.acceptedTarget.contentHash) {
1240
1707
  throw new UsageError(`Accepted proposal ${id} does not match the current asset content.`, "INVALID_FLAG_VALUE");
1241
1708
  }
1242
1709
  return { proposal, assetPath, ref: proposal.ref };
@@ -1244,9 +1711,13 @@ async function promoteProposalWithLease(stashDir, config, id, options, ctx) {
1244
1711
  if (proposal.status !== "pending") {
1245
1712
  throw new UsageError(`Proposal ${id} is not pending (current status: ${proposal.status}). Only pending proposals can be accepted.`, "INVALID_FLAG_VALUE");
1246
1713
  }
1247
- const assetPath = resolveAssetFilePathSafe(target.source, ref);
1714
+ const preflight = preflightProposalPromotion(config, proposal, { ...options, queueTarget: target }, ctx);
1715
+ await runAdapterPreCommitCheck(config, preflight);
1716
+ const mutationTarget = prepareWriteTargetForMutation(target);
1717
+ const assetPath = resolveAssetFilePathSafe(mutationTarget.source, ref);
1248
1718
  if (!assetPath)
1249
1719
  throw new UsageError(`Cannot resolve proposal target ${proposal.ref}.`, "INVALID_PROPOSAL");
1720
+ assertWriteTargetPathsClean(mutationTarget.source, [assetPath]);
1250
1721
  let backup;
1251
1722
  if (fs.existsSync(assetPath)) {
1252
1723
  try {
@@ -1256,16 +1727,48 @@ async function promoteProposalWithLease(stashDir, config, id, options, ctx) {
1256
1727
  throw new Error(`Proposal backup read failed for ${assetPath}: ${error instanceof Error ? error.message : String(error)}`);
1257
1728
  }
1258
1729
  }
1259
- const transaction = prepareProposalTransaction(stashDir, target, proposalToValidate, ref, repairedContent, {
1730
+ if (proposal.beforeHash !== undefined && (!backup || proposalHash(backup) !== proposal.beforeHash)) {
1731
+ throw new UsageError(`Proposal target changed after proposal ${id} was created; refusing to overwrite newer content.`, "INVALID_FLAG_VALUE");
1732
+ }
1733
+ if (proposal.beforeHash === undefined &&
1734
+ backup !== undefined &&
1735
+ proposal.changes.some((change) => change.op === "create")) {
1736
+ throw new UsageError(`Proposal target was created after proposal ${id}; refusing to overwrite newer content.`, "INVALID_FLAG_VALUE");
1737
+ }
1738
+ assertAkmAssetWrite(mutationTarget.source);
1739
+ // D2 (#730): stamp OKF v0.2 provenance onto the promoted content BEFORE
1740
+ // lint/write — reaching this point already proves the target is AKM-native
1741
+ // (assertAkmAssetWrite above rejects an OKF-adapter target first), so the
1742
+ // OKF write-rejection contract (runbook §10) is untouched: this code never
1743
+ // runs for it. Markdown-only: a task/env/script/other non-markdown target
1744
+ // has no frontmatter block to stamp into.
1745
+ //
1746
+ // workflow-format-unification removed the re-validation fallback that used
1747
+ // to live here: every AKM-native markdown type (workflow included) now
1748
+ // validates its frontmatter against a schema whose closed key set is
1749
+ // `envelope ∪ type-keys` (`schemas/akm-workflow.json` $ref's
1750
+ // `schemas/akm-asset-envelope.json`, which already admits `generated`/
1751
+ // `verified`/`provenance`/`status`/`stale_after`). A validator rejecting the
1752
+ // machine-stamped keys it is contractually required to admit is structurally
1753
+ // impossible now, so falling back to unstamped content on rejection would
1754
+ // only silently hide a real regression instead of promoting stamped content.
1755
+ const stampedContent = preflight.stampedContent;
1756
+ const proposalForPreflight = preflight.proposal;
1757
+ const refIdentity = proposalRefIdentity(proposalForPreflight.ref);
1758
+ const proposalForMutation = refIdentity?.bundle === undefined
1759
+ ? { ...proposalForPreflight, ref: `${target.source.name}//${refIdentity?.conceptId ?? ""}` }
1760
+ : proposalForPreflight;
1761
+ const transaction = prepareProposalTransaction(stashDir, mutationTarget, proposalForMutation, ref, stampedContent, {
1260
1762
  operation: "accept",
1261
1763
  originalHash: backup ? proposalHash(backup) : null,
1262
1764
  backup,
1263
1765
  eventMetadata: options.eventMetadata,
1766
+ gateDecision: options.gateDecision,
1264
1767
  }, ctx);
1265
- publishProposalAsset(transaction);
1266
- const accepted = await finalizeProposalTransaction(transaction, target, proposalToValidate, ctx);
1768
+ publishProposalAsset(transaction, mutationTarget);
1769
+ const accepted = await finalizeProposalTransaction(transaction, mutationTarget, proposalForMutation, ctx);
1267
1770
  cleanupTxn(transaction.dir);
1268
- return { proposal: accepted, assetPath: transaction.journal.payload.assetPath, ref: proposal.ref };
1771
+ return { proposal: accepted, assetPath: transaction.journal.payload.assetPath, ref: accepted.ref };
1269
1772
  }
1270
1773
  /**
1271
1774
  * Restore the prior content of an accepted proposal from the backup captured
@@ -1299,19 +1802,15 @@ async function revertProposalWithLease(stashDir, config, id, options, ctx) {
1299
1802
  await recoverProposalTransactionsForStash(stashDir, config, ctx, id);
1300
1803
  proposal = getProposal(stashDir, id, ctx);
1301
1804
  if (proposal.status === "reverted") {
1302
- if (options.target)
1303
- resolveWriteTarget(config, options.target);
1304
- const target = proposal.legacyAcceptedTargetDerived
1305
- ? resolveWritableTargets(config).find((candidate) => candidate.source.name === proposal.acceptedTarget?.source &&
1306
- path.resolve(candidate.source.path) === path.resolve(proposal.acceptedTarget?.root ?? ""))
1307
- : resolveWriteTarget(config, options.target);
1308
- const requestedAssetPath = target ? resolveAssetFilePathSafe(target.source, ref) : undefined;
1309
- if (!target ||
1310
- !requestedAssetPath ||
1311
- (proposal.acceptedTarget &&
1312
- (proposal.acceptedTarget.source !== target.source.name ||
1313
- path.resolve(proposal.acceptedTarget.root) !== path.resolve(target.source.path) ||
1314
- path.resolve(proposal.acceptedTarget.path) !== path.resolve(requestedAssetPath)))) {
1805
+ if (!proposal.acceptedTarget) {
1806
+ throw new UsageError(`Reverted proposal ${id} has no recorded target.`, "INVALID_PROPOSAL");
1807
+ }
1808
+ const target = resolveRecordedProposalTarget(config, id, proposal.acceptedTarget, options.target);
1809
+ const requestedAssetPath = resolveAssetFilePathSafe(target.source, ref);
1810
+ if (!requestedAssetPath ||
1811
+ proposal.acceptedTarget.source !== target.source.name ||
1812
+ path.resolve(proposal.acceptedTarget.root) !== path.resolve(target.source.path) ||
1813
+ path.resolve(proposal.acceptedTarget.path) !== path.resolve(requestedAssetPath)) {
1315
1814
  throw new UsageError(`proposal ${id} is bound to a different accepted target`, "INVALID_FLAG_VALUE");
1316
1815
  }
1317
1816
  return {
@@ -1327,96 +1826,26 @@ async function revertProposalWithLease(stashDir, config, id, options, ctx) {
1327
1826
  if (backupContent === undefined) {
1328
1827
  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.");
1329
1828
  }
1330
- const proposalBody = proposalContent(proposal);
1331
- const legacyAccepted = proposalBody.endsWith("\n") ? proposalBody : `${proposalBody}\n`;
1332
- let acceptedHash = proposal.acceptedTarget?.contentHash ?? proposal.acceptedContentHash ?? proposalHash(legacyAccepted);
1333
- const writableTargets = resolveWritableTargets(config);
1334
- let target;
1335
- let assetPath;
1336
1829
  if (!proposal.acceptedTarget) {
1337
- if (options.target)
1338
- resolveWriteTarget(config, options.target);
1339
- const candidates = writableTargets.flatMap((candidate) => {
1340
- const candidatePath = resolveAssetFilePathSafe(candidate.source, ref);
1341
- return candidatePath ? [{ target: candidate, assetPath: candidatePath }] : [];
1342
- });
1343
- const matching = candidates.filter((candidate) => fs.existsSync(candidate.assetPath) && proposalFileHash(candidate.assetPath) === acceptedHash);
1344
- if (matching.length > 1) {
1345
- throw new UsageError(`legacy proposal ${id} has ambiguous accepted content in multiple writable targets; refusing revert`, "INVALID_FLAG_VALUE");
1346
- }
1347
- const existing = candidates.filter((candidate) => fs.existsSync(candidate.assetPath));
1348
- if (matching.length === 0 && existing.length > 0) {
1349
- throw new UsageError(`asset content changed after proposal ${id} was accepted; refusing to clobber the newer content`, "INVALID_FLAG_VALUE");
1350
- }
1351
- if (matching.length === 0 && candidates.length !== 1) {
1352
- throw new UsageError(`legacy proposal ${id} has no accepted asset and ${candidates.length} writable targets can own the ref; ownership is ambiguous`, "INVALID_FLAG_VALUE");
1353
- }
1354
- const owner = matching[0] ?? candidates[0];
1355
- if (!owner) {
1356
- throw new UsageError(`legacy proposal ${id} has no writable target that can own ${proposal.ref}`, "INVALID_FLAG_VALUE");
1357
- }
1358
- const acceptedAssetWasAbsent = matching.length === 0;
1359
- proposal = withProposalsDb(stashDir, ctx, (db) => withImmediateTransaction(db, () => {
1360
- const current = requireProposal(db, stashDir, id);
1361
- if (current.status !== "accepted" || current.acceptedTarget) {
1362
- throw new Error(`Proposal ${id} changed while deriving its legacy revert target.`);
1363
- }
1364
- const bound = {
1365
- ...current,
1366
- acceptedContentHash: acceptedHash,
1367
- acceptedTarget: {
1368
- source: owner.target.source.name,
1369
- root: owner.target.source.path,
1370
- path: owner.assetPath,
1371
- contentHash: acceptedHash,
1372
- },
1373
- legacyAcceptedTargetDerived: true,
1374
- ...(acceptedAssetWasAbsent ? { legacyAcceptedAssetWasAbsent: true } : {}),
1375
- };
1376
- upsertProposal(db, bound, stashDir);
1377
- return bound;
1378
- }));
1379
- txnMutationHook("legacy-target-derived");
1380
- target = owner.target;
1381
- assetPath = owner.assetPath;
1382
- }
1383
- else if (proposal.legacyAcceptedTargetDerived) {
1384
- const acceptedTarget = proposal.acceptedTarget;
1385
- if (!acceptedTarget)
1386
- throw new Error(`Legacy proposal ${id} lost its derived accepted target.`);
1387
- const bound = writableTargets.find((candidate) => {
1388
- const candidatePath = resolveAssetFilePathSafe(candidate.source, ref);
1389
- return (candidate.source.name === acceptedTarget.source &&
1390
- path.resolve(candidate.source.path) === path.resolve(acceptedTarget.root) &&
1391
- candidatePath !== undefined &&
1392
- path.resolve(candidatePath) === path.resolve(acceptedTarget.path));
1393
- });
1394
- if (!bound) {
1395
- throw new UsageError(`legacy proposal ${id} is bound to a writable target that is no longer configured`, "INVALID_FLAG_VALUE");
1396
- }
1397
- target = bound;
1398
- assetPath = acceptedTarget.path;
1399
- }
1400
- else {
1401
- target = resolveWriteTarget(config, options.target);
1402
- const requestedAssetPath = resolveAssetFilePathSafe(target.source, ref);
1403
- if (proposal.acceptedTarget.source !== target.source.name ||
1404
- path.resolve(proposal.acceptedTarget.root) !== path.resolve(target.source.path) ||
1405
- !requestedAssetPath ||
1406
- path.resolve(proposal.acceptedTarget.path) !== path.resolve(requestedAssetPath)) {
1407
- throw new UsageError(`proposal ${id} is bound to a different accepted target`, "INVALID_FLAG_VALUE");
1408
- }
1409
- assetPath = requestedAssetPath;
1410
- }
1411
- await recoverTxnsForRoot(target.source.path, (journal) => journal.kind === "mv");
1412
- acceptedHash = proposal.acceptedTarget?.contentHash ?? proposal.acceptedContentHash ?? acceptedHash;
1413
- const acceptedAssetExists = fs.existsSync(assetPath);
1414
- if ((fs.existsSync(assetPath) && proposalFileHash(assetPath) !== acceptedHash) ||
1415
- (!fs.existsSync(assetPath) && !proposal.legacyAcceptedAssetWasAbsent)) {
1830
+ throw new UsageError(`Accepted proposal ${id} has no recorded target.`, "INVALID_PROPOSAL");
1831
+ }
1832
+ let target = resolveRecordedProposalTarget(config, id, proposal.acceptedTarget, options.target);
1833
+ const requestedAssetPath = resolveAssetFilePathSafe(target.source, ref);
1834
+ if (proposal.acceptedTarget.source !== target.source.name ||
1835
+ path.resolve(proposal.acceptedTarget.root) !== path.resolve(target.source.path) ||
1836
+ !requestedAssetPath ||
1837
+ path.resolve(proposal.acceptedTarget.path) !== path.resolve(requestedAssetPath)) {
1838
+ throw new UsageError(`proposal ${id} is bound to a different accepted target`, "INVALID_FLAG_VALUE");
1839
+ }
1840
+ const assetPath = requestedAssetPath;
1841
+ const acceptedHash = proposal.acceptedTarget.contentHash;
1842
+ target = prepareWriteTargetForMutation(target);
1843
+ if (!fs.existsSync(assetPath) || proposalFileHash(assetPath) !== acceptedHash) {
1416
1844
  throw new UsageError(`asset content changed after proposal ${id} was accepted; refusing to clobber the newer content`, "INVALID_FLAG_VALUE");
1417
1845
  }
1418
- const transaction = prepareProposalTransaction(stashDir, target, proposal, ref, backupContent, { operation: "revert", originalHash: acceptedAssetExists ? acceptedHash : null }, ctx);
1419
- publishProposalAsset(transaction);
1846
+ assertWriteTargetPathsClean(target.source, [assetPath]);
1847
+ const transaction = prepareProposalTransaction(stashDir, target, proposal, ref, backupContent, { operation: "revert", originalHash: acceptedHash }, ctx);
1848
+ publishProposalAsset(transaction, target);
1420
1849
  const reverted = await finalizeProposalTransaction(transaction, target, proposal, ctx);
1421
1850
  cleanupTxn(transaction.dir);
1422
1851
  return { proposal: reverted, assetPath, ref: proposal.ref };
@@ -1432,17 +1861,13 @@ export function diffProposal(stashDir, config, id, options = {}, ctx) {
1432
1861
  const ref = parseRefInput(proposal.ref);
1433
1862
  let targetPath;
1434
1863
  let existing = null;
1435
- try {
1436
- const target = resolveWriteTarget(config, options.target);
1864
+ const readTarget = (target) => {
1437
1865
  targetPath = resolveAssetFilePathSafe(target.source, ref);
1438
1866
  if (targetPath && fs.existsSync(targetPath)) {
1439
1867
  existing = fs.readFileSync(targetPath, "utf8");
1440
1868
  }
1441
- }
1442
- catch {
1443
- // No writable target configured — still return a "new asset" diff so
1444
- // callers can see the proposed payload without erroring out.
1445
- }
1869
+ };
1870
+ readTarget(resolveProposalWriteTarget(config, proposal, options.target, options.queueTarget));
1446
1871
  const proposed = proposalContent(proposal);
1447
1872
  if (existing === null) {
1448
1873
  return {
@@ -1488,14 +1913,12 @@ registerTxnKind(PROPOSAL_TXN_KIND, {
1488
1913
  finalize: async (txn) => {
1489
1914
  const p = txn.journal.payload;
1490
1915
  const config = loadConfig();
1491
- let target;
1492
- try {
1493
- target = resolveWriteTarget(config, p.targetSource);
1494
- }
1495
- catch {
1496
- target = resolveWriteTarget(config);
1916
+ let target = resolveProposalRecoveryTarget(config, txn.journal);
1917
+ if (txn.journal.phase === "asset-published") {
1918
+ target = prepareWriteTargetForMutation(target, { allowAhead: true });
1497
1919
  }
1498
- if (canonicalTxnRoot(target.source.path) !== canonicalTxnRoot(txn.journal.root)) {
1920
+ if (canonicalTxnRoot(target.source.path) !== canonicalTxnRoot(txn.journal.root) ||
1921
+ p.targetKind !== target.source.kind) {
1499
1922
  throw new Error(`Proposal transaction ${txn.journal.transactionId} is bound to a different target root.`);
1500
1923
  }
1501
1924
  const proposal = getProposal(p.stashDir, p.proposalId);