@rse/ase 1.0.7 → 2.0.0

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 (279) hide show
  1. package/dst/ase-agent-definition-api.d.ts +184 -0
  2. package/dst/ase-agent-definition-api.js +6 -0
  3. package/dst/ase-agent-definition-builtin/condenser.js +42 -0
  4. package/dst/ase-agent-definition-builtin/editor.js +45 -0
  5. package/dst/ase-agent-definition-builtin/estimator.js +98 -0
  6. package/dst/ase-agent-definition-builtin/implementer.js +121 -0
  7. package/dst/ase-agent-definition-builtin/integrator.js +56 -0
  8. package/dst/ase-agent-definition-builtin/qualifier.js +61 -0
  9. package/dst/ase-agent-definition-builtin/rebooter.js +41 -0
  10. package/dst/ase-agent-definition-builtin/researcher.js +77 -0
  11. package/dst/ase-agent-definition-core.js +827 -0
  12. package/dst/ase-agent-fleet.js +772 -0
  13. package/dst/ase-agent-process.js +232 -0
  14. package/dst/ase-agent-relay-client.js +509 -0
  15. package/dst/ase-agent-relay-core.js +213 -0
  16. package/dst/ase-agent-relay-server-bind.js +242 -0
  17. package/dst/ase-agent-relay-server-cli.js +50 -0
  18. package/dst/ase-agent-runtime-claim.js +189 -0
  19. package/dst/ase-agent-runtime-engine.js +1100 -0
  20. package/dst/ase-agent-runtime-exec.js +275 -0
  21. package/dst/ase-agent-runtime-phase.js +581 -0
  22. package/dst/ase-agent-runtime-request.js +272 -0
  23. package/dst/ase-agent-runtime-tool.js +207 -0
  24. package/dst/ase-agent-runtime.js +1316 -0
  25. package/dst/ase-agent.js +65 -0
  26. package/dst/ase-artifact.js +6 -5
  27. package/dst/ase-config-core.js +465 -0
  28. package/dst/ase-config-schema.js +351 -0
  29. package/dst/ase-config-scope.js +172 -0
  30. package/dst/ase-config.js +25 -585
  31. package/dst/ase-hook.js +228 -111
  32. package/dst/ase-lib-daemon-cli.js +130 -0
  33. package/dst/ase-lib-hash.js +20 -0
  34. package/dst/{ase-log.js → ase-lib-log.js} +40 -7
  35. package/dst/ase-lib-server-bind.js +115 -0
  36. package/dst/ase-lib-server-cli.js +474 -0
  37. package/dst/{ase-table.js → ase-lib-table.js} +1 -1
  38. package/dst/{ase-version.js → ase-lib-version.js} +3 -0
  39. package/dst/ase-man.js +96 -0
  40. package/dst/ase-mcp.js +166 -13
  41. package/dst/{ase-getopt.js → ase-service-getopt.js} +82 -80
  42. package/dst/{ase-markdown.js → ase-service-markdown.js} +18 -11
  43. package/dst/ase-service-otel.js +1246 -0
  44. package/dst/{ase-skills.js → ase-service-skills.js} +29 -64
  45. package/dst/ase-service-state.js +101 -0
  46. package/dst/ase-service.js +251 -76
  47. package/dst/ase-setup-common.js +141 -0
  48. package/dst/ase-setup-mcp.js +359 -0
  49. package/dst/ase-setup-settings.js +837 -0
  50. package/dst/ase-setup.js +113 -869
  51. package/dst/ase-spec.js +31 -19
  52. package/dst/ase-statusline.js +108 -43
  53. package/dst/ase-task-board-core.js +739 -0
  54. package/dst/ase-task-board-filter.js +127 -0
  55. package/dst/ase-task-board-flow.js +237 -0
  56. package/dst/ase-task-board-graph.js +497 -0
  57. package/dst/ase-task-board-stats.js +263 -0
  58. package/dst/ase-task-board-tui-control.js +775 -0
  59. package/dst/ase-task-board-tui-model.js +1008 -0
  60. package/dst/ase-task-board-tui-popup.js +626 -0
  61. package/dst/ase-task-board-tui-style.js +106 -0
  62. package/dst/ase-task-board-tui-view.js +847 -0
  63. package/dst/ase-task-board-tui.js +189 -0
  64. package/dst/ase-task-board-web-client.css +1 -0
  65. package/dst/ase-task-board-web-client.html +20 -0
  66. package/dst/ase-task-board-web-client.js +81 -0
  67. package/dst/ase-task-board-web-server.js +823 -0
  68. package/dst/ase-task-board.js +134 -0
  69. package/dst/ase-task-format.js +757 -0
  70. package/dst/ase-task-request.js +208 -0
  71. package/dst/ase-task-store-client.js +495 -0
  72. package/dst/ase-task-store-core.js +804 -0
  73. package/dst/ase-task-store-issues.js +237 -0
  74. package/dst/ase-task-store-plugin-api.js +6 -0
  75. package/dst/ase-task-store-plugin-ase.js +484 -0
  76. package/dst/ase-task-store-plugin-delegate.js +137 -0
  77. package/dst/ase-task-store-plugin-gitea.js +666 -0
  78. package/dst/ase-task-store-plugin-github.js +696 -0
  79. package/dst/ase-task-store-plugin-gitlab.js +668 -0
  80. package/dst/ase-task-store-server-bind.js +538 -0
  81. package/dst/ase-task-store-server-cli.js +154 -0
  82. package/dst/ase-task.js +1335 -357
  83. package/dst/{ase-compat.js → ase-util-compat.js} +2 -2
  84. package/dst/{ase-diagram.js → ase-util-diagram.js} +107 -32
  85. package/dst/{ase-meta.js → ase-util-meta.js} +2 -2
  86. package/dst/{ase-metric.js → ase-util-metric.js} +2 -2
  87. package/dst/{ase-mint.js → ase-util-mint.js} +19 -12
  88. package/dst/{ase-worktree.js → ase-util-worktree.js} +30 -19
  89. package/dst/ase-util.js +20 -0
  90. package/dst/ase.js +108 -32
  91. package/dst/man/ase-agent-adhoc.1 +515 -0
  92. package/dst/man/ase-agent-condenser.1 +193 -0
  93. package/dst/man/ase-agent-editor.1 +212 -0
  94. package/dst/man/ase-agent-estimator.1 +214 -0
  95. package/dst/man/ase-agent-fleet.1 +185 -0
  96. package/dst/man/ase-agent-implementer.1 +233 -0
  97. package/dst/man/ase-agent-integrator.1 +213 -0
  98. package/dst/man/ase-agent-qualifier.1 +214 -0
  99. package/dst/man/ase-agent-rebooter.1 +193 -0
  100. package/dst/man/ase-agent-relay.1 +128 -0
  101. package/dst/man/ase-agent-researcher.1 +217 -0
  102. package/dst/man/ase-agent.1 +264 -0
  103. package/dst/man/ase-artifact-list.1 +38 -0
  104. package/dst/man/ase-artifact-name.1 +39 -0
  105. package/dst/man/ase-artifact.1 +56 -0
  106. package/dst/man/ase-config-delete.1 +45 -0
  107. package/dst/man/ase-config-edit.1 +48 -0
  108. package/dst/man/ase-config-get.1 +46 -0
  109. package/dst/man/ase-config-init.1 +60 -0
  110. package/dst/man/ase-config-list.1 +42 -0
  111. package/dst/man/ase-config-set.1 +52 -0
  112. package/dst/man/ase-config.1 +225 -0
  113. package/dst/man/ase-hook-permission-request.1 +36 -0
  114. package/dst/man/ase-hook-pre-tool-use.1 +59 -0
  115. package/dst/man/ase-hook-session-end.1 +47 -0
  116. package/dst/man/ase-hook-session-persona.1 +37 -0
  117. package/dst/man/ase-hook-session-start.1 +81 -0
  118. package/dst/man/ase-hook-stop.1 +34 -0
  119. package/dst/man/ase-hook-user-prompt-submit.1 +34 -0
  120. package/dst/man/ase-hook.1 +51 -0
  121. package/dst/man/ase-man.1 +67 -0
  122. package/dst/man/ase-mcp.1 +61 -0
  123. package/dst/man/ase-service-log.1 +43 -0
  124. package/dst/man/ase-service-send.1 +47 -0
  125. package/dst/man/ase-service-start.1 +54 -0
  126. package/dst/man/ase-service-status.1 +46 -0
  127. package/dst/man/ase-service-stop.1 +37 -0
  128. package/dst/man/ase-service.1 +85 -0
  129. package/dst/man/ase-setup-disable.1 +44 -0
  130. package/dst/man/ase-setup-enable.1 +45 -0
  131. package/dst/man/ase-setup-install.1 +73 -0
  132. package/dst/man/ase-setup-mcp.1 +124 -0
  133. package/dst/man/ase-setup-status.1 +60 -0
  134. package/dst/man/ase-setup-statusline.1 +92 -0
  135. package/dst/man/ase-setup-telemetry.1 +92 -0
  136. package/dst/man/ase-setup-uninstall.1 +59 -0
  137. package/dst/man/ase-setup-update.1 +64 -0
  138. package/dst/man/ase-setup.1 +93 -0
  139. package/dst/man/ase-spec-export.1 +62 -0
  140. package/dst/man/ase-spec-lint.1 +40 -0
  141. package/dst/man/ase-spec-preview.1 +40 -0
  142. package/dst/man/ase-spec.1 +46 -0
  143. package/dst/man/ase-statusline.1 +189 -0
  144. package/dst/man/ase-task-assign.1 +71 -0
  145. package/dst/man/ase-task-board.1 +384 -0
  146. package/dst/man/ase-task-delete.1 +35 -0
  147. package/dst/man/ase-task-edit.1 +46 -0
  148. package/dst/man/ase-task-idscheme.1 +65 -0
  149. package/dst/man/ase-task-lifecycle.1 +57 -0
  150. package/dst/man/ase-task-list.1 +47 -0
  151. package/dst/man/ase-task-load.1 +36 -0
  152. package/dst/man/ase-task-newid.1 +61 -0
  153. package/dst/man/ase-task-purge.1 +37 -0
  154. package/dst/man/ase-task-rename.1 +44 -0
  155. package/dst/man/ase-task-request.1 +61 -0
  156. package/dst/man/ase-task-save.1 +48 -0
  157. package/dst/man/ase-task-status.1 +55 -0
  158. package/dst/man/ase-task-store.1 +194 -0
  159. package/dst/man/ase-task-view.1 +40 -0
  160. package/dst/man/ase-task.1 +160 -0
  161. package/dst/man/ase-util-compat.1 +25 -0
  162. package/dst/man/ase-util-diagram.1 +76 -0
  163. package/dst/man/ase-util-meta.1 +45 -0
  164. package/dst/man/ase-util-metric.1 +54 -0
  165. package/dst/man/ase-util-mint.1 +59 -0
  166. package/dst/man/ase-util-worktree.1 +72 -0
  167. package/dst/man/ase-util.1 +50 -0
  168. package/dst/man/ase.1 +124 -0
  169. package/package.json +79 -21
  170. package/plugin/.claude-plugin/plugin.json +1 -1
  171. package/plugin/.codex-plugin/plugin.json +1 -1
  172. package/plugin/.github/plugin/plugin.json +1 -1
  173. package/plugin/agents/ase-meta-diagram.md +12 -8
  174. package/plugin/agents/ase-repo-review.md +322 -0
  175. package/plugin/hooks/hooks-codex.json +5 -0
  176. package/plugin/hooks/hooks-copilot.json +5 -0
  177. package/plugin/hooks/hooks.json +8 -0
  178. package/plugin/meta/ase-common-approach.md +117 -0
  179. package/plugin/meta/ase-common-changeset.md +256 -0
  180. package/plugin/meta/ase-common-code.md +10 -173
  181. package/plugin/meta/ase-common-grill.md +317 -0
  182. package/plugin/meta/ase-common-resolve.md +305 -0
  183. package/plugin/meta/ase-common-task.md +83 -4
  184. package/plugin/meta/ase-constitution.md +0 -2
  185. package/plugin/meta/ase-format-specbook.md +208 -25
  186. package/plugin/meta/ase-format-specbook.yaml +242 -851
  187. package/plugin/meta/ase-format-task.md +669 -125
  188. package/plugin/meta/ase-persona.md +3 -4
  189. package/plugin/meta/ase-skill.md +9 -0
  190. package/plugin/mods/ase-mods.ts +409 -0
  191. package/plugin/mods/claude-code.d.ts +13417 -0
  192. package/plugin/skills/ase-arch-analyze/SKILL.md +1 -1
  193. package/plugin/skills/ase-arch-analyze/help.md +2 -2
  194. package/plugin/skills/ase-code-analyze/SKILL.md +59 -8
  195. package/plugin/skills/ase-code-analyze/help.md +29 -4
  196. package/plugin/skills/ase-code-edit/SKILL.md +107 -181
  197. package/plugin/skills/ase-code-edit/help.md +118 -31
  198. package/plugin/skills/ase-code-lint/SKILL.md +64 -13
  199. package/plugin/skills/ase-code-lint/help.md +32 -2
  200. package/plugin/skills/ase-docs-proofread/SKILL.md +4 -4
  201. package/plugin/skills/ase-docs-proofread/help.md +1 -1
  202. package/plugin/skills/ase-docs-refine/SKILL.md +4 -4
  203. package/plugin/skills/ase-docs-shorten/SKILL.md +4 -4
  204. package/plugin/skills/ase-help-intent/data.md +2164 -1087
  205. package/plugin/skills/ase-help-intent/help.md +1 -1
  206. package/plugin/skills/ase-help-skill/catalog.md +12 -9
  207. package/plugin/skills/ase-meta-brainstorm/SKILL.md +1 -1
  208. package/plugin/skills/ase-meta-config/SKILL.md +3 -4
  209. package/plugin/skills/ase-meta-config/help.md +31 -10
  210. package/plugin/skills/ase-meta-mint/SKILL.md +5 -5
  211. package/plugin/skills/ase-meta-mint/help.md +10 -5
  212. package/plugin/skills/ase-meta-why/help.md +1 -1
  213. package/plugin/skills/ase-meta-workflow/SKILL.md +7 -7
  214. package/plugin/skills/ase-meta-workflow/help.md +4 -4
  215. package/plugin/skills/ase-meta-workflow/sample.md +3 -3
  216. package/plugin/skills/ase-meta-workflow/workflow.txt +22 -18
  217. package/plugin/skills/{ase-meta-changelog → ase-repo-changelog}/SKILL.md +4 -3
  218. package/plugin/skills/{ase-meta-changelog → ase-repo-changelog}/help.md +5 -5
  219. package/plugin/skills/{ase-meta-commit → ase-repo-commit}/SKILL.md +6 -6
  220. package/plugin/skills/{ase-meta-commit → ase-repo-commit}/help.md +5 -5
  221. package/plugin/skills/{ase-meta-diff → ase-repo-diff}/SKILL.md +6 -6
  222. package/plugin/skills/{ase-meta-diff → ase-repo-diff}/help.md +8 -8
  223. package/plugin/skills/{ase-code-dissect → ase-repo-dissect}/SKILL.md +19 -19
  224. package/plugin/skills/{ase-code-dissect → ase-repo-dissect}/help.md +10 -12
  225. package/plugin/skills/ase-repo-merge/SKILL.md +346 -0
  226. package/plugin/skills/ase-repo-merge/help.md +107 -0
  227. package/plugin/skills/ase-repo-resolve/SKILL.md +158 -0
  228. package/plugin/skills/ase-repo-resolve/help.md +102 -0
  229. package/plugin/skills/ase-repo-review/SKILL.md +406 -0
  230. package/plugin/skills/ase-repo-review/help.md +181 -0
  231. package/plugin/skills/ase-spec-edit/SKILL.md +117 -183
  232. package/plugin/skills/ase-spec-edit/help.md +107 -26
  233. package/plugin/skills/ase-sync-reconcile/help.md +1 -1
  234. package/plugin/skills/ase-task-assign/SKILL.md +111 -0
  235. package/plugin/skills/ase-task-assign/help.md +119 -0
  236. package/plugin/skills/ase-task-condense/SKILL.md +21 -21
  237. package/plugin/skills/ase-task-condense/help.md +12 -10
  238. package/plugin/skills/ase-task-delete/SKILL.md +1 -1
  239. package/plugin/skills/ase-task-dissect/SKILL.md +71 -48
  240. package/plugin/skills/ase-task-dissect/help.md +10 -9
  241. package/plugin/skills/ase-task-edit/SKILL.md +379 -94
  242. package/plugin/skills/ase-task-edit/help.md +95 -22
  243. package/plugin/skills/ase-task-estimate/SKILL.md +492 -0
  244. package/plugin/skills/ase-task-estimate/help.md +158 -0
  245. package/plugin/skills/ase-task-grill/SKILL.md +246 -138
  246. package/plugin/skills/ase-task-grill/help.md +129 -26
  247. package/plugin/skills/ase-task-id/SKILL.md +8 -1
  248. package/plugin/skills/ase-task-id/help.md +6 -3
  249. package/plugin/skills/ase-task-implement/SKILL.md +535 -145
  250. package/plugin/skills/ase-task-implement/help.md +138 -47
  251. package/plugin/skills/ase-task-integrate/SKILL.md +549 -0
  252. package/plugin/skills/ase-task-integrate/help.md +187 -0
  253. package/plugin/skills/ase-task-list/SKILL.md +55 -23
  254. package/plugin/skills/ase-task-list/help.md +45 -21
  255. package/plugin/skills/ase-task-reboot/SKILL.md +84 -43
  256. package/plugin/skills/ase-task-reboot/help.md +28 -8
  257. package/plugin/skills/ase-task-rename/SKILL.md +10 -2
  258. package/plugin/skills/ase-task-research/SKILL.md +386 -0
  259. package/plugin/skills/ase-task-research/help.md +102 -0
  260. package/plugin/skills/ase-task-status/SKILL.md +137 -0
  261. package/plugin/skills/ase-task-status/help.md +80 -0
  262. package/plugin/skills/ase-task-view/SKILL.md +49 -24
  263. package/plugin/skills/ase-task-view/help.md +19 -9
  264. package/plugin/agents/ase-meta-review.md +0 -187
  265. package/plugin/skills/ase-code-craft/SKILL.md +0 -264
  266. package/plugin/skills/ase-code-craft/help.md +0 -122
  267. package/plugin/skills/ase-code-refactor/SKILL.md +0 -232
  268. package/plugin/skills/ase-code-refactor/help.md +0 -109
  269. package/plugin/skills/ase-code-resolve/SKILL.md +0 -289
  270. package/plugin/skills/ase-code-resolve/help.md +0 -116
  271. package/plugin/skills/ase-meta-review/SKILL.md +0 -210
  272. package/plugin/skills/ase-meta-review/help.md +0 -96
  273. package/plugin/skills/ase-task-preflight/SKILL.md +0 -166
  274. package/plugin/skills/ase-task-preflight/help.md +0 -82
  275. /package/dst/{ase-ignore.js → ase-lib-ignore.js} +0 -0
  276. /package/dst/{ase-stdio.js → ase-lib-stdio.js} +0 -0
  277. /package/dst/{ase-kv.js → ase-service-kv.js} +0 -0
  278. /package/dst/{ase-sleep.js → ase-service-sleep.js} +0 -0
  279. /package/dst/{ase-timestamp.js → ase-service-timestamp.js} +0 -0
package/dst/ase-task.js CHANGED
@@ -5,37 +5,46 @@
5
5
  */
6
6
  import path from "node:path";
7
7
  import fs from "node:fs";
8
+ import os from "node:os";
9
+ import readline from "node:readline/promises";
8
10
  import { execaSync } from "execa";
9
- import { DateTime } from "luxon";
10
- import picomatch from "picomatch";
11
11
  import { isScalar } from "yaml";
12
+ import { DateTime } from "luxon";
12
13
  import { z } from "zod";
13
14
  import { LRUCache } from "lru-cache";
14
- import { Config, configSchema, parseScope } from "./ase-config.js";
15
- import { Markdown } from "./ase-markdown.js";
16
- import { readStdin, writeStdout } from "./ase-stdio.js";
17
- /* the lifecycle states a task plan can be in, i.e., the accepted
18
- values of the "Status:" frontmatter key of a task plan */
19
- export const taskStates = [
20
- "DRAFTED", "REJECTED", "APPROVED", "DEFERRED",
21
- "STARTED", "BLOCKED", "COMPLETED", "CANCELLED"
22
- ];
23
- /* reusable functionality: persisted task plans under
24
- <project>/<basedir>/TASK-<id>.md (driven by the
25
- "project.artifact.task.{basedir,files}" configuration) */
15
+ import { Config, sessionState } from "./ase-config-core.js";
16
+ import { configSchema } from "./ase-config-schema.js";
17
+ import { parseScope, userStateDir, projectIdOf } from "./ase-config-scope.js";
18
+ import { Markdown } from "./ase-service-markdown.js";
19
+ import { readStdin, writeStdout } from "./ase-lib-stdio.js";
20
+ import TaskStoreCommand, { storeSchema } from "./ase-task-store-server-cli.js";
21
+ import { urlHost, isLoopback } from "./ase-lib-server-bind.js";
22
+ import * as Core from "./ase-task-store-core.js";
23
+ import * as Delegate from "./ase-task-store-plugin-delegate.js";
24
+ import * as TaskFormat from "./ase-task-format.js";
25
+ import { LocalTaskStoreClient, RemoteTaskStoreClient } from "./ase-task-store-client.js";
26
+ import { requestsOf } from "./ase-task-request.js";
27
+ /* the failure of a booking whose save might have been done nevertheless (e.g. on a lost
28
+ response, a timeout, or a partially done save of an issue tracker), so the booking must
29
+ not be retried, as it would then be booked twice */
30
+ export class UsageUncertain extends Error {
31
+ }
32
+ /* reusable functionality: the task plans of the current project,
33
+ forwarded to the task store selected by the "project.task.store.url"
34
+ configuration URL */
26
35
  export class Task {
27
36
  /* validate the task id to keep it safe as a filename component */
28
37
  static validateId(id) {
29
38
  if (typeof id !== "string" || id.length === 0)
30
39
  throw new Error("task: id must be a non-empty string");
31
- if (!/^[A-Za-z0-9_-]+$/.test(id))
32
- throw new Error("task: id must match [A-Za-z0-9_-]+");
40
+ if (!TaskFormat.TASK_ID_RE.test(id))
41
+ throw new Error("task: id must match [A-Za-z0-9#][A-Za-z0-9#_-]*");
33
42
  }
34
43
  /* validate the session id to keep it safe as a config scope term */
35
44
  static validateSession(session) {
36
45
  if (typeof session !== "string" || session.length === 0)
37
46
  throw new Error("task: session must be a non-empty string");
38
- if (!/^[A-Za-z0-9_-]+$/.test(session))
47
+ if (!TaskFormat.ID_RE.test(session))
39
48
  throw new Error("task: session must match [A-Za-z0-9_-]+");
40
49
  }
41
50
  /* cached project root determination (TTL-bounded, as the ASE
@@ -62,13 +71,30 @@ export class Task {
62
71
  Task.projectRootCache.set(cwd, root);
63
72
  return root;
64
73
  }
65
- /* cached task storage specification (TTL-bounded, mirroring the project
74
+ /* derive the fallback project id from a project root
75
+ (shared by task store, hook, and service) */
76
+ static projectIdOf(root) {
77
+ return projectIdOf(root);
78
+ }
79
+ /* cached task store specification (TTL-bounded, mirroring the project
66
80
  root cache, as each read parses the whole layered YAML config chain) */
67
81
  static specCache = new LRUCache({ max: 4, ttl: 2 * 1000 });
68
- /* read the configured "basedir" anchor and "files" miniglob spec for
69
- task storage; "basedir" is project-root-relative (POSIX, defaults
70
- to ".ase/task") and "files" constrains the task filenames
71
- (defaults to "*.md") */
82
+ /* the configuration files the task store specification is read from
83
+ (for read-only consumers watching them), and the invalidation of the
84
+ cached specification after a change of them */
85
+ static configFiles(log) {
86
+ const cfg = new Config("config", configSchema, log);
87
+ cfg.read();
88
+ return cfg.files();
89
+ }
90
+ static invalidate() {
91
+ Task.specCache.clear();
92
+ }
93
+ /* read the "project.id" of the project (defaulting to the sanitized
94
+ basename of the project root), the "project.task.store.url" URL (defaulting to
95
+ "ase:./.ase/task") and "project.task.store.token" (both with their
96
+ supplying scope), the "project.task.lifecycle" model (defaulting
97
+ to "solo"), and the "project.task.idscheme" scheme (defaulting to "slug") */
72
98
  static spec(log) {
73
99
  const root = Task.projectRoot();
74
100
  const cached = Task.specCache.get(root);
@@ -82,266 +108,733 @@ export class Task {
82
108
  return "";
83
109
  return String(isScalar(val) ? val.value : val);
84
110
  };
85
- const basedir = (read("project.artifact.task.basedir") || ".ase/task")
86
- .replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
87
- if (basedir.split("/").includes(".."))
88
- throw new Error(`task: configured "basedir" "${basedir}" must not escape the project root`);
89
- const files = read("project.artifact.task.files") || "*.md";
90
- const result = { basedir, files };
111
+ const scoped = (key) => {
112
+ const entry = cfg.getScoped(key);
113
+ if (entry === undefined)
114
+ return { value: "", scope: "" };
115
+ return {
116
+ value: String(isScalar(entry.value) ? entry.value.value : entry.value),
117
+ scope: Config.scopeLabel(entry.scope)
118
+ };
119
+ };
120
+ const explicit = read("project.id");
121
+ if (explicit !== "" && !TaskFormat.ID_RE.test(explicit))
122
+ throw new Error(`task: configured "project.id" "${explicit}" must match [A-Za-z0-9_-]+`);
123
+ /* sanitize a basename-derived project id, as it only keys the local
124
+ solo-mode store (a remote store requires an explicit one anyway) */
125
+ const projectId = explicit || Task.projectIdOf(root);
126
+ const store = scoped("project.task.store.url");
127
+ store.value ||= "ase:./.ase/task";
128
+ const token = scoped("project.task.store.token");
129
+ const name = read("project.task.lifecycle") || "solo";
130
+ const lifecycle = Object.hasOwn(TaskFormat.taskLifecycles, name) ? TaskFormat.taskLifecycles[name] : undefined;
131
+ if (lifecycle === undefined)
132
+ throw new Error(`task: configured "lifecycle" "${name}" must be one of: ` +
133
+ Object.keys(TaskFormat.taskLifecycles).join(", "));
134
+ const idscheme = read("project.task.idscheme") || "slug";
135
+ const error = TaskFormat.checkIdScheme(idscheme);
136
+ if (error !== "")
137
+ throw new Error(`task: configured "project.task.idscheme": ${error}`);
138
+ const result = { projectId, projectIdExplicit: explicit !== "", store, token, lifecycle, idscheme };
91
139
  Task.specCache.set(root, result);
92
140
  return result;
93
141
  }
94
- /* resolve the on-disk base directory for task storage */
95
- static baseDir(log) {
96
- return path.join(Task.projectRoot(), Task.spec(log).basedir);
97
- }
98
- /* ensure a task id's "TASK-<id>.md" filename satisfies
99
- the configured "files" miniglob */
100
- static enforceFiles(log, id) {
101
- const { files } = Task.spec(log);
102
- const filename = `TASK-${id}.md`;
103
- if (!picomatch(files, { dot: true })(filename))
104
- throw new Error(`task: id "${id}" yields filename "${filename}" ` +
105
- `which does not match the configured "files" glob "${files}"`);
106
- }
107
- /* resolve the on-disk path for a given task id; as a side effect,
108
- eagerly migrate any legacy <basedir>/<id>/plan.md files to the
109
- current <basedir>/TASK-<id>.md layout ("migrateAll" is a cheap
110
- no-op once the store is migrated) */
111
- static path(log, id) {
112
- Task.validateId(id);
113
- Task.enforceFiles(log, id);
114
- Task.migrateAll(log);
115
- return path.join(Task.baseDir(log), `TASK-${id}.md`);
116
- }
117
- /* migrate all legacy <basedir>/<id>/plan.md task files to the current
118
- <basedir>/TASK-<id>.md layout; an existing TASK-<id>.md is never
119
- overwritten; the legacy <id>/ directory is removed only if it is
120
- empty afterwards; returns the migrated task ids in lexicographic order */
121
- static migrateAll(log) {
122
- const dir = Task.baseDir(log);
123
- if (!fs.existsSync(dir))
124
- return [];
125
- const migrated = [];
126
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
127
- if (!entry.isDirectory() || !/^[A-Za-z0-9_-]+$/.test(entry.name))
128
- continue;
129
- const id = entry.name;
130
- const oldFile = path.join(dir, id, "plan.md");
131
- const newFile = path.join(dir, `TASK-${id}.md`);
132
- if (!fs.existsSync(oldFile))
133
- continue;
134
- if (fs.existsSync(newFile)) {
135
- log.write("warning", `task: not migrating "${id}": target "TASK-${id}.md" already exists`);
136
- continue;
137
- }
138
- fs.renameSync(oldFile, newFile);
139
- /* drop the legacy directory, but only if it is really empty */
140
- const legacyDir = path.dirname(oldFile);
141
- if (fs.readdirSync(legacyDir).length === 0)
142
- fs.rmdirSync(legacyDir);
143
- else
144
- log.write("warning", `task: keeping non-empty legacy directory "${legacyDir}"`);
145
- migrated.push(id);
142
+ /* resolve the effective task lifecycle model: the configured one for a
143
+ local task store, the one of the registered project for a remote one */
144
+ static lifecycle(log) {
145
+ return Task.with(log, (client) => Promise.resolve(client.lifecycle));
146
+ }
147
+ /* set the lifecycle model of the project in the task store (for a local
148
+ task store via "project.task.lifecycle" on scope "project"); returns
149
+ the name of the previous lifecycle model */
150
+ static async setLifecycle(log, name) {
151
+ if (!Object.hasOwn(TaskFormat.taskLifecycles, name))
152
+ throw new Error(`task: invalid lifecycle model "${name}" ` +
153
+ `(expected one of: ${Object.keys(TaskFormat.taskLifecycles).join(", ")})`);
154
+ return Task.with(log, async (client) => {
155
+ const from = client.lifecycle.name;
156
+ /* invalidate the cached specification, as a local
157
+ task store changed its configuration in any case */
158
+ try {
159
+ await client.setLifecycle(name);
160
+ }
161
+ finally {
162
+ Task.invalidate();
163
+ }
164
+ return from;
165
+ });
166
+ }
167
+ /* resolve the effective task id scheme: the configured one for a
168
+ local task store, the one of the registered project for a remote one */
169
+ static idScheme(log) {
170
+ return Task.with(log, (client) => Promise.resolve(client.idscheme));
171
+ }
172
+ /* set the task id scheme of the project in the task store (for a local
173
+ task store via "project.task.idscheme" on scope "project"); returns the
174
+ previous task id scheme */
175
+ static async setIdScheme(log, spec) {
176
+ const error = TaskFormat.checkIdScheme(spec);
177
+ if (error !== "")
178
+ throw new Error(`task: ${error}`);
179
+ return Task.with(log, async (client) => {
180
+ const from = client.idscheme;
181
+ try {
182
+ await client.setIdScheme(spec);
183
+ }
184
+ finally {
185
+ Task.invalidate();
186
+ }
187
+ return from;
188
+ });
189
+ }
190
+ /* allocate the next free task id according to the effective task id scheme,
191
+ derived from a title (scheme "slug") or a proposed id (scheme "any"),
192
+ by searching all existing task ids plus the additionally taken ones and,
193
+ for scheme "seq", the high-water mark of the removed and allocated ids
194
+ (reserving the allocated one); returns the scheme, the id, and the
195
+ regular expression matching the ids conforming to the scheme */
196
+ static async newId(log, title = "", proposal = "", taken = []) {
197
+ return Task.with(log, async (client) => {
198
+ try {
199
+ const { scheme, id } = await client.newId(title, proposal, taken);
200
+ return { scheme, id, match: TaskFormat.parseIdScheme(scheme).match };
201
+ }
202
+ catch (err) {
203
+ if (err instanceof Core.Problem && err.status === 422)
204
+ throw new Error(`task: ${err.message}`, { cause: err });
205
+ throw err;
206
+ }
207
+ });
208
+ }
209
+ /* determine whether the task store is a remote one (otherwise it
210
+ is operated in-process and follows the local configuration) */
211
+ static isRemote(log) {
212
+ return /^ases?:\/\//.test(Task.spec(log).store.value);
213
+ }
214
+ /* determine whether the task store is a local directory of the
215
+ built-in file-based storage plugin ("ase:<path>") */
216
+ static isLocal(log) {
217
+ return /^ase:(?!\/\/)/.test(Task.spec(log).store.value);
218
+ }
219
+ /* determine the identity of the task store (e.g. for keying state kept
220
+ per task store): its "project.task.store.url" URL, with the directory of
221
+ a local one resolved against the project root, and without the token
222
+ and the query of a remote one */
223
+ static storeKey(log) {
224
+ const store = Task.spec(log).store.value;
225
+ const m = /^ase:(?!\/\/)(.+)$/.exec(store);
226
+ if (m !== null)
227
+ return `ase:${path.resolve(Task.projectRoot(), m[1])}`;
228
+ return store.replace(/^(ases?:\/\/[^/?#]*).*$/, "$1");
229
+ }
230
+ /* resolve the bearer token of a remote task store: the token embedded
231
+ in the URL, else $ASE_TASK_STORE_TOKEN, else "project.task.store.token",
232
+ else the token of the locally started task store server (only if the
233
+ URL addresses it); a token potentially committed or redirected is warned about */
234
+ static token(log, spec, embedded, url) {
235
+ const repoScoped = spec.store.scope === "project" || spec.store.scope.startsWith("task:");
236
+ if (embedded !== undefined) {
237
+ if (repoScoped)
238
+ log.write("warning", `task: token embedded in "project.task.store.url" URL on scope "${spec.store.scope}" ` +
239
+ "might be committed -- use $ASE_TASK_STORE_TOKEN or \"project.task.store.token\" on scope \"user\" instead");
240
+ return embedded;
146
241
  }
147
- migrated.sort((a, b) => a.localeCompare(b));
148
- return migrated;
149
- }
150
- /* the legacy task plan header lines, each mapped onto the
151
- frontmatter key which superseded it */
152
- static legacy = [
153
- { key: "Created", re: /^⎈[ \t]+Created:[ \t]*(.*)$/m },
154
- { key: "Modified", re: /^⚙[ \t]+Modified:[ \t]*(.*)$/m },
155
- { key: "Kind", re: /^☯[ \t]+Kind:[ \t]*(.*)$/m }
156
- ];
157
- /* render a single frontmatter line with a column-aligned key */
158
- static frontLine(key, value) {
159
- return (key + ":").padEnd(12) + value;
160
- }
161
- /* normalize a legacy task plan -- one carrying its metadata in the
162
- "# TASK <id>: <title>" heading and the "⎈"/"⚙"/"☯" glyph header
163
- lines -- into the current Markdown frontmatter shape, so every
164
- consumer sees a single plan shape only; a plan already carrying a
165
- frontmatter block, and any content without a task heading at all,
166
- is passed through verbatim -- in particular, absent optional keys
167
- are never materialized, as they read as their default value */
168
- static normalize(id, text) {
169
- if (text === "" || /^---\r?\n/.test(text))
170
- return text;
171
- const heading = /^#[ \t]+TASK(?:[ \t]+[A-Za-z0-9_-]+)?[ \t]*:[ \t]*(.*)$/m.exec(text);
172
- if (heading === null)
173
- return text;
174
- /* lift the glyph header lines into their frontmatter keys, with
175
- the task id taken from the authoritative filename-derived id */
176
- let body = text.replace(heading[0], "");
177
- const front = [Task.frontLine("Id", id)];
178
- for (const legacy of Task.legacy) {
179
- const m = legacy.re.exec(body);
180
- if (m === null)
181
- continue;
182
- front.push(Task.frontLine(legacy.key, m[1].trim()));
183
- body = body.replace(m[0], "");
242
+ /* warn about a user token being sent to a non-loopback host selected on
243
+ a repository-supplied scope, as a cloned repository could redirect it this way */
244
+ const redirected = () => {
245
+ if (repoScoped && !isLoopback(url.hostname))
246
+ log.write("warning", `task: sending token to non-loopback host "${url.host}" selected by ` +
247
+ `"project.task.store.url" on scope "${spec.store.scope}" -- verify you trust this host` +
248
+ (url.protocol === "ase:" ? " (token is transmitted in plaintext)" : ""));
249
+ };
250
+ const env = process.env.ASE_TASK_STORE_TOKEN;
251
+ if (env !== undefined && env !== "") {
252
+ redirected();
253
+ return env;
184
254
  }
185
- /* re-assemble the plan from the frontmatter block, the reduced
186
- heading, and the body stripped of its now leading blank lines */
187
- return `---\n${front.join("\n")}\n---\n\n` +
188
- `# TASK: ${heading[1].trim()}\n\n` +
189
- body.replace(/^(?:[ \t]*\r?\n)+/, "");
190
- }
191
- /* load a task, normalized into the current Markdown frontmatter
192
- shape; returns empty string if no task exists */
193
- static load(log, id) {
194
- const file = Task.path(log, id);
195
- if (!fs.existsSync(file))
196
- return "";
197
- return Task.normalize(id, fs.readFileSync(file, "utf8"));
255
+ if (spec.token.value !== "") {
256
+ if (spec.token.scope !== "user")
257
+ log.write("warning", `task: "project.task.store.token" found on scope "${spec.token.scope}" ` +
258
+ "-- configure it on scope \"user\" only");
259
+ redirected();
260
+ return spec.token.value;
261
+ }
262
+ /* fall back to the token of the locally started task store server,
263
+ but only if the URL addresses exactly this server */
264
+ const cfg = new Config("store", storeSchema, log, parseScope("user"));
265
+ cfg.read();
266
+ const scalar = (key) => {
267
+ const val = cfg.get(key);
268
+ return val === undefined ? "" : String(isScalar(val) ? val.value : val);
269
+ };
270
+ const tok = scalar("token");
271
+ const address = scalar("address") || "127.0.0.1";
272
+ const wildcard = address === "0.0.0.0" || address === "::";
273
+ const host = urlHost(address).toLowerCase();
274
+ const loopback = isLoopback(url.hostname);
275
+ if (tok !== "" && scalar("port") === url.port
276
+ && (url.hostname.toLowerCase() === host || (loopback && (wildcard || isLoopback(host)))))
277
+ return tok;
278
+ throw new Error(`task: no token for task store "${spec.store.value}" ` +
279
+ "(set $ASE_TASK_STORE_TOKEN, \"project.task.store.token\" on scope \"user\", or \"/<token>\" in the URL)");
280
+ }
281
+ /* warn about task plans being sent to a GitHub/Gitea repository resp. GitLab project selected on a
282
+ repository-supplied scope, once per project root and repository only (persisted across
283
+ processes), so the warning re-appears only if the repository changes */
284
+ static warnRepository(log, plugin, repo, scope) {
285
+ const file = path.join(userStateDir(), `task-${plugin}.json`);
286
+ const root = Task.projectRoot();
287
+ let seen = {};
288
+ try {
289
+ const data = JSON.parse(fs.readFileSync(file, "utf8"));
290
+ if (typeof data === "object" && data !== null && !Array.isArray(data))
291
+ seen = data;
292
+ }
293
+ catch {
294
+ /* no (valid) state yet */
295
+ }
296
+ if (seen[root] === repo)
297
+ return;
298
+ log.write("warning", `task: sending task plans to ${plugin === "gitlab" ? "GitLab project" :
299
+ plugin === "gitea" ? "Gitea repository" : "GitHub repository"} ` +
300
+ `"${repo}" selected by ` +
301
+ `"project.task.store.url" on scope "${scope}" -- verify you trust this repository ` +
302
+ "(reported once per repository only)");
303
+ seen[root] = repo;
304
+ try {
305
+ fs.mkdirSync(path.dirname(file), { recursive: true });
306
+ fs.writeFileSync(file, JSON.stringify(seen, null, 4) + "\n", "utf8");
307
+ }
308
+ catch {
309
+ /* best-effort only (the warning then repeats) */
310
+ }
311
+ }
312
+ /* create the (unopened) client of the configured task store: the
313
+ URL "ase://<addr>:<port>[/<token>]" selects a remote task store
314
+ server via HTTP, "ases://<addr>:<port>[/<token>][?insecure]" via
315
+ HTTPS (optionally without certificate verification),
316
+ "ase:<path>" the built-in storage plugin in-process on <path>
317
+ (resolved relative to the project root), "github:<owner>/<repo>" resp.
318
+ "github+http[s]://<host>/<owner>/<repo>" the built-in GitHub storage plugin
319
+ in-process on the repository (of GitHub resp. the given instance),
320
+ "gitlab:[//<host>/]<namespace>/<project>" resp. "gitlab+http[s]://<host>/<namespace>/<project>"
321
+ the built-in GitLab storage plugin in-process on the project (of the default resp. given
322
+ instance), and
323
+ "gitea+http[s]://<host>/<owner>/<repo>" the built-in Gitea storage plugin
324
+ in-process on the repository of the given instance */
325
+ static client(log) {
326
+ const spec = Task.spec(log);
327
+ const { projectId, store, lifecycle, idscheme } = spec;
328
+ const unsupported = () => new Error(`task: unsupported "project.task.store.url" URL "${store.value}" ` +
329
+ "(expected: \"ase:<path>\", \"ase://<addr>:<port>[/<token>]\", " +
330
+ "\"ases://<addr>:<port>[/<token>][?insecure]\", \"github:<owner>/<repo>\", " +
331
+ "\"github+http[s]://<host>/<owner>/<repo>\", " +
332
+ "\"gitlab:[//<host>/]<namespace>/<project>\", \"gitlab+http[s]://<host>/<namespace>/<project>\", " +
333
+ "or \"gitea+http[s]://<host>/<owner>/<repo>\")");
334
+ let client;
335
+ let m;
336
+ if ((m = /^(ases?):\/\//.exec(store.value)) !== null) {
337
+ /* parse via WHATWG URL, as it supports bracketed IPv6 addresses (e.g. "[::1]")
338
+ and retains the brackets in "hostname" for direct reuse in the HTTP URL */
339
+ const secure = m[1] === "ases";
340
+ let url;
341
+ try {
342
+ url = new URL(store.value);
343
+ }
344
+ catch (_err) {
345
+ throw unsupported();
346
+ }
347
+ if (url.hostname === "" || url.port === "" || url.username !== "" || url.password !== ""
348
+ || url.hash !== "" || (url.search !== "" && !(secure && url.search === "?insecure"))
349
+ || (m = /^(?:\/([^/]+))?$/.exec(url.pathname)) === null)
350
+ throw unsupported();
351
+ const embedded = m[1] !== undefined ? decodeURIComponent(m[1]) : undefined;
352
+ /* require an explicit project id, as a basename-derived one
353
+ likely collides with unrelated projects on a shared server */
354
+ if (!spec.projectIdExplicit)
355
+ throw new Error(`task: remote task store "${url.protocol}//${url.host}" requires an explicit "project.id" ` +
356
+ `(set it via "ase config --scope project set project.id ${projectId}")`);
357
+ client = new RemoteTaskStoreClient(projectId, lifecycle, idscheme, log, `${secure ? "https" : "http"}://${url.hostname}:${url.port}`, Task.token(log, spec, embedded, url), url.search === "?insecure");
358
+ }
359
+ else if ((m = /^ase:(.+)$/.exec(store.value)) !== null) {
360
+ const root = Task.projectRoot();
361
+ const basedir = path.resolve(root, m[1]);
362
+ /* confine a base directory selected by a repository-supplied scope
363
+ ("project" or "task:<id>") to the project root (also through
364
+ symlinks), as a cloned repository could otherwise direct task
365
+ writes anywhere */
366
+ if (store.scope === "project" || store.scope.startsWith("task:")) {
367
+ let existing = basedir;
368
+ while (!fs.existsSync(existing) && path.dirname(existing) !== existing)
369
+ existing = path.dirname(existing);
370
+ const real = path.join(fs.realpathSync(existing), path.relative(existing, basedir));
371
+ const rel = path.relative(fs.realpathSync(root), real);
372
+ if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel))
373
+ throw new Error(`task: "project.task.store.url" "${store.value}" on scope "${store.scope}" ` +
374
+ "must not escape the project root (configure it on scope \"user\" instead)");
375
+ }
376
+ client = new LocalTaskStoreClient(projectId, lifecycle, idscheme, {
377
+ plugin: Delegate.BUILTIN_PLUGIN,
378
+ options: { basedir, solo: true, lifecycle: lifecycle.name, idscheme },
379
+ key: basedir
380
+ }, log);
381
+ }
382
+ else if ((m = /^(github):()([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)$/.exec(store.value)) !== null
383
+ || (m = /^(github)\+(https?:\/\/[A-Za-z0-9.-]+(?::\d+)?)\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)$/.exec(store.value)) !== null
384
+ || (m = /^(gitlab):(?:\/\/([A-Za-z0-9.-]+(?::\d+)?)\/)?([A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+)$/.exec(store.value)) !== null
385
+ || (m = /^(gitlab)\+(https?:\/\/[A-Za-z0-9.-]+(?::\d+)?)\/([A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+)$/.exec(store.value)) !== null
386
+ || (m = /^(gitea)\+(https?:\/\/[A-Za-z0-9.-]+(?::\d+)?)\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)$/.exec(store.value)) !== null) {
387
+ /* warn about task plans being sent to a repository selected on a repository-supplied
388
+ scope, and use the token of "project.task.store.token", else $GITHUB_TOKEN resp. $GH_TOKEN
389
+ (GitHub), $GITLAB_TOKEN (GitLab), or $GITEA_TOKEN (Gitea), and the given host (or
390
+ the given base URL of the "+http[s]" forms), else $GITLAB_HOST (GitLab) */
391
+ const [, plugin, host, repo] = m;
392
+ if (store.scope === "project" || store.scope.startsWith("task:"))
393
+ Task.warnRepository(log, plugin, host ? `${host}/${repo}` : repo, store.scope);
394
+ if (spec.token.value !== "" && spec.token.scope !== "user")
395
+ log.write("warning", `task: "project.task.store.token" found on scope "${spec.token.scope}" ` +
396
+ "-- configure it on scope \"user\" only");
397
+ client = new LocalTaskStoreClient(projectId, lifecycle, idscheme, {
398
+ plugin,
399
+ options: {
400
+ repos: { [projectId]: repo },
401
+ ...(host ? { url: /^https?:\/\//.test(host) ? host : `https://${host}` } : {}),
402
+ ...(spec.token.value !== "" ? { token: spec.token.value } : {})
403
+ },
404
+ key: `${store.value}#${projectId}`
405
+ }, log);
406
+ }
407
+ else
408
+ throw unsupported();
409
+ return client;
410
+ }
411
+ /* run an operation on the opened client of the configured task store */
412
+ static async with(log, op) {
413
+ const client = Task.client(log);
414
+ try {
415
+ await client.open();
416
+ return await op(client);
417
+ }
418
+ finally {
419
+ await client.close();
420
+ }
421
+ }
422
+ /* the textual form of a plan, or the minimal plan of a task id */
423
+ static format(plan) {
424
+ return TaskFormat.formatTaskText(plan);
425
+ }
426
+ static minimal(id) {
427
+ return { header: { Type: TaskFormat.TASK_TYPE, Id: id }, body: "", attachment: [] };
428
+ }
429
+ /* load a task as text, normalized into the current Markdown
430
+ frontmatter shape; returns empty string if no task exists */
431
+ static async load(log, id) {
432
+ Task.validateId(id);
433
+ const plan = await Task.with(log, (client) => client.load(id));
434
+ return plan === null ? "" : TaskFormat.formatTaskText(plan);
198
435
  }
199
- /* save a task as UTF-8 text under the given id into the
200
- <project>/<basedir>/TASK-<id>.md file */
201
- static save(log, id, text) {
436
+ /* load a task as text, optionally with its attachment payloads elided (see
437
+ TaskFormat.elideAttachments), together with its entity tag (for a conditional
438
+ save, always of the complete plan); returns null if no task exists */
439
+ static async source(log, id, elided = false) {
440
+ Task.validateId(id);
441
+ const plan = await Task.with(log, (client) => client.load(id));
442
+ return plan === null ? null : {
443
+ text: TaskFormat.formatTaskText(elided ? TaskFormat.elideAttachments(plan) : plan),
444
+ tag: Core.taskTag(plan)
445
+ };
446
+ }
447
+ /* save a task as text under the given id; throws if its "Status:"
448
+ frontmatter key is unknown to the task lifecycle model or not
449
+ reachable from the previous status, on a conditional save (with
450
+ an entity tag) a problem 412 if the task was changed in the meantime,
451
+ and on a create-only save an error if the task already exists;
452
+ returns a warning (else empty) if the id does not conform to the task id scheme,
453
+ the entity tag of the just-saved plan, and the text of the just-saved plan
454
+ (as sent, re-serialized from its parsed form, i.e., before any
455
+ normalization by the task store, and with its elided attachment
456
+ payloads still elided, while they are restored from the stored
457
+ plan for the save itself); the entity tag
458
+ is the one the task store reports for the plan as stored, and only on a
459
+ task store server predating this report the one of the plan as sent (which
460
+ never is the one of a plan saved by somebody else in the meantime, so a
461
+ conditional save based on it at worst is refused spuriously) */
462
+ static async save(log, id, text, tag, create = false) {
202
463
  if (typeof text !== "string")
203
464
  throw new Error("task: text must be a string");
204
- const file = Task.path(log, id);
205
- fs.mkdirSync(path.dirname(file), { recursive: true });
206
- fs.writeFileSync(file, text, "utf8");
207
- }
208
- /* delete a task by id; removes the single
209
- <project>/<basedir>/TASK-<id>.md file; returns true if a task existed */
210
- static delete(log, id) {
211
- const file = Task.path(log, id);
212
- if (!fs.existsSync(file))
213
- return false;
214
- fs.rmSync(file, { force: true });
215
- return true;
216
- }
217
- /* rename a task by moving its <project>/<basedir>/TASK-<oldId>.md file
218
- to <project>/<basedir>/TASK-<newId>.md; the embedded "Id:"
219
- frontmatter key inside the plan content is rewritten to the new id
220
- (falling back to the "# TASK <id>:" heading of a still legacy,
221
- not yet normalized plan); returns true on success, false if the
222
- source task does not exist; throws if the target id already exists */
223
- static rename(log, oldId, newId) {
224
- const oldFile = Task.path(log, oldId);
225
- const newFile = Task.path(log, newId);
226
- if (!fs.existsSync(oldFile))
227
- return false;
228
- if (fs.existsSync(newFile))
229
- throw new Error(`task: target id "${newId}" already exists`);
230
- const text = fs.readFileSync(oldFile, "utf8");
231
- const updated = /^---\r?\n/.test(text) ?
232
- text.replace(/^(Id:[ \t]*)[A-Za-z0-9_-]+[ \t]*$/m, `$1${newId}`) :
233
- text.replace(/(^#\s+TASK\s+)[A-Za-z0-9_-]+(\s*:)/m, `$1${newId}$2`);
234
- fs.mkdirSync(path.dirname(newFile), { recursive: true });
235
- fs.writeFileSync(newFile, updated, "utf8");
236
- fs.rmSync(oldFile, { force: true });
237
- return true;
238
- }
239
- /* scan the task base directory (after eager migration) for
240
- "TASK-<id>.md" files matching the configured "files" miniglob */
241
- static scan(log) {
242
- Task.migrateAll(log);
243
- const { basedir, files } = Task.spec(log);
244
- const dir = path.join(Task.projectRoot(), basedir);
245
- if (!fs.existsSync(dir))
465
+ Task.validateId(id);
466
+ try {
467
+ return await Task.with(log, async (client) => {
468
+ const sent = TaskFormat.parseTaskText(id, text, client.lifecycle);
469
+ const plan = sent.attachment.some((att) => TaskFormat.isElided(att.Data)) ?
470
+ TaskFormat.restoreAttachments(sent, await client.load(id)) : sent;
471
+ const saved = await client.save(id, plan, tag, create);
472
+ return {
473
+ warning: TaskFormat.idWarning(client.idscheme, id),
474
+ tag: saved ?? Core.taskTag(plan),
475
+ text: TaskFormat.formatTaskText(sent)
476
+ };
477
+ });
478
+ }
479
+ catch (err) {
480
+ if (create && tag === undefined && err instanceof Core.Problem && err.status === 412)
481
+ throw new Error(`task: task "${id}" already exists`, { cause: err });
482
+ throw err;
483
+ }
484
+ }
485
+ /* delete a task by id; returns true if a task existed */
486
+ static async delete(log, id) {
487
+ Task.validateId(id);
488
+ return Task.with(log, (client) => client.delete(id));
489
+ }
490
+ /* rename a task, rewriting its "Id:" frontmatter key, the "After" references
491
+ of all other tasks, and, for an explicit epic, the "Group" references of
492
+ itself and its members; returns null if the source task
493
+ does not exist, else the number of updated referring tasks plus a
494
+ warning (else empty) if the new id does not conform to the task id
495
+ scheme; throws if the target id already exists; the rename is reported
496
+ to the service of the project, so its usage windows follow it */
497
+ static async rename(log, oldId, newId) {
498
+ Task.validateId(oldId);
499
+ Task.validateId(newId);
500
+ let renamed;
501
+ try {
502
+ renamed = await Task.with(log, async (client) => {
503
+ const result = await client.patch(oldId, { id: newId });
504
+ return result === null ? null :
505
+ { warning: TaskFormat.idWarning(client.idscheme, newId), references: result.references ?? 0 };
506
+ });
507
+ }
508
+ catch (err) {
509
+ if (err instanceof Core.Problem && err.status === 409)
510
+ throw new Error(`task: target id "${newId}" already exists`, { cause: err });
511
+ throw err;
512
+ }
513
+ if (renamed !== null) {
514
+ const Usage = await import("./ase-service-otel.js");
515
+ await Usage.UsageTracker.renameRemote(Task.projectRoot(), oldId, newId).catch((err) => {
516
+ log.write("warning", `task: failed to report rename of "${oldId}" to "${newId}" to the ASE service: ` +
517
+ `${err instanceof Error ? err.message : String(err)}`);
518
+ });
519
+ }
520
+ return renamed;
521
+ }
522
+ /* flatten a task plan header into string values (joining the
523
+ array-typed keys "After" and "Tags" with commas) */
524
+ static flatHeader(header) {
525
+ return new Map(Object.entries(header).map(([key, val]) => [key, Array.isArray(val) ? val.join(", ") : val]));
526
+ }
527
+ /* split a task plan into its flattened header keys and its Markdown
528
+ body (without the attachments), for read-only consumers which must
529
+ not parse plans themselves; returns null if no task exists */
530
+ static async parts(log, id) {
531
+ Task.validateId(id);
532
+ const plan = await Task.with(log, (client) => client.load(id));
533
+ return plan === null ? null : { keys: Task.flatHeader(plan.header), body: plan.body };
534
+ }
535
+ /* list the attachments of a task plan, each with its type, description,
536
+ creation and modification timestamps (if any), and either the referenced
537
+ file (relative to a local task store) or the embedded data; returns an
538
+ empty list if no task exists */
539
+ static async attachments(log, id) {
540
+ Task.validateId(id);
541
+ const plan = await Task.with(log, (client) => client.load(id));
542
+ return (plan?.attachment ?? []).filter((a) => a.Type !== undefined).map((a) => ({
543
+ type: a.Type,
544
+ desc: a.Desc ?? "",
545
+ ...(a.Created !== undefined ? { created: a.Created } : {}),
546
+ ...(a.Modified !== undefined ? { modified: a.Modified } : {}),
547
+ ...(a.File !== undefined ? { file: a.File } : {}),
548
+ ...(a.Data !== undefined ? { data: a.Data } : {})
549
+ }));
550
+ }
551
+ /* get the raw content of an attachment of a task plan (its embedded data or
552
+ its referenced file), through the task store; returns null if missing */
553
+ static async attachmentContent(log, id, index) {
554
+ Task.validateId(id);
555
+ return Task.with(log, (client) => client.content(id, index));
556
+ }
557
+ /* add an attachment to a task plan, or on "append" extend the "Data" of its
558
+ last attachment with the same "Type" (see "POST {task}/attachment");
559
+ returns false if no task exists */
560
+ static async attach(log, id, attachment, append = false) {
561
+ Task.validateId(id);
562
+ return Task.with(log, (client) => client.attach(id, attachment, append));
563
+ }
564
+ /* subscribe to the change events of the task store: a remote one
565
+ (reconnecting automatically) or an in-process one (whose storage plugin
566
+ detects the external changes, e.g. by watching its directory or by polling),
567
+ reporting each change of the connection state; returns a function to unsubscribe */
568
+ static subscribe(log, onChange, onState) {
569
+ return Task.client(log).subscribe(onChange, onState);
570
+ }
571
+ /* list all persisted tasks (see list) with their flattened header keys,
572
+ plus the effective task lifecycle model, within a single task store access */
573
+ static async listHeaders(log) {
574
+ return Task.with(log, async (client) => {
575
+ const items = (await client.list("header")).map((item) => ({ ...item, keys: Task.flatHeader(item.header ?? {}) }));
576
+ return { lifecycle: client.lifecycle, items };
577
+ });
578
+ }
579
+ /* list the AI usage accounting (the amounts of the usage attachment and its period)
580
+ of all persisted tasks having one, keyed by task id */
581
+ static async listUsage(log) {
582
+ return Task.with(log, async (client) => new Map((await client.list("usage")).flatMap((item) => item.usage !== undefined ?
583
+ [[item.id, { usage: item.usage, period: item.usagePeriod ?? { created: "", modified: "" } }]] : [])));
584
+ }
585
+ /* get the lifecycle status of a task plan: the "Status:" frontmatter
586
+ key of the plan, falling back to the initial state of the task
587
+ lifecycle model; throws if no task exists */
588
+ static async getStatus(log, id) {
589
+ Task.validateId(id);
590
+ return Task.with(log, async (client) => {
591
+ const plan = await client.load(id);
592
+ if (plan === null)
593
+ throw new Error(`task: no task "${id}"`);
594
+ return TaskFormat.taskStatus(plan.header, client.lifecycle);
595
+ });
596
+ }
597
+ /* set the lifecycle status of a task plan: the (case-insensitively
598
+ given) status has to be a state of the task lifecycle model, while
599
+ the "Modified:" frontmatter key is left alone, as it tracks body
600
+ changes only; returns the previous and the new status; throws if no
601
+ task exists, the status is unknown, or it is not reachable from the
602
+ previous one in the state machine of the model */
603
+ static async setStatus(log, id, status) {
604
+ Task.validateId(id);
605
+ const to = status.trim().toUpperCase();
606
+ return Task.with(log, async (client) => {
607
+ const lifecycle = client.lifecycle;
608
+ if (!lifecycle.states.includes(to))
609
+ throw new Error(`task: invalid state "${status}" ` +
610
+ `(expected one of: ${lifecycle.states.join(", ")})`);
611
+ const result = await client.patch(id, { status: to });
612
+ if (result === null)
613
+ throw new Error(`task: no task "${id}"`);
614
+ return { from: result.from ?? lifecycle.initial, to: result.status };
615
+ });
616
+ }
617
+ /* inspect a task plan by the operation <op>, which derives a result from the
618
+ loaded plan (and the lifecycle model); returns null if no task exists */
619
+ static async inspect(log, id, op) {
620
+ Task.validateId(id);
621
+ return Task.with(log, async (client) => {
622
+ const plan = await client.load(id);
623
+ return plan === null ? null : op(plan, client.lifecycle);
624
+ });
625
+ }
626
+ /* update a task plan by the operation <op>, which modifies the loaded plan in
627
+ place and returns whether it changed it, where the plan is saved conditionally
628
+ (throwing if it was changed meanwhile), and only if changed, where with <usage>
629
+ (i.e., for a booking) the plan is saved conditionally on the complete plan
630
+ (throwing on a concurrent booking, too), as only then the usage attachment of
631
+ the plan is saved and not kept by the task store, and where a booking failing
632
+ once its save was started throws a "UsageUncertain" error, unless the task store
633
+ surely rejected the save (by a client error, like HTTP 412), as it might have
634
+ been done nevertheless; returns false if no task exists */
635
+ static async update(log, id, op, usage = false) {
636
+ Task.validateId(id);
637
+ let saving = false;
638
+ try {
639
+ return await Task.with(log, async (client) => {
640
+ const plan = await client.load(id);
641
+ if (plan === null)
642
+ return false;
643
+ const etag = usage ? Core.taskTagFull(plan) : Core.taskTag(plan);
644
+ if (op(plan, client.lifecycle)) {
645
+ saving = true;
646
+ await client.save(id, plan, etag);
647
+ }
648
+ return true;
649
+ });
650
+ }
651
+ catch (err) {
652
+ if (usage && saving && !(err instanceof Core.Problem && err.status >= 400 && err.status < 500))
653
+ throw new UsageUncertain(err instanceof Error ? err.message : String(err), { cause: err });
654
+ throw err;
655
+ }
656
+ }
657
+ /* update a task plan by the operation <op> in a single conditional save (see "update"),
658
+ retrying (with a growing delay, on a freshly loaded plan) on a failure, especially on a
659
+ concurrent change of the task (HTTP 412), so <op> has to (re)compute all its results,
660
+ where <rejected> is called whenever a save was surely not done (on HTTP 412), unlike
661
+ on other failures (e.g. a lost response), where the save might have been done,
662
+ and <usage> is passed through (retrying a booking on HTTP 412 only, as a retried
663
+ booking whose save might have been done would be booked twice) */
664
+ static async updateRetry(log, id, op, rejected, usage = false) {
665
+ for (let attempt = 1;; attempt++) {
666
+ try {
667
+ return await Task.update(log, id, op, usage);
668
+ }
669
+ catch (err) {
670
+ if (err instanceof Core.Problem && err.status === 412)
671
+ rejected?.();
672
+ else if (usage)
673
+ throw err;
674
+ if (attempt >= 5)
675
+ throw err;
676
+ await new Promise((resolve) => setTimeout(resolve, 100 * attempt));
677
+ }
678
+ }
679
+ }
680
+ /* assign a task plan by setting its "Assignee:" frontmatter key (or deleting it for a
681
+ null assignee) in a conditional save, leaving the "Modified:" key alone, as it tracks
682
+ body changes only; returns the previous assignee (or empty); throws if no task exists */
683
+ static async assign(log, id, assignee) {
684
+ if (assignee !== null && (assignee.trim() === "" || /[\r\n,]/.test(assignee)))
685
+ throw new Error("task: assignee must be a non-empty single-line name without commas");
686
+ let prev = "";
687
+ const exists = await Task.updateRetry(log, id, (plan) => {
688
+ prev = String(plan.header.Assignee ?? "").trim();
689
+ if (assignee === null) {
690
+ if (plan.header.Assignee === undefined)
691
+ return false;
692
+ delete plan.header.Assignee;
693
+ }
694
+ else if (prev === assignee.trim())
695
+ return false;
696
+ else
697
+ plan.header.Assignee = assignee.trim();
698
+ return true;
699
+ });
700
+ if (!exists)
701
+ throw new Error(`task: no task "${id}"`);
702
+ return prev;
703
+ }
704
+ /* the known human assignees of the project ("project.users", comma-separated) */
705
+ static users(log) {
706
+ const cfg = new Config("config", configSchema, log);
707
+ cfg.read();
708
+ const val = cfg.get("project.users");
709
+ if (val === undefined)
710
+ return [];
711
+ return String(isScalar(val) ? val.value : val).split(",").map((u) => u.trim()).filter((u) => u !== "");
712
+ }
713
+ /* the names of the agents currently present at the agent relay
714
+ (none if no agent relay is configured or reachable) */
715
+ static async agentNames(log) {
716
+ try {
717
+ const { relaySpec, AgentRelayClient } = await import("./ase-agent-relay-client.js");
718
+ const spec = relaySpec(log);
719
+ if (spec === null)
720
+ return [];
721
+ const agents = await new AgentRelayClient(log, spec).list();
722
+ return [...new Set(agents.map((a) => a.name))];
723
+ }
724
+ catch {
246
725
  return [];
247
- const isMatch = picomatch(files, { dot: true });
248
- const out = [];
249
- for (const entry of fs.readdirSync(dir)) {
250
- const m = /^TASK-([A-Za-z0-9_-]+)\.md$/.exec(entry);
251
- if (m === null || !isMatch(entry))
252
- continue;
253
- const file = path.join(dir, entry);
254
- const st = fs.statSync(file);
255
- if (!st.isFile())
256
- continue;
257
- out.push({ id: m[1], file, st });
258
726
  }
259
- return out;
260
- }
261
- /* read the "Status:" frontmatter key of a task plan file, falling back
262
- to the "DRAFTED" default of the task plan format for a plan whose
263
- frontmatter is absent or carries no such key */
264
- static status(file) {
265
- const fm = /^---\r?\n([\s\S]*?\r?\n)---\r?\n/.exec(fs.readFileSync(file, "utf8"));
266
- if (fm === null)
267
- return "DRAFTED";
268
- const m = /^Status:[ \t]*(\S+)[ \t]*$/m.exec(fm[1]);
269
- if (m === null)
270
- return "DRAFTED";
271
- return m[1];
272
- }
273
- /* list all persisted tasks in lexicographic id order, each with the
274
- `status` of its plan; if verbose is true, each entry's `mtime` is
275
- set to the task file's modification time formatted as
276
- "YYYY-MM-DD HH:MM", otherwise it is left undefined */
277
- static list(log, verbose = false) {
278
- const out = Task.scan(log).map((entry) => ({
727
+ }
728
+ /* check an assignee against the known ones (the current user, the users of
729
+ "project.users", and the agents present at the agent relay), answering a
730
+ warning for an unknown one (or empty), where an empty "project.users" knows all */
731
+ static async checkAssignee(log, assignee) {
732
+ const { userId } = await import("./ase-service-otel.js");
733
+ const name = assignee === "me" ? userId() : assignee.trim();
734
+ const users = Task.users(log);
735
+ const agents = await Task.agentNames(log);
736
+ if (name === "" || name === "none" || users.length === 0 || name === userId()
737
+ || users.includes(name) || agents.includes(name))
738
+ return "";
739
+ return `unknown assignee "${name}" (neither one of the users of "project.users" nor a known agent)`;
740
+ }
741
+ /* accumulate the AI usage of a task plan: add each positive amount of the delta to its
742
+ amount in the YAML data of the usage attachment (created on the first booking, and
743
+ rounded to the precision of the amount) in a conditional save on the complete plan
744
+ (retried on a concurrent change, so no concurrent change and no booking gets lost),
745
+ refreshing the "Modified:" key of the attachment, but leaving the one of the frontmatter
746
+ alone; returns false if no task exists; throws a "UsageUncertain" error if it failed,
747
+ but might have been done nevertheless, so it must not be retried */
748
+ static async addUsage(log, id, delta) {
749
+ return Task.updateRetry(log, id, (plan) => {
750
+ if (!TaskFormat.usageKeys.some((key) => (delta[key] ?? 0) > 0))
751
+ return false;
752
+ const at = plan.attachment.findIndex((att) => TaskFormat.isUsage(att) && att.Data !== undefined);
753
+ const usage = at >= 0 ? TaskFormat.usageOf(plan.attachment[at].Data) : {};
754
+ for (const key of TaskFormat.usageKeys) {
755
+ const sum = (usage[key] ?? 0) + Math.max(0, delta[key] ?? 0);
756
+ usage[key] = Number(sum.toFixed(key.startsWith("cost.") ? 4 : 0));
757
+ }
758
+ const now = DateTime.now().toFormat("yyyy-LL-dd HH:mm");
759
+ const data = TaskFormat.usageData(usage);
760
+ if (at >= 0)
761
+ plan.attachment[at] = { ...plan.attachment[at], Modified: now, Data: data };
762
+ else
763
+ plan.attachment.push({ Type: TaskFormat.USAGE_TYPE, Created: now, Modified: now, Data: data });
764
+ return true;
765
+ }, undefined, true);
766
+ }
767
+ /* list all persisted tasks in natural id order (numbers by their value), each with the
768
+ `status`, `title`, and `effort` size (empty if not estimated) of its
769
+ plan and the `mtime` of its last modification formatted as "YYYY-MM-DD HH:MM" */
770
+ static async list(log) {
771
+ return Task.with(log, async (client) => Task.withEffort(await client.list("header")));
772
+ }
773
+ /* reduce the entries of a header listing onto the plain entries, each with
774
+ the size of its "Effort:" frontmatter key (empty if not estimated) */
775
+ static withEffort(entries) {
776
+ return entries.map((entry) => ({
279
777
  id: entry.id,
280
- status: Task.status(entry.file),
281
- mtime: verbose ? DateTime.fromJSDate(entry.st.mtime).toFormat("yyyy-LL-dd HH:mm") : undefined
778
+ status: entry.status,
779
+ title: entry.title,
780
+ mtime: entry.mtime,
781
+ effort: TaskFormat.parseEffort(String(entry.header?.Effort ?? ""))?.size ?? ""
282
782
  }));
283
- out.sort((a, b) => a.id.localeCompare(b.id));
284
- return out;
285
- }
286
- /* resolve an "include" and an "exclude" comma-separated lifecycle
287
- state list into the effective state set a task plan has to be in
288
- to be listed at all; the "none" sentinel and empty tokens are
289
- silently dropped, an empty "include" list means all states, and
290
- the "exclude" list is applied after the "include" list */
291
- static states(include, exclude) {
292
- const parse = (list) => list.split(",")
293
- .map((token) => token.trim())
294
- .filter((token) => token !== "" && token.toUpperCase() !== "NONE")
295
- .map((token) => {
296
- const state = token.toUpperCase();
297
- if (!taskStates.includes(state))
298
- throw new Error(`task: invalid state "${token}" ` +
299
- `(expected one of: ${taskStates.join(", ")})`);
300
- return state;
301
- });
302
- const included = parse(include);
303
- const excluded = parse(exclude);
304
- const states = (included.length > 0 ? included : taskStates)
305
- .filter((state) => !excluded.includes(state));
306
- if (states.length === 0)
307
- throw new Error("task: options \"--include\" and \"--exclude\" cancel out to an empty state set");
308
- return states;
309
- }
310
- /* purge tasks whose modification time is older than the given cutoff in
311
- milliseconds; returns the list of removed task ids */
312
- static purge(log, maxAgeMs) {
313
- const cutoff = Date.now() - maxAgeMs;
314
- const removed = [];
315
- for (const entry of Task.scan(log)) {
316
- if (entry.st.mtimeMs < cutoff) {
317
- fs.rmSync(entry.file, { force: true });
318
- removed.push(entry.id);
783
+ }
784
+ /* list all persisted tasks (see list), plus the known states of the task lifecycle
785
+ model and the effective state set of the "--include" and "--exclude" lifecycle
786
+ state lists, all resolved within a single task store access */
787
+ static async listStates(log, include, exclude) {
788
+ return Task.with(log, async (client) => {
789
+ let states;
790
+ try {
791
+ states = TaskFormat.resolveStates(client.lifecycle, include, exclude);
319
792
  }
320
- }
321
- return removed;
793
+ catch (err) {
794
+ throw new Error(`task: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
795
+ }
796
+ return { items: Task.withEffort(await client.list("header")), known: client.lifecycle.states, states };
797
+ });
798
+ }
799
+ /* purge tasks whose modification time is older than the given age
800
+ ("<number><unit>" with unit h, d, m, or y); returns the removed task ids */
801
+ static async purge(log, age) {
802
+ if (!/^\d+[hdmy]$/.test(age))
803
+ throw new Error("task: <age> must match <number><unit> with unit h, d, m, or y");
804
+ return Task.with(log, (client) => client.purge(age));
322
805
  }
323
- /* get the active task id for a given session, or empty string if none */
806
+ /* get the active task id for a given session, or "default" if none */
324
807
  static getId(log, session) {
325
808
  Task.validateSession(session);
326
- const scope = parseScope(`session:${session}`);
327
- const cfg = new Config("config", configSchema, log, scope);
328
- cfg.read();
329
- const val = cfg.get("agent.task");
809
+ const state = sessionState(log, session);
810
+ state.read();
811
+ const val = state.get("task");
330
812
  if (val === undefined)
331
- return "";
813
+ return "default";
332
814
  return String(isScalar(val) ? val.value : val);
333
815
  }
334
- /* set the active task id for a given session */
335
- static setId(log, session, id) {
816
+ /* set the active task id for a given session; returns a warning (else empty)
817
+ if the id does not conform to the effective task id scheme of the task store
818
+ (an unreachable task store skips the check, as switching does not require it) */
819
+ static async setId(log, session, id) {
336
820
  Task.validateSession(session);
337
821
  Task.validateId(id);
338
- const scope = parseScope(`session:${session}`);
339
- const cfg = new Config("config", configSchema, log, scope);
340
- cfg.lock(() => {
341
- cfg.read();
342
- cfg.set("agent.task", id);
343
- cfg.write();
822
+ const state = sessionState(log, session);
823
+ state.lock(() => {
824
+ state.read();
825
+ state.set("task", id);
826
+ state.write();
344
827
  });
828
+ let idscheme;
829
+ try {
830
+ idscheme = await Task.idScheme(log);
831
+ }
832
+ catch (err) {
833
+ log.write("warning", `task: cannot check task id "${id}" against the task id scheme: ` +
834
+ `${err instanceof Error ? err.message : String(err)}`);
835
+ return "";
836
+ }
837
+ return TaskFormat.idWarning(idscheme, id);
345
838
  }
346
839
  }
347
840
  /* CLI command "ase task" */
@@ -350,69 +843,259 @@ export default class TaskCommand {
350
843
  constructor(log) {
351
844
  this.log = log;
352
845
  }
846
+ /* edit a task by id with $EDITOR, renaming it on a changed "Id:" key */
847
+ async edit(id) {
848
+ /* round-trip the plan through a temporary file, as the
849
+ task store is not necessarily a local file */
850
+ Task.validateId(id);
851
+ const source = await Task.source(this.log, id);
852
+ const before = source?.text ?? Task.format(Task.minimal(id));
853
+ const editor = process.env.EDITOR ?? process.env.VISUAL ?? "vi";
854
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ase-task-"));
855
+ const file = path.join(dir, `${id}.md`);
856
+ fs.writeFileSync(file, before, "utf8");
857
+ /* on save failures, offer re-editing and never discard the edits:
858
+ the temporary file is kept if the user declines re-editing, and
859
+ the save is conditional on the plan as loaded (or create-only, if
860
+ it did not exist), so changes of the task in the meantime (e.g. by
861
+ the agents) are never overwritten, but keep the edits unsaved instead */
862
+ let next = id;
863
+ let tag = source?.tag;
864
+ let conflict = false;
865
+ for (;;) {
866
+ try {
867
+ execaSync(`${editor} "${file}"`, { shell: true, stdio: "inherit" });
868
+ const after = fs.readFileSync(file, "utf8");
869
+ if (after !== before) {
870
+ /* a changed "Id:" key renames the task (refusing
871
+ an existing target id before anything is saved) */
872
+ next = TaskFormat.taskTextId(after) || id;
873
+ if (next !== id) {
874
+ Task.validateId(next);
875
+ if (await Task.source(this.log, next) !== null)
876
+ throw new Error(`task: target id "${next}" already exists`);
877
+ }
878
+ let warning;
879
+ try {
880
+ ({ warning, tag } = await Task.save(this.log, id, after, tag, tag === undefined));
881
+ }
882
+ catch (err) {
883
+ const problem = err instanceof Core.Problem ? err :
884
+ err instanceof Error && err.cause instanceof Core.Problem ? err.cause : null;
885
+ conflict = problem?.status === 412;
886
+ throw err;
887
+ }
888
+ if (next !== id)
889
+ warning = (await Task.rename(this.log, id, next))?.warning ?? "";
890
+ if (warning !== "")
891
+ this.log.write("warning", `task: ${warning}`);
892
+ }
893
+ break;
894
+ }
895
+ catch (err) {
896
+ const msg = err instanceof Error ? err.message : String(err);
897
+ this.log.write("error", msg);
898
+ if (conflict)
899
+ throw new Error(`task: "${id}" was changed in the meantime -- ` +
900
+ `edits not saved, but kept in "${file}"`, { cause: err });
901
+ let ans = "n";
902
+ if (process.stdin.isTTY) {
903
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
904
+ try {
905
+ ans = (await rl.question("re-edit? [Y/n] ")).trim().toLowerCase();
906
+ }
907
+ finally {
908
+ rl.close();
909
+ }
910
+ }
911
+ if (ans === "n" || ans === "no")
912
+ throw new Error(`task: edits of "${id}" not saved, but kept in "${file}"`, { cause: err });
913
+ }
914
+ }
915
+ fs.rmSync(dir, { recursive: true, force: true });
916
+ this.log.write("info", next !== id ?
917
+ `task: edited "${id}" and renamed it to "${next}"` : `task: edited "${id}"`);
918
+ }
353
919
  /* register commands */
354
920
  register(program) {
355
921
  /* register CLI top-level command "ase task" */
356
922
  const task = program
357
923
  .command("task")
358
- .description("Manage persisted tasks under <project>/<basedir>/TASK-<id>.md")
924
+ .description("Manage persisted tasks in the task store configured by \"project.task.store.url\"")
359
925
  .action(() => {
360
926
  task.outputHelp();
361
927
  process.exit(1);
362
928
  });
363
929
  /* register CLI sub-command "ase task list" */
930
+ const lifecycleStates = Object.values(TaskFormat.taskLifecycles)
931
+ .map((lifecycle) => `${lifecycle.name}: ${lifecycle.states.join("|")}`)
932
+ .join("; ");
364
933
  task
365
934
  .command("list")
366
935
  .description("List all persisted task ids, one per line")
367
- .option("-v, --verbose", "also show the task plan status and the task file " +
368
- "modification time as (YYYY-MM-DD HH:MM)")
936
+ .option("-v, --verbose", "also show the task plan status, the task plan " +
937
+ "modification time as (YYYY-MM-DD HH:MM), the task effort size (\"-\" if not estimated), " +
938
+ "and the task title")
369
939
  .option("-i, --include <states>", "comma-separated list of lifecycle states to list " +
370
- `(${taskStates.join("|")}), or "none" for no restriction`, "none")
940
+ `(${lifecycleStates}), "finished" for the finished states, ` +
941
+ "or \"none\" for no restriction", "none")
371
942
  .option("-e, --exclude <states>", "comma-separated list of lifecycle states to not list " +
372
- `(${taskStates.join("|")}), or "none" for no exclusion`, "COMPLETED,CANCELLED")
943
+ `(${lifecycleStates}), "finished" for the finished states, ` +
944
+ "or \"none\" for no exclusion", "finished")
373
945
  .action(async (opts) => {
374
- const states = Task.states(opts.include, opts.exclude);
375
- const items = Task.list(this.log, opts.verbose ?? false)
376
- .filter((item) => states.includes(item.status));
946
+ const { items: all, known, states } = await Task.listStates(this.log, opts.include, opts.exclude);
947
+ const items = all
948
+ .filter((item) => {
949
+ /* keep (but warn about) a task in an unknown state
950
+ instead of silently dropping it from the list */
951
+ if (!known.includes(item.status)) {
952
+ this.log.write("warning", `task: "${item.id}" has unknown status "${item.status}" ` +
953
+ `(expected one of: ${known.join(", ")})`);
954
+ return true;
955
+ }
956
+ return states.includes(item.status);
957
+ });
377
958
  for (const item of items) {
378
959
  if (opts.verbose)
379
- await writeStdout(`${item.id}\t${item.status}\t(${item.mtime})\n`);
960
+ await writeStdout(`${item.id}\t${item.status}\t(${item.mtime})\t${item.effort || "-"}\t${item.title}\n`);
380
961
  else
381
962
  await writeStdout(`${item.id}\n`);
382
963
  }
383
964
  });
965
+ /* register CLI sub-command "ase task status" */
966
+ task
967
+ .command("status")
968
+ .description("Get or set the lifecycle status of a task: without <status> the current status " +
969
+ "is printed, with <status> it is set (case-insensitively, failing on a status " +
970
+ "not reachable in the task lifecycle model); <id> defaults to $ASE_TASK_ID")
971
+ .argument("[<id>[:]]", "Task identifier (optionally colon-suffixed)")
972
+ .argument("[<status>]", "Lifecycle status to set")
973
+ .action(async (arg1, arg2) => {
974
+ /* resolve "[<id>[:]] [<status>]": a single token is the status
975
+ if it is a state of the task lifecycle model, else the id */
976
+ const states = (await Task.lifecycle(this.log)).states;
977
+ let id = process.env.ASE_TASK_ID ?? "default";
978
+ let status = arg2;
979
+ if (arg2 !== undefined)
980
+ id = arg1;
981
+ else if (arg1 !== undefined) {
982
+ if (!arg1.endsWith(":") && states.includes(arg1.toUpperCase()))
983
+ status = arg1;
984
+ else
985
+ id = arg1;
986
+ }
987
+ id = id.replace(/:$/, "");
988
+ if (status === undefined)
989
+ await writeStdout(`${await Task.getStatus(this.log, id)}\n`);
990
+ else {
991
+ const result = await Task.setStatus(this.log, id, status);
992
+ this.log.write("info", `task: set status of "${id}" from "${result.from}" to "${result.to}"`);
993
+ process.exit(0);
994
+ }
995
+ });
996
+ /* register CLI sub-command "ase task request" */
997
+ task
998
+ .command("request")
999
+ .description("Request the processing of a task by a prompt, attached as a request of a name " +
1000
+ "(\"edit\" for the edit requests of \"ase agent editor\", any other name for the requests " +
1001
+ "of a request agent of a definition), replacing its failed requests of the name " +
1002
+ "(refused for a finished task, and while a request of the name is being processed), " +
1003
+ "where the prompt may be omitted for any other name than \"edit\" (for the request " +
1004
+ "agents ignoring the prompts of their requests, like the estimator agent)")
1005
+ .argument("<id>", "Task identifier")
1006
+ .argument("<name>", "Request name")
1007
+ .argument("[<prompt...>]", "Prompt of the request")
1008
+ .action(async (id, name, prompt) => {
1009
+ await requestsOf(name).request(this.log, id, prompt.join(" "));
1010
+ this.log.write("info", `task: requested "${name}" of "${id}"`);
1011
+ process.exit(0);
1012
+ });
1013
+ /* register CLI sub-command "ase task assign" */
1014
+ task
1015
+ .command("assign")
1016
+ .description("Assign a task: set its \"Assignee:\" to <assignee> (\"me\" for the current user, " +
1017
+ "\"none\" removes it); with --session the AI usage window of the session is handled, too " +
1018
+ "(through the running ASE service of the project); <id> defaults to $ASE_TASK_ID")
1019
+ .argument("[<id>[:]]", "Task identifier (optionally colon-suffixed)")
1020
+ .argument("[<assignee>]", "Assignee (\"me\", \"none\", or any other name)")
1021
+ .option("-s, --session <id>", "agent tool session on whose behalf the task is assigned")
1022
+ .action(async (arg1, arg2, opts) => {
1023
+ let id = process.env.ASE_TASK_ID ?? "default";
1024
+ let assignee = arg2;
1025
+ if (arg2 !== undefined)
1026
+ id = arg1;
1027
+ else if (arg1?.endsWith(":"))
1028
+ id = arg1;
1029
+ else
1030
+ assignee = arg1;
1031
+ if (assignee === undefined)
1032
+ throw new Error("task: missing <assignee>");
1033
+ id = id.replace(/:$/, "");
1034
+ const Usage = await import("./ase-service-otel.js");
1035
+ if (opts.session !== undefined) {
1036
+ const result = await Usage.UsageTracker.request(Task.projectRoot(), "/usage/assign", { id, assignee, session: opts.session });
1037
+ if (result.notice !== "")
1038
+ this.log.write("warning", `task: ${result.notice}`);
1039
+ this.log.write("info", `task: assigned "${id}" from "${result.from}" to "${result.to}" ` +
1040
+ `(usage window: ${result.action}, booked: ${usageText(result.booked)})`);
1041
+ }
1042
+ else {
1043
+ const to = assignee === "me" ? Usage.userId() : assignee;
1044
+ const warning = await Task.checkAssignee(this.log, to);
1045
+ if (warning !== "")
1046
+ this.log.write("warning", `task: ${warning}`);
1047
+ const from = await Task.assign(this.log, id, to === "none" ? null : to);
1048
+ this.log.write("info", `task: assigned "${id}" from "${from}" to "${to === "none" ? "" : to}"`);
1049
+ }
1050
+ process.exit(0);
1051
+ });
384
1052
  /* register CLI sub-command "ase task load" */
385
1053
  task
386
1054
  .command("load")
387
1055
  .description("Load a task by id and write it to stdout")
388
1056
  .argument("<id>", "Task identifier")
389
1057
  .action(async (id) => {
390
- const text = Task.load(this.log, id);
1058
+ const text = await Task.load(this.log, id);
391
1059
  await writeStdout(text);
392
1060
  });
1061
+ /* register CLI sub-command "ase task view" */
1062
+ task
1063
+ .command("view")
1064
+ .description("View a task by id with $PAGER")
1065
+ .argument("<id>", "Task identifier")
1066
+ .action(async (id) => {
1067
+ const text = await Task.load(this.log, id);
1068
+ if (text === "")
1069
+ throw new Error(`task: no task "${id}"`);
1070
+ /* bypass the pager if stdout is not a terminal */
1071
+ if (!process.stdout.isTTY)
1072
+ await writeStdout(text);
1073
+ else {
1074
+ const pager = process.env.PAGER ?? "more";
1075
+ execaSync(pager, { shell: true, input: text, stdout: "inherit", stderr: "inherit" });
1076
+ }
1077
+ });
393
1078
  /* register CLI sub-command "ase task edit" */
394
1079
  task
395
1080
  .command("edit")
396
1081
  .description("Edit a task by id with $EDITOR")
397
1082
  .argument("<id>", "Task identifier")
398
- .action((id) => {
399
- const file = Task.path(this.log, id);
400
- const editor = process.env.EDITOR ?? process.env.VISUAL ?? "vi";
401
- fs.mkdirSync(path.dirname(file), { recursive: true });
402
- if (!fs.existsSync(file))
403
- fs.writeFileSync(file, "", "utf8");
404
- execaSync(editor, [file], { stdio: "inherit" });
405
- this.log.write("info", `task: edited "${id}"`);
1083
+ .action(async (id) => {
1084
+ await this.edit(id);
406
1085
  process.exit(0);
407
1086
  });
408
1087
  /* register CLI sub-command "ase task save" */
409
1088
  task
410
1089
  .command("save")
411
- .description("Save a task by id, reading content from stdin")
1090
+ .description("Save a task by id, reading content from stdin " +
1091
+ "(failing on a Status: not reachable in the task lifecycle model)")
1092
+ .option("-c, --create", "fail if the task already exists instead of overwriting it", false)
412
1093
  .argument("<id>", "Task identifier")
413
- .action(async (id) => {
1094
+ .action(async (id, opts) => {
414
1095
  const text = await readStdin();
415
- Task.save(this.log, id, text);
1096
+ const { warning } = await Task.save(this.log, id, text, undefined, opts.create);
1097
+ if (warning !== "")
1098
+ this.log.write("warning", `task: ${warning}`);
416
1099
  this.log.write("info", `task: saved "${id}"`);
417
1100
  process.exit(0);
418
1101
  });
@@ -421,8 +1104,8 @@ export default class TaskCommand {
421
1104
  .command("delete")
422
1105
  .description("Delete a task by id")
423
1106
  .argument("<id>", "Task identifier")
424
- .action((id) => {
425
- const removed = Task.delete(this.log, id);
1107
+ .action(async (id) => {
1108
+ const removed = await Task.delete(this.log, id);
426
1109
  if (removed)
427
1110
  this.log.write("info", `task: removed "${id}"`);
428
1111
  else
@@ -435,13 +1118,37 @@ export default class TaskCommand {
435
1118
  .description("Rename a task from <old> to <new>")
436
1119
  .argument("<old>", "Old task identifier")
437
1120
  .argument("<new>", "New task identifier")
438
- .action((oldId, newId) => {
439
- const renamed = Task.rename(this.log, oldId, newId);
440
- if (renamed)
441
- this.log.write("info", `task: renamed "${oldId}" to "${newId}"`);
1121
+ .action(async (oldId, newId) => {
1122
+ const result = await Task.rename(this.log, oldId, newId);
1123
+ if (result !== null) {
1124
+ if (result.warning !== "")
1125
+ this.log.write("warning", `task: ${result.warning}`);
1126
+ this.log.write("info", `task: renamed "${oldId}" to "${newId}" ` +
1127
+ `(updated references in ${result.references} other task(s))`);
1128
+ }
442
1129
  else
443
1130
  this.log.write("info", `task: no task "${oldId}" to rename`);
444
- process.exit(renamed ? 0 : 1);
1131
+ process.exit(result !== null ? 0 : 1);
1132
+ });
1133
+ /* register CLI sub-command "ase task newid" */
1134
+ task
1135
+ .command("newid")
1136
+ .description("Print the next free task id according to the task id scheme of the project, " +
1137
+ "determined by searching all existing task ids: for scheme \"slug\" derived from <title>, " +
1138
+ "for scheme \"seq\" the next sequence number (reserved), and for scheme \"any\" the (made unique) " +
1139
+ "--proposal, else derived from <title>; with --verbose additionally the scheme and " +
1140
+ "the regular expression matching the ids conforming to the scheme")
1141
+ .option("-p, --proposal <id>", "proposed task id (for task id scheme \"any\" only)", "")
1142
+ .option("-t, --taken <ids>", "comma-separated task ids to consider as taken additionally", "")
1143
+ .option("-v, --verbose", "print the scheme, the id, and the matching regular expression", false)
1144
+ .argument("[<title>]", "Task title", "")
1145
+ .action(async (title, opts) => {
1146
+ const taken = opts.taken.split(",").map((id) => id.trim()).filter((id) => id !== "");
1147
+ const result = await Task.newId(this.log, title, opts.proposal, taken);
1148
+ if (opts.verbose)
1149
+ await writeStdout(`scheme: ${result.scheme}\nid: ${result.id}\nmatch: ${result.match}\n`);
1150
+ else
1151
+ await writeStdout(`${result.id}\n`);
445
1152
  });
446
1153
  /* register CLI sub-command "ase task purge" */
447
1154
  task
@@ -449,19 +1156,8 @@ export default class TaskCommand {
449
1156
  .description("Remove all tasks with a modification time older than <age> (default: 31d); " +
450
1157
  "<age> is <number><unit> with unit h (hour), d (day), m (month), y (year)")
451
1158
  .argument("[<age>]", "Maximum task age as <number><unit>", "31d")
452
- .action((age) => {
453
- const m = /^(\d+)([hdmy])$/.exec(age);
454
- if (m === null)
455
- throw new Error("task: <age> must match <number><unit> with unit h, d, m, or y");
456
- const n = Number.parseInt(m[1], 10);
457
- const unit = m[2];
458
- const hour = 60 * 60 * 1000;
459
- const day = 24 * hour;
460
- const month = 30 * day;
461
- const year = 365 * day;
462
- const factors = { h: hour, d: day, m: month, y: year };
463
- const factor = factors[unit];
464
- const removed = Task.purge(this.log, n * factor);
1159
+ .action(async (age) => {
1160
+ const removed = await Task.purge(this.log, age);
465
1161
  if (removed.length === 0)
466
1162
  this.log.write("info", "task: no tasks to purge");
467
1163
  else
@@ -469,6 +1165,42 @@ export default class TaskCommand {
469
1165
  this.log.write("info", `task: purged "${id}"`);
470
1166
  process.exit(0);
471
1167
  });
1168
+ /* register CLI sub-command "ase task lifecycle" */
1169
+ task
1170
+ .command("lifecycle")
1171
+ .description("Get or set the task lifecycle model of the project: without <name> the effective " +
1172
+ "model is printed, with <name> the project in the task store is switched to it " +
1173
+ "(for a local task store by setting \"project.task.lifecycle\" on scope \"project\")")
1174
+ .argument("[<name>]", `Lifecycle model name (${Object.keys(TaskFormat.taskLifecycles).join("|")})`)
1175
+ .action(async (name) => {
1176
+ if (name === undefined)
1177
+ await writeStdout(`${(await Task.lifecycle(this.log)).name}\n`);
1178
+ else {
1179
+ const from = await Task.setLifecycle(this.log, name);
1180
+ this.log.write("info", `task: set lifecycle model of project from "${from}" to "${name}"`);
1181
+ process.exit(0);
1182
+ }
1183
+ });
1184
+ /* register CLI sub-command "ase task idscheme" */
1185
+ task
1186
+ .command("idscheme")
1187
+ .description("Get or set the task id scheme of the project: without <scheme> the effective " +
1188
+ "scheme is printed, with <scheme> the project in the task store is switched to it " +
1189
+ "(for a local task store by setting \"project.task.idscheme\" on scope \"project\")")
1190
+ .argument("[<scheme>]", "Task id scheme (slug[:<words>]|seq[:<template>]|any)")
1191
+ .action(async (scheme) => {
1192
+ if (scheme === undefined)
1193
+ await writeStdout(`${await Task.idScheme(this.log)}\n`);
1194
+ else {
1195
+ const from = await Task.setIdScheme(this.log, scheme);
1196
+ this.log.write("info", `task: set task id scheme of project from "${from}" to "${scheme}"`);
1197
+ process.exit(0);
1198
+ }
1199
+ });
1200
+ /* register CLI sub-command group "ase task store" */
1201
+ new TaskStoreCommand(this.log).register(task);
1202
+ /* provide "ase task" command for further sub-commands */
1203
+ return task;
472
1204
  }
473
1205
  }
474
1206
  /* render a caught error as an MCP tool error result */
@@ -479,11 +1211,22 @@ const mcpError = (err) => {
479
1211
  content: [{ type: "text", text: `ERROR: ${message}` }]
480
1212
  };
481
1213
  };
482
- /* MCP registration entry point for task tools */
1214
+ /* render a text as an MCP tool result */
1215
+ const mcpText = (text) => ({ content: [{ type: "text", text }] });
1216
+ /* render a task id scheme warning as a trailing notice line of an MCP tool result
1217
+ (not as "WARNING:", as callers treat such a result as a failed operation) */
1218
+ const notice = (warning) => warning !== "" ? `\nNOTICE: ${warning}` : "";
1219
+ /* render the positive amounts of a booked AI usage, e.g. "time.ai +12, cost.ai +0.4213" */
1220
+ const usageText = (usage) => Object.entries(usage ?? {}).filter(([, amount]) => amount > 0)
1221
+ .map(([key, amount]) => `${key} +${amount}`).join(", ") || "nothing";
1222
+ /* MCP registration entry point for task tools (with the task assignment
1223
+ handling the AI usage windows through the usage tracker of the service) */
483
1224
  export class TaskMCP {
484
1225
  log;
485
- constructor(log) {
1226
+ usage;
1227
+ constructor(log, usage) {
486
1228
  this.log = log;
1229
+ this.usage = usage;
487
1230
  }
488
1231
  /* register MCP tools */
489
1232
  register(mcp) {
@@ -491,30 +1234,39 @@ export class TaskMCP {
491
1234
  mcp.registerTool("ase_task_list", {
492
1235
  title: "ASE task list",
493
1236
  description: "List all persisted tasks. " +
494
- "Returns a `tasks` array (in lexicographic `id` order) where each item has the " +
495
- "task `id` and the `status` of its plan (the `Status:` frontmatter key, defaulting " +
496
- "to `DRAFTED`). If `verbose` is `true`, each item additionally has an `mtime` field " +
497
- "(last modification time of the task's `TASK-<id>.md` file, formatted as `YYYY-MM-DD HH:MM`). " +
1237
+ "Returns a `tasks` array (in natural `id` order, numbers by their value) where each item has the " +
1238
+ "task `id`, the `status` of its plan (the `Status:` frontmatter key, defaulting " +
1239
+ "to the initial state of the configured task lifecycle model), and the `title` of " +
1240
+ "its plan (the `# TASK: <title>` heading, empty if absent). " +
1241
+ "If `verbose` is `true`, each item additionally has an `mtime` field " +
1242
+ "(last modification time of the task plan, formatted as `YYYY-MM-DD HH:MM`) " +
1243
+ "and an `effort` field (the T-shirt size of its valid `Effort:` frontmatter key, " +
1244
+ "empty if not estimated). " +
498
1245
  "Returns an empty array if no tasks exist.",
499
1246
  inputSchema: {
500
1247
  verbose: z.boolean().optional()
501
- .describe("if true, also include the `mtime` field per task (default: false)")
1248
+ .describe("if true, also include the `mtime` and `effort` fields per task (default: false)")
502
1249
  },
503
1250
  outputSchema: {
504
1251
  tasks: z.array(z.object({
505
1252
  id: z.string().describe("task identifier"),
506
- status: z.string().describe("task plan lifecycle status (`Status:` frontmatter key, default `DRAFTED`)"),
1253
+ status: z.string().describe("task plan lifecycle status (`Status:` frontmatter key, " +
1254
+ "defaulting to the initial state of the configured task lifecycle model)"),
1255
+ title: z.string().describe("task plan title (`# TASK: <title>` heading, empty if absent)"),
507
1256
  mtime: z.string().optional()
508
- .describe("`TASK-<id>.md` modification time (`YYYY-MM-DD HH:MM`); only present if `verbose` is true")
509
- })).describe("all persisted tasks in lexicographic id order")
1257
+ .describe("task plan modification time (`YYYY-MM-DD HH:MM`); only present if `verbose` is true"),
1258
+ effort: z.string().optional()
1259
+ .describe("task effort size (`XS`, `S`, `M`, `L`, or `XL`, empty if not estimated); " +
1260
+ "only present if `verbose` is true")
1261
+ })).describe("all persisted tasks in natural id order")
510
1262
  }
511
1263
  }, async (args) => {
512
1264
  try {
513
1265
  const verbose = args.verbose ?? false;
514
- const items = Task.list(this.log, verbose);
1266
+ const items = await Task.list(this.log);
515
1267
  const tasks = verbose ?
516
- items.map((item) => ({ id: item.id, status: item.status, mtime: item.mtime ?? "" })) :
517
- items.map((item) => ({ id: item.id, status: item.status }));
1268
+ items.map((item) => ({ id: item.id, status: item.status, title: item.title, mtime: item.mtime, effort: item.effort })) :
1269
+ items.map((item) => ({ id: item.id, status: item.status, title: item.title }));
518
1270
  const result = { tasks };
519
1271
  return {
520
1272
  structuredContent: result,
@@ -537,18 +1289,33 @@ export class TaskMCP {
537
1289
  "`render` is the *rendering-prepared* form, which is for *display only* and " +
538
1290
  "MUST NOT be persisted; " +
539
1291
  "`both` returns the source form enclosed in `<task-plan-source>` delimiter lines, " +
540
- "followed by the render form enclosed in `<task-plan-render>` delimiter lines.",
1292
+ "followed by the render form enclosed in `<task-plan-render>` delimiter lines. " +
1293
+ "If `tag` is `true`, the entity tag of the loaded plan is additionally returned in a trailing " +
1294
+ "`<task-plan-tag>` delimiter line, to be passed as `base` into `ase_task_save` for a conditional save. " +
1295
+ "The `attachments` argument selects the returned form of the attachment payloads: `elided` (the default) " +
1296
+ "replaces the non-empty `Data` of each attachment block with a `[elided: <n> lines]` marker " +
1297
+ "(keeping all other keys), which `ase_task_save` restores from the stored plan as long as the " +
1298
+ "block is kept (removing the block removes the attachment, replacing the marker replaces the " +
1299
+ "payload); `full` returns the complete payloads, needed only to process them as a whole " +
1300
+ "(a single payload is better fetched via `ase_task_attachment`).",
541
1301
  inputSchema: {
542
1302
  id: z.string()
543
- .describe("task identifier (allowed characters: A-Z, a-z, 0-9, '_', '-')"),
1303
+ .describe("task identifier (allowed characters: A-Z, a-z, 0-9, '#', '_', '-')"),
544
1304
  variant: z.enum(["source", "render", "both"]).optional()
545
1305
  .describe("returned form of the plan: `source` (authoring form, the default), " +
546
1306
  "`render` (rendering-prepared form, display only), or `both` " +
547
- "(both forms, each enclosed in its delimiter lines)")
1307
+ "(both forms, each enclosed in its delimiter lines)"),
1308
+ attachments: z.enum(["elided", "full"]).optional()
1309
+ .describe("returned form of the attachment payloads: `elided` (each replaced " +
1310
+ "by a marker restored on saving, the default) or `full` (complete)"),
1311
+ tag: z.boolean().optional()
1312
+ .describe("if true, additionally return the entity tag of the plan in a trailing " +
1313
+ "`<task-plan-tag>` delimiter line (default: false)")
548
1314
  }
549
1315
  }, async (args) => {
550
1316
  try {
551
- const source = Task.load(this.log, args.id);
1317
+ const loaded = await Task.source(this.log, args.id, args.attachments !== "full");
1318
+ const source = loaded?.text ?? "";
552
1319
  const variant = args.variant ?? "source";
553
1320
  let text = source;
554
1321
  if (source !== "" && variant === "render")
@@ -557,9 +1324,9 @@ export class TaskMCP {
557
1324
  text =
558
1325
  `<task-plan-source>\n${source}\n</task-plan-source>\n\n` +
559
1326
  `<task-plan-render>\n${Markdown.prepare(source)}\n</task-plan-render>\n`;
560
- return {
561
- content: [{ type: "text", text }]
562
- };
1327
+ if (loaded !== null && args.tag === true)
1328
+ text += `\n<task-plan-tag>${loaded.tag}</task-plan-tag>\n`;
1329
+ return mcpText(text);
563
1330
  }
564
1331
  catch (err) {
565
1332
  return mcpError(err);
@@ -571,29 +1338,96 @@ export class TaskMCP {
571
1338
  description: "Persist a task as `text` under `id`. " +
572
1339
  "The `text` MUST be the *authoring* form of the plan (as returned by the " +
573
1340
  "`source` variant of `ase_task_load`) and hence MUST NOT carry any rendering " +
574
- "artifacts. Overwrites any existing task for the same `id`. " +
1341
+ "artifacts. Overwrites any existing task for the same `id`, unless `create` is `true`, " +
1342
+ "in which case the save fails if a task with this `id` already exists (to be used on the " +
1343
+ "first save of a task whose `id` was allocated via `ase_task_newid`). " +
575
1344
  "Returns a status `text` by default, or, if `render` is `true`, the " +
576
- "*rendering-prepared* form of the just-saved plan, for display purposes only.",
1345
+ "*rendering-prepared* form of the just-saved plan (with its attachment payloads elided), " +
1346
+ "for display purposes only. " +
1347
+ "An attachment block whose `Data` is an `[elided: <n> lines]` marker (see `ase_task_load`) gets " +
1348
+ "its payload restored from the stored attachment of the same `Type` (preferably also of the same " +
1349
+ "`Created`); the save fails if there is no such stored attachment. " +
1350
+ "The `Status:` frontmatter key of `text` is checked against the configured task " +
1351
+ "lifecycle model: if a changed status is not a state of the model or not reachable " +
1352
+ "from the previously saved status via one or more transitions of the state machine, " +
1353
+ "the save fails with an error. Prefer the `ase_task_status` MCP tool for pure status changes. " +
1354
+ "If `id` does not conform to the task id scheme of the project, the returned `text` " +
1355
+ "additionally carries a trailing `NOTICE: <info>` line. " +
1356
+ "With `base` (the entity tag of the plan the `text` is based on, as returned by `ase_task_load` " +
1357
+ "with `tag` or by a previous `ase_task_save` with `tag` or `base`), the save is conditional: it fails with " +
1358
+ "an error if the plan was changed meanwhile (by anybody else). " +
1359
+ "If `tag` is `true` or `base` is given, the returned `text` additionally carries the entity tag " +
1360
+ "of the just-saved plan in a trailing `<task-plan-tag>` delimiter line, to be passed as `base` " +
1361
+ "into the next `ase_task_save` of this plan.",
577
1362
  inputSchema: {
578
1363
  id: z.string()
579
- .describe("task identifier (allowed characters: A-Z, a-z, 0-9, '_', '-')"),
1364
+ .describe("task identifier (allowed characters: A-Z, a-z, 0-9, '#', '_', '-')"),
580
1365
  text: z.string()
581
1366
  .describe("text content of the task, in its authoring form"),
1367
+ create: z.boolean().optional()
1368
+ .describe("if true, fail if a task with `id` already exists instead of overwriting it (default: false)"),
582
1369
  render: z.boolean().optional()
583
1370
  .describe("if true, return the rendering-prepared form of the just-saved " +
584
- "plan instead of a status message (default: false)")
1371
+ "plan instead of a status message (default: false)"),
1372
+ base: z.string().optional()
1373
+ .describe("entity tag of the plan the text is based on, for a conditional save " +
1374
+ "failing if the plan was changed meanwhile (default: unconditional save)"),
1375
+ tag: z.boolean().optional()
1376
+ .describe("if true, additionally return the entity tag of the just-saved plan in a trailing " +
1377
+ "`<task-plan-tag>` delimiter line (default: false, but implied by `base`)")
585
1378
  }
586
1379
  }, async (args) => {
587
1380
  try {
588
- Task.save(this.log, args.id, args.text);
1381
+ const base = args.base !== undefined && args.base !== "" ? args.base : undefined;
1382
+ let result;
1383
+ try {
1384
+ result = await Task.save(this.log, args.id, args.text, base, args.create ?? false);
1385
+ }
1386
+ catch (err) {
1387
+ if (base !== undefined && err instanceof Core.Problem && err.status === 412)
1388
+ throw new Error(`task: task "${args.id}" was changed meanwhile -- save refused ` +
1389
+ "(reload the task and apply the change again)", { cause: err });
1390
+ throw err;
1391
+ }
589
1392
  /* return the rendering-prepared content on demand, so a caller
590
- displaying the just-saved plan does not have to re-load it */
591
- const text = (args.render ?? false) ?
592
- Markdown.prepare(args.text) :
593
- `OK: saved task "${args.id}"`;
594
- return {
595
- content: [{ type: "text", text }]
596
- };
1393
+ displaying the just-saved plan does not have to re-load it
1394
+ (rendered from the stored plan, as the store normalizes the text,
1395
+ but only if it still is the just-saved one, i.e., carries its
1396
+ entity tag, else from the plan as sent), and the entity tag of the
1397
+ just-saved plan for a conditional save (as reported by the save
1398
+ itself, so it never is the one of a subsequent save of somebody else) */
1399
+ const render = args.render ?? false;
1400
+ const stored = render ? await Task.source(this.log, args.id, true) : null;
1401
+ const source = stored !== null && stored.tag === result.tag ? stored.text : result.text;
1402
+ const text = (render ? Markdown.prepare(source) : `OK: saved task "${args.id}"`) +
1403
+ notice(result.warning) +
1404
+ (base !== undefined || args.tag === true ? `\n<task-plan-tag>${result.tag}</task-plan-tag>\n` : "");
1405
+ return mcpText(text);
1406
+ }
1407
+ catch (err) {
1408
+ return mcpError(err);
1409
+ }
1410
+ });
1411
+ /* task attachment */
1412
+ mcp.registerTool("ase_task_attachment", {
1413
+ title: "ASE task attachment",
1414
+ description: "Get the complete payload (`Data`) of an attachment of the persisted task `id` by its exact `type` " +
1415
+ "(the value of its `Type` key, like `text/x-diff; charset=utf-8; kind=\"draft\"`), " +
1416
+ "e.g. to process a payload elided by `ase_task_load`. If several attachments carry this `Type`, " +
1417
+ "the last one is taken. Returns the payload verbatim as `text`, or a `WARNING:` " +
1418
+ "if no such attachment with an embedded payload exists.",
1419
+ inputSchema: {
1420
+ id: z.string()
1421
+ .describe("task identifier (allowed characters: A-Z, a-z, 0-9, '#', '_', '-')"),
1422
+ type: z.string()
1423
+ .describe("exact value of the `Type` key of the attachment")
1424
+ }
1425
+ }, async (args) => {
1426
+ try {
1427
+ const attachment = (await Task.attachments(this.log, args.id)).findLast((a) => a.type === args.type);
1428
+ const text = attachment?.data !== undefined ? attachment.data :
1429
+ `WARNING: no attachment of type "${args.type}" with an embedded payload in task "${args.id}"`;
1430
+ return mcpText(text);
597
1431
  }
598
1432
  catch (err) {
599
1433
  return mcpError(err);
@@ -606,17 +1440,15 @@ export class TaskMCP {
606
1440
  "Returns a status `text` indicating whether a task existed and was removed.",
607
1441
  inputSchema: {
608
1442
  id: z.string()
609
- .describe("task identifier (allowed characters: A-Z, a-z, 0-9, '_', '-')")
1443
+ .describe("task identifier (allowed characters: A-Z, a-z, 0-9, '#', '_', '-')")
610
1444
  }
611
1445
  }, async (args) => {
612
1446
  try {
613
- const removed = Task.delete(this.log, args.id);
1447
+ const removed = await Task.delete(this.log, args.id);
614
1448
  const msg = removed ?
615
1449
  `OK: removed task "${args.id}"` :
616
1450
  `WARNING: no task "${args.id}" to remove`;
617
- return {
618
- content: [{ type: "text", text: msg }]
619
- };
1451
+ return mcpText(msg);
620
1452
  }
621
1453
  catch (err) {
622
1454
  return mcpError(err);
@@ -625,24 +1457,171 @@ export class TaskMCP {
625
1457
  /* task rename */
626
1458
  mcp.registerTool("ase_task_rename", {
627
1459
  title: "ASE task rename",
628
- description: "Rename a previously persisted task from `old` to `new` by moving the " +
629
- "task `TASK-<id>.md` file and rewriting its embedded `Id:` frontmatter key. " +
1460
+ description: "Rename a previously persisted task from `old` to `new`, " +
1461
+ "rewriting its embedded `Id:` frontmatter key, the `After:` references of all " +
1462
+ "other tasks, and, for an explicit epic (`Group:` equals `Id:`), the `Group:` " +
1463
+ "references of itself and its member tasks. " +
630
1464
  "Returns a status `text` indicating whether the rename succeeded. " +
631
1465
  "Fails with an error if the target id already exists.",
632
1466
  inputSchema: {
633
1467
  old: z.string()
634
- .describe("old task identifier (allowed characters: A-Z, a-z, 0-9, '_', '-')"),
1468
+ .describe("old task identifier (allowed characters: A-Z, a-z, 0-9, '#', '_', '-')"),
635
1469
  new: z.string()
636
- .describe("new task identifier (allowed characters: A-Z, a-z, 0-9, '_', '-')")
1470
+ .describe("new task identifier (allowed characters: A-Z, a-z, 0-9, '#', '_', '-')")
637
1471
  }
638
1472
  }, async (args) => {
639
1473
  try {
640
- const renamed = Task.rename(this.log, args.old, args.new);
641
- const msg = renamed ?
642
- `OK: renamed task "${args.old}" to "${args.new}"` :
1474
+ const result = await Task.rename(this.log, args.old, args.new);
1475
+ const msg = result !== null ?
1476
+ `OK: renamed task "${args.old}" to "${args.new}" ` +
1477
+ `(updated references in ${result.references} other task(s))` + notice(result.warning) :
643
1478
  `WARNING: no task "${args.old}" to rename`;
1479
+ return mcpText(msg);
1480
+ }
1481
+ catch (err) {
1482
+ return mcpError(err);
1483
+ }
1484
+ });
1485
+ /* task status get/set */
1486
+ mcp.registerTool("ase_task_status", {
1487
+ title: "ASE task status get/set",
1488
+ description: "Get or set the lifecycle `status` of the task plan `id` (its `Status:` frontmatter key). " +
1489
+ "If `status` is provided, it is set (case-insensitively, validated against the states of " +
1490
+ "the configured task lifecycle model, leaving the `Modified:` key alone) and a status `text` " +
1491
+ "is returned. Otherwise the current status is returned " +
1492
+ "as `text`, defaulting to the initial state of the model. " +
1493
+ "Fails with an error if no task exists, the status is unknown, or the status is not " +
1494
+ "reachable from the current one via one or more transitions of the state machine of the model.",
1495
+ inputSchema: {
1496
+ id: z.string()
1497
+ .describe("task identifier (allowed characters: A-Z, a-z, 0-9, '#', '_', '-')"),
1498
+ status: z.string().optional()
1499
+ .describe("lifecycle status to set (a state of the configured task lifecycle model, " +
1500
+ "case-insensitive); if omitted, the current status is returned")
1501
+ }
1502
+ }, async (args) => {
1503
+ try {
1504
+ if (args.status !== undefined) {
1505
+ const result = await Task.setStatus(this.log, args.id, args.status);
1506
+ const text = `OK: set status of task "${args.id}" from "${result.from}" to "${result.to}"`;
1507
+ return mcpText(text);
1508
+ }
1509
+ const text = await Task.getStatus(this.log, args.id);
1510
+ return mcpText(text);
1511
+ }
1512
+ catch (err) {
1513
+ return mcpError(err);
1514
+ }
1515
+ });
1516
+ /* task request */
1517
+ mcp.registerTool("ase_task_request", {
1518
+ title: "ASE task request",
1519
+ description: "Request the processing of the persisted task `id` by a `prompt`, attached as a request of the " +
1520
+ "`name` (`edit` for the edit requests of the `ase agent editor`, any other name for the requests " +
1521
+ "of a request agent of a definition), replacing its failed requests of the name. " +
1522
+ "Returns a status `text`. Fails with an error if the task does not exist or is finished, " +
1523
+ "while a request of the name is being processed, or if the `prompt` of an `edit` request is empty.",
1524
+ inputSchema: {
1525
+ id: z.string()
1526
+ .describe("task identifier (allowed characters: A-Z, a-z, 0-9, '#', '_', '-')"),
1527
+ name: z.string()
1528
+ .describe("request name (a lower-case word of letters, digits, and dashes)"),
1529
+ prompt: z.string().optional()
1530
+ .describe("prompt of the request (may be empty for any other name than `edit`, " +
1531
+ "for the request agents ignoring the prompts of their requests)")
1532
+ }
1533
+ }, async (args) => {
1534
+ try {
1535
+ await requestsOf(args.name).request(this.log, args.id, args.prompt ?? "");
1536
+ return mcpText(`OK: requested "${args.name}" of task "${args.id}"`);
1537
+ }
1538
+ catch (err) {
1539
+ return mcpError(err);
1540
+ }
1541
+ });
1542
+ /* task assignment (with AI usage accounting) */
1543
+ mcp.registerTool("ase_task_assign", {
1544
+ title: "ASE task assignment",
1545
+ description: "Assign the task plan `id` to `assignee` (its `Assignee:` frontmatter key, leaving the " +
1546
+ "`Modified:` key alone) on behalf of the agent tool `session`. The assignee `me` (or the " +
1547
+ "current user id) assigns it to oneself and opens (or attaches the session to) the AI usage " +
1548
+ "window of the task, which accounts the AI request time, tokens, and costs of all turns of the " +
1549
+ "session (received via OpenTelemetry), plus the human time and costs. Any other assignee, or " +
1550
+ "`none` (removing the `Assignee:` key), gives the task back: it closes the usage window and, " +
1551
+ "after a grace period of 10 seconds for late telemetry, atomically adds the accumulated usage " +
1552
+ "to the `time.{ai,hr,real}` (minutes), `token.{cread,cwrite,input,output}`, and `cost.{ai,hr}` " +
1553
+ "(USD) amounts of the usage attachment (`Type: application/yaml; kind=\"usage\"`) of the task. " +
1554
+ "Returns a status `text` with the previous and new assignee, the window action " +
1555
+ "(`opened`, `attached`, `closed`, or `none`), and the booked usage, plus a trailing " +
1556
+ "`NOTICE: <info>` line if telemetry seems not to be activated.",
1557
+ inputSchema: {
1558
+ id: z.string()
1559
+ .describe("task identifier (allowed characters: A-Z, a-z, 0-9, '#', '_', '-')"),
1560
+ assignee: z.string()
1561
+ .describe("assignee: `me` (oneself), `none` (unassign), or any other name"),
1562
+ session: z.string()
1563
+ .describe("session identifier of the agent tool (allowed characters: A-Z, a-z, 0-9, '_', '-')"),
1564
+ tool: z.enum(["claude", "codex", "copilot"]).optional()
1565
+ .describe("agent tool of the session, for checking its telemetry activation (default: none)")
1566
+ }
1567
+ }, async (args) => {
1568
+ try {
1569
+ if (this.usage === undefined)
1570
+ throw new Error("task: assignment requires the usage tracker of the ASE service");
1571
+ const result = await this.usage.assign(args.id, args.assignee, args.session);
1572
+ let warning = result.notice;
1573
+ if (warning === "" && args.tool !== undefined && (result.action === "opened" || result.action === "attached")) {
1574
+ const { SetupSettings } = await import("./ase-setup-settings.js");
1575
+ if (!await new SetupSettings(this.log).telemetryActive(args.tool))
1576
+ warning = `telemetry is not activated for tool "${args.tool}" ` +
1577
+ `(run "ase setup telemetry activate --tool ${args.tool}"), so no usage is accounted`;
1578
+ }
1579
+ const text = `OK: assigned task "${args.id}" from "${result.from || "(nobody)"}" ` +
1580
+ `to "${result.to || "(nobody)"}", usage window: ${result.action}` +
1581
+ (result.action === "closed" ? `, booked: ${usageText(result.booked)}` : "") +
1582
+ notice(warning);
1583
+ return mcpText(text);
1584
+ }
1585
+ catch (err) {
1586
+ return mcpError(err);
1587
+ }
1588
+ });
1589
+ /* task id generation */
1590
+ mcp.registerTool("ase_task_newid", {
1591
+ title: "ASE task id generation",
1592
+ description: "Determine the next free task id according to the task id scheme of the project " +
1593
+ "(the `project.task.idscheme` configuration, resp. the scheme registered in the task store), " +
1594
+ "by searching all existing task ids. The scheme is either `slug[:<words>]` (the first " +
1595
+ "`<words>` (default: 2) words of `title`, lower-cased and joined with `-`), " +
1596
+ "`seq[:<template>]` (the highest sequence number of all existing and ever removed ids plus one, rendered " +
1597
+ "through the sprintf-style `<template>` like `FOO-%03d`, `#%d`, or `%d` (the default)), or " +
1598
+ "`any` (the `proposal`, else derived from `title` like for `slug:2`). A slug or proposed id " +
1599
+ "which is already taken gets a numeric suffix `-2`, `-3`, etc. " +
1600
+ "Returns the `scheme`, the new `id`, and the regular expression `match` of all ids " +
1601
+ "conforming to the scheme (for `seq` capturing the sequence number, for `slug` accepting " +
1602
+ "the numeric suffix). For scheme `seq` the id is reserved (never handed out again), " +
1603
+ "for the other schemes it is not, so for allocating several ids at once pass the " +
1604
+ "already allocated ones as `taken`, and on the first save of the new task pass " +
1605
+ "`create: true` to `ase_task_save` to detect a concurrently created task with the same id.",
1606
+ inputSchema: {
1607
+ title: z.string().optional()
1608
+ .describe("title of the new task (required for scheme `slug`, ignored for scheme `seq`)"),
1609
+ proposal: z.string().optional()
1610
+ .describe("proposed task id (used for scheme `any` only)"),
1611
+ taken: z.array(z.string()).optional()
1612
+ .describe("task ids to consider as taken in addition to the existing ones (default: none)")
1613
+ },
1614
+ outputSchema: {
1615
+ scheme: z.string().describe("task id scheme of the project"),
1616
+ id: z.string().describe("next free task id"),
1617
+ match: z.string().describe("regular expression matching the ids conforming to the scheme")
1618
+ }
1619
+ }, async (args) => {
1620
+ try {
1621
+ const result = await Task.newId(this.log, args.title ?? "", args.proposal ?? "", args.taken ?? []);
644
1622
  return {
645
- content: [{ type: "text", text: msg }]
1623
+ structuredContent: result,
1624
+ content: [{ type: "text", text: JSON.stringify(result) }]
646
1625
  };
647
1626
  }
648
1627
  catch (err) {
@@ -654,10 +1633,12 @@ export class TaskMCP {
654
1633
  title: "ASE task id get/set",
655
1634
  description: "Get or set the active ASE task `id` for a given `session`. " +
656
1635
  "If `id` is provided, it sets the task id in the given `session`, " +
657
- "otherwise it returns the current task `id` of the `session`.",
1636
+ "otherwise it returns the current task `id` of the `session`. " +
1637
+ "If a set `id` does not conform to the task id scheme of the project, the returned `text` " +
1638
+ "additionally carries a trailing `NOTICE: <info>` line.",
658
1639
  inputSchema: {
659
1640
  id: z.string().optional()
660
- .describe("task identifier to set (allowed characters: A-Z, a-z, 0-9, '_', '-'); " +
1641
+ .describe("task identifier to set (allowed characters: A-Z, a-z, 0-9, '#', '_', '-'); " +
661
1642
  "if omitted, the current task id is returned"),
662
1643
  session: z.string()
663
1644
  .describe("session identifier (allowed characters: A-Z, a-z, 0-9, '_', '-')")
@@ -665,17 +1646,14 @@ export class TaskMCP {
665
1646
  }, async (args) => {
666
1647
  try {
667
1648
  if (args.id !== undefined) {
668
- Task.setId(this.log, args.session, args.id);
669
- const msg = `OK: set agent.task to "${args.id}" ` +
670
- `for session "${args.session}"`;
671
- return {
672
- content: [{ type: "text", text: msg }]
673
- };
1649
+ const warning = await Task.setId(this.log, args.session, args.id);
1650
+ this.usage?.activate(args.session);
1651
+ const msg = `OK: set active task id to "${args.id}" ` +
1652
+ `for session "${args.session}"` + notice(warning);
1653
+ return mcpText(msg);
674
1654
  }
675
1655
  const text = Task.getId(this.log, args.session);
676
- return {
677
- content: [{ type: "text", text }]
678
- };
1656
+ return mcpText(text);
679
1657
  }
680
1658
  catch (err) {
681
1659
  return mcpError(err);