@rse/ase 0.9.46 → 0.9.48

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.
@@ -10,7 +10,7 @@ import { isScalar } from "yaml";
10
10
  import { z } from "zod";
11
11
  import { Config, configSchema } from "./ase-config.js";
12
12
  import { Task } from "./ase-task.js";
13
- import { writeStdout } from "./ase-stdout.js";
13
+ import { writeStdout } from "./ase-stdio.js";
14
14
  /* the recognized artifact kinds, in descending precedence order;
15
15
  "othr" is the implicit catch-all and is always resolved last */
16
16
  export const artifactKinds = ["spec", "arch", "code", "docs", "infr", "othr"];
@@ -141,6 +141,8 @@ export class Artifact {
141
141
  const basedir = Artifact.configString(cfg, `project.artifact.${kind}.basedir`)
142
142
  .replace(/\\/g, "/").replace(/^\/+|\/+$/g, "")
143
143
  .replace(/^\.$/, "");
144
+ if (basedir.split("/").includes(".."))
145
+ throw new Error(`artifact: configured "basedir" "${basedir}" must not escape the project root`);
144
146
  const files = Artifact.configString(cfg, `project.artifact.${kind}.files`);
145
147
  return { basedir, files };
146
148
  }
package/dst/ase-compat.js CHANGED
@@ -3,6 +3,7 @@
3
3
  ** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
4
  ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
5
  */
6
+ import { writeStdout } from "./ase-stdio.js";
6
7
  /* the canonical expected values for every ase-meta-compat probe,
7
8
  keyed by "<category>/<probe-name>" as used in the skill */
8
9
  const EXPECTED = {
@@ -44,8 +45,8 @@ export default class CompatCommand {
44
45
  program
45
46
  .command("compat")
46
47
  .description("Output expected probe values for the ase-meta-compat self-test skill")
47
- .action(() => {
48
- process.stdout.write(formatExpected());
48
+ .action(async () => {
49
+ await writeStdout(formatExpected());
49
50
  });
50
51
  }
51
52
  }
package/dst/ase-config.js CHANGED
@@ -14,6 +14,8 @@ import Table from "cli-table3";
14
14
  import writeFileAtomic from "write-file-atomic";
15
15
  import lockfile from "proper-lockfile";
16
16
  import { z } from "zod";
17
+ import { LRUCache } from "lru-cache";
18
+ import { writeStdout } from "./ase-stdio.js";
17
19
  /* classification taxonomy */
18
20
  export const projectClassification = {
19
21
  boxing: ["white", "grey", "black"]
@@ -92,20 +94,33 @@ const parseScopeTerm = (value) => {
92
94
  else if (value === "project")
93
95
  return { kind: "project" };
94
96
  const m = /^(session|task):([A-Za-z0-9._-]+)$/.exec(value);
95
- if (m !== null)
97
+ if (m !== null) {
98
+ if (m[2] === "." || m[2] === "..")
99
+ throw new Error(`invalid --scope term "${value}": id must not be "." or ".."`);
96
100
  return { kind: m[1], id: m[2] };
101
+ }
97
102
  throw new Error(`invalid --scope term "${value}" ` +
98
103
  "(expected: \"user\", \"project\", \"task:<id>\", or \"session:<id>\")");
99
104
  };
100
- /* determine the Git top-level directory, if inside a Git repository */
105
+ /* determine the Git top-level directory, if inside a Git repository;
106
+ cached per working directory ("" ≡ not inside a Git working tree), as
107
+ each determination spawns a Git subprocess and the Git context can
108
+ change over the lifetime of the long-running ASE service */
109
+ const gitToplevelCache = new LRUCache({ max: 4, ttl: 2 * 1000 });
101
110
  const gitToplevel = () => {
111
+ const cwd = process.cwd();
112
+ const cached = gitToplevelCache.get(cwd);
113
+ if (cached !== undefined)
114
+ return cached === "" ? null : cached;
115
+ let top = "";
102
116
  try {
103
- const result = execaSync("git", ["rev-parse", "--show-toplevel"], { stderr: "ignore" });
104
- return result.stdout.trim() || null;
117
+ top = execaSync("git", ["rev-parse", "--show-toplevel"], { stderr: "ignore" }).stdout.trim();
105
118
  }
106
119
  catch {
107
- return null;
120
+ /* not inside a Git working tree */
108
121
  }
122
+ gitToplevelCache.set(cwd, top);
123
+ return top === "" ? null : top;
109
124
  };
110
125
  /* detect whether a project context exists, i.e. either we are inside
111
126
  a Git working tree or a ".ase" directory is present at or above cwd */
@@ -153,6 +168,11 @@ export const parseScope = (value) => {
153
168
  terms.unshift({ kind: "default" });
154
169
  return terms;
155
170
  };
171
+ /* schema for a single artifact kind's "basedir"/"files" specification */
172
+ const artifactSchema = v.optional(v.strictObject({
173
+ basedir: v.optional(v.string()),
174
+ files: v.optional(v.string())
175
+ }));
156
176
  /* schema for ".ase/config.yaml" */
157
177
  export const configSchema = v.nullish(v.strictObject({
158
178
  project: v.optional(v.strictObject({
@@ -160,12 +180,12 @@ export const configSchema = v.nullish(v.strictObject({
160
180
  name: v.optional(v.pipe(v.string(), v.minLength(1))),
161
181
  boxing: v.optional(v.picklist(projectClassification.boxing)),
162
182
  artifact: v.optional(v.strictObject({
163
- spec: v.optional(v.strictObject({ basedir: v.optional(v.string()), files: v.optional(v.string()) })),
164
- arch: v.optional(v.strictObject({ basedir: v.optional(v.string()), files: v.optional(v.string()) })),
165
- code: v.optional(v.strictObject({ basedir: v.optional(v.string()), files: v.optional(v.string()) })),
166
- docs: v.optional(v.strictObject({ basedir: v.optional(v.string()), files: v.optional(v.string()) })),
167
- infr: v.optional(v.strictObject({ basedir: v.optional(v.string()), files: v.optional(v.string()) })),
168
- task: v.optional(v.strictObject({ basedir: v.optional(v.string()), files: v.optional(v.string()) }))
183
+ spec: artifactSchema,
184
+ arch: artifactSchema,
185
+ code: artifactSchema,
186
+ docs: artifactSchema,
187
+ infr: artifactSchema,
188
+ task: artifactSchema
169
189
  }))
170
190
  })),
171
191
  agent: v.optional(v.strictObject({
@@ -188,7 +208,7 @@ export class Config {
188
208
  docs;
189
209
  target;
190
210
  /* creation */
191
- constructor(name, schema, log, scope = [{ kind: "project" }]) {
211
+ constructor(name, schema, log, scope = [{ kind: "user" }, { kind: "project" }]) {
192
212
  if (scope.length === 0)
193
213
  throw new Error("invalid scope: chain must not be empty");
194
214
  this.name = name;
@@ -560,7 +580,7 @@ export default class ConfigCommand {
560
580
  configCmd
561
581
  .command("list")
562
582
  .description("list all configured values as flat dotted keys")
563
- .action((_opts, cmd) => {
583
+ .action(async (_opts, cmd) => {
564
584
  const scope = parseScope(cmd.optsWithGlobals().scope);
565
585
  const cfg = new Config("config", configSchema, this.log, scope);
566
586
  cfg.read();
@@ -573,7 +593,7 @@ export default class ConfigCommand {
573
593
  const val = isScalar(e.value) ? e.value.value : e.value;
574
594
  table.push([e.key, String(val), Config.scopeLabel(e.scope)]);
575
595
  }
576
- process.stdout.write(`${table.toString()}\n`);
596
+ await writeStdout(`${table.toString()}\n`);
577
597
  });
578
598
  /* register CLI sub-command "ase config edit" */
579
599
  configCmd
@@ -612,7 +632,7 @@ export default class ConfigCommand {
612
632
  .command("get")
613
633
  .description("print the value at a dotted configuration key")
614
634
  .argument("<key>", "configuration key (dotted path)")
615
- .action((key, _opts, cmd) => {
635
+ .action(async (key, _opts, cmd) => {
616
636
  const scope = parseScope(cmd.optsWithGlobals().scope);
617
637
  const cfg = new Config("config", configSchema, this.log, scope);
618
638
  cfg.read();
@@ -621,7 +641,7 @@ export default class ConfigCommand {
621
641
  throw new Error(`key "${key}" is not set`);
622
642
  if (isMap(val))
623
643
  throw new Error(`key "${key}" is not a leaf key`);
624
- process.stdout.write(`${isScalar(val) ? val.value : val}\n`);
644
+ await writeStdout(`${isScalar(val) ? val.value : val}\n`);
625
645
  });
626
646
  /* register CLI sub-command "ase config set" */
627
647
  configCmd
@@ -7,7 +7,7 @@ import fs from "node:fs";
7
7
  import { InvalidArgumentError } from "commander";
8
8
  import { renderMermaidASCII, renderMermaidSVG } from "beautiful-mermaid";
9
9
  import { z } from "zod";
10
- import getStdin from "get-stdin";
10
+ import { readStdin } from "./ase-stdio.js";
11
11
  /* custom argument parser for Commander: non-negative integer */
12
12
  const parseInteger = (name) => (value) => {
13
13
  const n = Number.parseInt(value, 10);
@@ -150,6 +150,8 @@ export class Diagram {
150
150
  clipping below are meaningful only for ASCII art) */
151
151
  if (opts.format === "svg")
152
152
  return renderMermaidSVG(src);
153
+ /* determine theme colors (gray shades once colors are enabled) */
154
+ const colored = opts.colorMode !== "none";
153
155
  /* create diagram rendering */
154
156
  let out = renderMermaidASCII(src, {
155
157
  useAscii: opts.ascii,
@@ -157,20 +159,13 @@ export class Diagram {
157
159
  paddingY: opts.nodeMarginY,
158
160
  boxBorderPadding: opts.nodePadding,
159
161
  colorMode: opts.colorMode,
160
- theme: opts.colorMode !== "none" ? {
162
+ theme: {
161
163
  fg: "#000000",
162
- border: "#a0a0a0",
163
- junction: "#a0a0a0",
164
- arrow: "#404040",
165
- line: "#707070",
166
- corner: "#707070"
167
- } : {
168
- fg: "#000000",
169
- border: "#000000",
170
- junction: "#000000",
171
- arrow: "#000000",
172
- line: "#000000",
173
- corner: "#000000"
164
+ border: colored ? "#a0a0a0" : "#000000",
165
+ junction: colored ? "#a0a0a0" : "#000000",
166
+ arrow: colored ? "#404040" : "#000000",
167
+ line: colored ? "#707070" : "#000000",
168
+ corner: colored ? "#707070" : "#000000"
174
169
  }
175
170
  });
176
171
  /* optionally clip diagram rendering */
@@ -248,7 +243,7 @@ export default class DiagramCommand {
248
243
  }
249
244
  }
250
245
  else
251
- src = await getStdin();
246
+ src = await readStdin();
252
247
  if (src.trim() === "") {
253
248
  this.log.write("error", "diagram: empty Mermaid diagram specification");
254
249
  process.exit(1);
package/dst/ase-getopt.js CHANGED
@@ -6,6 +6,70 @@
6
6
  import { z } from "zod";
7
7
  import { Command, Option } from "commander";
8
8
  import { parse as shParse, quote as shQuote } from "shell-quote";
9
+ /* tokenize a raw input string into [start,end) token ranges, preserving
10
+ the quoting so the original text can later be sliced verbatim */
11
+ const tokenizeRanges = (input) => {
12
+ const ranges = [];
13
+ let i = 0;
14
+ while (i < input.length) {
15
+ while (i < input.length && /\s/.test(input[i]))
16
+ i++;
17
+ if (i >= input.length)
18
+ break;
19
+ const start = i;
20
+ while (i < input.length && !/\s/.test(input[i])) {
21
+ const ch = input[i];
22
+ if (ch === "\"" || ch === "'") {
23
+ const quote = ch;
24
+ i++;
25
+ while (i < input.length && input[i] !== quote) {
26
+ if (input[i] === "\\" && i + 1 < input.length)
27
+ i++;
28
+ i++;
29
+ }
30
+ if (i < input.length)
31
+ i++;
32
+ }
33
+ else
34
+ i++;
35
+ }
36
+ ranges.push({ start, end: i });
37
+ }
38
+ return ranges;
39
+ };
40
+ /* strip surrounding quotes/escapes from a raw token range so it can be
41
+ compared against an option flag spelling */
42
+ const unquote = (s) => {
43
+ let out = "";
44
+ let j = 0;
45
+ while (j < s.length) {
46
+ const ch = s[j];
47
+ if (ch === "\"" || ch === "'") {
48
+ const quote = ch;
49
+ j++;
50
+ while (j < s.length && s[j] !== quote) {
51
+ if (s[j] === "\\" && j + 1 < s.length) {
52
+ out += s[j + 1];
53
+ j += 2;
54
+ }
55
+ else {
56
+ out += s[j];
57
+ j++;
58
+ }
59
+ }
60
+ j++;
61
+ }
62
+ else if (ch === "\\" && j + 1 < s.length) {
63
+ out += s[j + 1];
64
+ j += 2;
65
+ }
66
+ else {
67
+ out += ch;
68
+ j++;
69
+ }
70
+ }
71
+ return out;
72
+ };
9
73
  /* MCP registration entry point for the option-parser tool */
10
74
  export class GetoptMCP {
11
75
  register(mcp) {
@@ -117,65 +181,7 @@ export class GetoptMCP {
117
181
  let argsVerbatim = "";
118
182
  if (argsRaw !== null) {
119
183
  /* tokenize raw input into [start,end) ranges, preserving quotes */
120
- const ranges = [];
121
- let i = 0;
122
- while (i < argsRaw.length) {
123
- while (i < argsRaw.length && /\s/.test(argsRaw[i]))
124
- i++;
125
- if (i >= argsRaw.length)
126
- break;
127
- const start = i;
128
- while (i < argsRaw.length && !/\s/.test(argsRaw[i])) {
129
- const ch = argsRaw[i];
130
- if (ch === "\"" || ch === "'") {
131
- const quote = ch;
132
- i++;
133
- while (i < argsRaw.length && argsRaw[i] !== quote) {
134
- if (argsRaw[i] === "\\" && i + 1 < argsRaw.length)
135
- i++;
136
- i++;
137
- }
138
- if (i < argsRaw.length)
139
- i++;
140
- }
141
- else
142
- i++;
143
- }
144
- ranges.push({ start, end: i });
145
- }
146
- /* helper function: strip surrounding quotes/escapes from a raw
147
- range so it can be compared against an option flag spelling */
148
- const unquote = (s) => {
149
- let out = "";
150
- let j = 0;
151
- while (j < s.length) {
152
- const ch = s[j];
153
- if (ch === "\"" || ch === "'") {
154
- const quote = ch;
155
- j++;
156
- while (j < s.length && s[j] !== quote) {
157
- if (s[j] === "\\" && j + 1 < s.length) {
158
- out += s[j + 1];
159
- j += 2;
160
- }
161
- else {
162
- out += s[j];
163
- j++;
164
- }
165
- }
166
- j++;
167
- }
168
- else if (ch === "\\" && j + 1 < s.length) {
169
- out += s[j + 1];
170
- j += 2;
171
- }
172
- else {
173
- out += ch;
174
- j++;
175
- }
176
- }
177
- return out;
178
- };
184
+ const ranges = tokenizeRanges(argsRaw);
179
185
  /* walk the raw ranges, consuming leading option tokens (and any
180
186
  separate value tokens they take) until the first positional
181
187
  is reached, then slice the original input from there -- this
@@ -202,7 +208,7 @@ export class GetoptMCP {
202
208
  argsVerbatim = argsRaw.slice(ranges[idx].start);
203
209
  }
204
210
  else
205
- argsVerbatim = cmd.args.join(" ");
211
+ argsVerbatim = shQuote(cmd.args);
206
212
  /* build markdown info rendering of parsed options */
207
213
  const opts = cmd.opts();
208
214
  const info = Object.entries(opts)
package/dst/ase-hook.js CHANGED
@@ -8,11 +8,10 @@ import fs from "node:fs";
8
8
  import os from "node:os";
9
9
  import { execaSync } from "execa";
10
10
  import { quote } from "shell-quote";
11
- import getStdin from "get-stdin";
12
11
  import * as v from "valibot";
13
12
  import Version from "./ase-version.js";
14
13
  import { Config, configSchema, parseScope } from "./ase-config.js";
15
- import { writeStdout } from "./ase-stdout.js";
14
+ import { readStdin, writeStdout } from "./ase-stdio.js";
16
15
  const addonMcpServers = [
17
16
  "chat-alibaba-qwen",
18
17
  "chat-deepseek",
@@ -69,6 +68,13 @@ const toolSpecs = {
69
68
  approvalEvent: "PermissionRequest"
70
69
  }
71
70
  };
71
+ /* schema (and derived type) of the tool invocation input fields
72
+ inspected by the tool-approval decision logic */
73
+ const toolInputSchema = v.object({
74
+ command: v.optional(v.string()),
75
+ skill: v.optional(v.string()),
76
+ file_path: v.optional(v.string())
77
+ });
72
78
  /* CLI command "ase hook" */
73
79
  export default class HookCommand {
74
80
  log;
@@ -79,14 +85,9 @@ export default class HookCommand {
79
85
  isValidSessionId(id) {
80
86
  return /^[A-Za-z0-9._-]+$/.test(id);
81
87
  }
82
- /* read the entire stdin payload asynchronously */
83
- readStdin() {
84
- /* best-effort: treat an unreadable/closed stdin as empty */
85
- return getStdin().catch(() => "");
86
- }
87
88
  /* drain and discard the stdin event payload */
88
89
  async drainStdin() {
89
- await this.readStdin();
90
+ await readStdin().catch(() => "");
90
91
  }
91
92
  /* best-effort JSON parse with valibot schema validation: returns
92
93
  an empty object on blank input, malformed JSON, or schema
@@ -204,7 +205,7 @@ export default class HookCommand {
204
205
  const versionHint = versionHints.length > 0 ? "(" + versionHints.join(", ") + ")" : "";
205
206
  /* read session information (Anthropic Claude Code CLI uses snake_case fields,
206
207
  GitHub Copilot CLI uses camelCase fields) */
207
- const stdin = await this.readStdin();
208
+ const stdin = await readStdin().catch(() => "");
208
209
  const input = this.parseJSON(stdin, v.object({
209
210
  session_id: v.optional(v.string()),
210
211
  sessionId: v.optional(v.string()),
@@ -242,16 +243,15 @@ export default class HookCommand {
242
243
  const projectId = path.basename(projectDir);
243
244
  /* determine user id */
244
245
  const userId = process.env.USER ?? process.env.LOGNAME ?? "unknown";
245
- /* determine agent persona style */
246
- let persona = process.env.ASE_PERSONA_STYLE ?? "engineer";
247
- const val = cfg.get("agent.persona");
248
- if (typeof val === "string")
249
- persona = val;
250
- /* determine project boxing transparency */
251
- let boxing = process.env.ASE_PROJECT_BOXING ?? "white";
252
- const valBoxing = cfg.get("project.boxing");
253
- if (typeof valBoxing === "string")
254
- boxing = valBoxing;
246
+ /* helper function: determine a setting from the configuration,
247
+ falling back to an environment variable and a default */
248
+ const setting = (key, envVar, dflt) => {
249
+ const val = cfg.get(key);
250
+ return typeof val === "string" ? val : (process.env[envVar] ?? dflt);
251
+ };
252
+ /* determine agent persona style and project boxing transparency */
253
+ const persona = setting("agent.persona", "ASE_PERSONA_STYLE", "engineer");
254
+ const boxing = setting("project.boxing", "ASE_PROJECT_BOXING", "white");
255
255
  /* determine headless mode */
256
256
  const headless = (process.env.ASE_HEADLESS ?? "false") === "true" ? "true" : "false";
257
257
  /* provide ASE information to Anthropic Claude Code CLI shell commands
@@ -267,7 +267,13 @@ export default class HookCommand {
267
267
  `export ASE_SESSION_ID=${quote([sessionId])}\n` +
268
268
  `export ASE_HEADLESS=${quote([headless])}\n` +
269
269
  `export ASE_AGENT_TOOL=${quote([tool])}\n`;
270
- fs.appendFileSync(envFile, script, "utf8");
270
+ try {
271
+ fs.appendFileSync(envFile, script, "utf8");
272
+ }
273
+ catch (err) {
274
+ const message = err instanceof Error ? err.message : String(err);
275
+ this.log.write("warning", `hook: failed to write environment file: ${message}`);
276
+ }
271
277
  }
272
278
  /* prepend ASE information to constitution markdown */
273
279
  md =
@@ -365,7 +371,7 @@ export default class HookCommand {
365
371
  }
366
372
  /* read session id from stdin JSON payload */
367
373
  async readSessionIdFromStdin() {
368
- const stdin = await this.readStdin();
374
+ const stdin = await readStdin().catch(() => "");
369
375
  const input = this.parseJSON(stdin, v.object({
370
376
  session_id: v.optional(v.string()),
371
377
  sessionId: v.optional(v.string())
@@ -422,13 +428,12 @@ export default class HookCommand {
422
428
  let toolInput = {};
423
429
  const rawInput = input[spec.toolInputField];
424
430
  if (spec.toolInputIsString && typeof rawInput === "string")
425
- toolInput = this.parseJSON(rawInput, v.object({
426
- command: v.optional(v.string()),
427
- skill: v.optional(v.string()),
428
- file_path: v.optional(v.string())
429
- }));
430
- else if (!spec.toolInputIsString && typeof rawInput === "object" && rawInput !== null)
431
- toolInput = rawInput;
431
+ toolInput = this.parseJSON(rawInput, toolInputSchema);
432
+ else if (!spec.toolInputIsString && typeof rawInput === "object" && rawInput !== null) {
433
+ const result = v.safeParse(toolInputSchema, rawInput);
434
+ if (result.success)
435
+ toolInput = result.output;
436
+ }
432
437
  const command = toolInput.command ?? "";
433
438
  if (toolName === spec.bashToolName && /^ase(\s|$)/.test(command)
434
439
  && !/[;&|<>`\n]|\$\(/.test(command))
@@ -453,7 +458,7 @@ export default class HookCommand {
453
458
  loosely-typed input object shared by the tool-approval handlers */
454
459
  async readHookInput(tool) {
455
460
  const spec = toolSpecs[tool];
456
- const stdin = await this.readStdin();
461
+ const stdin = await readStdin().catch(() => "");
457
462
  const input = this.parseJSON(stdin, v.looseObject({
458
463
  session_id: v.optional(v.string()),
459
464
  sessionId: v.optional(v.string())
@@ -538,53 +543,22 @@ export default class HookCommand {
538
543
  hookCmd.outputHelp();
539
544
  process.exit(1);
540
545
  });
541
- /* register CLI sub-command "ase hook session-start" */
542
- hookCmd
543
- .command("session-start")
544
- .description("handle SessionStart hook event")
545
- .option("-t, --tool <tool>", "target tool (\"claude\", \"copilot\", or \"codex\")", toolDflt)
546
- .action(async (opts) => {
547
- process.exitCode = await this.doSessionStart(this.parseTool(opts.tool));
548
- });
549
- /* register CLI sub-command "ase hook session-end" */
550
- hookCmd
551
- .command("session-end")
552
- .description("handle SessionEnd hook event")
553
- .option("-t, --tool <tool>", "target tool (\"claude\", \"copilot\", or \"codex\")", toolDflt)
554
- .action(async (opts) => {
555
- process.exitCode = await this.doSessionEnd(this.parseTool(opts.tool));
556
- });
557
- /* register CLI sub-command "ase hook pre-tool-use" */
558
- hookCmd
559
- .command("pre-tool-use")
560
- .description("handle tool PreToolUse hook event")
561
- .option("-t, --tool <tool>", "target tool (\"claude\", \"copilot\", or \"codex\")", toolDflt)
562
- .action(async (opts) => {
563
- process.exitCode = await this.doPreToolUse(this.parseTool(opts.tool));
564
- });
565
- /* register CLI sub-command "ase hook permission-request" */
566
- hookCmd
567
- .command("permission-request")
568
- .description("handle tool PermissionRequest hook event (Codex CLI)")
569
- .option("-t, --tool <tool>", "target tool (\"claude\", \"copilot\", or \"codex\")", toolDflt)
570
- .action(async (opts) => {
571
- process.exitCode = await this.doPermissionRequest(this.parseTool(opts.tool));
572
- });
573
- /* register CLI sub-command "ase hook user-prompt-submit" */
574
- hookCmd
575
- .command("user-prompt-submit")
576
- .description("handle UserPromptSubmit hook event (mark agent as busy)")
577
- .option("-t, --tool <tool>", "target tool (\"claude\", \"copilot\", or \"codex\")", toolDflt)
578
- .action(async (opts) => {
579
- process.exitCode = await this.doUserPromptSubmit(this.parseTool(opts.tool));
580
- });
581
- /* register CLI sub-command "ase hook stop" */
582
- hookCmd
583
- .command("stop")
584
- .description("handle Stop hook event (mark agent as ready)")
585
- .option("-t, --tool <tool>", "target tool (\"claude\", \"copilot\", or \"codex\")", toolDflt)
586
- .action(async (opts) => {
587
- process.exitCode = await this.doStop(this.parseTool(opts.tool));
588
- });
546
+ /* register CLI sub-commands "ase hook <event>" */
547
+ const subCmds = [
548
+ { name: "session-start", desc: "handle SessionStart hook event", handler: (tool) => this.doSessionStart(tool) },
549
+ { name: "session-end", desc: "handle SessionEnd hook event", handler: (tool) => this.doSessionEnd(tool) },
550
+ { name: "pre-tool-use", desc: "handle tool PreToolUse hook event", handler: (tool) => this.doPreToolUse(tool) },
551
+ { name: "permission-request", desc: "handle tool PermissionRequest hook event", handler: (tool) => this.doPermissionRequest(tool) },
552
+ { name: "user-prompt-submit", desc: "handle UserPromptSubmit hook event", handler: (tool) => this.doUserPromptSubmit(tool) },
553
+ { name: "stop", desc: "handle Stop hook event", handler: (tool) => this.doStop(tool) }
554
+ ];
555
+ for (const { name, desc, handler } of subCmds)
556
+ hookCmd
557
+ .command(name)
558
+ .description(desc)
559
+ .option("-t, --tool <tool>", "target tool (\"claude\", \"copilot\", or \"codex\")", toolDflt)
560
+ .action(async (opts) => {
561
+ process.exitCode = await handler(this.parseTool(opts.tool));
562
+ });
589
563
  }
590
564
  }