@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,65 @@
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 { builtinKinds, discoverKinds } from "./ase-agent-definition-core.js";
7
+ import AgentAdhoc, { AgentBuiltin, AgentOfDefinition } from "./ase-agent-runtime-engine.js";
8
+ import AgentProcessCommand from "./ase-agent-process.js";
9
+ import AgentRelayCommand from "./ase-agent-relay-server-cli.js";
10
+ import AgentFleetCommand from "./ase-agent-fleet.js";
11
+ /* CLI command "ase agent" */
12
+ export default class AgentCommand {
13
+ log;
14
+ constructor(log) {
15
+ this.log = log;
16
+ }
17
+ /* register command */
18
+ register(program) {
19
+ /* the available agent kinds, each with the creation of a fresh agent: the built-in
20
+ kinds (each the agent of its declarative definitions), the ad-hoc kind, and the
21
+ definition kinds of the user and the project (see below) */
22
+ const kinds = new Map([
23
+ ...builtinKinds.map((spec) => () => new AgentBuiltin(this.log, spec)),
24
+ () => new AgentAdhoc(this.log)
25
+ ].map((create) => [create().kind, create]));
26
+ /* register CLI top-level command "ase agent" */
27
+ const cmd = program
28
+ .command("agent")
29
+ .description("Autonomous agents driving the task lifecycle phases (implementer, qualifier, integrator), " +
30
+ "rebooting and condensing the plans of tasks (rebooter, condenser), " +
31
+ "processing the edit requests of tasks (editor), estimating the effort of tasks (estimator), " +
32
+ "researching the plans and topics of tasks (researcher), " +
33
+ "or of the definitions of the user or the project " +
34
+ "(\".ase/agents/<kind>.{yaml,ts,js}\") or given ad-hoc (adhoc), " +
35
+ "each running in its own process, individually or as a fleet, optionally reporting to the agent relay")
36
+ .action(() => {
37
+ cmd.outputHelp();
38
+ process.exit(1);
39
+ });
40
+ /* register CLI sub-command groups "ase agent <kind>" (of the built-in and
41
+ the ad-hoc kinds), "ase agent relay", and "ase agent fleet" */
42
+ for (const create of kinds.values())
43
+ new AgentProcessCommand(this.log, create(), kinds).register(cmd);
44
+ new AgentRelayCommand(this.log).register(cmd);
45
+ new AgentFleetCommand(this.log, kinds).register(cmd);
46
+ /* register CLI sub-command groups "ase agent <kind>" of the definition kinds of
47
+ the user and the project on the dispatch of "ase agent" only, as their discovery
48
+ scans the file system, where a definition kind replaces a built-in kind */
49
+ program.hook("preSubcommand", (_program, sub) => {
50
+ if (sub !== cmd)
51
+ return;
52
+ const commands = cmd.commands;
53
+ for (const spec of discoverKinds(this.log, ["adhoc", "relay", "fleet"])) {
54
+ const shadowed = commands.findIndex((command) => command.name() === spec.kind);
55
+ const create = () => new AgentOfDefinition(this.log, spec, shadowed >= 0);
56
+ kinds.set(spec.kind, create);
57
+ if (shadowed >= 0)
58
+ commands.splice(shadowed, 1);
59
+ new AgentProcessCommand(this.log, create(), kinds).register(cmd);
60
+ if (shadowed >= 0)
61
+ commands.splice(shadowed, 0, commands.pop());
62
+ }
63
+ });
64
+ }
65
+ }
@@ -8,10 +8,11 @@ import fs from "node:fs";
8
8
  import picomatch from "picomatch";
9
9
  import { isScalar } from "yaml";
10
10
  import { z } from "zod";
11
- import { Config, configSchema } from "./ase-config.js";
11
+ import { Config } from "./ase-config-core.js";
12
+ import { configSchema } from "./ase-config-schema.js";
12
13
  import { Task } from "./ase-task.js";
13
- import { Ignore } from "./ase-ignore.js";
14
- import { writeStdout } from "./ase-stdio.js";
14
+ import { Ignore } from "./ase-lib-ignore.js";
15
+ import { writeStdout } from "./ase-lib-stdio.js";
15
16
  /* the recognized artifact kinds, in descending precedence order;
16
17
  "othr" is the implicit catch-all and is always resolved last */
17
18
  export const artifactKinds = ["spec", "code", "docs", "infr", "othr"];
@@ -89,8 +90,8 @@ export class Artifact {
89
90
  for a single kind; "basedir" is project-root-relative (POSIX,
90
91
  "" ≡ project root) and "files" resolves relative to "basedir" */
91
92
  static spec(cfg, kind) {
92
- const basedir = Artifact.configString(cfg, `project.artifact.${kind}.basedir`)
93
- .replace(/\\/g, "/").replace(/^\/+|\/+$/g, "")
93
+ const basedir = path.posix.normalize(Artifact.configString(cfg, `project.artifact.${kind}.basedir`)
94
+ .replace(/\\/g, "/")).replace(/^\/+|\/+$/g, "")
94
95
  .replace(/^\.$/, "");
95
96
  if (basedir.split("/").includes(".."))
96
97
  throw new Error(`artifact: configured "basedir" "${basedir}" must not escape the project root`);
@@ -0,0 +1,465 @@
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 { Document, parseDocument, isMap, isScalar } from "yaml";
9
+ import * as v from "valibot";
10
+ import writeFileAtomic from "write-file-atomic";
11
+ import lockfile from "proper-lockfile";
12
+ import { projectRoot, userConfigDir, userStateDir } from "./ase-config-scope.js";
13
+ import { projectClassificationPresets, configWritableScopes, configWritableScopesDefault, configSecretKeys, sessionStateSchema } from "./ase-config-schema.js";
14
+ /* encapsulate read/write access to a stack of "<name>.yaml" configuration files,
15
+ each associated with a scope term; reads cascade along user < project < task < session,
16
+ writes are confined to the target (strongest) scope term */
17
+ export class Config {
18
+ /* public state */
19
+ filename;
20
+ /* private state */
21
+ name;
22
+ scope;
23
+ schema;
24
+ log;
25
+ docs;
26
+ target;
27
+ pruned;
28
+ locked = null;
29
+ /* creation */
30
+ constructor(name, schema, log, scope = [{ kind: "user" }, { kind: "project" }]) {
31
+ if (scope.length === 0)
32
+ throw new Error("invalid scope: chain must not be empty");
33
+ this.name = name;
34
+ this.scope = scope[0].kind === "default" ? scope : [{ kind: "default" }, ...scope];
35
+ this.schema = schema ?? null;
36
+ this.log = log;
37
+ const tgt = this.scope[this.scope.length - 1];
38
+ this.filename = this.resolveFilename(name, tgt);
39
+ this.docs = [{ scope: tgt, filename: this.filename, doc: new Document() }];
40
+ this.target = 0;
41
+ this.pruned = [];
42
+ }
43
+ /* the configuration files of the scope chain (after reading) */
44
+ files() {
45
+ return this.docs.map((layer) => layer.filename).filter((filename) => filename !== "");
46
+ }
47
+ /* render a scope term as a short textual label */
48
+ static scopeLabel(term) {
49
+ if (term.kind === "default" || term.kind === "user" || term.kind === "project")
50
+ return term.kind;
51
+ return `${term.kind}:${term.id}`;
52
+ }
53
+ /* resolve the configuration filename based on the selected scope term */
54
+ resolveFilename(name, term) {
55
+ if (term.kind === "default")
56
+ throw new Error("internal error: \"default\" scope has no filename");
57
+ if (term.kind === "user")
58
+ return path.join(userConfigDir(), `${name}.yaml`);
59
+ else if (term.kind === "project") {
60
+ const rel = path.join(".ase", `${name}.yaml`);
61
+ const root = projectRoot() ?? process.cwd();
62
+ const found = this.findUpward(process.cwd(), root, rel);
63
+ return found ?? path.join(root, rel);
64
+ }
65
+ else if (term.kind === "task") {
66
+ const root = projectRoot() ?? process.cwd();
67
+ return path.join(root, ".ase", "task", term.id, `${name}.yaml`);
68
+ }
69
+ else
70
+ return path.join(userStateDir(), "session", term.id, `${name}.yaml`);
71
+ }
72
+ /* upward-walk on filesystem for a file path relative to a start directory,
73
+ bounded above (inclusive) by a stop directory */
74
+ findUpward(start, stop, rel) {
75
+ let dir = fs.realpathSync(start);
76
+ const end = fs.realpathSync(stop);
77
+ const steps = dir.split(path.sep).length - end.split(path.sep).length;
78
+ if (steps < 0)
79
+ return null;
80
+ for (let i = 0; i <= steps; i++) {
81
+ const candidate = path.join(dir, rel);
82
+ if (fs.existsSync(candidate))
83
+ return candidate;
84
+ const parent = path.dirname(dir);
85
+ if (parent === dir)
86
+ return null;
87
+ dir = parent;
88
+ }
89
+ return null;
90
+ }
91
+ /* read the full scope chain into memory; the requested mode applies
92
+ to the target scope only, inherited scopes are always lenient */
93
+ read(mode = "lenient") {
94
+ const chain = this.scope;
95
+ const docs = [];
96
+ this.pruned = [];
97
+ for (let i = 0; i < chain.length; i++) {
98
+ const sc = chain[i];
99
+ if (sc.kind === "default") {
100
+ const doc = new Document();
101
+ doc.contents = doc.createNode({});
102
+ if (this.name === "config") {
103
+ const preset = projectClassificationPresets.default;
104
+ for (const [k, val] of Object.entries(preset)) {
105
+ const segments = k.split(".");
106
+ for (let j = 1; j < segments.length; j++) {
107
+ const prefix = segments.slice(0, j);
108
+ const node = doc.getIn(prefix, true);
109
+ if (node === undefined)
110
+ doc.setIn(prefix, doc.createNode({}));
111
+ }
112
+ doc.setIn(segments, doc.createNode(val));
113
+ }
114
+ }
115
+ docs.push({ scope: sc, filename: "", doc });
116
+ continue;
117
+ }
118
+ const filename = this.resolveFilename(this.name, sc);
119
+ const isTarget = (i === chain.length - 1);
120
+ const perDocMode = isTarget ? mode : "lenient";
121
+ const text = fs.existsSync(filename) ? fs.readFileSync(filename, "utf8") : "";
122
+ let doc = parseDocument(text);
123
+ if (doc.errors.length > 0) {
124
+ const msg = `invalid YAML in ${filename}: ${doc.errors[0].message}`;
125
+ if (perDocMode === "strict")
126
+ throw new Error(msg);
127
+ this.log.write("warning", msg);
128
+ if (isTarget)
129
+ this.pruned.push(`unparsable YAML (${doc.errors[0].message.split("\n")[0]})`);
130
+ doc = new Document();
131
+ }
132
+ else if (this.name === "config" && this.migrate(doc)) {
133
+ /* persist the migration under the file lock (unless already held by us),
134
+ re-reading the file there to not clobber a concurrent write */
135
+ const persist = () => {
136
+ const disk = parseDocument(fs.readFileSync(filename, "utf8"));
137
+ if (disk.errors.length > 0 || !this.migrate(disk))
138
+ return;
139
+ const empty = isMap(disk.contents) && disk.contents.items.length === 0;
140
+ writeFileAtomic.sync(filename, empty ? "" : disk.toString({ indent: 4 }), { encoding: "utf8", mode: this.fileMode(sc, disk) });
141
+ this.log.write("info", `migrated obsolete configuration entries in ${filename}`);
142
+ };
143
+ try {
144
+ if (this.locked === filename)
145
+ persist();
146
+ else {
147
+ const release = lockfile.lockSync(filename);
148
+ try {
149
+ persist();
150
+ }
151
+ finally {
152
+ release();
153
+ }
154
+ }
155
+ }
156
+ catch (err) {
157
+ this.log.write("warning", `failed to migrate obsolete configuration entries in ${filename}: ` +
158
+ (err instanceof Error ? err.message : String(err)));
159
+ }
160
+ }
161
+ docs.push({ scope: sc, filename, doc });
162
+ }
163
+ this.docs = docs;
164
+ this.target = docs.length - 1;
165
+ for (let i = 0; i < docs.length; i++) {
166
+ const isTarget = (i === this.target);
167
+ const perDocMode = isTarget ? mode : "lenient";
168
+ const removed = this.validateDoc(docs[i].doc, docs[i].filename, perDocMode);
169
+ if (isTarget)
170
+ this.pruned.push(...removed);
171
+ }
172
+ }
173
+ /* migrate a configuration document in place from obsolete variables: the
174
+ (pre-1.1.0) "project.artifact.task.{basedir,files}", where "basedir" becomes
175
+ "project.task.store.url" (as "ase:<basedir>") unless the latter is already
176
+ set and "files" is dropped, and the (pre-1.1.0) "agent.{task,skill}", which
177
+ are dropped, as they moved into the internal session state;
178
+ returns whether the document was changed */
179
+ migrate(doc) {
180
+ let changed = false;
181
+ const task = ["project", "artifact", "task"];
182
+ if (isMap(doc.getIn(task))) {
183
+ const basedir = doc.getIn([...task, "basedir"]);
184
+ const parent = doc.getIn(["project", "task"]);
185
+ const store = doc.getIn(["project", "task", "store"]);
186
+ if (typeof basedir === "string" && basedir !== ""
187
+ && (parent === undefined || isMap(parent)) && (store === undefined || isMap(store))
188
+ && !doc.hasIn(["project", "task", "store", "url"]))
189
+ doc.setIn(["project", "task", "store", "url"], `ase:${basedir}`);
190
+ doc.deleteIn(task);
191
+ changed = true;
192
+ }
193
+ if (isMap(doc.getIn(["agent"])))
194
+ for (const key of ["task", "skill"])
195
+ if (doc.deleteIn(["agent", key]))
196
+ changed = true;
197
+ if (!changed)
198
+ return false;
199
+ for (const p of [["project", "artifact"], ["project"], ["agent"]]) {
200
+ const node = doc.getIn(p);
201
+ if (isMap(node) && node.items.length === 0)
202
+ doc.deleteIn(p);
203
+ }
204
+ return true;
205
+ }
206
+ /* acquire a cross-process advisory lock on the target scope's file,
207
+ execute the callback, then release the lock */
208
+ lock(cb) {
209
+ const td = this.docs[this.target];
210
+ if (td.scope.kind === "default")
211
+ throw new Error("internal error: \"default\" scope is not lockable");
212
+ fs.mkdirSync(path.dirname(td.filename), { recursive: true });
213
+ if (!fs.existsSync(td.filename))
214
+ fs.writeFileSync(td.filename, "", { encoding: "utf8", mode: this.fileMode(td.scope, null) });
215
+ const release = lockfile.lockSync(td.filename);
216
+ this.locked = td.filename;
217
+ try {
218
+ cb();
219
+ }
220
+ finally {
221
+ this.locked = null;
222
+ release();
223
+ }
224
+ }
225
+ /* write in-memory configuration back to the target scope's file */
226
+ write() {
227
+ const td = this.docs[this.target];
228
+ if (td.scope.kind === "default")
229
+ throw new Error("internal error: \"default\" scope is not writable");
230
+ /* a lenient read physically removes the invalid content from the in-memory
231
+ target document, so writing that document back would silently erase the
232
+ very same content from the file on disk */
233
+ if (this.pruned.length > 0)
234
+ throw new Error(`refusing to overwrite ${td.filename}: it carries invalid content ` +
235
+ `(${this.pruned.join(", ")}) which was skipped on reading and hence would be ` +
236
+ "lost on writing -- repair the file first");
237
+ this.validateDoc(td.doc, td.filename, "strict");
238
+ fs.mkdirSync(path.dirname(td.filename), { recursive: true });
239
+ writeFileAtomic.sync(td.filename, td.doc.toString({ indent: 4 }), { encoding: "utf8", mode: this.fileMode(td.scope, td.doc) });
240
+ }
241
+ /* determine the file mode of a scope's file: user-private (0600) for the
242
+ user scope, the "store" files, and any file carrying secrets, else
243
+ undefined (which lets an existing file's mode be preserved) */
244
+ fileMode(scope, doc) {
245
+ if (scope.kind === "user" || this.name === "store"
246
+ || (doc !== null && configSecretKeys.some((key) => doc.hasIn(key.split(".")))))
247
+ return 0o600;
248
+ return undefined;
249
+ }
250
+ /* validate a single YAML document against the optional schema; in "strict"
251
+ mode all invalid entries are reported as a thrown error, in "lenient" mode
252
+ they are removed from the document and their dotted paths are returned */
253
+ validateDoc(doc, filename, mode = "strict") {
254
+ if (this.schema === null)
255
+ return [];
256
+ const removed = [];
257
+ for (;;) {
258
+ const result = v.safeParse(this.schema, doc.toJS());
259
+ if (result.success)
260
+ return removed;
261
+ if (mode === "strict") {
262
+ const issues = result.issues.map((i) => {
263
+ const dotPath = (i.path ?? []).map((p) => String(p.key)).join(".");
264
+ return dotPath ? `${dotPath}: ${i.message}` : i.message;
265
+ }).join("; ");
266
+ throw new Error(`invalid configuration in ${filename}: ${issues}`);
267
+ }
268
+ const before = removed.length;
269
+ for (const i of result.issues) {
270
+ const segs = (i.path ?? []).map((p) => String(p.key));
271
+ const dotPath = segs.join(".");
272
+ this.log.write("warning", `invalid entry in ${filename}: ${dotPath ? `${dotPath}: ` : ""}${i.message}`);
273
+ if (segs.length > 0 && doc.deleteIn(segs))
274
+ removed.push(dotPath);
275
+ /* issues at the document root and issues whose stringified path does not
276
+ address a deletable node (e.g. a non-string YAML key like "404:", which
277
+ "toJS" stringifies) cannot be removed; processing continues with the
278
+ remaining issues */
279
+ }
280
+ if (removed.length === before)
281
+ return removed;
282
+ }
283
+ }
284
+ /* enumerate all full dotted leaf paths from the attached valibot schema */
285
+ schemaLeafPaths() {
286
+ const unwrap = (s) => {
287
+ let cur = s;
288
+ while (cur !== undefined && cur !== null && (cur.type === "optional" || cur.type === "nullish"
289
+ || cur.type === "nullable" || cur.type === "undefinedable"))
290
+ cur = cur.wrapped;
291
+ return cur ?? null;
292
+ };
293
+ const walk = (s, prefix) => {
294
+ const u = unwrap(s);
295
+ if (u !== null
296
+ && (u.type === "object" || u.type === "strict_object" || u.type === "loose_object")
297
+ && u.entries !== undefined) {
298
+ const paths = [];
299
+ for (const [k, sub] of Object.entries(u.entries))
300
+ paths.push(...walk(sub, [...prefix, k]));
301
+ return paths;
302
+ }
303
+ return [prefix];
304
+ };
305
+ return walk(this.schema, []);
306
+ }
307
+ /* enumerate all full dotted keys known from the attached valibot schema */
308
+ keys() {
309
+ if (this.schema === null)
310
+ return [];
311
+ return this.schemaLeafPaths().map((p) => p.join("."));
312
+ }
313
+ /* resolve a (possibly trailing-segment) dotted key to its full schema path */
314
+ resolveKey(key) {
315
+ if (this.schema === null)
316
+ return key;
317
+ const segs = key.split(".");
318
+ const matches = this.schemaLeafPaths().filter((p) => {
319
+ if (p.length < segs.length)
320
+ return false;
321
+ for (let i = 0; i < segs.length; i++)
322
+ if (p[p.length - segs.length + i] !== segs[i])
323
+ return false;
324
+ return true;
325
+ });
326
+ if (matches.length === 0)
327
+ return key;
328
+ if (matches.length > 1)
329
+ throw new Error(`ambiguous key "${key}" matches: ${matches.map((m) => m.join(".")).join(", ")}`);
330
+ return matches[0].join(".");
331
+ }
332
+ /* retrieve the effective value at a dotted key (strongest scope wins),
333
+ or the target scope's root contents if no key is given */
334
+ get(key) {
335
+ if (key === undefined)
336
+ return this.docs[this.target].doc.contents;
337
+ return this.getScoped(key)?.value;
338
+ }
339
+ /* retrieve the effective value at a dotted key (strongest scope wins)
340
+ together with the scope term and the configuration file (empty for
341
+ the built-in "default" scope) which supplied it */
342
+ getScoped(key) {
343
+ const segs = this.resolveKey(key).split(".");
344
+ for (let i = this.docs.length - 1; i >= 0; i--) {
345
+ const node = this.docs[i].doc.getIn(segs);
346
+ if (node !== undefined)
347
+ return { value: node, scope: this.docs[i].scope, filename: this.docs[i].filename };
348
+ }
349
+ return undefined;
350
+ }
351
+ /* retrieve the explicitly configured value at a dotted key, i.e. the same
352
+ cascade as "get", but skipping the built-in "default" scope layer, so
353
+ callers can distinguish a deliberately configured value from a merely
354
+ preset one */
355
+ getExplicit(key) {
356
+ const segs = this.resolveKey(key).split(".");
357
+ for (let i = this.docs.length - 1; i >= 0; i--) {
358
+ if (this.docs[i].scope.kind === "default")
359
+ continue;
360
+ const node = this.docs[i].doc.getIn(segs);
361
+ if (node !== undefined)
362
+ return node;
363
+ }
364
+ return undefined;
365
+ }
366
+ /* enumerate the effective leaf entries across the full scope chain;
367
+ each returned entry identifies the originating scope */
368
+ entries() {
369
+ const keys = new Set();
370
+ const walk = (node, prefix) => {
371
+ if (isMap(node))
372
+ for (const item of node.items) {
373
+ const k = [...prefix, String(item.key)];
374
+ if (isMap(item.value))
375
+ walk(item.value, k);
376
+ else if (isScalar(item.value))
377
+ keys.add(k.join("."));
378
+ else
379
+ throw new Error(`key "${k.join(".")}" has unsupported node type`);
380
+ }
381
+ };
382
+ for (const d of this.docs)
383
+ walk(d.doc.contents, []);
384
+ const result = [];
385
+ for (const k of keys) {
386
+ const segs = k.split(".");
387
+ for (let i = this.docs.length - 1; i >= 0; i--) {
388
+ const node = this.docs[i].doc.getIn(segs);
389
+ if (node !== undefined) {
390
+ result.push({ key: k, value: node, scope: this.docs[i].scope });
391
+ break;
392
+ }
393
+ }
394
+ }
395
+ result.sort((a, b) => a.key.localeCompare(b.key));
396
+ return result;
397
+ }
398
+ /* determine whether a key is writable on a given scope kind */
399
+ isWritableOn(key, kind) {
400
+ if (kind === "default")
401
+ return false;
402
+ const resolved = this.resolveKey(key);
403
+ const allowed = configWritableScopes[resolved] ?? configWritableScopesDefault;
404
+ return allowed.includes(kind);
405
+ }
406
+ /* enforce write-scope policy for the current target scope */
407
+ assertWritable(key) {
408
+ const td = this.docs[this.target];
409
+ const resolved = this.resolveKey(key);
410
+ const allowed = configWritableScopes[resolved] ?? configWritableScopesDefault;
411
+ if (!allowed.includes(td.scope.kind))
412
+ throw new Error(`cannot set "${resolved}" on scope "${Config.scopeLabel(td.scope)}": ` +
413
+ `this key is only writable on scope(s): ${allowed.join(", ")}`);
414
+ }
415
+ /* set a value at a dotted key in the target scope, creating intermediate maps as needed */
416
+ set(key, value) {
417
+ this.assertWritable(key);
418
+ const segments = this.resolveKey(key).split(".");
419
+ const td = this.docs[this.target];
420
+ const next = td.doc.clone();
421
+ for (let i = 1; i < segments.length; i++) {
422
+ const prefix = segments.slice(0, i);
423
+ const node = next.getIn(prefix, true);
424
+ if (node !== undefined && !isMap(node))
425
+ throw new Error(`cannot set "${key}": intermediate path "${prefix.join(".")}" is not a map`);
426
+ if (node === undefined)
427
+ next.setIn(prefix, next.createNode({}));
428
+ }
429
+ next.setIn(segments, value);
430
+ const saved = td.doc;
431
+ td.doc = next;
432
+ try {
433
+ this.validateDoc(td.doc, td.filename, "strict");
434
+ }
435
+ catch (err) {
436
+ td.doc = saved;
437
+ throw err;
438
+ }
439
+ }
440
+ /* delete a value at a dotted key from the target scope */
441
+ delete(key) {
442
+ this.assertWritable(key);
443
+ const td = this.docs[this.target];
444
+ if (td.doc.contents === null || td.doc.contents === undefined)
445
+ return;
446
+ const next = td.doc.clone();
447
+ next.deleteIn(this.resolveKey(key).split("."));
448
+ const saved = td.doc;
449
+ td.doc = next;
450
+ try {
451
+ this.validateDoc(td.doc, td.filename, "strict");
452
+ }
453
+ catch (err) {
454
+ td.doc = saved;
455
+ throw err;
456
+ }
457
+ }
458
+ }
459
+ /* access the internal "state.yaml" of a session, which, unlike the
460
+ configuration, does not cascade through any weaker scope */
461
+ export const sessionState = (log, sessionId) => {
462
+ if (!/^[A-Za-z0-9._-]+$/.test(sessionId))
463
+ throw new Error(`invalid session id "${sessionId}"`);
464
+ return new Config("state", sessionStateSchema, log, [{ kind: "session", id: sessionId }]);
465
+ };