@rse/ase 0.9.45 → 0.9.47

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 (39) hide show
  1. package/dst/ase-compat.js +3 -2
  2. package/dst/ase-config.js +6 -5
  3. package/dst/ase-hook.js +22 -17
  4. package/dst/ase-kv.js +38 -43
  5. package/dst/ase-log.js +13 -5
  6. package/dst/ase-markdown.js +12 -2
  7. package/dst/ase-mcp.js +20 -3
  8. package/dst/ase-persona.js +1 -3
  9. package/dst/ase-service.js +15 -25
  10. package/dst/ase-setup.js +12 -39
  11. package/dst/ase-statusline.js +14 -28
  12. package/dst/ase-task.js +14 -23
  13. package/dst/ase.js +6 -2
  14. package/package.json +3 -3
  15. package/plugin/.claude-plugin/plugin.json +1 -1
  16. package/plugin/.codex-plugin/plugin.json +1 -1
  17. package/plugin/.github/plugin/plugin.json +1 -1
  18. package/plugin/etc/stx.conf +6 -1
  19. package/plugin/meta/ase-dialog.md +2 -2
  20. package/plugin/meta/ase-skill.md +9 -0
  21. package/plugin/package.json +1 -1
  22. package/plugin/skills/ase-code-craft/SKILL.md +16 -2
  23. package/plugin/skills/ase-code-craft/help.md +5 -4
  24. package/plugin/skills/ase-code-refactor/SKILL.md +16 -2
  25. package/plugin/skills/ase-code-refactor/help.md +5 -4
  26. package/plugin/skills/ase-code-resolve/SKILL.md +16 -2
  27. package/plugin/skills/ase-code-resolve/help.md +6 -5
  28. package/plugin/skills/ase-help-intent/SKILL.md +157 -0
  29. package/plugin/skills/ase-help-intent/help.md +57 -0
  30. package/plugin/skills/ase-help-skill/SKILL.md +203 -0
  31. package/plugin/skills/ase-help-skill/catalog.md +60 -0
  32. package/plugin/skills/ase-help-skill/help.md +83 -0
  33. package/plugin/skills/ase-meta-eli5/SKILL.md +116 -0
  34. package/plugin/skills/ase-meta-eli5/help.md +61 -0
  35. package/plugin/skills/ase-meta-proximity/SKILL.md +224 -0
  36. package/plugin/skills/ase-meta-proximity/help.md +78 -0
  37. package/plugin/skills/ase-task-condense/help.md +3 -3
  38. package/plugin/skills/ase-task-reboot/help.md +3 -3
  39. package/plugin/skills/ase-task-view/help.md +3 -3
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-stdout.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,7 @@ 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
18
  /* classification taxonomy */
18
19
  export const projectClassification = {
19
20
  boxing: ["white", "grey", "black"]
@@ -188,7 +189,7 @@ export class Config {
188
189
  docs;
189
190
  target;
190
191
  /* creation */
191
- constructor(name, schema, log, scope = [{ kind: "project" }]) {
192
+ constructor(name, schema, log, scope = [{ kind: "user" }, { kind: "project" }]) {
192
193
  if (scope.length === 0)
193
194
  throw new Error("invalid scope: chain must not be empty");
194
195
  this.name = name;
@@ -560,7 +561,7 @@ export default class ConfigCommand {
560
561
  configCmd
561
562
  .command("list")
562
563
  .description("list all configured values as flat dotted keys")
563
- .action((_opts, cmd) => {
564
+ .action(async (_opts, cmd) => {
564
565
  const scope = parseScope(cmd.optsWithGlobals().scope);
565
566
  const cfg = new Config("config", configSchema, this.log, scope);
566
567
  cfg.read();
@@ -573,7 +574,7 @@ export default class ConfigCommand {
573
574
  const val = isScalar(e.value) ? e.value.value : e.value;
574
575
  table.push([e.key, String(val), Config.scopeLabel(e.scope)]);
575
576
  }
576
- process.stdout.write(`${table.toString()}\n`);
577
+ await writeStdout(`${table.toString()}\n`);
577
578
  });
578
579
  /* register CLI sub-command "ase config edit" */
579
580
  configCmd
@@ -612,7 +613,7 @@ export default class ConfigCommand {
612
613
  .command("get")
613
614
  .description("print the value at a dotted configuration key")
614
615
  .argument("<key>", "configuration key (dotted path)")
615
- .action((key, _opts, cmd) => {
616
+ .action(async (key, _opts, cmd) => {
616
617
  const scope = parseScope(cmd.optsWithGlobals().scope);
617
618
  const cfg = new Config("config", configSchema, this.log, scope);
618
619
  cfg.read();
@@ -621,7 +622,7 @@ export default class ConfigCommand {
621
622
  throw new Error(`key "${key}" is not set`);
622
623
  if (isMap(val))
623
624
  throw new Error(`key "${key}" is not a leaf key`);
624
- process.stdout.write(`${isScalar(val) ? val.value : val}\n`);
625
+ await writeStdout(`${isScalar(val) ? val.value : val}\n`);
625
626
  });
626
627
  /* register CLI sub-command "ase config set" */
627
628
  configCmd
package/dst/ase-hook.js CHANGED
@@ -69,6 +69,13 @@ const toolSpecs = {
69
69
  approvalEvent: "PermissionRequest"
70
70
  }
71
71
  };
72
+ /* schema (and derived type) of the tool invocation input fields
73
+ inspected by the tool-approval decision logic */
74
+ const toolInputSchema = v.object({
75
+ command: v.optional(v.string()),
76
+ skill: v.optional(v.string()),
77
+ file_path: v.optional(v.string())
78
+ });
72
79
  /* CLI command "ase hook" */
73
80
  export default class HookCommand {
74
81
  log;
@@ -242,16 +249,15 @@ export default class HookCommand {
242
249
  const projectId = path.basename(projectDir);
243
250
  /* determine user id */
244
251
  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;
252
+ /* helper function: determine a setting from the configuration,
253
+ falling back to an environment variable and a default */
254
+ const setting = (key, envVar, dflt) => {
255
+ const val = cfg.get(key);
256
+ return typeof val === "string" ? val : (process.env[envVar] ?? dflt);
257
+ };
258
+ /* determine agent persona style and project boxing transparency */
259
+ const persona = setting("agent.persona", "ASE_PERSONA_STYLE", "engineer");
260
+ const boxing = setting("project.boxing", "ASE_PROJECT_BOXING", "white");
255
261
  /* determine headless mode */
256
262
  const headless = (process.env.ASE_HEADLESS ?? "false") === "true" ? "true" : "false";
257
263
  /* provide ASE information to Anthropic Claude Code CLI shell commands
@@ -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))
package/dst/ase-kv.js CHANGED
@@ -72,6 +72,36 @@ export class KV {
72
72
  for (const [k, v] of snap)
73
73
  KV.store.set(k, v);
74
74
  }
75
+ /* execute a single key/value command and return its result text */
76
+ static execute(c) {
77
+ if (c.command === "clear")
78
+ return `OK: removed ${KV.clear(c.prefix)} key(s)`;
79
+ else if (c.command === "set") {
80
+ if (c.key === undefined)
81
+ throw new Error("missing `key`");
82
+ if (c.val === undefined)
83
+ throw new Error("missing `val`");
84
+ KV.set(c.key, c.val);
85
+ return `OK: stored key "${c.key}"`;
86
+ }
87
+ else if (c.command === "get") {
88
+ if (c.key === undefined)
89
+ throw new Error("missing `key`");
90
+ if (!KV.has(c.key))
91
+ return "";
92
+ const val = KV.get(c.key);
93
+ return val === undefined ? "" : JSON.stringify(val);
94
+ }
95
+ else if (c.command === "delete") {
96
+ if (c.key === undefined)
97
+ throw new Error("missing `key`");
98
+ return KV.delete(c.key) ?
99
+ `OK: removed key "${c.key}"` :
100
+ `WARNING: no key "${c.key}" to remove`;
101
+ }
102
+ else
103
+ throw new Error(`invalid command "${String(c.command)}"`);
104
+ }
75
105
  }
76
106
  /* MCP registration entry point for in-memory key/value tools */
77
107
  export class KVMCP {
@@ -87,10 +117,7 @@ export class KVMCP {
87
117
  }
88
118
  }, async (args) => {
89
119
  try {
90
- if (!KV.has(args.key))
91
- return { content: [{ type: "text", text: "" }] };
92
- const val = KV.get(args.key);
93
- const text = val === undefined ? "" : JSON.stringify(val);
120
+ const text = KV.execute({ command: "get", key: args.key });
94
121
  return { content: [{ type: "text", text }] };
95
122
  }
96
123
  catch (err) {
@@ -112,8 +139,8 @@ export class KVMCP {
112
139
  }
113
140
  }, async (args) => {
114
141
  try {
115
- KV.set(args.key, args.val);
116
- return { content: [{ type: "text", text: `OK: stored key "${args.key}"` }] };
142
+ const text = KV.execute({ command: "set", key: args.key, val: args.val });
143
+ return { content: [{ type: "text", text }] };
117
144
  }
118
145
  catch (err) {
119
146
  const message = err instanceof Error ? err.message : String(err);
@@ -133,8 +160,8 @@ export class KVMCP {
133
160
  }
134
161
  }, async (args) => {
135
162
  try {
136
- const n = KV.clear(args.prefix);
137
- return { content: [{ type: "text", text: `OK: removed ${n} key(s)` }] };
163
+ const text = KV.execute({ command: "clear", prefix: args.prefix });
164
+ return { content: [{ type: "text", text }] };
138
165
  }
139
166
  catch (err) {
140
167
  const message = err instanceof Error ? err.message : String(err);
@@ -152,11 +179,8 @@ export class KVMCP {
152
179
  }
153
180
  }, async (args) => {
154
181
  try {
155
- const removed = KV.delete(args.key);
156
- const msg = removed ?
157
- `OK: removed key "${args.key}"` :
158
- `WARNING: no key "${args.key}" to remove`;
159
- return { content: [{ type: "text", text: msg }] };
182
+ const text = KV.execute({ command: "delete", key: args.key });
183
+ return { content: [{ type: "text", text }] };
160
184
  }
161
185
  catch (err) {
162
186
  const message = err instanceof Error ? err.message : String(err);
@@ -199,36 +223,7 @@ export class KVMCP {
199
223
  const snapshot = tx ? KV.snapshot() : null;
200
224
  for (const c of args.commands) {
201
225
  try {
202
- if (c.command === "clear") {
203
- const n = KV.clear(c.prefix);
204
- results.push(`OK: removed ${n} key(s)`);
205
- }
206
- else if (c.command === "set") {
207
- if (c.key === undefined)
208
- throw new Error("missing `key`");
209
- if (c.val === undefined)
210
- throw new Error("missing `val`");
211
- KV.set(c.key, c.val);
212
- results.push(`OK: stored key "${c.key}"`);
213
- }
214
- else if (c.command === "get") {
215
- if (c.key === undefined)
216
- throw new Error("missing `key`");
217
- if (!KV.has(c.key))
218
- results.push("");
219
- else {
220
- const val = KV.get(c.key);
221
- results.push(val === undefined ? "" : JSON.stringify(val));
222
- }
223
- }
224
- else if (c.command === "delete") {
225
- if (c.key === undefined)
226
- throw new Error("missing `key`");
227
- const removed = KV.delete(c.key);
228
- results.push(removed ?
229
- `OK: removed key "${c.key}"` :
230
- `WARNING: no key "${c.key}" to remove`);
231
- }
226
+ results.push(KV.execute(c));
232
227
  }
233
228
  catch (err) {
234
229
  const message = err instanceof Error ? err.message : String(err);
package/dst/ase-log.js CHANGED
@@ -24,11 +24,7 @@ export default class Log {
24
24
  this._logFile = _logFile;
25
25
  }
26
26
  async init() {
27
- /* log messages */
28
- const idx = levels.findIndex((l) => l.name === this._logLevel);
29
- if (idx === -1)
30
- throw new RangeError(`invalid log level "${this._logLevel}" (expected one of: ${levels.map((l) => l.name).join(", ")})`);
31
- this.logLevelIdx = idx;
27
+ this.logLevel(this._logLevel);
32
28
  if (this._logFile !== "-")
33
29
  this.stream = this.openStream(this._logFile);
34
30
  }
@@ -73,4 +69,16 @@ export default class Log {
73
69
  this.stream.write(line);
74
70
  }
75
71
  }
72
+ /* close the log file stream, flushing all pending writes */
73
+ async close() {
74
+ if (this.stream === null)
75
+ return;
76
+ const stream = this.stream;
77
+ this.stream = null;
78
+ await new Promise((resolve) => {
79
+ stream.end(() => {
80
+ resolve();
81
+ });
82
+ });
83
+ }
76
84
  }
@@ -69,6 +69,16 @@ export class Markdown {
69
69
  n++;
70
70
  return n;
71
71
  }
72
+ /* test whether the newline at offset pos starts a paragraph break,
73
+ i.e. whether it is followed by a blank line or the end of the text */
74
+ static isParagraphBreak(text, pos) {
75
+ let n = pos + 1;
76
+ while (n < text.length && (text[n] === " " || text[n] === "\t"))
77
+ n++;
78
+ if (text[n] === "\r")
79
+ n++;
80
+ return n >= text.length || text[n] === "\n";
81
+ }
72
82
  /* PASS 1 of rewrite(): rewrite inline code spans that carry
73
83
  backslash-escaped backticks (`\``) into CommonMark-correct spans */
74
84
  static rewriteEscapedSpans(text) {
@@ -118,7 +128,7 @@ export class Markdown {
118
128
  k += runLen;
119
129
  continue;
120
130
  }
121
- if (c === "\n" && /^[ \t]*\r?(?:\n|$)/.test(text.slice(k + 1)))
131
+ if (c === "\n" && Markdown.isParagraphBreak(text, k))
122
132
  /* a code span never crosses a paragraph break */
123
133
  break;
124
134
  inner += c;
@@ -185,7 +195,7 @@ export class Markdown {
185
195
  closes = true;
186
196
  p += r;
187
197
  }
188
- else if (text[p] === "\n" && /^[ \t]*\r?(?:\n|$)/.test(text.slice(p + 1)))
198
+ else if (text[p] === "\n" && Markdown.isParagraphBreak(text, p))
189
199
  /* a code span never crosses a paragraph break */
190
200
  break;
191
201
  else
package/dst/ase-mcp.js CHANGED
@@ -94,8 +94,11 @@ export default class MCPCommand {
94
94
  return;
95
95
  triggerReconnect("http connection lost");
96
96
  };
97
+ /* activate the connection and flush buffered messages */
97
98
  await next.start();
98
99
  client = next;
100
+ for (const msg of pending.splice(0, pending.length))
101
+ sendToClient(msg);
99
102
  };
100
103
  /* reconnect loop: restart service if needed, then reconnect client */
101
104
  const reconnect = async (attempt = 0) => {
@@ -132,12 +135,26 @@ export default class MCPCommand {
132
135
  this.log.write("warning", `mcp: ${reason} — reconnecting`);
133
136
  reconnect(0).catch(() => { });
134
137
  };
135
- /* wire stdio server */
136
- server.onmessage = (msg) => {
137
- client?.send(msg).catch((err) => {
138
+ /* bounded buffer for messages arriving while no HTTP client is connected */
139
+ const MAX_PENDING = 100;
140
+ const pending = [];
141
+ /* forward a message to the HTTP client, buffering it while the client
142
+ is disconnected so requests survive a reconnect window */
143
+ const sendToClient = (msg) => {
144
+ if (client === null) {
145
+ if (pending.length >= MAX_PENDING) {
146
+ this.log.write("warning", "mcp: pending queue overflow, dropping oldest message");
147
+ pending.shift();
148
+ }
149
+ pending.push(msg);
150
+ return;
151
+ }
152
+ client.send(msg).catch((err) => {
138
153
  this.log.write("error", `mcp: http send: ${this.asError(err).message}`);
139
154
  });
140
155
  };
156
+ /* wire stdio server */
157
+ server.onmessage = sendToClient;
141
158
  server.onerror = (err) => {
142
159
  this.log.write("error", `mcp: stdio: ${err.message}`);
143
160
  };
@@ -20,9 +20,7 @@ export class Persona {
20
20
  if (val === undefined)
21
21
  return "engineer";
22
22
  const style = String(isScalar(val) ? val.value : val);
23
- if (!Persona.styles.includes(style))
24
- return "engineer";
25
- return style;
23
+ return Persona.styles.find((s) => s === style) ?? "engineer";
26
24
  }
27
25
  /* set the persona style on the strongest scope of an optional session */
28
26
  static set(log, style, session) {
@@ -477,6 +477,16 @@ export default class ServiceCommand {
477
477
  }
478
478
  throw lastErr;
479
479
  }
480
+ /* build the "POST /command" request options for a command token */
481
+ commandRequest(cmd, timeoutMs) {
482
+ return {
483
+ method: "POST",
484
+ headers: { "Content-Type": "application/json" },
485
+ body: { command: cmd },
486
+ signal: AbortSignal.timeout(timeoutMs),
487
+ ignoreResponseError: true
488
+ };
489
+ }
480
490
  /* status flow: report whether the service is running */
481
491
  async doStatus() {
482
492
  const ctx = this.loadContext();
@@ -486,13 +496,7 @@ export default class ServiceCommand {
486
496
  }
487
497
  const match = await probe(ctx.port, ctx.projectId);
488
498
  if (match === true) {
489
- const r = await ofetch.raw(`http://${HOST}:${ctx.port}/command`, {
490
- method: "POST",
491
- headers: { "Content-Type": "application/json" },
492
- body: { command: "status" },
493
- signal: AbortSignal.timeout(2000),
494
- ignoreResponseError: true
495
- });
499
+ const r = await ofetch.raw(`http://${HOST}:${ctx.port}/command`, this.commandRequest("status", 2000));
496
500
  const d = r._data;
497
501
  const uptimeMs = typeof d?.uptimeMs === "number" ? d.uptimeMs : 0;
498
502
  const uptime = prettyMs(uptimeMs, { verbose: true });
@@ -509,30 +513,16 @@ export default class ServiceCommand {
509
513
  /* send command: POST /command with the arbitrary cmd token */
510
514
  async doSend(cmd) {
511
515
  let ctx = this.loadContext();
512
- if (ctx.port === null) {
513
- await this.doStart();
514
- ctx = this.loadContext();
515
- if (ctx.port === null)
516
- throw new Error("service not running (no port configured after auto-start)");
517
- }
518
- let match = await probe(ctx.port, ctx.projectId);
519
- if (match !== true) {
516
+ if (ctx.port === null || await probe(ctx.port, ctx.projectId) !== true) {
517
+ /* auto-start the service once, then re-check */
520
518
  await this.doStart();
521
519
  ctx = this.loadContext();
522
520
  if (ctx.port === null)
523
521
  throw new Error("service not running (no port configured after auto-start)");
524
- match = await probe(ctx.port, ctx.projectId);
525
- if (match !== true)
522
+ if (await probe(ctx.port, ctx.projectId) !== true)
526
523
  throw new Error(`service not responding on port ${ctx.port} after auto-start`);
527
524
  }
528
- const r = await ofetch.raw(`http://${HOST}:${ctx.port}/command`, {
529
- method: "POST",
530
- headers: { "Content-Type": "application/json" },
531
- body: { command: cmd },
532
- signal: AbortSignal.timeout(5000),
533
- ignoreResponseError: true,
534
- responseType: "text"
535
- });
525
+ const r = await ofetch.raw(`http://${HOST}:${ctx.port}/command`, { ...this.commandRequest(cmd, 5000), responseType: "text" });
536
526
  const body = typeof r._data === "string" ? r._data : JSON.stringify(r._data);
537
527
  process.stdout.write(body);
538
528
  if (!body.endsWith("\n"))
package/dst/ase-setup.js CHANGED
@@ -63,27 +63,18 @@ export default class SetupCommand {
63
63
  path.join(prefix, "bin"),
64
64
  path.join(prefix, "lib", "node_modules")
65
65
  ];
66
+ const accessible = (dir, mode) => fs.access(dir, mode).then(() => true, () => false);
66
67
  for (const dir of candidates) {
67
- try {
68
- await fs.access(dir, fs.constants.W_OK);
69
- }
70
- catch {
71
- /* directory exists but not writable, or does not exist
72
- inside a non-writable parent: require sudo */
73
- try {
74
- await fs.access(dir, fs.constants.F_OK);
75
- return true;
76
- }
77
- catch {
78
- /* directory does not exist: check parent writability */
79
- try {
80
- await fs.access(path.dirname(dir), fs.constants.W_OK);
81
- }
82
- catch {
83
- return true;
84
- }
85
- }
86
- }
68
+ /* a writable directory needs no elevation at all */
69
+ if (await accessible(dir, fs.constants.W_OK))
70
+ continue;
71
+ /* the directory exists, but is not writable: require sudo */
72
+ if (await accessible(dir, fs.constants.F_OK))
73
+ return true;
74
+ /* the directory does not exist: require sudo unless it can
75
+ be created inside a writable parent directory */
76
+ if (!(await accessible(path.dirname(dir), fs.constants.W_OK)))
77
+ return true;
87
78
  }
88
79
  return false;
89
80
  }
@@ -110,25 +101,7 @@ export default class SetupCommand {
110
101
  for (let i = 0; i < retries; i++) {
111
102
  const final = (i === retries - 1);
112
103
  try {
113
- if (quiet) {
114
- const result = await execa(cmd, args, { stdio: "ignore", cwd, reject: false });
115
- if (typeof result.exitCode === "number" && result.exitCode !== 0) {
116
- if (!final) {
117
- this.log.write("info", `setup: attempt ${i + 1}/${retries} failed for "${cmd} ${args.join(" ")}" ` +
118
- `(exit code: ${result.exitCode}): retrying...`);
119
- await new Promise((resolve) => setTimeout(resolve, 1000));
120
- continue;
121
- }
122
- if (ignoreError !== undefined) {
123
- this.log.write("info", `setup: ${ignoreError} (skipped)`);
124
- return;
125
- }
126
- this.log.write("error", `setup: command failed: exit code: ${result.exitCode}`);
127
- throw new Error(`command "${cmd} ${args.join(" ")}" failed (exit code: ${result.exitCode})`);
128
- }
129
- return;
130
- }
131
- await execa(cmd, args, { stdio: "pipe", cwd });
104
+ await execa(cmd, args, { stdio: quiet ? "ignore" : "pipe", cwd });
132
105
  return;
133
106
  }
134
107
  catch (err) {
@@ -166,6 +166,11 @@ const probeMemory = () => {
166
166
  return { used: 0, total: 0 };
167
167
  }
168
168
  };
169
+ /* memoize a zero-argument function, computing its value at most once on first use */
170
+ const memoize = (fn) => {
171
+ let cache = null;
172
+ return () => (cache ??= { value: fn() }).value;
173
+ };
169
174
  /* command-line handling */
170
175
  export default class StatuslineCommand {
171
176
  log;
@@ -263,16 +268,8 @@ export default class StatuslineCommand {
263
268
  /* lazy memoized probes for cross-renderer values: each is computed at most
264
269
  once per run and only when first requested by a renderer (or by the
265
270
  post-loop tmux publish for the Config cascade) */
266
- let sessCache = null;
267
- const getSession = () => {
268
- if (sessCache === null)
269
- sessCache = data.session_id ?? "unknown";
270
- return sessCache;
271
- };
272
- let cfgCache = null;
273
- const getCfg = () => {
274
- if (cfgCache !== null)
275
- return cfgCache;
271
+ const getSession = memoize(() => data.session_id ?? "unknown");
272
+ const getCfg = memoize(() => {
276
273
  let taskId = process.env.ASE_TASK_ID ?? "";
277
274
  let persona = process.env.ASE_PERSONA_STYLE ?? "";
278
275
  try {
@@ -288,21 +285,10 @@ export default class StatuslineCommand {
288
285
  catch (_e) {
289
286
  /* cascade unavailable; keep env-var fallbacks */
290
287
  }
291
- cfgCache = { taskId, persona };
292
- return cfgCache;
293
- };
294
- let gitCache = null;
295
- const getGit = () => {
296
- if (gitCache === null)
297
- gitCache = probeGit(data.workspace?.current_dir ?? "");
298
- return gitCache;
299
- };
300
- let memCache = null;
301
- const getMem = () => {
302
- if (memCache === null)
303
- memCache = probeMemory();
304
- return memCache;
305
- };
288
+ return { taskId, persona };
289
+ });
290
+ const getGit = memoize(() => probeGit(data.workspace?.current_dir ?? ""));
291
+ const getMem = memoize(() => probeMemory());
306
292
  /* identifier to renderer map: each callback fetches its own information
307
293
  directly from data (or via the lazy helpers above for shared values) */
308
294
  const renderers = {
@@ -432,11 +418,11 @@ export default class StatuslineCommand {
432
418
  },
433
419
  /* ==== VERSIONS ==== */
434
420
  V: () => {
435
- const ccVersion = data.version ?? "";
421
+ const toolVersion = data.version ?? "";
436
422
  const aseVersion = pkg.version ?? "";
437
423
  let version = "";
438
- if (ccVersion !== "")
439
- version += `claude/${ccVersion}`;
424
+ if (toolVersion !== "")
425
+ version += `${tool}/${toolVersion}`;
440
426
  if (aseVersion !== "")
441
427
  version += `${version !== "" ? " " : ""}ase/${aseVersion}`;
442
428
  emit(`${prefix("⎈", "version")}${c.bold(version)}`);
package/dst/ase-task.js CHANGED
@@ -71,6 +71,8 @@ export class Task {
71
71
  };
72
72
  const basedir = (read("project.artifact.task.basedir") || ".ase/task")
73
73
  .replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
74
+ if (basedir.split("/").includes(".."))
75
+ throw new Error(`task: configured "basedir" "${basedir}" must not escape the project root`);
74
76
  const files = read("project.artifact.task.files") || "*.md";
75
77
  return { basedir, files };
76
78
  }
@@ -89,33 +91,18 @@ export class Task {
89
91
  }
90
92
  /* resolve the on-disk path for a given task id; as a side effect,
91
93
  eagerly migrate any legacy <basedir>/<id>/plan.md files to the
92
- current <basedir>/TASK-<id>.md layout on first access (guarded by
93
- a cheap check, so it is a no-op once the store is migrated) */
94
+ current <basedir>/TASK-<id>.md layout ("migrateAll" is a cheap
95
+ no-op once the store is migrated) */
94
96
  static path(log, id) {
95
97
  Task.validateId(id);
96
98
  Task.enforceFiles(log, id);
97
- if (Task.needsMigration(log))
98
- Task.migrateAll(log);
99
+ Task.migrateAll(log);
99
100
  return path.join(Task.baseDir(log), `TASK-${id}.md`);
100
101
  }
101
- /* cheaply check whether any legacy <basedir>/<id>/plan.md file still
102
- exists in the task base directory and thus needs migration */
103
- static needsMigration(log) {
104
- const dir = Task.baseDir(log);
105
- if (!fs.existsSync(dir))
106
- return false;
107
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
108
- if (!entry.isDirectory() || !/^[A-Za-z0-9_-]+$/.test(entry.name))
109
- continue;
110
- if (fs.existsSync(path.join(dir, entry.name, "plan.md")))
111
- return true;
112
- }
113
- return false;
114
- }
115
102
  /* migrate all legacy <basedir>/<id>/plan.md task files to the current
116
103
  <basedir>/TASK-<id>.md layout; an existing TASK-<id>.md is never
117
- overwritten; the now-empty <id>/ directory is removed afterwards;
118
- returns the list of migrated task ids in lexicographic order */
104
+ overwritten; the legacy <id>/ directory is removed only if it is
105
+ empty afterwards; returns the migrated task ids in lexicographic order */
119
106
  static migrateAll(log) {
120
107
  const dir = Task.baseDir(log);
121
108
  if (!fs.existsSync(dir))
@@ -134,7 +121,12 @@ export class Task {
134
121
  continue;
135
122
  }
136
123
  fs.renameSync(oldFile, newFile);
137
- fs.rmSync(path.join(dir, id), { recursive: true, force: true });
124
+ /* drop the legacy directory, but only if it is really empty */
125
+ const legacyDir = path.dirname(oldFile);
126
+ if (fs.readdirSync(legacyDir).length === 0)
127
+ fs.rmdirSync(legacyDir);
128
+ else
129
+ log.write("warning", `task: keeping non-empty legacy directory "${legacyDir}"`);
138
130
  migrated.push(id);
139
131
  }
140
132
  migrated.sort((a, b) => a.localeCompare(b));
@@ -187,8 +179,7 @@ export class Task {
187
179
  /* scan the task base directory (after eager migration) for
188
180
  "TASK-<id>.md" files matching the configured "files" miniglob */
189
181
  static scan(log) {
190
- if (Task.needsMigration(log))
191
- Task.migrateAll(log);
182
+ Task.migrateAll(log);
192
183
  const { basedir, files } = Task.spec(log);
193
184
  const dir = path.join(Task.projectRoot(), basedir);
194
185
  if (!fs.existsSync(dir))