@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
@@ -0,0 +1,426 @@
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
+ Intent help, matching the still unsubmitted prompt, once suffixed with
9
+ a blank and "!!", as an intent against the help of all ASE skills (like
10
+ the skill "ase-help-intent", but over the session context), and showing
11
+ the proposed skills in a master/detail view in the band above the
12
+ prompt, where LEFT/RIGHT (or the wheel over the skills) selects a skill,
13
+ UP/DOWN and "b"/"f"/SPACE (or the wheel over the details) scroll its
14
+ details, RETURN picks its command into the prompt, and "!!", BACKSPACE, or
15
+ any other key restores the prompt as typed without the trigger. */
16
+
17
+ import type { Elements, Frozen, PromptBox, PromptEditInput, RenderElement } from "claude-code"
18
+ import { type Engine, type Char, type BandRender, type BandScroll, type View, PICKED, CODE,
19
+ held, decode, section, wrap, uniform, flow, frame, layout, wheel, panel } from "./ase-mods-common.ts"
20
+
21
+ /* the trigger of an intent inside the prompt (suffixed with a blank and "!!",
22
+ so the doubled exclamation mark of a sentence is no trigger), the skill of
23
+ a proposed command (with or without its "ase:" plugin prefix), the maximum
24
+ number of command proposals, the help corpus of all skills (relative to
25
+ the plugin directory), and the fallback model without any session to fork */
26
+ const INTENT = /\s+!!$/
27
+ const SKILL = /^\/?(?:ase:)?(ase-[a-z0-9]+-[a-z0-9]+)(?![\w-])/
28
+ const PROPOSALS = 8
29
+ const CORPUS = "skills/ase-help-intent/data.md"
30
+ const FALLBACK = "sonnet"
31
+
32
+ /* the color of the box around (and of the rationale inside) the command and
33
+ rationale of a proposal, the title placed into the top edge of the frame,
34
+ and the share of the band columns taken by the master view */
35
+ const BOXED = "blue"
36
+ const TITLE = "[ ⧉ ASE: Find Skill Command ]"
37
+ const MASTER = 0.3
38
+
39
+ /* the frames of the spinner animating the status message while matching,
40
+ and the milliseconds between two frames */
41
+ const SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
42
+ const SPIN = 100
43
+
44
+ /* the help of a single skill: its catalog entry and its entire manual page */
45
+ type Help = { catalog: string, manual: string }
46
+
47
+ /* a command proposal for an intent */
48
+ type Proposal = { skill: string, command: string, rationale: string }
49
+
50
+ /* an intent under matching: the scroll state of its band, the original prompt
51
+ (without the trigger and its blank, resp. without the trigger only), the
52
+ matching state, the status message,
53
+ the spinner frame, the command proposals, the selected proposal, and
54
+ the last key, if a "!" or "/" */
55
+ type Intent = View & {
56
+ draft: string,
57
+ typed: string,
58
+ state: "busy" | "done" | "fail",
59
+ message: string,
60
+ spin: number,
61
+ proposals: Proposal[],
62
+ select: number,
63
+ pending: string
64
+ }
65
+
66
+ /* parse the help corpus into the help of each skill, where
67
+ the catalog entry is the NAME, SYNOPSIS, DESCRIPTION, and SCENARIOS only */
68
+ const helps = (text: string): Map<string, Help> => {
69
+ const result = new Map<string, Help>()
70
+ for (const m of text.matchAll(/<purpose name="([^"]+)">\n([\s\S]*?)<\/purpose>/g)) {
71
+ const manual = m[2]!.trim()
72
+ const catalog = manual.split(/^(?=## {2})/m)
73
+ .filter((part) => /^## {2}(?:NAME|SYNOPSIS|DESCRIPTION|SCENARIOS)\b/.test(part))
74
+ .join("").trim()
75
+ result.set(m[1]!, { catalog, manual })
76
+ }
77
+ return result
78
+ }
79
+
80
+ /* extract the text out of a model reply (as the runtime result of a fork
81
+ or completion may deviate from its declared type, like a string, a result
82
+ object, an unanswered reply, or a refusal with a reason instead of null) */
83
+ const reply = (result: unknown): string | undefined => {
84
+ if (typeof result === "string")
85
+ return result
86
+ if (typeof result === "object" && result !== null) {
87
+ const r = result as { isAnswered?: boolean, text?: unknown }
88
+ if (r.isAnswered !== false && typeof r.text === "string")
89
+ return r.text
90
+ }
91
+ return undefined
92
+ }
93
+
94
+ /* extract the JSON array out of a model reply (trying each opening bracket,
95
+ as any prose before the array may contain brackets, too) */
96
+ const array = (text: string | undefined): unknown[] | undefined => {
97
+ if (text === undefined)
98
+ return undefined
99
+ const j = text.lastIndexOf("]")
100
+ for (let i = text.indexOf("["); i !== -1 && i < j; i = text.indexOf("[", i + 1)) {
101
+ try {
102
+ const value: unknown = JSON.parse(text.slice(i, j + 1))
103
+ if (Array.isArray(value))
104
+ return value
105
+ }
106
+ catch {
107
+ /* continue with the next opening bracket */
108
+ }
109
+ }
110
+ return undefined
111
+ }
112
+
113
+ /* the prompt for selecting the fitting skills out of the catalog */
114
+ const selection = (draft: string, catalog: string) => `
115
+ Do *not* call any tools and do *not* perform the following intent!
116
+ Instead, only *match* the following free-text intent, interpreted in the
117
+ context of this session, against the following catalog of ASE skills:
118
+
119
+ <intent>
120
+ ${draft}
121
+ </intent>
122
+
123
+ <catalog>
124
+ ${catalog}
125
+ </catalog>
126
+
127
+ Select *every* skill which adequately fits the intent -- judging the
128
+ fit primarily by its SCENARIOS and DESCRIPTION, and secondarily by its
129
+ NAME and SYNOPSIS -- ordered from best-fitting to worst-fitting, and
130
+ keep at most the ${PROPOSALS} best ones. Reply with
131
+ a JSON array of the skill names only (like \`[ "ase-code-lint" ]\`),
132
+ resp. with an empty JSON array if no skill fits, and nothing else.
133
+ `
134
+
135
+ /* the prompt for generating the commands out of the manual pages */
136
+ const generation = (draft: string, manuals: string) => `
137
+ Do *not* call any tools and do *not* perform the following intent!
138
+ Instead, only *generate* commands which realize the following free-text
139
+ intent, interpreted in the context of this session, out of the
140
+ following manual pages of ASE skills:
141
+
142
+ <intent>
143
+ ${draft}
144
+ </intent>
145
+
146
+ <manuals>
147
+ ${manuals}
148
+ </manuals>
149
+
150
+ For each skill, in the given order, generate a concrete command which
151
+ realizes the intent, with the option flags and positional arguments
152
+ derived from its SYNOPSIS, OPTIONS, and ARGUMENTS, and their concrete
153
+ values taken from the intent and the context of this session. Reply with
154
+ a JSON array of objects \`{ "command": "/ase:<skill> <arguments>",
155
+ "rationale": "<justification>" }\`, where the justification is a *very
156
+ brief*, single-sentence explanation of why the skill and its arguments
157
+ match the intent, and nothing else.
158
+ `
159
+
160
+ /* determine the lines of the detail view of a proposal out of the manual page
161
+ of its skill: the command and rationale of the proposal (inside a box), and
162
+ the name, title, and description of its skill */
163
+ const details = (proposal: Proposal, manual: string, columns: number): Char[][] => {
164
+ const title = section(manual, "NAME").trim().replace(/^`[^`]*`\s+-\s+/, "")
165
+ const boxed = wrap(uniform(`❯ ${proposal.command}`, true, CODE), columns - 4, 2)
166
+ if (proposal.rationale !== "")
167
+ boxed.push(...wrap(uniform(`▷ Rationale: ${proposal.rationale}`, false, BOXED), columns - 4, 2))
168
+ const lines = frame(boxed, columns, BOXED)
169
+ lines.push([], ...wrap(uniform(title !== "" ? `${proposal.skill}: ${title}` : proposal.skill,
170
+ true, undefined), columns, 0))
171
+ const description = section(manual, "DESCRIPTION")
172
+ if (description.trim() !== "")
173
+ lines.push([], ...flow(description, columns))
174
+ return lines
175
+ }
176
+
177
+ /* the intent help: its state and its operations */
178
+ export const modHelpFind = () => {
179
+ /* the help of all skills (loaded on first use) and the currently shown intent */
180
+ let corpus: Map<string, Help> | undefined
181
+ let intent: Intent | undefined
182
+
183
+ /* determine whether the intent band is shown */
184
+ const active = () => intent !== undefined
185
+
186
+ /* close the intent band */
187
+ const reset = () => {
188
+ intent = undefined
189
+ }
190
+
191
+ /* close the intent band (as on a failure) and determine the prompt to
192
+ restore (the prompt as typed), if the band was shown */
193
+ const cancel = (): string | undefined => {
194
+ const typed = intent?.typed
195
+ reset()
196
+ return typed
197
+ }
198
+
199
+ /* while the intent band is shown, every edit is a key of the band (with the
200
+ prompt held): LEFT/RIGHT select a proposal, UP/DOWN scroll its details by
201
+ a line and "b"/"f"/SPACE by a page (as the engine consumes PAGEUP/PAGEDOWN),
202
+ RETURN (arriving as a typed newline in the held prompt) operates the band
203
+ as at the prompt, "//" (two "/" in a row) closes the band with the prompt
204
+ as typed suffixed with the trigger of the manpage browser (to switch over
205
+ to it), and "!!" (two "!" in a row), a deletion (like BACKSPACE), or any
206
+ other key closes the band and restores the prompt as typed without the
207
+ trigger (onto whose end the edit of a typed text is still to be applied) */
208
+ const edit = (e: Frozen<PromptEditInput>): PromptBox | string => {
209
+ const current = intent!
210
+ const { draft, typed, state, proposals } = current
211
+ const key = decode(e)
212
+ const pending = current.pending
213
+ current.pending = /^[!/]$/.test(key.text) && key.text !== pending ? key.text : ""
214
+ if (current.pending !== "")
215
+ return held(draft)
216
+ if (key.text === "//" || (key.text === "/" && pending === "/")) {
217
+ reset()
218
+ const text = `${typed}//`
219
+ return { text, cursor: text.length }
220
+ }
221
+ if (key.name === "return") {
222
+ const text = submit()
223
+ return intent !== undefined ? held(draft) : { text, cursor: text.length }
224
+ }
225
+ const roll = key.name === "up" ? -1 : key.name === "down" ? 1 :
226
+ key.text === "b" ? -current.page : key.text === "f" || key.text === " " ? current.page : 0
227
+ const step = roll !== 0 ? 0 : key.step
228
+ if (state === "done" && (roll !== 0 || step !== 0)) {
229
+ if (step !== 0) {
230
+ current.select = Math.max(0, Math.min(proposals.length - 1, current.select + step))
231
+ current.scroll = 0
232
+ }
233
+ else
234
+ current.scroll = Math.max(0, current.scroll + roll)
235
+ return held(draft)
236
+ }
237
+ reset()
238
+ if (key.text === "" || key.text === "!!" || (key.text === "!" && pending === "!"))
239
+ return { text: typed, cursor: typed.length }
240
+ return typed
241
+ }
242
+
243
+ /* stash the prompt as the intent, hold the prompt (so the keys of the
244
+ band arrive as edits), and match the intent in the background */
245
+ const open = (box: PromptBox, engine: Pick<Engine, "invalidate" | "read" | "fork" | "complete" | "sleep">): PromptBox | undefined => {
246
+ const draft = box.text.replace(INTENT, "")
247
+ if (!INTENT.test(box.text) || draft.trim() === "")
248
+ return undefined
249
+ const current: Intent = { draft, typed: box.text.slice(0, -2), state: "busy",
250
+ message: "matching intent...", spin: 0, proposals: [],
251
+ select: 0, top: 0, scroll: 0, page: 1, split: 0, pending: "" }
252
+ intent = current
253
+
254
+ /* update the intent, unless it was closed in the meantime */
255
+ const settle = (state: Intent["state"], message: string, proposals: Proposal[] = []) => {
256
+ if (intent !== current)
257
+ return
258
+ Object.assign(current, { state, message, proposals })
259
+ engine.invalidate()
260
+ }
261
+
262
+ /* ask the model for a stage over the transcript of the session (sharing its
263
+ prompt cache), or, as the fork yields no text on a cold snapshot or an API
264
+ error, without any session context (telling so in the status message) */
265
+ const ask = async (prompt: string, stage: string): Promise<string | undefined> => {
266
+ settle("busy", `${stage}...`)
267
+ const forked = reply(await engine.fork(prompt))
268
+ if (forked !== undefined)
269
+ return forked
270
+ if (intent !== current)
271
+ return undefined
272
+ settle("busy", `${stage} (without session context)...`)
273
+ return reply(await engine.complete({ model: FALLBACK, prompt, maxTokens: 4096 }))
274
+ }
275
+
276
+ /* match the intent in two stages: select the fitting skills out of
277
+ the catalog, then generate the commands out of their manual pages */
278
+ const match = async () => {
279
+ const skills = corpus ??= helps(await engine.read(CORPUS))
280
+ const catalog = [ ...skills ].map(([ name, help ]) =>
281
+ `<skill name="${name}">\n${help.catalog}\n</skill>`).join("\n")
282
+ const names = (array(await ask(selection(draft, catalog), "matching intent")) ?? [])
283
+ .filter((name, i, all): name is string =>
284
+ typeof name === "string" && skills.has(name) && all.indexOf(name) === i)
285
+ .slice(0, PROPOSALS)
286
+ if (intent !== current)
287
+ return
288
+ if (names.length === 0) {
289
+ settle("fail", "no confident match for the intent -- please refine or clarify it")
290
+ return
291
+ }
292
+ const manuals = names.map((name) =>
293
+ `<skill name="${name}">\n${skills.get(name)!.manual}\n</skill>`).join("\n")
294
+ const proposals = (array(await ask(generation(draft, manuals), `generating commands for ${names.join(", ")}`)) ?? [])
295
+ .flatMap((item): Proposal[] => {
296
+ const { command, rationale } = (item ?? {}) as { command?: unknown, rationale?: unknown }
297
+ const text = typeof command === "string" ? command.trim() : ""
298
+ const skill = SKILL.exec(text)?.[1]
299
+ if (skill === undefined || !skills.has(skill))
300
+ return []
301
+ return [ {
302
+ skill,
303
+ command: text.replace(SKILL, "/ase:$1").replace(/\s+/g, " "),
304
+ rationale: typeof rationale === "string" ? rationale.trim().replace(/\s+/g, " ") : ""
305
+ } ]
306
+ })
307
+ .slice(0, PROPOSALS)
308
+
309
+ /* without any generated command, still propose the bare commands of the selected skills */
310
+ if (proposals.length === 0)
311
+ settle("done", "pick a command (without generated arguments)",
312
+ names.map((skill) => ({ skill, command: `/ase:${skill}`, rationale: "" })))
313
+ else
314
+ settle("done", "pick a command", proposals)
315
+ }
316
+ match().catch((err: unknown) => {
317
+ settle("fail", `matching failed: ${err instanceof Error ? err.message : String(err)}`)
318
+ })
319
+
320
+ /* animate the spinner while matching, unless the intent was closed in the meantime */
321
+ const animate = async () => {
322
+ while (intent === current && current.state === "busy") {
323
+ await engine.sleep(SPIN)
324
+ current.spin = (current.spin + 1) % SPINNER.length
325
+ engine.invalidate()
326
+ }
327
+ }
328
+ animate().catch(() => {
329
+ /* ignore a stopped animation, as the status message stays readable */
330
+ })
331
+ return held(draft)
332
+ }
333
+
334
+ /* operate the intent band by the wheel: over the master view step the
335
+ selection, over the detail view scroll it, resp. scroll it by a page
336
+ with the scroll keys (PAGEUP/PAGEDOWN) while the band is focused
337
+ (and tell whether the band took the scrolling) */
338
+ const scroll = (e: BandScroll): boolean => {
339
+ if (intent === undefined || intent.state !== "done")
340
+ return false
341
+ const current = intent
342
+ wheel(e, current, (by) => {
343
+ const select = Math.max(0, Math.min(current.proposals.length - 1, current.select + by))
344
+ if (select !== current.select) {
345
+ current.select = select
346
+ current.scroll = 0
347
+ }
348
+ })
349
+ return true
350
+ }
351
+
352
+ /* operate the intent band by RETURN at the prompt: pick the selected
353
+ proposal (closing the band), if any, resp. close a failed band, and
354
+ determine the prompt to refill (the held prompt while the band stays
355
+ shown, but without its space after the cursor, as the refill places
356
+ the cursor at its end) */
357
+ const submit = (): string => {
358
+ const current = intent!
359
+ const proposal = current.state === "done" ? current.proposals[current.select] : undefined
360
+ if (proposal !== undefined || current.state === "fail")
361
+ reset()
362
+ return proposal !== undefined ? `${proposal.command} ` :
363
+ intent !== undefined ? held(current.draft).text.trimEnd() : `${current.draft} `
364
+ }
365
+
366
+ /* draw the intent into the band above the prompt (terminal only): a master view
367
+ of the proposed skills and a detail view of the selected one side by side */
368
+ const render = (e: BandRender, ui: Elements["terminal"]): RenderElement | undefined => {
369
+ /* yield to a survey and draw nothing without an intent */
370
+ if (intent === undefined || e.props.hasSurvey)
371
+ return undefined
372
+ const current = intent
373
+ const { proposals } = current
374
+
375
+ /* determine the geometry of the band (with the master view in a fixed ratio)
376
+ and the lines of the detail view: the command and rationale of the
377
+ selected proposal (inside a box), and the name, title, and description
378
+ of its skill */
379
+ const { Text } = ui
380
+ const geometry = layout(e, MASTER)
381
+ const { span, body, master, detail } = geometry
382
+ const clip = (text: string, n: number) =>
383
+ text.length > n ? `${text.slice(0, n - 1)}…` : text
384
+ const proposal = current.state === "done" ? proposals[current.select] : undefined
385
+ const lines = proposal !== undefined ?
386
+ details(proposal, corpus?.get(proposal.skill)?.manual ?? "", detail) : undefined
387
+
388
+ /* keep the selected proposal in the master view */
389
+ current.top = Math.max(0, current.select - body + 1, Math.min(current.top, current.select))
390
+
391
+ /* determine the rows of the status message (as long as no proposals exist):
392
+ a box of half the columns, centered vertically and horizontally */
393
+ const hint: string[] = []
394
+ if (proposal === undefined) {
395
+ const size = Math.max(12, Math.floor(span / 2))
396
+ const text = current.state === "busy" ? `${SPINNER[current.spin]} ${current.message}` : current.message
397
+ const boxed = frame(wrap(uniform(text, false, undefined), size - 4, 0)
398
+ .slice(0, Math.max(1, body - 2)), size, undefined)
399
+ const pad = " ".repeat(Math.max(0, Math.floor((span - size) / 2)))
400
+ hint.push(...Array.from({ length: Math.max(0, Math.floor((body - boxed.length) / 2)) }, () => " "),
401
+ ...boxed.map((line) => pad + line.map((char) => char.ch).join("")))
402
+ }
403
+
404
+ /* draw the status message resp. both views (the master view with the
405
+ selected proposal in red, and the detail view), and the key hints
406
+ (the intent itself stays visible in the prompt) */
407
+ return panel(ui, geometry, current, {
408
+ title: TITLE,
409
+ hints: current.state === "done" ? "⌫/!!: cancel+close · ⏎: apply+close · ←/→: select · b/f/SPACE: scroll" +
410
+ (geometry.overflow ? " · wheel: select/scroll" : "") + " · //: Browse Skill Manpages" :
411
+ "⌫/!!/any key: cancel+close · //: Browse Skill Manpages",
412
+ rows: (i) => {
413
+ if (proposal === undefined)
414
+ return Text({ dimColor: true, wrap: "truncate-end", children: hint[i] ?? " " })
415
+ const k = current.top + i
416
+ const p = proposals[k]
417
+ return p !== undefined ? Text({ color: k === current.select ? PICKED : undefined,
418
+ children: clip(`${k === current.select ? "▶" : " "} ${p.skill}`, master) }) : undefined
419
+ },
420
+ lines
421
+ })
422
+ }
423
+
424
+ return { active, reset, cancel, edit, open, scroll, submit, render }
425
+ }
426
+
@@ -0,0 +1,134 @@
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
+ Hint band, showing for a skill command (bare or with its options and
9
+ arguments already typed) the entire argument hint of the skill, wrapped
10
+ over multiple lines, in the band above the prompt (instead of the one-line
11
+ and hence usually truncated argument hint of the engine), together with
12
+ the hint on the manual band and the synopsis form, while the prompt starts
13
+ with the skill command followed by a blank (so the typeahead of the engine
14
+ is closed), until the prompt is submitted. */
15
+
16
+ import type { Elements, RenderElement } from "claude-code"
17
+ import { type Engine, type Char, type BandRender, type View, COMMAND,
18
+ wrap, uniform, layout, panel } from "./ase-mods-common.ts"
19
+
20
+ /* the skill command (followed by a blank) at the start of the prompt, the
21
+ argument hint in the frontmatter of a skill, its groups (the bracketed
22
+ resp. parenthesized options and arguments, with one level of nesting, and
23
+ the other words), the non-breaking space keeping a group on a single line,
24
+ the colors of its syntax characters and of its references, the indentation
25
+ of the continuation lines, and the title placed into the top edge of the band */
26
+ const SKILL = /^\/(?:ase:)?(ase-[a-z0-9]+-[a-z0-9]+)(?=\s)/
27
+ const HINT = /^argument-hint:\s*"(.*)"\s*$/m
28
+ const GROUP = /\[(?:[^[\]]|\[[^\]]*\])*\]|\((?:[^()]|\([^)]*\))*\)|\S+/g
29
+ const NBSP = String.fromCodePoint(0xA0)
30
+ const SYNTAX = "gray"
31
+ const REFER = "red"
32
+ const INDENT = 4
33
+ const TITLE = "[ ⧉ ASE: Show Skill Synopsis ]"
34
+
35
+ /* a hint band: the scroll state of its frame, the skill command as typed,
36
+ the argument hint of its skill, and the lines with the columns they were
37
+ wrapped to */
38
+ type Hint = View & {
39
+ command: string,
40
+ hint: string,
41
+ lines: Char[][],
42
+ columns: number
43
+ }
44
+
45
+ /* convert an argument hint into styled characters: the syntax characters
46
+ of its optional groups, plain groups, and alternatives in grey and its "<...>" references in red */
47
+ const tint = (text: string): Char[] => {
48
+ const chars: Char[] = []
49
+ let reference = false
50
+ for (const ch of text) {
51
+ if (ch === "<")
52
+ reference = true
53
+ chars.push({ ch, bold: false, color: reference ? REFER : "[()|]".includes(ch) ? SYNTAX : undefined })
54
+ if (ch === ">")
55
+ reference = false
56
+ }
57
+ return chars
58
+ }
59
+
60
+ /* the hint band: its state and its operations */
61
+ export const modHelpHint = () => {
62
+ /* the argument hints of the skills (loaded on first use, undefined
63
+ for an unknown skill), the currently shown band, and the generation
64
+ of the latest tracking resp. closing (discarding any older tracking) */
65
+ const hints = new Map<string, string | undefined>()
66
+ let shown: Hint | undefined
67
+ let generation = 0
68
+
69
+ /* close the hint band */
70
+ const reset = () => {
71
+ generation++
72
+ shown = undefined
73
+ }
74
+
75
+ /* determine the argument hint of a skill (loaded on first use) */
76
+ const synopsis = async (skill: string, engine: Pick<Engine, "read">): Promise<string | undefined> => {
77
+ if (!hints.has(skill))
78
+ hints.set(skill, await engine.read(`skills/${skill}/SKILL.md`)
79
+ .then((text) => HINT.exec(text)?.[1]).catch(() => undefined))
80
+ return hints.get(skill)
81
+ }
82
+
83
+ /* determine the lines of the hint band, wrapped to the given columns: the
84
+ skill command after the prompt marker and in the prompt color, followed by the tinted groups of the
85
+ argument hint, each kept on a single line (if it fits) */
86
+ const wrapped = (current: Hint, columns: number): Char[][] => {
87
+ if (current.columns !== columns) {
88
+ const groups = (current.hint.match(GROUP) ?? []).map((group) =>
89
+ group.length <= columns - INDENT ? group.replaceAll(" ", NBSP) : group)
90
+ current.lines = wrap([ ...uniform("❯ ", false, undefined), ...uniform(current.command, false, COMMAND),
91
+ ...tint(` ${groups.join(" ")}`) ], columns, INDENT)
92
+ current.columns = columns
93
+ }
94
+ return current.lines
95
+ }
96
+
97
+ /* track the prompt and show the hint band while the prompt starts with the
98
+ command of a skill with an argument hint and a blank, else close it
99
+ (and tell whether the band changed) */
100
+ const track = async (text: string, engine: Pick<Engine, "read">): Promise<boolean> => {
101
+ const prior = shown
102
+ const current = ++generation
103
+ const command = SKILL.exec(text)
104
+ if (command === null)
105
+ shown = undefined
106
+ else if (shown?.command !== command[0]) {
107
+ const hint = await synopsis(command[1]!, engine)
108
+ if (current !== generation)
109
+ return false
110
+ shown = hint !== undefined ? { command: command[0], hint, lines: [], columns: 0,
111
+ top: 0, scroll: 0, page: 1, split: 0 } : undefined
112
+ }
113
+ return shown !== prior
114
+ }
115
+
116
+ /* draw the argument hint into the band above the prompt (terminal only),
117
+ with the band exactly as tall as the hint (as it always fits, without
118
+ a scroll position), yielding to a survey */
119
+ const render = (e: BandRender, ui: Elements["terminal"]): RenderElement | undefined => {
120
+ if (shown === undefined || e.props.hasSurvey)
121
+ return undefined
122
+ const geometry = layout(e, 0)
123
+ const lines = wrapped(shown, geometry.span - 3)
124
+ return panel(ui, { ...geometry, body: lines.length, overflow: false }, shown, {
125
+ title: TITLE,
126
+ hints: "//: Show Skill Manpage · !!: Compose Skill Command · (keep typing the command)",
127
+ lines,
128
+ fit: true
129
+ })
130
+ }
131
+
132
+ return { reset, synopsis, track, render }
133
+ }
134
+