@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
@@ -0,0 +1,757 @@
1
+ /*
2
+ ** Agentic Software Engineering (ASE)
3
+ ** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
+ */
6
+ import YAML from "yaml";
7
+ import { nanoid } from "./ase-lib-hash.js";
8
+ /* the pre-defined task lifecycle models (see "project.task.lifecycle") */
9
+ export const taskLifecycles = {
10
+ solo: {
11
+ name: "solo",
12
+ states: ["OPEN", "SHELVED", "CLOSED", "CANCELLED"],
13
+ initial: "OPEN",
14
+ finished: ["CLOSED", "CANCELLED"],
15
+ activity: [],
16
+ transitions: {
17
+ OPEN: ["SHELVED", "CLOSED", "CANCELLED"],
18
+ SHELVED: ["OPEN", "CANCELLED"],
19
+ CLOSED: [],
20
+ CANCELLED: []
21
+ }
22
+ },
23
+ team: {
24
+ name: "team",
25
+ states: ["PLANNING", "SHELVED", "IMPLEMENTING", "STALLED", "IMPLEMENTED", "CANCELLED"],
26
+ initial: "PLANNING",
27
+ finished: ["IMPLEMENTED", "CANCELLED"],
28
+ activity: ["IMPLEMENTING"],
29
+ transitions: {
30
+ PLANNING: ["SHELVED", "IMPLEMENTING", "CANCELLED"],
31
+ SHELVED: ["PLANNING", "CANCELLED"],
32
+ IMPLEMENTING: ["PLANNING", "STALLED", "IMPLEMENTED", "CANCELLED"],
33
+ STALLED: ["IMPLEMENTING", "CANCELLED"],
34
+ IMPLEMENTED: [],
35
+ CANCELLED: []
36
+ }
37
+ },
38
+ enterprise: {
39
+ name: "enterprise",
40
+ states: [
41
+ "DRAFTED", "SHELVED", "PLANNING", "PLANNED", "STALLED", "IMPLEMENTING",
42
+ "IMPLEMENTED", "DECLINED", "QUALIFYING", "QUALIFIED", "DEFERRED", "INTEGRATING",
43
+ "INTEGRATED", "CANCELLED"
44
+ ],
45
+ initial: "DRAFTED",
46
+ finished: ["INTEGRATED", "CANCELLED"],
47
+ activity: ["PLANNING", "IMPLEMENTING", "QUALIFYING", "INTEGRATING"],
48
+ transitions: {
49
+ DRAFTED: ["SHELVED", "PLANNING", "CANCELLED"],
50
+ SHELVED: ["DRAFTED", "CANCELLED"],
51
+ PLANNING: ["DRAFTED", "SHELVED", "PLANNED", "CANCELLED"],
52
+ PLANNED: ["STALLED", "IMPLEMENTING", "CANCELLED"],
53
+ STALLED: ["PLANNED", "CANCELLED"],
54
+ IMPLEMENTING: ["DRAFTED", "PLANNED", "STALLED", "IMPLEMENTED", "CANCELLED"],
55
+ IMPLEMENTED: ["DECLINED", "QUALIFYING", "CANCELLED"],
56
+ DECLINED: ["IMPLEMENTED", "CANCELLED"],
57
+ QUALIFYING: ["DRAFTED", "PLANNED", "IMPLEMENTED", "DECLINED", "QUALIFIED", "CANCELLED"],
58
+ QUALIFIED: ["DEFERRED", "INTEGRATING", "CANCELLED"],
59
+ DEFERRED: ["QUALIFIED", "CANCELLED"],
60
+ INTEGRATING: ["DRAFTED", "PLANNED", "QUALIFIED", "DEFERRED", "INTEGRATED", "CANCELLED"],
61
+ INTEGRATED: [],
62
+ CANCELLED: []
63
+ }
64
+ }
65
+ };
66
+ /* the effective lifecycle status of a header: its "Status" key
67
+ (upper-cased), or the initial state of the model if absent */
68
+ export const taskStatus = (header, lifecycle) => {
69
+ const status = header.Status;
70
+ if (typeof status === "string" && status.trim() !== "")
71
+ return status.trim().toUpperCase();
72
+ return lifecycle.initial;
73
+ };
74
+ /* check a "Status" change against the task lifecycle model: returns
75
+ an error (else empty) if the changed status is not a state of the
76
+ model or not reachable from the old status via one or more transitions
77
+ (as one operation may perform several stages); an old status foreign
78
+ to the model may change to any state, to allow its correction */
79
+ export const checkStatus = (lifecycle, from, to) => {
80
+ if (to === from)
81
+ return "";
82
+ if (!lifecycle.states.includes(to))
83
+ return `status "${to}" is not a state of the "${lifecycle.name}" task lifecycle model ` +
84
+ `(expected one of: ${lifecycle.states.join(", ")})`;
85
+ if (!lifecycle.states.includes(from))
86
+ return "";
87
+ const seen = new Set([from]);
88
+ const queue = [from];
89
+ while (queue.length > 0) {
90
+ const state = queue.shift();
91
+ if (state === to)
92
+ return "";
93
+ for (const next of lifecycle.transitions[state] ?? [])
94
+ if (!seen.has(next)) {
95
+ seen.add(next);
96
+ queue.push(next);
97
+ }
98
+ }
99
+ return `status "${to}" is not reachable from status "${from}" in the "${lifecycle.name}" task lifecycle model`;
100
+ };
101
+ /* the states of the task lifecycle models, each mapped onto its candidate
102
+ states in another model (tried after the state itself), where absent
103
+ states fall back to the initial (resp. first finished) state only */
104
+ const statusCandidates = {
105
+ PLANNING: ["OPEN"],
106
+ PLANNED: ["PLANNING", "OPEN"],
107
+ IMPLEMENTING: ["OPEN"],
108
+ STALLED: ["OPEN"],
109
+ DECLINED: ["IMPLEMENTING", "OPEN"],
110
+ IMPLEMENTED: ["INTEGRATED", "CLOSED"],
111
+ QUALIFYING: ["IMPLEMENTED", "CLOSED"],
112
+ QUALIFIED: ["IMPLEMENTED", "CLOSED"],
113
+ DEFERRED: ["IMPLEMENTED", "CLOSED"],
114
+ INTEGRATING: ["IMPLEMENTED", "CLOSED"],
115
+ INTEGRATED: ["IMPLEMENTED", "CLOSED"],
116
+ CLOSED: ["INTEGRATED", "IMPLEMENTED"]
117
+ };
118
+ /* map a status from one task lifecycle model onto another: the first
119
+ candidate state present in the target model wins (for a finished
120
+ status only a finished one), else the initial (resp. first finished)
121
+ state; a status foreign to the source model is kept as-is */
122
+ export const mapStatus = (from, to, status) => {
123
+ if (!from.states.includes(status))
124
+ return status;
125
+ const finished = from.finished.includes(status);
126
+ const mapped = [status, ...(statusCandidates[status] ?? [])]
127
+ .find((state) => to.states.includes(state) && (!finished || to.finished.includes(state)));
128
+ return mapped ?? (finished ? to.finished[0] : to.initial);
129
+ };
130
+ /* adapt a status onto a task lifecycle model: a state of the model is kept,
131
+ a state foreign to it is mapped onto it via the first model knowing the
132
+ state (see "mapStatus"), and a status unknown to all models falls back
133
+ to the initial state of the model */
134
+ export const adaptStatus = (lifecycle, status) => {
135
+ if (lifecycle.states.includes(status))
136
+ return status;
137
+ const source = Object.values(taskLifecycles).find((model) => model.states.includes(status));
138
+ return source !== undefined ? mapStatus(source, lifecycle, status) : lifecycle.initial;
139
+ };
140
+ /* resolve an "include" and an "exclude" comma-separated lifecycle
141
+ state list into the effective state set a task plan has to be in
142
+ to be listed at all; the "none" sentinel and empty tokens are
143
+ silently dropped, the "finished" sentinel expands to the finished
144
+ states of the task lifecycle model, an empty "include" list means
145
+ all states, and the "exclude" list is applied after the "include" list */
146
+ export const resolveStates = (lifecycle, include, exclude) => {
147
+ const parse = (list) => list.split(",")
148
+ .map((token) => token.trim())
149
+ .filter((token) => token !== "" && token.toUpperCase() !== "NONE")
150
+ .flatMap((token) => {
151
+ const state = token.toUpperCase();
152
+ if (state === "FINISHED")
153
+ return lifecycle.finished;
154
+ if (!lifecycle.states.includes(state))
155
+ throw new Error(`invalid state "${token}" ` +
156
+ `(expected one of: ${lifecycle.states.join(", ")}, or "finished")`);
157
+ return [state];
158
+ });
159
+ const included = parse(include);
160
+ const excluded = parse(exclude);
161
+ const states = (included.length > 0 ? included : lifecycle.states)
162
+ .filter((state) => !excluded.includes(state));
163
+ if (states.length === 0)
164
+ throw new Error("\"include\" and \"exclude\" cancel out to an empty state set");
165
+ return states;
166
+ };
167
+ /* the id pattern of projects and sessions, and the one of tasks
168
+ (additionally allowing "#", as in ids like "#42", but no leading "-" or "_") */
169
+ export const ID_RE = /^[A-Za-z0-9_-]+$/;
170
+ export const TASK_ID_RE = /^[A-Za-z0-9#][A-Za-z0-9#_-]*$/;
171
+ /* the reserved task id of the default task of a session */
172
+ export const DEFAULT_TASK_ID = "default";
173
+ /* the task id scheme callbacks, each mapping the optional argument
174
+ <arg> of "<kind>[:<arg>]" onto the scheme or an error message */
175
+ const idSchemes = {
176
+ slug: (arg) => {
177
+ if (arg !== undefined && !/^\d+$/.test(arg))
178
+ return "word count has to be a number";
179
+ const words = arg !== undefined ? Number.parseInt(arg, 10) : 2;
180
+ if (words < 1 || words > 16)
181
+ return "word count has to be 1-16";
182
+ return { kind: "slug", words, match: `^[a-z][a-z0-9]*(?:-[a-z0-9]+){0,${words - 1}}(?:-[0-9]+)?$` };
183
+ },
184
+ seq: (arg) => {
185
+ const m = /^((?:[A-Za-z#][A-Za-z#_-]*)?)%(?:0(\d+))?d([A-Za-z#_-]*)$/.exec(arg ?? "%d");
186
+ if (m === null)
187
+ return "template has to contain exactly one \"%d\" or \"%0<width>d\" " +
188
+ "and otherwise only the characters [A-Za-z#_-] (but not start with \"-\" or \"_\")";
189
+ const width = m[2] !== undefined ? Number.parseInt(m[2], 10) : 0;
190
+ if (width > 16)
191
+ return "width has to be 0-16";
192
+ /* prefix and suffix need no quoting, as [A-Za-z#_-] are no metacharacters */
193
+ const match = `^${m[1]}([0-9]${width > 0 ? `{${width},}` : "+"})${m[3]}$`;
194
+ return { kind: "seq", prefix: m[1], width, suffix: m[3], match };
195
+ },
196
+ any: (arg) => {
197
+ if (arg !== undefined)
198
+ return "scheme takes no argument";
199
+ return { kind: "any", match: TASK_ID_RE.source };
200
+ }
201
+ };
202
+ /* parse a task id scheme specification */
203
+ export const parseIdScheme = (spec) => {
204
+ const m = /^([a-z]+)(?::(.*))?$/.exec(spec);
205
+ const callback = m !== null && Object.hasOwn(idSchemes, m[1]) ? idSchemes[m[1]] : undefined;
206
+ if (m === null || callback === undefined)
207
+ throw new Error(`invalid task id scheme "${spec}" (expected "slug[:<words>]", "seq[:<template>]", or "any")`);
208
+ const scheme = callback(m[2]);
209
+ if (typeof scheme === "string")
210
+ throw new Error(`invalid task id scheme "${spec}" (${scheme})`);
211
+ return scheme;
212
+ };
213
+ /* check a task id scheme specification: returns an error (else empty) */
214
+ export const checkIdScheme = (spec) => {
215
+ try {
216
+ parseIdScheme(spec);
217
+ return "";
218
+ }
219
+ catch (err) {
220
+ return err instanceof Error ? err.message : String(err);
221
+ }
222
+ };
223
+ /* derive a slug from a title: diacritics stripped (and "ß" expanded), lower-cased,
224
+ and the first <words> alphanumeric words, starting with the first word
225
+ beginning with a letter, joined with "-" */
226
+ export const slugify = (title, words) => {
227
+ const list = title.normalize("NFKD").replace(/\p{M}/gu, "").replace(/ß/g, "ss").toLowerCase()
228
+ .split(/[^a-z0-9]+/).filter((word) => word !== "");
229
+ const i = list.findIndex((word) => /^[a-z]/.test(word));
230
+ return i < 0 ? "" : list.slice(i, i + words).join("-");
231
+ };
232
+ /* check whether a task id conforms to a task id scheme
233
+ (the reserved default task id always conforms) */
234
+ export const idConforms = (scheme, id) => id === DEFAULT_TASK_ID || new RegExp(scheme.match).test(id);
235
+ /* the warning (else empty) about a task id not conforming to a task id scheme */
236
+ export const idWarning = (spec, id) => idConforms(parseIdScheme(spec), id) ? "" :
237
+ `task id "${id}" does not conform to the task id scheme "${spec}" (see "project.task.idscheme")`;
238
+ /* the sequence number of a task id conforming to a "seq" task id scheme
239
+ (else 0, as for all ids under any other scheme or beyond the safe integer range) */
240
+ export const seqNumber = (scheme, id) => {
241
+ if (scheme.kind !== "seq")
242
+ return 0;
243
+ const n = Number.parseInt(new RegExp(scheme.match).exec(id)?.[1] ?? "0", 10);
244
+ return Number.isSafeInteger(n) ? n : 0;
245
+ };
246
+ /* determine the next free task id of a task id scheme, given all existing
247
+ task ids: for "seq" the highest number of all conforming ids and of the
248
+ high-water mark "seqmark" (of the removed ids) plus one, for "slug" the slug
249
+ of the title, and for "any" the proposed id (sanitized, else the slug of the
250
+ title), where a taken slug or id gets a numeric suffix */
251
+ export const nextTaskId = (scheme, ids, title = "", proposal = "", seqmark = 0) => {
252
+ const taken = new Set([...ids, DEFAULT_TASK_ID]);
253
+ let base;
254
+ if (scheme.kind === "seq") {
255
+ const n = ids.reduce((max, id) => Math.max(max, seqNumber(scheme, id)), seqmark) + 1;
256
+ return `${scheme.prefix}${String(n).padStart(scheme.width, "0")}${scheme.suffix}`;
257
+ }
258
+ else if (scheme.kind === "slug")
259
+ base = slugify(title, scheme.words);
260
+ else
261
+ base = proposal.trim().replace(/\s+/g, "-").replace(/[^A-Za-z0-9#_-]/g, "").replace(/^[_-]+/, "") || slugify(title, 2);
262
+ if (base === "")
263
+ throw new Error(`task id scheme "${scheme.kind}" requires a ` +
264
+ (scheme.kind === "any" ? "proposed id or " : "") + "title with at least one word beginning with a letter");
265
+ let id = base;
266
+ for (let n = 2; taken.has(id); n++)
267
+ id = `${base}-${n}`;
268
+ return id;
269
+ };
270
+ /* compare two task ids in natural order (numbers by their value) */
271
+ export const compareIds = (a, b) => a.localeCompare(b, "en", { numeric: true });
272
+ /* the task id of a task text: the value of the "Id:" key of its frontmatter,
273
+ or the empty string if absent */
274
+ export const taskTextId = (text) => {
275
+ const fm = /^---\r?\n([\s\S]*?\r?\n)---\r?\n/.exec(text);
276
+ return fm === null ? "" : (/^Id:[ \t]*(.*)$/m.exec(fm[1])?.[1].trim() ?? "");
277
+ };
278
+ /* the fixed value of the "Type" frontmatter key */
279
+ export const TASK_TYPE = "text/vnd.ase.task";
280
+ /* the frontmatter keys of the task plan format in their canonical
281
+ order, the array-typed ones among them, and the attachment keys
282
+ in their canonical order (see "ase-format-task.md") */
283
+ const frontKeys = ["Type", "Id", "Created", "Modified", "Group", "Phase", "After", "Status", "Assignee", "Kind", "Effort", "Tags", "Source", "Changeset", "Target"];
284
+ export const arrayKeys = ["After", "Tags"];
285
+ const attachKeys = ["Type", "Desc", "Created", "Modified", "Data", "File"];
286
+ /* the legacy task plan header lines, each mapped onto the
287
+ frontmatter key which superseded it */
288
+ const legacy = [
289
+ { key: "Created", re: /^⎈[ \t]+Created:[ \t]*(.*)$/m },
290
+ { key: "Modified", re: /^⚙[ \t]+Modified:[ \t]*(.*)$/m },
291
+ { key: "Kind", re: /^☯[ \t]+Kind:[ \t]*(.*)$/m }
292
+ ];
293
+ /* the legacy lifecycle states of the pre-lifecycle-model task plan
294
+ format, each mapped onto its candidate states in the current models
295
+ (first candidate present in the configured model wins, otherwise
296
+ the initial state of the model is used) */
297
+ const legacyStates = {
298
+ DRAFTED: [],
299
+ REJECTED: [],
300
+ APPROVED: ["PLANNED", "PLANNING", "OPEN"],
301
+ DEFERRED: ["SHELVED"],
302
+ STARTED: ["IMPLEMENTING", "OPEN"],
303
+ BLOCKED: ["STALLED", "OPEN"],
304
+ COMPLETED: ["INTEGRATED", "IMPLEMENTED", "CLOSED"],
305
+ CANCELLED: ["CANCELLED"]
306
+ };
307
+ /* migrate the legacy "Branch:" key (the branch the change set lands on, created
308
+ from HEAD on demand) into "Changeset:" (created from the source on demand),
309
+ where the legacy "current" (the checked-out branch) is the default "worktree":
310
+ returns the "Changeset:" value, or undefined if the key is to be dropped only */
311
+ export const migrateBranch = (branch) => branch !== "" && branch !== "current" ? `branch:${branch}` : undefined;
312
+ /* migrate the legacy "preflight" kind of an implementation draft attachment
313
+ into "draft" (in place) */
314
+ export const migrateAttachment = (att) => {
315
+ if (att.Type !== undefined && /^text\/x-diff\s*(?:;|$)/i.test(att.Type))
316
+ att.Type = att.Type.replace(/(;\s*kind\s*=\s*)("?)preflight\2(\s*(?:;|$))/i, "$1$2draft$2$3");
317
+ return att;
318
+ };
319
+ /* render a single key line with a column-aligned key
320
+ (a key too long for the column keeps at least one separating space) */
321
+ const keyLine = (key, value) => {
322
+ if (!/^[A-Za-z]+$/.test(key) || /[\r\n]/.test(value))
323
+ throw new Error(`invalid key line for key "${key}" (key has to be alphabetic and value single-line)`);
324
+ return value === "" ? key + ":" : (key + ":").padEnd(Math.max(11, key.length + 2)) + value;
325
+ };
326
+ /* the key line pattern of the frontmatter and attachment blocks
327
+ (value to be right-trimmed by the caller, avoiding regex backtracking) */
328
+ const keyLineRe = /^([A-Za-z]+):[ \t]*(.*)$/;
329
+ /* parse the frontmatter block of a plan text into its key/value pairs,
330
+ keeping any non-key lines for a verbatim pass-through, plus the
331
+ length of the block; returns null if the text carries no block */
332
+ const parseFront = (text) => {
333
+ const fm = /^---\r?\n([\s\S]*?\r?\n)---\r?\n/.exec(text);
334
+ if (fm === null)
335
+ return null;
336
+ const keys = new Map();
337
+ const other = [];
338
+ for (const line of fm[1].split(/\r?\n/)) {
339
+ const m = keyLineRe.exec(line);
340
+ if (m !== null)
341
+ keys.set(m[1], m[2].trimEnd());
342
+ else if (line !== "")
343
+ other.push(line);
344
+ }
345
+ return { keys, other, length: fm[0].length };
346
+ };
347
+ /* order keys canonically, with any unknown keys trailing */
348
+ const orderKeys = (canonical, keys) => [...canonical.filter((key) => keys.includes(key)), ...keys.filter((key) => !canonical.includes(key))];
349
+ /* re-assemble a frontmatter block with the keys in canonical order
350
+ (unknown keys and non-key lines trailing) and the values re-aligned */
351
+ const assembleFront = (keys, other) => {
352
+ const front = orderKeys(frontKeys, [...keys.keys()]).map((key) => keyLine(key, keys.get(key)));
353
+ return `---\n${[...front, ...other].join("\n")}\n---\n`;
354
+ };
355
+ /* normalize a legacy task plan text into the current Markdown frontmatter
356
+ shape, so every consumer sees a single plan shape only: a plan
357
+ carrying its metadata in the "# TASK <id>: <title>" heading and
358
+ the "⎈"/"⚙"/"☯" glyph header lines is first lifted into a
359
+ frontmatter block; then the frontmatter block is migrated by
360
+ inserting the mandatory "Type:" key, mapping a legacy "Status:" value
361
+ onto the configured lifecycle model, rewriting the legacy "Properties:"
362
+ key into "Tags:", rewriting the legacy "Branch:" key into "Changeset:",
363
+ and re-ordering and re-aligning the keys; any content without a
364
+ frontmatter block or task heading is passed through verbatim; absent
365
+ optional keys are never materialized, as they read as their default
366
+ value */
367
+ export const normalizeTaskText = (id, text, lifecycle) => {
368
+ if (text === "")
369
+ return text;
370
+ /* lift the heading and the glyph header lines into their frontmatter
371
+ keys, with the task id taken from the authoritative filename-derived id */
372
+ if (!/^---\r?\n/.test(text)) {
373
+ const heading = /^#[ \t]+TASK(?:[ \t]+[A-Za-z0-9#][A-Za-z0-9#_-]*)?[ \t]*:[ \t]*(.*)$/m.exec(text);
374
+ if (heading === null)
375
+ return text;
376
+ let body = text.replace(heading[0], "");
377
+ const front = [keyLine("Id", id)];
378
+ for (const entry of legacy) {
379
+ const m = entry.re.exec(body);
380
+ if (m === null)
381
+ continue;
382
+ front.push(keyLine(entry.key, m[1].trim()));
383
+ body = body.replace(m[0], "");
384
+ }
385
+ text = `---\n${front.join("\n")}\n---\n\n` +
386
+ `# TASK: ${heading[1].trim()}\n\n` +
387
+ body.replace(/^(?:[ \t]*\r?\n)+/, "");
388
+ }
389
+ /* parse the frontmatter block into its key/value pairs */
390
+ const fm = parseFront(text);
391
+ if (fm === null)
392
+ return text;
393
+ const keys = fm.keys;
394
+ /* a plan is legacy if it lacks the mandatory "Type:" key (or still
395
+ carries the "Properties:" key), decided before "Type:" is inserted */
396
+ const isLegacy = !keys.has("Type") || keys.has("Properties");
397
+ /* insert the mandatory "Type:" key */
398
+ if (!keys.has("Type"))
399
+ keys.set("Type", TASK_TYPE);
400
+ /* map a legacy "Status:" value unconditionally onto the configured
401
+ lifecycle model, as legacy values like "DEFERRED" carry
402
+ a different meaning than their same-named current states */
403
+ const status = keys.get("Status");
404
+ if (isLegacy && status !== undefined && Object.hasOwn(legacyStates, status)) {
405
+ const state = legacyStates[status].find((state) => lifecycle.states.includes(state));
406
+ keys.set("Status", state ?? lifecycle.initial);
407
+ }
408
+ /* rewrite the legacy "Properties:" key into "Tags:", where the
409
+ legacy "grilled" property (a fully grilled plan) becomes one
410
+ "grilled:" tag per section and any other property a plain tag */
411
+ const properties = keys.get("Properties");
412
+ if (properties !== undefined) {
413
+ keys.delete("Properties");
414
+ const tags = properties.split(",")
415
+ .map((token) => token.trim())
416
+ .filter((token) => token !== "" && token.toLowerCase() !== "none")
417
+ .flatMap((token) => token.toLowerCase() === "grilled" ?
418
+ ["grilled:specification", "grilled:design", "grilled:verification"] : [token]);
419
+ if (tags.length > 0)
420
+ keys.set("Tags", [keys.get("Tags") ?? "", ...tags].filter((tag) => tag !== "").join(", "));
421
+ }
422
+ /* rewrite the legacy "Branch:" key into "Changeset:" (see migrateBranch) */
423
+ const branch = keys.get("Branch");
424
+ if (branch !== undefined) {
425
+ keys.delete("Branch");
426
+ const changeset = migrateBranch(branch);
427
+ if (changeset !== undefined && !keys.has("Changeset"))
428
+ keys.set("Changeset", changeset);
429
+ }
430
+ /* re-assemble the frontmatter block, followed by the untouched
431
+ remainder of the plan */
432
+ return assembleFront(keys, fm.other) + text.slice(fm.length);
433
+ };
434
+ /* track the fenced code block state across a body line: returns the
435
+ opening fence, or an empty string if outside any fenced code block */
436
+ export const fenceTrack = (fence, line) => {
437
+ const m = /^ {0,3}(`{3,}|~{3,})[ \t]*(.*)$/.exec(line);
438
+ if (m !== null && fence === "")
439
+ return m[1];
440
+ else if (m !== null && m[2] === "" && m[1][0] === fence[0] && m[1].length >= fence.length)
441
+ return "";
442
+ return fence;
443
+ };
444
+ /* check whether a body survives a round-trip through the task plan text:
445
+ returns null, or the reason why the parser would misread it -- a "---"
446
+ line outside a fenced code block followed by a "Type:" line (read as an
447
+ attachment start) or an unclosed fenced code block (swallowing all attachments) */
448
+ export const bodyConflict = (body) => {
449
+ const lines = body.split(/\r?\n/);
450
+ let fence = "";
451
+ let open = 0;
452
+ for (let i = 0; i < lines.length; i++) {
453
+ const prev = fence;
454
+ fence = fenceTrack(fence, lines[i]);
455
+ if (prev === "" && fence !== "")
456
+ open = i + 1;
457
+ if (lines[i] === "---" && fence === "" && /^Type:/.test(lines[i + 1] ?? ""))
458
+ return `line ${i + 1}: a "---" line followed by a "Type:" line would start an attachment`;
459
+ }
460
+ return fence !== "" ? `line ${open}: fenced code block is not closed` : null;
461
+ };
462
+ /* parse a task plan text (after legacy normalization) into the JSON
463
+ task plan structure: the text is split into blocks at its "---"
464
+ lines, the first block yields the header (with "After" and "Tags"
465
+ split into arrays and "Id" taken from the authoritative id), the
466
+ second block the body (without its leading and trailing empty line),
467
+ and every further block an attachment (with a "Data: |4+" or "|4-" literal
468
+ block scalar de-indented and a legacy "preflight" diff kind migrated into
469
+ "draft"); a text without a closed frontmatter is taken as body;
470
+ a "---" line not starting a block stays part of the body */
471
+ export const parseTaskText = (id, text, lifecycle) => {
472
+ text = normalizeTaskText(id, text, lifecycle);
473
+ const lines = text.split(/\r?\n/);
474
+ if (lines.length > 0 && lines[lines.length - 1] === "")
475
+ lines.pop();
476
+ const blocks = [];
477
+ let fence = "";
478
+ for (let i = 0; i < lines.length; i++) {
479
+ /* track the fenced code blocks of the body */
480
+ if (blocks.length >= 2)
481
+ fence = fenceTrack(fence, lines[i]);
482
+ /* beyond the frontmatter, a "---" line starts a block only outside a
483
+ fenced code block and if followed by the mandatory "Type:" key of an
484
+ attachment -- else it is kept as body (lossless for legacy plans) */
485
+ if (lines[i] === "---" && lines[0] === "---" && fence === ""
486
+ && (blocks.length < 2 || /^Type:/.test(lines[i + 1] ?? "")))
487
+ blocks.push([]);
488
+ else if (blocks.length > 0)
489
+ blocks[blocks.length - 1].push(lines[i]);
490
+ }
491
+ const header = {};
492
+ let body;
493
+ const attachment = [];
494
+ if (blocks.length < 2)
495
+ body = text;
496
+ else {
497
+ for (const line of blocks[0]) {
498
+ const m = keyLineRe.exec(line);
499
+ if (m === null)
500
+ continue;
501
+ header[m[1]] = arrayKeys.includes(m[1]) ?
502
+ m[2].split(",").map((token) => token.trim()).filter((token) => token !== "") :
503
+ m[2].trimEnd();
504
+ }
505
+ const bodyLines = blocks[1] ?? [];
506
+ if (bodyLines[0] === "")
507
+ bodyLines.shift();
508
+ while (bodyLines.length > 0 && bodyLines[bodyLines.length - 1] === "")
509
+ bodyLines.pop();
510
+ body = bodyLines.length > 0 ? `${bodyLines.join("\n")}\n` : "";
511
+ for (const block of blocks.slice(2)) {
512
+ /* drop one trailing empty line of the block, as it separates the block
513
+ from the next one (written by formatTaskText only behind a block ending
514
+ with an empty line, but typically added by hand, too) */
515
+ if (block.length > 0 && block[block.length - 1] === "")
516
+ block.pop();
517
+ const att = {};
518
+ for (let i = 0; i < block.length; i++) {
519
+ const m = keyLineRe.exec(block[i]);
520
+ if (m === null)
521
+ continue;
522
+ if (m[1] === "Data" && m[2].startsWith("|")) {
523
+ const data = [];
524
+ while (i + 1 < block.length && (block[i + 1] === "" || block[i + 1].startsWith(" ")))
525
+ data.push(block[++i].slice(4));
526
+ /* honor the "strip" chomping indicator of a "|4-" block scalar */
527
+ if (m[2].trimEnd().endsWith("-")) {
528
+ while (data.length > 0 && data[data.length - 1] === "")
529
+ data.pop();
530
+ att.Data = data.join("\n");
531
+ }
532
+ else
533
+ att.Data = `${data.join("\n")}\n`;
534
+ }
535
+ else
536
+ att[m[1]] = m[2].trimEnd();
537
+ }
538
+ attachment.push(migrateAttachment(att));
539
+ }
540
+ }
541
+ header.Type ??= TASK_TYPE;
542
+ header.Id = id;
543
+ return { header, body, attachment };
544
+ };
545
+ /* format the header of the JSON task plan structure into the key lines
546
+ of the task plan text (without the enclosing "---" lines): the keys in
547
+ canonical order (unknown keys trailing) with the array values comma-joined */
548
+ export const formatTaskHeader = (header) => {
549
+ const value = (v) => Array.isArray(v) ? v.join(", ") : v;
550
+ return orderKeys(frontKeys, Object.keys(header).filter((key) => header[key] !== undefined))
551
+ .map((key) => `${keyLine(key, value(header[key]))}\n`).join("");
552
+ };
553
+ /* format the JSON task plan structure into the task plan text: the
554
+ header (see formatTaskHeader), the body enclosed in its leading and
555
+ trailing empty line, and every attachment as its own block with
556
+ "Data" rendered as a "|4+" (or, without trailing newline, "|4-")
557
+ literal block scalar, as an empty value if empty, or as the elision
558
+ marker itself if elided, and a block ending with an empty line (of its
559
+ "Data") followed by a separating empty line (see parseTaskText) */
560
+ export const formatTaskText = (plan) => {
561
+ let text = `---\n${formatTaskHeader(plan.header)}---\n\n`;
562
+ if (plan.body !== "")
563
+ text += plan.body.endsWith("\n") ? `${plan.body}\n` : `${plan.body}\n\n`;
564
+ for (const att of plan.attachment) {
565
+ const keys = orderKeys(attachKeys, Object.keys(att).filter((key) => att[key] !== undefined));
566
+ text += "---\n";
567
+ for (const key of keys) {
568
+ if (key === "Data" && (att.Data === "" || isElided(att.Data)))
569
+ text += `${keyLine("Data", att.Data)}\n`;
570
+ else if (key === "Data") {
571
+ const keep = att.Data.endsWith("\n");
572
+ const data = att.Data.split("\n");
573
+ if (keep)
574
+ data.pop();
575
+ text += `${keyLine("Data", keep ? "|4+" : "|4-")}\n`;
576
+ for (const line of data)
577
+ text += line === "" ? "\n" : ` ${line}\n`;
578
+ }
579
+ else
580
+ text += `${keyLine(key, att[key])}\n`;
581
+ }
582
+ if (text.endsWith("\n\n"))
583
+ text += "\n";
584
+ }
585
+ return text;
586
+ };
587
+ /* the marker of an elided "Data" of an attachment, carrying its number of lines */
588
+ const elidedRe = /^\[elided: \d+ lines?\]$/;
589
+ export const isElided = (data) => data !== undefined && elidedRe.test(data);
590
+ /* elide the non-empty "Data" of all attachments of a task plan, i.e., replace it
591
+ by a marker, so the plan can be passed through an LLM without its potentially
592
+ huge attachment payloads, and the marker be restored on saving (see restoreAttachments) */
593
+ export const elideAttachments = (plan) => ({
594
+ ...plan,
595
+ attachment: plan.attachment.map((att) => {
596
+ if (att.Data === undefined || att.Data === "" || isElided(att.Data))
597
+ return att;
598
+ const lines = att.Data.replace(/\n$/, "").split("\n").length;
599
+ return { ...att, Data: `[elided: ${lines} line${lines === 1 ? "" : "s"}]` };
600
+ })
601
+ });
602
+ /* restore the elided "Data" of the attachments of a task plan from the stored plan:
603
+ each elided attachment takes the "Data" of the first not yet taken stored attachment
604
+ of the same "Type", preferably also of the same "Created" (so the attachments can be
605
+ reordered or removed meanwhile); throws if no stored attachment matches */
606
+ export const restoreAttachments = (plan, stored) => {
607
+ if (!plan.attachment.some((att) => isElided(att.Data)))
608
+ return plan;
609
+ const taken = new Set();
610
+ const take = (att, created) => (stored?.attachment ?? []).findIndex((s, i) => !taken.has(i) && s.Type === att.Type
611
+ && s.Data !== undefined && !isElided(s.Data) && (!created || s.Created === att.Created));
612
+ return {
613
+ ...plan,
614
+ attachment: plan.attachment.map((att) => {
615
+ if (!isElided(att.Data))
616
+ return att;
617
+ let i = take(att, true);
618
+ if (i < 0)
619
+ i = take(att, false);
620
+ if (i < 0)
621
+ throw new Error(`elided attachment of type "${att.Type ?? ""}" not found in the stored task plan ` +
622
+ "(an elided attachment can only be kept, but not be created or copied)");
623
+ taken.add(i);
624
+ return { ...att, Data: stored.attachment[i].Data };
625
+ })
626
+ };
627
+ };
628
+ /* the type of the usage attachment of a task plan, carrying its AI usage accounting,
629
+ which is owned by the task store: changed by bookings only, but never by any other save */
630
+ export const USAGE_TYPE = "application/yaml; kind=\"usage\"";
631
+ /* whether an attachment is the usage attachment of a task plan */
632
+ export const isUsage = (att) => (att.Type ?? "").trim() === USAGE_TYPE;
633
+ /* a task plan without its usage attachments */
634
+ export const withoutUsage = (plan) => plan.attachment.some(isUsage) ? { ...plan, attachment: plan.attachment.filter((att) => !isUsage(att)) } : plan;
635
+ /* keep the usage attachments of the stored plan in a task plan to be saved: each usage
636
+ attachment of the plan is replaced (in place) by the next stored one or else dropped,
637
+ and the remaining stored ones are appended, so no save can change them */
638
+ export const keepUsage = (plan, stored) => {
639
+ const kept = stored.attachment.filter(isUsage);
640
+ if (kept.length === 0 && !plan.attachment.some(isUsage))
641
+ return plan;
642
+ const attachment = [];
643
+ for (const att of plan.attachment) {
644
+ if (!isUsage(att))
645
+ attachment.push(att);
646
+ else if (kept.length > 0)
647
+ attachment.push(kept.shift());
648
+ }
649
+ return { ...plan, attachment: [...attachment, ...kept] };
650
+ };
651
+ /* the amounts of the AI usage accounting of a task, as "<group>.<field>" of the YAML data of
652
+ its usage attachment: the AI request time, the human time, and the real (wall-clock) time
653
+ (in minutes), the cache read, cache write, (uncached) input, and output tokens, and the AI
654
+ and human costs (in USD) */
655
+ export const usageKeys = [
656
+ "time.ai", "time.hr", "time.real",
657
+ "token.cread", "token.cwrite", "token.input", "token.output",
658
+ "cost.ai", "cost.hr"
659
+ ];
660
+ /* parse the YAML data of a usage attachment into its amounts (leniently,
661
+ i.e., with unparsable data and non-numeric amounts taken as absent) */
662
+ export const usageOf = (data) => {
663
+ let doc;
664
+ try {
665
+ doc = YAML.parse(data);
666
+ }
667
+ catch {
668
+ doc = null;
669
+ }
670
+ const groups = (typeof doc === "object" && doc !== null ? doc : {});
671
+ const usage = {};
672
+ for (const key of usageKeys) {
673
+ const [group, field] = key.split(".");
674
+ const amount = Number(groups[group]?.[field]);
675
+ if (Number.isFinite(amount))
676
+ usage[key] = amount;
677
+ }
678
+ return usage;
679
+ };
680
+ /* render the amounts of a usage attachment as its YAML data: one flow mapping per group */
681
+ export const usageData = (usage) => {
682
+ const groups = new Map();
683
+ for (const key of usageKeys) {
684
+ const [group, field] = key.split(".");
685
+ groups.set(group, [...(groups.get(group) ?? []), `${field}: ${String(usage[key] ?? 0)}`]);
686
+ }
687
+ return Array.from(groups, ([group, fields]) => `${(group + ":").padEnd(7)} { ${fields.join(", ")} }\n`).join("");
688
+ };
689
+ /* the T-shirt sizes of the effort estimation of a task plan (its "Effort" key), in ascending order */
690
+ export const effortSizes = ["XS", "S", "M", "L", "XL"];
691
+ /* the default upper band bounds of the sizes "XS" to "L" (in human minutes,
692
+ see "project.task.effort.scale"), on a logarithmic scale of factor 4 */
693
+ export const EFFORT_SCALE = "15,60,240,960";
694
+ /* parse the upper band bounds "<xs>,<s>,<m>,<l>" of the sizes "XS" to "L"
695
+ (strictly ascending positive human minutes), else throw */
696
+ export const parseEffortScale = (spec) => {
697
+ const bounds = spec.split(",").map((bound) => bound.trim());
698
+ if (bounds.length !== 4 || !bounds.every((bound) => /^\d+(?:\.\d+)?$/.test(bound)))
699
+ throw new Error(`invalid effort scale "${spec}" (expected four comma-separated numbers of minutes)`);
700
+ const scale = bounds.map((bound) => Number(bound));
701
+ if (!scale.every((bound, i) => bound > (i > 0 ? scale[i - 1] : 0)))
702
+ throw new Error(`invalid effort scale "${spec}" (expected strictly ascending positive bounds)`);
703
+ return scale;
704
+ };
705
+ /* the representative human minutes of a size, for summing up efforts: the midpoint
706
+ of its band, where the open-ended "XL" counts twice the upper bound of "L" */
707
+ export const effortMinutes = (size, scale) => {
708
+ const i = effortSizes.indexOf(size);
709
+ return i < 4 ? ((i > 0 ? scale[i - 1] : 0) + scale[i]) / 2 : 2 * scale[3];
710
+ };
711
+ /* parse the value "<size> [<hash>]" of an "Effort" key into its size and its
712
+ content hash (empty if not stamped yet), or null if malformed */
713
+ export const parseEffort = (value) => {
714
+ const m = /^(XS|S|M|L|XL)(?:[ \t]+([0-9A-Za-z]+))?$/.exec(value.trim());
715
+ return m === null ? null : { size: m[1], hash: m[2] ?? "" };
716
+ };
717
+ /* the content hash of a task plan body, the "Effort" key is bound to: the body with
718
+ its bullet-points in the inert checkbox states "[-]" and "[>]" left out entirely, the
719
+ checkboxes of all other bullet-points left out, and all whitespace left out, so checkbox
720
+ state changes and re-wrapping keep the hash, while every other content change (including
721
+ cancelling or deferring a bullet-point) changes it */
722
+ export const effortHash = (body) => {
723
+ const content = [];
724
+ let inert = false;
725
+ for (const line of body.split(/\r?\n/)) {
726
+ const m = /^[-*+][ \t]+\[([ ?/xX>-])\]/.exec(line);
727
+ if (m !== null)
728
+ inert = m[1] === "-" || m[1] === ">";
729
+ else if (!/^[ \t]+\S/.test(line))
730
+ inert = false;
731
+ if (!inert)
732
+ content.push(line.replace(/^([ \t]*[-*+][ \t]+)\[[ ?/xX>-]\]/, "$1"));
733
+ }
734
+ return nanoid(`effort:${content.join("").replace(/\s+/g, "")}`);
735
+ };
736
+ /* bind the "Effort" key of a task plan to its body (in place): a key without
737
+ content hash is stamped with the content hash of the body, while a malformed
738
+ or stale key (whose content hash differs from the one of the body) is dropped */
739
+ export const bindEffort = (plan) => {
740
+ const value = plan.header.Effort;
741
+ if (value === undefined)
742
+ return plan;
743
+ const effort = typeof value === "string" ? parseEffort(value) : null;
744
+ const hash = effortHash(plan.body);
745
+ if (effort !== null && effort.hash === "")
746
+ plan.header.Effort = `${effort.size} ${hash}`;
747
+ else if (effort === null || effort.hash !== hash)
748
+ Reflect.deleteProperty(plan.header, "Effort");
749
+ return plan;
750
+ };
751
+ /* derive the title of a task plan from its body as defined by the
752
+ "Task titles" convention: the text following "TASK:" on the first
753
+ "# TASK: <title>" heading line, or the empty string if absent */
754
+ export const taskTitle = (body) => {
755
+ const m = /^#[ \t]+TASK:[ \t]*(.*)$/m.exec(body);
756
+ return m === null ? "" : m[1].trim();
757
+ };