@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,739 @@
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 path from "node:path";
7
+ import fs from "node:fs";
8
+ import { watch } from "chokidar";
9
+ import lockfile from "proper-lockfile";
10
+ import { DateTime } from "luxon";
11
+ import writeFileAtomic from "write-file-atomic";
12
+ import YAML from "yaml";
13
+ import * as v from "valibot";
14
+ import { ensureAseGitignore } from "./ase-config-scope.js";
15
+ import { Task } from "./ase-task.js";
16
+ import { Problem } from "./ase-task-store-core.js";
17
+ import * as TaskFormat from "./ase-task-format.js";
18
+ import { relaySpec } from "./ase-agent-relay-client.js";
19
+ import { AgentRelayClient } from "./ase-agent-relay-client.js";
20
+ import { editOf } from "./ase-task-request.js";
21
+ /* the marker of the state of the edit requests of a card, enclosed in brackets
22
+ (else empty): requested, being processed, or failed */
23
+ export const editMarker = (card) => card.edit === "pending" ? "[✎]" : card.edit === "active" ? "[✎…]" : card.edit === "failed" ? "[✎✗]" : "";
24
+ /* the marker of the effort size of a card: the size behind "⚖",
25
+ enclosed in brackets (else empty if not estimated) */
26
+ export const effortMarker = (card) => card.effort !== "" ? `[⚖${card.effort}]` : "";
27
+ /* the displayed names of the views (the persisted view ids being kept short) */
28
+ export const viewLabels = {
29
+ lanes: "task-lanes",
30
+ graph: "task-graph",
31
+ stats: "statistics",
32
+ agents: "agent-logs",
33
+ flow: "agent-graph"
34
+ };
35
+ /* the cycle order of the views (with the agent views only with an agent relay), and
36
+ the action of the key hint cycling them forward ("v") and backward ("V") */
37
+ export const viewOrder = (relay) => relay ? ["lanes", "graph", "stats", "agents", "flow"] : ["lanes", "graph", "stats"];
38
+ export const viewCycleHint = (view, relay) => {
39
+ const views = viewOrder(relay);
40
+ const i = views.indexOf(view);
41
+ return `view ${viewLabels[views[(i + 1) % views.length]]}/${viewLabels[views[(i + views.length - 1) % views.length]]}`;
42
+ };
43
+ /* shorthand for a lane specification */
44
+ const lane = (status, active, weight) => ({ status, active, weight, dashed: status === "CANCELLED" });
45
+ /* the fixed lane layouts per lifecycle model, mirroring the drawing
46
+ "docs/task-states.graffle": within a group the entry state comes
47
+ first, the active state second, and the parking state last, while the
48
+ finished states form the final group "Done" */
49
+ export const laneLayouts = {
50
+ solo: [
51
+ { title: "1 - Work in Progress", lanes: [lane("OPEN", true, 1), lane("SHELVED", false, 1)] },
52
+ { title: "2 - Done", lanes: [lane("CLOSED", false, 1), lane("CANCELLED", false, 1)] }
53
+ ],
54
+ team: [
55
+ { title: "1 - Planning", lanes: [lane("PLANNING", true, 1), lane("SHELVED", false, 1)] },
56
+ { title: "2 - Implementation", lanes: [lane("IMPLEMENTING", true, 1), lane("STALLED", false, 1)] },
57
+ { title: "3 - Done", lanes: [lane("IMPLEMENTED", false, 1), lane("CANCELLED", false, 1)] }
58
+ ],
59
+ enterprise: [
60
+ { title: "1 - Planning", lanes: [lane("DRAFTED", false, 2.0), lane("PLANNING", true, 2.0), lane("SHELVED", false, 2.0)] },
61
+ { title: "2 - Implementation", lanes: [lane("PLANNED", false, 1.0), lane("IMPLEMENTING", true, 3.0), lane("STALLED", false, 2.0)] },
62
+ { title: "3 - Qualification", lanes: [lane("IMPLEMENTED", false, 2.0), lane("QUALIFYING", true, 2.0), lane("DECLINED", false, 2.0)] },
63
+ { title: "4 - Integration", lanes: [lane("QUALIFIED", false, 1.0), lane("INTEGRATING", true, 3.0), lane("DEFERRED", false, 2.0)] },
64
+ { title: "5 - Done", lanes: [lane("INTEGRATED", false, 5.0), lane("CANCELLED", false, 1.0)] }
65
+ ]
66
+ };
67
+ /* the kind of a lifecycle state, as defined by the lifecycle model itself */
68
+ export const kindOf = (lifecycle, status) => status === lifecycle.initial ? "initial" :
69
+ lifecycle.finished.includes(status) ? "terminal" : "regular";
70
+ /* resolve the lane layout of a lifecycle model and check it against the
71
+ model, so every state of the model occurs in exactly one lane, and
72
+ annotate each lane with the kind of its state */
73
+ export const layoutOf = (lifecycle) => {
74
+ const groups = laneLayouts[lifecycle.name];
75
+ if (groups === undefined)
76
+ throw new Error(`board: no lane layout for lifecycle model "${lifecycle.name}"`);
77
+ const seen = groups.flatMap((g) => g.lanes.map((l) => l.status));
78
+ const missing = lifecycle.states.filter((s) => !seen.includes(s));
79
+ const extra = seen.filter((s, i) => !lifecycle.states.includes(s) || seen.indexOf(s) !== i);
80
+ if (missing.length > 0 || extra.length > 0)
81
+ throw new Error(`board: lane layout of lifecycle model "${lifecycle.name}" does not match ` +
82
+ `its states (missing: ${missing.join(", ") || "none"}, surplus: ${extra.join(", ") || "none"})`);
83
+ return groups.map((g) => ({
84
+ title: g.title,
85
+ lanes: g.lanes.map((l) => ({ ...l, kind: kindOf(lifecycle, l.status) }))
86
+ }));
87
+ };
88
+ /* the lane states of a lane row: the lanes at the same position in all expanded phases */
89
+ export const rowLanes = (groups, collapsed, l) => groups.filter((g) => l < g.lanes.length && !collapsed.includes(g.title)).map((g) => g.lanes[l].status);
90
+ /* split a height into integer shares by weights, where each positively
91
+ weighted share first gets a minimum (as far as the total allows), the
92
+ rest is split by weights, and the remainder goes to the first such share */
93
+ export const splitHeight = (total, weights, min = 0) => {
94
+ const sum = weights.reduce((n, w) => n + w, 0);
95
+ if (sum <= 0 || total <= 0)
96
+ return weights.map(() => 0);
97
+ const n = weights.filter((w) => w > 0).length;
98
+ const base = Math.min(min, Math.floor(total / n));
99
+ const rest = total - base * n;
100
+ const hs = weights.map((w) => w > 0 ? base + Math.floor(rest * w / sum) : 0);
101
+ hs[weights.findIndex((w) => w > 0)] += total - hs.reduce((n, h) => n + h, 0);
102
+ return hs;
103
+ };
104
+ /* the valibot schema of the persisted board state */
105
+ const surfaceSchema = v.object({
106
+ view: v.optional(v.picklist(["lanes", "graph", "stats", "agents", "flow"]), "lanes"),
107
+ minimized: v.optional(v.array(v.string()), []),
108
+ collapsed: v.optional(v.array(v.string()), []),
109
+ /* (a string of an older state file maps "all" onto true, else false) */
110
+ titles: v.optional(v.pipe(v.union([v.boolean(), v.string()]), v.transform((t) => typeof t === "boolean" ? t : t === "all")), false),
111
+ keys: v.optional(v.boolean(), true),
112
+ standalone: v.optional(v.boolean(), true)
113
+ });
114
+ const stateSchema = v.object({
115
+ tui: v.optional(surfaceSchema, { view: "lanes", minimized: [], collapsed: [], titles: false, keys: true, standalone: true }),
116
+ web: v.optional(surfaceSchema, { view: "lanes", minimized: [], collapsed: [], titles: false, keys: true, standalone: true })
117
+ });
118
+ /* reusable functionality: the persisted per-project board state in
119
+ ".ase/board.yaml", holding the view state of the TUI and web
120
+ surfaces, which is display state only and hence never written into any task */
121
+ export class BoardState {
122
+ /* resolve the state file of the current project */
123
+ static filename() {
124
+ return path.join(Task.projectRoot(), ".ase", "board.yaml");
125
+ }
126
+ /* parse a state file text, falling back to the empty state on any error */
127
+ static parse(text) {
128
+ try {
129
+ return v.parse(stateSchema, YAML.parse(text) ?? {});
130
+ }
131
+ catch {
132
+ return v.parse(stateSchema, {});
133
+ }
134
+ }
135
+ /* load the state of the current project */
136
+ static load() {
137
+ const file = BoardState.filename();
138
+ return BoardState.parse(fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "");
139
+ }
140
+ /* update the state under a cross-process lock, writing the file only
141
+ if its content actually changed; returns the updated state */
142
+ static async update(fn) {
143
+ const file = BoardState.filename();
144
+ fs.mkdirSync(path.dirname(file), { recursive: true });
145
+ ensureAseGitignore(path.dirname(file));
146
+ /* ensure the lockable file exists without truncating a concurrently written one */
147
+ fs.appendFileSync(file, "", "utf8");
148
+ /* wait for a concurrent holder (e.g. TUI vs. web service) instead of failing with ELOCKED */
149
+ const release = await lockfile.lock(file, {
150
+ retries: { retries: 50, minTimeout: 20, maxTimeout: 200 }
151
+ });
152
+ try {
153
+ const before = fs.readFileSync(file, "utf8");
154
+ const state = BoardState.parse(before);
155
+ fn(state);
156
+ const after = YAML.stringify(state);
157
+ if (after !== before)
158
+ writeFileAtomic.sync(file, after, { encoding: "utf8" });
159
+ return state;
160
+ }
161
+ finally {
162
+ await release();
163
+ }
164
+ }
165
+ /* toggle an entry of a surface list (minimized lanes or collapsed phases), resp. multiple
166
+ entries at once (all removed if all are listed already, else all missing ones added) */
167
+ static toggle(surface, list, entry) {
168
+ return BoardState.update((state) => {
169
+ const items = state[surface][list];
170
+ if (Array.isArray(entry)) {
171
+ if (entry.every((e) => items.includes(e)))
172
+ state[surface][list] = items.filter((e) => !entry.includes(e));
173
+ else
174
+ items.push(...entry.filter((e) => !items.includes(e)));
175
+ return;
176
+ }
177
+ const i = items.indexOf(entry);
178
+ if (i >= 0)
179
+ items.splice(i, 1);
180
+ else
181
+ items.push(entry);
182
+ });
183
+ }
184
+ /* toggle the showing of task titles, key hints, or standalone tasks */
185
+ static toggleFlag(surface, flag) {
186
+ return BoardState.update((state) => {
187
+ state[surface][flag] = !state[surface][flag];
188
+ });
189
+ }
190
+ /* set the shown view (lanes, graph, stats, or agents) */
191
+ static setView(surface, view) {
192
+ return BoardState.update((state) => {
193
+ state[surface].view = view;
194
+ });
195
+ }
196
+ }
197
+ /* compute the strongly connected components of a graph (Tarjan) */
198
+ const components = (nodes, pred) => {
199
+ const index = new Map();
200
+ const low = new Map();
201
+ const stack = [];
202
+ const onStk = new Set();
203
+ const result = [];
204
+ let next = 0;
205
+ const visit = (n) => {
206
+ index.set(n, next);
207
+ low.set(n, next);
208
+ next++;
209
+ stack.push(n);
210
+ onStk.add(n);
211
+ for (const p of pred.get(n) ?? []) {
212
+ if (!index.has(p)) {
213
+ visit(p);
214
+ low.set(n, Math.min(low.get(n), low.get(p)));
215
+ }
216
+ else if (onStk.has(p))
217
+ low.set(n, Math.min(low.get(n), index.get(p)));
218
+ }
219
+ if (low.get(n) === index.get(n)) {
220
+ const comp = [];
221
+ let m;
222
+ do {
223
+ m = stack.pop();
224
+ onStk.delete(m);
225
+ comp.push(m);
226
+ } while (m !== n);
227
+ result.push(comp);
228
+ }
229
+ };
230
+ for (const n of nodes)
231
+ if (!index.has(n))
232
+ visit(n);
233
+ return result;
234
+ };
235
+ /* the neutral order of cards: by creation time, then by task id */
236
+ export const byCreation = (a, b) => a.created.localeCompare(b.created) || TaskFormat.compareIds(a.id, b.id);
237
+ /* the label text of a card: the task id and, if existing, its title
238
+ (made safe for the terminal) behind a filled arrow */
239
+ export const cardLabel = (card) => {
240
+ const title = card.title.replace(/\t/g, " ").replace(/\p{Cc}/gu, "").trim();
241
+ return title !== "" ? `${card.id} ▶ ${title}` : card.id;
242
+ };
243
+ /* the group label of a card (made safe for the terminal), or empty if it has no group */
244
+ export const groupLabel = (card) => card.group.replace(/\s+/g, " ").replace(/\p{Cc}/gu, "").trim();
245
+ /* determine whether an attachment type is a diff (like an implementation draft or a change set snapshot) */
246
+ export const isDiff = (type) => /^text\/x-diff\s*(?:;|$)/i.test(type.trim());
247
+ /* the tab labels of a task view: the task plan, followed by its attachments
248
+ with the "kind" parameter of their type (if any) behind a filled arrow,
249
+ a "(stale)" marker for a change set snapshot older than the "Modified"
250
+ timestamp of the plan (as it was captured for an earlier plan), and
251
+ their description (if any, cut after 30 characters) behind a hollow arrow
252
+ (all made safe for the terminal) */
253
+ export const attachmentTabs = (atts, modified = "") => {
254
+ const safe = (s) => s.replace(/\t/g, " ").replace(/\p{Cc}/gu, "").trim();
255
+ return [
256
+ "0 ▶ plan",
257
+ ...atts.map((att, i) => {
258
+ const m = /;\s*kind\s*=\s*(?:"([^"]*)"|([^;\s]+))/i.exec(att.type);
259
+ const kind = safe(m !== null ? (m[1] ?? m[2]) : "");
260
+ const desc = safe(att.desc);
261
+ const text = desc.length > 30 ? desc.slice(0, 30) + "…" : desc;
262
+ const stale = isDiff(att.type) && kind === "changeset" && modified !== "" && (att.modified ?? "") < modified;
263
+ return `${i + 1}` + (kind !== "" ? ` ▶ ${kind}` : "") + (stale ? " (stale)" : "") + (text !== "" ? ` ▷ ${text}` : "");
264
+ })
265
+ ];
266
+ };
267
+ export const diffTones = (lines) => {
268
+ let hunk = false;
269
+ return lines.map((line, i) => {
270
+ if (line.startsWith("diff ") || (line.startsWith("--- ") && (lines[i + 1] ?? "").startsWith("+++ ")))
271
+ hunk = false;
272
+ if (line.startsWith("@@"))
273
+ hunk = true;
274
+ if (!hunk || line.startsWith("@@"))
275
+ return "head";
276
+ return line.startsWith("+") ? "add" : line.startsWith("-") ? "del" : "ctx";
277
+ });
278
+ };
279
+ export const dataLang = (type) => {
280
+ const m = /^(?:application|text)\/(?:x-|[\w.-]+\+)?(yaml|json)\s*(?:;|$)/i.exec(type.trim());
281
+ return m !== null ? m[1].toLowerCase() : null;
282
+ };
283
+ /* the syntax highlighter of structured data in YAML or JSON (via Shiki, with
284
+ its JavaScript regular expression engine, i.e. without WebAssembly, and
285
+ with an empty theme, as only the TextMate scopes of the tokens are used),
286
+ loaded on demand only once */
287
+ let dataHighlighter = null;
288
+ let dataLoading = null;
289
+ export const dataLoad = () => {
290
+ if (dataLoading === null) {
291
+ dataLoading = (async () => {
292
+ const [{ createHighlighterCore }, { createJavaScriptRegexEngine }, { bundledLanguages }] = await Promise.all([
293
+ import("shiki/core"), import("shiki/engine/javascript"), import("shiki/langs")
294
+ ]);
295
+ dataHighlighter = await createHighlighterCore({
296
+ themes: [{ name: "ase", type: "dark", settings: [] }],
297
+ langs: [bundledLanguages.yaml, bundledLanguages.json],
298
+ engine: createJavaScriptRegexEngine()
299
+ });
300
+ })();
301
+ }
302
+ return dataLoading;
303
+ };
304
+ const dataScopes = [
305
+ ["cmt", ["comment"]],
306
+ ["punct", ["meta.directive"]],
307
+ ["key", ["entity.name.tag", "support.type.property-name"]],
308
+ ["num", ["constant.numeric"]],
309
+ ["lit", ["constant.language", "meta.property", "keyword.control.flow.alias", "variable.other.alias"]],
310
+ ["str", ["string"]],
311
+ ["punct", ["punctuation", "keyword", "storage", "entity.other.document"]]
312
+ ];
313
+ export const dataSpans = (text, lang) => {
314
+ if (dataHighlighter === null)
315
+ return text.split(/\r?\n/).map((line) => [{ text: line, tone: "text" }]);
316
+ const { tokens } = dataHighlighter.codeToTokens(text, { lang, theme: "ase", includeExplanation: "scopeName" });
317
+ return tokens.map((line) => {
318
+ const out = [];
319
+ for (const part of line.flatMap((token) => token.explanation ?? [{ content: token.content, scopes: [] }])) {
320
+ const scopes = part.scopes.map((scope) => scope.scopeName);
321
+ const tone = dataScopes.find(([, prefixes]) => scopes.some((scope) => prefixes.some((prefix) => scope === prefix || scope.startsWith(prefix + "."))))?.[0] ?? "text";
322
+ const last = out[out.length - 1];
323
+ if (last !== undefined && last.tone === tone)
324
+ last.text += part.content;
325
+ else
326
+ out.push({ text: part.content, tone });
327
+ }
328
+ return out;
329
+ });
330
+ };
331
+ /* the placeholder columns in the task box labels (glued to the following
332
+ word for word-wrapping), in front of the task id, in front of the title,
333
+ and around and within the group (gluing it to the task id) */
334
+ export const glueId = String.fromCodePoint(0xE000);
335
+ export const glueTitle = String.fromCodePoint(0xE001);
336
+ export const glueGroup = String.fromCodePoint(0xE002);
337
+ /* word-wrap a text onto at most a maximum number of lines of a width,
338
+ hard-breaking too long words and ending a cut text with an ellipsis */
339
+ export const clampLines = (text, width, max) => {
340
+ const w = Math.max(1, width);
341
+ const lines = [];
342
+ for (let word of text.split(/\s+/).filter((s) => s !== "")) {
343
+ const last = lines.length - 1;
344
+ if (last >= 0 && lines[last].length + 1 + word.length <= w) {
345
+ lines[last] += " " + word;
346
+ continue;
347
+ }
348
+ while (word.length > w) {
349
+ lines.push(word.slice(0, w));
350
+ word = word.slice(w);
351
+ }
352
+ if (word !== "")
353
+ lines.push(word);
354
+ }
355
+ if (lines.length > max) {
356
+ lines.length = max;
357
+ lines[max - 1] = lines[max - 1].slice(0, w - 1).trimEnd() + "…";
358
+ }
359
+ return lines;
360
+ };
361
+ /* order the cards of one lane: dependency order among the cards of the
362
+ same lane (ignoring edges within a cycle), with the creation time and
363
+ then the task id breaking ties, and any remainder sorted by task id at the end */
364
+ const orderLane = (cards, pred, cyclicEdge) => {
365
+ const ids = new Set(cards.map((c) => c.id));
366
+ const deg = new Map();
367
+ const tie = byCreation;
368
+ for (const c of cards)
369
+ deg.set(c.id, (pred.get(c.id) ?? []).filter((p) => ids.has(p) && !cyclicEdge(p, c.id)).length);
370
+ const out = [];
371
+ const ready = cards.filter((c) => deg.get(c.id) === 0).sort(tie);
372
+ while (ready.length > 0) {
373
+ const c = ready.shift();
374
+ out.push(c);
375
+ for (const d of cards) {
376
+ if (!(pred.get(d.id) ?? []).includes(c.id) || cyclicEdge(c.id, d.id))
377
+ continue;
378
+ deg.set(d.id, deg.get(d.id) - 1);
379
+ if (deg.get(d.id) === 0) {
380
+ ready.push(d);
381
+ ready.sort(tie);
382
+ }
383
+ }
384
+ }
385
+ const rest = cards.filter((c) => !out.includes(c)).sort((a, b) => TaskFormat.compareIds(a.id, b.id));
386
+ return [...out, ...rest];
387
+ };
388
+ /* build the board of the current project exclusively through the task interface of ASE */
389
+ export const buildBoard = async (log) => {
390
+ const { lifecycle, items } = await Task.listHeaders(log);
391
+ const groups = layoutOf(lifecycle);
392
+ const warnings = [];
393
+ /* load all tasks, mapping a status foreign to the lifecycle model onto
394
+ it via the first model knowing the status (else its initial state) */
395
+ const cards = new Map();
396
+ for (const item of items) {
397
+ const keys = item.keys;
398
+ const after = [...new Set((keys.get("After") ?? "").split(",").map((s) => s.trim()).filter((s) => s !== ""))];
399
+ const status = TaskFormat.adaptStatus(lifecycle, item.status);
400
+ if (status !== item.status)
401
+ warnings.push(`task "${item.id}" has status "${item.status}" unknown to lifecycle model ` +
402
+ `"${lifecycle.name}" (shown as "${status}")`);
403
+ const group = (keys.get("Group") ?? "").trim();
404
+ const assignee = (keys.get("Assignee") ?? "").trim();
405
+ const effort = TaskFormat.parseEffort(keys.get("Effort") ?? "")?.size ?? "";
406
+ const created = keys.get("Created") ?? "";
407
+ const modified = keys.get("Modified") ?? "";
408
+ cards.set(item.id, { id: item.id, title: item.title, group, status, actual: item.status, edit: editOf(keys), assignee, effort, after, created, modified });
409
+ }
410
+ /* derive predecessors (known ones only) and successors */
411
+ const pred = new Map();
412
+ const succ = new Map();
413
+ for (const c of cards.values()) {
414
+ const known = [];
415
+ for (const p of c.after) {
416
+ if (cards.has(p))
417
+ known.push(p);
418
+ else
419
+ warnings.push(`task "${c.id}" references unknown predecessor "${p}"`);
420
+ }
421
+ /* an epic (a task being its own group) implicitly comes after all other tasks of its group */
422
+ if (c.group === c.id)
423
+ for (const m of cards.values())
424
+ if (m.group === c.id && m.id !== c.id && !known.includes(m.id))
425
+ known.push(m.id);
426
+ pred.set(c.id, known);
427
+ for (const p of known)
428
+ succ.set(p, [...(succ.get(p) ?? []), c.id]);
429
+ }
430
+ /* detect cycles */
431
+ const cyclic = new Set();
432
+ const sccOf = new Map();
433
+ for (const [i, comp] of components([...cards.keys()].sort(), pred).entries()) {
434
+ comp.forEach((id) => sccOf.set(id, i));
435
+ if (comp.length > 1 || (pred.get(comp[0]) ?? []).includes(comp[0])) {
436
+ comp.forEach((id) => cyclic.add(id));
437
+ warnings.push(`tasks ${comp.sort().map((id) => `"${id}"`).join(", ")} form a dependency cycle`);
438
+ }
439
+ }
440
+ const cyclicEdge = (a, b) => sccOf.get(a) === sccOf.get(b);
441
+ /* compute the graph levels (longest predecessor path, ignoring cyclic edges) */
442
+ const levels = new Map();
443
+ const level = (id, seen) => {
444
+ if (levels.has(id))
445
+ return levels.get(id);
446
+ seen.add(id);
447
+ let l = 0;
448
+ for (const p of pred.get(id) ?? [])
449
+ if (!seen.has(p) && !cyclicEdge(p, id))
450
+ l = Math.max(l, level(p, seen) + 1);
451
+ seen.delete(id);
452
+ levels.set(id, l);
453
+ return l;
454
+ };
455
+ for (const id of cards.keys())
456
+ level(id, new Set());
457
+ /* distribute the cards onto the lanes in display order */
458
+ const lanes = new Map();
459
+ for (const g of groups)
460
+ for (const l of g.lanes)
461
+ lanes.set(l.status, orderLane([...cards.values()].filter((c) => c.status === l.status), pred, cyclicEdge));
462
+ return { mode: lifecycle.name, groups, cards, lanes, pred, succ, levels, cyclic, context: new Set(), warnings };
463
+ };
464
+ export const toneOf = (board, card) => {
465
+ const lane = board.groups.flatMap((g) => g.lanes).find((l) => l.status === card.status);
466
+ return lane?.kind === "terminal" ? "done" : lane?.active === true ? "active" : "idle";
467
+ };
468
+ /* the lane states (except an own lane state) directly or indirectly
469
+ reachable from a status in the lifecycle model */
470
+ const reachableFrom = (board, lifecycle, from, own) => board.groups.flatMap((g) => g.lanes.map((l) => l.status))
471
+ .filter((to) => to !== own && TaskFormat.checkStatus(lifecycle, from, to) === "");
472
+ /* the lane states a card can be moved to, reachable from its actual status */
473
+ export const reachableStates = (board, lifecycle, card) => reachableFrom(board, lifecycle, card.actual, card.status);
474
+ /* the lane states a task can be moved to from each lane state */
475
+ export const laneMoves = (board, lifecycle) => Object.fromEntries(board.groups.flatMap((g) => g.lanes.map((l) => [l.status, reachableFrom(board, lifecycle, l.status, l.status)])));
476
+ /* the lane states a task with an actual status foreign to the lifecycle
477
+ model can be moved to (overriding the moves of its lane state) */
478
+ export const cardMoves = (board, lifecycle, card) => card.actual === card.status ? undefined : reachableStates(board, lifecycle, card);
479
+ /* the pre-filled text of a new task: all frontmatter keys (the optional ones
480
+ empty, except for the change set flow keys, left out to read their configured
481
+ defaults), the next free id of the task id scheme (derived from the sample title),
482
+ the initial state, and the body template of the task format with a sample
483
+ title, the three sections, and their placeholder items */
484
+ export const newTaskText = async (log, board, lifecycle) => {
485
+ const id = (await Task.newId(log, "New Task", "new-task", [...board.cards.keys()])).id;
486
+ const now = DateTime.now().toFormat("yyyy-LL-dd HH:mm");
487
+ return TaskFormat.formatTaskText({
488
+ header: {
489
+ Type: TaskFormat.TASK_TYPE, Id: id, Created: now, Modified: now, Group: "", Phase: "",
490
+ After: "", Status: lifecycle.initial, Assignee: "", Kind: "", Effort: "", Tags: ""
491
+ },
492
+ body: "# TASK: New Task\n\n" +
493
+ "## SPECIFICATION (WHAT)\n\n- [ ] DOM: **[...]**: [...]\n\n- [ ] IFC: **[...]**: [...]\n\n" +
494
+ "## DESIGN (HOW)\n\n- [ ] ARC: **[...]**: [...]\n\n- [ ] IMP: **[...]**: [...]\n\n" +
495
+ "## VERIFICATION (WHEN)\n\n- [ ] REG: **[...]**: [...]\n\n- [ ] CON: **[...]**: [...]\n",
496
+ attachment: []
497
+ });
498
+ };
499
+ /* the conflict of saving a task which was changed meanwhile, carrying
500
+ its current entity tag, or null if it was deleted meanwhile */
501
+ export class TaskConflict extends Error {
502
+ id;
503
+ tag;
504
+ constructor(id, tag) {
505
+ super(`task "${id}" was ${tag === null ? "deleted" : "changed"} meanwhile`);
506
+ this.id = id;
507
+ this.tag = tag;
508
+ }
509
+ }
510
+ /* save an edited task text (conditionally with an entity tag, throwing a
511
+ TaskConflict on a mismatch), renaming the task if the "Id:" key of its
512
+ frontmatter was changed (refusing an existing target id before anything
513
+ is saved); returns the resulting task id and a warning (else empty) if
514
+ it does not conform to the task id scheme */
515
+ export const saveTask = async (log, id, text, tag) => {
516
+ const next = TaskFormat.taskTextId(text) || id;
517
+ if (next !== id) {
518
+ if (!TaskFormat.TASK_ID_RE.test(next))
519
+ throw new Error(`invalid task id "${next}"`);
520
+ if (await Task.source(log, next) !== null)
521
+ throw new Error(`task "${next}" already exists`);
522
+ }
523
+ let warning;
524
+ try {
525
+ warning = (await Task.save(log, id, text, tag)).warning;
526
+ }
527
+ catch (err) {
528
+ if (err instanceof Problem && err.status === 412)
529
+ throw new TaskConflict(id, (await Task.source(log, id))?.tag ?? null);
530
+ throw err;
531
+ }
532
+ if (next !== id)
533
+ warning = (await Task.rename(log, id, next))?.warning ?? "";
534
+ return { id: next, warning };
535
+ };
536
+ /* create a task from its text under a new id, refusing an existing id,
537
+ and dropping the frontmatter keys left without a (non-blank) value;
538
+ returns a warning (else empty) if the id does not conform to the task id scheme */
539
+ export const createTask = async (log, id, text) => {
540
+ if (!TaskFormat.TASK_ID_RE.test(id))
541
+ throw new Error(`invalid task id "${id}"`);
542
+ if (await Task.source(log, id) !== null)
543
+ throw new Error(`task "${id}" already exists`);
544
+ text = text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, (fm) => fm.replace(/^[A-Za-z]+:[ \t]*\r?\n/gm, ""));
545
+ return (await Task.save(log, id, text, undefined, true)).warning;
546
+ };
547
+ /* watch the configuration files of a local task store (via their existing
548
+ directories, as a file may not exist yet), as its lifecycle model follows them */
549
+ const watchConfigs = (log, onChange) => {
550
+ const files = Task.configFiles(log);
551
+ const dirs = [...new Set(files.map((file) => path.dirname(file)))].filter((d) => fs.existsSync(d));
552
+ const watcher = watch(dirs, {
553
+ ignoreInitial: true,
554
+ depth: 0,
555
+ ignored: (file, stats) => stats !== undefined && !dirs.includes(file) && !files.includes(file)
556
+ });
557
+ watcher.on("all", () => {
558
+ Task.invalidate();
559
+ onChange();
560
+ });
561
+ watcher.on("error", (err) => {
562
+ log.write("warning", `board: config watcher: ${err instanceof Error ? err.message : String(err)}`);
563
+ });
564
+ return watcher;
565
+ };
566
+ /* watch the task store for changes and invoke the callback, debounced
567
+ and guarded against re-entrance: by subscribing to the change events of
568
+ the task store (for an in-process one incl. the external changes its storage
569
+ plugin detects, plus its configuration files; for a remote one incl. the
570
+ lifecycle model changes); reports the store state initially and on each
571
+ change of it; returns a function to stop watching */
572
+ export const watchTasks = (log, onChange, onStore) => {
573
+ const kind = Task.isRemote(log) ? "remote" : "local";
574
+ let configs = null;
575
+ let timer = null;
576
+ let running = false;
577
+ let pending = false;
578
+ let stopped = false;
579
+ const schedule = () => {
580
+ if (stopped)
581
+ return;
582
+ if (timer !== null)
583
+ clearTimeout(timer);
584
+ timer = setTimeout(() => {
585
+ timer = null;
586
+ fire().catch(() => { });
587
+ }, 150);
588
+ };
589
+ const fire = async () => {
590
+ if (running) {
591
+ pending = true;
592
+ return;
593
+ }
594
+ running = true;
595
+ try {
596
+ await onChange();
597
+ }
598
+ catch (err) {
599
+ log.write("warning", `board: refresh failed: ${err instanceof Error ? err.message : String(err)}`);
600
+ }
601
+ finally {
602
+ running = false;
603
+ if (pending) {
604
+ pending = false;
605
+ schedule();
606
+ }
607
+ }
608
+ };
609
+ /* the lifecycle model of an in-process task store follows the configuration */
610
+ if (kind === "local")
611
+ configs = watchConfigs(log, schedule);
612
+ onStore?.({ kind, connected: false });
613
+ const unsubscribe = Task.subscribe(log, schedule, (connected) => {
614
+ if (!stopped)
615
+ onStore?.({ kind, connected });
616
+ });
617
+ return async () => {
618
+ stopped = true;
619
+ if (timer !== null)
620
+ clearTimeout(timer);
621
+ unsubscribe();
622
+ await configs?.close();
623
+ };
624
+ };
625
+ /* resolve the agent relay of the project (from the given override, else from
626
+ "project.relay.url"), where a misconfigured one is warned about and ignored */
627
+ const boardRelay = (log, relay) => {
628
+ try {
629
+ return relaySpec(log, relay);
630
+ }
631
+ catch (err) {
632
+ log.write("warning", `board: ${err instanceof Error ? err.message : String(err)} -- agents not shown`);
633
+ return null;
634
+ }
635
+ };
636
+ /* the names of the requests processed by the agents announced to the agent relay (for their
637
+ suggestion on requesting a task by prompt): "edit" of the editor agent, and the request
638
+ names of the request agents of the definitions (e.g. of "ase agent adhoc") */
639
+ export const requestNames = (state) => [...new Set((state?.agents ?? []).map((agent) => agent.kind === "editor" ? "edit" : agent.request ?? "")
640
+ .filter((name) => name !== ""))].sort();
641
+ /* the use of the prompt of a request of a name by the agents announced to the agent relay
642
+ processing it: ignored if ignored by all of them (e.g. the "estimate" requests of "ase
643
+ agent estimator"), optional if optional or ignored by all of them (e.g. the "research"
644
+ requests of "ase agent researcher"), else (also without any such agent) required */
645
+ export const requestPrompt = (state, name) => {
646
+ const agents = (state?.agents ?? []).filter((agent) => (agent.kind === "editor" ? "edit" : agent.request ?? "") === name);
647
+ return agents.length === 0 || agents.some((agent) => agent.prompt === undefined) ? "required" :
648
+ agents.every((agent) => agent.prompt === "ignored") ? "ignored" : "optional";
649
+ };
650
+ /* whether an agent announced to the agent relay claims tasks by their assignee (for its
651
+ suggestion on assigning a task): not a request agent (which reacts on the requests of
652
+ a task only) and not an unclaimed phase agent (which never claims a task) */
653
+ export const assignable = (agent) => agent.kind !== "editor" && agent.request === undefined
654
+ && (agent.states === undefined || agent.states.activity !== undefined);
655
+ /* order the agents by name (and id, for equally named agents on different hosts) */
656
+ const byName = (a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id);
657
+ /* fetch the agents of the project once (null without an agent relay) */
658
+ export const fetchAgents = async (log, relay) => {
659
+ const spec = boardRelay(log, relay);
660
+ if (spec === null)
661
+ return null;
662
+ try {
663
+ return { connected: true, agents: (await new AgentRelayClient(log, spec).list()).sort(byName) };
664
+ }
665
+ catch (err) {
666
+ log.write("warning", `board: ${err instanceof Error ? err.message : String(err)}`);
667
+ return { connected: false, agents: [] };
668
+ }
669
+ };
670
+ /* watch the agents of the project via the agent relay and invoke the callback initially
671
+ and on each change of them or of the connection state, where no agent relay at all
672
+ is reported once as null; returns a function to stop watching */
673
+ export const watchAgents = (log, relay, onChange) => {
674
+ const spec = boardRelay(log, relay);
675
+ if (spec === null) {
676
+ onChange(null);
677
+ return () => { };
678
+ }
679
+ let state = { connected: false, agents: [] };
680
+ onChange(state);
681
+ return new AgentRelayClient(log, spec).subscribe((agents) => {
682
+ state = { ...state, agents: agents.sort(byName) };
683
+ onChange(state);
684
+ }, (connected) => {
685
+ state = { ...state, connected };
686
+ onChange(state);
687
+ });
688
+ };
689
+ /* a duration (in ms) in its coarsest unit, the label of an agent (its name, with its
690
+ differing kind), its user (its user id with its user name, resp. "n/a" for an
691
+ older agent), its uptime (the duration since its start, measured up to its latest
692
+ heartbeat), and its plain-text status (its uptime, its tasks, else "none" with its
693
+ state, its total and last usage of the agent tool, and its host and process id) */
694
+ export const agentDuration = (ms) => {
695
+ const s = Math.max(0, Math.floor(ms / 1000));
696
+ return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m` : s < 86400 ? `${Math.floor(s / 3600)}h` : `${Math.floor(s / 86400)}d`;
697
+ };
698
+ export const agentLabel = (agent) => agent.name !== agent.kind ? `${agent.name} (${agent.kind})` : agent.name;
699
+ export const agentUser = (agent) => agent.user === undefined ? "n/a" :
700
+ `${agent.uid !== undefined && agent.uid >= 0 ? agent.uid : "n/a"} (${agent.user !== "" ? agent.user : "unknown"})`;
701
+ export const agentUptime = (agent) => agentDuration(agent.beat - agent.started);
702
+ export const agentStatus = (agent) => `uptime: ${agentUptime(agent)} · ` +
703
+ `task: ${agent.tasks.length > 0 ? agent.tasks.join(", ") : `none (${agent.state})`} · ` +
704
+ `usage: ${agentUsage(agent.usage?.total)} (last: ${agentUsage(agent.usage?.last)}) · ` +
705
+ `host: ${agent.host} · pid: ${agent.pid}`;
706
+ /* a compact usage of the agent tool: its wall time, its output tokens (with
707
+ a metric suffix), and its costs (in USD), where an unavailable field is "n/a"
708
+ and no usage at all (before the first finished job) is "none" */
709
+ export const agentUsage = (usage) => {
710
+ if (usage === undefined)
711
+ return "none";
712
+ const tok = usage.costToken;
713
+ const tokens = tok === null ? "n/a" :
714
+ tok < 1000 ? `${tok}` : tok < 1000 * 1000 ? `${(tok / 1000).toFixed(1)}k` : `${(tok / (1000 * 1000)).toFixed(1)}M`;
715
+ const cost = usage.costUSD === null ? "$n/a" : `$${usage.costUSD.toFixed(2)}`;
716
+ return `${agentDuration(usage.walltimeSec * 1000)}, ${tokens} tok, ${cost}`;
717
+ };
718
+ /* the digest of the agents as shown outside the agents view (the agents with their
719
+ states and tasks, and the connection state), for detecting a visible change of them
720
+ (e.g. a mere heartbeat or new processing log lines are none) */
721
+ export const agentsDigest = (state) => state === undefined ? "" : state === null ? "null" :
722
+ JSON.stringify([state.connected, state.agents.map((a) => [a.id, a.kind, a.name, a.mode, a.state, a.tasks])]);
723
+ /* the agents busy with each task */
724
+ export const busyAgents = (state) => {
725
+ const busy = new Map();
726
+ for (const agent of state?.agents ?? [])
727
+ for (const id of agent.tasks)
728
+ busy.set(id, [...(busy.get(id) ?? []), agent]);
729
+ return busy;
730
+ };
731
+ export const badgesOf = (card, state, busy) => {
732
+ const clean = (text) => text.replace(/\p{Cc}/gu, "");
733
+ if (busy !== undefined && busy.length > 0)
734
+ return busy.map((agent) => ({ icon: "⚉", name: clean(agent.name) }));
735
+ if (card.assignee === "")
736
+ return [];
737
+ const agent = state?.agents.some((a) => a.name === card.assignee) ?? false;
738
+ return [{ icon: agent ? "⚉" : "☺", name: clean(card.assignee) }];
739
+ };