@rse/ase 2.0.3 → 2.0.4

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 (187) hide show
  1. package/dst/ase-hook.js +1 -1
  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 +12 -17
  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/claude-code.d.ts +26 -3
  99. package/plugin/skills/ase-help-intent/data.md +180 -1
  100. package/plugin/skills/ase-help-skill/catalog.md +1 -0
  101. package/plugin/skills/ase-meta-workflow/workflow.txt +2 -0
  102. package/plugin/skills/ase-repo-commit/SKILL.md +1 -0
  103. package/plugin/skills/ase-repo-merge/SKILL.md +1 -0
  104. package/plugin/skills/ase-spec-edit/SKILL.md +3 -3
  105. package/plugin/skills/ase-spec-edit/help.md +1 -1
  106. package/plugin/skills/ase-spec-lint/SKILL.md +695 -0
  107. package/plugin/skills/ase-spec-lint/help.md +171 -0
  108. package/plugin/skills/ase-task-implement/SKILL.md +24 -6
  109. package/plugin/skills/ase-task-implement/help.md +11 -1
  110. package/dst/man/ase-agent-adhoc.1 +0 -515
  111. package/dst/man/ase-agent-condenser.1 +0 -193
  112. package/dst/man/ase-agent-editor.1 +0 -212
  113. package/dst/man/ase-agent-estimator.1 +0 -214
  114. package/dst/man/ase-agent-fleet.1 +0 -185
  115. package/dst/man/ase-agent-implementer.1 +0 -233
  116. package/dst/man/ase-agent-integrator.1 +0 -213
  117. package/dst/man/ase-agent-qualifier.1 +0 -214
  118. package/dst/man/ase-agent-rebooter.1 +0 -193
  119. package/dst/man/ase-agent-relay.1 +0 -128
  120. package/dst/man/ase-agent-researcher.1 +0 -217
  121. package/dst/man/ase-agent.1 +0 -264
  122. package/dst/man/ase-artifact-list.1 +0 -38
  123. package/dst/man/ase-artifact-name.1 +0 -39
  124. package/dst/man/ase-artifact.1 +0 -56
  125. package/dst/man/ase-config-delete.1 +0 -45
  126. package/dst/man/ase-config-edit.1 +0 -48
  127. package/dst/man/ase-config-get.1 +0 -46
  128. package/dst/man/ase-config-init.1 +0 -60
  129. package/dst/man/ase-config-list.1 +0 -42
  130. package/dst/man/ase-config-set.1 +0 -52
  131. package/dst/man/ase-config.1 +0 -231
  132. package/dst/man/ase-hook-permission-request.1 +0 -36
  133. package/dst/man/ase-hook-pre-tool-use.1 +0 -59
  134. package/dst/man/ase-hook-session-end.1 +0 -47
  135. package/dst/man/ase-hook-session-persona.1 +0 -37
  136. package/dst/man/ase-hook-session-start.1 +0 -81
  137. package/dst/man/ase-hook-stop.1 +0 -34
  138. package/dst/man/ase-hook-user-prompt-submit.1 +0 -34
  139. package/dst/man/ase-hook.1 +0 -51
  140. package/dst/man/ase-man.1 +0 -67
  141. package/dst/man/ase-mcp.1 +0 -61
  142. package/dst/man/ase-service-log.1 +0 -43
  143. package/dst/man/ase-service-send.1 +0 -47
  144. package/dst/man/ase-service-start.1 +0 -54
  145. package/dst/man/ase-service-status.1 +0 -46
  146. package/dst/man/ase-service-stop.1 +0 -37
  147. package/dst/man/ase-service.1 +0 -85
  148. package/dst/man/ase-setup-disable.1 +0 -44
  149. package/dst/man/ase-setup-enable.1 +0 -45
  150. package/dst/man/ase-setup-install.1 +0 -73
  151. package/dst/man/ase-setup-mcp.1 +0 -124
  152. package/dst/man/ase-setup-status.1 +0 -60
  153. package/dst/man/ase-setup-statusline.1 +0 -92
  154. package/dst/man/ase-setup-telemetry.1 +0 -92
  155. package/dst/man/ase-setup-uninstall.1 +0 -59
  156. package/dst/man/ase-setup-update.1 +0 -64
  157. package/dst/man/ase-setup.1 +0 -93
  158. package/dst/man/ase-spec-export.1 +0 -62
  159. package/dst/man/ase-spec-lint.1 +0 -40
  160. package/dst/man/ase-spec-preview.1 +0 -40
  161. package/dst/man/ase-spec.1 +0 -46
  162. package/dst/man/ase-statusline.1 +0 -189
  163. package/dst/man/ase-task-assign.1 +0 -71
  164. package/dst/man/ase-task-board.1 +0 -384
  165. package/dst/man/ase-task-delete.1 +0 -35
  166. package/dst/man/ase-task-edit.1 +0 -46
  167. package/dst/man/ase-task-idscheme.1 +0 -65
  168. package/dst/man/ase-task-lifecycle.1 +0 -57
  169. package/dst/man/ase-task-list.1 +0 -47
  170. package/dst/man/ase-task-load.1 +0 -36
  171. package/dst/man/ase-task-newid.1 +0 -61
  172. package/dst/man/ase-task-purge.1 +0 -37
  173. package/dst/man/ase-task-rename.1 +0 -44
  174. package/dst/man/ase-task-request.1 +0 -61
  175. package/dst/man/ase-task-save.1 +0 -48
  176. package/dst/man/ase-task-status.1 +0 -55
  177. package/dst/man/ase-task-store.1 +0 -194
  178. package/dst/man/ase-task-view.1 +0 -40
  179. package/dst/man/ase-task.1 +0 -160
  180. package/dst/man/ase-util-compat.1 +0 -25
  181. package/dst/man/ase-util-diagram.1 +0 -76
  182. package/dst/man/ase-util-meta.1 +0 -45
  183. package/dst/man/ase-util-metric.1 +0 -54
  184. package/dst/man/ase-util-mint.1 +0 -59
  185. package/dst/man/ase-util-worktree.1 +0 -72
  186. package/dst/man/ase-util.1 +0 -50
  187. package/dst/man/ase.1 +0 -124
package/dst/ase-hook.js CHANGED
@@ -601,7 +601,7 @@ export default class HookCommand {
601
601
  }
602
602
  /* the edit-capable skills whose active state lets the pre-tool-use
603
603
  hook auto-approve subsequent "Edit" invocations */
604
- editCapableSkills = ["ase-code-lint", "ase-docs-shorten", "ase-docs-refine", "ase-docs-proofread"];
604
+ editCapableSkills = ["ase-code-lint", "ase-spec-lint", "ase-docs-shorten", "ase-docs-refine", "ase-docs-proofread"];
605
605
  /* determine whether an ASE tool invocation described by the parsed
606
606
  hook input should be auto-approved, and (if so) the human-readable
607
607
  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
+ };