@rse/ase 2.0.4 → 2.0.6

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 (48) hide show
  1. package/dst/ase-config-schema.js +1 -1
  2. package/dst/ase-hook.js +12 -3
  3. package/dst/ase-setup-settings.js +111 -1
  4. package/dst/ase-setup.js +19 -7
  5. package/dst/ase-spec.js +184 -49
  6. package/dst/ase-task-board-tui-model.js +8 -1
  7. package/dst/ase-task-board-tui.js +26 -2
  8. package/dst/ase-task-board-web-client.js +10 -10
  9. package/dst/man/ase-artifact.md +1 -1
  10. package/dst/man/ase-config.md +5 -3
  11. package/dst/man/ase-mcp.md +1 -1
  12. package/dst/man/ase-setup-install.md +10 -1
  13. package/dst/man/ase-setup-status.md +6 -0
  14. package/dst/man/ase-setup-uninstall.md +3 -1
  15. package/dst/man/ase-setup-update.md +3 -0
  16. package/dst/man/ase-setup.md +4 -4
  17. package/dst/man/ase-spec-export.md +14 -3
  18. package/dst/man/ase-spec-init.md +10 -1
  19. package/dst/man/ase-spec-lint.md +2 -1
  20. package/dst/man/ase-spec-preview.md +10 -1
  21. package/dst/man/ase-spec-query.md +96 -0
  22. package/dst/man/ase-spec.md +32 -11
  23. package/dst/man/ase.md +3 -3
  24. package/package.json +21 -18
  25. package/plugin/.claude-plugin/icon.png +0 -0
  26. package/plugin/.claude-plugin/plugin.json +1 -1
  27. package/plugin/.codex-plugin/plugin.json +1 -1
  28. package/plugin/.github/plugin/plugin.json +1 -1
  29. package/plugin/meta/ase-format-spec.md +29 -5
  30. package/plugin/meta/ase-format-specbook.md +427 -47
  31. package/plugin/meta/ase-format-specbook.yaml +180 -78
  32. package/plugin/mods/ase-mods-common.ts +474 -0
  33. package/plugin/mods/ase-mods-evaluate.ts +227 -0
  34. package/plugin/mods/ase-mods-grill.ts +283 -0
  35. package/plugin/mods/ase-mods-help-browse.ts +410 -0
  36. package/plugin/mods/ase-mods-help-compose.ts +561 -0
  37. package/plugin/mods/ase-mods-help-find.ts +434 -0
  38. package/plugin/mods/ase-mods-help-hint.ts +134 -0
  39. package/plugin/mods/ase-mods-help-show.ts +142 -0
  40. package/plugin/mods/ase-mods.ts +220 -381
  41. package/plugin/skills/ase-code-lint/SKILL.md +15 -15
  42. package/plugin/skills/ase-code-lint/help.md +21 -8
  43. package/plugin/skills/ase-help-intent/SKILL.md +2 -1
  44. package/plugin/skills/ase-help-intent/data.md +42 -16
  45. package/plugin/skills/ase-spec-activate/SKILL.md +4 -1
  46. package/plugin/skills/ase-spec-lint/SKILL.md +15 -15
  47. package/plugin/skills/ase-spec-lint/help.md +21 -8
  48. package/plugin/mods/claude-code.d.ts +0 -13440
@@ -0,0 +1,410 @@
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
+
7
+ /* Claude Code function hooks module ("mod", EARLY ACCESS):
8
+ Manpage browser, showing for a prompt of just "//", or of a free text
9
+ suffixed with a blank and "//", the manual pages of all ASE skills in a
10
+ master/detail view in the band above the prompt, filtered by a filter
11
+ field (initially the free text) with a fuzzy search (like "ase man"),
12
+ where typed text edits the filter, LEFT/RIGHT (or the wheel over the
13
+ skills) selects a skill, UP/DOWN (or the wheel over the manual page)
14
+ scroll its manual page (with the matches of the filter marked), RETURN
15
+ takes over its command into the prompt, SPACE takes it over into the
16
+ synopsis form, "!!" switches over to the intent help for the filter,
17
+ and "//" leaves the filter in the prompt (which mirrors the filter while
18
+ the browser is shown). */
19
+
20
+ import type { Elements, Frozen, PromptBox, PromptEditInput, RenderElement } from "claude-code"
21
+ import { type Engine, type Char, type BandRender, type BandScroll, type View, PICKED,
22
+ held, decode, uniform, wrap, layout, wheel, panel } from "./ase-mods-common.ts"
23
+ import { page } from "./ase-mods-help-show.ts"
24
+
25
+ /* the trigger of a manpage browser inside the prompt (just "//", or a free text
26
+ suffixed with a blank and "//"), the skill directories (relative to the plugin
27
+ directory), the title placed into the top edge of the frame, and the share of
28
+ the band columns taken by the master view */
29
+ const BROWSE = /^(?:([^]*\S)\s+)?\/\/$/
30
+ const SKILLS = "skills"
31
+ const TITLE = "[ ⧉ ASE: Browse Skill Manpages ]"
32
+ const MASTER = 0.3
33
+
34
+ /* the manual page of a skill: its name, its Markdown source, and the lower-cased
35
+ Markdown source and its words (for searching) */
36
+ type Doc = { skill: string, manual: string, text: string, words: Set<string> }
37
+
38
+ /* a manpage browser: the scroll state of its band, the filter, the selected
39
+ manual page (by its index into the matching ones), the last key, if a "/"
40
+ or "!", and the selection and filter shown (resetting the scrolling on
41
+ change) of the detail view */
42
+ type Browse = View & {
43
+ filter: string,
44
+ select: number,
45
+ pending: string,
46
+ anchor: string
47
+ }
48
+
49
+ /* the minimum length of a word for its fuzzy matching, the Levenshtein edit
50
+ distance, and the Sorensen-Dice bigram similarity coefficient of two words
51
+ (copies of "fuzzyMin", "levenshtein", and "diceCoefficient" of
52
+ "tool/src/ase-task-board-filter.ts", so keep them in sync) */
53
+ const fuzzyMin = 4
54
+ const levenshtein = (a: string, b: string): number => {
55
+ let prev = [] as number[]
56
+ let curr = [] as number[]
57
+ for (let j = 0; j <= b.length; j++)
58
+ prev[j] = j
59
+ for (let i = 1; i <= a.length; i++) {
60
+ curr[0] = i
61
+ for (let j = 1; j <= b.length; j++)
62
+ curr[j] = Math.min(prev[j]! + 1, curr[j - 1]! + 1,
63
+ prev[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1))
64
+ const swap = prev
65
+ prev = curr
66
+ curr = swap
67
+ }
68
+ return prev[b.length]!
69
+ }
70
+ const diceCoefficient = (a: string, b: string): number => {
71
+ if (a === b)
72
+ return 1
73
+ if (a.length < 2 || b.length < 2)
74
+ return 0
75
+ const bigrams = new Map<string, number>()
76
+ for (let i = 0; i < a.length - 1; i++) {
77
+ const bigram = a.substring(i, i + 2)
78
+ bigrams.set(bigram, (bigrams.get(bigram) ?? 0) + 1)
79
+ }
80
+ let hits = 0
81
+ for (let i = 0; i < b.length - 1; i++) {
82
+ const bigram = b.substring(i, i + 2)
83
+ const count = bigrams.get(bigram) ?? 0
84
+ if (count > 0) {
85
+ bigrams.set(bigram, count - 1)
86
+ hits++
87
+ }
88
+ }
89
+ return (2 * hits) / (a.length + b.length - 2)
90
+ }
91
+
92
+ /* escape a string for its literal use within a regular expression */
93
+ const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
94
+
95
+ /* the regular expression of the matches of a search in a page: the keywords as
96
+ plain substrings, plus the fuzzy variants of the page as whole words, with the
97
+ longest alternative first, so a variant is never cut short by a keyword nested
98
+ inside it (a copy of "matchRE" of "tool/src/ase-man-catalog.ts", so keep it in sync) */
99
+ const matchRE = (keywords: string[], variants: string[]): RegExp => {
100
+ const alts = keywords.map((k) => ({ len: k.length, src: escape(k) }))
101
+ .concat(variants.map((w) => ({ len: w.length, src: "(?<![a-z0-9])" + escape(w) + "(?![a-z0-9])" })))
102
+ alts.sort((a, b) => b.len - a.len)
103
+ return new RegExp(alts.map((a) => a.src).join("|"), "gi")
104
+ }
105
+
106
+ /* search all pages for a query (like "ase man"): all whitespace-separated keywords
107
+ have to occur in a page (AND logic), in up to two passes, the keywords as literal
108
+ substrings first and, only if this matches no page at all, as fuzzy variants of
109
+ their words; the result maps the skills of the matching pages onto the regular
110
+ expressions of their matches, or is null for a query without any keywords (a copy
111
+ of "searchPages" of "tool/src/ase-man-catalog.ts", so keep it in sync) */
112
+ const search = (docs: Doc[], vocab: Set<string>, query: string): Map<string, RegExp> | null => {
113
+ const keywords = query.toLowerCase().split(/\s+/).filter((word) => word !== "")
114
+ if (keywords.length === 0)
115
+ return null
116
+ const hits = new Map<string, RegExp>()
117
+ for (const doc of docs)
118
+ if (keywords.every((k) => doc.text.includes(k)))
119
+ hits.set(doc.skill, matchRE(keywords, []))
120
+ if (hits.size > 0)
121
+ return hits
122
+
123
+ /* the fuzzy-equivalent vocabulary words of a keyword, cached per keyword */
124
+ const fuzzy = new Map<string, string[]>()
125
+ const fuzzyWords = (word: string): string[] => {
126
+ let result = fuzzy.get(word)
127
+ if (result === undefined) {
128
+ result = word.length < fuzzyMin ? [] : [ ...vocab ].filter((given) =>
129
+ Math.abs(word.length - given.length) <= 1
130
+ && (diceCoefficient(word, given) >= 0.50 || levenshtein(word, given) <= 2))
131
+ fuzzy.set(word, result)
132
+ }
133
+ return result
134
+ }
135
+
136
+ /* the regular expression of the fuzzy variants of a compound keyword (like
137
+ "ase-task-edit"), with each of its words literally or fuzzily replaced
138
+ and its separators kept, cached per keyword (null for a single word) */
139
+ const compound = new Map<string, RegExp | null>()
140
+ const compoundRE = (keyword: string): RegExp | null => {
141
+ let result = compound.get(keyword)
142
+ if (result === undefined) {
143
+ const parts = keyword.split(/([^a-z0-9]+)/)
144
+ result = parts.length < 2 ? null : new RegExp("(?<![a-z0-9])" + parts.map((part, i) =>
145
+ i % 2 === 1 ? escape(part) : part === "" ? "" :
146
+ "(?:" + [ part, ...fuzzyWords(part) ].map(escape).join("|") + ")").join("") + "(?![a-z0-9])", "g")
147
+ compound.set(keyword, result)
148
+ }
149
+ return result
150
+ }
151
+ for (const doc of docs) {
152
+ const variants = [] as string[]
153
+ const hit = keywords.every((k) => {
154
+ if (doc.text.includes(k))
155
+ return true
156
+ const re = compoundRE(k)
157
+ const near = re !== null ? [ ...new Set([ ...doc.text.matchAll(re) ].map((m) => m[0])) ] :
158
+ fuzzyWords(k).filter((w) => doc.words.has(w))
159
+ variants.push(...near)
160
+ return near.length > 0
161
+ })
162
+ if (hit)
163
+ hits.set(doc.skill, matchRE(keywords, variants))
164
+ }
165
+ return hits
166
+ }
167
+
168
+ /* mark the matches of a (global) regular expression within a line of styled
169
+ characters (in bold and the color of a picked item) */
170
+ const mark = (line: Char[], re: RegExp): Char[] => {
171
+ const text = line.map((char) => char.ch).join("")
172
+ const hits = new Set<number>()
173
+ for (const m of text.matchAll(re))
174
+ for (let i = m.index; i < m.index + m[0].length; i++)
175
+ hits.add(i)
176
+ if (hits.size === 0)
177
+ return line
178
+ let pos = 0
179
+ return line.map((char) => {
180
+ const hit = hits.has(pos)
181
+ pos += char.ch.length
182
+ return hit ? { ...char, bold: true, color: PICKED } : char
183
+ })
184
+ }
185
+
186
+ /* the manpage browser: its state and its operations */
187
+ export const modHelpBrowse = () => {
188
+ /* the manual pages of all skills and their vocabulary (loaded on first use),
189
+ and the currently shown browser */
190
+ let docs: Doc[] | undefined
191
+ let vocab: Set<string> = new Set()
192
+ let browse: Browse | undefined
193
+
194
+ /* determine whether the manpage browser is shown */
195
+ const active = () => browse !== undefined
196
+
197
+ /* close the manpage browser */
198
+ const reset = () => {
199
+ browse = undefined
200
+ }
201
+
202
+ /* determine the prompt to leave on closing the manpage browser: its filter
203
+ (with a single trailing blank to type on), if any */
204
+ const leave = (current: Browse): string =>
205
+ current.filter.trim() !== "" ? `${current.filter.trimEnd()} ` : ""
206
+
207
+ /* close the manpage browser (as on a failure) and determine the prompt to
208
+ restore (the filter), if it was shown */
209
+ const cancel = (): string | undefined => {
210
+ const text = browse !== undefined ? leave(browse) : undefined
211
+ reset()
212
+ return text
213
+ }
214
+
215
+ /* determine the matching manual pages of the filter (all of them for an empty
216
+ filter), with the regular expressions of their matches, cached per filter
217
+ (and manual pages) */
218
+ let cache: { filter: string, docs: Doc[] | undefined, list: { doc: Doc, re: RegExp | undefined }[] } | undefined
219
+ const matching = (current: Browse): { doc: Doc, re: RegExp | undefined }[] => {
220
+ if (cache?.filter !== current.filter || cache.docs !== docs) {
221
+ const hits = search(docs ?? [], vocab, current.filter)
222
+ cache = { filter: current.filter, docs, list: (docs ?? []).filter((doc) => hits === null || hits.has(doc.skill))
223
+ .map((doc) => ({ doc, re: hits?.get(doc.skill) })) }
224
+ }
225
+ return cache.list
226
+ }
227
+
228
+ /* hold the prompt as the current filter while the browser is shown */
229
+ const hold = (current: Browse): PromptBox => held(current.filter.trimEnd())
230
+
231
+ /* take over the command of the selected manual page into the prompt (closing
232
+ the browser), if any page matches, optionally suffixed with a trigger (to
233
+ switch over to its band), and determine the prompt to show */
234
+ const take = (current: Browse, trigger = ""): PromptBox => {
235
+ const doc = matching(current)[current.select]?.doc
236
+ if (doc === undefined)
237
+ return hold(current)
238
+ reset()
239
+ const text = `/${doc.skill} ${trigger}`
240
+ return { text, cursor: text.length }
241
+ }
242
+
243
+ /* while the manpage browser is shown, every edit is a key of the browser:
244
+ LEFT/RIGHT select a manual page, UP/DOWN scroll it by a line, RETURN takes
245
+ over the command of the selected page, SPACE takes it over suffixed with
246
+ the trigger of the synopsis form, "//" (two "/" in a row) closes the
247
+ browser and leaves the filter in the prompt, "!!" (two "!" in a row, for
248
+ a non-empty filter) closes the browser with the filter suffixed with the
249
+ trigger of the intent help (both to switch over to the other band), a
250
+ deletion (like BACKSPACE) deletes the last character of the filter, and
251
+ any other typed text is appended to the filter */
252
+ const edit = (e: Frozen<PromptEditInput>): PromptBox => {
253
+ const current = browse!
254
+ const key = decode(e)
255
+ const pending = current.pending
256
+ current.pending = (key.text === "/" || (key.text === "!" && current.filter.trim() !== "")) &&
257
+ key.text !== pending ? key.text : ""
258
+ if (current.pending !== "") {
259
+ if (pending !== "") {
260
+ current.filter += pending
261
+ current.select = 0
262
+ }
263
+ return hold(current)
264
+ }
265
+ if (key.text === "//" || (key.text === "/" && pending === "/")) {
266
+ reset()
267
+ const text = leave(current)
268
+ return { text, cursor: text.length }
269
+ }
270
+ if (current.filter.trim() !== "" && (key.text === "!!" || (key.text === "!" && pending === "!"))) {
271
+ reset()
272
+ const text = `${current.filter.trim()} !!`
273
+ return { text, cursor: text.length }
274
+ }
275
+ current.filter += pending
276
+ if (key.name === "return")
277
+ return take(current)
278
+ if (key.text === " ")
279
+ return take(current, "!!")
280
+ if (key.name === "up" || key.name === "down")
281
+ current.scroll = Math.max(0, current.scroll + (key.name === "up" ? -1 : 1))
282
+ else if (key.step !== 0) {
283
+ current.select = Math.max(0, Math.min(matching(current).length - 1, current.select + key.step))
284
+ current.scroll = 0
285
+ }
286
+ else if (key.erase < 0)
287
+ current.filter = current.filter.slice(0, -1)
288
+ else if (key.text !== "")
289
+ current.filter += key.text.replace(/\s+/g, " ")
290
+ if (pending !== "" || key.erase < 0 || key.text !== "")
291
+ current.select = 0
292
+ return hold(current)
293
+ }
294
+
295
+ /* for a prompt of just the trigger, or of a free text suffixed with it, show
296
+ the manpage browser with the free text as its filter (and hold the prompt) */
297
+ const open = async (box: PromptBox, engine: Pick<Engine, "read" | "dirs">): Promise<PromptBox | undefined> => {
298
+ const m = BROWSE.exec(box.text)
299
+ if (m === null)
300
+ return undefined
301
+ if (docs === undefined) {
302
+ const skills = (await engine.dirs(SKILLS)).filter((name) => /^ase-[a-z0-9]+-[a-z0-9]+$/.test(name)).sort()
303
+ const loaded = await Promise.all(skills.map(async (skill) => {
304
+ const manual = await engine.read(`${SKILLS}/${skill}/help.md`).catch(() => undefined)
305
+ if (manual === undefined)
306
+ return []
307
+ const text = manual.toLowerCase()
308
+ return [ { skill, manual, text, words: new Set(text.split(/[^a-z0-9]+/).filter((w) => w !== "")) } ]
309
+ }))
310
+ docs = loaded.flat()
311
+ vocab = new Set(docs.flatMap((doc) => [ ...doc.words ]))
312
+ }
313
+ browse = { filter: m[1] ?? "", select: 0, pending: "", anchor: "",
314
+ top: 0, scroll: 0, page: 1, split: 0 }
315
+ return hold(browse)
316
+ }
317
+
318
+ /* operate the manpage browser by the wheel: over the master view select a
319
+ manual page, over the detail view scroll it, resp. scroll it by a page with
320
+ the scroll keys (PAGEUP/PAGEDOWN) while the band is focused (and tell
321
+ whether the browser took the scrolling) */
322
+ const scroll = (e: BandScroll): boolean => {
323
+ if (browse === undefined)
324
+ return false
325
+ const current = browse
326
+ wheel(e, current, (by) => {
327
+ const select = Math.max(0, Math.min(matching(current).length - 1, current.select + by))
328
+ if (select !== current.select) {
329
+ current.select = select
330
+ current.scroll = 0
331
+ }
332
+ })
333
+ return true
334
+ }
335
+
336
+ /* operate the manpage browser by RETURN at the prompt and determine the
337
+ prompt to refill (the held prompt while the browser stays shown, but
338
+ without its space after the cursor, else the taken over command) */
339
+ const submit = (): string => {
340
+ const box = take(browse!)
341
+ return browse !== undefined ? box.text.trimEnd() : box.text
342
+ }
343
+
344
+ /* draw the manpage browser into the band above the prompt (terminal only) */
345
+ const render = (e: BandRender, ui: Elements["terminal"]): RenderElement | undefined => {
346
+ /* yield to a survey and draw nothing without a browser */
347
+ if (browse === undefined || e.props.hasSurvey)
348
+ return undefined
349
+ const current = browse
350
+ const { Text } = ui
351
+
352
+ /* determine the geometry of the band and the matching manual pages
353
+ (with the selection clamped onto them) */
354
+ const geometry = layout(e, MASTER)
355
+ const { body, detail } = geometry
356
+ const list = matching(current)
357
+ current.select = Math.max(0, Math.min(list.length - 1, current.select))
358
+ const selected = list[current.select]
359
+
360
+ /* determine the lines of the detail view: the manual page of the selected
361
+ skill with the matches of the filter marked */
362
+ const lines = selected === undefined ?
363
+ wrap(uniform("No manual page matches the filter.", false, undefined), detail, 0) :
364
+ page(selected.doc.manual, detail).map((line) => selected.re !== undefined ? mark(line, selected.re) : line)
365
+
366
+ /* reset the scroll position of the detail view once the selection or the
367
+ filter changes, and keep the selected manual page in the master view
368
+ (below the filter field and its horizontal line) */
369
+ const anchor = `${current.select}/${current.filter}`
370
+ if (current.anchor !== anchor) {
371
+ current.anchor = anchor
372
+ current.scroll = 0
373
+ }
374
+ const rows = Math.max(1, body - 2)
375
+ current.top = Math.max(0, Math.min(current.select, Math.max(current.top, current.select - rows + 1)))
376
+
377
+ /* determine the lines of the master view: the filter field (with its caret,
378
+ resp. its placeholder), a horizontal line (joining the borders), and the
379
+ matching manual pages (the selected one in red) */
380
+ const field = Text({ wrap: "truncate-start", children: [ "❯ ",
381
+ ...(current.filter !== "" ? [ Text({ bold: true, children: current.filter }) ] : []),
382
+ Text({ inverse: true, children: " " }),
383
+ ...(current.filter === "" ? [ Text({ dimColor: true, italic: true, children: " filter…" }) ] : []) ] })
384
+ const entry = (k: number) => {
385
+ const item = list[k]
386
+ if (item === undefined)
387
+ return undefined
388
+ const picked = k === current.select
389
+ return Text({ wrap: "truncate-end", bold: picked, color: picked ? PICKED : undefined,
390
+ children: `${picked ? "▶" : " "} ${item.doc.skill}` })
391
+ }
392
+
393
+ /* draw both views (with the count of the matching manual pages in the
394
+ bottom edge) and the key hints */
395
+ const wheeled = geometry.overflow ? "/wheel" : ""
396
+ return panel(ui, geometry, current, {
397
+ title: TITLE,
398
+ status: `[ ${list.length}/${docs?.length ?? 0} manpages ]`,
399
+ hints: `←/→${wheeled}: select · ` +
400
+ `${geometry.overflow ? "wheel or CTRL+x + TAB + ↑/↓/PgUp/PgDn" : "↑/↓"}: scroll · ` +
401
+ "//: cancel+close · ⏎: apply+close · SPACE: compose" +
402
+ (current.filter.trim() !== "" ? " · !!: Find Skill Command" : "") + " · (type for filtering)",
403
+ rows: (i) => i === 0 ? field : entry(current.top + i - 2),
404
+ rule: (i) => i === 1,
405
+ lines
406
+ })
407
+ }
408
+
409
+ return { active, reset, cancel, edit, open, scroll, submit, render }
410
+ }