akm-cli 0.9.16 → 0.9.17-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (403) hide show
  1. package/CHANGELOG.md +2101 -0
  2. package/STABILITY.md +11 -10
  3. package/dist/akm +124 -193
  4. package/dist/akm-migrate +38 -19
  5. package/dist/assets/hints/cli-hints-full.md +6 -7
  6. package/dist/assets/improve-strategies/catchup.json +0 -3
  7. package/dist/assets/improve-strategies/consolidate.json +0 -1
  8. package/dist/assets/improve-strategies/default.json +1 -2
  9. package/dist/assets/improve-strategies/proactive-maintenance.json +1 -2
  10. package/dist/assets/improve-strategies/quick.json +1 -2
  11. package/dist/assets/improve-strategies/reflect-distill.json +1 -2
  12. package/dist/assets/improve-strategies/thorough.json +0 -3
  13. package/dist/assets/prompts/consolidate-pair.md +20 -0
  14. package/dist/assets/prompts/consolidate-system.md +4 -11
  15. package/dist/assets/prompts/retrieval-relevance-judge.md +6 -0
  16. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +20 -20
  17. package/dist/assets/stash-skeleton/facts/conventions/domains.md +2 -2
  18. package/dist/assets/templates/html/health.html +3 -5
  19. package/dist/cli/retired-commands.js +1 -1
  20. package/dist/cli/shared.js +6 -2
  21. package/dist/cli/unknown-flags.js +24 -1
  22. package/dist/cli.js +68 -10
  23. package/dist/commands/agent/agent-dispatch.js +1 -1
  24. package/dist/commands/command/command-execution.js +24 -62
  25. package/dist/commands/feedback-cli.js +0 -1
  26. package/dist/commands/health/accept-rate.js +6 -0
  27. package/dist/commands/health/archive-usage.js +92 -0
  28. package/dist/commands/health/checks.js +83 -74
  29. package/dist/commands/health/config-skew.js +38 -0
  30. package/dist/commands/health/data-dir-usage.js +25 -13
  31. package/dist/commands/health/egress.js +54 -0
  32. package/dist/commands/health/html-report.js +1 -42
  33. package/dist/commands/health/improve-metrics.js +136 -591
  34. package/dist/commands/health/md-report.js +1 -6
  35. package/dist/commands/health/plugin-staleness.js +53 -3
  36. package/dist/commands/health/renderers.js +12 -4
  37. package/dist/commands/health/report-view-model.js +14 -120
  38. package/dist/commands/health/types-improve.js +4 -19
  39. package/dist/commands/health/windows.js +64 -74
  40. package/dist/commands/health.js +145 -143
  41. package/dist/commands/improve/consolidate/chunking.js +26 -117
  42. package/dist/commands/improve/consolidate/continuity-check.js +137 -0
  43. package/dist/commands/improve/consolidate/pair-pass.js +791 -0
  44. package/dist/commands/improve/consolidate/sanitize.js +54 -149
  45. package/dist/commands/improve/consolidate.js +589 -1127
  46. package/dist/commands/improve/content-hash.js +16 -24
  47. package/dist/commands/improve/distill/content-repair.js +18 -100
  48. package/dist/commands/improve/distill-guards.js +20 -81
  49. package/dist/commands/improve/distill-promotion-policy.js +23 -243
  50. package/dist/commands/improve/distill.js +608 -1041
  51. package/dist/commands/improve/eligibility.js +126 -390
  52. package/dist/commands/improve/execution.js +8 -10
  53. package/dist/commands/improve/extract-prompt.js +1 -2
  54. package/dist/commands/improve/extract.js +487 -1046
  55. package/dist/commands/improve/feedback-valence.js +0 -25
  56. package/dist/commands/improve/improve-cli.js +75 -169
  57. package/dist/commands/improve/improve-result-file.js +10 -66
  58. package/dist/commands/improve/improve-strategies.js +52 -4
  59. package/dist/commands/improve/improve-usage-report.js +18 -64
  60. package/dist/commands/improve/improve.js +480 -1074
  61. package/dist/commands/improve/ledger.js +119 -0
  62. package/dist/commands/improve/locks.js +2 -8
  63. package/dist/commands/improve/loop-stages.js +415 -1073
  64. package/dist/commands/improve/memory/derived-ref.js +12 -77
  65. package/dist/commands/improve/memory/memory-belief.js +16 -118
  66. package/dist/commands/improve/memory/memory-improve.js +266 -14
  67. package/dist/commands/improve/outcome-loop.js +28 -156
  68. package/dist/commands/improve/planner.js +5 -15
  69. package/dist/commands/improve/preparation.js +779 -2319
  70. package/dist/commands/improve/proactive-maintenance.js +34 -101
  71. package/dist/commands/improve/reflect-noise.js +104 -280
  72. package/dist/commands/improve/reflect.js +642 -1353
  73. package/dist/commands/improve/retrieval-gate.js +127 -0
  74. package/dist/commands/improve/retrieval-scope.js +92 -0
  75. package/dist/commands/improve/salience.js +41 -240
  76. package/dist/commands/improve/session-asset.js +19 -100
  77. package/dist/commands/improve/stage.js +322 -0
  78. package/dist/commands/lint/base-linter.js +37 -15
  79. package/dist/commands/proposal/drain.js +261 -578
  80. package/dist/commands/proposal/proposal-cli.js +19 -20
  81. package/dist/commands/proposal/proposal-types.js +31 -24
  82. package/dist/commands/proposal/proposal.js +38 -8
  83. package/dist/commands/proposal/propose.js +134 -160
  84. package/dist/commands/proposal/repository.js +1097 -1394
  85. package/dist/commands/proposal/validators/proposal-quality-validators.js +71 -174
  86. package/dist/commands/proposal/validators/proposal-validators.js +1 -1
  87. package/dist/commands/proposal/validators/proposals.js +22 -89
  88. package/dist/commands/read/curate.js +105 -462
  89. package/dist/commands/read/knowledge.js +3 -2
  90. package/dist/commands/read/search-cli.js +16 -33
  91. package/dist/commands/read/search.js +17 -23
  92. package/dist/commands/read/show.js +57 -108
  93. package/dist/commands/sources/bundle-cli.js +25 -2
  94. package/dist/commands/sources/bundle-config-ops.js +4 -0
  95. package/dist/commands/sources/dangerous-env-audit.js +1 -2
  96. package/dist/commands/sources/info.js +127 -29
  97. package/dist/commands/sources/installed-stashes.js +197 -746
  98. package/dist/commands/sources/schema-repair.js +98 -129
  99. package/dist/commands/sources/source-add.js +62 -12
  100. package/dist/commands/sources/source-manage.js +9 -2
  101. package/dist/commands/sources/stash-cli.js +24 -4
  102. package/dist/commands/tasks/explain.js +10 -13
  103. package/dist/commands/tasks/tasks-cli.js +12 -13
  104. package/dist/commands/tasks/tasks.js +350 -936
  105. package/dist/commands/tasks/validate.js +26 -24
  106. package/dist/commands/workflow/plan.js +22 -29
  107. package/dist/commands/workflow-cli.js +4 -4
  108. package/dist/core/adapter/adapters/akm-adapter.js +2 -1
  109. package/dist/core/adapter/adapters/akm-lint.js +2 -3
  110. package/dist/core/adapter/adapters/akm-metadata.js +42 -12
  111. package/dist/core/adapter/adapters/akm-task-adapter.js +29 -8
  112. package/dist/core/adapter/adapters/akm-workflow-adapter.js +1 -1
  113. package/dist/core/adapter/execution-source.js +17 -29
  114. package/dist/core/asset/asset-placement.js +4 -13
  115. package/dist/core/asset/frontmatter.js +106 -1
  116. package/dist/core/asset/resolve-ref.js +1 -1
  117. package/dist/core/bundle-id.js +42 -5
  118. package/dist/core/bundle-rename.js +285 -0
  119. package/dist/core/config/config-io.js +1 -2
  120. package/dist/core/config/config-schema.js +9 -34
  121. package/dist/core/config/config-walker.js +1 -1
  122. package/dist/core/config/config.js +184 -111
  123. package/dist/core/config/engine-semantics.js +0 -2
  124. package/dist/core/config/legacy-source-shape-shim.js +38 -9
  125. package/dist/core/config/schema/embedding.js +20 -5
  126. package/dist/core/config/schema/engines.js +5 -0
  127. package/dist/core/config/schema/execution.js +1 -1
  128. package/dist/core/config/schema/experimental.js +1 -1
  129. package/dist/core/config/schema/improve-processes.js +54 -125
  130. package/dist/core/config/schema/improve.js +4 -42
  131. package/dist/core/config/schema/index-config.js +9 -48
  132. package/dist/core/config/schema/scheduler.js +12 -12
  133. package/dist/core/config/schema/search.js +6 -22
  134. package/dist/core/env-secret-ref.js +0 -1
  135. package/dist/core/errors.js +8 -9
  136. package/dist/core/file-change.js +13 -5
  137. package/dist/core/file-lock.js +76 -173
  138. package/dist/core/improve-result.js +35 -7
  139. package/dist/core/improve-types.js +0 -1
  140. package/dist/core/logs-db.js +2 -2
  141. package/dist/core/loopback.js +7 -12
  142. package/dist/core/non-task-input.js +20 -0
  143. package/dist/core/parse.js +13 -16
  144. package/dist/core/paths.js +0 -24
  145. package/dist/core/redaction.js +109 -2
  146. package/dist/core/run-lock.js +2 -5
  147. package/dist/core/spawn-env.js +1 -1
  148. package/dist/core/state/migrations.js +123 -61
  149. package/dist/core/state-db-scope.js +2 -4
  150. package/dist/core/state-db.js +126 -692
  151. package/dist/core/time.js +0 -20
  152. package/dist/core/type-presentation.js +1 -9
  153. package/dist/core/write-source.js +294 -1005
  154. package/dist/execution/input-contract.js +1 -1
  155. package/dist/execution/resolved-request.js +135 -689
  156. package/dist/execution/source.js +63 -257
  157. package/dist/execution/target-ref.js +1 -1
  158. package/dist/indexer/bundle-identity-guard.js +2 -2
  159. package/dist/indexer/db/llm-cache.js +2 -2
  160. package/dist/indexer/ensure-index.js +77 -73
  161. package/dist/indexer/index-rebuild-lock.js +3 -11
  162. package/dist/indexer/index-writer-lock.js +8 -17
  163. package/dist/indexer/index-written-assets.js +141 -154
  164. package/dist/indexer/indexer.js +400 -1124
  165. package/dist/indexer/links/declared-links.js +90 -0
  166. package/dist/indexer/materialize-embeddings.js +60 -397
  167. package/dist/indexer/passes/memory-inference.js +96 -90
  168. package/dist/indexer/passes/metadata.js +132 -219
  169. package/dist/indexer/read-preflight.js +0 -7
  170. package/dist/indexer/scan/doc-to-entry.js +2 -3
  171. package/dist/indexer/scan/drain-dir.js +1 -1
  172. package/dist/indexer/search/db-search.js +190 -590
  173. package/dist/indexer/search/fts-query.js +30 -41
  174. package/dist/indexer/search/ranking.js +28 -154
  175. package/dist/indexer/search/search-attribution.js +12 -32
  176. package/dist/indexer/search/search-fields.js +11 -15
  177. package/dist/indexer/search/search-hit-enrichers.js +54 -85
  178. package/dist/indexer/search/search-source.js +1 -4
  179. package/dist/indexer/usage/usage-events.js +36 -7
  180. package/dist/indexer/walk/walker.js +3 -4
  181. package/dist/integrations/agent/engine-fallback.js +23 -40
  182. package/dist/integrations/agent/engine-resolution.js +93 -183
  183. package/dist/integrations/agent/execution.js +507 -0
  184. package/dist/integrations/agent/model-map.js +28 -156
  185. package/dist/integrations/agent/request-lowering.js +66 -141
  186. package/dist/integrations/agent/runner-dispatch.js +143 -321
  187. package/dist/integrations/agent/runner.js +54 -14
  188. package/dist/integrations/lockfile.js +53 -101
  189. package/dist/llm/client.js +18 -6
  190. package/dist/llm/embedders/deterministic.js +2 -3
  191. package/dist/llm/embedders/profile.js +71 -0
  192. package/dist/llm/embedders/remote.js +11 -17
  193. package/dist/llm/feature-gate.js +0 -8
  194. package/dist/llm/index-passes.js +3 -5
  195. package/dist/llm/memory-infer.js +1 -2
  196. package/dist/llm/structured-call.js +5 -24
  197. package/dist/output/generic-render.js +23 -11
  198. package/dist/output/html-render.js +13 -10
  199. package/dist/output/render-registry.js +3 -32
  200. package/dist/output/shapes/helpers.js +25 -38
  201. package/dist/output/shapes/passthrough.js +1 -9
  202. package/dist/{indexer/graph/graph-types.js → output/text/bundle-rename.js} +4 -1
  203. package/dist/output/text/command-format.js +69 -31
  204. package/dist/output/text/helpers.js +1 -1
  205. package/dist/output/text/migrate.js +5 -14
  206. package/dist/output/text/proposal-format.js +48 -3
  207. package/dist/output/text/show-format.js +13 -17
  208. package/dist/output/text/workflow-format.js +0 -32
  209. package/dist/output/text.js +2 -0
  210. package/dist/registry/factory.js +4 -19
  211. package/dist/registry/network.js +66 -220
  212. package/dist/registry/providers/index.js +0 -2
  213. package/dist/registry/providers/skills-sh.js +3 -14
  214. package/dist/registry/providers/static-index.js +24 -26
  215. package/dist/registry/resolve.js +55 -131
  216. package/dist/scripts/akm-migrate-node.js +42948 -92369
  217. package/dist/scripts/akm-migrate.js +42935 -92354
  218. package/dist/setup/registry-stash-loader.js +4 -13
  219. package/dist/setup/semantic-assets.js +3 -44
  220. package/dist/setup/setup.js +1 -1
  221. package/dist/setup/steps/connection.js +5 -6
  222. package/dist/setup/steps/platforms.js +2 -2
  223. package/dist/setup/steps/tasks.js +25 -15
  224. package/dist/sources/provider-factory.js +17 -18
  225. package/dist/sources/providers/filesystem.js +2 -3
  226. package/dist/sources/providers/git-install.js +7 -1
  227. package/dist/sources/providers/git-provider.js +0 -3
  228. package/dist/sources/providers/git-stash.js +83 -21
  229. package/dist/sources/providers/npm.js +2 -4
  230. package/dist/sources/providers/provider-utils.js +5 -10
  231. package/dist/sources/providers/website.js +0 -2
  232. package/dist/sources/snapshot-fetchers/website-ingest.js +1 -1
  233. package/dist/sources/website-url.js +2 -2
  234. package/dist/storage/database.js +9 -35
  235. package/dist/storage/repositories/improve-ledger-repository.js +209 -0
  236. package/dist/storage/repositories/index-connection.js +39 -72
  237. package/dist/storage/repositories/index-entries-repository.js +131 -129
  238. package/dist/storage/repositories/index-entry-mapper.js +1 -2
  239. package/dist/storage/repositories/index-entry-schema.js +101 -268
  240. package/dist/storage/repositories/index-fts-repository.js +86 -256
  241. package/dist/storage/repositories/index-links-repository.js +143 -0
  242. package/dist/storage/repositories/index-llm-cache-repository.js +7 -9
  243. package/dist/storage/repositories/index-meta-repository.js +6 -4
  244. package/dist/storage/repositories/index-schema.js +257 -325
  245. package/dist/storage/repositories/index-utility-repository.js +8 -29
  246. package/dist/storage/repositories/index-vec-repository.js +133 -414
  247. package/dist/storage/repositories/outcome-repository.js +2 -1
  248. package/dist/storage/repositories/proposals-repository.js +104 -1
  249. package/dist/storage/repositories/registry-index-cache-repository.js +100 -0
  250. package/dist/storage/repositories/salience-repository.js +1 -19
  251. package/dist/storage/repositories/task-history-repository.js +26 -4
  252. package/dist/storage/repositories/workflow-runs-repository.js +53 -244
  253. package/dist/storage/sqlite-migrations.js +136 -0
  254. package/dist/storage/sqlite-pragmas.js +11 -9
  255. package/dist/storage/sqlite-transaction.js +170 -0
  256. package/dist/storage/state-db-integrity.js +130 -0
  257. package/dist/tasks/activation-config.js +134 -62
  258. package/dist/tasks/backends/cron.js +191 -302
  259. package/dist/tasks/backends/exec-utils.js +2 -5
  260. package/dist/tasks/backends/launchd.js +141 -748
  261. package/dist/tasks/backends/schtasks.js +119 -623
  262. package/dist/tasks/prepare/prepare-support.js +5 -15
  263. package/dist/tasks/prepare/prepare.js +0 -2
  264. package/dist/tasks/resolve-akm-bin.js +20 -79
  265. package/dist/tasks/run/attempt-lifecycle.js +0 -1
  266. package/dist/tasks/run/load-task.js +1 -1
  267. package/dist/tasks/scheduler-binding.js +20 -238
  268. package/dist/tasks/scheduler-invocation.js +136 -244
  269. package/dist/tasks/scheduler-lock.js +53 -0
  270. package/dist/tasks/scheduler-sync.js +368 -679
  271. package/dist/tasks/source/parse-task-source.js +55 -9
  272. package/dist/tasks/source/task-source-v3-frozen.js +3 -4
  273. package/dist/tasks/source/task-to-v4.js +464 -88
  274. package/dist/workflows/authoring/authoring.js +3 -12
  275. package/dist/workflows/compile.js +211 -0
  276. package/dist/workflows/concurrency-policy.js +13 -74
  277. package/dist/workflows/exec/child-invocation.js +3 -17
  278. package/dist/workflows/exec/child-workflow.js +32 -141
  279. package/dist/workflows/exec/dispatch-redaction.js +13 -53
  280. package/dist/workflows/exec/environment.js +98 -0
  281. package/dist/workflows/exec/exec-unit.js +33 -140
  282. package/dist/workflows/exec/frozen-judge.js +7 -59
  283. package/dist/workflows/exec/native-executor.js +82 -341
  284. package/dist/workflows/exec/param-secrets.js +29 -47
  285. package/dist/workflows/exec/run-workflow.js +154 -387
  286. package/dist/workflows/exec/scheduler.js +9 -36
  287. package/dist/workflows/exec/step-work.js +127 -430
  288. package/dist/workflows/exec/unit-dispatch.js +11 -63
  289. package/dist/workflows/exec/unit-writer.js +8 -52
  290. package/dist/workflows/exec/worktree.js +39 -273
  291. package/dist/workflows/freeze/child-output-references.js +4 -15
  292. package/dist/workflows/freeze/environment.js +99 -92
  293. package/dist/workflows/freeze/freeze.js +172 -0
  294. package/dist/workflows/freeze/step-values.js +19 -21
  295. package/dist/workflows/freeze/targets/child-workflow.js +23 -92
  296. package/dist/workflows/freeze/targets/command.js +10 -33
  297. package/dist/workflows/freeze/targets/script.js +5 -12
  298. package/dist/workflows/freeze/targets/shell.js +3 -6
  299. package/dist/workflows/freeze/targets/task.js +25 -80
  300. package/dist/workflows/freeze/task-bindings.js +20 -67
  301. package/dist/workflows/{source-ir/github-yaml.js → github-yaml.js} +88 -206
  302. package/dist/workflows/ir/params.js +6 -51
  303. package/dist/workflows/ir/plan-hash.js +2 -34
  304. package/dist/workflows/parser.js +140 -43
  305. package/dist/{commands/improve/consolidate/types.js → workflows/plan.js} +2 -1
  306. package/dist/workflows/renderer.js +36 -69
  307. package/dist/workflows/resource-limits.js +12 -120
  308. package/dist/workflows/runtime/agent-identity.js +8 -40
  309. package/dist/workflows/runtime/run-outputs.js +3 -6
  310. package/dist/workflows/runtime/run-plan.js +316 -0
  311. package/dist/workflows/runtime/runs.js +48 -200
  312. package/dist/workflows/runtime/workflow-asset-loader.js +24 -57
  313. package/dist/workflows/{source-ir/semantics.js → source-semantics.js} +16 -20
  314. package/dist/workflows/validate-summary.js +2 -7
  315. package/docs/integration/bundling-akm.md +49 -42
  316. package/docs/migration/README.md +1 -0
  317. package/docs/migration/release-notes/0.9.17.md +43 -0
  318. package/docs/migration/v0.9.1-to-v0.9.2.md +23 -7
  319. package/docs/reference/cli.md +232 -135
  320. package/docs/reference/configuration.md +71 -57
  321. package/docs/reference/data-and-telemetry.md +20 -21
  322. package/docs/reference/tasks.md +105 -39
  323. package/docs/reference/workflow-schema.md +14 -18
  324. package/docs/reference/workflows.md +6 -9
  325. package/package.json +1 -1
  326. package/schemas/akm-config.json +115 -738
  327. package/schemas/akm-workflow.json +1 -0
  328. package/dist/assets/improve-strategies/graph-refresh.json +0 -15
  329. package/dist/assets/prompts/contradiction-judge.md +0 -33
  330. package/dist/assets/prompts/graph-extract-system.md +0 -1
  331. package/dist/assets/prompts/graph-extract-user-prompt.md +0 -35
  332. package/dist/assets/prompts/metadata-enhance-system.md +0 -1
  333. package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +0 -4
  334. package/dist/commands/health/advisories.js +0 -150
  335. package/dist/commands/health/metrics.js +0 -329
  336. package/dist/commands/health/surfaces.js +0 -102
  337. package/dist/commands/improve/anti-collapse.js +0 -83
  338. package/dist/commands/improve/collapse-detector.js +0 -432
  339. package/dist/commands/improve/consolidate/eligibility.js +0 -48
  340. package/dist/commands/improve/consolidate/merge.js +0 -149
  341. package/dist/commands/improve/distill/promote-memory.js +0 -291
  342. package/dist/commands/improve/distill/quality-gate.js +0 -337
  343. package/dist/commands/improve/eval-cases.js +0 -52
  344. package/dist/commands/improve/memory/memory-contradiction-detect.js +0 -291
  345. package/dist/commands/improve/proposal-envelope.js +0 -31
  346. package/dist/commands/improve/run-context.js +0 -123
  347. package/dist/commands/improve/shared.js +0 -31
  348. package/dist/commands/improve/source-identity.js +0 -28
  349. package/dist/commands/improve/triage.js +0 -96
  350. package/dist/commands/proposal/drain-policies.js +0 -151
  351. package/dist/commands/sources/update-transaction.js +0 -220
  352. package/dist/core/action-contributors.js +0 -28
  353. package/dist/core/config/config-version-shim.js +0 -101
  354. package/dist/core/fs-txn.js +0 -405
  355. package/dist/core/lexical-score.js +0 -25
  356. package/dist/core/maintenance-barrier.js +0 -167
  357. package/dist/execution/executable-identity.js +0 -105
  358. package/dist/execution/guarded-source.js +0 -427
  359. package/dist/indexer/db/graph-db.js +0 -444
  360. package/dist/indexer/graph/graph-boost.js +0 -427
  361. package/dist/indexer/graph/graph-dedup.js +0 -95
  362. package/dist/indexer/graph/graph-extraction.js +0 -1108
  363. package/dist/indexer/search/name-match.js +0 -35
  364. package/dist/indexer/search/ranking-contributors.js +0 -515
  365. package/dist/indexer/search/ranking-types.js +0 -4
  366. package/dist/indexer/walk/project-context.js +0 -192
  367. package/dist/integrations/agent/execution-cascade.js +0 -566
  368. package/dist/integrations/agent/execution-definitions.js +0 -202
  369. package/dist/integrations/agent/execution-lowering.js +0 -841
  370. package/dist/integrations/agent/execution-preparation.js +0 -98
  371. package/dist/integrations/agent/inline-execution.js +0 -74
  372. package/dist/llm/graph-extract.js +0 -728
  373. package/dist/llm/metadata-enhance.js +0 -96
  374. package/dist/registry/create-provider-registry.js +0 -29
  375. package/dist/registry/pinned-request-helper.js +0 -247
  376. package/dist/registry/pinned-transport.js +0 -717
  377. package/dist/sources/providers/index.js +0 -14
  378. package/dist/storage/engines/sqlite-migrations.js +0 -271
  379. package/dist/storage/repositories/canaries-repository.js +0 -107
  380. package/dist/storage/repositories/embedding-salvage-repository.js +0 -184
  381. package/dist/storage/repositories/registry-cache.js +0 -113
  382. package/dist/tasks/scheduler-sync-preview.js +0 -52
  383. package/dist/tasks/source/task-to-v3.js +0 -507
  384. package/dist/workflows/freeze/resolve-steps.js +0 -86
  385. package/dist/workflows/freeze/source-freeze.js +0 -64
  386. package/dist/workflows/ir/compile.js +0 -321
  387. package/dist/workflows/ir/environment-v4.js +0 -330
  388. package/dist/workflows/ir/freeze-v4.js +0 -153
  389. package/dist/workflows/ir/schema-v4.js +0 -745
  390. package/dist/workflows/ir/schema.js +0 -354
  391. package/dist/workflows/program/schema.js +0 -77
  392. package/dist/workflows/runtime/checkin.js +0 -57
  393. package/dist/workflows/runtime/plan-classifier.js +0 -196
  394. package/dist/workflows/runtime/unit-checkin.js +0 -45
  395. package/dist/workflows/runtime/unit-phases.js +0 -20
  396. package/dist/workflows/schema.js +0 -4
  397. package/dist/workflows/source-ir/compile.js +0 -200
  398. package/dist/workflows/source-ir/program.js +0 -50
  399. package/dist/workflows/source-ir/result.js +0 -26
  400. package/dist/workflows/source-ir/schema.js +0 -786
  401. package/dist/workflows/source-ir/triggers.js +0 -79
  402. package/dist/workflows/source-ir/uses.js +0 -40
  403. package/dist/workflows/validator.js +0 -60
@@ -2,32 +2,32 @@
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  /**
5
- * write-source — the command-layer helper that performs asset writes.
5
+ * write-source — resolve a write target and publish writes to it.
6
6
  *
7
- * v1 architecture spec §2.6 / §2.7 / §10 step 5 (amended for 0.9.0): writing to
8
- * a source is *not* a SourceProvider interface concern. It's a small
9
- * command-layer helper that does a plain filesystem write for **every** kind.
7
+ * The only module that branches on `source.kind` for writes. It does two
8
+ * things:
10
9
  *
11
- * 0.9.0 amendment (issue #507): the per-asset git commit/push path is retired.
12
- * `writeAssetToSource` / `deleteAssetFromSource` no longer branch on `kind` for
13
- * commit behaviour — they only ever touch the filesystem. Git-backed targets
14
- * are committed in a SINGLE batch at the operation boundary via
15
- * {@link commitWriteTargetBoundary} (which delegates to `saveGitStash`). This
16
- * commits only operation-owned exact paths as one complete commit instead of
17
- * one noisy, incomplete commit per asset.
10
+ * 1. Resolve the destination: `--target` → `defaultWriteTarget` → working
11
+ * stash (`defaultBundle`). `writable` defaults to true on `filesystem` and
12
+ * false on `git`; `website` / `npm` are never writable (the config loader
13
+ * rejects `writable: true` on them).
14
+ * 2. Publish: write the file atomically inside the bundle root, and for a
15
+ * git-backed target commit exactly the operation's paths once at the
16
+ * boundary ({@link commitWriteTargetBoundary} → `saveGitStash`), pushing
17
+ * with `--force-with-lease` when the target is writable and has an
18
+ * upstream.
18
19
  *
19
- * This module is still the **single dispatch point** for write/delete: callers
20
- * (remember, import, source-add, etc.) MUST go through `writeAssetToSource` /
21
- * `deleteAssetFromSource` rather than re-inlining a filesystem write, and they
22
- * fire {@link commitWriteTargetBoundary} once after a batch of mutations to a
23
- * writable git target.
20
+ * Nothing commits per asset (issue #507). Callers write and delete through
21
+ * {@link writeAssetToSource} / {@link deleteAssetFromSource}, then fire
22
+ * {@link commitWriteTargetBoundary} once — or wrap a custom mutation in
23
+ * {@link withWriteTargetMutation}, which does both under the asset lease.
24
24
  */
25
25
  import fs from "node:fs";
26
26
  import path from "node:path";
27
27
  import { withAssetMutationLeaseSync } from "../indexer/index-writer-lock.js";
28
28
  import { lockContentRootFor } from "../integrations/lockfile.js";
29
29
  import { GitStashPushError, getCachePaths, inspectGitUpstream, isGitBackedStash, parseGitRepoUrl, runGit, saveGitStash, } from "../sources/providers/git.js";
30
- import { assertGitExactPathsClean, assertNoIgnoredExactPaths, listIgnoredExactPaths, reconcileGitExactPathIndex, } from "../sources/providers/git-stash.js";
30
+ import { listIgnoredExactPaths } from "../sources/providers/git-stash.js";
31
31
  import { detectAdapterId } from "./adapter/detect-adapter.js";
32
32
  import { ensureAkmMarkdownType } from "./asset/akm-markdown.js";
33
33
  import { assetPathForName, stashDirFor } from "./asset/asset-placement.js";
@@ -39,868 +39,26 @@ import { ConfigError, UsageError } from "./errors.js";
39
39
  import { sanitizeCommitMessage } from "./git-message.js";
40
40
  import { warn, warnOnce } from "./warn.js";
41
41
  import { recordWrittenPath } from "./write-provenance.js";
42
- /**
43
- * Source kinds that the loader is allowed to mark `writable: true`. Anything
44
- * else is rejected at config load (per locked decision 4) — see
45
- * {@link assertWritableAllowedForKind}.
46
- */
47
- const REJECTED_WRITABLE_KINDS = new Set(["website", "npm"]);
48
- const pendingGitMutations = new Map();
49
- const EMPTY_GIT_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
50
- const GIT_PUSH_TIMEOUT_MS = 120_000;
51
- function gitTargetKey(source) {
52
- return `${path.resolve(source.repoPath ?? source.path)}\0${path.resolve(source.path)}`;
53
- }
54
- /** Record the exact post-mutation blob for the target's boundary commit. */
55
- export function recordWriteTargetPath(source, filePath) {
56
- if (source.kind !== "git")
57
- return;
58
- const repoDir = path.resolve(source.repoPath ?? source.path);
59
- if (!isGitBackedStash(repoDir))
60
- return;
61
- const key = gitTargetKey(source);
62
- const pending = pendingGitMutations.get(key) ?? {
63
- baseHead: readOptionalGitHead(repoDir),
64
- snapshots: {},
65
- };
66
- const snapshot = captureGitPathSnapshot({ source, config: { type: "git" } }, filePath);
67
- pending.snapshots[snapshot.path] = snapshot.state;
68
- pendingGitMutations.set(key, pending);
69
- }
70
- function repoRelativeGitPath(source, filePath) {
71
- const repoDir = path.resolve(source.repoPath ?? source.path);
72
- const absolutePath = path.resolve(filePath);
73
- const [relativePath] = normalizePublicationPaths([path.relative(repoDir, absolutePath).replaceAll(path.sep, "/")]);
74
- return relativePath;
75
- }
76
- /** Reject dirty or ignored exact paths before a direct transaction mutation. */
77
- export function assertWriteTargetPathsClean(source, filePaths) {
78
- if (source.kind !== "git")
79
- return;
80
- const repoDir = path.resolve(source.repoPath ?? source.path);
81
- if (!isGitBackedStash(repoDir))
82
- return;
83
- const key = gitTargetKey(source);
84
- const pending = pendingGitMutations.get(key);
85
- for (const filePath of filePaths) {
86
- const relativePath = repoRelativeGitPath(source, filePath);
87
- if (pending && Object.hasOwn(pending.snapshots, relativePath)) {
88
- preflightGitPathMutation(source, filePath);
89
- }
90
- else {
91
- assertGitExactPathsClean(repoDir, [relativePath]);
92
- }
93
- }
94
- }
95
- /** Preflight one operation's complete exact-path set before its first mutation. */
96
- export function planWriteTargetPublication(target, filePaths, options) {
97
- const paths = [...new Set(filePaths.map((filePath) => path.resolve(filePath)))];
98
- assertNoWritePathDescendantSymlinks(target.source, paths);
99
- if (target.source.kind !== "git")
100
- return { target, paths, publish: false };
101
- const repoDir = path.resolve(target.source.repoPath ?? target.source.path);
102
- if (!isGitBackedStash(repoDir))
103
- return { target, paths, publish: false };
104
- const expectedBaseHead = readOptionalGitHead(repoDir);
105
- const relativePaths = paths.map((filePath) => repoRelativeGitPath(target.source, filePath));
106
- const ignored = new Set(listIgnoredExactPaths(repoDir, relativePaths));
107
- if (ignored.size > 0) {
108
- if (options.ignored === "reject") {
109
- throw new UsageError(`Exact Git publication path is ignored: ${[...ignored][0]}. Update .gitignore or choose a tracked destination before writing.`);
110
- }
111
- }
112
- const unignoredPaths = paths.filter((_, index) => !ignored.has(relativePaths[index]));
113
- assertWriteTargetPathsClean(target.source, unignoredPaths);
114
- // Not re-verified against `expectedBaseHead` here: any drift during the
115
- // ignored/clean-path checks above is caught by the SAME expectedBaseHead
116
- // comparison `beginWriteTargetMutation` runs immediately before the first
117
- // mutation (its documented contract, and every real caller calls it right
118
- // after this function returns) -- checking it again here a few lines
119
- // earlier for the exact same value only duplicated that check, not added
120
- // a new one. `publishWriteTargetPlan` independently re-checks a third time
121
- // after the actual mutation, which is a genuinely different point in time
122
- // and stays.
123
- return { target, paths, publish: ignored.size === 0, expectedBaseHead };
124
- }
125
- function assertNoWritePathDescendantSymlinks(source, filePaths) {
126
- const lexicalRoot = path.resolve(source.path);
127
- const canonicalRoot = fs.realpathSync(lexicalRoot);
128
- for (const filePath of filePaths) {
129
- const relative = path.relative(lexicalRoot, filePath);
130
- if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
131
- throw new UsageError(`Write path resolves outside source "${source.name}".`, "PATH_ESCAPE_VIOLATION");
132
- }
133
- let current = canonicalRoot;
134
- for (const segment of relative.split(path.sep)) {
135
- current = path.join(current, segment);
136
- try {
137
- if (fs.lstatSync(current).isSymbolicLink()) {
138
- throw new UsageError(`Write path contains a symbolic link below source "${source.name}": ${relative}`);
139
- }
140
- }
141
- catch (error) {
142
- if (error.code === "ENOENT")
143
- break;
144
- throw error;
145
- }
146
- }
147
- }
148
- }
149
- function assertWriteTargetPlanBase(target, expectedBaseHead) {
150
- const repoDir = path.resolve(target.source.repoPath ?? target.source.path);
151
- if (readOptionalGitHead(repoDir) !== expectedBaseHead) {
152
- throw new UsageError(`Git target "${target.source.name}" advanced after exact-path preflight.`);
153
- }
154
- }
155
- /** Revalidate a publication plan immediately before its first mutation. */
156
- export function beginWriteTargetMutation(plan) {
157
- if (plan.expectedBaseHead === undefined)
158
- return;
159
- assertWriteTargetPlanBase(plan.target, plan.expectedBaseHead);
160
- }
161
- /** Publish exactly the paths bound by {@link planWriteTargetPublication}. */
162
- export function publishWriteTargetPlan(plan, message, expectedSnapshots) {
163
- if (!plan.publish)
164
- return;
165
- if (plan.expectedBaseHead === undefined) {
166
- throw new UsageError(`Git publication plan for "${plan.target.source.name}" has no preflight base.`);
167
- }
168
- assertWriteTargetPlanBase(plan.target, plan.expectedBaseHead);
169
- for (const filePath of plan.paths)
170
- recordWriteTargetPath(plan.target.source, filePath);
171
- commitWriteTargetBoundary(plan.target, message, {
172
- paths: [...plan.paths],
173
- expectedBaseHead: plan.expectedBaseHead,
174
- ...(expectedSnapshots ? { expectedSnapshots } : {}),
175
- });
176
- }
177
- /** Hold the shared asset lease from exact-path preflight through publication. */
178
- export function withWriteTargetMutation(target, paths, options, mutate) {
179
- return withAssetMutationLeaseSync(options.purpose, () => {
180
- const plan = planWriteTargetPublication(target, paths, { ignored: options.ignored });
181
- beginWriteTargetMutation(plan);
182
- const result = mutate();
183
- const expectedSnapshots = captureIntendedWriteTargetState(plan);
184
- publishWriteTargetPlan(plan, options.message, expectedSnapshots);
185
- return result;
186
- });
187
- }
188
- function captureIntendedWriteTargetState(plan) {
189
- if (plan.expectedBaseHead === undefined)
190
- return undefined;
191
- const snapshots = {};
192
- for (const filePath of plan.paths) {
193
- const snapshot = captureGitPathSnapshot(plan.target, filePath);
194
- snapshots[snapshot.path] = snapshot.state;
195
- }
196
- return snapshots;
197
- }
198
- function readOptionalGitHead(repoDir) {
199
- const result = runGit(["-C", repoDir, "rev-parse", "--verify", "HEAD"]);
200
- return result.status === 0 && result.stdout.trim() ? result.stdout.trim() : null;
201
- }
202
- function preflightGitPathMutation(source, filePath) {
203
- if (source.kind !== "git")
204
- return undefined;
205
- const repoDir = path.resolve(source.repoPath ?? source.path);
206
- if (!isGitBackedStash(repoDir))
207
- return undefined;
208
- const relativePath = repoRelativeGitPath(source, filePath);
209
- const key = gitTargetKey(source);
210
- let pending = pendingGitMutations.get(key);
211
- const created = pending === undefined;
212
- if (!pending) {
213
- pending = { baseHead: readOptionalGitHead(repoDir), snapshots: {} };
214
- pendingGitMutations.set(key, pending);
215
- }
216
- try {
217
- if (readOptionalGitHead(repoDir) !== pending.baseHead) {
218
- throw new UsageError(`Git target "${source.name}" advanced before its exact-path mutation.`);
219
- }
220
- if (!Object.hasOwn(pending.snapshots, relativePath)) {
221
- assertGitExactPathsClean(repoDir, [relativePath]);
222
- return { key, created };
223
- }
224
- assertNoIgnoredExactPaths(repoDir, [relativePath]);
225
- const index = runGit([
226
- "--literal-pathspecs",
227
- "-C",
228
- repoDir,
229
- "diff",
230
- "--cached",
231
- "--quiet",
232
- pending.baseHead ?? EMPTY_GIT_TREE,
233
- "--",
234
- relativePath,
235
- ]);
236
- if (index.status === 1) {
237
- throw new UsageError(`Exact Git operation path has staged work: ${relativePath}. Commit, stash, or discard that path before retrying.`);
238
- }
239
- if (index.status !== 0) {
240
- throw new Error(`Cannot inspect Git index for exact operation path ${relativePath}: ${index.stderr.trim()}`);
241
- }
242
- const current = captureGitPathSnapshot({ source, config: { type: "git" } }, filePath);
243
- if (!sameGitPathState(current.state, pending.snapshots[relativePath] ?? null)) {
244
- throw new UsageError(`Exact Git operation path changed after AKM wrote it: ${relativePath}. Commit, stash, or discard that path before retrying.`);
245
- }
246
- return { key, created };
247
- }
248
- catch (error) {
249
- if (created && Object.keys(pending.snapshots).length === 0)
250
- pendingGitMutations.delete(key);
251
- throw error;
252
- }
253
- }
254
- function discardEmptyGitPreflight(preflight) {
255
- if (!preflight?.created)
256
- return;
257
- const pending = pendingGitMutations.get(preflight.key);
258
- if (pending && Object.keys(pending.snapshots).length === 0)
259
- pendingGitMutations.delete(preflight.key);
260
- }
261
- // ── Portability advisory (review 13, D1) ────────────────────────────────────
262
- /**
263
- * Matches an absolute host **home** path — `/home/<user>` or `/Users/<user>` —
264
- * requiring at least one user segment after the prefix. A bare `/home/` or
265
- * `/Users/` (no user segment) does NOT match. The user segment stops at the
266
- * first path separator, whitespace, or common delimiter so we capture just the
267
- * `/home/<user>` prefix rather than the whole path.
268
- *
269
- * Deliberately conservative: it does not exempt fenced code blocks, so content
270
- * that legitimately *documents* a system path (e.g. a tutorial) can produce a
271
- * false positive. That is accepted — the advisory is non-fatal and correctness
272
- * (never missing a real leak) is preferred over cleverness here.
273
- */
274
- const ABSOLUTE_HOME_PATH_RE = /\/(?:home|Users)\/[^\s/"'`)\]}<>|:;,]+/g;
275
- /**
276
- * Return the distinct `/home/<user>` / `/Users/<user>` prefixes embedded in
277
- * `content`, in first-seen order. Empty when the content is portable.
278
- *
279
- * Used by {@link writeAssetToSource} to emit a write-time advisory: absolute
280
- * host home paths make the stash non-portable and leak the local username.
281
- */
282
- export function findAbsoluteHomePaths(content) {
283
- const seen = new Set();
284
- for (const match of content.matchAll(ABSOLUTE_HOME_PATH_RE)) {
285
- seen.add(match[0]);
286
- }
287
- return [...seen];
288
- }
289
- // ── Public helpers ──────────────────────────────────────────────────────────
290
- /**
291
- * Resolve the effective `writable` flag for a source config entry, applying
292
- * the v1 default policy from spec §5.4:
293
- *
294
- * - `filesystem` → `true` by default
295
- * - everything else → `false` by default
296
- *
297
- * Users can opt out for `filesystem` via `writable: false`. They cannot opt
298
- * **in** for `website` / `npm` — that combination is rejected at config load
299
- * (see {@link assertWritableAllowedForKind}).
300
- */
42
+ // ── Write-target resolution ─────────────────────────────────────────────────
43
+ /** `writable` defaults to true on `filesystem` and false on every other kind. */
301
44
  export function resolveWritable(entry) {
302
45
  if (typeof entry.writable === "boolean")
303
46
  return entry.writable;
304
47
  return entry.type === "filesystem";
305
48
  }
306
- /**
307
- * Reject `writable: true` on `website` / `npm` sources at config-load time.
308
- * Per locked decision 4 (§6 of the v1 implementation plan): `sync()` would
309
- * clobber writes on the next refresh, so allowing writes here is a footgun.
310
- *
311
- * Throws {@link ConfigError} when the combination is rejected.
312
- */
313
- export function assertWritableAllowedForKind(entry) {
314
- if (entry.writable !== true)
315
- return;
316
- if (REJECTED_WRITABLE_KINDS.has(entry.type)) {
317
- const label = entry.name ? ` "${entry.name}"` : "";
318
- throw new ConfigError(`writable: true is only supported on filesystem and git sources (got "${entry.type}" on source${label}).`, "INVALID_CONFIG_FILE", "To author into a checked-out package, add the same path as a separate filesystem source.");
319
- }
320
- }
321
- /**
322
- * Write a textual asset (`content`) into `source` at the path implied by
323
- * `ref`. Always:
324
- *
325
- * 1. Refuses if `config.writable` is not truthy (per §5.4).
326
- * 2. Rejects unsupported kinds (anything but `filesystem` / `git`).
327
- * 3. Performs a plain filesystem write to `path.join(source.path, …)`.
328
- *
329
- * No commit runs here — for **every** kind. Git-backed targets are committed in
330
- * one batch at the operation boundary via {@link commitWriteTargetBoundary}
331
- * (0.9.0 amendment, issue #507). The caller fires that boundary commit once
332
- * after a batch of mutations to a writable git target.
333
- */
334
- export async function writeAssetToSource(source, config, ref, content) {
335
- ensureWritable(source, config);
336
- assertSupportedKind(source);
337
- assertAkmAssetWrite(source);
338
- const filePath = resolveAssetFilePath(source, ref);
339
- const authored = filePath.toLowerCase().endsWith(".md") ? ensureAkmMarkdownType(content, ref.type) : content;
340
- const normalized = authored.endsWith("\n") ? authored : `${authored}\n`;
341
- const preflight = preflightGitPathMutation(source, filePath);
342
- try {
343
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
344
- // Atomic: truncate-and-rewrite left a window in which a crash, a full disk,
345
- // or a concurrent reader saw a half-written or empty asset — destroying user
346
- // content that was fine a moment earlier. writeFileAtomic writes a sibling
347
- // temp file, fdatasyncs it, and renames over the target.
348
- writeFileAtomic(filePath, normalized, existingFileMode(filePath));
349
- recordWriteTargetPath(source, filePath);
350
- // #652: run-scoped write provenance — the canonical asset write is the
351
- // single largest contributor to an improve run's written-path set.
352
- recordWrittenPath(filePath);
353
- }
354
- catch (error) {
355
- discardEmptyGitPreflight(preflight);
356
- throw error;
357
- }
358
- // Non-fatal portability advisory (review 13, D1): flag absolute host home
359
- // paths in the written content. These make the stash non-portable and leak
360
- // the local username. We warn AFTER the write so the advisory never blocks it.
361
- const hostPaths = findAbsoluteHomePaths(normalized);
362
- if (hostPaths.length > 0) {
363
- warn(`warning: asset "${formatRefForMessage(ref)}" embeds absolute host path(s): ${hostPaths.join(", ")}. ` +
364
- "These make the stash non-portable and leak the local username — prefer $HOME or ~ relative references.");
365
- }
366
- return { path: filePath, ref: displayRef({ type: ref.type, name: ref.name, bundleId: ref.origin }) };
367
- }
368
- /**
369
- * Delete the asset at `ref` from `source`. Symmetric to
370
- * {@link writeAssetToSource}: same writable check, same unsupported-kind guard,
371
- * a plain `unlink` with no commit. Git-backed targets are committed once at the
372
- * operation boundary via {@link commitWriteTargetBoundary}.
373
- */
374
- export async function deleteAssetFromSource(source, config, ref) {
375
- ensureWritable(source, config);
376
- assertSupportedKind(source);
377
- assertAkmAssetWrite(source);
378
- const filePath = resolveAssetFilePath(source, ref);
379
- if (!fs.existsSync(filePath)) {
380
- throw new UsageError(`Asset "${formatRefForMessage(ref)}" not found in source "${source.name}" (expected at ${filePath}).`, "MISSING_REQUIRED_ARGUMENT");
381
- }
382
- const preflight = preflightGitPathMutation(source, filePath);
383
- try {
384
- fs.unlinkSync(filePath);
385
- recordWriteTargetPath(source, filePath);
386
- // #652: a removal is journaled exactly like a write — the stager stages the
387
- // final on-disk state, so a deleted path lands as a staged deletion.
388
- recordWrittenPath(filePath);
389
- }
390
- catch (error) {
391
- discardEmptyGitPreflight(preflight);
392
- throw error;
393
- }
394
- return { path: filePath, ref: displayRef({ type: ref.type, name: ref.name, bundleId: ref.origin }) };
395
- }
396
- /**
397
- * Fire the one-shot batch-at-boundary commit for a resolved write target.
398
- *
399
- * 0.9.0 (issue #507): replaces the retired per-asset git commit. Callers invoke
400
- * this EXACTLY ONCE after a batch of writes/deletes to a resolved write target.
401
- * It is a no-op for any non-git target (plain filesystem sources and the
402
- * primary stash stay non-committing here — the primary stash is committed by
403
- * the existing improve auto-sync boundary).
404
- *
405
- * For a git target it delegates to `saveGitStash(name, message, writable, …)`
406
- * with the exact paths recorded by the write/delete helpers (plus any explicit
407
- * caller paths), commits once, and pushes when the target is writable, has a
408
- * remote, and `push !== false`.
409
- *
410
- */
411
- export function commitWriteTargetBoundary(target, message, options) {
412
- if (target.source.kind !== "git")
413
- return;
414
- const push = options?.push;
415
- const writable = resolveWritable(target.config);
416
- const repoDir = path.resolve(target.source.repoPath ?? target.source.path);
417
- const key = gitTargetKey(target.source);
418
- const pending = pendingGitMutations.get(key);
419
- const expectedBaseHead = options?.expectedBaseHead !== undefined
420
- ? options.expectedBaseHead
421
- : pending
422
- ? pending.baseHead
423
- : readOptionalGitHead(repoDir);
424
- const normalizeBoundaryPath = (filePath) => {
425
- const relativePath = path.isAbsolute(filePath)
426
- ? path.relative(repoDir, path.resolve(filePath)).replaceAll(path.sep, "/")
427
- : filePath.replaceAll(path.sep, "/");
428
- return normalizePublicationPaths([relativePath])[0];
429
- };
430
- const providedSnapshots = new Map(Object.entries(options?.expectedSnapshots ?? {}).map(([filePath, state]) => [
431
- normalizeBoundaryPath(filePath),
432
- state,
433
- ]));
434
- const paths = normalizePublicationPaths([
435
- ...(options?.paths ?? []).map(normalizeBoundaryPath),
436
- ...Object.keys(pending?.snapshots ?? {}),
437
- ]);
438
- const expectedSnapshots = {};
439
- for (const filePath of paths) {
440
- if (providedSnapshots.has(filePath)) {
441
- expectedSnapshots[filePath] = providedSnapshots.get(filePath) ?? null;
442
- }
443
- else if (pending && Object.hasOwn(pending.snapshots, filePath)) {
444
- expectedSnapshots[filePath] = pending.snapshots[filePath] ?? null;
445
- }
446
- else {
447
- expectedSnapshots[filePath] = captureGitPathSnapshot(target, path.join(repoDir, filePath)).state;
448
- }
449
- }
450
- if (options?.expectedBaseHead !== undefined &&
451
- pending !== undefined &&
452
- options.expectedBaseHead !== pending.baseHead) {
453
- throw new Error(`Git boundary base does not match the recorded exact-path mutation base.`);
454
- }
455
- // Assets may live under <repo>/content, but git synchronization always runs
456
- // against the repository root.
457
- try {
458
- saveGitStash(undefined, message, writable, {
459
- repoDir,
460
- paths,
461
- expectedSnapshots,
462
- expectedBaseHead,
463
- ...(push === undefined ? {} : { push }),
464
- ...(options?.transactionId === undefined ? {} : { transactionId: options.transactionId }),
465
- });
466
- pendingGitMutations.delete(key);
467
- }
468
- catch (error) {
469
- const currentHead = readOptionalGitHead(repoDir);
470
- if (pending && currentHead !== pending.baseHead)
471
- pendingGitMutations.delete(key);
472
- if (error instanceof GitStashPushError) {
473
- throw new Error(`Changes were committed as ${error.commit}, but publication failed: ${error.message}`, {
474
- cause: error,
475
- });
476
- }
477
- throw error;
478
- }
479
- }
480
- function sameGitPathState(left, right) {
481
- return left === null ? right === null : right !== null && left.oid === right.oid && left.mode === right.mode;
482
- }
483
- /** Capture the exact checkout/upstream identity before a durable mutation starts. */
484
- export function captureGitPublication(target) {
485
- if (target.source.kind !== "git")
486
- return undefined;
487
- const identity = readGitPublicationIdentity(target);
488
- const head = runGit(["-C", identity.repoPath, "rev-parse", "HEAD"]);
489
- if (head.status !== 0 || !head.stdout.trim()) {
490
- throw new Error(`Cannot read Git HEAD for target "${target.source.name}".`);
491
- }
492
- const baseHead = head.stdout.trim();
493
- let upstreamHead;
494
- if (identity.upstream) {
495
- const upstream = runGit(["-C", identity.repoPath, "rev-parse", identity.upstream]);
496
- if (upstream.status !== 0 || !upstream.stdout.trim()) {
497
- throw new Error(`Cannot read Git upstream for target "${target.source.name}".`);
498
- }
499
- upstreamHead = upstream.stdout.trim();
500
- if (baseHead !== upstreamHead) {
501
- throw new Error(`Writable Git target "${target.source.name}" changed after mutation preflight.`);
502
- }
503
- }
504
- return { ...identity, baseHead, ...(upstreamHead ? { upstreamHead } : {}) };
505
- }
506
- /** Capture one repo-relative path exactly as Git will stage it. */
507
- export function captureGitPathSnapshot(target, filePath) {
508
- if (target.source.kind !== "git")
509
- throw new Error(`Target "${target.source.name}" is not Git-backed.`);
510
- const repoPath = path.resolve(target.source.repoPath ?? target.source.path);
511
- const absolutePath = path.resolve(filePath);
512
- const relativePath = path.relative(repoPath, absolutePath).replaceAll(path.sep, "/");
513
- const [normalizedPath] = normalizePublicationPaths([relativePath]);
514
- return {
515
- path: normalizedPath,
516
- state: captureGitPathState(repoPath, absolutePath, normalizedPath),
517
- };
518
- }
519
- function captureGitPathState(repoPath, absolutePath, relativePath) {
520
- let stat;
521
- try {
522
- stat = fs.lstatSync(absolutePath);
523
- }
524
- catch (error) {
525
- if (error.code === "ENOENT")
526
- return null;
527
- throw error;
528
- }
529
- let mode;
530
- let oidResult;
531
- if (stat.isSymbolicLink()) {
532
- mode = "120000";
533
- oidResult = runGit(["-C", repoPath, "hash-object", "--stdin"], { input: fs.readlinkSync(absolutePath) });
534
- }
535
- else if (stat.isFile()) {
536
- mode = stat.mode & 0o111 ? "100755" : "100644";
537
- oidResult = runGit(["-C", repoPath, "hash-object", `--path=${relativePath}`, "--", absolutePath]);
538
- }
539
- else {
540
- throw new Error(`Git publication path is not a file: ${relativePath}`);
541
- }
542
- if (oidResult.status !== 0 || !oidResult.stdout.trim()) {
543
- throw new Error(`Cannot snapshot Git publication path: ${relativePath}`);
544
- }
545
- return { oid: oidResult.stdout.trim(), mode };
546
- }
547
- /** Create or recover the one transaction-owned commit without pushing it. */
548
- export function ensureGitTransactionCommit(target, publication, options) {
549
- const paths = normalizePublicationPaths(options.paths);
550
- validateGitWorktreeSnapshots(target, paths, options.snapshots);
551
- if (publication.commit !== undefined) {
552
- if (publication.commit !== null) {
553
- validateGitTransactionCommit(target, publication, publication.commit, options.transactionId, paths, options.snapshots);
554
- reconcileGitExactPathIndex(publication.repoPath, publication.baseHead, publication.commit, paths);
555
- }
556
- return publication.commit;
557
- }
558
- const existing = findGitTransactionCommit(publication.repoPath, publication.baseHead, options.transactionId);
559
- if (existing) {
560
- validateGitTransactionCommit(target, publication, existing, options.transactionId, paths, options.snapshots);
561
- reconcileGitExactPathIndex(publication.repoPath, publication.baseHead, existing, paths);
562
- return existing;
563
- }
564
- const head = readGitHead(publication.repoPath, target.source.name);
565
- if (head !== publication.baseHead) {
566
- throw new Error(`Cannot publish Git transaction ${options.transactionId}: target "${target.source.name}" advanced before its commit was recorded.`);
567
- }
568
- commitWriteTargetBoundary(target, options.message, {
569
- paths,
570
- push: false,
571
- transactionId: options.transactionId,
572
- expectedBaseHead: publication.baseHead,
573
- expectedSnapshots: options.snapshots,
574
- });
575
- const committed = findGitTransactionCommit(publication.repoPath, publication.baseHead, options.transactionId);
576
- if (!committed) {
577
- if (readGitHead(publication.repoPath, target.source.name) === publication.baseHead) {
578
- validateGitCommitSnapshots(publication.repoPath, publication.baseHead, paths, options.snapshots);
579
- return null;
580
- }
581
- throw new Error(`Cannot identify the Git commit for transaction ${options.transactionId}.`);
582
- }
583
- validateGitTransactionCommit(target, publication, committed, options.transactionId, paths, options.snapshots);
584
- reconcileGitExactPathIndex(publication.repoPath, publication.baseHead, committed, paths);
585
- return committed;
586
- }
587
- /**
588
- * Kind-neutral snapshot capture for one mutated path.
589
- *
590
- * Returns `undefined` for kinds with no publication model, so command layers
591
- * never branch on `source.kind` themselves — the same fail-soft contract
592
- * {@link captureGitPublication} and {@link commitWriteTargetBoundary} already
593
- * use. `captureGitPathSnapshot` throws for non-git targets, which is what
594
- * forced callers to guard; this absorbs that guard.
595
- */
596
- export function captureWriteTargetPathSnapshot(target, filePath) {
597
- if (target.source.kind !== "git")
598
- return undefined;
599
- return captureGitPathSnapshot(target, filePath);
600
- }
601
- /**
602
- * Kind-neutral commit + publish for one transaction boundary.
603
- *
604
- * A no-op (returns `undefined`) for kinds with no publication model. For a
605
- * publication-backed target this absorbs BOTH the kind test and the
606
- * "transaction lacks durable publication identity" invariant, so callers hold
607
- * no provider knowledge. `onCommitRecorded` fires between the ensure and the
608
- * push, letting a caller persist the commit and advance its own journal phase
609
- * without inspecting the target.
610
- *
611
- * `missingPublicationError` lets a caller keep its own error CLASS for the
612
- * missing-identity case. The two call sites disagreed historically —
613
- * consolidate threw `ConfigError` (exit 78), proposal a plain `Error`
614
- * (exit 70) — and collapsing them here would silently change one command's
615
- * exit code. The guard moves; the classification stays with the caller.
616
- */
617
- export function publishWriteTargetTransaction(target, publication, options) {
618
- if (target.source.kind !== "git")
619
- return undefined;
620
- if (!publication) {
621
- throw (options.missingPublicationError?.(target.source.name) ??
622
- new Error(`Proposal transaction ${options.transactionId} has no Git publication identity.`));
623
- }
624
- const commit = ensureGitTransactionCommit(target, publication, {
625
- transactionId: options.transactionId,
626
- message: options.message,
627
- paths: options.paths,
628
- snapshots: options.snapshots,
629
- });
630
- options.onCommitRecorded?.(commit);
631
- publishGitTransactionCommit(target, publication, options.transactionId, options.paths, options.snapshots);
632
- return { commit };
633
- }
634
- /** Push only the recorded transaction commit, never later local descendants. */
635
- export function publishGitTransactionCommit(target, publication, transactionId, paths, snapshots) {
636
- if (publication.commit === undefined) {
637
- throw new Error(`Git transaction ${transactionId} has no recorded publication decision.`);
638
- }
639
- if (publication.commit === null)
640
- return;
641
- validateGitTransactionCommit(target, publication, publication.commit, transactionId, normalizePublicationPaths(paths), snapshots);
642
- if (!publication.remote || !publication.mergeRef)
643
- return;
644
- if (!publication.upstream)
645
- throw new Error(`Git transaction ${transactionId} has no recorded upstream.`);
646
- if (runGit(["-C", publication.repoPath, "merge-base", "--is-ancestor", publication.commit, publication.upstream])
647
- .status === 0) {
648
- return;
649
- }
650
- if (runGit(["-C", publication.repoPath, "merge-base", "--is-ancestor", publication.upstream, publication.commit])
651
- .status !== 0) {
652
- throw new Error(`Cannot publish Git transaction ${transactionId}: upstream history diverged.`);
653
- }
654
- if (!publication.upstreamHead) {
655
- throw new Error(`Git transaction ${transactionId} has no recorded upstream lease.`);
656
- }
657
- const pushed = runGit([
658
- "-C",
659
- publication.repoPath,
660
- "push",
661
- `--force-with-lease=${publication.mergeRef}:${publication.upstreamHead}`,
662
- publication.remote,
663
- `${publication.commit}:${publication.mergeRef}`,
664
- ], { timeout: GIT_PUSH_TIMEOUT_MS });
665
- if (pushed.status !== 0) {
666
- throw new Error(`git push failed for target "${target.source.name}": ${pushed.stderr.trim()}`);
667
- }
668
- }
669
- function readGitPublicationIdentity(target) {
670
- const repoPath = path.resolve(target.source.repoPath ?? target.source.path);
671
- const branchResult = runGit(["-C", repoPath, "symbolic-ref", "--quiet", "--short", "HEAD"]);
672
- const branch = branchResult.status === 0 ? branchResult.stdout.trim() : undefined;
673
- const remotes = runGit(["-C", repoPath, "remote"]);
674
- if (remotes.status !== 0)
675
- throw new Error(`Cannot inspect Git remotes for target "${target.source.name}".`);
676
- if (!remotes.stdout.trim())
677
- return { repoPath, ...(branch ? { branch } : {}) };
678
- if (!branch)
679
- throw new Error(`Writable Git target "${target.source.name}" is detached from a branch.`);
680
- const remoteResult = runGit(["-C", repoPath, "config", "--get", `branch.${branch}.remote`]);
681
- const mergeResult = runGit(["-C", repoPath, "config", "--get", `branch.${branch}.merge`]);
682
- if (remoteResult.status !== 0 || mergeResult.status !== 0) {
683
- throw new Error(`Writable Git target "${target.source.name}" has no configured upstream branch.`);
684
- }
685
- const remote = remoteResult.stdout.trim();
686
- const mergeRef = mergeResult.stdout.trim();
687
- const upstreamResult = runGit(["-C", repoPath, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
688
- if (upstreamResult.status !== 0 || !upstreamResult.stdout.trim()) {
689
- throw new Error(`Cannot read Git upstream for target "${target.source.name}".`);
690
- }
691
- const urlResult = runGit(["-C", repoPath, "remote", "get-url", remote]);
692
- if (urlResult.status !== 0 || !urlResult.stdout.trim()) {
693
- throw new Error(`Cannot read Git remote "${remote}" for target "${target.source.name}".`);
694
- }
695
- const pushUrlsResult = runGit(["-C", repoPath, "remote", "get-url", "--push", "--all", remote]);
696
- if (pushUrlsResult.status !== 0 || !pushUrlsResult.stdout.trim()) {
697
- throw new Error(`Cannot read Git push URL for target "${target.source.name}".`);
698
- }
699
- return {
700
- repoPath,
701
- branch,
702
- remote,
703
- mergeRef,
704
- remoteUrl: urlResult.stdout.trim(),
705
- pushUrls: pushUrlsResult.stdout
706
- .split("\n")
707
- .map((value) => value.trim())
708
- .filter(Boolean),
709
- upstream: upstreamResult.stdout.trim(),
710
- };
711
- }
712
- function normalizePublicationPaths(paths) {
713
- const normalized = new Set();
714
- for (const filePath of paths) {
715
- const candidate = filePath.replaceAll(path.sep, "/");
716
- if (!candidate ||
717
- candidate.includes("\0") ||
718
- path.isAbsolute(filePath) ||
719
- path.posix.isAbsolute(candidate) ||
720
- candidate === "." ||
721
- candidate === ".." ||
722
- candidate.startsWith("../") ||
723
- path.posix.normalize(candidate) !== candidate) {
724
- throw new Error("Git publication contains an unsafe path.");
725
- }
726
- normalized.add(candidate);
727
- }
728
- return [...normalized];
729
- }
730
- function readGitHead(repoPath, targetName) {
731
- const result = runGit(["-C", repoPath, "rev-parse", "HEAD"]);
732
- if (result.status !== 0 || !result.stdout.trim())
733
- throw new Error(`Cannot read Git HEAD for target "${targetName}".`);
734
- return result.stdout.trim();
735
- }
736
- function findGitTransactionCommit(repoPath, baseHead, transactionId) {
737
- const result = runGit([
738
- "-C",
739
- repoPath,
740
- "log",
741
- "--format=%H",
742
- "--fixed-strings",
743
- `--grep=AKM-Transaction: ${transactionId}`,
744
- `${baseHead}..HEAD`,
745
- ]);
746
- if (result.status !== 0)
747
- throw new Error(`Cannot inspect Git transaction ${transactionId}.`);
748
- const matches = result.stdout
749
- .split("\n")
750
- .map((value) => value.trim())
751
- .filter(Boolean);
752
- if (matches.length > 1)
753
- throw new Error(`Git transaction ${transactionId} has multiple candidate commits.`);
754
- return matches[0];
755
- }
756
- function validateGitTransactionCommit(target, publication, commit, transactionId, expectedPaths, snapshots) {
757
- const parent = runGit(["-C", publication.repoPath, "rev-list", "--parents", "-n", "1", commit]);
758
- const ancestry = parent.stdout.trim().split(/\s+/);
759
- if (parent.status !== 0 || ancestry.length !== 2 || ancestry[1] !== publication.baseHead) {
760
- throw new Error(`Git commit ${commit} is not the direct transaction commit for "${target.source.name}".`);
761
- }
762
- const message = runGit(["-C", publication.repoPath, "show", "-s", "--format=%B", commit]);
763
- if (message.status !== 0 ||
764
- !message.stdout.split(/\r?\n/).some((line) => line.trim() === `AKM-Transaction: ${transactionId}`)) {
765
- throw new Error(`Git commit ${commit} is not owned by transaction ${transactionId}.`);
766
- }
767
- const changed = runGit([
768
- "-C",
769
- publication.repoPath,
770
- "diff-tree",
771
- "--no-commit-id",
772
- "--name-only",
773
- "-z",
774
- "--no-renames",
775
- "-r",
776
- commit,
777
- ]);
778
- const expected = new Set(expectedPaths);
779
- const changedPaths = changed.stdout.split("\0").filter(Boolean);
780
- if (changed.status !== 0 || changedPaths.length === 0 || changedPaths.some((filePath) => !expected.has(filePath))) {
781
- throw new Error(`Git commit ${commit} contains paths outside transaction ${transactionId}.`);
782
- }
783
- validateGitCommitSnapshots(publication.repoPath, commit, expectedPaths, snapshots);
784
- if (runGit(["-C", publication.repoPath, "merge-base", "--is-ancestor", commit, "HEAD"]).status !== 0) {
785
- throw new Error(`Git commit ${commit} is no longer on the target branch.`);
786
- }
787
- }
788
- function validateGitWorktreeSnapshots(target, expectedPaths, snapshots) {
789
- for (const expectedPath of expectedPaths) {
790
- if (!Object.hasOwn(snapshots, expectedPath)) {
791
- throw new Error(`Git publication lacks a snapshot for ${expectedPath}.`);
792
- }
793
- const current = captureGitPathSnapshot(target, path.join(target.source.repoPath ?? target.source.path, expectedPath));
794
- if (!sameGitPathState(current.state, snapshots[expectedPath] ?? null)) {
795
- throw new Error(`Git publication path diverged after mutation: ${expectedPath}.`);
796
- }
797
- }
798
- }
799
- function validateGitCommitSnapshots(repoPath, commit, expectedPaths, snapshots) {
800
- for (const expectedPath of expectedPaths) {
801
- if (!Object.hasOwn(snapshots, expectedPath)) {
802
- throw new Error(`Git publication lacks a snapshot for ${expectedPath}.`);
803
- }
804
- const expected = snapshots[expectedPath];
805
- const tree = runGit(["-C", repoPath, "ls-tree", commit, "--", expectedPath]);
806
- if (tree.status !== 0)
807
- throw new Error(`Cannot inspect committed Git path: ${expectedPath}.`);
808
- const match = tree.stdout.trim().match(/^(\d+)\s+blob\s+([0-9a-f]+)\t/);
809
- if (expected === null) {
810
- if (tree.stdout.trim())
811
- throw new Error(`Git transaction unexpectedly retained ${expectedPath}.`);
812
- }
813
- else if (expected === undefined || !match || match[1] !== expected.mode || match[2] !== expected.oid) {
814
- throw new Error(`Git transaction committed unexpected content for ${expectedPath}.`);
815
- }
816
- }
817
- }
818
- /**
819
- * Validate and normalize a write target before a command mutates it.
820
- *
821
- * Git bundle locks record the materialized content root, which can be a
822
- * subdirectory of the checkout. Resolve the actual repository boundary from
823
- * that root so scoped commits use repository-relative paths. Extracted or
824
- * unmaterialized Git caches are not writable checkouts and must fail before a
825
- * command writes files into them.
826
- */
827
- export function prepareWriteTargetForMutation(target, options = {}) {
828
- assertAkmAssetWrite(target.source, options.allowedAdapters);
829
- if (target.source.kind !== "git")
830
- return target;
831
- const contentRoot = path.resolve(target.source.path);
832
- let stat;
833
- try {
834
- stat = fs.statSync(contentRoot);
835
- }
836
- catch {
837
- throw gitTargetNotMaterialized(target, contentRoot);
838
- }
839
- if (!stat.isDirectory())
840
- throw gitTargetNotMaterialized(target, contentRoot);
841
- const rootResult = runGit(["-C", contentRoot, "rev-parse", "--show-toplevel"]);
842
- if (rootResult.status !== 0 || !rootResult.stdout.trim()) {
843
- throw gitTargetNotMaterialized(target, contentRoot);
844
- }
845
- const repoPath = path.resolve(rootResult.stdout.trim());
846
- const gitDirResult = runGit(["-C", repoPath, "rev-parse", "--git-dir"]);
847
- if (gitDirResult.status !== 0 || !gitDirResult.stdout.trim()) {
848
- throw gitTargetNotMaterialized(target, contentRoot);
849
- }
850
- const gitDir = path.resolve(repoPath, gitDirResult.stdout.trim());
851
- let realContentRoot;
852
- let realRepoPath;
853
- try {
854
- realContentRoot = fs.realpathSync(contentRoot);
855
- realRepoPath = fs.realpathSync(repoPath);
856
- fs.accessSync(realContentRoot, fs.constants.W_OK);
857
- fs.accessSync(gitDir, fs.constants.W_OK);
858
- }
859
- catch {
860
- throw new ConfigError(`Writable Git target "${target.source.name}" is not writable at ${contentRoot}.`, "INVALID_CONFIG_FILE", `Fix the checkout permissions or choose a different --target.`);
861
- }
862
- if (!isWithin(realContentRoot, realRepoPath)) {
863
- throw new ConfigError(`Writable Git target "${target.source.name}" resolves outside its checkout: ${contentRoot}.`, "INVALID_CONFIG_FILE");
864
- }
865
- const statusResult = runGit(["-C", repoPath, "status", "--porcelain"]);
866
- if (statusResult.status !== 0 || statusResult.error) {
867
- throw gitTargetNotMaterialized(target, contentRoot);
868
- }
869
- const branchResult = runGit(["-C", repoPath, "symbolic-ref", "--quiet", "HEAD"]);
870
- if (branchResult.status !== 0 || !branchResult.stdout.trim()) {
871
- throw new UsageError(`Writable Git target "${target.source.name}" is detached from a branch.`, "INVALID_FLAG_VALUE");
872
- }
873
- const upstream = inspectGitUpstream(repoPath);
874
- if (upstream.behind > 0) {
875
- warnOnce(`write-source:git-behind:${realRepoPath}`, `Writable Git target "${target.source.name}" is ${upstream.behind} commit(s) behind ${upstream.upstream}; writing anyway. Run \`akm bundle update ${target.source.name}\` to catch up.`);
876
- }
877
- if (upstream.ahead > 0) {
878
- warnOnce(`write-source:git-ahead:${realRepoPath}`, `Writable Git target "${target.source.name}" has ${upstream.ahead} unpushed commit(s); writing another on top. Push or reconcile them when convenient.`);
879
- }
880
- return {
881
- ...target,
882
- source: { ...target.source, path: contentRoot, repoPath },
883
- };
884
- }
885
- function gitTargetNotMaterialized(target, contentRoot) {
886
- return new ConfigError(`Writable Git target "${target.source.name}" is not materialized as a Git checkout at ${contentRoot}; refusing to write without a commit boundary.`, "INVALID_CONFIG_FILE", `Run \`akm bundle update ${target.source.name}\` to materialize it, or point the bundle at a writable Git checkout.`);
49
+ /** The two kinds writes are defined for; scheduler state binds only to these. */
50
+ export function isWriteCapableSourceKind(kind) {
51
+ return kind === "filesystem" || kind === "git";
887
52
  }
888
53
  /**
889
- * Resolve the destination for a write per locked decision 3:
890
- *
891
- * 1. Explicit `--target <name>` (when supplied)
892
- * 2. `config.defaultWriteTarget`
893
- * 3. `config.defaultBundle`'s path (the working stash created by `akm bundle create`)
894
- * 4. `ConfigError("no writable source configured; run `akm bundle create`")`
895
- *
896
- * The legacy `first-writable-in-source-array-order` fallback is *not* used —
897
- * see plan §6 decision 3 for the rationale.
54
+ * Resolve the destination for a write: explicit `--target`, then
55
+ * `defaultWriteTarget`, then the working stash (`defaultBundle`). There is no
56
+ * fallback to the first writable source.
898
57
  */
899
58
  export function resolveWriteTarget(akmConfig, explicitTarget, options = {}) {
900
59
  const allConfiguredSources = resolveConfiguredSources(akmConfig);
901
60
  const configuredSources = resolveActiveConfiguredSources(akmConfig);
902
61
  const requireWritable = options.requireWritable !== false;
903
- // 1. Explicit --target wins.
904
62
  if (explicitTarget) {
905
63
  const match = configuredSources.find((s) => s.name === explicitTarget);
906
64
  if (!match) {
@@ -909,36 +67,21 @@ export function resolveWriteTarget(akmConfig, explicitTarget, options = {}) {
909
67
  }
910
68
  throw new UsageError(`--target must reference a source name from your config. No source named "${explicitTarget}" is configured. Run \`akm bundle list\` to see available sources.`, "INVALID_FLAG_VALUE");
911
69
  }
912
- // Up-front writable check so an explicit --target fails fast with a
913
- // ConfigError (rather than the generic UsageError ensureWritable would
914
- // raise after we've already started building paths). Resolve the
915
- // effective writable flag (filesystem defaults to true; everything else
916
- // defaults to false) so unset values are interpreted correctly.
917
- const effectiveWritable = resolveWritable({ type: match.type, writable: match.writable });
918
- if (requireWritable && !effectiveWritable) {
70
+ if (requireWritable && !resolveWritable({ type: match.type, writable: match.writable })) {
919
71
  throw new ConfigError(`source ${explicitTarget} is not writable`, "INVALID_CONFIG_FILE", `Set \`writable: true\` on the "${explicitTarget}" source in your config, or pass --target to a different source.`);
920
72
  }
921
73
  return adaptConfiguredSource(match);
922
74
  }
923
- // 2. config.defaultWriteTarget.
924
75
  if (akmConfig.defaultWriteTarget) {
925
76
  const match = configuredSources.find((s) => s.name === akmConfig.defaultWriteTarget);
926
- if (match) {
927
- // BUG-H3: mirror the --target writability gate so a misconfigured
928
- // defaultWriteTarget pointed at a non-writable kind (website/npm) or
929
- // an explicit `writable: false` filesystem entry fails fast with a
930
- // ConfigError, rather than surfacing as a generic UsageError after
931
- // path-building has already begun.
932
- const effectiveWritable = resolveWritable({ type: match.type, writable: match.writable });
933
- if (requireWritable && !effectiveWritable) {
934
- 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.`);
935
- }
936
- return adaptConfiguredSource(match);
77
+ if (!match) {
78
+ 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 bundle list` to see configured sources.");
79
+ }
80
+ if (requireWritable && !resolveWritable({ type: match.type, writable: match.writable })) {
81
+ 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.`);
937
82
  }
938
- // Fall through if the named target no longer exists — surface a clear error.
939
- 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 bundle list` to see configured sources.");
83
+ return adaptConfiguredSource(match);
940
84
  }
941
- // 3. Configured default bundle.
942
85
  return resolveWorkingStashTarget(akmConfig, options);
943
86
  }
944
87
  /** Resolve the implicit working stash without consulting `defaultWriteTarget`. */
@@ -978,112 +121,19 @@ export function resolveWorkingStashTarget(akmConfig, options = {}) {
978
121
  }
979
122
  return { ...target, selector: undefined };
980
123
  }
981
- // ── Internals ───────────────────────────────────────────────────────────────
982
- function ensureWritable(source, config) {
983
- // Apply the same default-resolution rule as resolveWritable so callers can
984
- // pass through a SourceConfigEntry with an absent `writable` field.
985
- const writable = resolveWritable(config);
986
- if (!writable) {
987
- throw new UsageError(`Source "${source.name}" is not writable. Set \`writable: true\` on the source config entry to enable writes.`, "INVALID_FLAG_VALUE");
988
- }
989
- }
990
124
  /**
991
- * MS-DOS device names Windows still reserves in every directory, with or
992
- * without an extension (CON, PRN, AUX, NUL, COM1-9, LPT1-9).
993
- */
994
- const WINDOWS_RESERVED_DEVICE_NAMES = new Set([
995
- "con",
996
- "prn",
997
- "aux",
998
- "nul",
999
- ...Array.from({ length: 9 }, (_, i) => `com${i + 1}`),
1000
- ...Array.from({ length: 9 }, (_, i) => `lpt${i + 1}`),
1001
- ]);
1002
- function resolveAssetFilePath(source, ref) {
1003
- const basename = path.posix.basename(ref.name.replaceAll("\\", "/")).replace(/\.md$/i, "").toLowerCase();
1004
- if (basename === "index" || basename === "log") {
1005
- warnOnce(`write-source:reserved-basename:${basename}`, `Concept name "${basename}" collides with a reserved word some tooling treats specially; writing it anyway.`);
1006
- }
1007
- // Windows resolves these names as DEVICES no matter the directory or the
1008
- // extension, so `CON.md` is not a file — a write goes to the console and a
1009
- // read blocks on console input. Rejected on every platform so a stash stays
1010
- // portable: an asset authored on Linux must not become unopenable when the
1011
- // same bundle is used on Windows.
1012
- if (WINDOWS_RESERVED_DEVICE_NAMES.has(basename)) {
1013
- warnOnce(`write-source:windows-device-name:${basename}`, `Asset name "${basename}" is a reserved Windows device name; writing it anyway, but this bundle will not be portable to Windows.`);
1014
- }
1015
- const typeDir = stashDirFor(ref.type);
1016
- if (!typeDir) {
1017
- throw new UsageError(`Unknown asset type "${ref.type}". Cannot resolve a write path.`, "INVALID_FLAG_VALUE");
1018
- }
1019
- const typeRoot = path.join(source.path, typeDir);
1020
- const assetPath = assetPathForName(ref.type, typeRoot, ref.name);
1021
- if (!isWithin(assetPath, typeRoot)) {
1022
- throw new UsageError(`Resolved asset path escapes its source: "${ref.name}" in source "${source.name}".`, "PATH_ESCAPE_VIOLATION");
1023
- }
1024
- return assetPath;
1025
- }
1026
- export function assertAkmAssetWrite(source, allowedAdapters = ["akm"]) {
1027
- if (!source.adapterId || allowedAdapters.includes(source.adapterId))
1028
- return;
1029
- throw new UsageError(`Bundle "${source.name}" uses adapter "${source.adapterId}", which does not support AKM asset writes.`, "INVALID_FLAG_VALUE");
1030
- }
1031
- /**
1032
- * Reject any kind reaching the write/delete helpers other than the two
1033
- * supported writable kinds. The config loader is the first line of defence
1034
- * (assertWritableAllowedForKind), but we throw here so external callers that
1035
- * bypass the loader still get a clear error.
1036
- */
1037
- function assertSupportedKind(source) {
1038
- if (source.kind === "filesystem" || source.kind === "git")
1039
- return;
1040
- throw new ConfigError(`write-source: unsupported kind "${source.kind}" for source "${source.name}". ` +
1041
- "Writes are only defined for `filesystem` and `git` sources.", "INVALID_CONFIG_FILE", 'Set `kind: "filesystem"` (or `kind: "git"`) on the source, or add a parallel filesystem entry.');
1042
- }
1043
- export function formatRefForMessage(ref) {
1044
- // Sanitize each component independently. `ref.origin` originates from user
1045
- // config and could contain CR/LF that would otherwise be smuggled into the
1046
- // commit subject and forge trailers downstream. `ref.type` and `ref.name`
1047
- // are also sanitized defensively — the asset-spec validator should already
1048
- // reject control bytes there, but a single sanitizer at the boundary keeps
1049
- // the contract explicit and centralized.
1050
- const origin = ref.origin ? sanitizeCommitMessage(ref.origin) : "";
1051
- const type = sanitizeCommitMessage(ref.type);
1052
- const name = sanitizeCommitMessage(ref.name);
1053
- // 0.9.0 (Q-02): the retired `type:name` colon grammar is gone — emit the
1054
- // slash conceptId (`workflows/name`), qualified with `origin//` when the
1055
- // ref carries one. Mirrors the `displayRef`/`conceptIdFromTypeName` rule
1056
- // used elsewhere in this file (see `resolveAssetFilePath` callers above).
1057
- const conceptId = conceptIdFromTypeName(type, name);
1058
- return origin ? `${origin}//${conceptId}` : conceptId;
1059
- }
1060
- /**
1061
- * Derive a {@link WriteTargetSource} + persisted {@link SourceConfigEntry}
1062
- * from the runtime {@link ConfiguredSource} representation used elsewhere in
1063
- * the codebase. The mapping is:
1064
- *
1065
- * ConfiguredSource.type → WriteTargetSource.kind
1066
- * ConfiguredSource.name → WriteTargetSource.name
1067
- * ConfiguredSource.source.* → WriteTargetSource.path (via parseSourceSpec)
1068
- *
125
+ * Map a runtime {@link ConfiguredSource} onto a write target. A managed git
126
+ * bundle's content root comes from the lock (`localRoot`) first — the same
127
+ * resolver the indexer's read path uses, so a write lands exactly where a read
128
+ * walks. Before the first lock row it falls back to the cache checkout and its
129
+ * `content/` convention.
1069
130
  */
1070
131
  function adaptConfiguredSource(runtime) {
1071
- // Map the runtime kind to the write helper's `kind` discriminator. Only
1072
- // filesystem and git produce writable sources at v1; any other kind
1073
- // reaching this point is a config-loader bug (assertWritableAllowedForKind
1074
- // should have rejected it). Throw a ConfigError rather than silently
1075
- // forwarding an unsupported kind.
1076
- if (runtime.type !== "filesystem" && runtime.type !== "git") {
132
+ if (!isWriteCapableSourceKind(runtime.type)) {
1077
133
  throw new ConfigError(`write-source: source "${runtime.name}" has unsupported kind "${runtime.type}" for writes. ` +
1078
134
  "Writes are only defined for `filesystem` and `git` sources.", "INVALID_CONFIG_FILE", 'Use `kind: "filesystem"` or `kind: "git"` for writable sources.');
1079
135
  }
1080
136
  const kind = runtime.type;
1081
- // §10.2 lock-first (BEHAVIOR FIX): a managed git bundle's resolved content
1082
- // root lives in the lock (`localRoot`), NOT the desired config. Resolve there
1083
- // FIRST — via the SAME shared resolver the indexer READ path uses — so a write
1084
- // lands in exactly the directory a read walks; git sync/commit then runs
1085
- // against that same root. Before the first lock row exists, fall back to the
1086
- // derived cache repoDir + content/-subdir convention used by the read path.
1087
137
  const lockRoot = kind === "git" ? lockContentRootFor(runtime.name, runtime.type) : undefined;
1088
138
  const repoPath = lockRoot ?? pathFromConfiguredSource(runtime);
1089
139
  if (!repoPath) {
@@ -1104,13 +154,7 @@ function adaptConfiguredSource(runtime) {
1104
154
  };
1105
155
  return {
1106
156
  selector: runtime.name,
1107
- source: {
1108
- kind,
1109
- name: runtime.name,
1110
- path: componentRoot,
1111
- adapterId,
1112
- ...(kind === "git" ? { repoPath } : {}),
1113
- },
157
+ source: { kind, name: runtime.name, path: componentRoot, adapterId, ...(kind === "git" ? { repoPath } : {}) },
1114
158
  config,
1115
159
  };
1116
160
  }
@@ -1120,21 +164,12 @@ export function resolveGitContentRoot(repoPath) {
1120
164
  return fs.existsSync(contentPath) && fs.statSync(contentPath).isDirectory() ? contentPath : repoPath;
1121
165
  }
1122
166
  function pathFromConfiguredSource(runtime) {
1123
- // ConfiguredSource.source is the parsed SourceSpec (filesystem|git|website|npm).
1124
- // For writable kinds we only ever care about a local on-disk path: filesystem
1125
- // sources expose it directly; git sources resolve through the cache mirror
1126
- // (handled by the existing source provider). For v1 the helper trusts
1127
- // callers to materialise the cache path beforehand and does not re-clone.
1128
167
  const spec = runtime.source;
1129
168
  if (spec.type === "filesystem")
1130
169
  return spec.path;
1131
- // For git sources we fall back to the cached repo directory the provider
1132
- // already materialised. The lookup is intentionally lazy — we only import
1133
- // it when needed to keep the helper's import graph small.
1134
170
  if (spec.type === "git") {
1135
171
  try {
1136
- const repo = parseGitRepoUrl(spec.url);
1137
- return getCachePaths(repo.canonicalUrl).repoDir;
172
+ return getCachePaths(parseGitRepoUrl(spec.url).canonicalUrl).repoDir;
1138
173
  }
1139
174
  catch {
1140
175
  return undefined;
@@ -1142,3 +177,257 @@ function pathFromConfiguredSource(runtime) {
1142
177
  }
1143
178
  return undefined;
1144
179
  }
180
+ // ── Preparing a target for mutation ─────────────────────────────────────────
181
+ /** Refuse AKM asset writes into a bundle whose adapter owns a different layout. */
182
+ export function assertAkmAssetWrite(source, allowedAdapters = ["akm"]) {
183
+ if (!source.adapterId || allowedAdapters.includes(source.adapterId))
184
+ return;
185
+ throw new UsageError(`Bundle "${source.name}" uses adapter "${source.adapterId}", which does not support AKM asset writes.`, "INVALID_FLAG_VALUE");
186
+ }
187
+ /**
188
+ * Validate a write target before a command mutates it. A git target must be a
189
+ * materialized checkout on a branch; its repository root is resolved from the
190
+ * content root (which may be a subdirectory) so the boundary commit uses
191
+ * repository-relative paths. Being ahead of or behind upstream only warns.
192
+ */
193
+ export function prepareWriteTargetForMutation(target, options = {}) {
194
+ assertAkmAssetWrite(target.source, options.allowedAdapters);
195
+ if (target.source.kind !== "git")
196
+ return target;
197
+ const contentRoot = path.resolve(target.source.path);
198
+ const rootResult = runGit(["-C", contentRoot, "rev-parse", "--show-toplevel"]);
199
+ if (rootResult.status !== 0 || !rootResult.stdout.trim()) {
200
+ throw new ConfigError(`Writable Git target "${target.source.name}" is not materialized as a Git checkout at ${contentRoot}; refusing to write without a commit boundary.`, "INVALID_CONFIG_FILE", `Run \`akm bundle update ${target.source.name}\` to materialize it, or point the bundle at a writable Git checkout.`);
201
+ }
202
+ const repoPath = path.resolve(rootResult.stdout.trim());
203
+ const branchResult = runGit(["-C", repoPath, "symbolic-ref", "--quiet", "HEAD"]);
204
+ if (branchResult.status !== 0 || !branchResult.stdout.trim()) {
205
+ throw new UsageError(`Writable Git target "${target.source.name}" is detached from a branch.`, "INVALID_FLAG_VALUE");
206
+ }
207
+ try {
208
+ const upstream = inspectGitUpstream(repoPath);
209
+ if (upstream.behind > 0) {
210
+ warnOnce(`write-source:git-behind:${repoPath}`, `Writable Git target "${target.source.name}" is ${upstream.behind} commit(s) behind ${upstream.upstream}; writing anyway. Run \`akm bundle update ${target.source.name}\` to catch up.`);
211
+ }
212
+ if (upstream.ahead > 0) {
213
+ warnOnce(`write-source:git-ahead:${repoPath}`, `Writable Git target "${target.source.name}" has ${upstream.ahead} unpushed commit(s); writing another on top. Push or reconcile them when convenient.`);
214
+ }
215
+ }
216
+ catch (error) {
217
+ // The upstream check is advisory (it fetches). Being offline must not stop
218
+ // a local write; the push reports its own failure at the boundary.
219
+ warnOnce(`write-source:git-upstream:${repoPath}`, `Could not check upstream for Git target "${target.source.name}" (${error instanceof Error ? error.message : String(error)}); writing anyway.`);
220
+ }
221
+ return { ...target, source: { ...target.source, path: contentRoot, repoPath } };
222
+ }
223
+ // ── Writing assets ──────────────────────────────────────────────────────────
224
+ /**
225
+ * Write a textual asset into `source` at the path implied by `ref`: refuses a
226
+ * non-writable config, rejects any kind but `filesystem` / `git`, then writes
227
+ * atomically inside the bundle root. No commit runs here for any kind; a git
228
+ * target records the path for {@link commitWriteTargetBoundary}.
229
+ */
230
+ export async function writeAssetToSource(source, config, ref, content) {
231
+ ensureWritable(source, config);
232
+ assertSupportedKind(source);
233
+ assertAkmAssetWrite(source);
234
+ const filePath = resolveAssetFilePath(source, ref);
235
+ const authored = filePath.toLowerCase().endsWith(".md") ? ensureAkmMarkdownType(content, ref.type) : content;
236
+ const normalized = authored.endsWith("\n") ? authored : `${authored}\n`;
237
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
238
+ // Atomic: sibling temp file, fdatasync, rename — a crash or a full disk never
239
+ // leaves a half-written asset where a good one was.
240
+ writeFileAtomic(filePath, normalized, existingFileMode(filePath));
241
+ recordWriteTargetPath(source, filePath);
242
+ // Run-scoped write provenance (#652).
243
+ recordWrittenPath(filePath);
244
+ // Non-fatal portability advisory, after the write so it never blocks it.
245
+ const hostPaths = findAbsoluteHomePaths(normalized);
246
+ if (hostPaths.length > 0) {
247
+ warn(`warning: asset "${formatRefForMessage(ref)}" embeds absolute host path(s): ${hostPaths.join(", ")}. ` +
248
+ "These make the stash non-portable and leak the local username — prefer $HOME or ~ relative references.");
249
+ }
250
+ return { path: filePath, ref: displayRef({ type: ref.type, name: ref.name, bundleId: ref.origin }) };
251
+ }
252
+ /** Delete the asset at `ref` from `source`. Same gates as {@link writeAssetToSource}, no commit. */
253
+ export async function deleteAssetFromSource(source, config, ref) {
254
+ ensureWritable(source, config);
255
+ assertSupportedKind(source);
256
+ assertAkmAssetWrite(source);
257
+ const filePath = resolveAssetFilePath(source, ref);
258
+ if (!fs.existsSync(filePath)) {
259
+ throw new UsageError(`Asset "${formatRefForMessage(ref)}" not found in source "${source.name}" (expected at ${filePath}).`, "MISSING_REQUIRED_ARGUMENT");
260
+ }
261
+ fs.unlinkSync(filePath);
262
+ recordWriteTargetPath(source, filePath);
263
+ recordWrittenPath(filePath);
264
+ return { path: filePath, ref: displayRef({ type: ref.type, name: ref.name, bundleId: ref.origin }) };
265
+ }
266
+ function ensureWritable(source, config) {
267
+ if (resolveWritable(config))
268
+ return;
269
+ throw new UsageError(`Source "${source.name}" is not writable. Set \`writable: true\` on the source config entry to enable writes.`, "INVALID_FLAG_VALUE");
270
+ }
271
+ function assertSupportedKind(source) {
272
+ if (isWriteCapableSourceKind(source.kind))
273
+ return;
274
+ throw new ConfigError(`write-source: unsupported kind "${source.kind}" for source "${source.name}". ` +
275
+ "Writes are only defined for `filesystem` and `git` sources.", "INVALID_CONFIG_FILE", 'Set `kind: "filesystem"` (or `kind: "git"`) on the source, or add a parallel filesystem entry.');
276
+ }
277
+ /** MS-DOS device names Windows reserves in every directory, with or without an extension. */
278
+ const WINDOWS_RESERVED_DEVICE_NAMES = new Set([
279
+ "con",
280
+ "prn",
281
+ "aux",
282
+ "nul",
283
+ ...Array.from({ length: 9 }, (_, i) => `com${i + 1}`),
284
+ ...Array.from({ length: 9 }, (_, i) => `lpt${i + 1}`),
285
+ ]);
286
+ /** The on-disk path for `ref` inside `source`, refusing anything that escapes the type directory. */
287
+ function resolveAssetFilePath(source, ref) {
288
+ const basename = path.posix.basename(ref.name.replaceAll("\\", "/")).replace(/\.md$/i, "").toLowerCase();
289
+ if (basename === "index" || basename === "log") {
290
+ warnOnce(`write-source:reserved-basename:${basename}`, `Concept name "${basename}" collides with a reserved word some tooling treats specially; writing it anyway.`);
291
+ }
292
+ if (WINDOWS_RESERVED_DEVICE_NAMES.has(basename)) {
293
+ warnOnce(`write-source:windows-device-name:${basename}`, `Asset name "${basename}" is a reserved Windows device name; writing it anyway, but this bundle will not be portable to Windows.`);
294
+ }
295
+ const typeDir = stashDirFor(ref.type);
296
+ if (!typeDir) {
297
+ throw new UsageError(`Unknown asset type "${ref.type}". Cannot resolve a write path.`, "INVALID_FLAG_VALUE");
298
+ }
299
+ const typeRoot = path.join(source.path, typeDir);
300
+ const assetPath = assetPathForName(ref.type, typeRoot, ref.name);
301
+ if (!isWithin(assetPath, typeRoot)) {
302
+ throw new UsageError(`Resolved asset path escapes its source: "${ref.name}" in source "${source.name}".`, "PATH_ESCAPE_VIOLATION");
303
+ }
304
+ return assetPath;
305
+ }
306
+ /**
307
+ * Matches an absolute host home path (`/home/<user>`, `/Users/<user>`) with at
308
+ * least one user segment. Deliberately does not exempt fenced code, so content
309
+ * that documents such a path can trip it; the advisory is non-fatal.
310
+ */
311
+ const ABSOLUTE_HOME_PATH_RE = /\/(?:home|Users)\/[^\s/"'`)\]}<>|:;,]+/g;
312
+ /** Distinct `/home/<user>` / `/Users/<user>` prefixes in `content`, first-seen order. */
313
+ export function findAbsoluteHomePaths(content) {
314
+ const seen = new Set();
315
+ for (const match of content.matchAll(ABSOLUTE_HOME_PATH_RE))
316
+ seen.add(match[0]);
317
+ return [...seen];
318
+ }
319
+ /** `[origin//]conceptId` for a commit subject, each component sanitized against CR/LF/NUL smuggling. */
320
+ export function formatRefForMessage(ref) {
321
+ const origin = ref.origin ? sanitizeCommitMessage(ref.origin) : "";
322
+ const conceptId = conceptIdFromTypeName(sanitizeCommitMessage(ref.type), sanitizeCommitMessage(ref.name));
323
+ return origin ? `${origin}//${conceptId}` : conceptId;
324
+ }
325
+ // ── Boundary commit ─────────────────────────────────────────────────────────
326
+ /**
327
+ * Absolute paths written to a git target since its last boundary commit, keyed
328
+ * by repository root. This is what lets the boundary `git add` exactly the
329
+ * operation's files and nothing else in the checkout.
330
+ */
331
+ const pendingGitPaths = new Map();
332
+ function repoDirFor(source) {
333
+ return path.resolve(source.repoPath ?? source.path);
334
+ }
335
+ /** Record a path the git target's next boundary commit must include. */
336
+ export function recordWriteTargetPath(source, filePath) {
337
+ if (source.kind !== "git")
338
+ return;
339
+ const repoDir = repoDirFor(source);
340
+ const pending = pendingGitPaths.get(repoDir) ?? new Set();
341
+ pending.add(path.resolve(filePath));
342
+ pendingGitPaths.set(repoDir, pending);
343
+ }
344
+ function lstatOrNull(filePath) {
345
+ try {
346
+ return fs.lstatSync(filePath);
347
+ }
348
+ catch (error) {
349
+ if (error.code === "ENOENT")
350
+ return null;
351
+ throw error;
352
+ }
353
+ }
354
+ /**
355
+ * Refuse a write path outside the source root, or one that passes through a
356
+ * symbolic link below it (a linked directory would redirect the write out of
357
+ * the bundle). The source root itself may be a symlink.
358
+ */
359
+ function assertWritePathsInsideSource(source, filePaths) {
360
+ const lexicalRoot = path.resolve(source.path);
361
+ const canonicalRoot = fs.realpathSync(lexicalRoot);
362
+ for (const filePath of filePaths) {
363
+ const relative = path.relative(lexicalRoot, path.resolve(filePath));
364
+ if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
365
+ throw new UsageError(`Write path resolves outside source "${source.name}".`, "PATH_ESCAPE_VIOLATION");
366
+ }
367
+ let current = canonicalRoot;
368
+ for (const segment of relative.split(path.sep)) {
369
+ current = path.join(current, segment);
370
+ const stat = lstatOrNull(current);
371
+ if (!stat)
372
+ break;
373
+ if (stat.isSymbolicLink()) {
374
+ throw new UsageError(`Write path contains a symbolic link below source "${source.name}": ${relative}`);
375
+ }
376
+ }
377
+ }
378
+ }
379
+ /**
380
+ * Run `mutate` under the shared asset-mutation lease, then commit exactly
381
+ * `paths` on a git target (a no-op on filesystem targets). Every path must
382
+ * stay inside the source root.
383
+ */
384
+ export function withWriteTargetMutation(target, paths, options, mutate) {
385
+ return withAssetMutationLeaseSync(options.purpose, () => {
386
+ assertWritePathsInsideSource(target.source, paths);
387
+ const result = mutate();
388
+ commitWriteTargetBoundary(target, options.message, { paths });
389
+ return result;
390
+ });
391
+ }
392
+ /**
393
+ * Commit a git target's recorded paths plus `options.paths` (absolute or
394
+ * repository-relative) as one commit, and push it with `--force-with-lease`
395
+ * when the target is writable, has an upstream, and `push !== false`. A no-op
396
+ * for filesystem targets. Ignored paths stay local: they are dropped from the
397
+ * commit with a warning rather than failing a write that already landed.
398
+ */
399
+ export function commitWriteTargetBoundary(target, message, options) {
400
+ if (target.source.kind !== "git")
401
+ return;
402
+ const repoDir = repoDirFor(target.source);
403
+ const recorded = pendingGitPaths.get(repoDir) ?? new Set();
404
+ pendingGitPaths.delete(repoDir);
405
+ if (!isGitBackedStash(repoDir))
406
+ return;
407
+ const toRepoRelative = (filePath) => (path.isAbsolute(filePath) ? path.relative(repoDir, filePath) : filePath).replaceAll(path.sep, "/");
408
+ const paths = [...new Set([...(options?.paths ?? []), ...recorded].map(toRepoRelative))];
409
+ if (paths.length === 0)
410
+ return;
411
+ const ignored = new Set(listIgnoredExactPaths(repoDir, paths));
412
+ if (ignored.size > 0) {
413
+ warn(`warning: ${ignored.size} path(s) in "${target.source.name}" are ignored by .gitignore and stay local (not committed): ${[...ignored].join(", ")}`);
414
+ }
415
+ const committable = paths.filter((filePath) => !ignored.has(filePath));
416
+ if (committable.length === 0)
417
+ return;
418
+ try {
419
+ saveGitStash(undefined, message, resolveWritable(target.config), {
420
+ repoDir,
421
+ paths: committable,
422
+ ...(options?.push === undefined ? {} : { push: options.push }),
423
+ });
424
+ }
425
+ catch (error) {
426
+ if (error instanceof GitStashPushError) {
427
+ throw new Error(`Changes were committed as ${error.commit}, but publication failed: ${error.message}`, {
428
+ cause: error,
429
+ });
430
+ }
431
+ throw error;
432
+ }
433
+ }