@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,484 @@
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 { parse as parseYAML } from "yaml";
9
+ import writeFileAtomic from "write-file-atomic";
10
+ import lockfile from "proper-lockfile";
11
+ import { watch } from "chokidar";
12
+ import * as TaskFormat from "./ase-task-format.js";
13
+ /* the task file pattern and the per-project registry file */
14
+ const TASK_FILE_RE = /^TASK-([A-Za-z0-9#][A-Za-z0-9#_-]*)\.md$/;
15
+ const PROJECT_FILE = "PROJECT.yaml";
16
+ /* check for the existence of a filesystem path (without blocking the event loop) */
17
+ const exists = (p) => fs.promises.access(p).then(() => true, () => false);
18
+ /* the built-in storage plugin: task plans persisted as "TASK-<id>.md"
19
+ files in the textual task format (see "ase-format-task.md"), either
20
+ flat in the base directory ("solo" mode, accepting any project id)
21
+ or below "<basedir>/<prjId>/", with the "PROJECT.yaml" file of the
22
+ directory serving as the project registry entry and carrying the
23
+ lifecycle model name, the task id scheme, and the sequence number
24
+ high-water mark */
25
+ class FileTaskStoragePlugin {
26
+ ctx;
27
+ name = "ase";
28
+ verbatim = true;
29
+ basedir;
30
+ solo;
31
+ lifecycle;
32
+ idscheme;
33
+ /* the change detection state: the listener, the directory watcher, the
34
+ observed projects (onto their directories), the stamps of the task plan
35
+ files per directory, and the debounced, non-overlapping rescanning */
36
+ listener = null;
37
+ watcher = null;
38
+ tracked = new Map();
39
+ stamps = new Map();
40
+ timer = null;
41
+ watching = false;
42
+ scanning = false;
43
+ pending = false;
44
+ constructor(ctx) {
45
+ this.ctx = ctx;
46
+ const options = ctx.options;
47
+ if (typeof options.basedir !== "string" || options.basedir === "")
48
+ throw new Error("task store: plugin \"ase\" requires the \"basedir\" option");
49
+ this.basedir = path.resolve(options.basedir);
50
+ this.solo = options.solo === true;
51
+ this.lifecycle = typeof options.lifecycle === "string" && options.lifecycle !== "" ? options.lifecycle : "solo";
52
+ this.idscheme = typeof options.idscheme === "string" && options.idscheme !== "" ? options.idscheme : "slug";
53
+ if (!Object.hasOwn(TaskFormat.taskLifecycles, this.lifecycle))
54
+ throw new Error(`task store: plugin "ase" received unknown lifecycle model "${this.lifecycle}"`);
55
+ if (TaskFormat.checkIdScheme(this.idscheme) !== "")
56
+ throw new Error(`task store: plugin "ase" received invalid task id scheme "${this.idscheme}"`);
57
+ }
58
+ /* the storage lifecycle (the base directory is not created upfront,
59
+ but on demand once the first file is stored into it), watching
60
+ the base directory for changes while opened and observed */
61
+ async open() {
62
+ if (this.listener !== null) {
63
+ this.watching = true;
64
+ this.arm();
65
+ }
66
+ this.ctx.log("debug", `opened base directory "${this.basedir}" ` +
67
+ `(storage: ${this.solo ? "single" : "multi"}, lifecycle: ${this.lifecycle})`);
68
+ }
69
+ async close() {
70
+ this.watching = false;
71
+ if (this.timer !== null)
72
+ clearTimeout(this.timer);
73
+ this.timer = null;
74
+ const watcher = this.watcher;
75
+ this.watcher = null;
76
+ await watcher?.close();
77
+ this.tracked.clear();
78
+ this.stamps.clear();
79
+ }
80
+ /* the directory of a project */
81
+ dir(prjId) {
82
+ return this.solo ? this.basedir : path.join(this.basedir, prjId);
83
+ }
84
+ /* the cross-process lock of a project, taken beside its directory
85
+ (as "<dir>.lock"), as the directory itself is created on demand only;
86
+ parent directories created for the lock are removed again afterwards,
87
+ unless the operation stored something into them */
88
+ async lock(prjId, op) {
89
+ const dir = this.dir(prjId);
90
+ const parent = path.resolve(path.dirname(dir));
91
+ let topmost;
92
+ const release = await lockfile.lock(dir, {
93
+ realpath: false,
94
+ retries: { retries: 50, minTimeout: 20, maxTimeout: 200 },
95
+ onCompromised: (err) => this.ctx.log("error", `lock of project "${prjId}" compromised: ${err.message}`),
96
+ fs: {
97
+ ...fs,
98
+ mkdir: (p, cb) => {
99
+ const attempt = (tries) => {
100
+ fs.mkdir(path.dirname(p), { recursive: true }, (err, made) => {
101
+ if (err !== null)
102
+ return cb(err);
103
+ if (made !== undefined && (topmost === undefined || made.length < topmost.length))
104
+ topmost = made;
105
+ fs.mkdir(p, (error) => {
106
+ /* retry if a concurrent cleanup removed the parent directory meanwhile */
107
+ if (error?.code === "ENOENT" && tries > 0)
108
+ return attempt(tries - 1);
109
+ cb(error);
110
+ });
111
+ });
112
+ };
113
+ attempt(3);
114
+ }
115
+ }
116
+ });
117
+ try {
118
+ return await op();
119
+ }
120
+ finally {
121
+ await release().catch((err) => this.ctx.log("warning", `unlock of project "${prjId}" failed: ${err.message}`));
122
+ if (topmost !== undefined)
123
+ for (let d = parent; d.length >= topmost.length; d = path.dirname(d))
124
+ try {
125
+ await fs.promises.rmdir(d);
126
+ }
127
+ catch {
128
+ break;
129
+ }
130
+ }
131
+ }
132
+ /* read the registry entry of a project directory: its lifecycle model
133
+ name, its task id scheme, the high-water mark of its sequence numbers,
134
+ and (in "solo" mode only) the id it was first registered under (a
135
+ malformed file falls back to the defaults and is flagged as broken) */
136
+ async projectOf(dir) {
137
+ const file = path.join(dir, PROJECT_FILE);
138
+ if (!await exists(file))
139
+ return { lifecycle: this.lifecycle, idscheme: this.idscheme, seqmark: 0 };
140
+ const text = await fs.promises.readFile(file, "utf8");
141
+ let doc;
142
+ try {
143
+ doc = parseYAML(text);
144
+ }
145
+ catch (err) {
146
+ this.ctx.log("warning", `malformed project file "${file}" ignored: ${err.message}`);
147
+ return { lifecycle: this.lifecycle, idscheme: this.idscheme, seqmark: 0, broken: true };
148
+ }
149
+ return {
150
+ ...(typeof doc?.id === "string" && TaskFormat.ID_RE.test(doc.id) ? { id: doc.id } : {}),
151
+ lifecycle: typeof doc?.lifecycle === "string" && doc.lifecycle !== "" ? doc.lifecycle : this.lifecycle,
152
+ idscheme: typeof doc?.idscheme === "string" && doc.idscheme !== "" ? doc.idscheme : this.idscheme,
153
+ seqmark: typeof doc?.seqmark === "number" && Number.isSafeInteger(doc.seqmark) && doc.seqmark > 0 ? doc.seqmark : 0
154
+ };
155
+ }
156
+ /* write the registry entry of a project directory, where in "solo" mode the id
157
+ of the first registration is kept, as any project id is accepted and a
158
+ differing one must not cause a rewrite of the file (all strings are
159
+ quoted, as e.g. the task id scheme can contain "#" and ":") */
160
+ async projectWrite(dir, id, lifecycle, idscheme, seqmark) {
161
+ await fs.promises.mkdir(dir, { recursive: true });
162
+ await writeFileAtomic(path.join(dir, PROJECT_FILE), (this.solo ? `id: ${JSON.stringify(id)}\n` : "") + `lifecycle: ${JSON.stringify(lifecycle)}\n` +
163
+ `idscheme: ${JSON.stringify(idscheme)}\n` +
164
+ (seqmark > 0 ? `seqmark: ${seqmark}\n` : ""), { encoding: "utf8" });
165
+ }
166
+ /* the project registry */
167
+ async projectList() {
168
+ const out = [];
169
+ if (this.solo) {
170
+ if (await exists(path.join(this.basedir, PROJECT_FILE))) {
171
+ const { id, lifecycle, idscheme, seqmark } = await this.projectOf(this.basedir);
172
+ out.push({ id: id ?? path.basename(this.basedir), lifecycle, idscheme, seqmark });
173
+ }
174
+ }
175
+ else if (await exists(this.basedir))
176
+ for (const entry of await fs.promises.readdir(this.basedir, { withFileTypes: true }))
177
+ if (entry.isDirectory() && TaskFormat.ID_RE.test(entry.name) && await exists(path.join(this.basedir, entry.name, PROJECT_FILE))) {
178
+ const { lifecycle, idscheme, seqmark } = await this.projectOf(path.join(this.basedir, entry.name));
179
+ out.push({ id: entry.name, lifecycle, idscheme, seqmark });
180
+ }
181
+ return out;
182
+ }
183
+ async projectGet(prjId) {
184
+ const dir = this.dir(prjId);
185
+ if (!await exists(path.join(dir, PROJECT_FILE)))
186
+ return null;
187
+ const { lifecycle, idscheme, seqmark } = await this.projectOf(dir);
188
+ await this.track(prjId);
189
+ return { id: prjId, lifecycle, idscheme, seqmark };
190
+ }
191
+ async projectSet(prjId, lifecycle, idscheme) {
192
+ const dir = this.dir(prjId);
193
+ const result = await exists(path.join(dir, PROJECT_FILE)) ? "updated" : "created";
194
+ const prev = await this.projectOf(dir);
195
+ if (result === "created" || prev.broken || prev.lifecycle !== lifecycle || prev.idscheme !== idscheme)
196
+ await this.projectWrite(dir, prev.id ?? prjId, lifecycle, idscheme, prev.seqmark);
197
+ await this.track(prjId);
198
+ return result;
199
+ }
200
+ async projectMark(prjId, seqmark) {
201
+ const dir = this.dir(prjId);
202
+ const prev = await this.projectOf(dir);
203
+ if (seqmark !== prev.seqmark)
204
+ await this.projectWrite(dir, prev.id ?? prjId, prev.lifecycle, prev.idscheme, seqmark);
205
+ }
206
+ async projectDelete(prjId) {
207
+ const dir = this.dir(prjId);
208
+ const file = path.join(dir, PROJECT_FILE);
209
+ if (!await exists(file))
210
+ return false;
211
+ this.untrack(prjId);
212
+ /* unregister the project, but keep its directory as long as it still carries any files */
213
+ await fs.promises.rm(file, { force: true });
214
+ if ((await fs.promises.readdir(dir)).length === 0)
215
+ await fs.promises.rmdir(dir);
216
+ return true;
217
+ }
218
+ /* the task plan file of a project and the lifecycle model its
219
+ legacy content is normalized against */
220
+ file(prjId, taskId) {
221
+ return path.join(this.dir(prjId), `TASK-${taskId}.md`);
222
+ }
223
+ async model(prjId) {
224
+ const name = (await this.projectOf(this.dir(prjId))).lifecycle;
225
+ return Object.hasOwn(TaskFormat.taskLifecycles, name) ? TaskFormat.taskLifecycles[name] : TaskFormat.taskLifecycles[this.lifecycle];
226
+ }
227
+ /* the listing entry of a task plan file of a directory (null if missing or empty) */
228
+ async entry(dir, taskId, lifecycle) {
229
+ const file = path.join(dir, `TASK-${taskId}.md`);
230
+ const st = await fs.promises.stat(file).catch(() => null);
231
+ if (st === null || !st.isFile())
232
+ return null;
233
+ const text = await fs.promises.readFile(file, "utf8");
234
+ if (text === "")
235
+ return null;
236
+ const plan = TaskFormat.parseTaskText(taskId, text, lifecycle);
237
+ return { id: taskId, title: TaskFormat.taskTitle(plan.body), header: plan.header, mtime: st.mtime };
238
+ }
239
+ /* the task plans */
240
+ async taskList(prjId) {
241
+ const dir = this.dir(prjId);
242
+ if (!await exists(dir))
243
+ return [];
244
+ const lifecycle = await this.model(prjId);
245
+ const out = [];
246
+ for (const entry of await fs.promises.readdir(dir)) {
247
+ const m = TASK_FILE_RE.exec(entry);
248
+ if (m === null)
249
+ continue;
250
+ const task = await this.entry(dir, m[1], lifecycle);
251
+ if (task !== null)
252
+ out.push(task);
253
+ }
254
+ return out;
255
+ }
256
+ async taskLoad(prjId, taskId) {
257
+ const file = this.file(prjId, taskId);
258
+ if (!await exists(file))
259
+ return null;
260
+ /* an empty task file reads as no task (as before the task store) */
261
+ const text = await fs.promises.readFile(file, "utf8");
262
+ if (text === "")
263
+ return null;
264
+ return TaskFormat.parseTaskText(taskId, text, await this.model(prjId));
265
+ }
266
+ async taskSave(prjId, taskId, plan) {
267
+ const file = this.file(prjId, taskId);
268
+ const result = await exists(file) ? "updated" : "created";
269
+ await fs.promises.mkdir(path.dirname(file), { recursive: true });
270
+ await writeFileAtomic(file, TaskFormat.formatTaskText(plan), { encoding: "utf8" });
271
+ await this.written(prjId, taskId);
272
+ return result;
273
+ }
274
+ async taskDelete(prjId, taskId) {
275
+ const file = this.file(prjId, taskId);
276
+ if (!await exists(file))
277
+ return false;
278
+ await fs.promises.rm(file, { force: true });
279
+ await this.written(prjId, taskId);
280
+ return true;
281
+ }
282
+ async taskRename(prjId, oldId, newId) {
283
+ const oldFile = this.file(prjId, oldId);
284
+ const newFile = this.file(prjId, newId);
285
+ if (!await exists(oldFile))
286
+ return false;
287
+ /* move atomically first and rewrite the header afterwards, as a crash
288
+ in between is harmless: parsing takes the "Id" from the filename */
289
+ await fs.promises.rename(oldFile, newFile);
290
+ const plan = TaskFormat.parseTaskText(newId, await fs.promises.readFile(newFile, "utf8"), await this.model(prjId));
291
+ await writeFileAtomic(newFile, TaskFormat.formatTaskText(plan), { encoding: "utf8" });
292
+ await this.written(prjId, oldId, newId);
293
+ return true;
294
+ }
295
+ /* the referenced attachment files, confined to the project directory */
296
+ async fileRead(prjId, file) {
297
+ const dir = await fs.promises.realpath(this.dir(prjId)).catch(() => null);
298
+ const full = dir !== null ? await fs.promises.realpath(path.resolve(dir, file)).catch(() => null) : null;
299
+ if (dir === null || full === null || !full.startsWith(dir + path.sep))
300
+ return null;
301
+ const st = await fs.promises.stat(full).catch(() => null);
302
+ if (st === null || !st.isFile())
303
+ return null;
304
+ return fs.promises.readFile(full);
305
+ }
306
+ /* ==== change detection ==== */
307
+ /* observe the changes of the task plan files made outside of this plugin instance */
308
+ watch(listener) {
309
+ this.listener = listener;
310
+ }
311
+ /* watch the base directory, or, as long as it does not exist yet, its
312
+ nearest existing ancestor for the creation of the next path step
313
+ towards it, re-arming once it appears (chokidar alone misses this) */
314
+ arm() {
315
+ if (!this.watching)
316
+ return;
317
+ const dir = this.basedir;
318
+ let w;
319
+ if (fs.existsSync(dir)) {
320
+ w = watch(dir, {
321
+ ignoreInitial: true,
322
+ depth: this.solo ? 0 : 1,
323
+ ignored: (file, stats) => (file !== dir && file.endsWith(".lock"))
324
+ || (stats?.isFile() === true && !TASK_FILE_RE.test(path.basename(file)))
325
+ });
326
+ w.on("all", () => this.schedule());
327
+ }
328
+ else {
329
+ let base = path.dirname(dir);
330
+ while (!fs.existsSync(base) && path.dirname(base) !== base)
331
+ base = path.dirname(base);
332
+ const next = path.join(base, path.relative(base, dir).split(path.sep)[0]);
333
+ w = watch(base, {
334
+ ignoreInitial: true,
335
+ depth: 0,
336
+ ignored: (file) => file !== base && file !== next
337
+ });
338
+ const rearm = () => {
339
+ if (this.watcher !== w)
340
+ return;
341
+ this.watcher = null;
342
+ w.close().catch(() => { }).finally(() => {
343
+ this.arm();
344
+ this.schedule();
345
+ });
346
+ };
347
+ w.on("addDir", (file) => {
348
+ if (file === next)
349
+ rearm();
350
+ });
351
+ w.on("ready", () => {
352
+ if (fs.existsSync(next))
353
+ rearm();
354
+ });
355
+ }
356
+ w.on("error", (err) => {
357
+ this.ctx.log("warning", `watcher: ${err instanceof Error ? err.message : String(err)}`);
358
+ });
359
+ this.watcher = w;
360
+ }
361
+ /* the stamp (modification time and size) of a non-empty task plan file (null if missing or empty) */
362
+ async stamp(file) {
363
+ const st = await fs.promises.stat(file).catch(() => null);
364
+ return st !== null && st.isFile() && st.size > 0 ? `${st.mtimeMs}:${st.size}` : null;
365
+ }
366
+ /* the stamps of the task plan files of a directory */
367
+ async scan(dir) {
368
+ const stamps = new Map();
369
+ const entries = await fs.promises.readdir(dir).catch((err) => {
370
+ if (err.code === "ENOENT")
371
+ return [];
372
+ throw err;
373
+ });
374
+ for (const entry of entries) {
375
+ const m = TASK_FILE_RE.exec(entry);
376
+ if (m === null)
377
+ continue;
378
+ const stamp = await this.stamp(path.join(dir, entry));
379
+ if (stamp !== null)
380
+ stamps.set(m[1], stamp);
381
+ }
382
+ return stamps;
383
+ }
384
+ /* start resp. stop observing a project, taking the initial stamps of its directory */
385
+ async track(prjId) {
386
+ if (!this.watching || this.tracked.has(prjId))
387
+ return;
388
+ const dir = this.dir(prjId);
389
+ this.tracked.set(prjId, dir);
390
+ if (!this.stamps.has(dir))
391
+ this.stamps.set(dir, await this.scan(dir));
392
+ }
393
+ untrack(prjId) {
394
+ const dir = this.tracked.get(prjId);
395
+ this.tracked.delete(prjId);
396
+ if (dir !== undefined && ![...this.tracked.values()].includes(dir))
397
+ this.stamps.delete(dir);
398
+ }
399
+ /* record the stamps of the task plan files written by this plugin instance,
400
+ so the change detection does not report them as external changes */
401
+ async written(prjId, ...taskIds) {
402
+ const stamps = this.stamps.get(this.dir(prjId));
403
+ if (stamps === undefined)
404
+ return;
405
+ for (const taskId of taskIds) {
406
+ const stamp = await this.stamp(this.file(prjId, taskId));
407
+ if (stamp === null)
408
+ stamps.delete(taskId);
409
+ else
410
+ stamps.set(taskId, stamp);
411
+ }
412
+ }
413
+ /* schedule a rescan of the observed projects, debounced */
414
+ schedule() {
415
+ if (!this.watching)
416
+ return;
417
+ if (this.timer !== null)
418
+ clearTimeout(this.timer);
419
+ this.timer = setTimeout(() => {
420
+ this.timer = null;
421
+ this.rescan().catch(() => { });
422
+ }, 100);
423
+ this.timer.unref();
424
+ }
425
+ /* rescan the directories of the observed projects, never overlapping itself,
426
+ and report the task plan files whose stamps changed: as added if unknown
427
+ before, as deleted if gone, else as updated (to all projects of a directory) */
428
+ async rescan() {
429
+ if (this.scanning) {
430
+ this.pending = true;
431
+ return;
432
+ }
433
+ this.scanning = true;
434
+ try {
435
+ const dirs = new Map();
436
+ for (const [prjId, dir] of this.tracked)
437
+ dirs.set(dir, [...(dirs.get(dir) ?? []), prjId]);
438
+ for (const [dir, prjIds] of dirs) {
439
+ try {
440
+ const before = this.stamps.get(dir);
441
+ if (before === undefined)
442
+ continue;
443
+ const after = await this.scan(dir);
444
+ const lifecycle = await this.model(prjIds[0]);
445
+ const change = { added: [], updated: [], deleted: [] };
446
+ for (const [taskId, stamp] of after) {
447
+ if (before.get(taskId) === stamp)
448
+ continue;
449
+ const entry = await this.entry(dir, taskId, lifecycle);
450
+ if (entry !== null)
451
+ (before.has(taskId) ? change.updated : change.added).push(entry);
452
+ }
453
+ for (const taskId of before.keys())
454
+ if (!after.has(taskId))
455
+ change.deleted.push(taskId);
456
+ /* update the stamps in place (and only of the reported task plans),
457
+ as the own writes meanwhile already recorded theirs */
458
+ if (!this.watching || this.stamps.get(dir) !== before)
459
+ continue;
460
+ for (const entry of [...change.added, ...change.updated])
461
+ before.set(entry.id, after.get(entry.id));
462
+ for (const taskId of change.deleted)
463
+ before.delete(taskId);
464
+ if (this.listener !== null && (change.added.length > 0 || change.updated.length > 0 || change.deleted.length > 0))
465
+ for (const prjId of prjIds)
466
+ this.listener(prjId, change);
467
+ }
468
+ catch (err) {
469
+ this.ctx.log("warning", `scanning directory "${dir}" failed: ${err instanceof Error ? err.message : String(err)}`);
470
+ }
471
+ }
472
+ }
473
+ finally {
474
+ this.scanning = false;
475
+ if (this.pending) {
476
+ this.pending = false;
477
+ this.schedule();
478
+ }
479
+ }
480
+ }
481
+ }
482
+ /* the plugin factory */
483
+ const factory = (ctx) => new FileTaskStoragePlugin(ctx);
484
+ export default factory;
@@ -0,0 +1,137 @@
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 { pathToFileURL } from "node:url";
8
+ import PQueue from "p-queue";
9
+ /* the methods every storage plugin has to provide */
10
+ const pluginMethods = [
11
+ "open", "close",
12
+ "projectList", "projectGet", "projectSet", "projectDelete",
13
+ "taskList", "taskLoad", "taskSave", "taskDelete", "taskRename"
14
+ ];
15
+ /* the name of the default built-in storage plugin, and the modules of all built-in ones */
16
+ export const BUILTIN_PLUGIN = "ase";
17
+ const builtinPlugins = {
18
+ ase: "./ase-task-store-plugin-ase.js",
19
+ github: "./ase-task-store-plugin-github.js",
20
+ gitlab: "./ase-task-store-plugin-gitlab.js",
21
+ gitea: "./ase-task-store-plugin-gitea.js"
22
+ };
23
+ /* resolve a storage plugin name onto its module specifier: the name
24
+ "ase" (or no name) selects the default built-in plugin, "github", "gitlab", resp. "gitea"
25
+ the built-in GitHub, GitLab, resp. Gitea Issues plugin, any other plain name selects the NPM
26
+ package "ase-task-store-<name>", a relative or absolute path is taken
27
+ as a local module, and anything else is passed through as a module specifier */
28
+ export const resolveTaskStoragePlugin = (name) => {
29
+ if (name === null || name === "")
30
+ name = BUILTIN_PLUGIN;
31
+ if (Object.hasOwn(builtinPlugins, name))
32
+ return new URL(builtinPlugins[name], import.meta.url).href;
33
+ if (/^[A-Za-z0-9_-]+$/.test(name))
34
+ return `ase-task-store-${name}`;
35
+ if (name.startsWith(".") || path.isAbsolute(name))
36
+ return pathToFileURL(path.resolve(name)).href;
37
+ return name;
38
+ };
39
+ /* load a storage plugin by name, instantiate it through its factory
40
+ with the given context, and verify it provides the plugin interface */
41
+ export const loadTaskStoragePlugin = async (name, ctx) => {
42
+ const specifier = resolveTaskStoragePlugin(name);
43
+ name ??= BUILTIN_PLUGIN;
44
+ let mod;
45
+ try {
46
+ mod = await import(specifier);
47
+ }
48
+ catch (err) {
49
+ const message = err instanceof Error ? err.message : String(err);
50
+ throw new Error(`task store: failed to load storage plugin "${name}" (module "${specifier}"): ${message}`, { cause: err });
51
+ }
52
+ const factory = mod?.default;
53
+ if (typeof factory !== "function")
54
+ throw new Error(`task store: storage plugin "${name}" (module "${specifier}") ` +
55
+ "does not default-export a plugin factory function");
56
+ let plugin;
57
+ try {
58
+ plugin = factory(ctx);
59
+ }
60
+ catch (err) {
61
+ const message = err instanceof Error ? err.message : String(err);
62
+ throw new Error(`task store: storage plugin "${name}" factory failed: ${message}`, { cause: err });
63
+ }
64
+ if (typeof plugin !== "object" || plugin === null)
65
+ throw new Error(`task store: storage plugin "${name}" factory did not return a plugin object`);
66
+ if (typeof plugin.name !== "string" || plugin.name === "")
67
+ throw new Error(`task store: storage plugin "${name}" lacks the "name" property`);
68
+ for (const method of pluginMethods)
69
+ if (typeof plugin[method] !== "function")
70
+ throw new Error(`task store: storage plugin "${name}" lacks the "${method}" method`);
71
+ for (const method of ["lock", "fileRead", "watch", "projectMark"])
72
+ if (plugin[method] !== undefined && typeof plugin[method] !== "function")
73
+ throw new Error(`task store: storage plugin "${name}" provides a non-function "${method}" property`);
74
+ return plugin;
75
+ };
76
+ /* the storage delegate the REST API server operates on: it forwards
77
+ every persistence operation into the loaded storage plugin and
78
+ additionally serializes the operations per project, as the part
79
+ endpoints are read-modify-write cycles on entire plans */
80
+ export class TaskStore {
81
+ plugin;
82
+ queues = new Map();
83
+ constructor(plugin) {
84
+ this.plugin = plugin;
85
+ }
86
+ /* the name of the underlying storage plugin */
87
+ get name() {
88
+ return this.plugin.name;
89
+ }
90
+ /* whether the underlying storage plugin stores task plans verbatim */
91
+ get verbatim() {
92
+ return this.plugin.verbatim === true;
93
+ }
94
+ /* run an operation of a project strictly serialized: in-process
95
+ queued in arrival order (hence not re-entrant) and additionally
96
+ under the cross-process lock of the storage plugin (if any) */
97
+ async serialize(prjId, op) {
98
+ let queue = this.queues.get(prjId);
99
+ if (queue === undefined) {
100
+ queue = new PQueue({ concurrency: 1 });
101
+ queue.addEventListener("idle", () => this.queues.delete(prjId));
102
+ this.queues.set(prjId, queue);
103
+ }
104
+ return queue.add(() => this.plugin.lock !== undefined ? this.plugin.lock(prjId, op) : op());
105
+ }
106
+ /* delegate the storage lifecycle, closing only after all queued operations settled */
107
+ open() { return this.plugin.open(); }
108
+ async close() {
109
+ while (this.queues.size > 0)
110
+ await Promise.all(Array.from(this.queues.values(), (queue) => queue.onIdle()));
111
+ return this.plugin.close();
112
+ }
113
+ /* delegate the project registry operations */
114
+ projectList() { return this.plugin.projectList(); }
115
+ projectGet(prjId) { return this.plugin.projectGet(prjId); }
116
+ projectSet(prjId, lifecycle, idscheme) { return this.plugin.projectSet(prjId, lifecycle, idscheme); }
117
+ projectDelete(prjId) { return this.plugin.projectDelete(prjId); }
118
+ /* delegate the optional high-water mark persistence (a no-op if unsupported) */
119
+ projectMark(prjId, seqmark) {
120
+ return this.plugin.projectMark !== undefined ? this.plugin.projectMark(prjId, seqmark) : Promise.resolve();
121
+ }
122
+ /* delegate the task plan operations */
123
+ taskList(prjId) { return this.plugin.taskList(prjId); }
124
+ taskLoad(prjId, taskId) { return this.plugin.taskLoad(prjId, taskId); }
125
+ taskSave(prjId, taskId, plan) { return this.plugin.taskSave(prjId, taskId, plan); }
126
+ taskDelete(prjId, taskId) { return this.plugin.taskDelete(prjId, taskId); }
127
+ taskRename(prjId, oldId, newId) { return this.plugin.taskRename(prjId, oldId, newId); }
128
+ /* delegate the optional observation of changes made outside of the plugin (a no-op if unsupported) */
129
+ watch(listener) {
130
+ this.plugin.watch?.(listener);
131
+ }
132
+ /* delegate the optional referenced file reading (null if unsupported) */
133
+ get canReadFiles() { return this.plugin.fileRead !== undefined; }
134
+ fileRead(prjId, file) {
135
+ return this.plugin.fileRead !== undefined ? this.plugin.fileRead(prjId, file) : Promise.resolve(null);
136
+ }
137
+ }