akm-cli 0.6.0 → 0.7.0-rc1

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 (319) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/dist/{cli.js → src/cli.js} +672 -29
  3. package/dist/{commands → src/commands}/config-cli.js +5 -4
  4. package/dist/src/commands/distill.js +283 -0
  5. package/dist/src/commands/events.js +108 -0
  6. package/dist/src/commands/history.js +120 -0
  7. package/dist/{commands → src/commands}/installed-stashes.js +28 -2
  8. package/dist/src/commands/proposal.js +119 -0
  9. package/dist/src/commands/propose.js +171 -0
  10. package/dist/src/commands/reflect.js +193 -0
  11. package/dist/{commands → src/commands}/registry-search.js +2 -1
  12. package/dist/{commands → src/commands}/remember.js +12 -0
  13. package/dist/{commands → src/commands}/search.js +74 -1
  14. package/dist/{commands → src/commands}/self-update.js +4 -3
  15. package/dist/{commands → src/commands}/show.js +67 -2
  16. package/dist/{core → src/core}/asset-ref.js +5 -5
  17. package/dist/{core → src/core}/asset-spec.js +12 -0
  18. package/dist/{core → src/core}/common.js +1 -1
  19. package/dist/{core → src/core}/config.js +175 -121
  20. package/dist/{core → src/core}/errors.js +4 -0
  21. package/dist/src/core/events.js +239 -0
  22. package/dist/src/core/lesson-lint.js +86 -0
  23. package/dist/src/core/proposals.js +406 -0
  24. package/dist/src/core/warn.js +72 -0
  25. package/dist/{core → src/core}/write-source.js +80 -5
  26. package/dist/{indexer → src/indexer}/db-search.js +119 -27
  27. package/dist/{indexer → src/indexer}/db.js +76 -23
  28. package/dist/{indexer → src/indexer}/file-context.js +0 -3
  29. package/dist/src/indexer/graph-boost.js +179 -0
  30. package/dist/src/indexer/graph-extraction.js +212 -0
  31. package/dist/{indexer → src/indexer}/indexer.js +73 -6
  32. package/dist/src/indexer/memory-inference.js +263 -0
  33. package/dist/{indexer → src/indexer}/metadata.js +114 -11
  34. package/dist/src/integrations/agent/config.js +292 -0
  35. package/dist/src/integrations/agent/detect.js +94 -0
  36. package/dist/src/integrations/agent/index.js +17 -0
  37. package/dist/src/integrations/agent/profiles.js +65 -0
  38. package/dist/src/integrations/agent/prompts.js +167 -0
  39. package/dist/src/integrations/agent/spawn.js +221 -0
  40. package/dist/{integrations → src/integrations}/lockfile.js +0 -26
  41. package/dist/{llm → src/llm}/client.js +33 -2
  42. package/dist/src/llm/feature-gate.js +108 -0
  43. package/dist/src/llm/graph-extract.js +107 -0
  44. package/dist/src/llm/index-passes.js +35 -0
  45. package/dist/src/llm/memory-infer.js +86 -0
  46. package/dist/{output → src/output}/renderers.js +60 -1
  47. package/dist/src/output/shapes.js +516 -0
  48. package/dist/{output → src/output}/text.js +447 -4
  49. package/dist/{registry → src/registry}/build-index.js +14 -4
  50. package/dist/{registry → src/registry}/factory.js +0 -8
  51. package/dist/{registry → src/registry}/providers/static-index.js +3 -2
  52. package/dist/{registry → src/registry}/resolve.js +68 -2
  53. package/dist/{setup → src/setup}/setup.js +43 -5
  54. package/dist/{sources → src/sources}/providers/git.js +7 -15
  55. package/dist/{wiki → src/wiki}/wiki.js +9 -11
  56. package/dist/tests/add-website-source.test.js +119 -0
  57. package/dist/tests/agent/agent-config-loader.test.js +70 -0
  58. package/dist/tests/agent/agent-config.test.js +221 -0
  59. package/dist/tests/agent/agent-detect.test.js +100 -0
  60. package/dist/tests/agent/agent-spawn.test.js +234 -0
  61. package/dist/tests/agent-output.test.js +186 -0
  62. package/dist/tests/architecture/agent-no-llm-sdk-guard.test.js +103 -0
  63. package/dist/tests/architecture/agent-spawn-seam.test.js +193 -0
  64. package/dist/tests/architecture/llm-stateless-seam.test.js +112 -0
  65. package/dist/tests/asset-ref.test.js +192 -0
  66. package/dist/tests/asset-registry.test.js +103 -0
  67. package/dist/tests/asset-spec.test.js +241 -0
  68. package/dist/tests/bench/attribution.test.js +995 -0
  69. package/dist/tests/bench/cleanup-sigint.test.js +83 -0
  70. package/dist/tests/bench/cleanup.js +203 -0
  71. package/dist/tests/bench/cleanup.test.js +166 -0
  72. package/dist/tests/bench/cli.js +683 -0
  73. package/dist/tests/bench/cli.test.js +177 -0
  74. package/dist/tests/bench/compare.test.js +556 -0
  75. package/dist/tests/bench/corpus.js +314 -0
  76. package/dist/tests/bench/corpus.test.js +258 -0
  77. package/dist/tests/bench/driver.js +346 -0
  78. package/dist/tests/bench/driver.test.js +443 -0
  79. package/dist/tests/bench/evolve-metrics.js +179 -0
  80. package/dist/tests/bench/evolve-metrics.test.js +187 -0
  81. package/dist/tests/bench/evolve.js +580 -0
  82. package/dist/tests/bench/evolve.test.js +616 -0
  83. package/dist/tests/bench/failure-modes.test.js +300 -0
  84. package/dist/tests/bench/feedback-integrity.test.js +456 -0
  85. package/dist/tests/bench/leakage.test.js +125 -0
  86. package/dist/tests/bench/learning-curve.test.js +133 -0
  87. package/dist/tests/bench/metrics.js +2319 -0
  88. package/dist/tests/bench/metrics.test.js +1144 -0
  89. package/dist/tests/bench/no-os-tmpdir-invariant.test.js +43 -0
  90. package/dist/tests/bench/report.js +1821 -0
  91. package/dist/tests/bench/report.test.js +989 -0
  92. package/dist/tests/bench/runner.js +536 -0
  93. package/dist/tests/bench/runner.test.js +958 -0
  94. package/dist/tests/bench/search-bridge.test.js +331 -0
  95. package/dist/tests/bench/tmp.js +41 -0
  96. package/dist/tests/bench/trajectory.js +116 -0
  97. package/dist/tests/bench/trajectory.test.js +127 -0
  98. package/dist/tests/bench/verifier.js +109 -0
  99. package/dist/tests/bench/verifier.test.js +118 -0
  100. package/dist/tests/bench/workflow-evaluator.js +557 -0
  101. package/dist/tests/bench/workflow-evaluator.test.js +421 -0
  102. package/dist/tests/bench/workflow-spec.js +358 -0
  103. package/dist/tests/bench/workflow-spec.test.js +363 -0
  104. package/dist/tests/bench/workflow-trace.js +438 -0
  105. package/dist/tests/bench/workflow-trace.test.js +254 -0
  106. package/dist/tests/benchmark-search-quality.js +536 -0
  107. package/dist/tests/benchmark-suite.js +1441 -0
  108. package/dist/tests/capture-cli.test.js +112 -0
  109. package/dist/tests/cli-errors.test.js +203 -0
  110. package/dist/tests/commands/events.test.js +370 -0
  111. package/dist/tests/commands/history.test.js +223 -0
  112. package/dist/tests/commands/import.test.js +103 -0
  113. package/dist/tests/commands/proposal-cli.test.js +209 -0
  114. package/dist/tests/commands/reflect-propose-cli.test.js +333 -0
  115. package/dist/tests/commands/remember.test.js +97 -0
  116. package/dist/tests/commands/scope-flags.test.js +300 -0
  117. package/dist/tests/commands/search.test.js +537 -0
  118. package/dist/tests/commands/show-indexer-parity.test.js +117 -0
  119. package/dist/tests/commands/show.test.js +294 -0
  120. package/dist/tests/common.test.js +266 -0
  121. package/dist/tests/completions.test.js +142 -0
  122. package/dist/tests/config-cli.test.js +193 -0
  123. package/dist/tests/config-llm-features.test.js +139 -0
  124. package/dist/tests/config.test.js +544 -0
  125. package/dist/tests/contracts/migration-baseline.test.js +43 -0
  126. package/dist/tests/contracts/reflect-propose-envelope.test.js +139 -0
  127. package/dist/tests/contracts/spec-helpers.js +46 -0
  128. package/dist/tests/contracts/v1-spec-section-11-proposal-queue.test.js +228 -0
  129. package/dist/tests/contracts/v1-spec-section-12-agent-config.test.js +56 -0
  130. package/dist/tests/contracts/v1-spec-section-13-lesson-type.test.js +34 -0
  131. package/dist/tests/contracts/v1-spec-section-14-llm-features.test.js +94 -0
  132. package/dist/tests/contracts/v1-spec-section-4-1-asset-types.test.js +39 -0
  133. package/dist/tests/contracts/v1-spec-section-4-2-quality-rules.test.js +44 -0
  134. package/dist/tests/contracts/v1-spec-section-5-configuration.test.js +47 -0
  135. package/dist/tests/contracts/v1-spec-section-6-orchestration.test.js +40 -0
  136. package/dist/tests/contracts/v1-spec-section-7-module-layout.test.js +58 -0
  137. package/dist/tests/contracts/v1-spec-section-8-extension-points.test.js +34 -0
  138. package/dist/tests/contracts/v1-spec-section-9-4-cli-surface.test.js +75 -0
  139. package/dist/tests/contracts/v1-spec-section-9-7-llm-agent-boundary.test.js +36 -0
  140. package/dist/tests/core/write-source.test.js +366 -0
  141. package/dist/tests/curate-command.test.js +87 -0
  142. package/dist/tests/db-scoring.test.js +201 -0
  143. package/dist/tests/db.test.js +654 -0
  144. package/dist/tests/distill-cli-flag.test.js +208 -0
  145. package/dist/tests/distill.test.js +515 -0
  146. package/dist/tests/docker-install.test.js +120 -0
  147. package/dist/tests/e2e.test.js +1398 -0
  148. package/dist/tests/embedder.test.js +340 -0
  149. package/dist/tests/embedding-model-config.test.js +379 -0
  150. package/dist/tests/feedback-command.test.js +172 -0
  151. package/dist/tests/file-context.test.js +552 -0
  152. package/dist/tests/fixtures/scripts/git/summarize-diff.js +9 -0
  153. package/dist/tests/fixtures/scripts/lint/eslint-check.js +7 -0
  154. package/dist/tests/fixtures/stashes/load.js +166 -0
  155. package/dist/tests/fixtures/stashes/load.test.js +88 -0
  156. package/dist/tests/fixtures/stashes/ranking-baseline/scripts/mem0-search.js +12 -0
  157. package/dist/tests/frontmatter.test.js +190 -0
  158. package/dist/tests/fts-field-weighting.test.js +254 -0
  159. package/dist/tests/fuzzy-search.test.js +230 -0
  160. package/dist/tests/git-provider-clone.test.js +45 -0
  161. package/dist/tests/github.test.js +161 -0
  162. package/dist/tests/graph-boost-ranking.test.js +305 -0
  163. package/dist/tests/graph-extraction.test.js +282 -0
  164. package/dist/tests/helpers/usage-events.js +8 -0
  165. package/dist/tests/index-pass-llm.test.js +161 -0
  166. package/dist/tests/indexer.test.js +559 -0
  167. package/dist/tests/info-command.test.js +166 -0
  168. package/dist/tests/init.test.js +69 -0
  169. package/dist/tests/install-script.test.js +246 -0
  170. package/dist/tests/integration/agent-real-profile.test.js +94 -0
  171. package/dist/tests/issue-36-repro.test.js +304 -0
  172. package/dist/tests/issues-191-194.test.js +160 -0
  173. package/dist/tests/lesson-lint.test.js +111 -0
  174. package/dist/tests/llm-client.test.js +115 -0
  175. package/dist/tests/llm-feature-gate.test.js +151 -0
  176. package/dist/tests/llm.test.js +139 -0
  177. package/dist/tests/lockfile.test.js +216 -0
  178. package/dist/tests/manifest.test.js +205 -0
  179. package/dist/tests/markdown.test.js +126 -0
  180. package/dist/tests/matchers-unit.test.js +189 -0
  181. package/dist/tests/memory-inference.test.js +299 -0
  182. package/dist/tests/merge-scoring.test.js +136 -0
  183. package/dist/tests/metadata.test.js +313 -0
  184. package/dist/tests/migration-help.test.js +89 -0
  185. package/dist/tests/origin-resolve.test.js +124 -0
  186. package/dist/tests/output-baseline.test.js +217 -0
  187. package/dist/tests/output-shapes-unit.test.js +476 -0
  188. package/dist/tests/parallel-search.test.js +272 -0
  189. package/dist/tests/parameter-metadata.test.js +365 -0
  190. package/dist/tests/paths.test.js +177 -0
  191. package/dist/tests/progressive-disclosure.test.js +280 -0
  192. package/dist/tests/proposals.test.js +279 -0
  193. package/dist/tests/proposed-quality.test.js +271 -0
  194. package/dist/tests/provider-registry.test.js +32 -0
  195. package/dist/tests/ranking-regression.test.js +548 -0
  196. package/dist/tests/reflect-propose.test.js +455 -0
  197. package/dist/tests/registry-build-index.test.js +378 -0
  198. package/dist/tests/registry-cli.test.js +290 -0
  199. package/dist/tests/registry-index-v2.test.js +430 -0
  200. package/dist/tests/registry-install.test.js +728 -0
  201. package/dist/tests/registry-providers/parity.test.js +189 -0
  202. package/dist/tests/registry-providers/skills-sh.test.js +309 -0
  203. package/dist/tests/registry-providers/static-index.test.js +204 -0
  204. package/dist/tests/registry-resolve.test.js +126 -0
  205. package/dist/tests/registry-search.test.js +723 -0
  206. package/dist/tests/remember-frontmatter.test.js +380 -0
  207. package/dist/tests/remember-unit.test.js +123 -0
  208. package/dist/tests/ripgrep-install.test.js +251 -0
  209. package/dist/tests/ripgrep-resolve.test.js +108 -0
  210. package/dist/tests/ripgrep.test.js +163 -0
  211. package/dist/tests/save-command.test.js +94 -0
  212. package/dist/tests/save-trust-qa-fixes.test.js +270 -0
  213. package/dist/tests/scoring-pipeline.test.js +648 -0
  214. package/dist/tests/search-include-proposed-cli.test.js +118 -0
  215. package/dist/tests/self-update.test.js +442 -0
  216. package/dist/tests/semantic-search-e2e.test.js +512 -0
  217. package/dist/tests/semantic-status.test.js +471 -0
  218. package/dist/tests/setup-run.integration.js +877 -0
  219. package/dist/tests/setup-wizard.test.js +198 -0
  220. package/dist/tests/setup.test.js +131 -0
  221. package/dist/tests/source-add.test.js +11 -0
  222. package/dist/tests/source-clone.test.js +254 -0
  223. package/dist/tests/source-manage.test.js +366 -0
  224. package/dist/tests/source-providers/filesystem.test.js +82 -0
  225. package/dist/tests/source-providers/git.test.js +252 -0
  226. package/dist/tests/source-providers/website.test.js +128 -0
  227. package/dist/tests/source-qa-fixes.test.js +268 -0
  228. package/dist/tests/source-registry.test.js +350 -0
  229. package/dist/tests/source-resolve.test.js +100 -0
  230. package/dist/tests/source-source.test.js +221 -0
  231. package/dist/tests/source.test.js +533 -0
  232. package/dist/tests/tar-utils-scan.test.js +73 -0
  233. package/dist/tests/toggle-components.test.js +73 -0
  234. package/dist/tests/usage-telemetry.test.js +265 -0
  235. package/dist/tests/utility-scoring.test.js +558 -0
  236. package/dist/tests/vault-load-error.test.js +78 -0
  237. package/dist/tests/vault-qa-fixes.test.js +194 -0
  238. package/dist/tests/vault.test.js +429 -0
  239. package/dist/tests/vector-search.test.js +608 -0
  240. package/dist/tests/walker.test.js +252 -0
  241. package/dist/tests/wave2-cluster-bc.test.js +228 -0
  242. package/dist/tests/wave2-cluster-d.test.js +180 -0
  243. package/dist/tests/wave2-cluster-e.test.js +179 -0
  244. package/dist/tests/wiki-qa-fixes.test.js +270 -0
  245. package/dist/tests/wiki.test.js +529 -0
  246. package/dist/tests/workflow-cli.test.js +271 -0
  247. package/dist/tests/workflow-markdown.test.js +171 -0
  248. package/dist/tests/workflow-path-escape.test.js +132 -0
  249. package/dist/tests/workflow-qa-fixes.test.js +377 -0
  250. package/dist/tests/workflows/indexer-rejection.test.js +213 -0
  251. package/docs/README.md +8 -0
  252. package/docs/migration/release-notes/0.7.0.md +244 -0
  253. package/package.json +2 -2
  254. package/dist/core/warn.js +0 -27
  255. package/dist/output/shapes.js +0 -212
  256. /package/dist/{commands → src/commands}/completions.js +0 -0
  257. /package/dist/{commands → src/commands}/curate.js +0 -0
  258. /package/dist/{commands → src/commands}/info.js +0 -0
  259. /package/dist/{commands → src/commands}/init.js +0 -0
  260. /package/dist/{commands → src/commands}/install-audit.js +0 -0
  261. /package/dist/{commands → src/commands}/migration-help.js +0 -0
  262. /package/dist/{commands → src/commands}/source-add.js +0 -0
  263. /package/dist/{commands → src/commands}/source-clone.js +0 -0
  264. /package/dist/{commands → src/commands}/source-manage.js +0 -0
  265. /package/dist/{commands → src/commands}/vault.js +0 -0
  266. /package/dist/{core → src/core}/asset-registry.js +0 -0
  267. /package/dist/{core → src/core}/frontmatter.js +0 -0
  268. /package/dist/{core → src/core}/markdown.js +0 -0
  269. /package/dist/{core → src/core}/paths.js +0 -0
  270. /package/dist/{indexer → src/indexer}/manifest.js +0 -0
  271. /package/dist/{indexer → src/indexer}/matchers.js +0 -0
  272. /package/dist/{indexer → src/indexer}/search-fields.js +0 -0
  273. /package/dist/{indexer → src/indexer}/search-source.js +0 -0
  274. /package/dist/{indexer → src/indexer}/semantic-status.js +0 -0
  275. /package/dist/{indexer → src/indexer}/usage-events.js +0 -0
  276. /package/dist/{indexer → src/indexer}/walker.js +0 -0
  277. /package/dist/{integrations → src/integrations}/github.js +0 -0
  278. /package/dist/{llm → src/llm}/embedder.js +0 -0
  279. /package/dist/{llm → src/llm}/embedders/cache.js +0 -0
  280. /package/dist/{llm → src/llm}/embedders/local.js +0 -0
  281. /package/dist/{llm → src/llm}/embedders/remote.js +0 -0
  282. /package/dist/{llm → src/llm}/embedders/types.js +0 -0
  283. /package/dist/{llm → src/llm}/metadata-enhance.js +0 -0
  284. /package/dist/{output → src/output}/cli-hints.js +0 -0
  285. /package/dist/{output → src/output}/context.js +0 -0
  286. /package/dist/{registry → src/registry}/create-provider-registry.js +0 -0
  287. /package/dist/{registry → src/registry}/origin-resolve.js +0 -0
  288. /package/dist/{registry → src/registry}/providers/index.js +0 -0
  289. /package/dist/{registry → src/registry}/providers/skills-sh.js +0 -0
  290. /package/dist/{registry → src/registry}/providers/types.js +0 -0
  291. /package/dist/{registry → src/registry}/types.js +0 -0
  292. /package/dist/{setup → src/setup}/detect.js +0 -0
  293. /package/dist/{setup → src/setup}/ripgrep-install.js +0 -0
  294. /package/dist/{setup → src/setup}/ripgrep-resolve.js +0 -0
  295. /package/dist/{setup → src/setup}/steps.js +0 -0
  296. /package/dist/{sources → src/sources}/include.js +0 -0
  297. /package/dist/{sources → src/sources}/provider-factory.js +0 -0
  298. /package/dist/{sources → src/sources}/provider.js +0 -0
  299. /package/dist/{sources → src/sources}/providers/filesystem.js +0 -0
  300. /package/dist/{sources → src/sources}/providers/index.js +0 -0
  301. /package/dist/{sources → src/sources}/providers/install-types.js +0 -0
  302. /package/dist/{sources → src/sources}/providers/npm.js +0 -0
  303. /package/dist/{sources → src/sources}/providers/provider-utils.js +0 -0
  304. /package/dist/{sources → src/sources}/providers/sync-from-ref.js +0 -0
  305. /package/dist/{sources → src/sources}/providers/tar-utils.js +0 -0
  306. /package/dist/{sources → src/sources}/providers/website.js +0 -0
  307. /package/dist/{sources → src/sources}/resolve.js +0 -0
  308. /package/dist/{sources → src/sources}/types.js +0 -0
  309. /package/dist/{templates → src/templates}/wiki-templates.js +0 -0
  310. /package/dist/{version.js → src/version.js} +0 -0
  311. /package/dist/{workflows → src/workflows}/authoring.js +0 -0
  312. /package/dist/{workflows → src/workflows}/cli.js +0 -0
  313. /package/dist/{workflows → src/workflows}/db.js +0 -0
  314. /package/dist/{workflows → src/workflows}/document-cache.js +0 -0
  315. /package/dist/{workflows → src/workflows}/parser.js +0 -0
  316. /package/dist/{workflows → src/workflows}/renderer.js +0 -0
  317. /package/dist/{workflows → src/workflows}/runs.js +0 -0
  318. /package/dist/{workflows → src/workflows}/schema.js +0 -0
  319. /package/dist/{workflows → src/workflows}/validator.js +0 -0
@@ -0,0 +1,406 @@
1
+ /**
2
+ * Proposal substrate (#225).
3
+ *
4
+ * One durable proposal store for every future reflection / generation flow
5
+ * (`akm reflect`, `akm propose`, `akm distill`, lesson distillation, …).
6
+ * Proposals are *queue state*, not source-of-truth assets — they sit on disk
7
+ * waiting for human (or automated) review and only become assets after
8
+ * `akm proposal accept` validates and promotes them via
9
+ * {@link writeAssetToSource}.
10
+ *
11
+ * # Storage layout
12
+ *
13
+ * <stashRoot>/.akm/proposals/<id>/proposal.json
14
+ * <stashRoot>/.akm/proposals/archive/<id>/proposal.json
15
+ *
16
+ * One directory per proposal id (a stable `crypto.randomUUID()`), so multiple
17
+ * proposals can target the same `ref` without filesystem collisions.
18
+ *
19
+ * # Why direct fs (and not `writeAssetToSource`)
20
+ *
21
+ * The architectural rule "all writes go through `writeAssetToSource`" applies
22
+ * to *assets*. Proposals are **not** assets — they live outside the asset tree
23
+ * (under `.akm/proposals/`, parallel to how `events.jsonl` lives outside the
24
+ * asset tree). Routing them through `writeAssetToSource` would force them into
25
+ * a `TYPE_DIRS` slot, would commit them to git, and would leak unaccepted
26
+ * drafts through the normal indexer. None of that is what we want for queue
27
+ * state. The {@link promoteProposal} step is the bridge: it routes the
28
+ * accepted payload through `writeAssetToSource` so the actual asset write
29
+ * still funnels through the single dispatch point in
30
+ * `src/core/write-source.ts`.
31
+ *
32
+ * Direct `fs` IO here is deliberate and the only place in the v1 codebase
33
+ * that bypasses `writeAssetToSource` for "stash-adjacent" durable state. See
34
+ * CLAUDE.md ("Writes" section) for the contract.
35
+ */
36
+ import { randomUUID } from "node:crypto";
37
+ import fs from "node:fs";
38
+ import path from "node:path";
39
+ import { makeAssetRef, parseAssetRef } from "./asset-ref";
40
+ import { resolveAssetPathFromName, TYPE_DIRS } from "./asset-spec";
41
+ import { NotFoundError, UsageError } from "./errors";
42
+ import { parseFrontmatter } from "./frontmatter";
43
+ import { lintLessonContent } from "./lesson-lint";
44
+ import { resolveWriteTarget, writeAssetToSource } from "./write-source";
45
+ // ── Path helpers ────────────────────────────────────────────────────────────
46
+ /**
47
+ * Resolve `<stashRoot>/.akm/proposals` (or its archive subdirectory). Direct
48
+ * fs paths because proposal storage is queue state, not asset state — see the
49
+ * module docblock for the architectural carve-out.
50
+ */
51
+ export function getProposalsRoot(stashDir, archive = false) {
52
+ return archive ? path.join(stashDir, ".akm", "proposals", "archive") : path.join(stashDir, ".akm", "proposals");
53
+ }
54
+ function proposalDir(stashDir, id, archive) {
55
+ return path.join(getProposalsRoot(stashDir, archive), id);
56
+ }
57
+ function proposalFile(stashDir, id, archive) {
58
+ return path.join(proposalDir(stashDir, id, archive), "proposal.json");
59
+ }
60
+ function nowIso(ctx) {
61
+ const fn = ctx?.now ?? Date.now;
62
+ return new Date(fn()).toISOString();
63
+ }
64
+ function newId(ctx) {
65
+ const fn = ctx?.randomUUID ?? randomUUID;
66
+ return fn();
67
+ }
68
+ // ── Read / write primitives ─────────────────────────────────────────────────
69
+ function readProposalFile(filePath) {
70
+ let raw;
71
+ try {
72
+ raw = fs.readFileSync(filePath, "utf8");
73
+ }
74
+ catch (err) {
75
+ throw new NotFoundError(`Proposal not found at ${filePath}.`, "FILE_NOT_FOUND", `The proposal file is missing or unreadable: ${err.message}`);
76
+ }
77
+ let parsed;
78
+ try {
79
+ parsed = JSON.parse(raw);
80
+ }
81
+ catch (err) {
82
+ throw new UsageError(`Proposal file at ${filePath} is not valid JSON: ${err.message}`, "INVALID_JSON_ARGUMENT", "Re-create the proposal or remove the corrupt file under .akm/proposals/<id>/.");
83
+ }
84
+ if (typeof parsed !== "object" || parsed === null) {
85
+ throw new UsageError(`Proposal file at ${filePath} is not a JSON object.`, "INVALID_JSON_ARGUMENT");
86
+ }
87
+ return parsed;
88
+ }
89
+ function writeProposalFile(filePath, proposal) {
90
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
91
+ fs.writeFileSync(filePath, `${JSON.stringify(proposal, null, 2)}\n`, "utf8");
92
+ }
93
+ // ── Public API ──────────────────────────────────────────────────────────────
94
+ /**
95
+ * Create a new pending proposal. The id is a stable random UUID, so two
96
+ * proposals with the same `ref` never collide on disk.
97
+ */
98
+ export function createProposal(stashDir, input, ctx) {
99
+ // Validate the ref up front so callers get a clear error instead of a
100
+ // surprise during `accept`. This also normalises the ref string.
101
+ const parsedRef = parseAssetRef(input.ref);
102
+ const normalizedRef = makeAssetRef(parsedRef.type, parsedRef.name, parsedRef.origin);
103
+ const id = newId(ctx);
104
+ const created = nowIso(ctx);
105
+ const proposal = {
106
+ id,
107
+ ref: normalizedRef,
108
+ status: "pending",
109
+ source: input.source,
110
+ ...(input.sourceRun !== undefined ? { sourceRun: input.sourceRun } : {}),
111
+ createdAt: created,
112
+ updatedAt: created,
113
+ payload: {
114
+ content: input.payload.content,
115
+ ...(input.payload.frontmatter !== undefined ? { frontmatter: input.payload.frontmatter } : {}),
116
+ },
117
+ };
118
+ writeProposalFile(proposalFile(stashDir, id, false), proposal);
119
+ return proposal;
120
+ }
121
+ /**
122
+ * List every proposal under the stash. By default returns pending proposals
123
+ * from the live queue; pass `{ includeArchive: true }` to include rejected /
124
+ * accepted entries that have been moved aside.
125
+ */
126
+ export function listProposals(stashDir, options = {}) {
127
+ const out = [];
128
+ const roots = [{ dir: getProposalsRoot(stashDir, false), archive: false }];
129
+ if (options.includeArchive) {
130
+ roots.push({ dir: getProposalsRoot(stashDir, true), archive: true });
131
+ }
132
+ for (const { dir } of roots) {
133
+ if (!fs.existsSync(dir))
134
+ continue;
135
+ let entries;
136
+ try {
137
+ entries = fs.readdirSync(dir, { withFileTypes: true });
138
+ }
139
+ catch {
140
+ continue;
141
+ }
142
+ for (const entry of entries) {
143
+ // Skip the archive subdirectory when iterating the live queue.
144
+ if (!entry.isDirectory())
145
+ continue;
146
+ if (entry.name === "archive")
147
+ continue;
148
+ const filePath = path.join(dir, entry.name, "proposal.json");
149
+ if (!fs.existsSync(filePath))
150
+ continue;
151
+ try {
152
+ out.push(readProposalFile(filePath));
153
+ }
154
+ catch {
155
+ // Surface invalid proposal files via a synthetic stub so callers can
156
+ // see something in `akm proposal list` rather than the file
157
+ // disappearing silently.
158
+ out.push({
159
+ id: entry.name,
160
+ ref: "unknown:unknown",
161
+ status: "pending",
162
+ source: "invalid",
163
+ createdAt: "",
164
+ updatedAt: "",
165
+ payload: { content: "" },
166
+ review: {
167
+ outcome: "rejected",
168
+ reason: "Invalid proposal file (could not be parsed).",
169
+ decidedAt: "",
170
+ },
171
+ });
172
+ }
173
+ }
174
+ }
175
+ return out
176
+ .filter((p) => (options.status ? p.status === options.status : true))
177
+ .filter((p) => (options.ref ? p.ref === options.ref : true))
178
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt));
179
+ }
180
+ /**
181
+ * Look up a proposal by id. Searches the live queue first, then the archive.
182
+ * Throws `NotFoundError` when no match exists.
183
+ */
184
+ export function getProposal(stashDir, id) {
185
+ const livePath = proposalFile(stashDir, id, false);
186
+ if (fs.existsSync(livePath))
187
+ return readProposalFile(livePath);
188
+ const archivedPath = proposalFile(stashDir, id, true);
189
+ if (fs.existsSync(archivedPath))
190
+ return readProposalFile(archivedPath);
191
+ throw new NotFoundError(`Proposal "${id}" not found.`, "FILE_NOT_FOUND");
192
+ }
193
+ /**
194
+ * Whether a proposal currently lives in the archive (used by callers that
195
+ * need to know whether to look in the archive root for files / paths).
196
+ */
197
+ export function isProposalArchived(stashDir, id) {
198
+ return !fs.existsSync(proposalFile(stashDir, id, false)) && fs.existsSync(proposalFile(stashDir, id, true));
199
+ }
200
+ /**
201
+ * Move a proposal directory into the archive subtree and update its status.
202
+ * Used by both accept (status `accepted`) and reject (status `rejected`)
203
+ * paths so the live queue only contains pending entries.
204
+ */
205
+ export function archiveProposal(stashDir, id, status, reason, ctx) {
206
+ const sourceDir = proposalDir(stashDir, id, false);
207
+ if (!fs.existsSync(sourceDir)) {
208
+ // If it's already archived, just update the metadata in place.
209
+ const archived = proposalFile(stashDir, id, true);
210
+ if (fs.existsSync(archived)) {
211
+ const existing = readProposalFile(archived);
212
+ const updated = {
213
+ ...existing,
214
+ status,
215
+ updatedAt: nowIso(ctx),
216
+ review: {
217
+ outcome: status,
218
+ ...(reason !== undefined ? { reason } : {}),
219
+ decidedAt: nowIso(ctx),
220
+ },
221
+ };
222
+ writeProposalFile(archived, updated);
223
+ return updated;
224
+ }
225
+ throw new NotFoundError(`Proposal "${id}" not found.`, "FILE_NOT_FOUND");
226
+ }
227
+ const targetDir = proposalDir(stashDir, id, true);
228
+ fs.mkdirSync(path.dirname(targetDir), { recursive: true });
229
+ fs.renameSync(sourceDir, targetDir);
230
+ const updated = {
231
+ ...readProposalFile(proposalFile(stashDir, id, true)),
232
+ status,
233
+ updatedAt: nowIso(ctx),
234
+ review: {
235
+ outcome: status,
236
+ ...(reason !== undefined ? { reason } : {}),
237
+ decidedAt: nowIso(ctx),
238
+ },
239
+ };
240
+ writeProposalFile(proposalFile(stashDir, id, true), updated);
241
+ return updated;
242
+ }
243
+ /**
244
+ * Validate a proposal payload before promotion. Generic by default — any
245
+ * proposal must parse cleanly and carry a non-empty body. Lessons get the
246
+ * extra per-type lint from {@link lintLessonContent} so the contract documented
247
+ * in v1 spec §13 is enforced at promotion time. Other asset types can hook
248
+ * here in the future without changing call sites.
249
+ */
250
+ export function validateProposal(proposal) {
251
+ const findings = [];
252
+ if (!proposal.payload || typeof proposal.payload.content !== "string" || proposal.payload.content.trim() === "") {
253
+ findings.push({ kind: "empty-content", message: `Proposal ${proposal.id} has empty content.` });
254
+ }
255
+ let ref;
256
+ try {
257
+ ref = parseAssetRef(proposal.ref);
258
+ }
259
+ catch (err) {
260
+ findings.push({
261
+ kind: "invalid-ref",
262
+ message: `Proposal ${proposal.id} has invalid ref "${proposal.ref}": ${err.message}`,
263
+ });
264
+ return { ok: false, findings };
265
+ }
266
+ // Generic frontmatter parse check for markdown-y types. If the content
267
+ // *looks* like it has frontmatter (`---\n…\n---`) we ensure it parses.
268
+ if (proposal.payload.content.startsWith("---")) {
269
+ try {
270
+ parseFrontmatter(proposal.payload.content);
271
+ }
272
+ catch (err) {
273
+ findings.push({
274
+ kind: "invalid-frontmatter",
275
+ message: `Proposal ${proposal.id} frontmatter could not be parsed: ${err.message}`,
276
+ });
277
+ }
278
+ }
279
+ // Type-specific validators.
280
+ if (ref.type === "lesson") {
281
+ const lessonReport = lintLessonContent(proposal.payload.content, `proposal:${proposal.id}`);
282
+ for (const finding of lessonReport.findings) {
283
+ findings.push({ kind: finding.kind, message: finding.message });
284
+ }
285
+ }
286
+ return { ok: findings.length === 0, findings };
287
+ }
288
+ /**
289
+ * Validate a proposal, then promote it through the canonical
290
+ * {@link writeAssetToSource} dispatch (the single place that branches on
291
+ * `source.kind`). On success the proposal directory is moved to the archive
292
+ * with status `accepted`. Validation failures throw a `UsageError` carrying
293
+ * every finding so the CLI can render a single clear error envelope.
294
+ */
295
+ export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
296
+ const proposal = getProposal(stashDir, id);
297
+ if (proposal.status !== "pending") {
298
+ throw new UsageError(`Proposal ${id} is not pending (current status: ${proposal.status}). Only pending proposals can be accepted.`, "INVALID_FLAG_VALUE");
299
+ }
300
+ const report = validateProposal(proposal);
301
+ if (!report.ok) {
302
+ const message = report.findings.map((f) => `[${f.kind}] ${f.message}`).join("\n");
303
+ 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.");
304
+ }
305
+ const ref = parseAssetRef(proposal.ref);
306
+ if (!TYPE_DIRS[ref.type]) {
307
+ throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
308
+ }
309
+ const target = resolveWriteTarget(config, options.target);
310
+ const written = await writeAssetToSource(target.source, target.config, ref, proposal.payload.content);
311
+ const archived = archiveProposal(stashDir, id, "accepted", undefined, ctx);
312
+ return { proposal: archived, assetPath: written.path, ref: written.ref };
313
+ }
314
+ /**
315
+ * Compute a diff between a proposal payload and the existing on-disk asset.
316
+ * Uses {@link resolveWriteTarget} to find where the asset would land — so the
317
+ * diff matches exactly what `accept` will write. Falls back to "new asset"
318
+ * when no asset is currently materialised at the target ref.
319
+ */
320
+ export function diffProposal(stashDir, config, id, options = {}) {
321
+ const proposal = getProposal(stashDir, id);
322
+ const ref = parseAssetRef(proposal.ref);
323
+ let targetPath;
324
+ let existing = null;
325
+ try {
326
+ const target = resolveWriteTarget(config, options.target);
327
+ targetPath = resolveAssetFilePathSafe(target.source, ref);
328
+ if (targetPath && fs.existsSync(targetPath)) {
329
+ existing = fs.readFileSync(targetPath, "utf8");
330
+ }
331
+ }
332
+ catch {
333
+ // No writable target configured — still return a "new asset" diff so
334
+ // callers can see the proposed payload without erroring out.
335
+ }
336
+ const proposed = proposal.payload.content;
337
+ if (existing === null) {
338
+ return {
339
+ existing: null,
340
+ proposed,
341
+ unified: formatNewAssetDiff(proposal.ref, proposed),
342
+ isNew: true,
343
+ ...(targetPath ? { targetPath } : {}),
344
+ };
345
+ }
346
+ return {
347
+ existing,
348
+ proposed,
349
+ unified: formatUnifiedDiff(existing, proposed, proposal.ref),
350
+ isNew: false,
351
+ ...(targetPath ? { targetPath } : {}),
352
+ };
353
+ }
354
+ function resolveAssetFilePathSafe(source, ref) {
355
+ const typeDir = TYPE_DIRS[ref.type];
356
+ if (!typeDir)
357
+ return undefined;
358
+ const typeRoot = path.join(source.path, typeDir);
359
+ try {
360
+ return resolveAssetPathFromName(ref.type, typeRoot, ref.name);
361
+ }
362
+ catch {
363
+ return undefined;
364
+ }
365
+ }
366
+ /**
367
+ * Minimal unified-diff renderer. We deliberately avoid pulling a runtime
368
+ * dependency just for this — proposals diffs are usually small (a single
369
+ * lesson / skill file), so the LCS-free greedy renderer below is plenty for
370
+ * humans to review. The output mirrors `git diff --no-index` for the first
371
+ * `@@ … @@` hunk: enough to be familiar, not so detailed that we re-implement
372
+ * a full LCS table.
373
+ */
374
+ export function formatUnifiedDiff(left, right, label) {
375
+ if (left === right)
376
+ return "";
377
+ const leftLines = left.split("\n");
378
+ const rightLines = right.split("\n");
379
+ const lines = [`--- ${label} (existing)`, `+++ ${label} (proposed)`];
380
+ // Pad to the longer side so alignment is one-to-one. Real diff tools use
381
+ // LCS to align matching runs; we don't need that fidelity for a review
382
+ // surface — both halves are visible regardless.
383
+ const max = Math.max(leftLines.length, rightLines.length);
384
+ lines.push(`@@ 1,${leftLines.length} 1,${rightLines.length} @@`);
385
+ for (let i = 0; i < max; i += 1) {
386
+ const l = leftLines[i];
387
+ const r = rightLines[i];
388
+ if (l === r && l !== undefined) {
389
+ lines.push(` ${l}`);
390
+ continue;
391
+ }
392
+ if (l !== undefined)
393
+ lines.push(`-${l}`);
394
+ if (r !== undefined)
395
+ lines.push(`+${r}`);
396
+ }
397
+ return lines.join("\n");
398
+ }
399
+ function formatNewAssetDiff(ref, content) {
400
+ const lines = [`--- /dev/null`, `+++ ${ref} (proposed, new asset)`];
401
+ lines.push(`@@ 0,0 1,${content.split("\n").length} @@`);
402
+ for (const line of content.split("\n")) {
403
+ lines.push(`+${line}`);
404
+ }
405
+ return lines.join("\n");
406
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Module-level quiet/verbose flags for stderr warning gating.
3
+ *
4
+ * `quiet` is controlled by the CLI `--quiet`/`-q` flag.
5
+ * `verbose` is controlled by the CLI `--verbose` flag, with `AKM_VERBOSE`
6
+ * (env var) winning regardless: env > flag > default (false).
7
+ */
8
+ let quiet = false;
9
+ let verbose = false;
10
+ export function setQuiet(value) {
11
+ quiet = value;
12
+ }
13
+ /**
14
+ * Reset the quiet flag to false.
15
+ * Intended for test teardown to prevent quiet state from leaking between tests.
16
+ */
17
+ export function resetQuiet() {
18
+ quiet = false;
19
+ }
20
+ export function isQuiet() {
21
+ return quiet;
22
+ }
23
+ /**
24
+ * Set the verbose flag from a CLI flag. The `AKM_VERBOSE` env var, when set,
25
+ * always wins regardless of this flag (env > flag > default).
26
+ */
27
+ export function setVerbose(value) {
28
+ verbose = value;
29
+ }
30
+ /**
31
+ * Reset the verbose flag to false. Intended for test teardown so verbose
32
+ * state does not leak between tests.
33
+ */
34
+ export function resetVerbose() {
35
+ verbose = false;
36
+ }
37
+ /**
38
+ * Returns true when verbose output is requested.
39
+ *
40
+ * Precedence: `AKM_VERBOSE` env var (when truthy) > `setVerbose(true)` > false.
41
+ * Truthy matches `1`, `true`, `yes`, `on` (case-insensitive). The values
42
+ * `0`, `false`, `no`, `off` hard-disable verbose even if the flag is set,
43
+ * so operators can override per-invocation. Any other value (including
44
+ * empty string) is treated as "not set" and falls through to the flag.
45
+ */
46
+ export function isVerbose() {
47
+ const env = process.env.AKM_VERBOSE?.trim().toLowerCase();
48
+ if (env === "1" || env === "true" || env === "yes" || env === "on")
49
+ return true;
50
+ if (env === "0" || env === "false" || env === "no" || env === "off")
51
+ return false;
52
+ return verbose;
53
+ }
54
+ /**
55
+ * Emit a warning to stderr unless --quiet is active.
56
+ * Drop-in replacement for console.warn() across the codebase.
57
+ */
58
+ export function warn(...args) {
59
+ if (!quiet) {
60
+ console.warn(...args);
61
+ }
62
+ }
63
+ /**
64
+ * Emit a warning only when verbose output is requested. Use for noisy
65
+ * per-item diagnostics that should be replaced by a one-line summary at
66
+ * default verbosity (e.g. registry-content workflow validation errors).
67
+ */
68
+ export function warnVerbose(...args) {
69
+ if (isVerbose()) {
70
+ warn(...args);
71
+ }
72
+ }
@@ -30,6 +30,50 @@ import { ConfigError, UsageError } from "./errors";
30
30
  * {@link assertWritableAllowedForKind}.
31
31
  */
32
32
  const REJECTED_WRITABLE_KINDS = new Set(["website", "npm"]);
33
+ /**
34
+ * Maximum length of a sanitized git commit message. Git itself imposes no
35
+ * fixed limit, but message strings come from refs and `--message` flags that
36
+ * can be supplied by users or upstream config. A 4096-char clamp keeps audit
37
+ * trails readable and prevents pathological payloads from bloating the log
38
+ * stream a downstream consumer parses.
39
+ */
40
+ const COMMIT_MESSAGE_MAX_LENGTH = 4096;
41
+ /**
42
+ * Sanitize a string before passing it as `git commit -m <message>`.
43
+ *
44
+ * Defenses, in order:
45
+ * 1. Strip NUL bytes (`\0`) — git rejects them anyway, but we never want
46
+ * them in argv.
47
+ * 2. Replace any CR/LF (`\r`, `\n`) and other ASCII control chars with a
48
+ * single space. This collapses newline-injection attempts that would
49
+ * otherwise turn a single-line commit subject into a forged trailer
50
+ * block.
51
+ * 3. Collapse runs of whitespace into a single space and trim.
52
+ * 4. Clamp to {@link COMMIT_MESSAGE_MAX_LENGTH} characters.
53
+ *
54
+ * If the result is empty after sanitization the caller should substitute a
55
+ * default — this helper returns `""` rather than throwing because not every
56
+ * callsite has a sensible "invalid input" exit code, and "empty" is a
57
+ * recoverable signal.
58
+ */
59
+ export function sanitizeCommitMessage(input) {
60
+ if (typeof input !== "string")
61
+ return "";
62
+ // 1. Strip NULs outright.
63
+ let out = input.replace(/\0/g, "");
64
+ // 2. Replace CR/LF + other C0 control characters (0x00-0x1F, 0x7F) with a
65
+ // space. Tab (0x09) is included intentionally — commit subjects should
66
+ // be a single visual line.
67
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional sanitization
68
+ out = out.replace(/[\x00-\x1F\x7F]/g, " ");
69
+ // 3. Collapse whitespace runs and trim.
70
+ out = out.replace(/\s+/g, " ").trim();
71
+ // 4. Clamp length.
72
+ if (out.length > COMMIT_MESSAGE_MAX_LENGTH) {
73
+ out = out.slice(0, COMMIT_MESSAGE_MAX_LENGTH).trimEnd();
74
+ }
75
+ return out;
76
+ }
33
77
  // ── Public helpers ──────────────────────────────────────────────────────────
34
78
  /**
35
79
  * Resolve the effective `writable` flag for a source config entry, applying
@@ -135,8 +179,18 @@ export function resolveWriteTarget(akmConfig, explicitTarget) {
135
179
  // 2. config.defaultWriteTarget.
136
180
  if (akmConfig.defaultWriteTarget) {
137
181
  const match = configuredSources.find((s) => s.name === akmConfig.defaultWriteTarget);
138
- if (match)
182
+ if (match) {
183
+ // BUG-H3: mirror the --target writability gate so a misconfigured
184
+ // defaultWriteTarget pointed at a non-writable kind (website/npm) or
185
+ // an explicit `writable: false` filesystem entry fails fast with a
186
+ // ConfigError, rather than surfacing as a generic UsageError after
187
+ // path-building has already begun.
188
+ const effectiveWritable = resolveWritable({ type: match.type, writable: match.writable });
189
+ if (!effectiveWritable) {
190
+ throw new ConfigError(`defaultWriteTarget "${akmConfig.defaultWriteTarget}" is not writable`, "INVALID_CONFIG_FILE", `Set \`writable: true\` on the "${akmConfig.defaultWriteTarget}" source in your config, or change \`defaultWriteTarget\` to a writable source.`);
191
+ }
139
192
  return adaptConfiguredSource(match);
193
+ }
140
194
  // Fall through if the named target no longer exists — surface a clear error.
141
195
  throw new ConfigError(`defaultWriteTarget "${akmConfig.defaultWriteTarget}" does not match any configured source.`, "INVALID_CONFIG_FILE", "Update `defaultWriteTarget` in your config (run `akm config get defaultWriteTarget`) or run `akm list` to see configured sources.");
142
196
  }
@@ -200,9 +254,14 @@ function runGitCommit(repoDir, filePath, message) {
200
254
  if (addResult.status !== 0) {
201
255
  throw new Error(`git add failed: ${addResult.stderr?.trim() || "unknown error"}`);
202
256
  }
257
+ // Defense in depth: sanitize the commit subject one more time at the spawn
258
+ // boundary. Callers should already pass sanitized strings (via
259
+ // formatRefForMessage / saveGitStash), but this guards against future
260
+ // refactors that forget. Empty after sanitize falls back to a safe stub.
261
+ const safeMessage = sanitizeCommitMessage(message) || "akm update";
203
262
  // Provide a fallback identity so fresh CI/test environments without
204
263
  // user.name/user.email configured can always commit.
205
- const commitResult = spawnSync("git", ["-C", repoDir, "-c", "user.name=akm", "-c", "user.email=akm@local", "commit", "-m", message], { encoding: "utf8" });
264
+ const commitResult = spawnSync("git", ["-C", repoDir, "-c", "user.name=akm", "-c", "user.email=akm@local", "commit", "-m", safeMessage], { encoding: "utf8" });
206
265
  if (commitResult.status !== 0) {
207
266
  // `nothing to commit` is a no-op success — the file may have matched the
208
267
  // existing tree exactly. Surface other errors verbatim.
@@ -221,7 +280,16 @@ function runGitPush(repoDir) {
221
280
  }
222
281
  }
223
282
  function formatRefForMessage(ref) {
224
- return ref.origin ? `${ref.origin}//${ref.type}:${ref.name}` : `${ref.type}:${ref.name}`;
283
+ // Sanitize each component independently. `ref.origin` originates from user
284
+ // config and could contain CR/LF that would otherwise be smuggled into the
285
+ // commit subject and forge trailers downstream. `ref.type` and `ref.name`
286
+ // are also sanitized defensively — the asset-spec validator should already
287
+ // reject control bytes there, but a single sanitizer at the boundary keeps
288
+ // the contract explicit and centralized.
289
+ const origin = ref.origin ? sanitizeCommitMessage(ref.origin) : "";
290
+ const type = sanitizeCommitMessage(ref.type);
291
+ const name = sanitizeCommitMessage(ref.name);
292
+ return origin ? `${origin}//${type}:${name}` : `${type}:${name}`;
225
293
  }
226
294
  /**
227
295
  * Derive a {@link WriteTargetSource} + persisted {@link SourceConfigEntry}
@@ -241,8 +309,15 @@ function adaptConfiguredSource(runtime) {
241
309
  throw new ConfigError(`Source "${runtime.name}" has no resolvable on-disk path; writes are unsupported for this entry.`, "INVALID_CONFIG_FILE");
242
310
  }
243
311
  // Map the runtime kind to the write helper's `kind` discriminator. Only
244
- // filesystem and git produce writable sources at v1.
245
- const kind = runtime.type === "filesystem" || runtime.type === "git" ? runtime.type : runtime.type;
312
+ // filesystem and git produce writable sources at v1; any other kind
313
+ // reaching this point is a config-loader bug (assertWritableAllowedForKind
314
+ // should have rejected it). Throw a ConfigError rather than silently
315
+ // forwarding an unsupported kind.
316
+ if (runtime.type !== "filesystem" && runtime.type !== "git") {
317
+ throw new ConfigError(`write-source: source "${runtime.name}" has unsupported kind "${runtime.type}" for writes. ` +
318
+ "Writes are only defined for `filesystem` and `git` sources.", "INVALID_CONFIG_FILE", 'Use `kind: "filesystem"` or `kind: "git"` for writable sources.');
319
+ }
320
+ const kind = runtime.type;
246
321
  const config = {
247
322
  type: runtime.type,
248
323
  name: runtime.name,