akm-cli 0.9.17-alpha.3 → 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 +731 -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 +361 -751
  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 -441
  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
@@ -7,8 +7,13 @@
7
7
  * Each handler exported here is a pure function that performs the real work;
8
8
  * `src/cli.ts` wraps these in citty `defineCommand`s and shapes their return
9
9
  * values via `output()`.
10
+ *
11
+ * Every command that writes the native scheduler (`add`, `enable`,
12
+ * `disable`, `sync`, `prune --yes`) runs under the one scheduler lock
13
+ * (`withSchedulerLock`): read the installed rows once, plan against the
14
+ * sources, then install, update, or remove row by row. One row that fails is
15
+ * reported and the rest still apply.
10
16
  */
11
- import { createHash } from "node:crypto";
12
17
  import fs from "node:fs";
13
18
  import path from "node:path";
14
19
  import { stringify as yamlStringify } from "yaml";
@@ -17,28 +22,28 @@ import { assetPathForName } from "../../core/asset/asset-placement.js";
17
22
  import { makeBundleRef, parseBundleRef } from "../../core/asset/asset-ref.js";
18
23
  import { conceptIdFromTypeName, isFullRefInput } from "../../core/asset/resolve-ref.js";
19
24
  import { isWithin, resolveStashDir } from "../../core/common.js";
20
- import { loadConfig } from "../../core/config/config.js";
21
- import { bundleComponentConfig, bundleKeyForContentRoot, resolveActiveConfiguredSources, resolveConfiguredSources, } from "../../core/config/config-sources.js";
25
+ import { loadConfig, mutateConfig, resetConfigCache } from "../../core/config/config.js";
26
+ import { bundleKeyForContentRoot, resolveActiveConfiguredSources, resolveConfiguredSources, } from "../../core/config/config-sources.js";
22
27
  import { IMPROVE_AUTONOMY_CONFIG_KEY, isImproveAutonomyEnabled } from "../../core/config/experimental.js";
23
28
  import { ConfigError, NotFoundError, UsageError } from "../../core/errors.js";
24
29
  import { getTaskHistoryDir, getTaskLogDir } from "../../core/paths.js";
25
30
  import { warn } from "../../core/warn.js";
26
- import { commitWriteTargetBoundary, deleteAssetFromSource, isWriteCapableSourceKind, prepareWriteTargetForMutation, resolveWorkingStashTarget, resolveWriteTarget, writeAssetToSource, } from "../../core/write-source.js";
31
+ import { commitWriteTargetBoundary, isWriteCapableSourceKind, prepareWriteTargetForMutation, resolveWorkingStashTarget, resolveWriteTarget, writeAssetToSource, } from "../../core/write-source.js";
27
32
  import { withEngineFallback } from "../../integrations/agent/engine-fallback.js";
28
33
  import { resolveAssetPath } from "../../sources/resolve.js";
29
- import { activeSchedulerActivations, isSchedulerRefEnabled, setSchedulerRefEnabled, } from "../../tasks/activation-config.js";
34
+ import { enabledRefsFromInstalled, schedulerEnabledRefs, setSchedulerRefEnabled } from "../../tasks/activation-config.js";
30
35
  import { backendNameForPlatform, selectBackend } from "../../tasks/backends/index.js";
31
36
  import { prepareTaskV3Execution } from "../../tasks/prepare/prepare.js";
32
- import { resolveAkmInvocation } from "../../tasks/resolve-akm-bin.js";
37
+ import { isCheckoutInvocation, resolveAkmInvocation } from "../../tasks/resolve-akm-bin.js";
33
38
  import { createExecutionProvenanceContext } from "../../tasks/run/provenance.js";
34
39
  import { runTask } from "../../tasks/run/run-task.js";
35
40
  import { readTaskHistory } from "../../tasks/run/task-history.js";
36
41
  import { exitCodeForStatus } from "../../tasks/run/task-result.js";
37
42
  import { parseSchedule, SCHEDULE_SUPPORTED_SUBSET_HINT } from "../../tasks/schedule.js";
38
- import { assertSchedulerMutationArtifact, assertSchedulerNativeArtifactCardinality, compileTaskSchedulerBindings, schedulerBindingNativeId, schedulerBindingOrdinal, schedulerNativeArtifactKey, schedulerNativeBindingId, } from "../../tasks/scheduler-binding.js";
43
+ import { compileTaskSchedulerBindings, schedulerBindingNativeId, } from "../../tasks/scheduler-binding.js";
39
44
  import { schedulerContextDescriptor, schedulerContextPath, validateSchedulerContextDescriptor, writeSchedulerContextDescriptor, } from "../../tasks/scheduler-invocation.js";
40
- import { assertSchedulerBackendInspection, assertSchedulerNativeArtifactOwnership, assertSchedulerSourceSnapshot, buildSchedulerRemoveOperation, finalizeSchedulerSyncPlan, prepareSchedulerSyncSourceSet, } from "../../tasks/scheduler-sync.js";
41
- import { renderSchedulerPlanPreview, renderSchedulerSyncPlanPreview, } from "../../tasks/scheduler-sync-preview.js";
45
+ import { withSchedulerLock } from "../../tasks/scheduler-lock.js";
46
+ import { compileSchedulerSources, installedRowNativeId, installedRowOwner, installedRowScope, planSchedulerSync, renderSchedulerPlanPreview, scheduledInvocationBundle, } from "../../tasks/scheduler-sync.js";
42
47
  import { parseTaskSource } from "../../tasks/source/parse-task-source.js";
43
48
  import { projectTaskSourceV4 } from "../../tasks/source/project-v4.js";
44
49
  import { normaliseTaskConceptId, normaliseTaskId } from "../../tasks/task-id.js";
@@ -47,27 +52,17 @@ import { resolveImproveStrategy } from "../improve/improve-strategies.js";
47
52
  export async function akmTasksAdd(input, deps = {}) {
48
53
  const id = normaliseTaskId(input.id);
49
54
  assertTaskAddTargetShape(input);
50
- // Validate the schedule for the active backend before writing anything.
51
- // WI-9.10e: the injected backend (tests) carries its own name, so derive it
52
- // from `deps.backend` when present — retiring the `_setBackendsForTests` seam.
55
+ // Validate the schedule for the active backend before writing anything. An
56
+ // injected backend (tests) carries its own name.
53
57
  const backend = deps.backend?.name ?? backendNameForPlatform();
54
58
  parseSchedule(input.schedule, backend);
55
- const bundle = resolveTaskBundle(input.target, { requireWritable: true });
56
- const writeTarget = bundle.resolved;
59
+ const bundle = resolveTaskBundle(input.target);
57
60
  const stashDir = bundle.stashDir;
58
- const installOpts = bundle.installTarget !== undefined ? { target: bundle.installTarget } : undefined;
59
61
  const typeRoot = path.join(stashDir, "tasks");
60
62
  const assetPath = assetPathForName("task", typeRoot, id);
61
63
  if (!isWithin(assetPath, typeRoot)) {
62
64
  throw new UsageError(`Resolved task path escapes the stash: "${id}".`, "PATH_ESCAPE_VIOLATION");
63
65
  }
64
- if (fs.existsSync(assetPath) && !input.force) {
65
- throw new UsageError(`Task "${id}" already exists. Pass --force to overwrite, or delete its file and run \`akm task sync\` first.`, "RESOURCE_ALREADY_EXISTS");
66
- }
67
- const sourceExpectation = captureTaskSourceExpectation(assetPath, stashDir);
68
- if (sourceExpectation.state === "present" && !input.force) {
69
- throw new UsageError(`Task "${id}" appeared while add was preparing. Pass --force only after reviewing the current owner.`, "RESOURCE_ALREADY_EXISTS");
70
- }
71
66
  const yaml = renderTaskYaml({
72
67
  id,
73
68
  schedule: input.schedule,
@@ -94,12 +89,9 @@ export async function akmTasksAdd(input, deps = {}) {
94
89
  config: bundle.config,
95
90
  resolveAsset: taskProjectionAssetResolver(bundle.config, bundle.bundleName, stashDir),
96
91
  });
97
- // Bindings are compiled from the original parsed document so each
98
- // schedule entry's inputs reach the generated invocation.
99
- const taskBindings = compileTaskSchedulerBindings({
92
+ const bindings = compileTaskSchedulerBindings({
100
93
  id,
101
94
  qualifiedRef,
102
- ...(bundle.installTarget ? { bundleTarget: bundle.installTarget } : {}),
103
95
  schedules: parsedTask.v4.schedule.map((schedule) => ({
104
96
  cron: schedule.cron,
105
97
  ordinal: schedule.ordinal,
@@ -107,136 +99,49 @@ export async function akmTasksAdd(input, deps = {}) {
107
99
  inputs: schedule.inputs,
108
100
  })),
109
101
  });
110
- const taskBinding = taskBindings[0];
111
- if (!taskBinding)
102
+ const first = bindings[0];
103
+ if (!first)
112
104
  throw new UsageError(`Task "${id}" has no schedulable trigger.`, "INVALID_FLAG_VALUE");
113
- const ref = taskAssetRef(id);
114
105
  const sched = deps.backend ?? selectBackend();
115
- const writeAsset = deps.writeAsset ?? writeAssetToSource;
116
- const deleteAsset = deps.deleteAsset ?? deleteAssetFromSource;
117
- const commitBoundary = deps.commitBoundary ?? commitWriteTargetBoundary;
118
- if (input.disabled) {
119
- // Revoke before publishing replacement bytes. If a write/commit/sync
120
- // fails, the safe partial state is an inert task, never an activated new
121
- // source (or a stale native binding that can still dispatch it).
122
- setSchedulerRefEnabled("task", qualifiedRef, false);
123
- await writeAsset(writeTarget.source, writeTarget.config, ref, yaml);
124
- commitBoundary(writeTarget, `Update tasks/${id}`);
125
- await akmTasksSync(deps, bundle.bundleName);
106
+ return withSchedulerLock(async () => {
107
+ if (fs.existsSync(assetPath) && !input.force) {
108
+ throw new UsageError(`Task "${id}" already exists. Pass --force to overwrite, or delete its file and run \`akm task sync\` first.`, "RESOURCE_ALREADY_EXISTS");
109
+ }
110
+ // Native ids are shared by every bundle and installation: refuse before
111
+ // writing anything when another one already schedules this id.
112
+ const scope = bundleScope(bundle.bundleName, stashDir);
113
+ const installed = await listInstalledRows(sched);
114
+ for (const binding of bindings) {
115
+ const row = installed.find((candidate) => installedRowNativeId(candidate) === schedulerBindingNativeId(binding));
116
+ if (row && !installedRowScope(row, [scope])) {
117
+ throw new UsageError(`Task id "${id}" is already scheduled from ${installedRowOwner(row)}; rename the task or disable the existing one first.`, "RESOURCE_ALREADY_EXISTS");
118
+ }
119
+ }
120
+ await ensureSchedulerChoice({ backend: sched });
121
+ if (input.disabled)
122
+ setSchedulerRefEnabled(qualifiedRef, false);
123
+ await (deps.writeAsset ?? writeAssetToSource)(bundle.resolved.source, bundle.resolved.config, taskAssetRef(id), yaml);
124
+ (deps.commitBoundary ?? commitWriteTargetBoundary)(bundle.resolved, `Update tasks/${id}`);
125
+ if (!input.disabled)
126
+ setSchedulerRefEnabled(qualifiedRef, true);
127
+ const sync = await akmTasksSync(deps, bundle.bundleName, { rebind: input.rebind === true });
128
+ for (const warning of sync.warnings ?? [])
129
+ warn(warning);
130
+ const failure = sync.failures.find((candidate) => candidate.ref === qualifiedRef);
131
+ if (failure) {
132
+ throw new ConfigError(`Task "${id}" was written to ${assetPath}${input.disabled ? "" : " and enabled"}, but it could not be scheduled: ${failure.reason}`, "INVALID_CONFIG_FILE", "Fix the cause and run `akm task sync`.");
133
+ }
126
134
  return {
127
135
  id,
128
136
  ref: conceptIdFromTypeName("task", id),
129
137
  path: assetPath,
130
138
  bundleDir: stashDir,
131
- schedule: taskBinding.cron,
132
- enabled: false,
139
+ schedule: first.cron,
140
+ enabled: input.disabled !== true,
133
141
  backend,
134
142
  target: task.target,
135
143
  };
136
- }
137
- const transaction = await prepareTaskAddSchedulerTransaction({
138
- id,
139
- installTarget: bundle.installTarget,
140
- ownerTarget: bundle.bundleName,
141
- installOpts,
142
- taskBindings,
143
- sched,
144
- deps,
145
- rebind: input.rebind === true,
146
144
  });
147
- let sourceMutationReceipt;
148
- let sourcePublished = false;
149
- let sourcePublicationAttempted = false;
150
- const publishSource = async () => {
151
- assertTaskSourceExpectation(sourceExpectation);
152
- sourcePublicationAttempted = true;
153
- await writeAsset(writeTarget.source, writeTarget.config, ref, yaml);
154
- const publishedSource = captureTaskSourceExpectation(assetPath, stashDir);
155
- if (publishedSource.state !== "present" || publishedSource.sha256 !== hashTaskSource(yaml)) {
156
- throw new UsageError(`Task source ${JSON.stringify(assetPath)} changed during publication.`, "RESOURCE_ALREADY_EXISTS");
157
- }
158
- sourceMutationReceipt = publishedSource;
159
- sourcePublished = true;
160
- transaction.publishRuntime?.();
161
- };
162
- await applySchedulerTransaction(sched, transaction.operations, {
163
- initialExpectations: transaction.initialExpectations,
164
- assertReadSet: () => {
165
- if (sourcePublished) {
166
- if (!sourceMutationReceipt) {
167
- throw new ConfigError("Published task source lost its transaction receipt.", "INVALID_CONFIG_FILE");
168
- }
169
- assertTaskSourceExpectation(sourceMutationReceipt);
170
- }
171
- else {
172
- assertTaskSourceExpectation(sourceExpectation);
173
- }
174
- },
175
- beforeOperation: async (_operation, index) => {
176
- if (index === transaction.publishOperationIndex && !sourcePublished)
177
- await publishSource();
178
- },
179
- afterOperations: () => commitBoundary(writeTarget, `Update tasks/${id}`),
180
- rollbackExternal: async () => {
181
- if (!sourcePublicationAttempted)
182
- return;
183
- if (!sourceMutationReceipt) {
184
- const current = captureTaskSourceExpectation(assetPath, stashDir);
185
- if (sameTaskSourceExpectation(current, sourceExpectation))
186
- return;
187
- if (current.state !== "present" || current.sha256 !== hashTaskSource(yaml)) {
188
- throw new UsageError(`Task source ${JSON.stringify(assetPath)} has an unowned publication state; refusing rollback over a possible concurrent owner.`, "RESOURCE_ALREADY_EXISTS");
189
- }
190
- sourceMutationReceipt = current;
191
- }
192
- assertTaskSourceExpectation(sourceMutationReceipt);
193
- try {
194
- if (sourceExpectation.state === "absent") {
195
- await deleteAsset(writeTarget.source, writeTarget.config, ref);
196
- }
197
- else {
198
- await writeAsset(writeTarget.source, writeTarget.config, ref, sourceExpectation.content);
199
- const providerRestored = captureTaskSourceExpectation(assetPath, stashDir);
200
- if (providerRestored.state !== "present" || providerRestored.sha256 !== sourceExpectation.sha256) {
201
- // Provider writers conventionally normalize a trailing newline.
202
- // Rollback is byte-exact, so finish the already-owned restore with
203
- // the frozen bytes before checking the physical source state.
204
- fs.writeFileSync(assetPath, Buffer.from(sourceExpectation.bytesBase64, "base64"));
205
- }
206
- }
207
- assertTaskSourceRestored(sourceExpectation);
208
- commitBoundary(writeTarget, `Restore tasks/${id}`);
209
- }
210
- catch (cause) {
211
- // A write/commit seam may report failure after it has already restored
212
- // the exact source bytes. Prove that state before allowing native
213
- // rollback; otherwise preserve the possible concurrent source owner.
214
- try {
215
- assertTaskSourceRestored(sourceExpectation);
216
- }
217
- catch {
218
- throw cause;
219
- }
220
- throw new TaskSourceRestoredBoundaryError(cause);
221
- }
222
- },
223
- suppressNativeRollbackWhenExternalFails: true,
224
- allowNativeRollbackAfterExternalFailure: (error) => error instanceof TaskSourceRestoredBoundaryError,
225
- });
226
- // Publish the host-local grant only after source and native state commit.
227
- // A failure before this point therefore leaves any new binding inert at
228
- // its scheduled-fire gate instead of authorizing partially committed code.
229
- setSchedulerRefEnabled("task", qualifiedRef, true);
230
- return {
231
- id,
232
- ref: conceptIdFromTypeName("task", id),
233
- path: assetPath,
234
- bundleDir: stashDir,
235
- schedule: taskBinding.cron,
236
- enabled: true,
237
- backend,
238
- target: task.target,
239
- };
240
145
  }
241
146
  function assertTaskAddTargetShape(input) {
242
147
  const hasCommand = input.command !== undefined &&
@@ -264,11 +169,6 @@ export async function akmTasksRun(id, options = {}) {
264
169
  const adapterId = bundle.source.adapterId ?? detectAdapterId(bundle.source.path);
265
170
  const resolvedId = taskIdForAdapter(parsed.id, adapterId);
266
171
  const scheduled = options.scheduled === true;
267
- const conceptId = adapterId === "akm" ? `tasks/${resolvedId}` : resolvedId;
268
- const qualifiedRef = makeBundleRef(bundle.source.name, conceptId);
269
- if (scheduled && !isSchedulerRefEnabled(loadConfig(), "task", qualifiedRef)) {
270
- throw new UsageError(`Scheduled task ${JSON.stringify(qualifiedRef)} is not enabled in local scheduler config; run \`akm task enable ${qualifiedRef}\`.`, "INVALID_FLAG_VALUE");
271
- }
272
172
  // D5 "Construction" (spec docs/plans/specs/p1b-model-extraction.md §1.2/
273
173
  // §5.2): built ONCE at this invocation boundary. eventSource is "task"
274
174
  // whether or not --scheduled was passed (§1.6 D5-N1) — scheduled stays a
@@ -342,17 +242,41 @@ function resolveTaskActivation(ref, target, requireSchedulable) {
342
242
  }
343
243
  return { qualifiedRef: makeBundleRef(bundle.source.name, conceptId), sourcePath, bundleName: bundle.source.name };
344
244
  }
245
+ /**
246
+ * A config that predates `scheduler.enabled` means "keep what is installed":
247
+ * write that as the host's explicit list before one ref is added or removed,
248
+ * so enabling or disabling a single task never silently drops the others.
249
+ */
250
+ async function ensureSchedulerChoice(deps) {
251
+ const config = loadConfig();
252
+ if (schedulerEnabledRefs(config) !== undefined)
253
+ return;
254
+ initializeSchedulerChoice(await (deps.backend ?? selectBackend()).list(), config, { write: true });
255
+ }
256
+ function initializeSchedulerChoice(installed, config, options) {
257
+ const refs = enabledRefsFromInstalled(installed, config);
258
+ if (options.write) {
259
+ mutateConfig((current) => ({ ...current, scheduler: { ...current.scheduler, enabled: [...refs] } }));
260
+ resetConfigCache();
261
+ }
262
+ warn(`This host had no scheduler.enabled list; ${options.write ? "it was" : "it would be"} initialized from the ` +
263
+ `${refs.length} installed akm scheduler ${refs.length === 1 ? "binding" : "bindings"}` +
264
+ `${refs.length > 0 ? `: ${refs.join(", ")}` : ""}. Use \`akm task enable\`/\`akm task disable <ref>\` to change it.`);
265
+ return refs;
266
+ }
345
267
  export async function akmTasksEnable(ref, options = {}, deps = {}) {
346
- const resolved = resolveTaskActivation(ref, options.target, true);
347
- const activation = setSchedulerRefEnabled("task", resolved.qualifiedRef, true);
348
- const sync = await akmTasksSync(deps, resolved.bundleName);
349
- return { ref: resolved.qualifiedRef, enabled: true, changed: activation.changed, sync };
268
+ return setTaskActivation(resolveTaskActivation(ref, options.target, true), true, deps);
350
269
  }
351
270
  export async function akmTasksDisable(ref, options = {}, deps = {}) {
352
- const resolved = resolveTaskActivation(ref, options.target, false);
353
- const activation = setSchedulerRefEnabled("task", resolved.qualifiedRef, false);
354
- const sync = await akmTasksSync(deps, resolved.bundleName);
355
- return { ref: resolved.qualifiedRef, enabled: false, changed: activation.changed, sync };
271
+ return setTaskActivation(resolveTaskActivation(ref, options.target, false), false, deps);
272
+ }
273
+ async function setTaskActivation(resolved, enabled, deps) {
274
+ return withSchedulerLock(async () => {
275
+ await ensureSchedulerChoice(deps);
276
+ const activation = setSchedulerRefEnabled(resolved.qualifiedRef, enabled);
277
+ const sync = await akmTasksSync(deps, resolved.bundleName);
278
+ return { ref: resolved.qualifiedRef, enabled, changed: activation.changed, sync };
279
+ });
356
280
  }
357
281
  export async function akmTasksHistory(input) {
358
282
  const limit = input.limit !== undefined && input.limit > 0 ? input.limit : 50;
@@ -365,272 +289,236 @@ export async function akmTasksHistory(input) {
365
289
  }
366
290
  /**
367
291
  * Reconcile host-local scheduler activation with authored tasks/workflows.
368
- * • without --bundle, scan every enabled configured bundle in one transaction
369
- * • with --bundle, reconcile only that bundle
370
- * • only refs present in scheduler.enabled enter the desired set
371
- * • install missing bindings only after that whole-set preflight succeeds
372
- * • reinstall bindings whose authored schedule or runtime state changed
373
- * (drift detected by comparing the backend's installed signature against
374
- * the signature the current definition would produce)
375
- * • remove orphan scheduler entries that no longer have a backing file
292
+ *
293
+ * • without a bundle, every enabled configured filesystem/git bundle; with
294
+ * one, only that bundle
295
+ * • only refs listed in `scheduler.enabled` are read and scheduled
296
+ * • a row is installed when missing, rewritten when its rendered definition
297
+ * differs, and removed when its source is gone or no longer enabled
298
+ * • rows another bundle or installation owns are never touched, and a row
299
+ * whose source fails to compile is left as it is
300
+ * • a disabled bundle's rows are removed by an unscoped sync
376
301
  */
302
+ export async function akmTasksSync(deps = {}, bundleTarget, options = {}) {
303
+ return withSchedulerLock(async () => {
304
+ const { sched, plan, publish, warnings } = await buildSchedulerSyncPlan(deps, bundleTarget, options);
305
+ if (publish && plan.operations.some((operation) => operation.kind !== "remove"))
306
+ publish();
307
+ const failed = new Set();
308
+ const failures = [...plan.failures];
309
+ for (const operation of plan.operations) {
310
+ try {
311
+ if (operation.kind === "remove")
312
+ await sched.uninstall(operation.nativeId);
313
+ else
314
+ await sched.install(operation.binding, operation.options);
315
+ }
316
+ catch (cause) {
317
+ if (operation.kind === "remove") {
318
+ failed.add(operation.id);
319
+ failures.push({ path: operation.nativeId, reason: errorMessage(cause) });
320
+ }
321
+ else {
322
+ failed.add(operation.binding.id);
323
+ failures.push({
324
+ path: operation.binding.source,
325
+ ref: operation.binding.logicalSource.ref,
326
+ reason: errorMessage(cause),
327
+ });
328
+ }
329
+ }
330
+ }
331
+ const applied = (ids) => ids.filter((id) => !failed.has(id));
332
+ return {
333
+ installed: applied(plan.installed),
334
+ updated: applied(plan.updated),
335
+ removed: applied(plan.removed),
336
+ unchanged: [...plan.unchanged],
337
+ skipped: [],
338
+ backend: sched.name,
339
+ failures: failures.map((failure) => ({ ...failure })),
340
+ ...(warnings.length > 0 ? { warnings } : {}),
341
+ };
342
+ });
343
+ }
377
344
  /**
378
- * Compute (but never apply) a scheduler sync plan: everything through
379
- * `finalizeSchedulerSyncPlan`'s final call, stopping strictly before
380
- * `applySchedulerSyncPlan`. Shared by `akmTasksSync` (applies the plan) and
381
- * `akmTasksSyncPlan` (#849 `--dry-run`, never applies it) so the two paths
382
- * can never drift on what "the plan" means. `prepared?.publish`, the one
383
- * deferred write-producing closure in this pipeline, is returned but never
384
- * invoked here — only `applySchedulerSyncPlan` may call it.
345
+ * `akm task sync --dry-run` (#849): the exact plan `akmTasksSync` would
346
+ * apply, previewed. It writes nothing: no config, no descriptor, no row.
385
347
  */
348
+ export async function akmTasksSyncPlan(deps = {}, bundleTarget, options = {}) {
349
+ const { sched, plan } = await buildSchedulerSyncPlan(deps, bundleTarget, { ...options, dryRun: true });
350
+ return renderSchedulerPlanPreview(sched.name, plan.operations, plan.unchanged, plan.failures);
351
+ }
386
352
  async function buildSchedulerSyncPlan(deps, bundleTarget, options) {
387
- const config = loadConfig();
353
+ let config = loadConfig();
388
354
  const sched = deps.backend ?? selectBackend();
389
- if (!sched.inspectBindings) {
390
- throw new ConfigError(`Scheduler backend "${sched.name}" cannot provide one coherent inspection for transactional sync.`, "INVALID_CONFIG_FILE");
355
+ const installed = await listInstalledRows(sched);
356
+ // A config that predates `scheduler.enabled` means "keep what is
357
+ // installed": the akm-written rows become the host's choice (written
358
+ // unless this is a dry run) before the desired set, which would otherwise
359
+ // remove them, is computed.
360
+ let enabledRefs = schedulerEnabledRefs(config);
361
+ if (enabledRefs === undefined) {
362
+ enabledRefs = initializeSchedulerChoice(installed, config, { write: options.dryRun !== true });
363
+ if (options.dryRun !== true)
364
+ config = loadConfig();
391
365
  }
392
- const inspection = await sched.inspectBindings({ rebind: options.rebind === true });
393
- const rawEntries = [...inspection.installed];
394
- const allEntries = rawEntries.map((entry) => {
395
- const contextPath = "contextPath" in entry ? entry.contextPath : "";
396
- // #846: recover the resolved bundle path this entry was installed
397
- // under from its own scheduler-context descriptor. Any failure (no
398
- // descriptor, unreadable, corrupt, owned by another user) leaves
399
- // ownerBundlePath unset — belongsToBundle must never treat that as
400
- // "mine".
401
- const ownerBundlePath = contextPath ? resolveInstalledOwnerPath(contextPath) : undefined;
402
- return {
403
- ...entry,
404
- ...(entry.nativeId !== undefined ? { nativeId: entry.nativeId } : {}),
405
- ...(entry.invocation !== undefined ? { invocation: Object.freeze([...entry.invocation]) } : {}),
406
- binding: "binding" in entry ? [...entry.binding] : [],
407
- contextPath,
408
- ...(ownerBundlePath !== undefined ? { ownerBundlePath } : {}),
409
- };
410
- });
411
- const nativeArtifacts = inspection.artifacts;
412
- const configuredSources = resolveConfiguredSources(config);
413
366
  const activeSources = resolveActiveConfiguredSources(config);
414
367
  if (bundleTarget) {
415
- // adaptConfiguredSource (src/core/write-source.ts) rejects any kind
416
- // other than filesystem/git outright, so a website/npm bundle can never
417
- // carry scheduler state (akm task enable already fails the same way).
418
- // Surface that as a clear usage error here instead of letting the
419
- // write-target resolution below raise a generic ConfigError.
368
+ // Only filesystem and git bundles can carry scheduler state.
420
369
  const targetSource = activeSources.find((source) => source.name === bundleTarget);
421
370
  if (targetSource && !isWriteCapableSourceKind(targetSource.type)) {
422
371
  throw new UsageError(`Bundle "${bundleTarget}" has kind "${targetSource.type}"; task scheduling is only supported for filesystem and git bundles.`, "INVALID_FLAG_VALUE");
423
372
  }
424
373
  }
425
- // Unscoped sync only installs/removes bindings for bundles that can carry
426
- // them (filesystem/git). A website/npm bundle contributes no installs and
427
- // must not crash the loop; inactiveOperations below still sees it via
428
- // configuredSources for removal/revocation.
429
- const sourceNames = bundleTarget
374
+ const configuredSources = resolveConfiguredSources(config);
375
+ const schedulable = activeSources
376
+ .filter((source) => isWriteCapableSourceKind(source.type))
377
+ .map((source) => source.name);
378
+ // No configured bundle at all: the working stash (e.g. `AKM_BUNDLE_DIR`).
379
+ const selected = bundleTarget
430
380
  ? [bundleTarget]
431
- : activeSources.filter((source) => isWriteCapableSourceKind(source.type)).map((source) => source.name);
432
- const inactiveOperations = bundleTarget
381
+ : schedulable.length > 0 || configuredSources.length > 0
382
+ ? schedulable
383
+ : [undefined];
384
+ const enabledRefSet = new Set(enabledRefs);
385
+ const desired = [];
386
+ const failures = [];
387
+ const keepRefs = new Set();
388
+ const scopes = [];
389
+ // Each bundle is its own item: one that cannot be read is reported and
390
+ // left untouched while the others reconcile. A scoped sync has only that
391
+ // bundle, so its failure is the whole operation's.
392
+ for (const name of selected) {
393
+ try {
394
+ const resolved = resolveTaskReadBundle(undefined, name);
395
+ const stashDir = resolved.source.path;
396
+ const adapterId = resolved.source.adapterId ?? detectAdapterId(stashDir);
397
+ const compiled = await compileSchedulerSources({
398
+ sourceRoot: stashDir,
399
+ adapterId,
400
+ bundleName: resolved.source.name,
401
+ backend: sched.name,
402
+ config,
403
+ resolveAsset: taskProjectionAssetResolver(config, resolved.source.name, stashDir),
404
+ enabledRefs: enabledRefSet,
405
+ });
406
+ desired.push(...compiled.desired);
407
+ failures.push(...compiled.failures);
408
+ for (const failure of compiled.failures)
409
+ if (failure.ref)
410
+ keepRefs.add(failure.ref);
411
+ scopes.push({ ...bundleScope(resolved.source.name, stashDir), adapterId });
412
+ }
413
+ catch (cause) {
414
+ if (bundleTarget)
415
+ throw cause;
416
+ failures.push({ path: name ?? "(default bundle)", reason: errorMessage(cause) });
417
+ }
418
+ }
419
+ // An enabled ref of a synced bundle that produced nothing names a source
420
+ // that is gone or has no schedule; its row, if any, is removed below.
421
+ const represented = new Set([...desired.map((binding) => binding.logicalSource.ref), ...keepRefs]);
422
+ const synced = new Set(scopes.map((scope) => scope.bundleName));
423
+ for (const ref of enabledRefs) {
424
+ const bundle = parseBundleRef(ref).bundle;
425
+ if (bundle !== undefined && synced.has(bundle) && !represented.has(ref)) {
426
+ failures.push({ path: ref, ref, reason: `Enabled ref ${JSON.stringify(ref)} was not found or has no schedule.` });
427
+ }
428
+ }
429
+ // A disabled bundle's rows go on an unscoped sync, without reading its content.
430
+ const inactive = new Set(configuredSources.filter((source) => source.enabled === false).map((source) => source.name));
431
+ const extraRemovals = bundleTarget
433
432
  ? []
434
- : inactiveBundleRemovalOperations(config, configuredSources, allEntries, nativeArtifacts);
435
- if (!bundleTarget && sourceNames.length === 0 && configuredSources.length > 0) {
436
- assertSchedulerBackendInspection({ installed: allEntries, artifacts: nativeArtifacts });
437
- const plan = emptySchedulerSyncPlan(inactiveOperations);
438
- return { sched, plan, sourceSnapshots: Object.freeze([]), prepared: undefined, warnings: [] };
439
- }
440
- const selectedNames = sourceNames.length > 0 ? sourceNames : [undefined];
441
- const enabled = activeSchedulerActivations(config);
442
- const enabledActivations = new Set(enabled.map((activation) => `${activation.kind}\0${activation.ref}`));
443
- const preparedSets = [];
444
- for (const sourceName of selectedNames) {
445
- const resolved = resolveTaskReadBundle(undefined, sourceName);
446
- const stashDir = resolved.source.path;
447
- const syncTarget = sourceName !== undefined && !isPrimaryStashPath(stashDir) ? sourceName : undefined;
448
- const common = {
449
- sourceRoot: stashDir,
450
- adapterId: resolved.source.adapterId ?? detectAdapterId(stashDir),
451
- bundleName: resolved.source.name,
452
- ...(syncTarget === undefined ? { bundlePath: path.resolve(stashDir) } : {}),
453
- ...(syncTarget ? { bundleTarget: syncTarget } : {}),
454
- backend: sched.name,
455
- installed: allEntries,
456
- nativeArtifacts,
457
- inspection: Object.freeze({ installed: allEntries, artifacts: nativeArtifacts }),
458
- enabledActivations,
459
- rebind: options.rebind === true,
460
- config,
461
- resolveAsset: taskProjectionAssetResolver(config, resolved.source.name, stashDir),
462
- };
463
- preparedSets.push({
464
- common,
465
- preparedSources: await prepareSchedulerSyncSourceSet(common),
466
- ...(syncTarget ? { syncTarget } : {}),
433
+ : installed.filter((row) => {
434
+ const bundle = installedSchedulerBundle(config, row);
435
+ return bundle !== undefined && inactive.has(bundle);
467
436
  });
468
- }
469
- // Pass one validates every selected bundle and computes whether any desired
470
- // activation needs a new runtime descriptor before native mutation begins.
471
- const preflights = preparedSets.map(({ common, preparedSources }) => finalizeSchedulerSyncPlan(common, preparedSources));
472
- const warnings = [];
473
- const expectedSignature = sched.expectedSignature?.bind(sched);
474
- const needsRuntime = preflights.some((preflight) => preflight.operations.some((operation) => operation.kind !== "remove" && operation.options?.binding === undefined));
475
- const prepared = needsRuntime
476
- ? prepareSchedulerSyncRuntime(undefined, deps, warnings, allEntries.map((entry) => entry.binding))
477
- : undefined;
478
- const plans = preparedSets.map(({ common, preparedSources, syncTarget }) => finalizeSchedulerSyncPlan({
479
- ...common,
480
- ...(prepared?.options
481
- ? { installOptions: { ...prepared.options, ...(syncTarget ? { target: syncTarget } : {}) } }
482
- : syncTarget
483
- ? { installOptions: { target: syncTarget } }
484
- : {}),
485
- ...(expectedSignature
486
- ? {
487
- expectedSignature: (binding, install) => expectedSignature(binding, install),
488
- }
489
- : {}),
490
- }, preparedSources));
491
- assertNoCrossBundleSchedulerCollisions(plans);
492
- const representedRefs = new Set(plans.flatMap((candidate) => [
493
- ...candidate.desired.map((binding) => binding.logicalSource.ref),
494
- ...candidate.failures.flatMap((failure) => (failure.ref ? [failure.ref] : [])),
495
- ]));
496
- const selectedBundleNames = new Set(preparedSets.map(({ common }) => common.bundleName));
497
- const missingActivationFailures = enabled
498
- .filter((activation) => {
499
- const bundle = parseBundleRef(activation.ref).bundle;
500
- return bundle !== undefined && selectedBundleNames.has(bundle) && !representedRefs.has(activation.ref);
501
- })
502
- .map((activation) => ({
503
- path: activation.ref,
504
- ref: activation.ref,
505
- reason: `Enabled ${activation.kind} ${JSON.stringify(activation.ref)} was not found or has no schedule.`,
506
- }));
507
- const first = plans[0];
508
- if (!first)
509
- throw new ConfigError("No configured bundle is available for scheduler sync.", "INVALID_CONFIG_FILE");
510
- const plan = Object.freeze({
511
- desired: Object.freeze(plans.flatMap((candidate) => candidate.desired)),
512
- installed: Object.freeze(plans.flatMap((candidate) => candidate.installed)),
513
- updated: Object.freeze(plans.flatMap((candidate) => candidate.updated)),
514
- removed: Object.freeze([
515
- ...plans.flatMap((candidate) => candidate.removed),
516
- ...inactiveOperations.map((operation) => operation.id),
517
- ]),
518
- unchanged: Object.freeze(plans.flatMap((candidate) => candidate.unchanged)),
519
- operations: Object.freeze([...plans.flatMap((candidate) => candidate.operations), ...inactiveOperations]),
520
- sourceSnapshot: first.sourceSnapshot,
521
- failures: Object.freeze([...plans.flatMap((candidate) => candidate.failures), ...missingActivationFailures]),
522
- });
523
- return { sched, plan, sourceSnapshots: plans.map((candidate) => candidate.sourceSnapshot), prepared, warnings };
524
- }
525
- function inactiveBundleRemovalOperations(config, configuredSources, installed, artifacts) {
526
- const inactive = new Set(configuredSources.filter((source) => source.enabled === false).map((source) => source.name));
527
- if (inactive.size === 0)
528
- return [];
529
- return installed
530
- .map((entry) => ({ entry, bundleName: installedSchedulerBundle(config, entry) }))
531
- .filter((candidate) => candidate.bundleName !== undefined && inactive.has(candidate.bundleName))
532
- .sort((left, right) => left.entry.id.localeCompare(right.entry.id))
533
- .map(({ entry, bundleName }) => {
534
- const adapterId = bundleComponentConfig(config.bundles?.[bundleName])?.adapter ?? "akm";
535
- return buildSchedulerRemoveOperation(entry.id, entry, artifacts, { adapterId, bundleName });
437
+ const runtime = prepareSchedulerRuntime(deps);
438
+ const plan = planSchedulerSync({
439
+ desired,
440
+ installed,
441
+ scopes,
442
+ ...(sched.expectedSignature ? { expectedSignature: sched.expectedSignature.bind(sched) } : {}),
443
+ ...(runtime.options ? { installOptions: runtime.options } : {}),
444
+ rebind: options.rebind === true,
445
+ extraRemovals,
446
+ keepRefs,
536
447
  });
448
+ const warnings = [];
449
+ const writesLauncher = options.rebind === true
450
+ ? plan.operations.some((operation) => operation.kind !== "remove")
451
+ : plan.installed.length > 0;
452
+ if (runtime.via === "checkout" && writesLauncher) {
453
+ warnings.push(`Scheduled tasks now run akm from a source checkout (${runtime.options?.binding?.join(" ")}); they run whatever the checkout holds when they fire. Install akm with \`npm install --global akm-cli\` or a standalone release, then run \`akm task sync --rebind\`.`);
454
+ }
455
+ return {
456
+ sched,
457
+ plan: { ...plan, failures: [...failures, ...plan.failures] },
458
+ ...(runtime.publish ? { publish: runtime.publish } : {}),
459
+ warnings,
460
+ };
537
461
  }
538
- function installedSchedulerBundle(config, entry) {
539
- const direct = entry.target ?? (entry.invocation ? scheduledInvocationBundle(entry.invocation) : undefined);
540
- if (direct !== undefined)
541
- return direct;
542
- if (entry.ownerBundlePath === undefined)
543
- return undefined;
544
- return bundleKeyForContentRoot(config, entry.ownerBundlePath);
462
+ /**
463
+ * The launcher and descriptor rows are written with. The descriptor follows
464
+ * the current policy on every sync; a row that is already installed keeps its
465
+ * launcher unless `--rebind` (see `installOptionsFor`). An injected backend
466
+ * (tests) renders with its own defaults.
467
+ */
468
+ function prepareSchedulerRuntime(deps) {
469
+ if (deps.schedulerRuntime) {
470
+ const runtime = deps.schedulerRuntime();
471
+ return {
472
+ options: { binding: runtime.binding, contextPath: runtime.contextPath },
473
+ ...(runtime.via ? { via: runtime.via } : {}),
474
+ };
475
+ }
476
+ if (deps.backend)
477
+ return {};
478
+ const descriptor = schedulerContextDescriptor();
479
+ const invocation = resolveAkmInvocation();
480
+ return {
481
+ options: { binding: invocation.argv, contextPath: schedulerContextPath(descriptor) },
482
+ // The content-addressed descriptor is written once, before the first row that references it.
483
+ publish: () => {
484
+ writeSchedulerContextDescriptor(descriptor);
485
+ },
486
+ via: invocation.via,
487
+ };
545
488
  }
546
- function emptySchedulerSyncPlan(operations) {
547
- const sourceSnapshot = Object.freeze({
548
- adapterId: "akm",
549
- sourceRoot: "",
550
- sourceRealPath: "",
551
- sourcePhysicalIdentity: "inactive",
552
- sourceDirectoryVersion: "inactive",
553
- files: Object.freeze([]),
554
- directoryManifests: Object.freeze([]),
555
- });
556
- return Object.freeze({
557
- desired: Object.freeze([]),
558
- installed: Object.freeze([]),
559
- updated: Object.freeze([]),
560
- removed: Object.freeze(operations.map((operation) => operation.id)),
561
- unchanged: Object.freeze([]),
562
- operations: Object.freeze([...operations]),
563
- sourceSnapshot,
564
- failures: Object.freeze([]),
489
+ /** One read of the akm-owned rows, each attributed to the bundle path its own descriptor names (#846). */
490
+ async function listInstalledRows(sched) {
491
+ return (await sched.list()).map((row) => {
492
+ const ownerBundlePath = row.contextPath ? resolveInstalledOwnerPath(row.contextPath) : undefined;
493
+ return ownerBundlePath !== undefined ? { ...row, ownerBundlePath } : row;
565
494
  });
566
495
  }
567
- function scheduledInvocationBundle(invocation) {
568
- const bundleIndex = invocation.indexOf("--bundle");
569
- if (bundleIndex >= 0)
570
- return invocation[bundleIndex + 1];
571
- if (invocation[0] === "workflow" && invocation[1] === "run" && invocation[2]) {
572
- try {
573
- return parseBundleRef(invocation[2]).bundle;
574
- }
575
- catch {
576
- return undefined;
577
- }
496
+ /** Best-effort recovery of an installed binding's owning bundle path (#846). */
497
+ function resolveInstalledOwnerPath(contextPath) {
498
+ try {
499
+ return validateSchedulerContextDescriptor(contextPath).environment.AKM_BUNDLE_DIR;
578
500
  }
579
- return undefined;
580
- }
581
- function assertNoCrossBundleSchedulerCollisions(plans) {
582
- const owners = new Map();
583
- for (const binding of plans.flatMap((plan) => plan.desired)) {
584
- const key = schedulerNativeArtifactKey(schedulerBindingNativeId(binding));
585
- const existing = owners.get(key);
586
- if (existing && existing.logicalSource.ref !== binding.logicalSource.ref) {
587
- throw new UsageError(`Scheduler native id ${JSON.stringify(schedulerBindingNativeId(binding))} is claimed by both ` +
588
- `${JSON.stringify(existing.logicalSource.ref)} and ${JSON.stringify(binding.logicalSource.ref)}. ` +
589
- "Rename one task before enabling both.", "RESOURCE_ALREADY_EXISTS");
590
- }
591
- owners.set(key, binding);
501
+ catch {
502
+ return undefined;
592
503
  }
593
504
  }
594
- export async function akmTasksSync(deps = {}, bundleTarget, options = {}) {
595
- const { sched, plan, sourceSnapshots, prepared, warnings } = await buildSchedulerSyncPlan(deps, bundleTarget, options);
596
- await applySchedulerSyncPlan(sched, plan, prepared?.publish && plan.operations.some((operation) => operation.kind !== "remove")
597
- ? prepared.publish
598
- : undefined, sourceSnapshots);
599
- return {
600
- installed: [...plan.installed],
601
- updated: [...plan.updated],
602
- removed: [...plan.removed],
603
- unchanged: [...plan.unchanged],
604
- skipped: [],
605
- backend: sched.name,
606
- failures: plan.failures.map((failure) => ({ ...failure })),
607
- ...(warnings.length > 0 ? { warnings } : {}),
608
- };
505
+ /** The primary bundle proves its rows by path (#846); any other bundle by its config name. */
506
+ function bundleScope(bundleName, stashDir) {
507
+ return isPrimaryStashPath(stashDir) ? { bundleName, bundlePath: path.resolve(stashDir) } : { bundleName };
609
508
  }
610
- /**
611
- * `akm task sync --dry-run` (#849): compute the exact same plan
612
- * `akmTasksSync` would apply, then return a non-mutating preview instead of
613
- * calling `applySchedulerSyncPlan`. `buildSchedulerSyncPlan` is shared with
614
- * the real sync path specifically so this can never see a different plan
615
- * than the one a real sync would apply — and specifically so this function
616
- * never even holds a reference to a callable `publish` closure past this
617
- * point: `prepared.publish`, if any, is dropped on the floor here, never
618
- * invoked. Zero durable writes, mirroring `akm workflow plan`.
619
- */
620
- export async function akmTasksSyncPlan(deps = {}, bundleTarget, options = {}) {
621
- const { sched, plan } = await buildSchedulerSyncPlan(deps, bundleTarget, options);
622
- return renderSchedulerSyncPlanPreview(sched.name, plan);
509
+ function installedSchedulerBundle(config, row) {
510
+ const direct = row.target ?? scheduledInvocationBundle(row.invocation);
511
+ if (direct !== undefined)
512
+ return direct;
513
+ if (row.ownerBundlePath === undefined)
514
+ return undefined;
515
+ return bundleKeyForContentRoot(config, row.ownerBundlePath);
623
516
  }
624
517
  /**
625
- * Classify one installed scheduler binding as a prune candidate (#851), using
626
- * the same two signals `doctor`'s `inspectInstalledBinding` already computes
627
- * — deliberately narrower than that function's full `status` set. Only an
628
- * entry whose ownership can NEVER be resolved (`invalid-context`) or whose
629
- * resolved owner no longer exists on disk (`dead-bundle-path`) is a
630
- * candidate; `missing-path` (e.g. the akm binary itself moved) is a
631
- * different failure mode and is intentionally NOT folded in here, per the
632
- * scoping in #851 — an entry that still resolves to a live bundle is never a
633
- * candidate, full stop.
518
+ * Why `akm task prune` (#851) would remove an installed row: its own
519
+ * descriptor does not load (`invalid-context`) or names a bundle directory
520
+ * that is gone (`dead-bundle-path`). A row that still resolves to a live
521
+ * bundle is never a candidate.
634
522
  */
635
523
  function classifyPruneCandidate(entry) {
636
524
  let ownerBundlePath;
@@ -644,78 +532,47 @@ function classifyPruneCandidate(entry) {
644
532
  return "dead-bundle-path";
645
533
  return undefined;
646
534
  }
647
- async function buildTaskPrunePlan(deps = {}, options = {}) {
648
- const sched = deps.backend ?? selectBackend();
649
- if (!sched.inspectBindings) {
650
- throw new ConfigError(`Scheduler backend "${sched.name}" cannot provide one coherent inspection for prune.`, "INVALID_CONFIG_FILE");
651
- }
652
- const inspection = await sched.inspectBindings({});
653
- const candidates = new Map();
654
- for (const entry of inspection.installed) {
655
- const reason = classifyPruneCandidate(entry);
656
- if (reason)
657
- candidates.set(entry.id, reason);
658
- }
659
- const requestedIds = options.id?.filter((id) => id.length > 0) ?? [];
660
- for (const id of requestedIds) {
661
- if (!candidates.has(id)) {
662
- throw new UsageError(`Scheduler binding ${JSON.stringify(id)} is not an orphaned prune candidate ` +
663
- "(either not installed, or it still resolves to a live bundle) — refusing to prune it.", "INVALID_FLAG_VALUE");
664
- }
665
- }
666
- const idFilter = requestedIds.length > 0 ? new Set(requestedIds) : undefined;
667
- const resolved = resolveTaskReadBundle(undefined, undefined);
668
- const bundleContext = {
669
- adapterId: resolved.source.adapterId ?? detectAdapterId(resolved.source.path),
670
- bundleName: resolved.source.name,
671
- };
672
- const operations = [];
673
- for (const entry of inspection.installed) {
674
- const reason = candidates.get(entry.id);
675
- if (!reason)
676
- continue;
677
- if (idFilter && !idFilter.has(entry.id))
678
- continue;
679
- const operation = buildSchedulerRemoveOperation(entry.id, entry, inspection.artifacts, bundleContext);
680
- operations.push(Object.freeze({ ...operation, reason }));
681
- }
682
- return { sched, operations: Object.freeze(operations) };
683
- }
684
535
  /**
685
- * `akm task prune` (#851): remove installed scheduler bindings `sync` can
686
- * never reclaim because their own `--scheduler-context` descriptor doesn't
687
- * resolve to a live bundle. Defaults to dry-run — no `--yes` and no `--id`
688
- * means zero backend calls that could mutate anything, matching
689
- * `akmTasksSyncPlan`'s zero-write guarantee. `--id` (one or more) narrows
690
- * execution to exactly those bindings; `--yes` alone executes every
691
- * currently-computed candidate. Both still return the full preview so the
692
- * plan is never silent about what it did.
536
+ * `akm task prune` (#851): remove installed rows `sync` can never reclaim
537
+ * because their descriptor no longer resolves to a live bundle. It scans
538
+ * every installed row, not one bundle's. Without `--yes` it only previews and
539
+ * writes nothing; `--id` narrows it to named candidates.
693
540
  */
694
541
  export async function akmTasksPrune(deps = {}, options = {}) {
695
- const { sched, operations } = await buildTaskPrunePlan(deps, options);
696
- const preview = renderSchedulerPlanPreview(sched.name, operations);
697
- if (!options.yes) {
698
- return { backend: sched.name, dryRun: true, preview, removed: [] };
699
- }
700
- await applySchedulerTransaction(sched, operations, {
701
- initialExpectations: operations.map((operation) => operation.expected),
702
- });
703
- return {
704
- backend: sched.name,
705
- dryRun: false,
706
- preview,
707
- removed: operations.map((operation) => operation.id),
542
+ const prune = async () => {
543
+ const sched = deps.backend ?? selectBackend();
544
+ const operations = pruneOperations(await sched.list(), options.id ?? []);
545
+ const preview = renderSchedulerPlanPreview(sched.name, operations);
546
+ if (!options.yes)
547
+ return { backend: sched.name, dryRun: true, preview, removed: [] };
548
+ for (const operation of operations)
549
+ await sched.uninstall(operation.nativeId);
550
+ return { backend: sched.name, dryRun: false, preview, removed: operations.map((operation) => operation.id) };
708
551
  };
552
+ return options.yes ? withSchedulerLock(prune) : prune();
553
+ }
554
+ function pruneOperations(installed, requestedIds) {
555
+ const candidates = installed.flatMap((row) => {
556
+ const reason = classifyPruneCandidate(row);
557
+ return reason ? [{ row, reason }] : [];
558
+ });
559
+ const ids = requestedIds.filter((id) => id.length > 0);
560
+ for (const id of ids) {
561
+ if (!candidates.some(({ row }) => row.id === id)) {
562
+ throw new UsageError(`Scheduler binding ${JSON.stringify(id)} is not an orphaned prune candidate ` +
563
+ "(either not installed, or it still resolves to a live bundle) — refusing to prune it.", "INVALID_FLAG_VALUE");
564
+ }
565
+ }
566
+ return candidates
567
+ .filter(({ row }) => ids.length === 0 || ids.includes(row.id))
568
+ .map(({ row, reason }) => ({ kind: "remove", id: row.id, nativeId: installedRowNativeId(row), reason }));
709
569
  }
710
570
  export async function akmTasksDoctor(deps = {}) {
711
571
  const warnings = [];
712
- let invocation = {
713
- argv: [],
714
- via: "unresolved",
715
- };
572
+ let invocation = { argv: [], via: "unresolved" };
716
573
  try {
717
574
  const r = (deps.resolveInvocation ?? resolveAkmInvocation)();
718
- invocation = { argv: r.argv, via: r.via, kind: r.kind, eligible: r.eligible };
575
+ invocation = { argv: r.argv, via: r.via };
719
576
  }
720
577
  catch (err) {
721
578
  warnings.push(err instanceof Error ? err.message : String(err));
@@ -735,7 +592,7 @@ export async function akmTasksDoctor(deps = {}) {
735
592
  warnings.push(`Unable to inspect installed ${backend} definitions: ${error instanceof Error ? error.message : String(error)}`);
736
593
  }
737
594
  }
738
- const bindings = groupInstalledBindings(installed, invocation);
595
+ const bindings = groupInstalledBindings(installed);
739
596
  // Report the EFFECTIVE engine view — the same one the runner resolves —
740
597
  // so doctor never says "no engine" on an install where tasks actually run.
741
598
  const { config } = withEngineFallback(loadConfig());
@@ -765,7 +622,6 @@ export async function akmTasksDoctor(deps = {}) {
765
622
  defaultStrategy: improveStrategyName,
766
623
  enabled: triage.enabled === true,
767
624
  applyMode: triage.applyMode ?? "queue",
768
- policy: triage.policy ?? "personal-stash",
769
625
  }
770
626
  : undefined;
771
627
  return {
@@ -786,298 +642,11 @@ export async function akmTasksDoctor(deps = {}) {
786
642
  };
787
643
  }
788
644
  // ── helpers ─────────────────────────────────────────────────────────────────
789
- async function applySchedulerSyncPlan(backend, plan, publish, sourceSnapshots = [plan.sourceSnapshot]) {
790
- await applySchedulerTransaction(backend, plan.operations, {
791
- initialExpectations: plan.operations.map((operation) => operation.expected),
792
- assertReadSet: () => {
793
- for (const snapshot of sourceSnapshots)
794
- assertSchedulerSourceSnapshot(snapshot);
795
- },
796
- beforeOperation: (_operation, index) => {
797
- if (index === 0)
798
- publish?.();
799
- },
800
- });
801
- }
802
- async function applySchedulerTransaction(backend, operations, hooks) {
803
- if (operations.length === 0) {
804
- hooks.assertReadSet?.();
805
- await hooks.afterOperations?.();
806
- hooks.assertReadSet?.();
807
- return;
808
- }
809
- hooks.assertReadSet?.();
810
- if (!backend.snapshotBindings || !backend.restoreBindings) {
811
- throw new ConfigError(`Scheduler backend "${backend.name}" cannot snapshot and restore a whole-set transaction.`, "INVALID_CONFIG_FILE");
812
- }
813
- const nativeIds = [
814
- ...new Set(operations.map((operation) => operation.kind === "remove" ? operation.nativeId : schedulerBindingNativeId(operation.binding))),
815
- ];
816
- const snapshot = await backend.snapshotBindings(nativeIds);
817
- assertSchedulerTransactionSnapshot(snapshot, nativeIds, hooks.initialExpectations);
818
- const rollbackExpected = schedulerRollbackExpectations(snapshot, operations);
819
- hooks.assertReadSet?.();
820
- try {
821
- for (const [index, operation] of operations.entries()) {
822
- hooks.assertReadSet?.();
823
- await hooks.beforeOperation?.(operation, index);
824
- hooks.assertReadSet?.();
825
- if (operation.kind === "remove")
826
- await backend.uninstall(operation.nativeId, operation.expected);
827
- else
828
- await backend.install(operation.binding, operation.options, operation.expected);
829
- hooks.assertReadSet?.();
830
- }
831
- hooks.assertReadSet?.();
832
- await hooks.afterOperations?.();
833
- hooks.assertReadSet?.();
834
- }
835
- catch (primaryError) {
836
- let externalRollbackError;
837
- try {
838
- await hooks.rollbackExternal?.();
839
- }
840
- catch (error) {
841
- externalRollbackError = error;
842
- }
843
- let nativeRollbackError;
844
- const externalStateAllowsNativeRollback = externalRollbackError !== undefined &&
845
- hooks.allowNativeRollbackAfterExternalFailure?.(externalRollbackError) === true;
846
- if (!(externalRollbackError && hooks.suppressNativeRollbackWhenExternalFails && !externalStateAllowsNativeRollback)) {
847
- try {
848
- await backend.restoreBindings(snapshot, rollbackExpected);
849
- }
850
- catch (error) {
851
- nativeRollbackError = error;
852
- }
853
- }
854
- const rollbackErrors = [externalRollbackError, nativeRollbackError].filter((error) => error !== undefined);
855
- if (rollbackErrors.length > 0) {
856
- throw new AggregateError([primaryError, ...rollbackErrors], `Scheduler transaction failed and rollback was incomplete: ${errorMessage(primaryError)}`);
857
- }
858
- throw primaryError;
859
- }
860
- }
861
- function schedulerRollbackExpectations(snapshot, operations) {
862
- return Object.freeze(snapshot.nativeIds.map((nativeId) => {
863
- const prior = snapshot.artifacts.find((artifact) => schedulerNativeArtifactKey(artifact.nativeId) === schedulerNativeArtifactKey(nativeId));
864
- const allowed = [];
865
- if (prior) {
866
- if (prior.fingerprint === undefined) {
867
- throw new ConfigError(`Scheduler backend snapshot for ${JSON.stringify(nativeId)} has no exact fingerprint.`, "INVALID_CONFIG_FILE");
868
- }
869
- allowed.push(Object.freeze({
870
- state: "present",
871
- ...(prior.bindingId !== undefined ? { bindingId: prior.bindingId } : {}),
872
- ...(prior.invocation !== undefined ? { invocation: Object.freeze([...prior.invocation]) } : {}),
873
- fingerprint: prior.fingerprint,
874
- }));
875
- }
876
- else {
877
- allowed.push(Object.freeze({ state: "absent" }));
878
- }
879
- const matchingOperations = operations.filter((candidate) => {
880
- const operationNativeId = candidate.kind === "remove" ? candidate.nativeId : schedulerBindingNativeId(candidate.binding);
881
- return schedulerNativeArtifactKey(operationNativeId) === schedulerNativeArtifactKey(nativeId);
882
- });
883
- for (const operation of matchingOperations) {
884
- if (operation.kind === "remove") {
885
- if (!allowed.some((state) => state.state === "absent")) {
886
- allowed.push(Object.freeze({ state: "absent" }));
887
- }
888
- continue;
889
- }
890
- if (operation.resultFingerprint === undefined) {
891
- throw new ConfigError(`Scheduler backend cannot freeze the post-mutation fingerprint for ${JSON.stringify(nativeId)}.`, "INVALID_CONFIG_FILE");
892
- }
893
- allowed.push(Object.freeze({
894
- state: "present",
895
- bindingId: operation.binding.id,
896
- invocation: Object.freeze([...operation.binding.invocation]),
897
- fingerprint: operation.resultFingerprint,
898
- }));
899
- }
900
- return Object.freeze({ nativeId, allowed: Object.freeze(allowed) });
901
- }));
902
- }
903
- function assertSchedulerTransactionSnapshot(snapshot, nativeIds, initialExpectations) {
904
- if (!snapshot ||
905
- !Array.isArray(snapshot.nativeIds) ||
906
- !Array.isArray(snapshot.artifacts) ||
907
- nativeIds.some((nativeId) => !snapshot.nativeIds.includes(nativeId))) {
908
- throw new ConfigError("Scheduler backend returned an incomplete transaction snapshot.", "INVALID_CONFIG_FILE");
909
- }
910
- const snapshotKeys = snapshot.nativeIds.map(schedulerNativeArtifactKey);
911
- const requestedKeys = nativeIds.map(schedulerNativeArtifactKey);
912
- if (snapshotKeys.length !== requestedKeys.length ||
913
- new Set(snapshotKeys).size !== snapshotKeys.length ||
914
- snapshotKeys.some((key) => !requestedKeys.includes(key))) {
915
- throw new ConfigError("Scheduler backend returned an inexact normalized transaction snapshot set.", "INVALID_CONFIG_FILE");
916
- }
917
- for (const expected of initialExpectations) {
918
- const artifact = assertSchedulerNativeArtifactCardinality(snapshot.artifacts, expected.nativeId, expected.state === "absent" ? 0 : 1);
919
- assertSchedulerMutationArtifact(artifact, expected);
920
- }
921
- }
922
- async function prepareTaskAddSchedulerTransaction(input) {
923
- if (!input.sched.inspectBindings) {
924
- throw new ConfigError(`Scheduler backend "${input.sched.name}" cannot provide one coherent inspection for transactional add.`, "INVALID_CONFIG_FILE");
925
- }
926
- if (!input.sched.snapshotBindings || !input.sched.restoreBindings || !input.sched.expectedSignature) {
927
- throw new ConfigError(`Scheduler backend "${input.sched.name}" cannot provide exact snapshot, restore, and signature contracts for transactional add.`, "INVALID_CONFIG_FILE");
928
- }
929
- const inspection = await input.sched.inspectBindings({ rebind: input.rebind });
930
- const installedEntries = [...inspection.installed];
931
- const nativeArtifacts = [...inspection.artifacts];
932
- const seenNativeKeys = new Set();
933
- for (const artifact of nativeArtifacts) {
934
- const key = schedulerNativeArtifactKey(artifact.nativeId);
935
- if (seenNativeKeys.has(key)) {
936
- throw new UsageError(`Scheduler inspection has duplicate normalized native artifact ${JSON.stringify(artifact.nativeId)}.`, "RESOURCE_ALREADY_EXISTS");
937
- }
938
- seenNativeKeys.add(key);
939
- }
940
- for (const binding of input.taskBindings) {
941
- assertNoForeignSchedule(installedEntries, binding.id, input.ownerTarget);
942
- }
943
- const taskEntries = installedEntries.filter((entry) => installedEntryRunsTask(entry, input.id));
944
- const foreignTaskEntry = taskEntries.find((entry) => !sameBundle(entry.target, input.ownerTarget));
945
- if (foreignTaskEntry) {
946
- throw new UsageError(foreignScheduleMessage(input.id, foreignTaskEntry.target), "RESOURCE_ALREADY_EXISTS");
947
- }
948
- assertSchedulerNativeArtifactOwnership(input.taskBindings, nativeArtifacts);
949
- const primary = input.taskBindings[0];
950
- if (!primary)
951
- throw new Error("invariant: scheduler transaction has no desired binding");
952
- const installedEntry = installedEntries.find((entry) => entry.id === primary.id) ?? taskEntries[0];
953
- const preparedRuntime = installedEntry && !input.rebind
954
- ? {
955
- options: {
956
- ...input.installOpts,
957
- binding: Object.freeze([...installedEntry.binding]),
958
- contextPath: installedEntry.contextPath,
959
- },
960
- }
961
- : prepareSchedulerSyncRuntime(input.installOpts, input.deps, []);
962
- const runtimeOpts = preparedRuntime.options;
963
- const removals = taskEntries.map((entry) => {
964
- const invocation = entry.invocation;
965
- const nativeId = entry.nativeId ?? schedulerNativeBindingId(entry.id);
966
- const artifact = assertSchedulerNativeArtifactCardinality(nativeArtifacts, nativeId, 1);
967
- if (!artifact?.fingerprint || artifact.bindingId !== entry.id) {
968
- throw new UsageError(`Installed scheduler binding ${JSON.stringify(entry.id)} has no exact coherent fingerprint.`, "RESOURCE_ALREADY_EXISTS");
969
- }
970
- const logicalSource = primary.logicalSource;
971
- const ordinal = invocation ? schedulerBindingOrdinal(entry.id, logicalSource, invocation) : undefined;
972
- if (!invocation || ordinal === undefined) {
973
- warn(`Installed scheduler binding ${JSON.stringify(entry.id)} (native id ${JSON.stringify(nativeId)}) could not be exactly parsed — likely a hand-edited entry; replacing it without a compare-and-swap guard.`);
974
- return Object.freeze({
975
- kind: "remove",
976
- id: entry.id,
977
- nativeId,
978
- });
979
- }
980
- return Object.freeze({
981
- kind: "remove",
982
- id: entry.id,
983
- nativeId,
984
- expected: Object.freeze({
985
- bindingId: entry.id,
986
- nativeId,
987
- logicalSource,
988
- ordinal,
989
- invocation: Object.freeze([...invocation]),
990
- fingerprint: artifact.fingerprint,
991
- }),
992
- });
993
- });
994
- const installs = input.taskBindings.map((binding) => {
995
- const nativeId = schedulerBindingNativeId(binding);
996
- const resultFingerprint = input.sched.expectedSignature(binding, runtimeOpts);
997
- if (!resultFingerprint) {
998
- throw new ConfigError(`Scheduler backend "${input.sched.name}" cannot freeze the post-install fingerprint for ${JSON.stringify(binding.id)}.`, "INVALID_CONFIG_FILE");
999
- }
1000
- return Object.freeze({
1001
- kind: "install",
1002
- binding,
1003
- expected: Object.freeze({
1004
- state: "absent",
1005
- bindingId: binding.id,
1006
- nativeId,
1007
- logicalSource: binding.logicalSource,
1008
- ordinal: binding.ordinal,
1009
- invocation: binding.invocation,
1010
- }),
1011
- resultFingerprint,
1012
- ...(runtimeOpts ? { options: runtimeOpts } : {}),
1013
- });
1014
- });
1015
- const initialByKey = new Map();
1016
- for (const removal of removals) {
1017
- if (removal.kind !== "remove")
1018
- continue;
1019
- if (!removal.expected)
1020
- continue;
1021
- initialByKey.set(schedulerNativeArtifactKey(removal.nativeId), Object.freeze({ ...removal.expected, state: "present" }));
1022
- }
1023
- for (const install of installs) {
1024
- if (install.kind === "remove")
1025
- continue;
1026
- const key = schedulerNativeArtifactKey(schedulerBindingNativeId(install.binding));
1027
- if (!initialByKey.has(key))
1028
- initialByKey.set(key, install.expected);
1029
- }
1030
- return Object.freeze({
1031
- runtimeOpts,
1032
- ...(preparedRuntime.publish ? { publishRuntime: preparedRuntime.publish } : {}),
1033
- operations: Object.freeze([...removals, ...installs]),
1034
- initialExpectations: Object.freeze([...initialByKey.values()]),
1035
- publishOperationIndex: removals.length,
1036
- });
1037
- }
1038
- function prepareSchedulerSyncRuntime(base, deps, warnings, installedBindings = []) {
1039
- if (deps.backend && !deps.schedulerRuntime)
1040
- return base ? { options: base } : {};
1041
- if (deps.schedulerRuntime) {
1042
- const runtime = deps.schedulerRuntime();
1043
- warnIneligibleRebind(runtime, warnings, installedBindings);
1044
- return { options: { ...base, binding: runtime.binding, contextPath: runtime.contextPath } };
1045
- }
1046
- const invocation = resolveAndValidateSchedulerInvocation();
1047
- warnIneligibleRebind(invocation, warnings, installedBindings);
1048
- const descriptor = schedulerContextDescriptor();
1049
- const contextPath = schedulerContextPath(descriptor);
1050
- return {
1051
- options: { ...base, binding: invocation.binding, contextPath },
1052
- publish: () => {
1053
- const written = writeSchedulerContextDescriptor(descriptor);
1054
- if (written !== contextPath) {
1055
- throw new ConfigError("Scheduler context descriptor path changed after preflight.", "INVALID_CONFIG_FILE");
1056
- }
1057
- },
1058
- };
1059
- }
1060
- function resolveAndValidateSchedulerInvocation() {
1061
- const invocation = resolveAkmInvocation();
1062
- return { binding: invocation.argv, contextPath: "", eligible: invocation.eligible, kind: invocation.kind };
1063
- }
1064
- function warnIneligibleRebind(runtime, warnings, installedBindings) {
1065
- if (runtime.eligible !== false || warnings.length > 0)
1066
- return;
1067
- // #868 residue: binding every currently-installed entry to the SAME
1068
- // invocation it already carries changes nothing — this is the steady
1069
- // state of an image-baked install re-running `task sync` on a timer.
1070
- // Only warn when the bind actually moves an entry to a different
1071
- // invocation.
1072
- if (installedBindings.length > 0 && installedBindings.every((bound) => sameArgv(bound, runtime.binding)))
1073
- return;
1074
- warnings.push(`Scheduled tasks are bound to an ineligible ${runtime.kind ?? "unknown"} invocation (${runtime.binding.join(" ")}); scheduled runs will invoke a mutable, unproven binary. Install akm via \`npm install --global akm-cli\` or a standalone release, then re-run \`akm task sync --rebind\`.`);
1075
- }
1076
- function groupInstalledBindings(entries, invocation) {
645
+ function groupInstalledBindings(entries) {
1077
646
  const groups = new Map();
1078
647
  for (const entry of entries) {
1079
648
  const argv = [...entry.binding];
1080
- const status = inspectInstalledBinding(entry, invocation);
649
+ const status = inspectInstalledBinding(entry);
1081
650
  const key = JSON.stringify([argv, entry.contextPath, status]);
1082
651
  const existing = groups.get(key);
1083
652
  if (existing) {
@@ -1093,23 +662,11 @@ function groupInstalledBindings(entries, invocation) {
1093
662
  }
1094
663
  return [...groups.values()].map((group) => ({ ...group, taskIds: group.taskIds.sort() }));
1095
664
  }
1096
- /** Best-effort recovery of an installed binding's owning bundle path (#846). */
1097
- function resolveInstalledOwnerPath(contextPath) {
1098
- try {
1099
- return validateSchedulerContextDescriptor(contextPath).environment.AKM_BUNDLE_DIR;
1100
- }
1101
- catch {
1102
- return undefined;
1103
- }
1104
- }
1105
- function inspectInstalledBinding(entry, invocation) {
665
+ function inspectInstalledBinding(entry) {
1106
666
  const status = [];
1107
667
  const binding = entry.binding;
1108
- if (!(invocation.eligible === true && sameArgv(binding, invocation.argv)) &&
1109
- binding.some((part) => /(?:^|[\\/])src[\\/]cli\.ts$|(?:^|[\\/])dist[\\/](?:cli\.js|cli-node\.mjs)$/i.test(part) ||
1110
- (path.isAbsolute(part) && hasGitAncestor(part)))) {
668
+ if (isCheckoutInvocation(binding))
1111
669
  status.push("checkout");
1112
- }
1113
670
  if (binding.some((part) => part === "akm" || part === "bun" || part === "node"))
1114
671
  status.push("path-selected");
1115
672
  try {
@@ -1125,153 +682,14 @@ function inspectInstalledBinding(entry, invocation) {
1125
682
  status.push("ok");
1126
683
  return status;
1127
684
  }
1128
- function sameArgv(left, right) {
1129
- return left.length === right.length && left.every((value, index) => value === right[index]);
1130
- }
1131
- function hasGitAncestor(file) {
1132
- let current;
1133
- try {
1134
- current = path.dirname(fs.realpathSync(file));
1135
- }
1136
- catch {
1137
- return false;
1138
- }
1139
- for (;;) {
1140
- if (fs.existsSync(path.join(current, ".git")))
1141
- return true;
1142
- const parent = path.dirname(current);
1143
- if (parent === current)
1144
- return false;
1145
- current = parent;
1146
- }
1147
- }
1148
685
  function taskAssetRef(id) {
1149
686
  return { type: "task", name: id };
1150
687
  }
1151
- function captureTaskSourceExpectation(filePathInput, rootInput) {
1152
- const filePath = path.resolve(filePathInput);
1153
- const root = path.resolve(rootInput);
1154
- const lexicalRelative = path.relative(root, filePath);
1155
- if (lexicalRelative === "" || lexicalRelative.startsWith("..") || path.isAbsolute(lexicalRelative)) {
1156
- throw new UsageError(`${filePathInput} resolves outside the task source root.`, "PATH_ESCAPE_VIOLATION");
1157
- }
1158
- const rootRealPath = fs.realpathSync(root);
1159
- const rootStat = fs.statSync(rootRealPath, { bigint: true });
1160
- if (!rootStat.isDirectory()) {
1161
- throw new UsageError(`${root} is not a task source directory.`, "INVALID_FLAG_VALUE");
1162
- }
1163
- const common = { filePath, rootRealPath };
1164
- let descriptor;
1165
- try {
1166
- descriptor = fs.openSync(filePath, fs.constants.O_RDONLY);
1167
- let before = fs.fstatSync(descriptor, { bigint: true });
1168
- if (!before.isFile()) {
1169
- throw new UsageError(`${filePath} is not a regular task source.`, "INVALID_FLAG_VALUE");
1170
- }
1171
- let bytes = fs.readFileSync(descriptor);
1172
- const torn = !sameTaskSourceStat(before, fs.fstatSync(descriptor, { bigint: true })) ||
1173
- BigInt(bytes.byteLength) !== before.size;
1174
- if (torn) {
1175
- fs.closeSync(descriptor);
1176
- descriptor = fs.openSync(filePath, fs.constants.O_RDONLY);
1177
- before = fs.fstatSync(descriptor, { bigint: true });
1178
- bytes = fs.readFileSync(descriptor);
1179
- warn(`${filePath} changed while its guarded bytes were read; retried once and proceeding with the latest read (its SHA-256 is re-verified before anything is published).`);
1180
- }
1181
- const realPath = fs.realpathSync(filePath);
1182
- const physicalRelative = path.relative(rootRealPath, realPath);
1183
- if (physicalRelative === "" || physicalRelative.startsWith("..") || path.isAbsolute(physicalRelative)) {
1184
- throw new UsageError(`${filePath} resolves outside the task source root.`, "PATH_ESCAPE_VIOLATION");
1185
- }
1186
- let content;
1187
- try {
1188
- content = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
1189
- }
1190
- catch {
1191
- throw new UsageError(`${filePath} contains invalid UTF-8 bytes.`, "INVALID_FLAG_VALUE");
1192
- }
1193
- return Object.freeze({
1194
- state: "present",
1195
- ...common,
1196
- realPath,
1197
- size: bytes.byteLength,
1198
- sha256: createHash("sha256").update(bytes).digest("hex"),
1199
- bytesBase64: bytes.toString("base64"),
1200
- content,
1201
- });
1202
- }
1203
- catch (cause) {
1204
- if (cause.code === "ENOENT") {
1205
- return Object.freeze({ state: "absent", ...common });
1206
- }
1207
- if (cause instanceof UsageError)
1208
- throw cause;
1209
- throw new UsageError(`${filePath} could not be guarded as a contained regular task source: ${errorMessage(cause)}`, "PATH_ESCAPE_VIOLATION");
1210
- }
1211
- finally {
1212
- if (descriptor !== undefined)
1213
- fs.closeSync(descriptor);
1214
- }
1215
- }
1216
- // TOCTOU note: this compares CONTENT (state + sha256), not filesystem
1217
- // identity (inode/mtime/ctime/directory timestamps) — the same split already
1218
- // applied to the task migrator in 0.9.5. An unrelated touch to the file or
1219
- // its containing directory must not trip a "changed after planning" refusal;
1220
- // only a real content change should.
1221
- function assertTaskSourceExpectation(expected) {
1222
- const actual = captureTaskSourceExpectation(expected.filePath, expected.rootRealPath);
1223
- if (!sameTaskSourceExpectation(actual, expected)) {
1224
- throw new UsageError(`Task source ${JSON.stringify(expected.filePath)} changed after transaction planning.`, "RESOURCE_ALREADY_EXISTS");
1225
- }
1226
- }
1227
- function sameTaskSourceExpectation(left, right) {
1228
- if (left.state !== right.state || left.filePath !== right.filePath || left.rootRealPath !== right.rootRealPath) {
1229
- return false;
1230
- }
1231
- if (left.state === "absent")
1232
- return true;
1233
- return left.sha256 === right.sha256;
1234
- }
1235
- function assertTaskSourceRestored(expected) {
1236
- const actual = captureTaskSourceExpectation(expected.filePath, expected.rootRealPath);
1237
- const restored = actual.state === expected.state &&
1238
- (actual.state === "absent" ||
1239
- (expected.state === "present" && actual.sha256 === expected.sha256 && actual.content === expected.content));
1240
- if (!restored) {
1241
- throw new UsageError(`Task source ${JSON.stringify(expected.filePath)} could not be restored without replacing a concurrent owner.`, "RESOURCE_ALREADY_EXISTS");
1242
- }
1243
- }
1244
- class TaskSourceRestoredBoundaryError extends Error {
1245
- cause;
1246
- constructor(cause) {
1247
- super(errorMessage(cause));
1248
- this.name = "TaskSourceRestoredBoundaryError";
1249
- this.cause = cause;
1250
- }
1251
- }
1252
- function sameTaskSourceStat(left, right) {
1253
- return (left.dev === right.dev &&
1254
- left.ino === right.ino &&
1255
- left.mode === right.mode &&
1256
- left.size === right.size &&
1257
- left.mtimeNs === right.mtimeNs &&
1258
- left.ctimeNs === right.ctimeNs);
1259
- }
1260
- function hashTaskSource(source) {
1261
- return createHash("sha256").update(Buffer.from(source, "utf8")).digest("hex");
1262
- }
1263
- /**
1264
- * Resolve the bundle a mutating/run task command targets. Returns the resolved
1265
- * write/read target, its stash path, and the `--bundle <bundle>` token to embed
1266
- * in scheduled invocations. The primary bundle uses the target-less form.
1267
- */
1268
- function resolveTaskBundle(target, opts) {
688
+ /** The bundle `task add` writes into: its write target, config, stash path, and name. */
689
+ function resolveTaskBundle(target) {
1269
690
  const config = loadConfig();
1270
- const selected = resolveWriteTarget(config, target, { requireWritable: opts.requireWritable });
1271
- const resolved = opts.requireWritable ? prepareWriteTargetForMutation(selected) : selected;
1272
- const stashDir = resolved.source.path;
1273
- const installTarget = isPrimaryStashPath(stashDir) ? undefined : (resolved.selector ?? resolved.source.name);
1274
- return { resolved, config, stashDir, bundleName: resolved.source.name, installTarget };
691
+ const resolved = prepareWriteTargetForMutation(resolveWriteTarget(config, target, { requireWritable: true }));
692
+ return { resolved, config, stashDir: resolved.source.path, bundleName: resolved.source.name };
1275
693
  }
1276
694
  function taskProjectionAssetResolver(config, bundleName, bundleRoot) {
1277
695
  return async ({ bundle, type, name }) => {
@@ -1345,31 +763,9 @@ function isPrimaryStashPath(candidate) {
1345
763
  }
1346
764
  return path.resolve(candidate) === primary;
1347
765
  }
1348
- /** Two bundle attributions match when both are the primary (undefined) or equal names. */
1349
- function sameBundle(a, b) {
1350
- return (a ?? undefined) === (b ?? undefined);
1351
- }
1352
- function installedEntryRunsTask(entry, id) {
1353
- const invocation = entry.invocation;
1354
- return invocation?.[0] === "task" && invocation[1] === "run" && invocation[2] === id;
1355
- }
1356
- function foreignScheduleMessage(id, existingTarget) {
1357
- const where = existingTarget === undefined ? "the default bundle" : `bundle "${existingTarget}"`;
1358
- return `Task id "${id}" is already scheduled from ${where}; rename the task or disable the existing one first.`;
1359
- }
1360
766
  function errorMessage(cause) {
1361
767
  return cause instanceof Error ? cause.message : String(cause);
1362
768
  }
1363
- /**
1364
- * Refuse to schedule an id already installed from a DIFFERENT bundle. Scheduler
1365
- * ids are the bare task id (never namespaced), so a single id can be active from
1366
- * only one bundle at a time — a collision is a hard error, not an auto-rename.
1367
- */
1368
- function assertNoForeignSchedule(entries, id, installTarget) {
1369
- const foreign = entries.find((entry) => entry.id === id && !sameBundle(entry.target, installTarget));
1370
- if (foreign)
1371
- throw new UsageError(foreignScheduleMessage(id, foreign.target), "RESOURCE_ALREADY_EXISTS");
1372
- }
1373
769
  /**
1374
770
  * Infer a JSON-Schema-subset `type` keyword from one `--params` value's
1375
771
  * runtime shape (spec docs/plans/specs/p4-deletions-closeout.md §3.2.6, row