@rse/ase 0.9.42 → 0.9.44

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 (73) hide show
  1. package/dst/ase-artifact.js +28 -24
  2. package/dst/ase-config.js +27 -36
  3. package/dst/ase-diagram.js +15 -35
  4. package/dst/ase-getopt.js +10 -15
  5. package/dst/ase-hook.js +29 -27
  6. package/dst/ase-kv.js +24 -29
  7. package/dst/ase-log.js +10 -3
  8. package/dst/ase-markdown.js +135 -102
  9. package/dst/ase-mcp.js +3 -10
  10. package/dst/ase-meta.js +6 -4
  11. package/dst/ase-persona.js +7 -11
  12. package/dst/ase-service.js +28 -28
  13. package/dst/ase-setup.js +15 -28
  14. package/dst/ase-skills.js +2 -2
  15. package/dst/ase-statusline.js +26 -34
  16. package/dst/ase-task.js +60 -63
  17. package/dst/ase-version.js +2 -1
  18. package/dst/ase.js +7 -11
  19. package/package.json +13 -12
  20. package/plugin/.claude-plugin/plugin.json +1 -1
  21. package/plugin/.codex-plugin/plugin.json +1 -1
  22. package/plugin/.github/plugin/plugin.json +2 -2
  23. package/plugin/agents/ase-code-lint.md +4 -4
  24. package/plugin/agents/ase-docs-proofread.md +1 -1
  25. package/plugin/agents/ase-meta-chat.md +6 -6
  26. package/plugin/agents/ase-meta-diagram.md +1 -1
  27. package/plugin/agents/ase-meta-search.md +1 -1
  28. package/plugin/etc/eslint.mjs +0 -2
  29. package/plugin/meta/ase-constitution.md +1 -1
  30. package/plugin/meta/ase-control.md +1 -1
  31. package/plugin/meta/ase-dialog.md +2 -2
  32. package/plugin/meta/ase-format-arch.md +8 -8
  33. package/plugin/meta/ase-format-task.md +8 -10
  34. package/plugin/meta/ase-getopt.md +1 -1
  35. package/plugin/meta/ase-skill.md +1 -1
  36. package/plugin/package.json +8 -8
  37. package/plugin/skills/ase-arch-analyze/help.md +2 -2
  38. package/plugin/skills/ase-arch-discover/SKILL.md +2 -2
  39. package/plugin/skills/ase-arch-discover/help.md +3 -2
  40. package/plugin/skills/ase-code-analyze/SKILL.md +0 -1
  41. package/plugin/skills/ase-code-craft/SKILL.md +4 -4
  42. package/plugin/skills/ase-code-lint/SKILL.md +12 -10
  43. package/plugin/skills/ase-code-refactor/SKILL.md +6 -6
  44. package/plugin/skills/ase-code-refactor/help.md +1 -1
  45. package/plugin/skills/ase-code-resolve/SKILL.md +4 -4
  46. package/plugin/skills/ase-code-resolve/help.md +1 -1
  47. package/plugin/skills/ase-docs-distill/help.md +6 -4
  48. package/plugin/skills/ase-docs-proofread/SKILL.md +5 -5
  49. package/plugin/skills/ase-meta-brainstorm/help.md +1 -1
  50. package/plugin/skills/ase-meta-changelog/SKILL.md +4 -3
  51. package/plugin/skills/ase-meta-commit/SKILL.md +3 -1
  52. package/plugin/skills/ase-meta-compat/SKILL.md +11 -2
  53. package/plugin/skills/ase-meta-diaboli/SKILL.md +4 -4
  54. package/plugin/skills/ase-meta-diff/SKILL.md +10 -10
  55. package/plugin/skills/ase-meta-persona/SKILL.md +1 -1
  56. package/plugin/skills/ase-meta-quorum/SKILL.md +10 -8
  57. package/plugin/skills/ase-meta-search/help.md +4 -2
  58. package/plugin/skills/ase-meta-steelman/SKILL.md +2 -2
  59. package/plugin/skills/ase-meta-steelman/help.md +5 -3
  60. package/plugin/skills/ase-meta-why/SKILL.md +1 -1
  61. package/plugin/skills/ase-meta-why/help.md +3 -1
  62. package/plugin/skills/ase-sync-export/SKILL.md +13 -10
  63. package/plugin/skills/ase-sync-import/help.md +2 -2
  64. package/plugin/skills/ase-sync-reconcile/SKILL.md +1 -1
  65. package/plugin/skills/ase-sync-reconcile/help.md +1 -1
  66. package/plugin/skills/ase-task-condense/SKILL.md +6 -7
  67. package/plugin/skills/ase-task-delete/SKILL.md +1 -1
  68. package/plugin/skills/ase-task-edit/SKILL.md +2 -2
  69. package/plugin/skills/ase-task-grill/SKILL.md +1 -1
  70. package/plugin/skills/ase-task-implement/help.md +2 -2
  71. package/plugin/skills/ase-task-preflight/SKILL.md +1 -1
  72. package/plugin/skills/ase-task-reboot/SKILL.md +13 -9
  73. package/plugin/skills/ase-task-reboot/help.md +2 -1
@@ -28,8 +28,9 @@ export class Artifact {
28
28
  }
29
29
  /* translate a single ".gitignore" line into a picomatch-backed rule,
30
30
  honoring the anchored-vs-floating, directory-only, and negation
31
- semantics of ".gitignore" patterns */
32
- static compileIgnoreRule(line) {
31
+ semantics of ".gitignore" patterns; anchored patterns resolve
32
+ relative to the "base" directory holding the ".gitignore" */
33
+ static compileIgnoreRule(line, base) {
33
34
  let pattern = line.trim();
34
35
  if (pattern === "" || pattern.startsWith("#"))
35
36
  return null;
@@ -46,18 +47,19 @@ export class Artifact {
46
47
  const anchored = pattern.includes("/");
47
48
  if (pattern.startsWith("/"))
48
49
  pattern = pattern.slice(1);
49
- const glob = anchored ? pattern : `**/${pattern}`;
50
+ const glob = anchored ? (base === "" ? pattern : `${base}/${pattern}`) : `**/${pattern}`;
50
51
  const isMatch = picomatch(glob, { dot: true });
51
52
  return { matcher: (p) => isMatch(p), negated, dirOnly };
52
53
  }
53
- /* load the ".gitignore" rules located directly in a directory */
54
- static loadIgnoreRules(dir) {
54
+ /* load the ".gitignore" rules located directly in a directory,
55
+ given as absolute "dir" and project-relative "relDir" */
56
+ static loadIgnoreRules(dir, relDir) {
55
57
  const file = path.join(dir, ".gitignore");
56
58
  if (!fs.existsSync(file))
57
59
  return [];
58
60
  const rules = [];
59
61
  for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
60
- const rule = Artifact.compileIgnoreRule(line);
62
+ const rule = Artifact.compileIgnoreRule(line, relDir);
61
63
  if (rule !== null)
62
64
  rules.push(rule);
63
65
  }
@@ -83,7 +85,7 @@ export class Artifact {
83
85
  const root = Task.projectRoot();
84
86
  const files = new Set();
85
87
  const walk = (dir, relDir, inherited) => {
86
- const rules = [...inherited, ...Artifact.loadIgnoreRules(dir)];
88
+ const rules = [...inherited, ...Artifact.loadIgnoreRules(dir, relDir)];
87
89
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
88
90
  if (entry.name === ".git")
89
91
  continue;
@@ -135,9 +137,7 @@ export class Artifact {
135
137
  /* read the configured "basedir" anchor and "files" miniglob spec
136
138
  for a single kind; "basedir" is project-root-relative (POSIX,
137
139
  "" ≡ project root) and "files" resolves relative to "basedir" */
138
- static spec(log, kind) {
139
- const cfg = new Config("config", configSchema, log);
140
- cfg.read();
140
+ static spec(cfg, kind) {
141
141
  const basedir = Artifact.configString(cfg, `project.artifact.${kind}.basedir`)
142
142
  .replace(/\\/g, "/").replace(/^\/+|\/+$/g, "")
143
143
  .replace(/^\.$/, "");
@@ -163,10 +163,12 @@ export class Artifact {
163
163
  /* resolve the requested kinds to project-relative file lists */
164
164
  static list(log, kinds) {
165
165
  const all = Artifact.universe();
166
+ const cfg = new Config("config", configSchema, log);
167
+ cfg.read();
166
168
  /* raw-resolve all five configured kinds */
167
169
  const raw = new Map();
168
170
  for (const kind of configuredKinds) {
169
- const { basedir, files } = Artifact.spec(log, kind);
171
+ const { basedir, files } = Artifact.spec(cfg, kind);
170
172
  raw.set(kind, Artifact.resolveKind(basedir, files, all));
171
173
  }
172
174
  /* partition the universe by descending precedence */
@@ -174,8 +176,9 @@ export class Artifact {
174
176
  const part = new Map();
175
177
  for (const kind of configuredKinds) {
176
178
  const own = [];
177
- for (const file of raw.get(kind).size > 0 ? all : []) {
178
- if (raw.get(kind).has(file) && !claimed.has(file)) {
179
+ const resolved = raw.get(kind);
180
+ for (const file of all) {
181
+ if (resolved.has(file) && !claimed.has(file)) {
179
182
  own.push(file);
180
183
  claimed.add(file);
181
184
  }
@@ -198,7 +201,11 @@ export class Artifact {
198
201
  const file = filename.replace(/\\/g, "/").replace(/^\/+/, "");
199
202
  if (file === "")
200
203
  throw new Error("artifact: filename must not be empty");
201
- const { basedir } = Artifact.spec(log, kind);
204
+ if (file.split("/").includes(".."))
205
+ throw new Error("artifact: filename must not contain \"..\" segments");
206
+ const cfg = new Config("config", configSchema, log);
207
+ cfg.read();
208
+ const { basedir } = Artifact.spec(cfg, kind);
202
209
  return basedir === "" ? file : `${basedir}/${file}`;
203
210
  }
204
211
  }
@@ -248,6 +255,11 @@ export default class ArtifactCommand {
248
255
  });
249
256
  }
250
257
  }
258
+ /* render a caught error as an MCP tool error result */
259
+ const mcpToolError = (err) => ({
260
+ isError: true,
261
+ content: [{ type: "text", text: `ERROR: ${err instanceof Error ? err.message : String(err)}` }]
262
+ });
251
263
  /* MCP registration entry point for artifact tools */
252
264
  export class ArtifactMCP {
253
265
  log;
@@ -285,11 +297,7 @@ export class ArtifactMCP {
285
297
  };
286
298
  }
287
299
  catch (err) {
288
- const message = err instanceof Error ? err.message : String(err);
289
- return {
290
- isError: true,
291
- content: [{ type: "text", text: `ERROR: ${message}` }]
292
- };
300
+ return mcpToolError(err);
293
301
  }
294
302
  });
295
303
  mcp.registerTool("ase_artifact_name", {
@@ -319,11 +327,7 @@ export class ArtifactMCP {
319
327
  };
320
328
  }
321
329
  catch (err) {
322
- const message = err instanceof Error ? err.message : String(err);
323
- return {
324
- isError: true,
325
- content: [{ type: "text", text: `ERROR: ${message}` }]
326
- };
330
+ return mcpToolError(err);
327
331
  }
328
332
  });
329
333
  }
package/dst/ase-config.js CHANGED
@@ -97,17 +97,21 @@ const parseScopeTerm = (value) => {
97
97
  throw new Error(`invalid --scope term "${value}" ` +
98
98
  "(expected: \"user\", \"project\", \"task:<id>\", or \"session:<id>\")");
99
99
  };
100
- /* detect whether a project context exists, i.e. either we are inside
101
- a Git working tree or a ".ase" directory is present at or above cwd */
102
- const hasProjectContext = () => {
100
+ /* determine the Git top-level directory, if inside a Git repository */
101
+ const gitToplevel = () => {
103
102
  try {
104
103
  const result = execaSync("git", ["rev-parse", "--show-toplevel"], { stderr: "ignore" });
105
- if (result.stdout.trim() !== "")
106
- return true;
104
+ return result.stdout.trim() || null;
107
105
  }
108
106
  catch {
109
- /* not inside a Git working tree */
107
+ return null;
110
108
  }
109
+ };
110
+ /* detect whether a project context exists, i.e. either we are inside
111
+ a Git working tree or a ".ase" directory is present at or above cwd */
112
+ const hasProjectContext = () => {
113
+ if (gitToplevel() !== null)
114
+ return true;
111
115
  let dir = fs.realpathSync(process.cwd());
112
116
  for (;;) {
113
117
  if (fs.existsSync(path.join(dir, ".ase")))
@@ -226,14 +230,14 @@ export class Config {
226
230
  else if (term.kind === "project") {
227
231
  const rel = path.join(".ase", `${name}.yaml`);
228
232
  const cwd = process.cwd();
229
- const top = this.gitToplevel();
233
+ const top = gitToplevel();
230
234
  const found = top !== null ?
231
235
  this.findUpward(cwd, top, rel) :
232
236
  (fs.existsSync(path.join(cwd, rel)) ? path.join(cwd, rel) : null);
233
237
  return found ?? path.join(top ?? cwd, rel);
234
238
  }
235
239
  else if (term.kind === "task") {
236
- const top = this.gitToplevel() ?? process.cwd();
240
+ const top = gitToplevel() ?? process.cwd();
237
241
  return path.join(top, ".ase", "task", term.id, `${name}.yaml`);
238
242
  }
239
243
  else
@@ -258,18 +262,6 @@ export class Config {
258
262
  }
259
263
  return null;
260
264
  }
261
- /* determine the Git top-level directory, if inside a Git repository */
262
- gitToplevel() {
263
- try {
264
- const result = execaSync("git", ["rev-parse", "--show-toplevel"], {
265
- stderr: "ignore"
266
- });
267
- return result.stdout.trim() || null;
268
- }
269
- catch {
270
- return null;
271
- }
272
- }
273
265
  /* read the full scope chain into memory; the requested mode applies
274
266
  to the target scope only, inherited scopes are always lenient */
275
267
  read(mode = "lenient") {
@@ -538,8 +530,8 @@ export default class ConfigCommand {
538
530
  "\"user\" is always implicitly included and \"project\" is implicitly " +
539
531
  "included whenever a project context (Git repo or upward \".ase\" directory) exists")
540
532
  .description("manage ASE configuration")
541
- .action((_opts, cmd) => {
542
- cmd.outputHelp();
533
+ .action(() => {
534
+ configCmd.outputHelp();
543
535
  process.exit(1);
544
536
  });
545
537
  /* register CLI sub-command "ase config init" */
@@ -662,6 +654,11 @@ export default class ConfigCommand {
662
654
  });
663
655
  }
664
656
  }
657
+ /* render a caught error as an MCP tool error result */
658
+ const mcpToolError = (err) => ({
659
+ isError: true,
660
+ content: [{ type: "text", text: `ERROR: ${err instanceof Error ? err.message : String(err)}` }]
661
+ });
665
662
  /* MCP registration entry point for layered YAML configuration access */
666
663
  export class ConfigMCP {
667
664
  log;
@@ -687,17 +684,13 @@ export class ConfigMCP {
687
684
  try {
688
685
  const scope = parseScope(args.scope);
689
686
  const cfg = new Config("config", configSchema, this.log, scope);
690
- let text = "";
691
- cfg.lock(() => {
692
- cfg.read();
693
- const val = cfg.get(args.key);
694
- text = val === undefined ? "" : JSON.stringify(val);
695
- });
687
+ cfg.read();
688
+ const val = cfg.get(args.key);
689
+ const text = val === undefined ? "" : JSON.stringify(val);
696
690
  return { content: [{ type: "text", text }] };
697
691
  }
698
692
  catch (err) {
699
- const message = err instanceof Error ? err.message : String(err);
700
- return { isError: true, content: [{ type: "text", text: `config_get: ERROR: ${message}` }] };
693
+ return mcpToolError(err);
701
694
  }
702
695
  });
703
696
  /* config set */
@@ -723,11 +716,10 @@ export class ConfigMCP {
723
716
  cfg.set(args.key, args.val);
724
717
  cfg.write();
725
718
  });
726
- return { content: [{ type: "text", text: `config_set: OK: stored "${args.key}" on scope "${args.scope}"` }] };
719
+ return { content: [{ type: "text", text: `OK: stored "${args.key}" on scope "${args.scope}"` }] };
727
720
  }
728
721
  catch (err) {
729
- const message = err instanceof Error ? err.message : String(err);
730
- return { isError: true, content: [{ type: "text", text: `config_set: ERROR: ${message}` }] };
722
+ return mcpToolError(err);
731
723
  }
732
724
  });
733
725
  /* config delete */
@@ -750,11 +742,10 @@ export class ConfigMCP {
750
742
  cfg.delete(args.key);
751
743
  cfg.write();
752
744
  });
753
- return { content: [{ type: "text", text: `config_delete: OK: removed "${args.key}" on scope "${args.scope}"` }] };
745
+ return { content: [{ type: "text", text: `OK: removed "${args.key}" on scope "${args.scope}"` }] };
754
746
  }
755
747
  catch (err) {
756
- const message = err instanceof Error ? err.message : String(err);
757
- return { isError: true, content: [{ type: "text", text: `config_delete: ERROR: ${message}` }] };
748
+ return mcpToolError(err);
758
749
  }
759
750
  });
760
751
  }
@@ -7,6 +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
11
  /* custom argument parser for Commander: non-negative integer */
11
12
  const parseInteger = (name) => (value) => {
12
13
  const n = Number.parseInt(value, 10);
@@ -211,13 +212,6 @@ export class Diagram {
211
212
  return out;
212
213
  }
213
214
  }
214
- /* read stdin into a single string */
215
- const readStdin = async () => {
216
- const chunks = [];
217
- for await (const chunk of process.stdin)
218
- chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
219
- return Buffer.concat(chunks).toString("utf8");
220
- };
221
215
  /* command-line handling */
222
216
  export default class DiagramCommand {
223
217
  log;
@@ -241,12 +235,20 @@ export default class DiagramCommand {
241
235
  .option("--terminal-width <n>", "width of terminal of <n> characters (for diagram clipping)", parseInteger("--terminal-width"), Diagram.detectTermWidth())
242
236
  .option("--terminal-height <n>", "height of terminal of <n> lines (for diagram clipping)", parseInteger("--terminal-height"), Diagram.detectTermHeight())
243
237
  .action(async (opts) => {
244
- /* fetch Mermaid diagram specification from stdin */
238
+ /* fetch Mermaid diagram specification from file or stdin */
245
239
  let src;
246
- if (opts.input !== undefined)
247
- src = fs.readFileSync(opts.input, "utf8");
240
+ if (opts.input !== undefined) {
241
+ try {
242
+ src = fs.readFileSync(opts.input, "utf8");
243
+ }
244
+ catch (err) {
245
+ const message = err instanceof Error ? err.message : String(err);
246
+ this.log.write("error", `diagram: failed to read input file: ${message}`);
247
+ process.exit(1);
248
+ }
249
+ }
248
250
  else
249
- src = await readStdin();
251
+ src = await getStdin();
250
252
  if (src.trim() === "") {
251
253
  this.log.write("error", "diagram: empty Mermaid diagram specification");
252
254
  process.exit(1);
@@ -254,18 +256,7 @@ export default class DiagramCommand {
254
256
  /* create diagram rendering */
255
257
  let out;
256
258
  try {
257
- out = Diagram.render(src, {
258
- format: opts.format,
259
- ascii: opts.ascii ?? false,
260
- colorMode: opts.colorMode,
261
- nodeMarginX: opts.nodeMarginX,
262
- nodeMarginY: opts.nodeMarginY,
263
- nodePadding: opts.nodePadding,
264
- diagramClipX: opts.diagramClipX,
265
- diagramClipY: opts.diagramClipY,
266
- terminalWidth: opts.terminalWidth,
267
- terminalHeight: opts.terminalHeight
268
- });
259
+ out = Diagram.render(src, opts);
269
260
  }
270
261
  catch (err) {
271
262
  const message = err instanceof Error ? err.message : String(err);
@@ -321,18 +312,7 @@ export class DiagramMCP {
321
312
  }
322
313
  }, async (args) => {
323
314
  try {
324
- const out = Diagram.render(args.diagram, {
325
- format: args.format,
326
- ascii: args.ascii,
327
- colorMode: args.colorMode,
328
- nodeMarginX: args.nodeMarginX,
329
- nodeMarginY: args.nodeMarginY,
330
- nodePadding: args.nodePadding,
331
- diagramClipX: args.diagramClipX,
332
- diagramClipY: args.diagramClipY,
333
- terminalWidth: args.terminalWidth,
334
- terminalHeight: args.terminalHeight
335
- });
315
+ const out = Diagram.render(args.diagram, args);
336
316
  return {
337
317
  content: [{ type: "text", text: out }]
338
318
  };
package/dst/ase-getopt.js CHANGED
@@ -55,8 +55,10 @@ export class GetoptMCP {
55
55
  /* tokenize spec and add one option per token */
56
56
  const tokens = args.spec.split(/\s+/).filter((e) => e.length > 0);
57
57
  const re = /^--([A-Za-z][A-Za-z0-9-]*)(?:\|-([A-Za-z]))?(?:=(\((.*)\)(\.\.\.)?|.*))?$/;
58
+ const camelKey = (long) => long.replace(/-(.)/g, (_, c) => c.toUpperCase());
58
59
  const internals = new Set();
59
60
  const flagTakesValue = new Map();
61
+ const listOpts = [];
60
62
  for (const tok of tokens) {
61
63
  const m = re.exec(tok);
62
64
  if (m === null)
@@ -83,32 +85,25 @@ export class GetoptMCP {
83
85
  }
84
86
  else
85
87
  opt.default(false);
86
- if (long !== undefined && long.startsWith("int-")) {
88
+ if (choices !== null && isList)
89
+ listOpts.push({ long, choices });
90
+ if (long.startsWith("int-")) {
87
91
  /* internal option: hide from usage help and remember
88
92
  its camel-cased key for the info rendering below */
89
93
  opt.hideHelp();
90
- internals.add(long.replace(/-(.)/g, (_, c) => c.toUpperCase()));
94
+ internals.add(camelKey(long));
91
95
  }
92
96
  cmd.addOption(opt);
93
97
  }
94
98
  /* parse args */
95
99
  cmd.parse(argsVec, { from: "user" });
96
- /* validate comma-separated list-of-choices options */
97
- const listOpts = [];
98
- for (const tok of tokens) {
99
- const m = re.exec(tok);
100
- if (m === null)
101
- continue;
102
- const long = m[1];
103
- const choicePart = m[4] ?? null;
104
- const listMarker = m[5] ?? null;
105
- if (choicePart !== null && listMarker !== null)
106
- listOpts.push({ long, choices: choicePart.split("|") });
107
- }
100
+ /* validate comma-separated list-of-choices options (only
101
+ the first supplied token is checked here -- skills
102
+ validate the remaining tokens as they consume them) */
108
103
  if (listOpts.length > 0) {
109
104
  const opts = cmd.opts();
110
105
  for (const { long, choices } of listOpts) {
111
- const key = long.replace(/-(.)/g, (_, c) => c.toUpperCase());
106
+ const key = camelKey(long);
112
107
  const v = opts[key];
113
108
  if (typeof v !== "string")
114
109
  continue;
package/dst/ase-hook.js CHANGED
@@ -217,16 +217,13 @@ export default class HookCommand {
217
217
  const cfg = new Config("config", configSchema, this.log, hasSession ? parseScope(`session:${sessionId}`) : parseScope(undefined));
218
218
  /* determine task id (only persist when scoped to a real session) */
219
219
  const taskId = process.env.ASE_TASK_ID ?? "default";
220
- if (hasSession)
221
- cfg.lock(() => {
222
- cfg.read();
220
+ cfg.lock(() => {
221
+ cfg.read();
222
+ if (hasSession) {
223
223
  cfg.set("agent.task", taskId);
224
224
  cfg.write();
225
- });
226
- else
227
- cfg.lock(() => {
228
- cfg.read();
229
- });
225
+ }
226
+ });
230
227
  /* initialize agent activity status */
231
228
  this.writeAgentStatus("ready");
232
229
  /* determine project id */
@@ -333,19 +330,19 @@ export default class HookCommand {
333
330
  "@ase_agent_status", icon], { stdio: "ignore", reject: false });
334
331
  }
335
332
  }
336
- /* handler for "ase hook user-prompt-submit" (both tools) */
333
+ /* handler for "ase hook user-prompt-submit" (all tools) */
337
334
  async doUserPromptSubmit(_tool) {
338
335
  await this.drainStdin();
339
336
  this.writeAgentStatus("busy");
340
337
  return 0;
341
338
  }
342
- /* handler for "ase hook stop" (both tools) */
339
+ /* handler for "ase hook stop" (all tools) */
343
340
  async doStop(_tool) {
344
341
  await this.drainStdin();
345
342
  this.writeAgentStatus("ready");
346
343
  return 0;
347
344
  }
348
- /* handler for "ase hook session-end" (both tools) */
345
+ /* handler for "ase hook session-end" (all tools) */
349
346
  async doSessionEnd(_tool) {
350
347
  /* determine session id */
351
348
  const sessionId = await this.readSessionIdFromStdin();
@@ -432,7 +429,9 @@ export default class HookCommand {
432
429
  }));
433
430
  else if (!spec.toolInputIsString && typeof rawInput === "object" && rawInput !== null)
434
431
  toolInput = rawInput;
435
- if (toolName === spec.bashToolName && /^ase(\s|$)/.test(toolInput.command ?? ""))
432
+ const command = toolInput.command ?? "";
433
+ if (toolName === spec.bashToolName && /^ase(\s|$)/.test(command)
434
+ && !/[;&|<>`\n]|\$\(/.test(command))
436
435
  return { approve: true, reason: "ASE CLI invocation auto-approved" };
437
436
  else if (toolName === "Skill" && /^(?:ase:)?ase-.+/.test(toolInput.skill ?? ""))
438
437
  return { approve: true, reason: "ASE skill invocation auto-approved" };
@@ -450,6 +449,17 @@ export default class HookCommand {
450
449
  }
451
450
  return { approve: false, reason: "" };
452
451
  }
452
+ /* read the hook event payload from stdin and parse it into the
453
+ loosely-typed input object shared by the tool-approval handlers */
454
+ async readHookInput(tool) {
455
+ const spec = toolSpecs[tool];
456
+ const stdin = await this.readStdin();
457
+ const input = this.parseJSON(stdin, v.looseObject({
458
+ session_id: v.optional(v.string()),
459
+ sessionId: v.optional(v.string())
460
+ }));
461
+ return { spec, input };
462
+ }
453
463
  /* handler for "ase hook pre-tool-use" (all tools).
454
464
  For Anthropic Claude Code CLI and GitHub Copilot CLI this is where ASE tool
455
465
  invocations are auto-approved (via "permissionDecision: allow").
@@ -459,13 +469,8 @@ export default class HookCommand {
459
469
  still drain stdin, as Codex treats a non-draining hook as a hard
460
470
  error. */
461
471
  async doPreToolUse(tool) {
462
- const spec = toolSpecs[tool];
463
472
  /* read tool invocation information */
464
- const stdin = await this.readStdin();
465
- const input = this.parseJSON(stdin, v.looseObject({
466
- session_id: v.optional(v.string()),
467
- sessionId: v.optional(v.string())
468
- }));
473
+ const { spec, input } = await this.readHookInput(tool);
469
474
  /* Codex auto-approves through "PermissionRequest", not here */
470
475
  if (spec.approvalEvent !== "PreToolUse")
471
476
  return 0;
@@ -496,13 +501,8 @@ export default class HookCommand {
496
501
  Staying silent (or returning a non-approval) defers to Codex's
497
502
  normal approval flow. */
498
503
  async doPermissionRequest(tool) {
499
- const spec = toolSpecs[tool];
500
504
  /* read tool invocation information */
501
- const stdin = await this.readStdin();
502
- const input = this.parseJSON(stdin, v.looseObject({
503
- session_id: v.optional(v.string()),
504
- sessionId: v.optional(v.string())
505
- }));
505
+ const { spec, input } = await this.readHookInput(tool);
506
506
  /* determine whether to auto-approve the tool invocation */
507
507
  const { approve } = this.decideApproval(tool, spec, input);
508
508
  /* emit the Codex "PermissionRequest" approval decision */
@@ -525,13 +525,15 @@ export default class HookCommand {
525
525
  }
526
526
  /* register commands */
527
527
  register(program) {
528
- /* default for --tool derived from ASE_TOOL environment variable */
528
+ /* default for --tool derived from ASE_TOOL environment variable
529
+ (validated lazily by "parseTool" in each action, so an invalid
530
+ value cannot break unrelated "ase" commands at startup) */
529
531
  const envTool = process.env.ASE_TOOL ?? "";
530
- const toolDflt = envTool !== "" ? this.parseTool(envTool) : "claude";
532
+ const toolDflt = envTool !== "" ? envTool : "claude";
531
533
  /* register CLI top-level command "ase hook" */
532
534
  const hookCmd = program
533
535
  .command("hook")
534
- .description("Anthropic Claude Code CLI and GitHub Copilot CLI hook entry points")
536
+ .description("Anthropic Claude Code CLI, GitHub Copilot CLI, and OpenAI Codex CLI hook entry points")
535
537
  .action(() => {
536
538
  hookCmd.outputHelp();
537
539
  process.exit(1);