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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (343) hide show
  1. package/CHANGELOG.md +756 -0
  2. package/dist/akm +94 -196
  3. package/dist/cli/shared.js +6 -2
  4. package/dist/cli.js +22 -9
  5. package/dist/commands/agent/agent-dispatch.js +1 -1
  6. package/dist/commands/command/command-execution.js +24 -62
  7. package/dist/commands/feedback-cli.js +0 -1
  8. package/dist/commands/health/accept-rate.js +2 -2
  9. package/dist/commands/health/checks.js +30 -75
  10. package/dist/commands/health/config-skew.js +38 -0
  11. package/dist/commands/health/egress.js +54 -0
  12. package/dist/commands/health/html-report.js +0 -38
  13. package/dist/commands/health/improve-metrics.js +123 -562
  14. package/dist/commands/health/plugin-staleness.js +53 -3
  15. package/dist/commands/health/renderers.js +12 -4
  16. package/dist/commands/health/report-view-model.js +11 -106
  17. package/dist/commands/health/types-improve.js +4 -19
  18. package/dist/commands/health/windows.js +64 -73
  19. package/dist/commands/health.js +122 -143
  20. package/dist/commands/improve/consolidate/chunking.js +25 -100
  21. package/dist/commands/improve/consolidate/sanitize.js +54 -149
  22. package/dist/commands/improve/consolidate.js +538 -1075
  23. package/dist/commands/improve/content-hash.js +16 -24
  24. package/dist/commands/improve/distill/content-repair.js +18 -100
  25. package/dist/commands/improve/distill-guards.js +20 -81
  26. package/dist/commands/improve/distill-promotion-policy.js +23 -243
  27. package/dist/commands/improve/distill.js +608 -1075
  28. package/dist/commands/improve/eligibility.js +126 -400
  29. package/dist/commands/improve/execution.js +3 -5
  30. package/dist/commands/improve/extract.js +487 -1046
  31. package/dist/commands/improve/feedback-valence.js +0 -25
  32. package/dist/commands/improve/improve-cli.js +29 -166
  33. package/dist/commands/improve/improve-result-file.js +10 -66
  34. package/dist/commands/improve/improve-strategies.js +12 -7
  35. package/dist/commands/improve/improve-usage-report.js +18 -64
  36. package/dist/commands/improve/improve.js +443 -1063
  37. package/dist/commands/improve/ledger.js +114 -0
  38. package/dist/commands/improve/locks.js +2 -8
  39. package/dist/commands/improve/loop-stages.js +459 -1172
  40. package/dist/commands/improve/memory/derived-ref.js +12 -77
  41. package/dist/commands/improve/memory/memory-belief.js +14 -118
  42. package/dist/commands/improve/memory/memory-improve.js +4 -3
  43. package/dist/commands/improve/outcome-loop.js +28 -156
  44. package/dist/commands/improve/planner.js +5 -10
  45. package/dist/commands/improve/preparation.js +851 -2339
  46. package/dist/commands/improve/proactive-maintenance.js +34 -101
  47. package/dist/commands/improve/reflect-noise.js +104 -280
  48. package/dist/commands/improve/reflect.js +621 -1367
  49. package/dist/commands/improve/salience.js +46 -232
  50. package/dist/commands/improve/session-asset.js +19 -100
  51. package/dist/commands/improve/stage.js +323 -0
  52. package/dist/commands/proposal/drain.js +251 -644
  53. package/dist/commands/proposal/proposal-cli.js +3 -18
  54. package/dist/commands/proposal/proposal-types.js +20 -41
  55. package/dist/commands/proposal/proposal.js +1 -2
  56. package/dist/commands/proposal/propose.js +134 -160
  57. package/dist/commands/proposal/repository.js +502 -1487
  58. package/dist/commands/proposal/validators/proposal-quality-validators.js +71 -174
  59. package/dist/commands/proposal/validators/proposal-validators.js +1 -1
  60. package/dist/commands/proposal/validators/proposals.js +13 -89
  61. package/dist/commands/read/curate.js +63 -413
  62. package/dist/commands/read/search-cli.js +16 -33
  63. package/dist/commands/read/search.js +17 -23
  64. package/dist/commands/read/show.js +2 -13
  65. package/dist/commands/sources/bundle-cli.js +25 -2
  66. package/dist/commands/sources/bundle-config-ops.js +7 -0
  67. package/dist/commands/sources/dangerous-env-audit.js +1 -2
  68. package/dist/commands/sources/info.js +2 -11
  69. package/dist/commands/sources/installed-stashes.js +197 -746
  70. package/dist/commands/sources/schema-repair.js +98 -129
  71. package/dist/commands/sources/source-add.js +62 -12
  72. package/dist/commands/sources/stash-cli.js +1 -1
  73. package/dist/commands/tasks/explain.js +10 -13
  74. package/dist/commands/tasks/tasks-cli.js +9 -8
  75. package/dist/commands/tasks/tasks.js +326 -930
  76. package/dist/commands/tasks/validate.js +42 -21
  77. package/dist/commands/workflow/plan.js +22 -29
  78. package/dist/commands/workflow-cli.js +4 -4
  79. package/dist/core/adapter/adapters/akm-adapter.js +0 -1
  80. package/dist/core/adapter/adapters/akm-lint.js +2 -3
  81. package/dist/core/adapter/adapters/akm-metadata.js +11 -12
  82. package/dist/core/adapter/adapters/akm-workflow-adapter.js +1 -1
  83. package/dist/core/adapter/execution-source.js +17 -29
  84. package/dist/core/asset/resolve-ref.js +1 -1
  85. package/dist/core/bundle-id.js +42 -5
  86. package/dist/core/bundle-rename.js +291 -0
  87. package/dist/core/config/config-io.js +1 -2
  88. package/dist/core/config/config-schema.js +1 -33
  89. package/dist/core/config/config-walker.js +1 -1
  90. package/dist/core/config/config.js +163 -68
  91. package/dist/core/config/legacy-source-shape-shim.js +38 -9
  92. package/dist/core/config/schema/embedding.js +20 -5
  93. package/dist/core/config/schema/engines.js +5 -0
  94. package/dist/core/config/schema/execution.js +1 -1
  95. package/dist/core/config/schema/experimental.js +1 -1
  96. package/dist/core/config/schema/improve-processes.js +21 -95
  97. package/dist/core/config/schema/improve.js +4 -42
  98. package/dist/core/config/schema/scheduler.js +12 -12
  99. package/dist/core/config/schema/search.js +6 -22
  100. package/dist/core/env-secret-ref.js +0 -1
  101. package/dist/core/errors.js +8 -9
  102. package/dist/core/file-lock.js +76 -173
  103. package/dist/core/logs-db.js +2 -2
  104. package/dist/core/paths.js +0 -27
  105. package/dist/core/redaction.js +109 -2
  106. package/dist/core/run-lock.js +2 -5
  107. package/dist/core/spawn-env.js +1 -1
  108. package/dist/core/state/migrations.js +108 -61
  109. package/dist/core/state-db-scope.js +2 -4
  110. package/dist/core/state-db.js +126 -692
  111. package/dist/core/type-presentation.js +1 -9
  112. package/dist/core/write-source.js +293 -1012
  113. package/dist/execution/input-contract.js +1 -1
  114. package/dist/execution/resolved-request.js +135 -689
  115. package/dist/execution/source.js +63 -257
  116. package/dist/execution/target-ref.js +1 -1
  117. package/dist/indexer/bundle-identity-guard.js +2 -2
  118. package/dist/indexer/db/graph-db.js +106 -46
  119. package/dist/indexer/ensure-index.js +44 -85
  120. package/dist/indexer/graph/graph-extraction.js +340 -562
  121. package/dist/indexer/graph/graph-related.js +130 -0
  122. package/dist/indexer/index-rebuild-lock.js +3 -11
  123. package/dist/indexer/index-writer-lock.js +8 -17
  124. package/dist/indexer/index-written-assets.js +139 -151
  125. package/dist/indexer/indexer.js +524 -846
  126. package/dist/indexer/materialize-embeddings.js +60 -397
  127. package/dist/indexer/passes/memory-inference.js +81 -90
  128. package/dist/indexer/passes/metadata.js +132 -200
  129. package/dist/indexer/read-preflight.js +0 -7
  130. package/dist/indexer/scan/doc-to-entry.js +1 -3
  131. package/dist/indexer/scan/drain-dir.js +1 -1
  132. package/dist/indexer/search/db-search.js +181 -590
  133. package/dist/indexer/search/fts-query.js +30 -41
  134. package/dist/indexer/search/ranking.js +28 -154
  135. package/dist/indexer/search/search-attribution.js +12 -32
  136. package/dist/indexer/search/search-fields.js +11 -15
  137. package/dist/indexer/search/search-hit-enrichers.js +54 -85
  138. package/dist/indexer/search/search-source.js +1 -4
  139. package/dist/indexer/usage/usage-events.js +2 -7
  140. package/dist/integrations/agent/engine-fallback.js +23 -40
  141. package/dist/integrations/agent/engine-resolution.js +93 -183
  142. package/dist/integrations/agent/execution.js +507 -0
  143. package/dist/integrations/agent/model-map.js +28 -156
  144. package/dist/integrations/agent/request-lowering.js +66 -141
  145. package/dist/integrations/agent/runner-dispatch.js +143 -321
  146. package/dist/integrations/agent/runner.js +54 -14
  147. package/dist/integrations/lockfile.js +53 -101
  148. package/dist/llm/embedders/deterministic.js +2 -3
  149. package/dist/llm/embedders/profile.js +71 -0
  150. package/dist/llm/embedders/remote.js +10 -15
  151. package/dist/llm/graph-extract.js +3 -12
  152. package/dist/llm/index-passes.js +3 -5
  153. package/dist/llm/memory-infer.js +1 -2
  154. package/dist/llm/metadata-enhance.js +1 -2
  155. package/dist/llm/structured-call.js +5 -24
  156. package/dist/output/generic-render.js +23 -11
  157. package/dist/output/html-render.js +13 -10
  158. package/dist/output/render-registry.js +3 -32
  159. package/dist/output/shapes/helpers.js +2 -34
  160. package/dist/output/shapes/passthrough.js +1 -9
  161. package/dist/{indexer/search/ranking-types.js → output/text/bundle-rename.js} +4 -1
  162. package/dist/output/text/command-format.js +60 -23
  163. package/dist/output/text/helpers.js +1 -1
  164. package/dist/output/text/migrate.js +5 -14
  165. package/dist/output/text/proposal-format.js +1 -2
  166. package/dist/output/text/workflow-format.js +0 -32
  167. package/dist/output/text.js +2 -0
  168. package/dist/registry/factory.js +4 -19
  169. package/dist/registry/network.js +66 -220
  170. package/dist/registry/providers/index.js +0 -2
  171. package/dist/registry/providers/skills-sh.js +3 -14
  172. package/dist/registry/providers/static-index.js +24 -26
  173. package/dist/registry/resolve.js +55 -131
  174. package/dist/scripts/akm-migrate-node.js +43937 -93313
  175. package/dist/scripts/akm-migrate.js +43697 -93071
  176. package/dist/setup/registry-stash-loader.js +4 -13
  177. package/dist/setup/semantic-assets.js +3 -44
  178. package/dist/setup/setup.js +1 -1
  179. package/dist/setup/steps/tasks.js +25 -15
  180. package/dist/sources/provider-factory.js +17 -18
  181. package/dist/sources/providers/filesystem.js +2 -3
  182. package/dist/sources/providers/git-install.js +7 -1
  183. package/dist/sources/providers/git-provider.js +0 -3
  184. package/dist/sources/providers/git-stash.js +0 -17
  185. package/dist/sources/providers/npm.js +2 -4
  186. package/dist/sources/providers/provider-utils.js +5 -10
  187. package/dist/sources/providers/website.js +0 -2
  188. package/dist/sources/snapshot-fetchers/website-ingest.js +1 -1
  189. package/dist/sources/website-url.js +2 -2
  190. package/dist/storage/database.js +9 -35
  191. package/dist/storage/repositories/improve-ledger-repository.js +168 -0
  192. package/dist/storage/repositories/index-connection.js +34 -70
  193. package/dist/storage/repositories/index-entries-repository.js +69 -111
  194. package/dist/storage/repositories/index-entry-mapper.js +1 -2
  195. package/dist/storage/repositories/index-entry-schema.js +83 -269
  196. package/dist/storage/repositories/index-fts-repository.js +86 -256
  197. package/dist/storage/repositories/index-llm-cache-repository.js +17 -0
  198. package/dist/storage/repositories/index-meta-repository.js +6 -4
  199. package/dist/storage/repositories/index-schema.js +192 -220
  200. package/dist/storage/repositories/index-utility-repository.js +8 -29
  201. package/dist/storage/repositories/index-vec-repository.js +133 -414
  202. package/dist/storage/repositories/outcome-repository.js +2 -1
  203. package/dist/storage/repositories/proposals-repository.js +35 -0
  204. package/dist/storage/repositories/registry-index-cache-repository.js +100 -0
  205. package/dist/storage/repositories/task-history-repository.js +26 -4
  206. package/dist/storage/repositories/workflow-runs-repository.js +53 -244
  207. package/dist/storage/sqlite-migrations.js +136 -0
  208. package/dist/storage/sqlite-pragmas.js +11 -9
  209. package/dist/storage/sqlite-transaction.js +170 -0
  210. package/dist/storage/state-db-integrity.js +34 -27
  211. package/dist/tasks/activation-config.js +134 -62
  212. package/dist/tasks/backends/cron.js +129 -277
  213. package/dist/tasks/backends/exec-utils.js +2 -5
  214. package/dist/tasks/backends/launchd.js +125 -745
  215. package/dist/tasks/backends/schtasks.js +101 -620
  216. package/dist/tasks/prepare/prepare-support.js +5 -15
  217. package/dist/tasks/prepare/prepare.js +0 -2
  218. package/dist/tasks/resolve-akm-bin.js +20 -79
  219. package/dist/tasks/run/attempt-lifecycle.js +0 -1
  220. package/dist/tasks/scheduler-binding.js +18 -238
  221. package/dist/tasks/scheduler-invocation.js +52 -52
  222. package/dist/tasks/scheduler-lock.js +53 -0
  223. package/dist/tasks/scheduler-sync.js +363 -679
  224. package/dist/tasks/source/parse-task-source.js +160 -10
  225. package/dist/tasks/source/task-source-v3-frozen.js +3 -4
  226. package/dist/tasks/source/task-to-v4.js +2 -2
  227. package/dist/workflows/authoring/authoring.js +3 -12
  228. package/dist/workflows/compile.js +211 -0
  229. package/dist/workflows/concurrency-policy.js +13 -74
  230. package/dist/workflows/exec/child-invocation.js +3 -17
  231. package/dist/workflows/exec/child-workflow.js +32 -141
  232. package/dist/workflows/exec/dispatch-redaction.js +13 -53
  233. package/dist/workflows/exec/environment.js +98 -0
  234. package/dist/workflows/exec/exec-unit.js +33 -140
  235. package/dist/workflows/exec/frozen-judge.js +7 -59
  236. package/dist/workflows/exec/native-executor.js +82 -341
  237. package/dist/workflows/exec/param-secrets.js +29 -47
  238. package/dist/workflows/exec/run-workflow.js +154 -387
  239. package/dist/workflows/exec/scheduler.js +9 -36
  240. package/dist/workflows/exec/step-work.js +127 -430
  241. package/dist/workflows/exec/unit-dispatch.js +11 -63
  242. package/dist/workflows/exec/unit-writer.js +8 -52
  243. package/dist/workflows/exec/worktree.js +39 -273
  244. package/dist/workflows/freeze/child-output-references.js +4 -15
  245. package/dist/workflows/freeze/environment.js +99 -92
  246. package/dist/workflows/freeze/freeze.js +172 -0
  247. package/dist/workflows/freeze/step-values.js +19 -21
  248. package/dist/workflows/freeze/targets/child-workflow.js +23 -92
  249. package/dist/workflows/freeze/targets/command.js +10 -33
  250. package/dist/workflows/freeze/targets/script.js +5 -12
  251. package/dist/workflows/freeze/targets/shell.js +3 -6
  252. package/dist/workflows/freeze/targets/task.js +25 -80
  253. package/dist/workflows/freeze/task-bindings.js +20 -67
  254. package/dist/workflows/{source-ir/github-yaml.js → github-yaml.js} +88 -206
  255. package/dist/workflows/ir/params.js +6 -51
  256. package/dist/workflows/ir/plan-hash.js +2 -34
  257. package/dist/workflows/parser.js +140 -43
  258. package/dist/{commands/improve/consolidate/types.js → workflows/plan.js} +2 -1
  259. package/dist/workflows/renderer.js +36 -69
  260. package/dist/workflows/resource-limits.js +12 -120
  261. package/dist/workflows/runtime/agent-identity.js +8 -40
  262. package/dist/workflows/runtime/run-outputs.js +3 -6
  263. package/dist/workflows/runtime/run-plan.js +316 -0
  264. package/dist/workflows/runtime/runs.js +48 -200
  265. package/dist/workflows/runtime/workflow-asset-loader.js +24 -57
  266. package/dist/workflows/{source-ir/semantics.js → source-semantics.js} +16 -20
  267. package/dist/workflows/validate-summary.js +2 -7
  268. package/docs/integration/bundling-akm.md +49 -42
  269. package/docs/migration/README.md +1 -0
  270. package/docs/migration/release-notes/0.9.17.md +41 -0
  271. package/docs/migration/v0.9.1-to-v0.9.2.md +19 -7
  272. package/docs/reference/cli.md +182 -125
  273. package/docs/reference/configuration.md +49 -56
  274. package/docs/reference/data-and-telemetry.md +19 -20
  275. package/docs/reference/tasks.md +86 -38
  276. package/docs/reference/workflow-schema.md +14 -18
  277. package/docs/reference/workflows.md +6 -9
  278. package/package.json +1 -1
  279. package/schemas/akm-config.json +87 -406
  280. package/dist/commands/health/advisories.js +0 -150
  281. package/dist/commands/health/metrics.js +0 -329
  282. package/dist/commands/health/surfaces.js +0 -102
  283. package/dist/commands/improve/anti-collapse.js +0 -83
  284. package/dist/commands/improve/collapse-detector.js +0 -432
  285. package/dist/commands/improve/consolidate/eligibility.js +0 -48
  286. package/dist/commands/improve/consolidate/merge.js +0 -146
  287. package/dist/commands/improve/distill/promote-memory.js +0 -329
  288. package/dist/commands/improve/distill/quality-gate.js +0 -500
  289. package/dist/commands/improve/memory/memory-contradiction-detect.js +0 -291
  290. package/dist/commands/improve/proposal-envelope.js +0 -31
  291. package/dist/commands/improve/run-context.js +0 -123
  292. package/dist/commands/improve/shared.js +0 -21
  293. package/dist/commands/improve/source-identity.js +0 -28
  294. package/dist/commands/improve/triage.js +0 -96
  295. package/dist/commands/proposal/drain-policies.js +0 -151
  296. package/dist/commands/sources/update-transaction.js +0 -220
  297. package/dist/core/action-contributors.js +0 -28
  298. package/dist/core/config/config-version-shim.js +0 -101
  299. package/dist/core/config/retired-experimental-keys-shim.js +0 -62
  300. package/dist/core/fs-txn.js +0 -405
  301. package/dist/core/lexical-score.js +0 -25
  302. package/dist/core/maintenance-barrier.js +0 -167
  303. package/dist/execution/executable-identity.js +0 -105
  304. package/dist/execution/guarded-source.js +0 -427
  305. package/dist/indexer/graph/graph-boost.js +0 -427
  306. package/dist/indexer/graph/graph-dedup.js +0 -95
  307. package/dist/indexer/search/name-match.js +0 -35
  308. package/dist/indexer/search/ranking-contributors.js +0 -515
  309. package/dist/indexer/walk/project-context.js +0 -192
  310. package/dist/integrations/agent/execution-cascade.js +0 -566
  311. package/dist/integrations/agent/execution-definitions.js +0 -202
  312. package/dist/integrations/agent/execution-lowering.js +0 -841
  313. package/dist/integrations/agent/execution-preparation.js +0 -98
  314. package/dist/integrations/agent/inline-execution.js +0 -74
  315. package/dist/registry/create-provider-registry.js +0 -29
  316. package/dist/registry/pinned-request-helper.js +0 -247
  317. package/dist/registry/pinned-transport.js +0 -717
  318. package/dist/sources/providers/index.js +0 -14
  319. package/dist/storage/engines/sqlite-migrations.js +0 -271
  320. package/dist/storage/repositories/canaries-repository.js +0 -107
  321. package/dist/storage/repositories/embedding-salvage-repository.js +0 -184
  322. package/dist/storage/repositories/registry-cache.js +0 -113
  323. package/dist/tasks/scheduler-sync-preview.js +0 -52
  324. package/dist/workflows/freeze/resolve-steps.js +0 -86
  325. package/dist/workflows/freeze/source-freeze.js +0 -64
  326. package/dist/workflows/ir/compile.js +0 -321
  327. package/dist/workflows/ir/environment-v4.js +0 -330
  328. package/dist/workflows/ir/freeze-v4.js +0 -153
  329. package/dist/workflows/ir/schema-v4.js +0 -745
  330. package/dist/workflows/ir/schema.js +0 -354
  331. package/dist/workflows/program/schema.js +0 -78
  332. package/dist/workflows/runtime/checkin.js +0 -57
  333. package/dist/workflows/runtime/plan-classifier.js +0 -196
  334. package/dist/workflows/runtime/unit-checkin.js +0 -45
  335. package/dist/workflows/runtime/unit-phases.js +0 -20
  336. package/dist/workflows/schema.js +0 -4
  337. package/dist/workflows/source-ir/compile.js +0 -200
  338. package/dist/workflows/source-ir/program.js +0 -50
  339. package/dist/workflows/source-ir/result.js +0 -26
  340. package/dist/workflows/source-ir/schema.js +0 -786
  341. package/dist/workflows/source-ir/triggers.js +0 -79
  342. package/dist/workflows/source-ir/uses.js +0 -40
  343. package/dist/workflows/validator.js +0 -60
@@ -2,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,120 +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
- * The write-capable source kinds. Writes (and therefore scheduler state,
1033
- * which only ever binds to a writable source) are defined only for these
1034
- * two kinds; anything else throws `ConfigError`.
1035
- */
1036
- export function isWriteCapableSourceKind(kind) {
1037
- return kind === "filesystem" || kind === "git";
1038
- }
1039
- /**
1040
- * Reject any kind reaching the write/delete helpers other than the two
1041
- * supported writable kinds. The config loader is the first line of defence
1042
- * (assertWritableAllowedForKind), but we throw here so external callers that
1043
- * bypass the loader still get a clear error.
1044
- */
1045
- function assertSupportedKind(source) {
1046
- if (isWriteCapableSourceKind(source.kind))
1047
- return;
1048
- throw new ConfigError(`write-source: unsupported kind "${source.kind}" for source "${source.name}". ` +
1049
- "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.');
1050
- }
1051
- export function formatRefForMessage(ref) {
1052
- // Sanitize each component independently. `ref.origin` originates from user
1053
- // config and could contain CR/LF that would otherwise be smuggled into the
1054
- // commit subject and forge trailers downstream. `ref.type` and `ref.name`
1055
- // are also sanitized defensively — the asset-spec validator should already
1056
- // reject control bytes there, but a single sanitizer at the boundary keeps
1057
- // the contract explicit and centralized.
1058
- const origin = ref.origin ? sanitizeCommitMessage(ref.origin) : "";
1059
- const type = sanitizeCommitMessage(ref.type);
1060
- const name = sanitizeCommitMessage(ref.name);
1061
- // 0.9.0 (Q-02): the retired `type:name` colon grammar is gone — emit the
1062
- // slash conceptId (`workflows/name`), qualified with `origin//` when the
1063
- // ref carries one. Mirrors the `displayRef`/`conceptIdFromTypeName` rule
1064
- // used elsewhere in this file (see `resolveAssetFilePath` callers above).
1065
- const conceptId = conceptIdFromTypeName(type, name);
1066
- return origin ? `${origin}//${conceptId}` : conceptId;
1067
- }
1068
- /**
1069
- * Derive a {@link WriteTargetSource} + persisted {@link SourceConfigEntry}
1070
- * from the runtime {@link ConfiguredSource} representation used elsewhere in
1071
- * the codebase. The mapping is:
1072
- *
1073
- * ConfiguredSource.type → WriteTargetSource.kind
1074
- * ConfiguredSource.name → WriteTargetSource.name
1075
- * ConfiguredSource.source.* → WriteTargetSource.path (via parseSourceSpec)
1076
- *
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.
1077
130
  */
1078
131
  function adaptConfiguredSource(runtime) {
1079
- // Map the runtime kind to the write helper's `kind` discriminator. Only
1080
- // filesystem and git produce writable sources at v1; any other kind
1081
- // reaching this point is a config-loader bug (assertWritableAllowedForKind
1082
- // should have rejected it). Throw a ConfigError rather than silently
1083
- // forwarding an unsupported kind.
1084
132
  if (!isWriteCapableSourceKind(runtime.type)) {
1085
133
  throw new ConfigError(`write-source: source "${runtime.name}" has unsupported kind "${runtime.type}" for writes. ` +
1086
134
  "Writes are only defined for `filesystem` and `git` sources.", "INVALID_CONFIG_FILE", 'Use `kind: "filesystem"` or `kind: "git"` for writable sources.');
1087
135
  }
1088
136
  const kind = runtime.type;
1089
- // §10.2 lock-first (BEHAVIOR FIX): a managed git bundle's resolved content
1090
- // root lives in the lock (`localRoot`), NOT the desired config. Resolve there
1091
- // FIRST — via the SAME shared resolver the indexer READ path uses — so a write
1092
- // lands in exactly the directory a read walks; git sync/commit then runs
1093
- // against that same root. Before the first lock row exists, fall back to the
1094
- // derived cache repoDir + content/-subdir convention used by the read path.
1095
137
  const lockRoot = kind === "git" ? lockContentRootFor(runtime.name, runtime.type) : undefined;
1096
138
  const repoPath = lockRoot ?? pathFromConfiguredSource(runtime);
1097
139
  if (!repoPath) {
@@ -1112,13 +154,7 @@ function adaptConfiguredSource(runtime) {
1112
154
  };
1113
155
  return {
1114
156
  selector: runtime.name,
1115
- source: {
1116
- kind,
1117
- name: runtime.name,
1118
- path: componentRoot,
1119
- adapterId,
1120
- ...(kind === "git" ? { repoPath } : {}),
1121
- },
157
+ source: { kind, name: runtime.name, path: componentRoot, adapterId, ...(kind === "git" ? { repoPath } : {}) },
1122
158
  config,
1123
159
  };
1124
160
  }
@@ -1128,21 +164,12 @@ export function resolveGitContentRoot(repoPath) {
1128
164
  return fs.existsSync(contentPath) && fs.statSync(contentPath).isDirectory() ? contentPath : repoPath;
1129
165
  }
1130
166
  function pathFromConfiguredSource(runtime) {
1131
- // ConfiguredSource.source is the parsed SourceSpec (filesystem|git|website|npm).
1132
- // For writable kinds we only ever care about a local on-disk path: filesystem
1133
- // sources expose it directly; git sources resolve through the cache mirror
1134
- // (handled by the existing source provider). For v1 the helper trusts
1135
- // callers to materialise the cache path beforehand and does not re-clone.
1136
167
  const spec = runtime.source;
1137
168
  if (spec.type === "filesystem")
1138
169
  return spec.path;
1139
- // For git sources we fall back to the cached repo directory the provider
1140
- // already materialised. The lookup is intentionally lazy — we only import
1141
- // it when needed to keep the helper's import graph small.
1142
170
  if (spec.type === "git") {
1143
171
  try {
1144
- const repo = parseGitRepoUrl(spec.url);
1145
- return getCachePaths(repo.canonicalUrl).repoDir;
172
+ return getCachePaths(parseGitRepoUrl(spec.url).canonicalUrl).repoDir;
1146
173
  }
1147
174
  catch {
1148
175
  return undefined;
@@ -1150,3 +177,257 @@ function pathFromConfiguredSource(runtime) {
1150
177
  }
1151
178
  return undefined;
1152
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
+ }