@rse/ase 0.9.47 → 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,7 +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-stdout.js";
6
+ import { writeStdout } from "./ase-stdio.js";
7
7
  /* the canonical expected values for every ase-meta-compat probe,
8
8
  keyed by "<category>/<probe-name>" as used in the skill */
9
9
  const EXPECTED = {
package/dst/ase-config.js CHANGED
@@ -14,7 +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 { writeStdout } from "./ase-stdout.js";
17
+ import { LRUCache } from "lru-cache";
18
+ import { writeStdout } from "./ase-stdio.js";
18
19
  /* classification taxonomy */
19
20
  export const projectClassification = {
20
21
  boxing: ["white", "grey", "black"]
@@ -93,20 +94,33 @@ const parseScopeTerm = (value) => {
93
94
  else if (value === "project")
94
95
  return { kind: "project" };
95
96
  const m = /^(session|task):([A-Za-z0-9._-]+)$/.exec(value);
96
- 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 ".."`);
97
100
  return { kind: m[1], id: m[2] };
101
+ }
98
102
  throw new Error(`invalid --scope term "${value}" ` +
99
103
  "(expected: \"user\", \"project\", \"task:<id>\", or \"session:<id>\")");
100
104
  };
101
- /* 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 });
102
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 = "";
103
116
  try {
104
- const result = execaSync("git", ["rev-parse", "--show-toplevel"], { stderr: "ignore" });
105
- return result.stdout.trim() || null;
117
+ top = execaSync("git", ["rev-parse", "--show-toplevel"], { stderr: "ignore" }).stdout.trim();
106
118
  }
107
119
  catch {
108
- return null;
120
+ /* not inside a Git working tree */
109
121
  }
122
+ gitToplevelCache.set(cwd, top);
123
+ return top === "" ? null : top;
110
124
  };
111
125
  /* detect whether a project context exists, i.e. either we are inside
112
126
  a Git working tree or a ".ase" directory is present at or above cwd */
@@ -154,6 +168,11 @@ export const parseScope = (value) => {
154
168
  terms.unshift({ kind: "default" });
155
169
  return terms;
156
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
+ }));
157
176
  /* schema for ".ase/config.yaml" */
158
177
  export const configSchema = v.nullish(v.strictObject({
159
178
  project: v.optional(v.strictObject({
@@ -161,12 +180,12 @@ export const configSchema = v.nullish(v.strictObject({
161
180
  name: v.optional(v.pipe(v.string(), v.minLength(1))),
162
181
  boxing: v.optional(v.picklist(projectClassification.boxing)),
163
182
  artifact: v.optional(v.strictObject({
164
- spec: v.optional(v.strictObject({ basedir: v.optional(v.string()), files: v.optional(v.string()) })),
165
- arch: v.optional(v.strictObject({ basedir: v.optional(v.string()), files: v.optional(v.string()) })),
166
- code: v.optional(v.strictObject({ basedir: v.optional(v.string()), files: v.optional(v.string()) })),
167
- docs: v.optional(v.strictObject({ basedir: v.optional(v.string()), files: v.optional(v.string()) })),
168
- infr: v.optional(v.strictObject({ basedir: v.optional(v.string()), files: v.optional(v.string()) })),
169
- 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
170
189
  }))
171
190
  })),
172
191
  agent: v.optional(v.strictObject({
@@ -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",
@@ -86,14 +85,9 @@ export default class HookCommand {
86
85
  isValidSessionId(id) {
87
86
  return /^[A-Za-z0-9._-]+$/.test(id);
88
87
  }
89
- /* read the entire stdin payload asynchronously */
90
- readStdin() {
91
- /* best-effort: treat an unreadable/closed stdin as empty */
92
- return getStdin().catch(() => "");
93
- }
94
88
  /* drain and discard the stdin event payload */
95
89
  async drainStdin() {
96
- await this.readStdin();
90
+ await readStdin().catch(() => "");
97
91
  }
98
92
  /* best-effort JSON parse with valibot schema validation: returns
99
93
  an empty object on blank input, malformed JSON, or schema
@@ -211,7 +205,7 @@ export default class HookCommand {
211
205
  const versionHint = versionHints.length > 0 ? "(" + versionHints.join(", ") + ")" : "";
212
206
  /* read session information (Anthropic Claude Code CLI uses snake_case fields,
213
207
  GitHub Copilot CLI uses camelCase fields) */
214
- const stdin = await this.readStdin();
208
+ const stdin = await readStdin().catch(() => "");
215
209
  const input = this.parseJSON(stdin, v.object({
216
210
  session_id: v.optional(v.string()),
217
211
  sessionId: v.optional(v.string()),
@@ -273,7 +267,13 @@ export default class HookCommand {
273
267
  `export ASE_SESSION_ID=${quote([sessionId])}\n` +
274
268
  `export ASE_HEADLESS=${quote([headless])}\n` +
275
269
  `export ASE_AGENT_TOOL=${quote([tool])}\n`;
276
- 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
+ }
277
277
  }
278
278
  /* prepend ASE information to constitution markdown */
279
279
  md =
@@ -371,7 +371,7 @@ export default class HookCommand {
371
371
  }
372
372
  /* read session id from stdin JSON payload */
373
373
  async readSessionIdFromStdin() {
374
- const stdin = await this.readStdin();
374
+ const stdin = await readStdin().catch(() => "");
375
375
  const input = this.parseJSON(stdin, v.object({
376
376
  session_id: v.optional(v.string()),
377
377
  sessionId: v.optional(v.string())
@@ -458,7 +458,7 @@ export default class HookCommand {
458
458
  loosely-typed input object shared by the tool-approval handlers */
459
459
  async readHookInput(tool) {
460
460
  const spec = toolSpecs[tool];
461
- const stdin = await this.readStdin();
461
+ const stdin = await readStdin().catch(() => "");
462
462
  const input = this.parseJSON(stdin, v.looseObject({
463
463
  session_id: v.optional(v.string()),
464
464
  sessionId: v.optional(v.string())
@@ -543,53 +543,22 @@ export default class HookCommand {
543
543
  hookCmd.outputHelp();
544
544
  process.exit(1);
545
545
  });
546
- /* register CLI sub-command "ase hook session-start" */
547
- hookCmd
548
- .command("session-start")
549
- .description("handle SessionStart hook event")
550
- .option("-t, --tool <tool>", "target tool (\"claude\", \"copilot\", or \"codex\")", toolDflt)
551
- .action(async (opts) => {
552
- process.exitCode = await this.doSessionStart(this.parseTool(opts.tool));
553
- });
554
- /* register CLI sub-command "ase hook session-end" */
555
- hookCmd
556
- .command("session-end")
557
- .description("handle SessionEnd hook event")
558
- .option("-t, --tool <tool>", "target tool (\"claude\", \"copilot\", or \"codex\")", toolDflt)
559
- .action(async (opts) => {
560
- process.exitCode = await this.doSessionEnd(this.parseTool(opts.tool));
561
- });
562
- /* register CLI sub-command "ase hook pre-tool-use" */
563
- hookCmd
564
- .command("pre-tool-use")
565
- .description("handle tool PreToolUse hook event")
566
- .option("-t, --tool <tool>", "target tool (\"claude\", \"copilot\", or \"codex\")", toolDflt)
567
- .action(async (opts) => {
568
- process.exitCode = await this.doPreToolUse(this.parseTool(opts.tool));
569
- });
570
- /* register CLI sub-command "ase hook permission-request" */
571
- hookCmd
572
- .command("permission-request")
573
- .description("handle tool PermissionRequest hook event (Codex CLI)")
574
- .option("-t, --tool <tool>", "target tool (\"claude\", \"copilot\", or \"codex\")", toolDflt)
575
- .action(async (opts) => {
576
- process.exitCode = await this.doPermissionRequest(this.parseTool(opts.tool));
577
- });
578
- /* register CLI sub-command "ase hook user-prompt-submit" */
579
- hookCmd
580
- .command("user-prompt-submit")
581
- .description("handle UserPromptSubmit hook event (mark agent as busy)")
582
- .option("-t, --tool <tool>", "target tool (\"claude\", \"copilot\", or \"codex\")", toolDflt)
583
- .action(async (opts) => {
584
- process.exitCode = await this.doUserPromptSubmit(this.parseTool(opts.tool));
585
- });
586
- /* register CLI sub-command "ase hook stop" */
587
- hookCmd
588
- .command("stop")
589
- .description("handle Stop hook event (mark agent as ready)")
590
- .option("-t, --tool <tool>", "target tool (\"claude\", \"copilot\", or \"codex\")", toolDflt)
591
- .action(async (opts) => {
592
- process.exitCode = await this.doStop(this.parseTool(opts.tool));
593
- });
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
+ });
594
563
  }
595
564
  }
package/dst/ase-kv.js CHANGED
@@ -30,10 +30,10 @@ export class KV {
30
30
  KV.validateKey(key);
31
31
  return KV.store.has(key);
32
32
  }
33
- /* get a value by key; returns undefined if no value is stored */
33
+ /* get a deep copy of a value by key; returns undefined if no value is stored */
34
34
  static get(key) {
35
35
  KV.validateKey(key);
36
- return KV.store.get(key);
36
+ return structuredClone(KV.store.get(key));
37
37
  }
38
38
  /* set a value under the given key; overwrites any existing value */
39
39
  static set(key, val) {
@@ -103,6 +103,17 @@ export class KV {
103
103
  throw new Error(`invalid command "${String(c.command)}"`);
104
104
  }
105
105
  }
106
+ /* extract the message of a caught error */
107
+ const errorMessage = (err) => err instanceof Error ? err.message : String(err);
108
+ /* execute a single key/value command and render it as an MCP tool result */
109
+ const mcpToolExec = (c) => {
110
+ try {
111
+ return { content: [{ type: "text", text: KV.execute(c) }] };
112
+ }
113
+ catch (err) {
114
+ return { isError: true, content: [{ type: "text", text: `ERROR: ${errorMessage(err)}` }] };
115
+ }
116
+ };
106
117
  /* MCP registration entry point for in-memory key/value tools */
107
118
  export class KVMCP {
108
119
  register(mcp) {
@@ -115,16 +126,7 @@ export class KVMCP {
115
126
  key: z.string()
116
127
  .describe("key identifier (non-empty, no whitespace-only, no control characters, up to 1024 characters)")
117
128
  }
118
- }, async (args) => {
119
- try {
120
- const text = KV.execute({ command: "get", key: args.key });
121
- return { content: [{ type: "text", text }] };
122
- }
123
- catch (err) {
124
- const message = err instanceof Error ? err.message : String(err);
125
- return { isError: true, content: [{ type: "text", text: `ERROR: ${message}` }] };
126
- }
127
- });
129
+ }, async (args) => mcpToolExec({ command: "get", key: args.key }));
128
130
  /* key/value set */
129
131
  mcp.registerTool("ase_kv_set", {
130
132
  title: "ASE key/value set",
@@ -137,16 +139,7 @@ export class KVMCP {
137
139
  val: z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.unknown()), z.record(z.string(), z.unknown())])
138
140
  .describe("arbitrary JSON-compatible value to store under `key`")
139
141
  }
140
- }, async (args) => {
141
- try {
142
- const text = KV.execute({ command: "set", key: args.key, val: args.val });
143
- return { content: [{ type: "text", text }] };
144
- }
145
- catch (err) {
146
- const message = err instanceof Error ? err.message : String(err);
147
- return { isError: true, content: [{ type: "text", text: `ERROR: ${message}` }] };
148
- }
149
- });
142
+ }, async (args) => mcpToolExec({ command: "set", key: args.key, val: args.val }));
150
143
  /* key/value clear */
151
144
  mcp.registerTool("ase_kv_clear", {
152
145
  title: "ASE key/value clear",
@@ -158,16 +151,7 @@ export class KVMCP {
158
151
  prefix: z.string().optional()
159
152
  .describe("if given, only remove keys starting with this prefix (otherwise remove all keys)")
160
153
  }
161
- }, async (args) => {
162
- try {
163
- const text = KV.execute({ command: "clear", prefix: args.prefix });
164
- return { content: [{ type: "text", text }] };
165
- }
166
- catch (err) {
167
- const message = err instanceof Error ? err.message : String(err);
168
- return { isError: true, content: [{ type: "text", text: `ERROR: ${message}` }] };
169
- }
170
- });
154
+ }, async (args) => mcpToolExec({ command: "clear", prefix: args.prefix }));
171
155
  /* key/value delete */
172
156
  mcp.registerTool("ase_kv_delete", {
173
157
  title: "ASE key/value delete",
@@ -177,16 +161,7 @@ export class KVMCP {
177
161
  key: z.string()
178
162
  .describe("key identifier (non-empty, no whitespace-only, no control characters, up to 1024 characters)")
179
163
  }
180
- }, async (args) => {
181
- try {
182
- const text = KV.execute({ command: "delete", key: args.key });
183
- return { content: [{ type: "text", text }] };
184
- }
185
- catch (err) {
186
- const message = err instanceof Error ? err.message : String(err);
187
- return { isError: true, content: [{ type: "text", text: `ERROR: ${message}` }] };
188
- }
189
- });
164
+ }, async (args) => mcpToolExec({ command: "delete", key: args.key }));
190
165
  /* key/value batch */
191
166
  mcp.registerTool("ase_kv_batch", {
192
167
  title: "ASE key/value batch",
@@ -226,7 +201,7 @@ export class KVMCP {
226
201
  results.push(KV.execute(c));
227
202
  }
228
203
  catch (err) {
229
- const message = err instanceof Error ? err.message : String(err);
204
+ const message = errorMessage(err);
230
205
  if (tx) {
231
206
  if (snapshot !== null)
232
207
  KV.restore(snapshot);
package/dst/ase-mcp.js CHANGED
@@ -23,7 +23,7 @@ export default class MCPCommand {
23
23
  let ctx = this.loadContext();
24
24
  /* fast path: already running */
25
25
  if (ctx.port !== null) {
26
- const match = await probe(ctx.port, ctx.projectId);
26
+ const match = await probe(ctx.port, ctx.projectId).catch(() => null);
27
27
  if (match === true)
28
28
  return { projectId: ctx.projectId, port: ctx.port };
29
29
  }
package/dst/ase-meta.js CHANGED
@@ -6,7 +6,7 @@
6
6
  import path from "node:path";
7
7
  import fs from "node:fs";
8
8
  import { fileURLToPath } from "node:url";
9
- import { writeStdout } from "./ase-stdout.js";
9
+ import { writeStdout } from "./ase-stdio.js";
10
10
  /* reusable functionality: resolve and read plugin "meta/" files */
11
11
  export class Meta {
12
12
  /* determine the plugin "meta/" directory; the build process copies
@@ -494,7 +494,7 @@ export default class ServiceCommand {
494
494
  process.stdout.write("service: not running (no port configured)\n");
495
495
  return 1;
496
496
  }
497
- const match = await probe(ctx.port, ctx.projectId);
497
+ const match = await probe(ctx.port, ctx.projectId).catch(() => null);
498
498
  if (match === true) {
499
499
  const r = await ofetch.raw(`http://${HOST}:${ctx.port}/command`, this.commandRequest("status", 2000));
500
500
  const d = r._data;
@@ -513,13 +513,13 @@ export default class ServiceCommand {
513
513
  /* send command: POST /command with the arbitrary cmd token */
514
514
  async doSend(cmd) {
515
515
  let ctx = this.loadContext();
516
- if (ctx.port === null || await probe(ctx.port, ctx.projectId) !== true) {
516
+ if (ctx.port === null || await probe(ctx.port, ctx.projectId).catch(() => null) !== true) {
517
517
  /* auto-start the service once, then re-check */
518
518
  await this.doStart();
519
519
  ctx = this.loadContext();
520
520
  if (ctx.port === null)
521
521
  throw new Error("service not running (no port configured after auto-start)");
522
- if (await probe(ctx.port, ctx.projectId) !== true)
522
+ if (await probe(ctx.port, ctx.projectId).catch(() => null) !== true)
523
523
  throw new Error(`service not responding on port ${ctx.port} after auto-start`);
524
524
  }
525
525
  const r = await ofetch.raw(`http://${HOST}:${ctx.port}/command`, { ...this.commandRequest(cmd, 5000), responseType: "text" });
@@ -536,7 +536,7 @@ export default class ServiceCommand {
536
536
  this.log.write("info", "service: not running (no port configured)");
537
537
  return 0;
538
538
  }
539
- const match = await probe(ctx.port, ctx.projectId);
539
+ const match = await probe(ctx.port, ctx.projectId).catch(() => null);
540
540
  if (match === false) {
541
541
  this.log.write("info", `service: not running (port ${ctx.port} in use by foreign service)`);
542
542
  return 1;
package/dst/ase-setup.js CHANGED
@@ -356,29 +356,13 @@ export default class SetupCommand {
356
356
  args.push(name, transport.url);
357
357
  }
358
358
  }
359
- else if (tool === "copilot") {
360
- /* GitHub Copilot CLI implies the stdio transport when the
361
- command is provided after "--"; only "http"/"sse" servers
362
- need an explicit "--transport" flag and take the URL as a
363
- positional argument */
364
- if (transport.type === "stdio") {
365
- args.push(name);
366
- for (const [key, val] of Object.entries(env))
367
- args.push("--env", `${key}=${val}`);
368
- args.push("--", ...transport.command);
369
- }
370
- else {
371
- args.push("--transport", "http");
372
- for (const [key, val] of Object.entries(transport.headers ?? {}))
373
- args.push("--header", `${key}: ${val}`);
374
- args.push(name, transport.url);
375
- }
376
- }
377
359
  else {
378
- /* OpenAI Codex CLI takes the server name as the first
379
- positional argument and implies the stdio transport when the
380
- command is provided after "--"; "http" servers take the URL
381
- via the "--url" option (with optional "--header" flags) */
360
+ /* the GitHub Copilot CLI and OpenAI Codex CLI both take the
361
+ server name as a positional argument and imply the stdio
362
+ transport when the command is provided after "--"; for "http"
363
+ servers the GitHub Copilot CLI needs an explicit
364
+ "--transport" flag and takes the URL positionally, while the
365
+ OpenAI Codex CLI takes it via the "--url" option */
382
366
  if (transport.type === "stdio") {
383
367
  args.push(name);
384
368
  for (const [key, val] of Object.entries(env))
@@ -386,9 +370,14 @@ export default class SetupCommand {
386
370
  args.push("--", ...transport.command);
387
371
  }
388
372
  else {
373
+ if (tool === "copilot")
374
+ args.push("--transport", "http");
389
375
  for (const [key, val] of Object.entries(transport.headers ?? {}))
390
376
  args.push("--header", `${key}: ${val}`);
391
- args.push(name, "--url", transport.url);
377
+ if (tool === "copilot")
378
+ args.push(name, transport.url);
379
+ else
380
+ args.push(name, "--url", transport.url);
392
381
  }
393
382
  }
394
383
  await this.run(toolSpecs[tool].cli, args);
@@ -657,24 +646,18 @@ export default class SetupCommand {
657
646
  AST, reproducing the exact whitespace tokens json-asty emits for a
658
647
  canonically 4-space-indented settings.json */
659
648
  statuslineBuildMember(root, command) {
660
- const strMember = (key, val, epilog) => {
661
- const k = root.create("string").set({ body: JSON.stringify(key), value: key, epilog: ": " });
662
- const v = root.create("string").set({ body: JSON.stringify(val), value: val });
663
- const m = root.create("member");
664
- if (epilog !== undefined)
665
- m.set({ epilog });
666
- return m.add(k, v);
667
- };
668
- const numMember = (key, val, epilog) => {
649
+ const member = (key, val, epilog) => {
650
+ const type = typeof val === "number" ? "number" : "string";
651
+ const body = typeof val === "number" ? String(val) : JSON.stringify(val);
669
652
  const k = root.create("string").set({ body: JSON.stringify(key), value: key, epilog: ": " });
670
- const v = root.create("number").set({ body: String(val), value: val });
653
+ const v = root.create(type).set({ body, value: val });
671
654
  const m = root.create("member");
672
655
  if (epilog !== undefined)
673
656
  m.set({ epilog });
674
657
  return m.add(k, v);
675
658
  };
676
659
  const obj = root.create("object").set({ prolog: "{\n ", epilog: "\n }\n" });
677
- obj.add(strMember("type", "command", ",\n "), strMember("command", command, ",\n "), numMember("padding", 0));
660
+ obj.add(member("type", "command", ",\n "), member("command", command, ",\n "), member("padding", 0));
678
661
  const key = root.create("string").set({
679
662
  body: JSON.stringify("statusLine"),
680
663
  value: "statusLine",
package/dst/ase-skills.js CHANGED
@@ -54,7 +54,7 @@ export class Skills {
54
54
  }
55
55
  /* fetch GitHub stars given a repository URL (or empty string) */
56
56
  static async fetchStars(repository) {
57
- const m = /^.+?\/\/github\.com\/([^/]+\/[^/#?]+?)(?:\.git)?(?:[/#?].*)?$/.exec(repository);
57
+ const m = /^(?:.*?:\/\/)?(?:[^@/]*@)?github\.com[:/]([^/]+\/[^/#?]+?)(?:\.git)?(?:[/#?].*)?$/.exec(repository);
58
58
  if (m === null)
59
59
  return "N.A.";
60
60
  try {
@@ -11,7 +11,7 @@ import { InvalidArgumentError } from "commander";
11
11
  import { execaSync } from "execa";
12
12
  import { Chalk } from "chalk";
13
13
  import { Config, configSchema, parseScope } from "./ase-config.js";
14
- import { writeStdout } from "./ase-stdout.js";
14
+ import { readStdin, writeStdout } from "./ase-stdio.js";
15
15
  import pkg from "../package.json" with { type: "json" };
16
16
  /* forced-color chalk instance: stdout is a pipe under Anthropic Claude Code CLI,
17
17
  so chalk auto-detection would yield level 0; force level 1 to keep
@@ -28,13 +28,6 @@ const parseInteger = (name) => (value) => {
28
28
  throw new InvalidArgumentError(`${name} must be a non-negative integer`);
29
29
  return n;
30
30
  };
31
- /* read stdin into a single string */
32
- const readStdin = async () => {
33
- const chunks = [];
34
- for await (const chunk of process.stdin)
35
- chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
36
- return Buffer.concat(chunks).toString("utf8");
37
- };
38
31
  /* detect terminal column width via /dev/tty (stdout is a pipe under Anthropic Claude Code CLI) */
39
32
  const detectTermWidth = () => {
40
33
  let width = 0;
@@ -218,8 +211,7 @@ export default class StatuslineCommand {
218
211
  }
219
212
  catch (err) {
220
213
  const message = err instanceof Error ? err.message : String(err);
221
- this.log.write("error", `statusline: invalid JSON on stdin: ${message}`);
222
- process.exit(1);
214
+ throw new Error(`statusline: invalid JSON on stdin: ${message}`);
223
215
  }
224
216
  /* normalize Copilot CLI's top-level "cwd" into the
225
217
  "workspace.current_dir" structure shared with Anthropic Claude Code CLI */
@@ -340,10 +332,18 @@ export default class StatuslineCommand {
340
332
  emit(`${prefix("◔", "context")}${bar} ${pct}%`);
341
333
  },
342
334
  C: () => {
343
- const pct = data.context_window?.used_percentage ?? 0;
344
- const tokensCur = (data.context_window?.total_input_tokens ?? 0) +
345
- (data.context_window?.total_output_tokens ?? 0);
346
- const tokensLim = pct > 0 && tokensCur > 0 ? Math.round(tokensCur * 100 / pct) : 0;
335
+ /* probe the live-context fields first and fall back to the
336
+ cumulative ones, so each harness contributes its own view:
337
+ GitHub Copilot CLI reports the live window separately (its
338
+ "total_*" are session sums), whereas Anthropic Claude Code CLI
339
+ folds it into "total_input_tokens". Output tokens stay out --
340
+ "used_percentage" is input-side only. */
341
+ const cw = data.context_window;
342
+ const pct = cw?.used_percentage ?? 0;
343
+ const tokensCur = cw?.current_context_tokens ??
344
+ cw?.last_call_input_tokens ?? cw?.total_input_tokens ?? 0;
345
+ const tokensLim = cw?.displayed_context_limit ?? cw?.context_window_size ??
346
+ (pct > 0 && tokensCur > 0 ? Math.round(tokensCur * 100 / pct) : 0);
347
347
  if (tokensLim > 0)
348
348
  emit(`${prefix("◆", "tokens")}${c.bold(formatTokens(tokensCur) + "/" + formatTokens(tokensLim))}`);
349
349
  },
@@ -0,0 +1,25 @@
1
+ /*
2
+ ** Agentic Software Engineering (ASE)
3
+ ** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
+ */
6
+ import getStdin from "get-stdin";
7
+ /* read the entire stdin payload as a UTF-8 string, treating an
8
+ interactive terminal as empty input so callers never block on a
9
+ user which is not going to type anything */
10
+ export const readStdin = () => {
11
+ return getStdin();
12
+ };
13
+ /* write a string to stdout, awaiting the write callback which only
14
+ fires once the data has been flushed to the underlying pipe, so a
15
+ subsequent "process.exit" cannot truncate the output */
16
+ export const writeStdout = (text) => {
17
+ return new Promise((resolve, reject) => {
18
+ process.stdout.write(text, (err) => {
19
+ if (err)
20
+ reject(err);
21
+ else
22
+ resolve();
23
+ });
24
+ });
25
+ };
package/dst/ase-task.js CHANGED
@@ -13,7 +13,7 @@ import { z } from "zod";
13
13
  import { LRUCache } from "lru-cache";
14
14
  import { Config, configSchema, parseScope } from "./ase-config.js";
15
15
  import { Markdown } from "./ase-markdown.js";
16
- import { writeStdout } from "./ase-stdout.js";
16
+ import { readStdin, writeStdout } from "./ase-stdio.js";
17
17
  /* reusable functionality: persisted task plans under
18
18
  <project>/<basedir>/TASK-<id>.md (driven by the
19
19
  "project.artifact.task.{basedir,files}" configuration) */
@@ -56,11 +56,18 @@ export class Task {
56
56
  Task.projectRootCache.set(cwd, root);
57
57
  return root;
58
58
  }
59
+ /* cached task storage specification (TTL-bounded, mirroring the project
60
+ root cache, as each read parses the whole layered YAML config chain) */
61
+ static specCache = new LRUCache({ max: 4, ttl: 2 * 1000 });
59
62
  /* read the configured "basedir" anchor and "files" miniglob spec for
60
63
  task storage; "basedir" is project-root-relative (POSIX, defaults
61
64
  to ".ase/task") and "files" constrains the task filenames
62
65
  (defaults to "*.md") */
63
66
  static spec(log) {
67
+ const root = Task.projectRoot();
68
+ const cached = Task.specCache.get(root);
69
+ if (cached !== undefined)
70
+ return cached;
64
71
  const cfg = new Config("config", configSchema, log);
65
72
  cfg.read();
66
73
  const read = (key) => {
@@ -74,7 +81,9 @@ export class Task {
74
81
  if (basedir.split("/").includes(".."))
75
82
  throw new Error(`task: configured "basedir" "${basedir}" must not escape the project root`);
76
83
  const files = read("project.artifact.task.files") || "*.md";
77
- return { basedir, files };
84
+ const result = { basedir, files };
85
+ Task.specCache.set(root, result);
86
+ return result;
78
87
  }
79
88
  /* resolve the on-disk base directory for task storage */
80
89
  static baseDir(log) {
@@ -245,15 +254,6 @@ export class Task {
245
254
  });
246
255
  }
247
256
  }
248
- /* read all of stdin as a UTF-8 string */
249
- const readStdin = () => {
250
- return new Promise((resolve, reject) => {
251
- const chunks = [];
252
- process.stdin.on("data", (chunk) => chunks.push(chunk));
253
- process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
254
- process.stdin.on("error", (err) => reject(err));
255
- });
256
- };
257
257
  /* CLI command "ase task" */
258
258
  export default class TaskCommand {
259
259
  log;
package/dst/ase.js CHANGED
@@ -60,7 +60,7 @@ const main = async () => {
60
60
  await program.parseAsync(process.argv);
61
61
  /* gracefully terminate */
62
62
  await log.close();
63
- process.exit(0);
63
+ process.exit(process.exitCode ?? 0);
64
64
  };
65
65
  main().catch(async (err) => {
66
66
  if (err instanceof CommanderError) {
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "homepage": "https://ase.tools",
7
7
  "repository": { "url": "git+https://github.com/rse/ase.git", "type": "git" },
8
8
  "bugs": { "url": "https://github.com/rse/ase/issues" },
9
- "version": "0.9.47",
9
+ "version": "0.9.48",
10
10
  "license": "Apache-2.0",
11
11
  "author": {
12
12
  "name": "Dr. Ralf S. Engelschall",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ase",
3
- "version": "0.9.47",
3
+ "version": "0.9.48",
4
4
  "description": "Agentic Software Engineering (ASE)",
5
5
  "keywords": [ "agentic", "software", "engineering" ],
6
6
  "homepage": "https://ase.tools",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ase",
3
- "version": "0.9.47",
3
+ "version": "0.9.48",
4
4
  "description": "Agentic Software Engineering (ASE)",
5
5
  "keywords": [ "agentic", "software", "engineering" ],
6
6
  "homepage": "https://ase.tools",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ase",
3
- "version": "0.9.47",
3
+ "version": "0.9.48",
4
4
  "description": "Agentic Software Engineering (ASE)",
5
5
  "keywords": [ "agentic", "software", "engineering" ],
6
6
  "homepage": "https://ase.tools",
@@ -6,7 +6,7 @@
6
6
  "homepage": "https://ase.tools",
7
7
  "repository": { "url": "git+https://github.com/rse/ase.git", "type": "git" },
8
8
  "bugs": { "url": "https://github.com/rse/ase/issues" },
9
- "version": "0.9.47",
9
+ "version": "0.9.48",
10
10
  "license": "Apache-2.0",
11
11
  "author": {
12
12
  "name": "Dr. Ralf S. Engelschall",
@@ -102,16 +102,11 @@ catalog you match <intent/> against:
102
102
  <template>
103
103
  <ase-tpl-head title="SKILL COMMAND PROPOSAL"/>
104
104
 
105
- ● **INTENT**:
106
- ○ <intent/>
107
-
108
- ● **COMMAND**:
109
- ⌘ `<command/>`
110
-
111
- ● **RATIONALE**:
112
- ○ <rationale/>
105
+ ❯ `<command/>`
113
106
 
114
107
  <ase-tpl-foot title="SKILL COMMAND PROPOSAL"/>
108
+
109
+ **RATIONALE**: <rationale/>
115
110
  </template>
116
111
 
117
112
  4. *Dispatch Command*: