akm-cli 0.9.1 → 0.9.2-alpha.2

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 (350) hide show
  1. package/CHANGELOG.md +103 -28
  2. package/README.md +3 -1
  3. package/SECURITY.md +1 -1
  4. package/STABILITY.md +1 -1
  5. package/dist/akm +2 -2
  6. package/dist/akm-migrate +2 -2
  7. package/dist/assets/hints/cli-hints-full.md +14 -9
  8. package/dist/assets/improve-strategies/proactive-maintenance.json +1 -1
  9. package/dist/assets/improve-strategies/reflect-distill.json +1 -1
  10. package/dist/assets/models.json +35 -0
  11. package/dist/assets/stash-skeleton/facts/conventions/backlinks.md +3 -4
  12. package/dist/assets/stash-skeleton/facts/conventions/organization.md +1 -3
  13. package/dist/assets/tasks/core/extract.yml +6 -5
  14. package/dist/assets/tasks/core/improve.yml +6 -5
  15. package/dist/assets/tasks/core/index-refresh.yml +6 -5
  16. package/dist/assets/tasks/core/sync.yml +6 -5
  17. package/dist/assets/tasks/core/version-check.yml +6 -5
  18. package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +6 -5
  19. package/dist/assets/tasks/improve/akm-improve-catchup.yml +6 -5
  20. package/dist/assets/tasks/improve/akm-improve-consolidate.yml +6 -5
  21. package/dist/assets/tasks/improve/akm-improve-frequent.yml +6 -5
  22. package/dist/assets/tasks/improve/akm-improve-nightly.yml +6 -5
  23. package/dist/cli/confirm.js +2 -2
  24. package/dist/cli/parse-args.js +3 -24
  25. package/dist/cli/retired-commands.js +1 -1
  26. package/dist/cli/shared.js +2 -2
  27. package/dist/cli.js +11 -9
  28. package/dist/commands/agent/agent-dispatch.js +55 -89
  29. package/dist/commands/agent/contribute-cli.js +12 -45
  30. package/dist/commands/command/builtin-action.js +32 -0
  31. package/dist/commands/command/command-cli.js +99 -0
  32. package/dist/commands/command/command-execution.js +308 -0
  33. package/dist/commands/command/execution-source-loader.js +176 -0
  34. package/dist/commands/command/portable-template.js +60 -0
  35. package/dist/commands/config-cli.js +10 -4
  36. package/dist/commands/env/env.js +4 -2
  37. package/dist/commands/feedback-cli.js +1 -1
  38. package/dist/commands/health/checks.js +241 -29
  39. package/dist/commands/health/html-report.js +0 -14
  40. package/dist/commands/health/report-view-model.js +0 -1
  41. package/dist/commands/health/surfaces.js +6 -7
  42. package/dist/commands/health/types.js +0 -2
  43. package/dist/commands/health.js +63 -18
  44. package/dist/commands/improve/collapse-detector.js +5 -6
  45. package/dist/commands/improve/consolidate.js +251 -214
  46. package/dist/commands/improve/distill/promote-memory.js +71 -34
  47. package/dist/commands/improve/distill/quality-gate.js +17 -5
  48. package/dist/commands/improve/distill.js +232 -155
  49. package/dist/commands/improve/eligibility.js +112 -79
  50. package/dist/commands/improve/execution.js +57 -0
  51. package/dist/commands/improve/extract-cli.js +5 -5
  52. package/dist/commands/improve/extract-prompt.js +64 -22
  53. package/dist/commands/improve/extract.js +608 -360
  54. package/dist/commands/improve/improve-strategies.js +43 -14
  55. package/dist/commands/improve/improve.js +249 -29
  56. package/dist/commands/improve/loop-stages.js +11 -17
  57. package/dist/commands/improve/memory/memory-contradiction-detect.js +90 -66
  58. package/dist/commands/improve/outcome-loop.js +22 -38
  59. package/dist/commands/improve/planner.js +134 -0
  60. package/dist/commands/improve/preparation.js +730 -409
  61. package/dist/commands/improve/reflect.js +386 -223
  62. package/dist/commands/improve/run-context.js +3 -4
  63. package/dist/commands/improve/salience.js +6 -58
  64. package/dist/commands/improve/session-asset.js +12 -12
  65. package/dist/commands/lint/index.js +101 -29
  66. package/dist/commands/migrate-cli.js +11 -69
  67. package/dist/commands/migration-tool.js +6 -9
  68. package/dist/commands/models-cli.js +27 -0
  69. package/dist/commands/proposal/drain.js +258 -186
  70. package/dist/commands/proposal/proposal-cli.js +32 -10
  71. package/dist/commands/proposal/proposal.js +2 -5
  72. package/dist/commands/proposal/propose.js +192 -172
  73. package/dist/commands/proposal/repository.js +54 -91
  74. package/dist/commands/proposal/validators/proposal-validators.js +9 -7
  75. package/dist/commands/read/curate.js +53 -22
  76. package/dist/commands/read/registry-search.js +25 -9
  77. package/dist/commands/read/remember-cli.js +14 -2
  78. package/dist/commands/read/search.js +10 -4
  79. package/dist/commands/read/show.js +139 -153
  80. package/dist/commands/registry-cli.js +16 -7
  81. package/dist/commands/remember.js +33 -18
  82. package/dist/commands/sources/add-cli.js +19 -178
  83. package/dist/commands/sources/bundle-cli.js +15 -3
  84. package/dist/commands/sources/dangerous-env-audit.js +135 -0
  85. package/dist/commands/sources/info.js +2 -1
  86. package/dist/commands/sources/installed-stashes.js +901 -177
  87. package/dist/commands/sources/schema-repair.js +174 -95
  88. package/dist/commands/sources/self-update.js +30 -74
  89. package/dist/commands/sources/source-add.js +3 -5
  90. package/dist/commands/sources/sources-cli.js +2 -15
  91. package/dist/commands/sources/update-transaction.js +220 -0
  92. package/dist/commands/tasks/tasks-cli.js +3 -3
  93. package/dist/commands/tasks/tasks.js +736 -317
  94. package/dist/commands/workflow-cli.js +2 -2
  95. package/dist/core/adapter/adapters/agent-skills-adapter.js +3 -0
  96. package/dist/core/adapter/adapters/akm-adapter.js +85 -35
  97. package/dist/core/adapter/adapters/akm-lint.js +54 -39
  98. package/dist/core/adapter/adapters/akm-metadata.js +45 -45
  99. package/dist/core/adapter/adapters/akm-task-adapter.js +32 -49
  100. package/dist/core/adapter/adapters/akm-workflow-adapter.js +38 -23
  101. package/dist/core/adapter/adapters/dotenv-adapter.js +30 -1
  102. package/dist/core/adapter/adapters/generic-files-adapter.js +11 -0
  103. package/dist/core/adapter/adapters/index.js +0 -9
  104. package/dist/core/adapter/adapters/llm-wiki-adapter.js +4 -0
  105. package/dist/core/adapter/adapters/okf-adapter.js +4 -0
  106. package/dist/core/adapter/adapters/opencode-adapter.js +5 -8
  107. package/dist/core/adapter/adapters/tool-dir-shared.js +63 -6
  108. package/dist/core/adapter/adapters/website-snapshot-adapter.js +4 -0
  109. package/dist/core/adapter/execution-source.js +308 -0
  110. package/dist/core/adapter/recognize-match.js +36 -13
  111. package/dist/core/adapter/registry.js +0 -9
  112. package/dist/core/asset/stash-meta.js +94 -4
  113. package/dist/core/common.js +6 -11
  114. package/dist/core/config/config-io.js +3 -3
  115. package/dist/core/config/config-schema.js +18 -40
  116. package/dist/core/config/config-sources.js +11 -21
  117. package/dist/core/config/config-walker.js +31 -13
  118. package/dist/core/config/config.js +23 -26
  119. package/dist/core/config/schema/engines.js +8 -7
  120. package/dist/core/config/schema/improve-processes.js +29 -5
  121. package/dist/core/config/schema/index-config.js +0 -27
  122. package/dist/core/config/schema/primitives.js +1 -23
  123. package/dist/core/config/schema/sources-bundles.js +13 -16
  124. package/dist/core/errors.js +2 -0
  125. package/dist/core/events.js +68 -32
  126. package/dist/core/extra-params.js +1 -0
  127. package/dist/core/improve-result.js +315 -0
  128. package/dist/core/lesson-lint.js +0 -6
  129. package/dist/core/maintenance-barrier.js +4 -4
  130. package/dist/core/network-policy.js +152 -0
  131. package/dist/core/paths.js +1 -1
  132. package/dist/core/recognition-util.js +4 -4
  133. package/dist/core/registry-url.js +456 -0
  134. package/dist/core/state/migrations.js +161 -47
  135. package/dist/core/state-db.js +453 -80
  136. package/dist/core/system-error.js +32 -0
  137. package/dist/core/time.js +2 -12
  138. package/dist/core/write-source.js +0 -18
  139. package/dist/execution/directory-identity.js +52 -0
  140. package/dist/execution/executable-identity.js +107 -0
  141. package/dist/execution/guarded-source.js +398 -0
  142. package/dist/execution/json.js +95 -0
  143. package/dist/{commands/health/types-session-log.js → execution/limits.js} +2 -1
  144. package/dist/execution/record.js +55 -0
  145. package/dist/execution/resolved-request.js +730 -0
  146. package/dist/execution/source.js +320 -0
  147. package/dist/indexer/bundle-identity-guard.js +5 -4
  148. package/dist/indexer/db/graph-db.js +33 -0
  149. package/dist/indexer/graph/graph-boost.js +3 -4
  150. package/dist/indexer/graph/graph-extraction.js +562 -373
  151. package/dist/indexer/index-written-assets.js +78 -39
  152. package/dist/indexer/indexer.js +471 -432
  153. package/dist/indexer/installations.js +6 -0
  154. package/dist/indexer/lookup/adapter-concept-owner.js +283 -0
  155. package/dist/indexer/materialize-embeddings.js +155 -0
  156. package/dist/indexer/passes/memory-inference.js +227 -174
  157. package/dist/indexer/passes/metadata.js +263 -118
  158. package/dist/indexer/scan/doc-to-entry.js +7 -10
  159. package/dist/indexer/scan/drain-dir.js +51 -23
  160. package/dist/indexer/search/db-search.js +156 -50
  161. package/dist/indexer/search/fts-query.js +40 -40
  162. package/dist/indexer/search/ranking.js +36 -1
  163. package/dist/indexer/search/search-attribution.js +3 -1
  164. package/dist/indexer/search/search-fields.js +23 -14
  165. package/dist/indexer/search/search-hit-enrichers.js +1 -1
  166. package/dist/indexer/search/search-source.js +7 -16
  167. package/dist/indexer/search/semantic-status.js +10 -1
  168. package/dist/indexer/usage/show-usage.js +105 -0
  169. package/dist/indexer/usage/usage-events.js +7 -2
  170. package/dist/indexer/walk/matchers.js +40 -10
  171. package/dist/indexer/walk/path-resolver.js +5 -2
  172. package/dist/indexer/walk/walker.js +20 -2
  173. package/dist/integrations/agent/builder-shared.js +3 -6
  174. package/dist/integrations/agent/conversation-fallback.js +16 -0
  175. package/dist/integrations/agent/engine-resolution.js +87 -87
  176. package/dist/integrations/agent/execution-cascade.js +566 -0
  177. package/dist/integrations/agent/execution-definitions.js +211 -0
  178. package/dist/integrations/agent/execution-lowering.js +811 -0
  179. package/dist/integrations/agent/execution-preparation.js +67 -0
  180. package/dist/integrations/agent/index.js +0 -2
  181. package/dist/integrations/agent/inline-execution.js +74 -0
  182. package/dist/integrations/agent/model-map.js +515 -0
  183. package/dist/integrations/agent/persona-fallback.js +30 -0
  184. package/dist/integrations/agent/request-lowering.js +186 -0
  185. package/dist/integrations/agent/runner-dispatch.js +230 -37
  186. package/dist/integrations/agent/runner.js +12 -83
  187. package/dist/integrations/harnesses/aider/agent-builder.js +8 -0
  188. package/dist/integrations/harnesses/aider/index.js +0 -1
  189. package/dist/integrations/harnesses/amazonq/agent-builder.js +8 -0
  190. package/dist/integrations/harnesses/amazonq/index.js +0 -1
  191. package/dist/integrations/harnesses/claude/agent-builder.js +14 -1
  192. package/dist/integrations/harnesses/claude/index.js +1 -5
  193. package/dist/integrations/harnesses/claude/session-log.js +3 -33
  194. package/dist/integrations/harnesses/codex/agent-builder.js +8 -0
  195. package/dist/integrations/harnesses/codex/index.js +0 -1
  196. package/dist/integrations/harnesses/copilot/agent-builder.js +8 -0
  197. package/dist/integrations/harnesses/copilot/index.js +0 -1
  198. package/dist/integrations/harnesses/gemini/agent-builder.js +8 -0
  199. package/dist/integrations/harnesses/gemini/index.js +0 -1
  200. package/dist/integrations/harnesses/index.js +4 -44
  201. package/dist/integrations/harnesses/opencode/agent-builder.js +16 -9
  202. package/dist/integrations/harnesses/opencode/index.js +0 -2
  203. package/dist/integrations/harnesses/opencode/session-log.js +14 -204
  204. package/dist/integrations/harnesses/opencode-sdk/harness.js +12 -1
  205. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +40 -42
  206. package/dist/integrations/harnesses/openhands/agent-builder.js +8 -0
  207. package/dist/integrations/harnesses/openhands/index.js +0 -1
  208. package/dist/integrations/harnesses/pi/agent-builder.js +8 -0
  209. package/dist/integrations/harnesses/pi/index.js +0 -1
  210. package/dist/integrations/harnesses/shared.js +0 -1
  211. package/dist/integrations/harnesses/types.js +1 -3
  212. package/dist/integrations/lockfile.js +82 -79
  213. package/dist/integrations/session-logs/index.js +6 -17
  214. package/dist/integrations/session-logs/provider-base.js +1 -29
  215. package/dist/llm/client.js +10 -5
  216. package/dist/llm/embedder.js +6 -7
  217. package/dist/llm/embedders/local.js +37 -88
  218. package/dist/llm/embedders/types.js +1 -1
  219. package/dist/llm/graph-extract.js +75 -50
  220. package/dist/llm/index-passes.js +43 -5
  221. package/dist/llm/memory-infer.js +8 -6
  222. package/dist/llm/metadata-enhance.js +5 -3
  223. package/dist/llm/structured-call.js +122 -25
  224. package/dist/output/format-exempt.js +1 -1
  225. package/dist/output/render-registry.js +0 -16
  226. package/dist/output/renderers.js +12 -7
  227. package/dist/output/shapes/curate.js +1 -0
  228. package/dist/output/shapes/helpers.js +10 -2
  229. package/dist/output/shapes/passthrough.js +2 -0
  230. package/dist/output/text/command-format.js +31 -33
  231. package/dist/output/text/health-format.js +1 -29
  232. package/dist/output/text/migrate.js +6 -56
  233. package/dist/output/text/proposal-format.js +16 -1
  234. package/dist/output/text/workflow-format.js +16 -0
  235. package/dist/registry/network.js +279 -0
  236. package/dist/registry/pinned-request-helper.js +247 -0
  237. package/dist/registry/pinned-transport.js +717 -0
  238. package/dist/registry/providers/skills-sh.js +18 -6
  239. package/dist/registry/providers/static-index.js +20 -7
  240. package/dist/registry/resolve.js +53 -28
  241. package/dist/scripts/akm-migrate-node.js +19334 -52269
  242. package/dist/scripts/akm-migrate.js +19270 -51612
  243. package/dist/setup/registry-stash-loader.js +64 -20
  244. package/dist/setup/semantic-assets.js +9 -34
  245. package/dist/setup/setup.js +12 -30
  246. package/dist/setup/source-identity.js +17 -0
  247. package/dist/setup/steps/sources.js +36 -15
  248. package/dist/setup/steps/tasks.js +39 -11
  249. package/dist/sources/providers/git-provider.js +3 -3
  250. package/dist/sources/providers/npm.js +2 -2
  251. package/dist/sources/providers/provider-utils.js +4 -3
  252. package/dist/sources/providers/website.js +11 -7
  253. package/dist/sources/snapshot-fetchers/host-guard.js +9 -136
  254. package/dist/sources/snapshot-fetchers/website-ingest.js +25 -109
  255. package/dist/sources/website-url.js +73 -0
  256. package/dist/storage/engines/sqlite-migrations.js +81 -26
  257. package/dist/storage/managed-db.js +27 -24
  258. package/dist/storage/repositories/events-repository.js +3 -0
  259. package/dist/storage/repositories/index-connection.js +42 -10
  260. package/dist/storage/repositories/index-entries-repository.js +203 -229
  261. package/dist/storage/repositories/index-entry-mapper.js +8 -12
  262. package/dist/storage/repositories/index-entry-schema.js +255 -0
  263. package/dist/storage/repositories/index-fts-repository.js +64 -71
  264. package/dist/storage/repositories/index-llm-cache-repository.js +8 -13
  265. package/dist/storage/repositories/index-meta-repository.js +0 -11
  266. package/dist/storage/repositories/index-schema.js +74 -350
  267. package/dist/storage/repositories/index-utility-repository.js +12 -17
  268. package/dist/storage/repositories/index-vec-repository.js +56 -7
  269. package/dist/storage/repositories/proposals-repository.js +4 -127
  270. package/dist/storage/repositories/registry-cache.js +2 -1
  271. package/dist/storage/repositories/task-history-repository.js +20 -40
  272. package/dist/storage/repositories/workflow-runs-repository.js +228 -129
  273. package/dist/storage/sqlite-read-snapshot.js +148 -0
  274. package/dist/tasks/backends/cron.js +170 -42
  275. package/dist/tasks/backends/index.js +1 -1
  276. package/dist/tasks/backends/launchd.js +787 -202
  277. package/dist/tasks/backends/schtasks.js +282 -83
  278. package/dist/tasks/embedded.js +7 -7
  279. package/dist/tasks/frozen-script.js +50 -0
  280. package/dist/tasks/resolve-akm-bin.js +5 -1
  281. package/dist/tasks/runner.js +239 -251
  282. package/dist/tasks/runtime-v3.js +281 -0
  283. package/dist/tasks/scheduler-binding.js +272 -0
  284. package/dist/tasks/scheduler-invocation.js +57 -43
  285. package/dist/tasks/scheduler-sync.js +654 -0
  286. package/dist/tasks/source-v3.js +752 -0
  287. package/dist/tasks/standalone-script-entry.js +5 -0
  288. package/dist/tasks/task-id.js +29 -0
  289. package/dist/workflows/authoring/authoring.js +15 -32
  290. package/dist/workflows/exec/dispatch-redaction.js +14 -8
  291. package/dist/workflows/exec/exec-unit.js +7 -28
  292. package/dist/workflows/exec/frozen-judge.js +57 -89
  293. package/dist/workflows/exec/lowering-notices.js +23 -0
  294. package/dist/workflows/exec/native-executor.js +301 -458
  295. package/dist/workflows/exec/param-secrets.js +4 -3
  296. package/dist/workflows/exec/run-workflow.js +26 -32
  297. package/dist/workflows/exec/step-work.js +105 -109
  298. package/dist/workflows/exec/unit-dispatch.js +103 -27
  299. package/dist/workflows/exec/unit-writer.js +3 -3
  300. package/dist/workflows/exec/worktree.js +2 -2
  301. package/dist/workflows/ir/compile.js +86 -72
  302. package/dist/workflows/ir/environment-v4.js +328 -0
  303. package/dist/workflows/ir/freeze-v4.js +122 -0
  304. package/dist/workflows/ir/plan-hash.js +13 -7
  305. package/dist/workflows/ir/schema-v4.js +525 -0
  306. package/dist/workflows/ir/schema.js +25 -284
  307. package/dist/workflows/ir/source-freeze-v4.js +506 -0
  308. package/dist/workflows/parser.js +27 -24
  309. package/dist/workflows/program/schema.js +1 -2
  310. package/dist/workflows/renderer.js +42 -29
  311. package/dist/workflows/resource-limits.js +4 -5
  312. package/dist/workflows/runtime/agent-identity.js +11 -13
  313. package/dist/workflows/runtime/plan-classifier.js +8 -8
  314. package/dist/workflows/runtime/runs.js +27 -43
  315. package/dist/workflows/runtime/workflow-asset-loader.js +45 -205
  316. package/dist/workflows/source-files.js +373 -0
  317. package/dist/workflows/source-ir/compile.js +196 -0
  318. package/dist/workflows/source-ir/github-yaml.js +577 -0
  319. package/dist/workflows/source-ir/ordering.js +38 -0
  320. package/dist/workflows/source-ir/program.js +50 -0
  321. package/dist/workflows/source-ir/result.js +26 -0
  322. package/dist/workflows/source-ir/schema.js +772 -0
  323. package/dist/workflows/source-ir/semantics.js +242 -0
  324. package/dist/workflows/source-ir/uses.js +14 -0
  325. package/docs/README.md +2 -0
  326. package/docs/migration/README.md +3 -1
  327. package/docs/migration/release-notes/0.9.2.md +55 -0
  328. package/docs/migration/release-notes/README.md +5 -0
  329. package/docs/migration/v0.8-to-v0.9.md +76 -1077
  330. package/docs/migration/v0.9.0-troubleshooting.md +104 -516
  331. package/docs/migration/v0.9.1-to-v0.9.2.md +150 -0
  332. package/docs/reference/README.md +1 -0
  333. package/docs/reference/cli.md +230 -98
  334. package/docs/reference/configuration.md +159 -36
  335. package/docs/reference/data-and-telemetry.md +19 -1
  336. package/docs/reference/supported-formats.md +23 -3
  337. package/docs/reference/tasks.md +182 -0
  338. package/docs/reference/workflow-schema.md +91 -40
  339. package/docs/reference/workflows.md +33 -6
  340. package/package.json +10 -6
  341. package/schemas/akm-config.json +372 -224
  342. package/schemas/akm-task.json +324 -80
  343. package/schemas/akm-workflow.json +6 -9
  344. package/dist/core/migration-operation.js +0 -75
  345. package/dist/integrations/agent/model-aliases.js +0 -74
  346. package/dist/tasks/parser.js +0 -380
  347. package/dist/tasks/schema.js +0 -123
  348. package/dist/tasks/validator.js +0 -80
  349. package/dist/workflows/ir/freeze.js +0 -320
  350. package/dist/workflows/runtime/document-cache.js +0 -13
@@ -8,48 +8,38 @@
8
8
  * `src/cli.ts` wraps these in citty `defineCommand`s and shapes their return
9
9
  * values via `output()`.
10
10
  */
11
+ import { createHash } from "node:crypto";
11
12
  import fs from "node:fs";
12
13
  import path from "node:path";
13
14
  import { stringify as yamlStringify } from "yaml";
15
+ import { detectAdapterId } from "../../core/adapter/detect-adapter.js";
14
16
  import { assetPathForName } from "../../core/asset/asset-placement.js";
15
- import { conceptIdFromTypeName, parseRefInput } from "../../core/asset/resolve-ref.js";
17
+ import { makeBundleRef, parseBundleRef } from "../../core/asset/asset-ref.js";
18
+ import { conceptIdFromTypeName, isFullRefInput } from "../../core/asset/resolve-ref.js";
16
19
  import { isWithin, resolveStashDir } from "../../core/common.js";
17
20
  import { loadConfig } from "../../core/config/config.js";
21
+ import { resolveConfiguredSources } from "../../core/config/config-sources.js";
18
22
  import { IMPROVE_AUTONOMY_CONFIG_KEY, isImproveAutonomyEnabled } from "../../core/config/experimental.js";
19
23
  import { ConfigError, NotFoundError, UsageError } from "../../core/errors.js";
20
24
  import { getTaskHistoryDir, getTaskLogDir } from "../../core/paths.js";
21
- import { commitWriteTargetBoundary, deleteAssetFromSource, prepareWriteTargetForMutation, resolveWriteTarget, writeAssetToSource, } from "../../core/write-source.js";
25
+ import { commitWriteTargetBoundary, deleteAssetFromSource, prepareWriteTargetForMutation, resolveWorkingStashTarget, resolveWriteTarget, writeAssetToSource, } from "../../core/write-source.js";
22
26
  import { withEngineFallback } from "../../integrations/agent/engine-fallback.js";
27
+ import { resolveAssetPath } from "../../sources/resolve.js";
23
28
  import { backendNameForPlatform, selectBackend } from "../../tasks/backends/index.js";
24
- import { parseTaskDocument } from "../../tasks/parser.js";
25
29
  import { resolveAkmInvocation } from "../../tasks/resolve-akm-bin.js";
26
- import { exitCodeForStatus, INVALID_TASK_ATTEMPT_ID, readTaskHistory, recordTaskAttemptFailure, runTask, } from "../../tasks/runner.js";
30
+ import { exitCodeForStatus, readTaskHistory, runTask } from "../../tasks/runner.js";
31
+ import { prepareTaskV3Execution } from "../../tasks/runtime-v3.js";
27
32
  import { parseSchedule, SCHEDULE_SUPPORTED_SUBSET_HINT } from "../../tasks/schedule.js";
28
- import { schedulerContextDescriptor, validateSchedulerContextDescriptor, writeSchedulerContextDescriptor, } from "../../tasks/scheduler-invocation.js";
29
- import { normaliseTaskId } from "../../tasks/task-id.js";
30
- import { validateTaskDocument } from "../../tasks/validator.js";
33
+ import { assertSchedulerMutationArtifact, assertSchedulerNativeArtifactCardinality, compileTaskSchedulerBindings, schedulerBindingNativeId, schedulerBindingOrdinal, schedulerNativeArtifactKey, schedulerNativeBindingId, } from "../../tasks/scheduler-binding.js";
34
+ import { schedulerContextDescriptor, schedulerContextPath, validateSchedulerContextDescriptor, writeSchedulerContextDescriptor, } from "../../tasks/scheduler-invocation.js";
35
+ import { assertSchedulerNativeArtifactOwnership, assertSchedulerSourceSnapshot, finalizeSchedulerSyncPlan, prepareSchedulerSyncSourceSet, } from "../../tasks/scheduler-sync.js";
36
+ import { parseTaskV3Yaml, TASK_V3_MAX_SOURCE_BYTES } from "../../tasks/source-v3.js";
37
+ import { normaliseTaskConceptId, normaliseTaskId } from "../../tasks/task-id.js";
31
38
  import { applyAutonomyGate, configuredDirectAutonomyLanes, describeGatedLanes } from "../improve/autonomy-gate.js";
32
39
  import { resolveImproveStrategy } from "../improve/improve-strategies.js";
33
40
  export async function akmTasksAdd(input, deps = {}) {
34
41
  const id = normaliseTaskId(input.id);
35
- const hasCommand = input.command !== undefined &&
36
- input.command !== null &&
37
- !(typeof input.command === "string" && input.command.trim() === "") &&
38
- !(Array.isArray(input.command) && input.command.length === 0);
39
- const targetCount = [Boolean(input.workflow), Boolean(input.prompt), hasCommand].filter(Boolean).length;
40
- if (targetCount !== 1) {
41
- throw new UsageError("Pass exactly one of --workflow <ref>, --prompt <asset-ref|./file.md|text>, or --command <shell-command>.", "INVALID_FLAG_VALUE");
42
- }
43
- // `--timeout-ms` IS valid on a workflow task: it is the whole-run bound the
44
- // task runner turns into an abort signal (issue 11), the same one
45
- // `akm workflow run --timeout` applies interactively. Engine and model stay
46
- // prompt-only — a workflow's engines come from its frozen plan.
47
- if (input.workflow && (input.engine !== undefined || input.model !== undefined)) {
48
- throw new UsageError("Workflow tasks accept --params and --timeout-ms; engine and model are prompt-task fields.", "INVALID_FLAG_VALUE");
49
- }
50
- if (hasCommand && (input.engine !== undefined || input.model !== undefined)) {
51
- throw new UsageError("Command tasks accept --timeout-ms but not --engine or --model.", "INVALID_FLAG_VALUE");
52
- }
42
+ assertTaskAddTargetShape(input);
53
43
  // Validate the schedule for the active backend before writing anything.
54
44
  // WI-9.10e: the injected backend (tests) carries its own name, so derive it
55
45
  // from `deps.backend` when present — retiring the `_setBackendsForTests` seam.
@@ -64,14 +54,13 @@ export async function akmTasksAdd(input, deps = {}) {
64
54
  if (!isWithin(assetPath, typeRoot)) {
65
55
  throw new UsageError(`Resolved task path escapes the stash: "${id}".`, "PATH_ESCAPE_VIOLATION");
66
56
  }
67
- // Pre-0.8.0 tasks were markdown; the 0.8.0 cutover moved them to pure YAML
68
- // (see the tasks dir rule in src/indexer/walk/matchers.ts). A leftover
69
- // `<id>.md` still names the same task, so creating `<id>.yml` beside it
70
- // must collide loudly rather than silently minting a duplicate.
71
- const legacyAssetPath = path.join(typeRoot, `${id}.md`);
72
- if ((fs.existsSync(assetPath) || fs.existsSync(legacyAssetPath)) && !input.force) {
57
+ if (fs.existsSync(assetPath) && !input.force) {
73
58
  throw new UsageError(`Task "${id}" already exists. Pass --force to overwrite, or delete its file and run \`akm task sync\` first.`, "RESOURCE_ALREADY_EXISTS");
74
59
  }
60
+ const sourceExpectation = captureTaskSourceExpectation(assetPath, stashDir);
61
+ if (sourceExpectation.state === "present" && !input.force) {
62
+ throw new UsageError(`Task "${id}" appeared while add was preparing. Pass --force only after reviewing the current owner.`, "RESOURCE_ALREADY_EXISTS");
63
+ }
75
64
  const yaml = renderTaskYaml({
76
65
  id,
77
66
  schedule: input.schedule,
@@ -88,152 +77,165 @@ export async function akmTasksAdd(input, deps = {}) {
88
77
  tags: input.tags,
89
78
  enabled: input.disabled !== true,
90
79
  });
91
- const task = parseTaskDocument({ yaml, filePath: assetPath, id });
92
- await validateTaskDocument(task, { backend, stashDir });
80
+ const task = parseTaskV3Yaml({ yaml, filePath: assetPath, workspaceRoot: stashDir });
81
+ const qualifiedRef = makeBundleRef(bundle.bundleName, `tasks/${id}`);
82
+ await prepareTaskV3Execution(task, {
83
+ taskId: id,
84
+ taskRef: qualifiedRef,
85
+ bundleName: bundle.bundleName,
86
+ bundleRoot: stashDir,
87
+ config: bundle.config,
88
+ resolveAsset: taskProjectionAssetResolver(bundle.config, bundle.bundleName, stashDir),
89
+ });
90
+ const taskBindings = compileTaskSchedulerBindings({
91
+ id,
92
+ qualifiedRef,
93
+ ...(bundle.installTarget ? { bundleTarget: bundle.installTarget } : {}),
94
+ enabled: task.akm?.enabled !== false,
95
+ schedules: task.triggers.schedules,
96
+ });
97
+ const taskBinding = taskBindings[0];
98
+ if (!taskBinding)
99
+ throw new UsageError(`Task "${id}" has no schedulable trigger.`, "INVALID_FLAG_VALUE");
93
100
  const ref = taskAssetRef(id);
94
- const previousYaml = fs.existsSync(assetPath) ? fs.readFileSync(assetPath, "utf8") : undefined;
95
- let previousTask;
96
- let previousTaskError;
97
- if (previousYaml !== undefined) {
98
- try {
99
- previousTask = parseTaskDocument({ yaml: previousYaml, filePath: assetPath, id });
100
- }
101
- catch (err) {
102
- previousTaskError = err;
103
- }
104
- }
105
101
  const sched = deps.backend ?? selectBackend();
106
102
  const writeAsset = deps.writeAsset ?? writeAssetToSource;
107
103
  const deleteAsset = deps.deleteAsset ?? deleteAssetFromSource;
108
104
  const commitBoundary = deps.commitBoundary ?? commitWriteTargetBoundary;
109
- const installedEntries = await sched.list();
110
- assertNoForeignSchedule(installedEntries, id, bundle.installTarget);
111
- const wasInstalled = previousYaml !== undefined && installedEntries.some((entry) => entry.id === id);
112
- const installedEntry = installedEntries.find((entry) => entry.id === id);
113
- const runtimeOpts = schedulerInstallOptions(installOpts, installedEntry, deps, installedEntry ? false : input.rebind === true, `create scheduler entry for task "${id}"`);
114
- let sourceRestoreArmed = false;
115
- let installSucceeded = false;
116
- try {
117
- sourceRestoreArmed = true;
105
+ const transaction = await prepareTaskAddSchedulerTransaction({
106
+ id,
107
+ installTarget: bundle.installTarget,
108
+ ownerTarget: bundle.bundleName,
109
+ installOpts,
110
+ taskBindings,
111
+ sched,
112
+ deps,
113
+ rebind: input.rebind === true,
114
+ });
115
+ let sourceMutationReceipt;
116
+ let sourcePublished = false;
117
+ let sourcePublicationAttempted = false;
118
+ const publishSource = async () => {
119
+ assertTaskSourceExpectation(sourceExpectation);
120
+ sourcePublicationAttempted = true;
118
121
  await writeAsset(writeTarget.source, writeTarget.config, ref, yaml);
119
- await sched.install(task, runtimeOpts);
120
- installSucceeded = true;
121
- commitBoundary(writeTarget, `Update tasks/${id}`);
122
- }
123
- catch (err) {
124
- const rollbackErrors = [];
125
- let sourceRestored = false;
126
- if (sourceRestoreArmed) {
127
- try {
128
- if (previousYaml === undefined) {
129
- if (fs.existsSync(assetPath)) {
130
- await deleteAsset(writeTarget.source, writeTarget.config, ref);
131
- sourceRestored = true;
132
- }
133
- }
134
- else {
135
- await restoreTaskSourceBytes(writeAsset, writeTarget.source, writeTarget.config, ref, assetPath, previousYaml);
136
- sourceRestored = true;
122
+ const publishedSource = captureTaskSourceExpectation(assetPath, stashDir);
123
+ if (publishedSource.state !== "present" || publishedSource.sha256 !== hashTaskSource(yaml)) {
124
+ throw new UsageError(`Task source ${JSON.stringify(assetPath)} changed during publication.`, "RESOURCE_ALREADY_EXISTS");
125
+ }
126
+ sourceMutationReceipt = publishedSource;
127
+ sourcePublished = true;
128
+ transaction.publishRuntime?.();
129
+ };
130
+ await applySchedulerTransaction(sched, transaction.operations, {
131
+ initialExpectations: transaction.initialExpectations,
132
+ assertReadSet: () => {
133
+ if (sourcePublished) {
134
+ if (!sourceMutationReceipt) {
135
+ throw new ConfigError("Published task source lost its transaction receipt.", "INVALID_CONFIG_FILE");
137
136
  }
137
+ assertTaskSourceExpectation(sourceMutationReceipt);
138
138
  }
139
- catch (rollbackError) {
140
- rollbackErrors.push(rollbackError);
141
- }
142
- }
143
- if (installSucceeded && !wasInstalled) {
144
- try {
145
- await sched.uninstall(id);
139
+ else {
140
+ assertTaskSourceExpectation(sourceExpectation);
146
141
  }
147
- catch (rollbackError) {
148
- rollbackErrors.push(rollbackError);
142
+ },
143
+ beforeOperation: async (_operation, index) => {
144
+ if (index === transaction.publishOperationIndex && !sourcePublished)
145
+ await publishSource();
146
+ },
147
+ afterOperations: () => commitBoundary(writeTarget, `Update tasks/${id}`),
148
+ rollbackExternal: async () => {
149
+ if (!sourcePublicationAttempted)
150
+ return;
151
+ if (!sourceMutationReceipt) {
152
+ const current = captureTaskSourceExpectation(assetPath, stashDir);
153
+ if (sameTaskSourceExpectation(current, sourceExpectation))
154
+ return;
155
+ if (current.state !== "present" || current.sha256 !== hashTaskSource(yaml)) {
156
+ throw new UsageError(`Task source ${JSON.stringify(assetPath)} has an unowned publication state; refusing rollback over a possible concurrent owner.`, "RESOURCE_ALREADY_EXISTS");
157
+ }
158
+ sourceMutationReceipt = current;
149
159
  }
150
- }
151
- else if (installSucceeded && previousTask) {
160
+ assertTaskSourceExpectation(sourceMutationReceipt);
152
161
  try {
153
- await sched.install(previousTask, runtimeOpts);
154
- }
155
- catch (rollbackError) {
156
- rollbackErrors.push(rollbackError);
157
- try {
158
- if (typeof sched.setEnabled !== "function") {
159
- throw new Error(`Scheduler backend "${sched.name}" cannot disable task "${id}".`);
160
- }
161
- await sched.setEnabled(id, false);
162
+ if (sourceExpectation.state === "absent") {
163
+ await deleteAsset(writeTarget.source, writeTarget.config, ref);
162
164
  }
163
- catch (disableError) {
164
- rollbackErrors.push(disableError);
165
- try {
166
- await sched.uninstall(id);
167
- }
168
- catch (uninstallError) {
169
- rollbackErrors.push(uninstallError);
165
+ else {
166
+ await writeAsset(writeTarget.source, writeTarget.config, ref, sourceExpectation.content);
167
+ const providerRestored = captureTaskSourceExpectation(assetPath, stashDir);
168
+ if (providerRestored.state !== "present" || providerRestored.sha256 !== sourceExpectation.sha256) {
169
+ // Provider writers conventionally normalize a trailing newline.
170
+ // Rollback is byte-exact, so finish the already-owned restore with
171
+ // the frozen bytes before checking the physical source state.
172
+ fs.writeFileSync(assetPath, Buffer.from(sourceExpectation.bytesBase64, "base64"));
170
173
  }
171
174
  }
172
- }
173
- }
174
- else if (installSucceeded && wasInstalled) {
175
- rollbackErrors.push(previousTaskError ?? new Error(`Prior task "${id}" could not be restored.`));
176
- }
177
- if (sourceRestored) {
178
- try {
175
+ assertTaskSourceRestored(sourceExpectation);
179
176
  commitBoundary(writeTarget, `Restore tasks/${id}`);
180
177
  }
181
- catch (rollbackError) {
182
- rollbackErrors.push(rollbackError);
178
+ catch (cause) {
179
+ // A write/commit seam may report failure after it has already restored
180
+ // the exact source bytes. Prove that state before allowing native
181
+ // rollback; otherwise preserve the possible concurrent source owner.
182
+ try {
183
+ assertTaskSourceRestored(sourceExpectation);
184
+ }
185
+ catch {
186
+ throw cause;
187
+ }
188
+ throw new TaskSourceRestoredBoundaryError(cause);
183
189
  }
184
- }
185
- if (rollbackErrors.length > 0) {
186
- const message = err instanceof Error ? err.message : String(err);
187
- throw new AggregateError([err, ...rollbackErrors], `${message}; rollback for task "${id}" was incomplete.`);
188
- }
189
- throw err;
190
- }
190
+ },
191
+ suppressNativeRollbackWhenExternalFails: true,
192
+ allowNativeRollbackAfterExternalFailure: (error) => error instanceof TaskSourceRestoredBoundaryError,
193
+ });
191
194
  return {
192
195
  id,
193
196
  ref: conceptIdFromTypeName("task", id),
194
197
  path: assetPath,
195
198
  bundleDir: stashDir,
196
- schedule: task.schedule,
197
- enabled: task.enabled,
199
+ schedule: taskBinding.cron,
200
+ enabled: taskBinding.enabled,
198
201
  backend,
199
202
  target: task.target,
200
203
  };
201
204
  }
202
- export async function akmTasksRun(id, options = {}) {
203
- const startedAt = new Date();
204
- let normalised;
205
- try {
206
- normalised = parseTaskRef(id).id;
207
- }
208
- catch (failure) {
209
- recordTaskAttemptFailure({
210
- taskId: INVALID_TASK_ATTEMPT_ID,
211
- reason: "invalid_task_id",
212
- failure,
213
- startedAt,
214
- });
215
- throw failure;
205
+ function assertTaskAddTargetShape(input) {
206
+ const hasCommand = input.command !== undefined &&
207
+ input.command !== null &&
208
+ !(typeof input.command === "string" && input.command.trim() === "") &&
209
+ !(Array.isArray(input.command) && input.command.length === 0);
210
+ const targetCount = [Boolean(input.workflow), Boolean(input.prompt), hasCommand].filter(Boolean).length;
211
+ if (targetCount !== 1) {
212
+ throw new UsageError("Pass exactly one of --workflow <ref>, --prompt <inline-text>, or --command <shell-command>.", "INVALID_FLAG_VALUE");
216
213
  }
217
- let stashDir;
218
- try {
219
- // No --bundle uses the primary stash. With --bundle, resolve (read-only)
220
- // the named bundle so the task file and
221
- // its relative asset refs load from that bundle's path.
222
- stashDir =
223
- options.target !== undefined
224
- ? resolveWriteTarget(loadConfig(), options.target, { requireWritable: false }).source.path
225
- : resolveStashDir();
226
- }
227
- catch (failure) {
228
- recordTaskAttemptFailure({
229
- taskId: normalised,
230
- reason: "task_load_failed",
231
- failure,
232
- startedAt,
233
- });
234
- throw failure;
214
+ // `--timeout-ms` is the workflow's whole-run bound. Engine and model stay
215
+ // prompt-only because workflow engines come from the frozen plan.
216
+ if (input.workflow && (input.engine !== undefined || input.model !== undefined)) {
217
+ throw new UsageError("Workflow tasks accept --params and --timeout-ms; engine and model are prompt-task fields.", "INVALID_FLAG_VALUE");
218
+ }
219
+ if (hasCommand && (input.engine !== undefined || input.model !== undefined)) {
220
+ throw new UsageError("Command tasks accept --timeout-ms but not --engine or --model.", "INVALID_FLAG_VALUE");
235
221
  }
236
- const result = await runTask(normalised, { stashDir, scheduled: options.scheduled === true });
222
+ if (input.prompt !== undefined)
223
+ assertInlineTaskPrompt(input.prompt);
224
+ }
225
+ export async function akmTasksRun(id, options = {}) {
226
+ const parsed = parseTaskRef(id);
227
+ const bundle = resolveTaskReadBundle(parsed.bundle, options.target);
228
+ const adapterId = bundle.source.adapterId ?? detectAdapterId(bundle.source.path);
229
+ const resolvedId = taskIdForAdapter(parsed.id, adapterId);
230
+ const runOptions = {
231
+ stashDir: bundle.source.path,
232
+ bundleName: bundle.source.name,
233
+ adapterId,
234
+ scheduled: options.scheduled === true,
235
+ };
236
+ // The runner owns the prepare-before-reserve boundary. Invalid source,
237
+ // projectability, and resolver failures therefore create no history row.
238
+ const result = await runTask(resolvedId, runOptions);
237
239
  const exitCode = result.status === "failed" && result.target.kind === "command" && result.detail?.exitCode === 78
238
240
  ? 78
239
241
  : exitCodeForStatus(result.status);
@@ -245,14 +247,18 @@ export async function akmTasksRun(id, options = {}) {
245
247
  }
246
248
  export async function akmTasksHistory(input) {
247
249
  const limit = input.limit !== undefined && input.limit > 0 ? input.limit : 50;
248
- const id = input.id ? normaliseTaskId(input.id) : undefined;
250
+ const parsed = input.id ? parseTaskRef(input.id) : undefined;
251
+ const bundle = resolveTaskReadBundle(parsed?.bundle, input.target);
252
+ const adapterId = bundle.source.adapterId ?? detectAdapterId(bundle.source.path);
253
+ const id = parsed ? taskIdForAdapter(parsed.id, adapterId) : undefined;
249
254
  // History rows are keyed by task id in state.db, not per bundle.
250
255
  return { rows: readTaskHistory({ id, limit }) };
251
256
  }
252
257
  /**
253
258
  * Reconcile the on-disk task files of ONE bundle with the OS scheduler.
254
- * • install missing tasks (after validating them invalid files are
255
- * skipped with a per-task reason rather than aborting the whole sync)
259
+ * • compile and runtime-project the complete desired task/workflow set;
260
+ * one invalid source rejects the sync before descriptor/backend mutation
261
+ * • install missing bindings only after that whole-set preflight succeeds
256
262
  * • reinstall tasks whose schedule or enabled state changed in the .yml
257
263
  * (drift detected by comparing the backend's installed signature against
258
264
  * the signature the current definition would produce)
@@ -268,101 +274,65 @@ export async function akmTasksHistory(input) {
268
274
  * (or `sync --bundle` on a bundle whose task files are already present).
269
275
  */
270
276
  export async function akmTasksSync(deps = {}, bundleTarget, options = {}) {
271
- const stashDir = resolveTaskInspectDir(bundleTarget);
272
- // Primary-bundle scheduler entries omit --bundle; other bundles carry it.
277
+ const resolved = resolveTaskReadBundle(undefined, bundleTarget);
278
+ const config = loadConfig();
279
+ const stashDir = resolved.source.path;
273
280
  const syncTarget = bundleTarget !== undefined && !isPrimaryStashPath(stashDir) ? bundleTarget : undefined;
274
- const typeRoot = path.join(stashDir, "tasks");
275
- const fileIds = fs.existsSync(typeRoot)
276
- ? fs
277
- .readdirSync(typeRoot)
278
- .filter((f) => f.endsWith(".yml"))
279
- .map((f) => f.slice(0, -4))
280
- : [];
281
281
  const sched = deps.backend ?? selectBackend();
282
- const backend = sched.name;
283
- const installOpts = syncTarget !== undefined ? { target: syncTarget } : undefined;
284
- const allEntries = options.rebind && sched.listForRebind ? await sched.listForRebind() : await sched.list();
285
- // Attribution filter: only entries installed from THIS bundle are reconciled
286
- // here. Entries carrying a `--bundle` for a different bundle are invisible to
287
- // this sync never removed, never touched.
288
- const present = new Map(allEntries.filter((t) => sameBundle(t.target, syncTarget)).map((t) => [t.id, t]));
289
- const installed = [];
290
- const updated = [];
291
- const unchanged = [];
292
- const skipped = [];
282
+ if (!sched.inspectBindings) {
283
+ throw new ConfigError(`Scheduler backend "${sched.name}" cannot provide one coherent inspection for transactional sync.`, "INVALID_CONFIG_FILE");
284
+ }
285
+ const inspection = await sched.inspectBindings({ rebind: options.rebind === true });
286
+ const rawEntries = [...inspection.installed];
287
+ const allEntries = rawEntries.map((entry) => ({
288
+ ...entry,
289
+ ...(entry.nativeId !== undefined ? { nativeId: entry.nativeId } : {}),
290
+ ...(entry.invocation !== undefined ? { invocation: Object.freeze([...entry.invocation]) } : {}),
291
+ binding: "binding" in entry ? [...entry.binding] : [],
292
+ contextPath: "contextPath" in entry ? entry.contextPath : "",
293
+ }));
294
+ const nativeArtifacts = inspection.artifacts;
295
+ const common = {
296
+ sourceRoot: stashDir,
297
+ adapterId: resolved.source.adapterId ?? detectAdapterId(stashDir),
298
+ bundleName: resolved.source.name,
299
+ ...(syncTarget ? { bundleTarget: syncTarget } : {}),
300
+ backend: sched.name,
301
+ installed: allEntries,
302
+ nativeArtifacts,
303
+ inspection: Object.freeze({ installed: allEntries, artifacts: nativeArtifacts }),
304
+ rebind: options.rebind === true,
305
+ config,
306
+ resolveAsset: taskProjectionAssetResolver(config, resolved.source.name, stashDir),
307
+ };
308
+ // Pass one validates every desired source, ownership domain, schedule, and
309
+ // installed-id collision before runtime descriptor preparation is possible.
310
+ const preparedSources = await prepareSchedulerSyncSourceSet(common);
311
+ const preflight = finalizeSchedulerSyncPlan(common, preparedSources);
293
312
  const warnings = [];
294
- for (const id of fileIds) {
295
- const filePath = path.join(typeRoot, `${id}.yml`);
296
- let task;
297
- try {
298
- task = parseTaskDocument({ yaml: fs.readFileSync(filePath, "utf8"), filePath, id });
299
- await validateTaskDocument(task, { backend, stashDir });
300
- // A bare id can only be scheduled from ONE bundle at a time (scheduler ids
301
- // are never namespaced). If this id is already scheduled from a different
302
- // bundle, refuse rather than clobber it — surface it as a per-task skip so
303
- // the rest of the sync still proceeds.
304
- const foreign = allEntries.find((e) => e.id === id && !sameBundle(e.target, syncTarget));
305
- if (foreign)
306
- throw new UsageError(foreignScheduleMessage(id, foreign.target), "RESOURCE_ALREADY_EXISTS");
307
- }
308
- catch (err) {
309
- skipped.push({ id, reason: err instanceof Error ? err.message : String(err) });
310
- if (present.has(id)) {
311
- try {
312
- await sched.setEnabled(id, false);
313
- }
314
- catch (disableError) {
315
- try {
316
- await sched.uninstall(id);
317
- }
318
- catch (uninstallError) {
319
- throw new AggregateError([err, disableError, uninstallError], `Task "${id}" is invalid and its installed scheduler entry could not be disabled or removed.`);
320
- }
321
- }
322
- }
323
- continue;
324
- }
325
- if (!present.has(id)) {
326
- try {
327
- const runtimeOpts = schedulerInstallOptions(installOpts, undefined, deps, options.rebind === true, `create scheduler entry for task "${id}"`, warnings);
328
- await sched.install(task, runtimeOpts);
329
- installed.push(id);
330
- }
331
- catch (error) {
332
- skipped.push({ id, reason: error instanceof Error ? error.message : String(error) });
313
+ const expectedSignature = sched.expectedSignature?.bind(sched);
314
+ const needsRuntime = preflight.operations.some((operation) => operation.kind !== "remove" && operation.options?.binding === undefined);
315
+ const prepared = needsRuntime
316
+ ? prepareSchedulerSyncRuntime(syncTarget ? { target: syncTarget } : undefined, deps, options.rebind === true, "reconcile native scheduler bindings", warnings)
317
+ : undefined;
318
+ const plan = finalizeSchedulerSyncPlan({
319
+ ...common,
320
+ ...(prepared?.options ? { installOptions: prepared.options } : {}),
321
+ ...(expectedSignature
322
+ ? {
323
+ expectedSignature: (binding, install) => expectedSignature(binding, install),
333
324
  }
334
- continue;
335
- }
336
- // Already installed reconcile against the current definition. Compare the
337
- // installed signature to what this task would render to; reinstall on drift.
338
- // When the backend can't produce a signature (no expectedSignature, or it
339
- // didn't record one), reinstall unconditionally — install() is idempotent,
340
- // so the cost is one crontab write and correctness is guaranteed.
341
- const installedEntry = present.get(id);
342
- const runtimeOpts = schedulerInstallOptions(installOpts, options.rebind ? undefined : installedEntry, deps, options.rebind === true, `rebind scheduler entry for task "${id}"`, warnings);
343
- const installedSig = installedEntry.signature;
344
- const expectedSig = sched.expectedSignature?.(task, runtimeOpts);
345
- if (installedSig !== undefined && expectedSig !== undefined && installedSig === expectedSig) {
346
- unchanged.push(id);
347
- }
348
- else {
349
- await sched.install(task, runtimeOpts);
350
- updated.push(id);
351
- }
352
- }
353
- const removed = [];
354
- for (const installedId of present.keys()) {
355
- if (!fileIds.includes(installedId)) {
356
- await sched.uninstall(installedId);
357
- removed.push(installedId);
358
- }
359
- }
325
+ : {}),
326
+ }, preparedSources);
327
+ await applySchedulerSyncPlan(sched, plan, prepared?.publish && plan.operations.some((operation) => operation.kind !== "remove")
328
+ ? prepared.publish
329
+ : undefined);
360
330
  return {
361
- installed,
362
- updated,
363
- removed,
364
- unchanged,
365
- skipped,
331
+ installed: [...plan.installed],
332
+ updated: [...plan.updated],
333
+ removed: [...plan.removed],
334
+ unchanged: [...plan.unchanged],
335
+ skipped: [],
366
336
  backend: sched.name,
367
337
  ...(warnings.length > 0 ? { warnings } : {}),
368
338
  };
@@ -446,24 +416,281 @@ export async function akmTasksDoctor(deps = {}) {
446
416
  };
447
417
  }
448
418
  // ── helpers ─────────────────────────────────────────────────────────────────
449
- function schedulerInstallOptions(base, installed, deps, explicitRebind, operation, warnings = []) {
450
- if (installed && !explicitRebind) {
451
- return {
452
- ...base,
453
- binding: installed.binding,
454
- contextPath: installed.contextPath,
455
- };
419
+ async function applySchedulerSyncPlan(backend, plan, publish) {
420
+ await applySchedulerTransaction(backend, plan.operations, {
421
+ initialExpectations: plan.operations.map((operation) => operation.expected),
422
+ assertReadSet: () => assertSchedulerSourceSnapshot(plan.sourceSnapshot),
423
+ beforeOperation: (_operation, index) => {
424
+ if (index === 0)
425
+ publish?.();
426
+ },
427
+ });
428
+ }
429
+ async function applySchedulerTransaction(backend, operations, hooks) {
430
+ if (operations.length === 0) {
431
+ hooks.assertReadSet?.();
432
+ await hooks.afterOperations?.();
433
+ hooks.assertReadSet?.();
434
+ return;
435
+ }
436
+ hooks.assertReadSet?.();
437
+ if (!backend.snapshotBindings || !backend.restoreBindings) {
438
+ throw new ConfigError(`Scheduler backend "${backend.name}" cannot snapshot and restore a whole-set transaction.`, "INVALID_CONFIG_FILE");
456
439
  }
457
- // Injected backends can own their default runtime unless a resolver is supplied.
440
+ const nativeIds = [
441
+ ...new Set(operations.map((operation) => operation.kind === "remove" ? operation.nativeId : schedulerBindingNativeId(operation.binding))),
442
+ ];
443
+ const snapshot = await backend.snapshotBindings(nativeIds);
444
+ assertSchedulerTransactionSnapshot(snapshot, nativeIds, hooks.initialExpectations);
445
+ const rollbackExpected = schedulerRollbackExpectations(snapshot, operations);
446
+ hooks.assertReadSet?.();
447
+ try {
448
+ for (const [index, operation] of operations.entries()) {
449
+ hooks.assertReadSet?.();
450
+ await hooks.beforeOperation?.(operation, index);
451
+ hooks.assertReadSet?.();
452
+ if (operation.kind === "remove")
453
+ await backend.uninstall(operation.nativeId, operation.expected);
454
+ else
455
+ await backend.install(operation.binding, operation.options, operation.expected);
456
+ hooks.assertReadSet?.();
457
+ }
458
+ hooks.assertReadSet?.();
459
+ await hooks.afterOperations?.();
460
+ hooks.assertReadSet?.();
461
+ }
462
+ catch (primaryError) {
463
+ let externalRollbackError;
464
+ try {
465
+ await hooks.rollbackExternal?.();
466
+ }
467
+ catch (error) {
468
+ externalRollbackError = error;
469
+ }
470
+ let nativeRollbackError;
471
+ const externalStateAllowsNativeRollback = externalRollbackError !== undefined &&
472
+ hooks.allowNativeRollbackAfterExternalFailure?.(externalRollbackError) === true;
473
+ if (!(externalRollbackError && hooks.suppressNativeRollbackWhenExternalFails && !externalStateAllowsNativeRollback)) {
474
+ try {
475
+ await backend.restoreBindings(snapshot, rollbackExpected);
476
+ }
477
+ catch (error) {
478
+ nativeRollbackError = error;
479
+ }
480
+ }
481
+ const rollbackErrors = [externalRollbackError, nativeRollbackError].filter((error) => error !== undefined);
482
+ if (rollbackErrors.length > 0) {
483
+ throw new AggregateError([primaryError, ...rollbackErrors], `Scheduler transaction failed and rollback was incomplete: ${errorMessage(primaryError)}`);
484
+ }
485
+ throw primaryError;
486
+ }
487
+ }
488
+ function schedulerRollbackExpectations(snapshot, operations) {
489
+ return Object.freeze(snapshot.nativeIds.map((nativeId) => {
490
+ const prior = snapshot.artifacts.find((artifact) => schedulerNativeArtifactKey(artifact.nativeId) === schedulerNativeArtifactKey(nativeId));
491
+ const allowed = [];
492
+ if (prior) {
493
+ if (prior.fingerprint === undefined) {
494
+ throw new ConfigError(`Scheduler backend snapshot for ${JSON.stringify(nativeId)} has no exact fingerprint.`, "INVALID_CONFIG_FILE");
495
+ }
496
+ allowed.push(Object.freeze({
497
+ state: "present",
498
+ ...(prior.bindingId !== undefined ? { bindingId: prior.bindingId } : {}),
499
+ ...(prior.invocation !== undefined ? { invocation: Object.freeze([...prior.invocation]) } : {}),
500
+ fingerprint: prior.fingerprint,
501
+ }));
502
+ }
503
+ else {
504
+ allowed.push(Object.freeze({ state: "absent" }));
505
+ }
506
+ const matchingOperations = operations.filter((candidate) => {
507
+ const operationNativeId = candidate.kind === "remove" ? candidate.nativeId : schedulerBindingNativeId(candidate.binding);
508
+ return schedulerNativeArtifactKey(operationNativeId) === schedulerNativeArtifactKey(nativeId);
509
+ });
510
+ for (const operation of matchingOperations) {
511
+ if (operation.kind === "remove") {
512
+ if (!allowed.some((state) => state.state === "absent")) {
513
+ allowed.push(Object.freeze({ state: "absent" }));
514
+ }
515
+ continue;
516
+ }
517
+ if (operation.resultFingerprint === undefined) {
518
+ throw new ConfigError(`Scheduler backend cannot freeze the post-mutation fingerprint for ${JSON.stringify(nativeId)}.`, "INVALID_CONFIG_FILE");
519
+ }
520
+ allowed.push(Object.freeze({
521
+ state: "present",
522
+ bindingId: operation.binding.id,
523
+ invocation: Object.freeze([...operation.binding.invocation]),
524
+ fingerprint: operation.resultFingerprint,
525
+ }));
526
+ }
527
+ return Object.freeze({ nativeId, allowed: Object.freeze(allowed) });
528
+ }));
529
+ }
530
+ function assertSchedulerTransactionSnapshot(snapshot, nativeIds, initialExpectations) {
531
+ if (!snapshot ||
532
+ !Array.isArray(snapshot.nativeIds) ||
533
+ !Array.isArray(snapshot.artifacts) ||
534
+ nativeIds.some((nativeId) => !snapshot.nativeIds.includes(nativeId))) {
535
+ throw new ConfigError("Scheduler backend returned an incomplete transaction snapshot.", "INVALID_CONFIG_FILE");
536
+ }
537
+ const snapshotKeys = snapshot.nativeIds.map(schedulerNativeArtifactKey);
538
+ const requestedKeys = nativeIds.map(schedulerNativeArtifactKey);
539
+ if (snapshotKeys.length !== requestedKeys.length ||
540
+ new Set(snapshotKeys).size !== snapshotKeys.length ||
541
+ snapshotKeys.some((key) => !requestedKeys.includes(key))) {
542
+ throw new ConfigError("Scheduler backend returned an inexact normalized transaction snapshot set.", "INVALID_CONFIG_FILE");
543
+ }
544
+ for (const expected of initialExpectations) {
545
+ const artifact = assertSchedulerNativeArtifactCardinality(snapshot.artifacts, expected.nativeId, expected.state === "absent" ? 0 : 1);
546
+ assertSchedulerMutationArtifact(artifact, expected);
547
+ }
548
+ }
549
+ async function prepareTaskAddSchedulerTransaction(input) {
550
+ if (!input.sched.inspectBindings) {
551
+ throw new ConfigError(`Scheduler backend "${input.sched.name}" cannot provide one coherent inspection for transactional add.`, "INVALID_CONFIG_FILE");
552
+ }
553
+ if (!input.sched.snapshotBindings || !input.sched.restoreBindings || !input.sched.expectedSignature) {
554
+ throw new ConfigError(`Scheduler backend "${input.sched.name}" cannot provide exact snapshot, restore, and signature contracts for transactional add.`, "INVALID_CONFIG_FILE");
555
+ }
556
+ const inspection = await input.sched.inspectBindings({ rebind: input.rebind });
557
+ const installedEntries = [...inspection.installed];
558
+ const nativeArtifacts = [...inspection.artifacts];
559
+ const seenNativeKeys = new Set();
560
+ for (const artifact of nativeArtifacts) {
561
+ const key = schedulerNativeArtifactKey(artifact.nativeId);
562
+ if (seenNativeKeys.has(key)) {
563
+ throw new UsageError(`Scheduler inspection has duplicate normalized native artifact ${JSON.stringify(artifact.nativeId)}.`, "RESOURCE_ALREADY_EXISTS");
564
+ }
565
+ seenNativeKeys.add(key);
566
+ }
567
+ for (const binding of input.taskBindings) {
568
+ assertNoForeignSchedule(installedEntries, binding.id, input.ownerTarget);
569
+ }
570
+ const taskEntries = installedEntries.filter((entry) => installedEntryRunsTask(entry, input.id));
571
+ const foreignTaskEntry = taskEntries.find((entry) => !sameBundle(entry.target, input.ownerTarget));
572
+ if (foreignTaskEntry) {
573
+ throw new UsageError(foreignScheduleMessage(input.id, foreignTaskEntry.target), "RESOURCE_ALREADY_EXISTS");
574
+ }
575
+ assertSchedulerNativeArtifactOwnership(input.taskBindings, nativeArtifacts);
576
+ const primary = input.taskBindings[0];
577
+ if (!primary)
578
+ throw new Error("invariant: scheduler transaction has no desired binding");
579
+ const installedEntry = installedEntries.find((entry) => entry.id === primary.id) ?? taskEntries[0];
580
+ const preparedRuntime = installedEntry && !input.rebind
581
+ ? {
582
+ options: {
583
+ ...input.installOpts,
584
+ binding: Object.freeze([...installedEntry.binding]),
585
+ contextPath: installedEntry.contextPath,
586
+ },
587
+ }
588
+ : prepareSchedulerSyncRuntime(input.installOpts, input.deps, input.rebind, `create scheduler entry for task "${input.id}"`, []);
589
+ const runtimeOpts = preparedRuntime.options;
590
+ const removals = taskEntries.map((entry) => {
591
+ const invocation = entry.invocation;
592
+ if (!invocation) {
593
+ throw new UsageError(`Installed scheduler binding ${JSON.stringify(entry.id)} has no exact parsed owner; refusing replacement.`, "RESOURCE_ALREADY_EXISTS");
594
+ }
595
+ const nativeId = entry.nativeId ?? schedulerNativeBindingId(entry.id);
596
+ const artifact = assertSchedulerNativeArtifactCardinality(nativeArtifacts, nativeId, 1);
597
+ if (!artifact?.fingerprint || artifact.bindingId !== entry.id) {
598
+ throw new UsageError(`Installed scheduler binding ${JSON.stringify(entry.id)} has no exact coherent fingerprint.`, "RESOURCE_ALREADY_EXISTS");
599
+ }
600
+ const logicalSource = primary.logicalSource;
601
+ const ordinal = schedulerBindingOrdinal(entry.id, logicalSource, invocation);
602
+ if (ordinal === undefined) {
603
+ throw new UsageError(`Installed scheduler binding ${JSON.stringify(entry.id)} has no exact schedule ordinal; refusing replacement.`, "RESOURCE_ALREADY_EXISTS");
604
+ }
605
+ return Object.freeze({
606
+ kind: "remove",
607
+ id: entry.id,
608
+ nativeId,
609
+ expected: Object.freeze({
610
+ bindingId: entry.id,
611
+ nativeId,
612
+ logicalSource,
613
+ ordinal,
614
+ invocation: Object.freeze([...invocation]),
615
+ fingerprint: artifact.fingerprint,
616
+ }),
617
+ });
618
+ });
619
+ const installs = input.taskBindings.map((binding) => {
620
+ const nativeId = schedulerBindingNativeId(binding);
621
+ const resultFingerprint = input.sched.expectedSignature(binding, runtimeOpts);
622
+ if (!resultFingerprint) {
623
+ throw new ConfigError(`Scheduler backend "${input.sched.name}" cannot freeze the post-install fingerprint for ${JSON.stringify(binding.id)}.`, "INVALID_CONFIG_FILE");
624
+ }
625
+ return Object.freeze({
626
+ kind: "install",
627
+ binding,
628
+ expected: Object.freeze({
629
+ state: "absent",
630
+ bindingId: binding.id,
631
+ nativeId,
632
+ logicalSource: binding.logicalSource,
633
+ ordinal: binding.ordinal,
634
+ invocation: binding.invocation,
635
+ }),
636
+ resultFingerprint,
637
+ ...(runtimeOpts ? { options: runtimeOpts } : {}),
638
+ });
639
+ });
640
+ const initialByKey = new Map();
641
+ for (const removal of removals) {
642
+ if (removal.kind !== "remove")
643
+ continue;
644
+ initialByKey.set(schedulerNativeArtifactKey(removal.nativeId), Object.freeze({ ...removal.expected, state: "present" }));
645
+ }
646
+ for (const install of installs) {
647
+ if (install.kind === "remove")
648
+ continue;
649
+ const key = schedulerNativeArtifactKey(schedulerBindingNativeId(install.binding));
650
+ if (!initialByKey.has(key))
651
+ initialByKey.set(key, install.expected);
652
+ }
653
+ return Object.freeze({
654
+ runtimeOpts,
655
+ ...(preparedRuntime.publish ? { publishRuntime: preparedRuntime.publish } : {}),
656
+ operations: Object.freeze([...removals, ...installs]),
657
+ initialExpectations: Object.freeze([...initialByKey.values()]),
658
+ publishOperationIndex: removals.length,
659
+ });
660
+ }
661
+ function prepareSchedulerSyncRuntime(base, deps, explicitRebind, operation, warnings) {
458
662
  if (deps.backend && !deps.schedulerRuntime)
459
- return base;
460
- const runtime = deps.schedulerRuntime?.() ?? prepareSchedulerRuntime(explicitRebind, operation);
461
- // --rebind bypasses the eligibility refusal in prepareSchedulerRuntime; warn once
462
- // per sync run rather than silently writing a mutable/unproven binary into cron.
463
- if (explicitRebind && runtime.eligible === false && warnings.length === 0) {
464
- warnings.push(`--rebind bound scheduled tasks 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\`.`);
663
+ return base ? { options: base } : {};
664
+ if (deps.schedulerRuntime) {
665
+ const runtime = deps.schedulerRuntime();
666
+ warnIneligibleRebind(runtime, explicitRebind, warnings);
667
+ return { options: { ...base, binding: runtime.binding, contextPath: runtime.contextPath } };
465
668
  }
466
- return { ...base, binding: runtime.binding, contextPath: runtime.contextPath };
669
+ const invocation = resolveAndValidateSchedulerInvocation(explicitRebind, operation);
670
+ warnIneligibleRebind(invocation, explicitRebind, warnings);
671
+ const descriptor = schedulerContextDescriptor();
672
+ const contextPath = schedulerContextPath(descriptor);
673
+ return {
674
+ options: { ...base, binding: invocation.binding, contextPath },
675
+ publish: () => {
676
+ const written = writeSchedulerContextDescriptor(descriptor);
677
+ if (written !== contextPath) {
678
+ throw new ConfigError("Scheduler context descriptor path changed after preflight.", "INVALID_CONFIG_FILE");
679
+ }
680
+ },
681
+ };
682
+ }
683
+ function resolveAndValidateSchedulerInvocation(explicitRebind, operation) {
684
+ const invocation = resolveAkmInvocation();
685
+ if (!invocation.eligible && !explicitRebind) {
686
+ throw new UsageError(`Refusing to ${operation} from an ineligible ${invocation.kind ?? "unknown"} invocation (${invocation.argv.join(" ")}).`, "INVALID_FLAG_VALUE", "npm-global ownership could not be verified. Run `npm install --global akm-cli` and use that launcher, use a standalone installation, or explicitly repeat the operation with --rebind.");
687
+ }
688
+ return { binding: invocation.argv, contextPath: "", eligible: invocation.eligible, kind: invocation.kind };
689
+ }
690
+ function warnIneligibleRebind(runtime, explicitRebind, warnings) {
691
+ if (!explicitRebind || runtime.eligible !== false || warnings.length > 0)
692
+ return;
693
+ warnings.push(`--rebind bound scheduled tasks 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\`.`);
467
694
  }
468
695
  export function prepareSchedulerRuntime(explicitRebind, operation, deps = {}) {
469
696
  const invocation = (deps.resolveInvocation ?? resolveAkmInvocation)();
@@ -476,7 +703,7 @@ export function prepareSchedulerRuntime(explicitRebind, operation, deps = {}) {
476
703
  function groupInstalledBindings(entries, invocation) {
477
704
  const groups = new Map();
478
705
  for (const entry of entries) {
479
- const argv = entry.binding;
706
+ const argv = [...entry.binding];
480
707
  const status = inspectInstalledBinding(entry, invocation);
481
708
  const key = JSON.stringify([argv, entry.contextPath, status]);
482
709
  const existing = groups.get(key);
@@ -539,10 +766,119 @@ function hasGitAncestor(file) {
539
766
  function taskAssetRef(id) {
540
767
  return { type: "task", name: id };
541
768
  }
542
- async function restoreTaskSourceBytes(writeAsset, source, config, ref, filePath, yaml) {
543
- await writeAsset(source, config, ref, yaml);
544
- // The normal write path adds a trailing newline; rollback restores the raw snapshot exactly.
545
- fs.writeFileSync(filePath, yaml, "utf8");
769
+ function captureTaskSourceExpectation(filePathInput, rootInput) {
770
+ const filePath = path.resolve(filePathInput);
771
+ const root = path.resolve(rootInput);
772
+ const lexicalRelative = path.relative(root, filePath);
773
+ if (lexicalRelative === "" || lexicalRelative.startsWith("..") || path.isAbsolute(lexicalRelative)) {
774
+ throw new UsageError(`${filePathInput} resolves outside the task source root.`, "PATH_ESCAPE_VIOLATION");
775
+ }
776
+ const rootRealPath = fs.realpathSync(root);
777
+ const rootStat = fs.statSync(rootRealPath, { bigint: true });
778
+ if (!rootStat.isDirectory()) {
779
+ throw new UsageError(`${root} is not a task source directory.`, "INVALID_FLAG_VALUE");
780
+ }
781
+ const common = {
782
+ filePath,
783
+ rootRealPath,
784
+ rootPhysicalIdentity: rootStat.ino === 0n ? `path:${rootRealPath}` : `inode:${rootStat.dev}:${rootStat.ino}`,
785
+ rootMtimeNs: String(rootStat.mtimeNs),
786
+ rootCtimeNs: String(rootStat.ctimeNs),
787
+ };
788
+ let descriptor;
789
+ try {
790
+ const noFollow = "O_NOFOLLOW" in fs.constants ? fs.constants.O_NOFOLLOW : 0;
791
+ descriptor = fs.openSync(filePath, fs.constants.O_RDONLY | noFollow);
792
+ const before = fs.fstatSync(descriptor, { bigint: true });
793
+ if (!before.isFile()) {
794
+ throw new UsageError(`${filePath} is not a regular task source.`, "INVALID_FLAG_VALUE");
795
+ }
796
+ if (before.size > BigInt(TASK_V3_MAX_SOURCE_BYTES)) {
797
+ throw new UsageError(`${filePath} exceeds the 1 MiB (${TASK_V3_MAX_SOURCE_BYTES}-byte) task source limit.`, "INVALID_FLAG_VALUE");
798
+ }
799
+ const bytes = fs.readFileSync(descriptor);
800
+ const after = fs.fstatSync(descriptor, { bigint: true });
801
+ if (!sameTaskSourceStat(before, after) || BigInt(bytes.byteLength) !== before.size) {
802
+ throw new UsageError(`${filePath} changed while its guarded bytes were read.`, "RESOURCE_ALREADY_EXISTS");
803
+ }
804
+ const realPath = fs.realpathSync(filePath);
805
+ const physicalRelative = path.relative(rootRealPath, realPath);
806
+ if (physicalRelative === "" || physicalRelative.startsWith("..") || path.isAbsolute(physicalRelative)) {
807
+ throw new UsageError(`${filePath} resolves outside the task source root.`, "PATH_ESCAPE_VIOLATION");
808
+ }
809
+ let content;
810
+ try {
811
+ content = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
812
+ }
813
+ catch {
814
+ throw new UsageError(`${filePath} contains invalid UTF-8 bytes.`, "INVALID_FLAG_VALUE");
815
+ }
816
+ return Object.freeze({
817
+ state: "present",
818
+ ...common,
819
+ realPath,
820
+ physicalIdentity: before.ino === 0n ? `path:${realPath}` : `inode:${before.dev}:${before.ino}`,
821
+ size: bytes.byteLength,
822
+ mtimeNs: String(before.mtimeNs),
823
+ ctimeNs: String(before.ctimeNs),
824
+ sha256: createHash("sha256").update(bytes).digest("hex"),
825
+ bytesBase64: bytes.toString("base64"),
826
+ content,
827
+ });
828
+ }
829
+ catch (cause) {
830
+ if (cause.code === "ENOENT") {
831
+ return Object.freeze({ state: "absent", ...common });
832
+ }
833
+ if (cause instanceof UsageError)
834
+ throw cause;
835
+ if (cause.code === "ELOOP") {
836
+ throw new UsageError(`${filePath} must not be a symbolic task source.`, "RESOURCE_ALREADY_EXISTS");
837
+ }
838
+ throw new UsageError(`${filePath} could not be guarded as a contained regular task source: ${errorMessage(cause)}`, "PATH_ESCAPE_VIOLATION");
839
+ }
840
+ finally {
841
+ if (descriptor !== undefined)
842
+ fs.closeSync(descriptor);
843
+ }
844
+ }
845
+ function assertTaskSourceExpectation(expected) {
846
+ const actual = captureTaskSourceExpectation(expected.filePath, expected.rootRealPath);
847
+ if (!sameTaskSourceExpectation(actual, expected)) {
848
+ throw new UsageError(`Task source ${JSON.stringify(expected.filePath)} changed after transaction planning.`, "RESOURCE_ALREADY_EXISTS");
849
+ }
850
+ }
851
+ function sameTaskSourceExpectation(left, right) {
852
+ return JSON.stringify(left) === JSON.stringify(right);
853
+ }
854
+ function assertTaskSourceRestored(expected) {
855
+ const actual = captureTaskSourceExpectation(expected.filePath, expected.rootRealPath);
856
+ const restored = actual.state === expected.state &&
857
+ actual.rootPhysicalIdentity === expected.rootPhysicalIdentity &&
858
+ (actual.state === "absent" ||
859
+ (expected.state === "present" && actual.sha256 === expected.sha256 && actual.content === expected.content));
860
+ if (!restored) {
861
+ throw new UsageError(`Task source ${JSON.stringify(expected.filePath)} could not be restored without replacing a concurrent owner.`, "RESOURCE_ALREADY_EXISTS");
862
+ }
863
+ }
864
+ class TaskSourceRestoredBoundaryError extends Error {
865
+ cause;
866
+ constructor(cause) {
867
+ super(errorMessage(cause));
868
+ this.name = "TaskSourceRestoredBoundaryError";
869
+ this.cause = cause;
870
+ }
871
+ }
872
+ function sameTaskSourceStat(left, right) {
873
+ return (left.dev === right.dev &&
874
+ left.ino === right.ino &&
875
+ left.mode === right.mode &&
876
+ left.size === right.size &&
877
+ left.mtimeNs === right.mtimeNs &&
878
+ left.ctimeNs === right.ctimeNs);
879
+ }
880
+ function hashTaskSource(source) {
881
+ return createHash("sha256").update(Buffer.from(source, "utf8")).digest("hex");
546
882
  }
547
883
  /**
548
884
  * Resolve the bundle a mutating/run task command targets. Returns the resolved
@@ -550,20 +886,66 @@ async function restoreTaskSourceBytes(writeAsset, source, config, ref, filePath,
550
886
  * in scheduled invocations. The primary bundle uses the target-less form.
551
887
  */
552
888
  function resolveTaskBundle(target, opts) {
553
- const selected = resolveWriteTarget(loadConfig(), target, { requireWritable: opts.requireWritable });
889
+ const config = loadConfig();
890
+ const selected = resolveWriteTarget(config, target, { requireWritable: opts.requireWritable });
554
891
  const resolved = opts.requireWritable ? prepareWriteTargetForMutation(selected) : selected;
555
892
  const stashDir = resolved.source.path;
556
893
  const installTarget = isPrimaryStashPath(stashDir) ? undefined : (resolved.selector ?? resolved.source.name);
557
- return { resolved, stashDir, installTarget };
894
+ return { resolved, config, stashDir, bundleName: resolved.source.name, installTarget };
895
+ }
896
+ function taskProjectionAssetResolver(config, bundleName, bundleRoot) {
897
+ return async ({ bundle, type, name }) => {
898
+ if (bundle === bundleName) {
899
+ return { file: await resolveAssetPath(bundleRoot, type, name), bundleRoot };
900
+ }
901
+ const target = resolveWriteTarget(config, bundle, { requireWritable: false });
902
+ return {
903
+ file: await resolveAssetPath(target.source.path, type, name),
904
+ bundleRoot: target.source.path,
905
+ };
906
+ };
907
+ }
908
+ function resolveTaskReadBundle(refBundle, flagBundle) {
909
+ if (refBundle && flagBundle && refBundle !== flagBundle) {
910
+ throw new UsageError(`Task ref selects bundle ${JSON.stringify(refBundle)}, but --bundle selects ${JSON.stringify(flagBundle)}.`, "INVALID_FLAG_VALUE");
911
+ }
912
+ const selector = flagBundle ?? refBundle;
913
+ const config = loadConfig();
914
+ let resolved;
915
+ if (!selector) {
916
+ resolved = resolveWorkingStashTarget(config, { requireWritable: false });
917
+ }
918
+ else {
919
+ const configured = resolveConfiguredSources(config).some((source) => source.name === selector);
920
+ const implicit = configured ? undefined : resolveImplicitScheduledBundleTarget(config, selector);
921
+ resolved = implicit ?? resolveWriteTarget(config, selector, { requireWritable: false });
922
+ }
923
+ if (refBundle && resolved.source.name !== refBundle) {
924
+ throw new UsageError(`Task ref bundle ${JSON.stringify(refBundle)} does not match the resolved source.`, "INVALID_FLAG_VALUE");
925
+ }
926
+ return resolved;
558
927
  }
559
928
  /**
560
- * Resolve the tasks/ directory a read/inspect command operates on. No
561
- * `--bundle` uses the primary stash; `--bundle X` resolves bundle X read-only.
929
+ * New scheduler bindings always carry a canonical `--bundle <owner>` token,
930
+ * including an env-selected working stash. That stash need not be persisted in
931
+ * config (CI, one-shot tools, and fresh installs commonly use only
932
+ * AKM_BUNDLE_DIR), so its scheduled child must accept precisely its derived
933
+ * owner name after the scheduler context restores the environment.
934
+ *
935
+ * This is intentionally narrower than an unknown-bundle fallback: a configured
936
+ * source always wins, and an unconfigured selector is accepted only when it is
937
+ * exactly the current env-selected working stash identity.
562
938
  */
563
- function resolveTaskInspectDir(target) {
564
- if (target === undefined)
565
- return resolveStashDir();
566
- return resolveWriteTarget(loadConfig(), target, { requireWritable: false }).source.path;
939
+ function resolveImplicitScheduledBundleTarget(config, selector) {
940
+ if (!process.env.AKM_BUNDLE_DIR?.trim())
941
+ return undefined;
942
+ try {
943
+ const working = resolveWorkingStashTarget(config, { requireWritable: false });
944
+ return working.source.name === selector ? working : undefined;
945
+ }
946
+ catch {
947
+ return undefined;
948
+ }
567
949
  }
568
950
  /** True when `candidate` resolves to the same directory as the primary stash. */
569
951
  function isPrimaryStashPath(candidate) {
@@ -580,10 +962,17 @@ function isPrimaryStashPath(candidate) {
580
962
  function sameBundle(a, b) {
581
963
  return (a ?? undefined) === (b ?? undefined);
582
964
  }
965
+ function installedEntryRunsTask(entry, id) {
966
+ const invocation = entry.invocation;
967
+ return invocation?.[0] === "task" && invocation[1] === "run" && invocation[2] === id;
968
+ }
583
969
  function foreignScheduleMessage(id, existingTarget) {
584
970
  const where = existingTarget === undefined ? "the default bundle" : `bundle "${existingTarget}"`;
585
971
  return `Task id "${id}" is already scheduled from ${where}; rename the task or disable the existing one first.`;
586
972
  }
973
+ function errorMessage(cause) {
974
+ return cause instanceof Error ? cause.message : String(cause);
975
+ }
587
976
  /**
588
977
  * Refuse to schedule an id already installed from a DIFFERENT bundle. Scheduler
589
978
  * ids are the bare task id (never namespaced), so a single id can be active from
@@ -595,42 +984,44 @@ function assertNoForeignSchedule(entries, id, installTarget) {
595
984
  throw new UsageError(foreignScheduleMessage(id, foreign.target), "RESOURCE_ALREADY_EXISTS");
596
985
  }
597
986
  function renderTaskYaml(input) {
598
- const obj = { version: 2, schedule: input.schedule, enabled: input.enabled };
987
+ const obj = { version: 3 };
599
988
  if (input.workflow) {
600
- obj.workflow = input.workflow;
601
- if (input.params) {
602
- obj.params = parseJsonObjectArg(input.params);
603
- }
604
- if (input.timeoutMs !== undefined)
605
- obj.timeoutMs = input.timeoutMs;
989
+ obj.uses = input.workflow;
990
+ if (input.params)
991
+ obj.with = parseJsonObjectArg(input.params);
606
992
  }
607
993
  else if (input.prompt) {
608
- obj.prompt = input.prompt;
609
- if (input.engine)
610
- obj.engine = input.engine;
611
- if (input.model)
612
- obj.model = input.model;
613
- if (input.timeoutMs !== undefined)
614
- obj.timeoutMs = input.timeoutMs;
994
+ obj.uses = "akm/command";
995
+ obj.with = { content: input.prompt };
615
996
  }
616
997
  else if (input.command !== undefined) {
617
- // Emit a string when given a string, an array when given an array. The
618
- // parser accepts both forms; preserving the caller's shape keeps the YAML
619
- // ergonomic for humans editing the file later.
620
- obj.command = input.command;
621
- if (input.timeoutMs !== undefined)
622
- obj.timeoutMs = input.timeoutMs;
998
+ if (Array.isArray(input.command)) {
999
+ throw new UsageError("Task v3 --command accepts one shell string; argv arrays require manual migration.", "INVALID_FLAG_VALUE");
1000
+ }
1001
+ obj.run = input.command;
623
1002
  }
624
1003
  if (input.name)
625
1004
  obj.name = input.name;
626
- if (input.description)
627
- obj.description = input.description;
628
- if (input.when_to_use)
629
- obj.when_to_use = input.when_to_use;
630
- if (input.tags && input.tags.length > 0)
631
- obj.tags = input.tags;
1005
+ obj.akm = {
1006
+ schedule: input.schedule,
1007
+ enabled: input.enabled,
1008
+ ...(input.description !== undefined ? { description: input.description } : {}),
1009
+ ...(input.when_to_use !== undefined ? { when_to_use: input.when_to_use } : {}),
1010
+ ...(input.tags && input.tags.length > 0 ? { tags: input.tags } : {}),
1011
+ ...(input.engine !== undefined ? { engine: input.engine } : {}),
1012
+ ...(input.model !== undefined ? { model: input.model } : {}),
1013
+ ...(input.timeoutMs !== undefined ? { timeout: input.timeoutMs } : {}),
1014
+ };
632
1015
  return yamlStringify(obj);
633
1016
  }
1017
+ function assertInlineTaskPrompt(input) {
1018
+ const value = input.trim();
1019
+ const pathShaped = /^(?:\.{1,2}[\\/]|~[\\/]|[\\/]|[A-Za-z]:[\\/])/.test(value) ||
1020
+ (!/\s/.test(value) && /[\\/]/.test(value) && path.extname(value) !== "");
1021
+ if (!isFullRefInput(value) && !pathShaped)
1022
+ return;
1023
+ throw new UsageError("--prompt accepts inline text only; asset refs and file paths are not prompt content. Use --workflow or an authored command ref where appropriate.", "INVALID_FLAG_VALUE");
1024
+ }
634
1025
  function parseJsonObjectArg(raw) {
635
1026
  let parsed;
636
1027
  try {
@@ -645,7 +1036,7 @@ function parseJsonObjectArg(raw) {
645
1036
  return parsed;
646
1037
  }
647
1038
  /**
648
- * Toggle the `enabled:` value in a task YAML file in-place without a full
1039
+ * Toggle the v3 `akm.enabled:` value in a task YAML file without a full
649
1040
  * parse/render round-trip (which would reformat the file). Appends the key
650
1041
  * if absent.
651
1042
  *
@@ -653,40 +1044,68 @@ function parseJsonObjectArg(raw) {
653
1044
  * case-sensitive matching (YAML keys are case-sensitive).
654
1045
  */
655
1046
  export function setEnabledInYaml(yaml, enabled) {
656
- // Match: key prefix (group 1), value (group 2), optional trailing comment (group 3)
657
- const pattern = /^(enabled:\s*)([^\s#\r\n][^\r\n]*?)(\s*(?:#[^\r\n]*))?$/m;
658
- if (pattern.test(yaml)) {
659
- return yaml.replace(pattern, `$1${enabled}$3`);
660
- }
661
- // Handle the case where enabled: has no value yet (bare key)
662
- const simplePattern = /^(enabled:)\s*$/m;
663
- if (simplePattern.test(yaml)) {
664
- return yaml.replace(simplePattern, `$1 ${enabled}`);
1047
+ const lines = yaml.replace(/\r\n/g, "\n").split("\n");
1048
+ const akmLine = lines.findIndex((line) => /^akm:\s*(?:#.*)?$/.test(line));
1049
+ if (akmLine < 0)
1050
+ return `${yaml.trimEnd()}\nakm:\n enabled: ${enabled}\n`;
1051
+ let insertAt = akmLine + 1;
1052
+ for (let index = akmLine + 1; index < lines.length; index += 1) {
1053
+ const line = lines[index];
1054
+ if (line === undefined)
1055
+ break;
1056
+ if (line !== "" && !/^[ \t]/.test(line))
1057
+ break;
1058
+ insertAt = index + 1;
1059
+ const match = line.match(/^([ \t]+enabled:\s*)([^\s#\r\n][^\r\n]*?)(\s*(?:#[^\r\n]*))?$/);
1060
+ if (match) {
1061
+ lines[index] = `${match[1]}${enabled}${match[3] ?? ""}`;
1062
+ return `${lines.join("\n").trimEnd()}\n`;
1063
+ }
1064
+ const bare = line.match(/^([ \t]+enabled:)\s*$/);
1065
+ if (bare) {
1066
+ lines[index] = `${bare[1]} ${enabled}`;
1067
+ return `${lines.join("\n").trimEnd()}\n`;
1068
+ }
665
1069
  }
666
- return `${yaml.trimEnd()}\nenabled: ${enabled}\n`;
1070
+ lines.splice(insertAt, 0, ` enabled: ${enabled}`);
1071
+ return `${lines.join("\n").trimEnd()}\n`;
667
1072
  }
668
1073
  // Re-exported so tests can verify the validator path directly.
669
1074
  // Re-export error classes consumed by callers that want to instanceof-check.
670
1075
  // Re-export this so the CLI can decide what process exit code to use after
671
1076
  // `akm task run` completes.
672
- export { ConfigError, exitCodeForStatus, NotFoundError, parseTaskDocument, UsageError };
673
- // Accept a bare task id or the canonical `[bundle//]tasks/<id>` ref.
1077
+ export { ConfigError, exitCodeForStatus, NotFoundError, UsageError };
1078
+ // Parse only the bundle/ref syntax here. Whether a concept is a task is an
1079
+ // adapter-specific decision made after resolving the selected bundle.
674
1080
  export function parseTaskRef(input) {
675
1081
  const trimmed = input.trim();
676
- // Canonical conceptId form: `[bundle//]tasks/<id>`. A `/` unambiguously marks
677
- // it — a bare task id can never contain `/` (`validateTaskId` forbids it) — so
678
- // route it through the shared parser, which strips any bundle prefix and maps
679
- // the `tasks/` stash-subdir back to the `task` type in one place.
680
1082
  if (trimmed.includes("/")) {
681
1083
  try {
682
- const parsed = parseRefInput(trimmed);
683
- if (parsed.type === "task")
684
- return { id: normaliseTaskId(parsed.name) };
1084
+ const parsed = parseBundleRef(trimmed);
1085
+ if (parsed.fragment !== undefined)
1086
+ throw new Error("task refs do not accept fragments");
1087
+ return {
1088
+ id: normaliseTaskConceptId(parsed.conceptId),
1089
+ ...(parsed.bundle ? { bundle: parsed.bundle } : {}),
1090
+ };
685
1091
  }
686
1092
  catch {
687
1093
  // fall through to the shared error below
688
1094
  }
689
- throw new UsageError(`Expected a task id or tasks/<id> ref, got "${input}".`, "INVALID_FLAG_VALUE");
1095
+ throw new UsageError(`Expected a syntactically valid task concept id, got "${input}".`, "INVALID_FLAG_VALUE");
690
1096
  }
691
1097
  return { id: normaliseTaskId(trimmed) };
692
1098
  }
1099
+ function taskIdForAdapter(parsedId, adapterId) {
1100
+ if (adapterId === "akm-task")
1101
+ return normaliseTaskConceptId(parsedId);
1102
+ if (adapterId === "akm") {
1103
+ if (!parsedId.includes("/"))
1104
+ return normaliseTaskId(parsedId);
1105
+ if (parsedId.startsWith("tasks/") && !parsedId.slice("tasks/".length).includes("/")) {
1106
+ return normaliseTaskId(parsedId.slice("tasks/".length));
1107
+ }
1108
+ throw new UsageError(`The native akm adapter accepts only a bare task id or tasks/<id>, got "${parsedId}".`, "INVALID_FLAG_VALUE");
1109
+ }
1110
+ throw new UsageError(`Bundle adapter "${adapterId}" does not define task runtime identity.`, "INVALID_FLAG_VALUE");
1111
+ }