@rse/ase 2.0.3 → 2.0.5

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 (198) hide show
  1. package/dst/ase-hook.js +13 -4
  2. package/dst/ase-lib-version.js +14 -5
  3. package/dst/ase-man-catalog.js +229 -0
  4. package/dst/ase-man-render.js +278 -0
  5. package/dst/ase-man-tui.js +636 -0
  6. package/dst/ase-man.js +33 -27
  7. package/dst/ase-setup.js +2 -2
  8. package/dst/ase-spec.js +103 -20
  9. package/dst/ase-task-board-filter.js +5 -4
  10. package/dst/ase-task-board-web-client.js +26 -26
  11. package/dst/man/ase-agent-adhoc.md +1165 -0
  12. package/dst/man/ase-agent-condenser.md +383 -0
  13. package/dst/man/ase-agent-editor.md +441 -0
  14. package/dst/man/ase-agent-estimator.md +477 -0
  15. package/dst/man/ase-agent-fleet.md +358 -0
  16. package/dst/man/ase-agent-implementer.md +521 -0
  17. package/dst/man/ase-agent-integrator.md +449 -0
  18. package/dst/man/ase-agent-qualifier.md +426 -0
  19. package/dst/man/ase-agent-rebooter.md +384 -0
  20. package/dst/man/ase-agent-relay.md +262 -0
  21. package/dst/man/ase-agent-researcher.md +478 -0
  22. package/dst/man/ase-agent.md +628 -0
  23. package/dst/man/ase-artifact-list.md +57 -0
  24. package/dst/man/ase-artifact-name.md +56 -0
  25. package/dst/man/ase-artifact.md +92 -0
  26. package/dst/man/ase-config-delete.md +74 -0
  27. package/dst/man/ase-config-edit.md +83 -0
  28. package/dst/man/ase-config-get.md +70 -0
  29. package/dst/man/ase-config-init.md +110 -0
  30. package/dst/man/ase-config-list.md +68 -0
  31. package/dst/man/ase-config-set.md +92 -0
  32. package/dst/man/ase-config.md +426 -0
  33. package/dst/man/ase-hook-permission-request.md +61 -0
  34. package/dst/man/ase-hook-pre-tool-use.md +96 -0
  35. package/dst/man/ase-hook-session-end.md +71 -0
  36. package/dst/man/ase-hook-session-persona.md +65 -0
  37. package/dst/man/ase-hook-session-start.md +184 -0
  38. package/dst/man/ase-hook-stop.md +52 -0
  39. package/dst/man/ase-hook-user-prompt-submit.md +54 -0
  40. package/dst/man/ase-hook.md +93 -0
  41. package/dst/man/ase-man.md +195 -0
  42. package/dst/man/ase-mcp.md +102 -0
  43. package/dst/man/ase-service-log.md +59 -0
  44. package/dst/man/ase-service-send.md +60 -0
  45. package/dst/man/ase-service-start.md +93 -0
  46. package/dst/man/ase-service-status.md +58 -0
  47. package/dst/man/ase-service-stop.md +52 -0
  48. package/dst/man/ase-service.md +124 -0
  49. package/dst/man/ase-setup-disable.md +67 -0
  50. package/dst/man/ase-setup-enable.md +66 -0
  51. package/dst/man/ase-setup-install.md +116 -0
  52. package/dst/man/ase-setup-mcp.md +213 -0
  53. package/dst/man/ase-setup-status.md +101 -0
  54. package/dst/man/ase-setup-statusline.md +162 -0
  55. package/dst/man/ase-setup-telemetry.md +166 -0
  56. package/dst/man/ase-setup-uninstall.md +85 -0
  57. package/dst/man/ase-setup-update.md +117 -0
  58. package/dst/man/ase-setup.md +156 -0
  59. package/dst/man/ase-spec-export.md +99 -0
  60. package/dst/man/ase-spec-init.md +57 -0
  61. package/dst/man/ase-spec-lint.md +74 -0
  62. package/dst/man/ase-spec-preview.md +57 -0
  63. package/dst/man/ase-spec.md +85 -0
  64. package/dst/man/ase-statusline.md +330 -0
  65. package/dst/man/ase-task-assign.md +138 -0
  66. package/dst/man/ase-task-board.md +651 -0
  67. package/dst/man/ase-task-delete.md +46 -0
  68. package/dst/man/ase-task-edit.md +73 -0
  69. package/dst/man/ase-task-idscheme.md +92 -0
  70. package/dst/man/ase-task-lifecycle.md +92 -0
  71. package/dst/man/ase-task-list.md +86 -0
  72. package/dst/man/ase-task-load.md +48 -0
  73. package/dst/man/ase-task-newid.md +100 -0
  74. package/dst/man/ase-task-purge.md +49 -0
  75. package/dst/man/ase-task-rename.md +69 -0
  76. package/dst/man/ase-task-request.md +108 -0
  77. package/dst/man/ase-task-save.md +78 -0
  78. package/dst/man/ase-task-status.md +79 -0
  79. package/dst/man/ase-task-store.md +377 -0
  80. package/dst/man/ase-task-view.md +50 -0
  81. package/dst/man/ase-task.md +335 -0
  82. package/dst/man/ase-util-compat.md +38 -0
  83. package/dst/man/ase-util-diagram.md +140 -0
  84. package/dst/man/ase-util-meta.md +68 -0
  85. package/dst/man/ase-util-metric.md +74 -0
  86. package/dst/man/ase-util-mint.md +85 -0
  87. package/dst/man/ase-util-worktree.md +109 -0
  88. package/dst/man/ase-util.md +81 -0
  89. package/dst/man/ase.md +228 -0
  90. package/package.json +16 -21
  91. package/plugin/.claude-plugin/plugin.json +1 -1
  92. package/plugin/.codex-plugin/plugin.json +1 -1
  93. package/plugin/.github/plugin/plugin.json +1 -1
  94. package/plugin/agents/ase-code-lint.md +3 -3
  95. package/plugin/agents/ase-docs-proofread.md +3 -3
  96. package/plugin/agents/ase-docs-refine.md +3 -3
  97. package/plugin/agents/ase-spec-lint.md +338 -0
  98. package/plugin/mods/ase-mods-common.ts +444 -0
  99. package/plugin/mods/ase-mods-grill.ts +312 -0
  100. package/plugin/mods/ase-mods-help-browse.ts +405 -0
  101. package/plugin/mods/ase-mods-help-compose.ts +561 -0
  102. package/plugin/mods/ase-mods-help-find.ts +426 -0
  103. package/plugin/mods/ase-mods-help-hint.ts +134 -0
  104. package/plugin/mods/ase-mods-help-show.ts +142 -0
  105. package/plugin/mods/ase-mods.ts +214 -381
  106. package/plugin/mods/claude-code.d.ts +26 -3
  107. package/plugin/skills/ase-code-lint/SKILL.md +15 -15
  108. package/plugin/skills/ase-code-lint/help.md +21 -8
  109. package/plugin/skills/ase-help-intent/SKILL.md +2 -1
  110. package/plugin/skills/ase-help-intent/data.md +214 -9
  111. package/plugin/skills/ase-help-skill/catalog.md +1 -0
  112. package/plugin/skills/ase-meta-workflow/workflow.txt +2 -0
  113. package/plugin/skills/ase-repo-commit/SKILL.md +1 -0
  114. package/plugin/skills/ase-repo-merge/SKILL.md +1 -0
  115. package/plugin/skills/ase-spec-edit/SKILL.md +3 -3
  116. package/plugin/skills/ase-spec-edit/help.md +1 -1
  117. package/plugin/skills/ase-spec-lint/SKILL.md +695 -0
  118. package/plugin/skills/ase-spec-lint/help.md +184 -0
  119. package/plugin/skills/ase-task-implement/SKILL.md +24 -6
  120. package/plugin/skills/ase-task-implement/help.md +11 -1
  121. package/dst/man/ase-agent-adhoc.1 +0 -515
  122. package/dst/man/ase-agent-condenser.1 +0 -193
  123. package/dst/man/ase-agent-editor.1 +0 -212
  124. package/dst/man/ase-agent-estimator.1 +0 -214
  125. package/dst/man/ase-agent-fleet.1 +0 -185
  126. package/dst/man/ase-agent-implementer.1 +0 -233
  127. package/dst/man/ase-agent-integrator.1 +0 -213
  128. package/dst/man/ase-agent-qualifier.1 +0 -214
  129. package/dst/man/ase-agent-rebooter.1 +0 -193
  130. package/dst/man/ase-agent-relay.1 +0 -128
  131. package/dst/man/ase-agent-researcher.1 +0 -217
  132. package/dst/man/ase-agent.1 +0 -264
  133. package/dst/man/ase-artifact-list.1 +0 -38
  134. package/dst/man/ase-artifact-name.1 +0 -39
  135. package/dst/man/ase-artifact.1 +0 -56
  136. package/dst/man/ase-config-delete.1 +0 -45
  137. package/dst/man/ase-config-edit.1 +0 -48
  138. package/dst/man/ase-config-get.1 +0 -46
  139. package/dst/man/ase-config-init.1 +0 -60
  140. package/dst/man/ase-config-list.1 +0 -42
  141. package/dst/man/ase-config-set.1 +0 -52
  142. package/dst/man/ase-config.1 +0 -231
  143. package/dst/man/ase-hook-permission-request.1 +0 -36
  144. package/dst/man/ase-hook-pre-tool-use.1 +0 -59
  145. package/dst/man/ase-hook-session-end.1 +0 -47
  146. package/dst/man/ase-hook-session-persona.1 +0 -37
  147. package/dst/man/ase-hook-session-start.1 +0 -81
  148. package/dst/man/ase-hook-stop.1 +0 -34
  149. package/dst/man/ase-hook-user-prompt-submit.1 +0 -34
  150. package/dst/man/ase-hook.1 +0 -51
  151. package/dst/man/ase-man.1 +0 -67
  152. package/dst/man/ase-mcp.1 +0 -61
  153. package/dst/man/ase-service-log.1 +0 -43
  154. package/dst/man/ase-service-send.1 +0 -47
  155. package/dst/man/ase-service-start.1 +0 -54
  156. package/dst/man/ase-service-status.1 +0 -46
  157. package/dst/man/ase-service-stop.1 +0 -37
  158. package/dst/man/ase-service.1 +0 -85
  159. package/dst/man/ase-setup-disable.1 +0 -44
  160. package/dst/man/ase-setup-enable.1 +0 -45
  161. package/dst/man/ase-setup-install.1 +0 -73
  162. package/dst/man/ase-setup-mcp.1 +0 -124
  163. package/dst/man/ase-setup-status.1 +0 -60
  164. package/dst/man/ase-setup-statusline.1 +0 -92
  165. package/dst/man/ase-setup-telemetry.1 +0 -92
  166. package/dst/man/ase-setup-uninstall.1 +0 -59
  167. package/dst/man/ase-setup-update.1 +0 -64
  168. package/dst/man/ase-setup.1 +0 -93
  169. package/dst/man/ase-spec-export.1 +0 -62
  170. package/dst/man/ase-spec-lint.1 +0 -40
  171. package/dst/man/ase-spec-preview.1 +0 -40
  172. package/dst/man/ase-spec.1 +0 -46
  173. package/dst/man/ase-statusline.1 +0 -189
  174. package/dst/man/ase-task-assign.1 +0 -71
  175. package/dst/man/ase-task-board.1 +0 -384
  176. package/dst/man/ase-task-delete.1 +0 -35
  177. package/dst/man/ase-task-edit.1 +0 -46
  178. package/dst/man/ase-task-idscheme.1 +0 -65
  179. package/dst/man/ase-task-lifecycle.1 +0 -57
  180. package/dst/man/ase-task-list.1 +0 -47
  181. package/dst/man/ase-task-load.1 +0 -36
  182. package/dst/man/ase-task-newid.1 +0 -61
  183. package/dst/man/ase-task-purge.1 +0 -37
  184. package/dst/man/ase-task-rename.1 +0 -44
  185. package/dst/man/ase-task-request.1 +0 -61
  186. package/dst/man/ase-task-save.1 +0 -48
  187. package/dst/man/ase-task-status.1 +0 -55
  188. package/dst/man/ase-task-store.1 +0 -194
  189. package/dst/man/ase-task-view.1 +0 -40
  190. package/dst/man/ase-task.1 +0 -160
  191. package/dst/man/ase-util-compat.1 +0 -25
  192. package/dst/man/ase-util-diagram.1 +0 -76
  193. package/dst/man/ase-util-meta.1 +0 -45
  194. package/dst/man/ase-util-metric.1 +0 -54
  195. package/dst/man/ase-util-mint.1 +0 -59
  196. package/dst/man/ase-util-worktree.1 +0 -72
  197. package/dst/man/ase-util.1 +0 -50
  198. package/dst/man/ase.1 +0 -124
package/dst/ase-hook.js CHANGED
@@ -462,7 +462,8 @@ export default class HookCommand {
462
462
  guaranteed to appear once in every non-headless session;
463
463
  Anthropic Claude Code CLI and OpenAI Codex CLI surface a top-level
464
464
  "systemMessage" field for this -- GitHub Copilot CLI has no equivalent);
465
- the trailing help hint is emitted only if the guidance level asks for it */
465
+ the trailing help hints are emitted only if the guidance level asks for them,
466
+ and the inline ones only for Anthropic Claude Code CLI, which alone runs the mods */
466
467
  const banner = "\n" +
467
468
  `\n⧉ ASE: ⎈ version: ${version}${versionHint !== "" ? " " + versionHint.replace(/\*/g, "") : ""}` +
468
469
  `\n⧉ ASE: ※ user: ${userId}, ⚑ project: ${projectId}` +
@@ -470,8 +471,16 @@ export default class HookCommand {
470
471
  `\n⧉ ASE: ☯ persona: ${persona}, ▶ guidance: ${guidance}, ▢ boxing: ${boxing}` +
471
472
  (guidance === "normal" || guidance === "verbose" ?
472
473
  "\n" +
473
- "\n⧉ ASE: ▷ hint: use \"/ase-help-intent <intent-description>\" for skill command proposals" +
474
- "\n⧉ ASE: ▷ hint: use \"/ase-help-skill [<skill-name>]\" for skill catalog or skill manpage" : "");
474
+ "\n⧉ ASE: ▷ hint: use \"/ase-help-intent <intent>\" for conversational skill command proposals" +
475
+ "\n⧉ ASE: ▷ hint: use \"/ase-help-skill [<skill>]\" for conversational skill catalog or manpage lookup" +
476
+ (tool === "claude" ?
477
+ "\n⧉ ASE: ▷ hint: use \"<intent> !!\" for inline skill command proposals" : "") +
478
+ (tool === "claude" ?
479
+ "\n⧉ ASE: ▷ hint: use \"/<skill> !!\" for inline skill command composition" : "") +
480
+ (tool === "claude" ?
481
+ "\n⧉ ASE: ▷ hint: use \"/<skill> //\" for inline skill manpage lookup" : "") +
482
+ (tool === "claude" ?
483
+ "\n⧉ ASE: ▷ hint: use \"[<filter>] //\" for inline skill manpage browsing" : "") : "");
475
484
  /* inject markdown into session context.
476
485
  Anthropic Claude Code CLI and OpenAI Codex CLI expect the context nested in
477
486
  "hookSpecificOutput"; GitHub Copilot CLI expects a flat top-level
@@ -601,7 +610,7 @@ export default class HookCommand {
601
610
  }
602
611
  /* the edit-capable skills whose active state lets the pre-tool-use
603
612
  hook auto-approve subsequent "Edit" invocations */
604
- editCapableSkills = ["ase-code-lint", "ase-docs-shorten", "ase-docs-refine", "ase-docs-proofread"];
613
+ editCapableSkills = ["ase-code-lint", "ase-spec-lint", "ase-docs-shorten", "ase-docs-refine", "ase-docs-proofread"];
605
614
  /* determine whether an ASE tool invocation described by the parsed
606
615
  hook input should be auto-approved, and (if so) the human-readable
607
616
  reason. The input field names and value shapes differ between
@@ -13,18 +13,27 @@ export default class Version {
13
13
  return pkg.version;
14
14
  }
15
15
  /* return latest ASE version known from the NPM registry
16
- (cached background check, falling back to the current version) */
17
- static async latest() {
16
+ (cached background check or, if requested, immediate direct check,
17
+ both falling back to the current version) */
18
+ static async latest(direct = false) {
18
19
  const notifier = updateNotifier({
19
20
  pkg,
20
21
  updateCheckInterval: 1000 * 60 * 60
21
22
  });
22
- /* re-persist the cached update information, as update-notifier consumes it on read,
23
+ /* optionally query the NPM registry directly, where only
24
+ a successful query supersedes the cached update information */
25
+ let update = notifier.update;
26
+ if (direct) {
27
+ const info = await Promise.resolve(notifier.fetchInfo()).catch(() => undefined);
28
+ if (info !== undefined)
29
+ update = info;
30
+ }
31
+ /* re-persist the update information, as update-notifier consumes it on read,
23
32
  but only while it still denotes a newer version (it is never cleared otherwise) */
24
- const latest = notifier.update?.latest;
33
+ const latest = update?.latest;
25
34
  if (latest === undefined || !semver.valid(latest) || !semver.gt(latest, Version.current()))
26
35
  return Version.current();
27
- notifier.config?.set("update", notifier.update);
36
+ notifier.config?.set("update", update);
28
37
  return latest;
29
38
  }
30
39
  }
@@ -0,0 +1,229 @@
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 { fileURLToPath } from "node:url";
9
+ import { fuzzyMin, levenshtein, diceCoefficient } from "./ase-task-board-filter.js";
10
+ /* the grouping and ordering of the skills: a copy of the groups shown by the
11
+ catalog of the website ("skillPrefixGroups" of "pages/src/data/skills.ts",
12
+ in the column order of "Section-Usage.astro"), so keep both in sync, where
13
+ any skill not listed here is caught by a trailing "OTHER" group */
14
+ const skillGroups = [
15
+ ["HELP", ["ase-help-skill", "ase-help-intent"]],
16
+ ["META: CONFIG", ["ase-meta-config", "ase-meta-compat"]],
17
+ ["META: EXPLAIN", ["ase-meta-why", "ase-meta-eli5"]],
18
+ ["META: JUDGE", ["ase-meta-evaluate", "ase-meta-diaboli", "ase-meta-steelman"]],
19
+ ["META: RESEARCH", ["ase-meta-brainstorm", "ase-meta-search", "ase-meta-quorum",
20
+ "ase-meta-proximity", "ase-meta-chat", "ase-meta-quotes"]],
21
+ ["META: GENERATE", ["ase-meta-mint", "ase-meta-workflow"]],
22
+ ["ARCH", ["ase-arch-analyze", "ase-arch-discover"]],
23
+ ["DOCS", ["ase-docs-shorten", "ase-docs-refine", "ase-docs-proofread", "ase-docs-distill"]],
24
+ ["SPEC", ["ase-spec-lint", "ase-spec-edit", "ase-spec-activate"]],
25
+ ["CODE", ["ase-code-edit", "ase-code-analyze", "ase-code-insight", "ase-code-explain", "ase-code-lint"]],
26
+ ["REPO", ["ase-repo-review", "ase-repo-diff", "ase-repo-changelog", "ase-repo-commit",
27
+ "ase-repo-dissect", "ase-repo-merge", "ase-repo-resolve"]],
28
+ ["SYNC", ["ase-sync-reconcile", "ase-sync-import", "ase-sync-export"]],
29
+ ["TASK: MANAGE", ["ase-task-list", "ase-task-rename", "ase-task-delete"]],
30
+ ["TASK: HANDLE", ["ase-task-id", "ase-task-status", "ase-task-assign"]],
31
+ ["TASK: EDIT", ["ase-task-edit", "ase-task-view", "ase-task-grill", "ase-task-research",
32
+ "ase-task-estimate", "ase-task-reboot", "ase-task-condense", "ase-task-dissect"]],
33
+ ["TASK: EXECUTE", ["ase-task-implement", "ase-task-integrate"]]
34
+ ];
35
+ /* the order of the command groups: a copy of the column order of "manColumns"
36
+ of "pages/src/data/manpages.ts", so keep both in sync, where any group not
37
+ listed here (like the one of a newly added top-level command) is appended */
38
+ const commandGroups = ["Top-Level", "setup", "config", "service", "task", "spec", "util", "artifact", "agent", "hook"];
39
+ /* read the Markdown sources of the entries of a directory, by the page
40
+ names and the relative source paths derived from the entry names */
41
+ const readSources = (dir, pick) => {
42
+ let entries;
43
+ try {
44
+ entries = fs.readdirSync(dir);
45
+ }
46
+ catch (err) {
47
+ throw new Error(`man: no manual pages found in "${dir}" (tool not completely built?)`, { cause: err });
48
+ }
49
+ const sources = new Map();
50
+ for (const entry of entries) {
51
+ const picked = pick(entry);
52
+ if (picked !== null && fs.existsSync(path.join(dir, picked[1])))
53
+ sources.set(picked[0], fs.readFileSync(path.join(dir, picked[1]), "utf8"));
54
+ }
55
+ return sources;
56
+ };
57
+ /* create a manual page */
58
+ const makePage = (id, kind, label, title, md) => {
59
+ const text = md.toLowerCase();
60
+ return { id, kind, label, title, md, text, words: new Set(text.split(/[^a-z0-9]+/).filter((w) => w !== "")) };
61
+ };
62
+ /* load the skill pages from the "help.md" files of the bundled plugin (see
63
+ the "build-plugin" build step), grouped like the catalog of the website */
64
+ const loadSkills = () => {
65
+ const pkgdir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
66
+ const dir = path.join(pkgdir, "plugin", "skills");
67
+ const sources = readSources(dir, (entry) => /^ase-[a-z0-9-]+$/.test(entry) ? [entry, path.join(entry, "help.md")] : null);
68
+ const page = (name) => {
69
+ const md = sources.get(name);
70
+ const m = /^#+\s+NAME\s*\n+\s*`?[\w-]+`?\s+-+\s+(.+)$/m.exec(md);
71
+ return makePage(name, "skill", name, m !== null ? m[1].trim() : name, md);
72
+ };
73
+ const listed = new Set(skillGroups.flatMap(([, names]) => names));
74
+ const other = [...sources.keys()].filter((name) => !listed.has(name)).sort();
75
+ return [...skillGroups, ["OTHER", other]]
76
+ .map(([label, names]) => ({ label, pages: names.filter((name) => sources.has(name)).map(page) }))
77
+ .filter((group) => group.pages.length > 0);
78
+ };
79
+ /* load the command pages from the Markdown sources of the manual pages
80
+ (see the "build-man" build step), arranged into the command tree and
81
+ grouped like the catalog of the website ("pages/src/data/manpages.ts") */
82
+ const loadCommands = () => {
83
+ const dir = path.join(path.dirname(fileURLToPath(import.meta.url)), "man");
84
+ const sources = readSources(dir, (entry) => /^ase(?:-[a-z0-9-]+)?\.md$/.test(entry) ? [entry.slice(0, -3), entry] : null);
85
+ const names = [...sources.keys()].sort();
86
+ /* the parent of a page is the longest other page name it extends by a "-<suffix>" */
87
+ const parentOf = (name) => names.filter((other) => name.startsWith(other + "-"))
88
+ .sort((a, b) => b.length - a.length)[0] ?? "";
89
+ /* the children of a page, ordered by their first "`<name>`(1)" reference
90
+ within the COMMANDS section of the page (or the entire page), else alphabetically */
91
+ const childrenOf = (parent) => {
92
+ const md = sources.get(parent);
93
+ const m = /^## COMMANDS\s*$([\s\S]*?)(?=^## |(?![\s\S]))/m.exec(md);
94
+ const section = m !== null ? m[1] : md;
95
+ const pos = (name) => {
96
+ const idx = section.indexOf("`" + name + "`(1)");
97
+ return idx >= 0 ? idx : Infinity;
98
+ };
99
+ return names.filter((name) => parentOf(name) === parent)
100
+ .sort((a, b) => (pos(a) - pos(b)) || a.localeCompare(b));
101
+ };
102
+ /* flatten the command tree depth-first */
103
+ const tree = [];
104
+ const walk = (name, label, depth) => {
105
+ const md = sources.get(name);
106
+ const m = /^#\s+\S+\(1\)\s+--\s+(.+)$/m.exec(md);
107
+ tree.push({ page: makePage(`${name}.1`, "command", label, m !== null ? m[1].trim() : name, md), depth });
108
+ for (const child of childrenOf(name))
109
+ walk(child, label + " " + child.slice(name.length + 1), depth + 1);
110
+ };
111
+ for (const root of names.filter((name) => parentOf(name) === ""))
112
+ walk(root, root.replace(/-/g, " "), 0);
113
+ /* group the command tree: "ase" itself and the top-level commands without
114
+ sub-commands form the "Top-Level" group, and every other top-level
115
+ command forms an own group with all of its deeper commands */
116
+ const groups = [{ label: "Top-Level", pages: [] }];
117
+ for (const { page, depth } of tree) {
118
+ if (depth === 0)
119
+ groups[0].pages.push(page);
120
+ else if (depth === 1) {
121
+ if (tree.some((other) => other.depth > 1 && other.page.label.startsWith(page.label + " ")))
122
+ groups.push({ label: page.label.slice(page.label.lastIndexOf(" ") + 1), pages: [page] });
123
+ else
124
+ groups[0].pages.push(page);
125
+ }
126
+ else
127
+ groups[groups.length - 1].pages.push(page);
128
+ }
129
+ const rank = (group) => {
130
+ const idx = commandGroups.indexOf(group.label);
131
+ return idx >= 0 ? idx : commandGroups.length;
132
+ };
133
+ return groups
134
+ .filter((group) => group.pages.length > 0)
135
+ .sort((a, b) => rank(a) - rank(b))
136
+ .map((group) => ({ ...group, label: group.label.toUpperCase() }));
137
+ };
138
+ /* load the catalog of all manual pages */
139
+ export const loadCatalog = () => {
140
+ const skills = loadSkills();
141
+ const commands = loadCommands();
142
+ const pages = new Map([...skills, ...commands].flatMap((group) => group.pages).map((page) => [page.id, page]));
143
+ const vocab = new Set([...pages.values()].flatMap((page) => [...page.words]));
144
+ return { skills, commands, pages, vocab };
145
+ };
146
+ /* find the page fitting exactly onto the words of a page name or command path,
147
+ which are joined with dashes, where the "ase" prefix is optional, the name is
148
+ case-insensitive, and of a skill and a command of the same name, a page name
149
+ (e.g. "task-edit") prefers the skill, while a command path (e.g. "task edit")
150
+ prefers the command */
151
+ export const findPage = (catalog, words) => {
152
+ const parts = words.join(" ").trim().toLowerCase().split(/\s+/);
153
+ const name = parts.join("-");
154
+ const full = name === "ase" || name.startsWith("ase-") ? name : `ase-${name}`;
155
+ const skill = catalog.pages.get(full);
156
+ const command = catalog.pages.get(`${full}.1`);
157
+ return parts.length === 1 && name.includes("-") ? skill ?? command : command ?? skill;
158
+ };
159
+ /* escape a string for its literal use within a regular expression */
160
+ const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
161
+ /* the regular expression of the matches of a search in a page: the
162
+ keywords as plain substrings, plus the fuzzy variants of the page as
163
+ whole words, with the longest alternative first, so a variant is never
164
+ cut short by a keyword nested inside it */
165
+ const matchRE = (keywords, variants) => {
166
+ const alts = keywords.map((k) => ({ len: k.length, src: escape(k) }))
167
+ .concat(variants.map((w) => ({ len: w.length, src: "(?<![a-z0-9])" + escape(w) + "(?![a-z0-9])" })));
168
+ alts.sort((a, b) => b.len - a.len);
169
+ return new RegExp(alts.map((a) => a.src).join("|"), "gi");
170
+ };
171
+ /* search all pages for a query (as the website does): all whitespace-separated
172
+ keywords have to occur in a page (AND logic), in up to two passes, the
173
+ keywords as literal substrings first and, only if this matches no page at
174
+ all, as fuzzy variants of their words, as the edit-distance neighborhood of
175
+ a keyword would otherwise dilute a correctly spelled query; the result
176
+ maps the ids of the matching pages onto the regular expressions of their
177
+ matches, or is null for a query without any keywords */
178
+ export const searchPages = (catalog, query) => {
179
+ const keywords = query.toLowerCase().split(/\s+/).filter((word) => word !== "");
180
+ if (keywords.length === 0)
181
+ return null;
182
+ const pages = [...catalog.pages.values()];
183
+ const hits = new Map();
184
+ for (const page of pages)
185
+ if (keywords.every((k) => page.text.includes(k)))
186
+ hits.set(page.id, matchRE(keywords, []));
187
+ if (hits.size > 0)
188
+ return hits;
189
+ /* the fuzzy-equivalent vocabulary words of a keyword, cached per keyword */
190
+ const fuzzy = new Map();
191
+ const fuzzyWords = (word) => {
192
+ let result = fuzzy.get(word);
193
+ if (result === undefined) {
194
+ result = word.length < fuzzyMin ? [] : [...catalog.vocab].filter((given) => Math.abs(word.length - given.length) <= 1
195
+ && (diceCoefficient(word, given) >= 0.50 || levenshtein(word, given) <= 2));
196
+ fuzzy.set(word, result);
197
+ }
198
+ return result;
199
+ };
200
+ /* the regular expression of the fuzzy variants of a compound keyword (like
201
+ "ase-task-edit"), with each of its words literally or fuzzily replaced
202
+ and its separators kept, cached per keyword (null for a single word) */
203
+ const compound = new Map();
204
+ const compoundRE = (keyword) => {
205
+ let result = compound.get(keyword);
206
+ if (result === undefined) {
207
+ const parts = keyword.split(/([^a-z0-9]+)/);
208
+ result = parts.length < 2 ? null : new RegExp("(?<![a-z0-9])" + parts.map((part, i) => i % 2 === 1 ? escape(part) : part === "" ? "" :
209
+ "(?:" + [part, ...fuzzyWords(part)].map(escape).join("|") + ")").join("") + "(?![a-z0-9])", "g");
210
+ compound.set(keyword, result);
211
+ }
212
+ return result;
213
+ };
214
+ for (const page of pages) {
215
+ const variants = [];
216
+ const hit = keywords.every((k) => {
217
+ if (page.text.includes(k))
218
+ return true;
219
+ const re = compoundRE(k);
220
+ const near = re !== null ? [...new Set([...page.text.matchAll(re)].map((m) => m[0]))] :
221
+ fuzzyWords(k).filter((w) => page.words.has(w));
222
+ variants.push(...near);
223
+ return near.length > 0;
224
+ });
225
+ if (hit)
226
+ hits.set(page.id, matchRE(keywords, variants));
227
+ }
228
+ return hits;
229
+ };
@@ -0,0 +1,278 @@
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 { marked } from "marked";
7
+ /* the syntax highlighter of the code blocks (via Shiki, with its JavaScript
8
+ regular expression engine, i.e. without WebAssembly, and with an empty
9
+ theme, as only the TextMate scopes of the tokens are used), loaded on
10
+ demand only once */
11
+ let highlighter = null;
12
+ let loading = null;
13
+ export const codeLoad = () => {
14
+ if (loading === null) {
15
+ loading = (async () => {
16
+ const [{ createHighlighterCore }, { createJavaScriptRegexEngine }, { bundledLanguages }] = await Promise.all([
17
+ import("shiki/core"), import("shiki/engine/javascript"), import("shiki/langs")
18
+ ]);
19
+ highlighter = await createHighlighterCore({
20
+ themes: [{ name: "ase", type: "dark", settings: [] }],
21
+ langs: [bundledLanguages.shellscript, bundledLanguages.yaml, bundledLanguages.json, bundledLanguages.typescript],
22
+ engine: createJavaScriptRegexEngine()
23
+ });
24
+ })();
25
+ }
26
+ return loading;
27
+ };
28
+ /* the highlighted languages of the code blocks, by their fence info */
29
+ const codeLangs = {
30
+ sh: "shellscript", bash: "shellscript", shell: "shellscript", shellscript: "shellscript",
31
+ yaml: "yaml", yml: "yaml", json: "json", ts: "typescript", typescript: "typescript"
32
+ };
33
+ /* the tones of the code tokens, by the prefixes of their TextMate scopes (first match wins) */
34
+ const codeScopes = [
35
+ ["cmt", ["comment"]],
36
+ ["str", ["string.quoted", "string.template", "string.unquoted.plain", "string.unquoted.block"]],
37
+ ["opt", ["constant.other.option"]],
38
+ ["var", ["variable.other.normal", "variable.other.special", "variable.other.positional"]],
39
+ ["num", ["constant.numeric", "constant.language"]],
40
+ ["key", ["entity.name.tag", "support.type.property-name"]],
41
+ ["cmd", ["entity.name.command", "entity.name.function", "support.function"]],
42
+ ["kw", ["keyword", "storage"]],
43
+ ["punct", ["punctuation"]]
44
+ ];
45
+ /* append a span to a line, merging it into the last span of equal attributes */
46
+ const append = (line, span) => {
47
+ const last = line[line.length - 1];
48
+ if (last !== undefined && last.tone === span.tone && last.ref === span.ref && last.match === span.match)
49
+ last.text += span.text;
50
+ else
51
+ line.push({ ...span });
52
+ };
53
+ /* split the text of a code block into lines of syntax-highlighting spans
54
+ (all as plain text for an unknown language or while the highlighter is
55
+ not loaded yet, see "codeLoad") */
56
+ const codeLines = (text, lang) => {
57
+ const name = codeLangs[(lang ?? "").toLowerCase()];
58
+ if (highlighter === null || name === undefined)
59
+ return text.split(/\r?\n/).map((line) => [{ text: line, tone: "text" }]);
60
+ const { tokens } = highlighter.codeToTokens(text, { lang: name, theme: "ase", includeExplanation: "scopeName" });
61
+ return tokens.map((line) => {
62
+ const out = [];
63
+ for (const part of line.flatMap((token) => token.explanation ?? [{ content: token.content, scopes: [] }])) {
64
+ const scopes = part.scopes.map((scope) => scope.scopeName);
65
+ const tone = codeScopes.find(([, prefixes]) => scopes.some((scope) => prefixes.some((prefix) => scope === prefix || scope.startsWith(prefix + "."))))?.[0] ?? "text";
66
+ append(out, { text: part.content, tone });
67
+ }
68
+ return out;
69
+ });
70
+ };
71
+ /* register a reference onto a page, if it is referable */
72
+ const refOf = (ctx, id) => {
73
+ if (!ctx.exists(id))
74
+ return undefined;
75
+ ctx.refs.push({ id, line: -1 });
76
+ return ctx.refs.length - 1;
77
+ };
78
+ /* render inline tokens into spans of a base tone, where a code span naming
79
+ a page (a skill by its name, or a command by its name directly followed by
80
+ "(1)") and a link onto the "help.md" of a skill reference this page, and
81
+ a hard line break is a span of a single newline */
82
+ const inline = (tokens, tone, ctx) => {
83
+ const out = [];
84
+ tokens.forEach((token, i) => {
85
+ const next = tokens[i + 1];
86
+ if (token.type === "strong")
87
+ out.push(...inline(token.tokens, "bold", ctx));
88
+ else if (token.type === "em")
89
+ out.push(...inline(token.tokens, "italic", ctx));
90
+ else if (token.type === "del")
91
+ out.push(...inline(token.tokens, tone, ctx));
92
+ else if (token.type === "codespan") {
93
+ const text = token.text;
94
+ const man = next !== undefined && next.type === "text" && next.text.startsWith("(1)");
95
+ out.push({ text: text.replace(/\n/g, " "), tone: "code", ref: refOf(ctx, man ? `${text}.1` : text) });
96
+ }
97
+ else if (token.type === "link") {
98
+ const link = token;
99
+ const spans = inline(link.tokens, "link", ctx);
100
+ const m = /^(?:\.\.\/)?(ase-[a-z0-9-]+)\/help\.md$/.exec(link.href);
101
+ const ref = m !== null && !spans.some((span) => span.ref !== undefined) ? refOf(ctx, m[1]) : undefined;
102
+ out.push(...spans.map((span) => ({ ...span, ref: span.ref ?? ref })));
103
+ }
104
+ else if (token.type === "text") {
105
+ const text = token;
106
+ if (text.tokens !== undefined && text.tokens.length > 0)
107
+ out.push(...inline(text.tokens, tone, ctx));
108
+ else
109
+ out.push({ text: text.text.replace(/\n/g, " "), tone });
110
+ }
111
+ else if (token.type === "br")
112
+ out.push({ text: "\n", tone });
113
+ else if (token.type === "escape" || token.type === "html" || token.type === "image")
114
+ out.push({ text: token.text.replace(/\n/g, " "), tone });
115
+ else
116
+ out.push({ text: token.raw.replace(/\n/g, " "), tone });
117
+ });
118
+ return out;
119
+ };
120
+ /* word-wrap spans onto lines of a width: the spans are split into words
121
+ (glued across adjacent spans, like a code span directly followed by its
122
+ punctuation), which are laid out with single spaces in between, where a
123
+ span of a single newline breaks the line and an overlong word is broken hard */
124
+ const wrap = (spans, width) => {
125
+ const atoms = [];
126
+ let atom = null;
127
+ let space = false;
128
+ for (const span of spans) {
129
+ if (span.text === "\n") {
130
+ atoms.push(null);
131
+ atom = null;
132
+ space = false;
133
+ continue;
134
+ }
135
+ for (const piece of span.text.split(/(\s+)/)) {
136
+ if (piece === "")
137
+ continue;
138
+ if (/^\s+$/.test(piece)) {
139
+ atom = null;
140
+ space = true;
141
+ continue;
142
+ }
143
+ if (atom === null) {
144
+ atom = { parts: [], len: 0, space };
145
+ space = false;
146
+ atoms.push(atom);
147
+ }
148
+ atom.parts.push({ ...span, text: piece });
149
+ atom.len += piece.length;
150
+ }
151
+ }
152
+ const lines = [];
153
+ let line = [];
154
+ let col = 0;
155
+ const newline = () => {
156
+ lines.push(line);
157
+ line = [];
158
+ col = 0;
159
+ };
160
+ for (const a of atoms) {
161
+ if (a === null) {
162
+ newline();
163
+ continue;
164
+ }
165
+ if (col > 0 && col + (a.space ? 1 : 0) + a.len > width)
166
+ newline();
167
+ if (col > 0 && a.space) {
168
+ const prev = line[line.length - 1];
169
+ const same = prev.tone === a.parts[0].tone && prev.ref === a.parts[0].ref;
170
+ append(line, same ? { ...prev, text: " " } : { text: " ", tone: "text" });
171
+ col++;
172
+ }
173
+ for (const part of a.parts) {
174
+ let text = part.text;
175
+ while (col + text.length > width) {
176
+ const n = width - col;
177
+ if (n > 0)
178
+ append(line, { ...part, text: text.slice(0, n) });
179
+ text = text.slice(n);
180
+ newline();
181
+ }
182
+ if (text !== "") {
183
+ append(line, { ...part, text });
184
+ col += text.length;
185
+ }
186
+ }
187
+ }
188
+ if (line.length > 0 || lines.length === 0)
189
+ lines.push(line);
190
+ return lines;
191
+ };
192
+ /* render block tokens into lines of a width, separated by empty lines
193
+ (unless tight, as within the items of a tight list), where list items,
194
+ code blocks, and block quotes are all indented by four columns */
195
+ const blocks = (tokens, width, ctx, tight = false) => {
196
+ const out = [];
197
+ const add = (lines) => {
198
+ if (lines.length === 0)
199
+ return;
200
+ if (out.length > 0 && !tight)
201
+ out.push([]);
202
+ out.push(...lines);
203
+ };
204
+ for (const token of tokens) {
205
+ if (token.type === "space" || token.type === "def")
206
+ continue;
207
+ else if (token.type === "heading") {
208
+ const head = token;
209
+ add(wrap(inline(head.tokens, head.depth === 1 ? "title" : head.depth === 2 ? "head" : "sub", ctx), width));
210
+ }
211
+ else if (token.type === "paragraph" || token.type === "text") {
212
+ const para = token;
213
+ add(wrap(para.tokens !== undefined ? inline(para.tokens, "text", ctx) : [{ text: para.text, tone: "text" }], width));
214
+ }
215
+ else if (token.type === "list") {
216
+ const list = token;
217
+ const lines = [];
218
+ list.items.forEach((item, k) => {
219
+ const bullet = list.ordered ? `${(list.start === "" ? 1 : list.start) + k}. `.padEnd(4) : "● ";
220
+ const body = blocks(item.tokens, Math.max(10, width - bullet.length), ctx, !item.loose);
221
+ if (list.loose && k > 0)
222
+ lines.push([]);
223
+ if (body.length === 0)
224
+ body.push([]);
225
+ body.forEach((line, n) => {
226
+ lines.push([{ text: n === 0 ? bullet : " ".repeat(bullet.length), tone: "bullet" }, ...line]);
227
+ });
228
+ });
229
+ add(lines);
230
+ }
231
+ else if (token.type === "code") {
232
+ const code = token;
233
+ add(codeLines(code.text, code.lang).map((line) => [{ text: " ", tone: "text" }, ...line]));
234
+ }
235
+ else if (token.type === "blockquote")
236
+ add(blocks(token.tokens, Math.max(10, width - 4), ctx)
237
+ .map((line) => [{ text: " │ ", tone: "dim" }, ...line]));
238
+ else if (token.type === "hr")
239
+ add([[{ text: "─".repeat(width), tone: "dim" }]]);
240
+ else
241
+ add(token.raw.replace(/\n+$/, "").split("\n").map((line) => [{ text: line, tone: "dim" }]));
242
+ }
243
+ return out;
244
+ };
245
+ /* render the Markdown source of a page (with the id "self") into lines of
246
+ a width, where references onto the page itself are omitted */
247
+ export const renderPage = (md, width, self, exists) => {
248
+ const ctx = { refs: [], exists: (id) => id !== self && exists(id) };
249
+ const lines = blocks(marked.lexer(md.replace(/\t/g, " ")), Math.max(10, width), ctx);
250
+ lines.forEach((line, n) => {
251
+ for (const span of line)
252
+ if (span.ref !== undefined && ctx.refs[span.ref].line < 0)
253
+ ctx.refs[span.ref].line = n;
254
+ });
255
+ return { lines, refs: ctx.refs };
256
+ };
257
+ /* mark the matches of a (global) regular expression within a line */
258
+ export const markMatches = (line, re) => {
259
+ const text = line.map((span) => span.text).join("");
260
+ const marks = new Array(text.length).fill(false);
261
+ let found = false;
262
+ for (const m of text.matchAll(re)) {
263
+ if (m[0].length === 0)
264
+ continue;
265
+ marks.fill(true, m.index, m.index + m[0].length);
266
+ found = true;
267
+ }
268
+ if (!found)
269
+ return line;
270
+ const out = [];
271
+ let pos = 0;
272
+ for (const span of line) {
273
+ for (let i = 0; i < span.text.length; i++)
274
+ append(out, { ...span, text: span.text[i], match: marks[pos + i] });
275
+ pos += span.text.length;
276
+ }
277
+ return out;
278
+ };