@rse/ase 0.9.46 → 0.9.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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;
@@ -166,6 +159,11 @@ const probeMemory = () => {
166
159
  return { used: 0, total: 0 };
167
160
  }
168
161
  };
162
+ /* memoize a zero-argument function, computing its value at most once on first use */
163
+ const memoize = (fn) => {
164
+ let cache = null;
165
+ return () => (cache ??= { value: fn() }).value;
166
+ };
169
167
  /* command-line handling */
170
168
  export default class StatuslineCommand {
171
169
  log;
@@ -213,8 +211,7 @@ export default class StatuslineCommand {
213
211
  }
214
212
  catch (err) {
215
213
  const message = err instanceof Error ? err.message : String(err);
216
- this.log.write("error", `statusline: invalid JSON on stdin: ${message}`);
217
- process.exit(1);
214
+ throw new Error(`statusline: invalid JSON on stdin: ${message}`);
218
215
  }
219
216
  /* normalize Copilot CLI's top-level "cwd" into the
220
217
  "workspace.current_dir" structure shared with Anthropic Claude Code CLI */
@@ -263,16 +260,8 @@ export default class StatuslineCommand {
263
260
  /* lazy memoized probes for cross-renderer values: each is computed at most
264
261
  once per run and only when first requested by a renderer (or by the
265
262
  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;
263
+ const getSession = memoize(() => data.session_id ?? "unknown");
264
+ const getCfg = memoize(() => {
276
265
  let taskId = process.env.ASE_TASK_ID ?? "";
277
266
  let persona = process.env.ASE_PERSONA_STYLE ?? "";
278
267
  try {
@@ -288,21 +277,10 @@ export default class StatuslineCommand {
288
277
  catch (_e) {
289
278
  /* cascade unavailable; keep env-var fallbacks */
290
279
  }
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
- };
280
+ return { taskId, persona };
281
+ });
282
+ const getGit = memoize(() => probeGit(data.workspace?.current_dir ?? ""));
283
+ const getMem = memoize(() => probeMemory());
306
284
  /* identifier to renderer map: each callback fetches its own information
307
285
  directly from data (or via the lazy helpers above for shared values) */
308
286
  const renderers = {
@@ -354,10 +332,18 @@ export default class StatuslineCommand {
354
332
  emit(`${prefix("◔", "context")}${bar} ${pct}%`);
355
333
  },
356
334
  C: () => {
357
- const pct = data.context_window?.used_percentage ?? 0;
358
- const tokensCur = (data.context_window?.total_input_tokens ?? 0) +
359
- (data.context_window?.total_output_tokens ?? 0);
360
- 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);
361
347
  if (tokensLim > 0)
362
348
  emit(`${prefix("◆", "tokens")}${c.bold(formatTokens(tokensCur) + "/" + formatTokens(tokensLim))}`);
363
349
  },
@@ -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)}`);
@@ -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) => {
@@ -71,8 +78,12 @@ export class Task {
71
78
  };
72
79
  const basedir = (read("project.artifact.task.basedir") || ".ase/task")
73
80
  .replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
81
+ if (basedir.split("/").includes(".."))
82
+ throw new Error(`task: configured "basedir" "${basedir}" must not escape the project root`);
74
83
  const files = read("project.artifact.task.files") || "*.md";
75
- return { basedir, files };
84
+ const result = { basedir, files };
85
+ Task.specCache.set(root, result);
86
+ return result;
76
87
  }
77
88
  /* resolve the on-disk base directory for task storage */
78
89
  static baseDir(log) {
@@ -89,33 +100,18 @@ export class Task {
89
100
  }
90
101
  /* resolve the on-disk path for a given task id; as a side effect,
91
102
  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) */
103
+ current <basedir>/TASK-<id>.md layout ("migrateAll" is a cheap
104
+ no-op once the store is migrated) */
94
105
  static path(log, id) {
95
106
  Task.validateId(id);
96
107
  Task.enforceFiles(log, id);
97
- if (Task.needsMigration(log))
98
- Task.migrateAll(log);
108
+ Task.migrateAll(log);
99
109
  return path.join(Task.baseDir(log), `TASK-${id}.md`);
100
110
  }
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
111
  /* migrate all legacy <basedir>/<id>/plan.md task files to the current
116
112
  <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 */
113
+ overwritten; the legacy <id>/ directory is removed only if it is
114
+ empty afterwards; returns the migrated task ids in lexicographic order */
119
115
  static migrateAll(log) {
120
116
  const dir = Task.baseDir(log);
121
117
  if (!fs.existsSync(dir))
@@ -134,7 +130,12 @@ export class Task {
134
130
  continue;
135
131
  }
136
132
  fs.renameSync(oldFile, newFile);
137
- fs.rmSync(path.join(dir, id), { recursive: true, force: true });
133
+ /* drop the legacy directory, but only if it is really empty */
134
+ const legacyDir = path.dirname(oldFile);
135
+ if (fs.readdirSync(legacyDir).length === 0)
136
+ fs.rmdirSync(legacyDir);
137
+ else
138
+ log.write("warning", `task: keeping non-empty legacy directory "${legacyDir}"`);
138
139
  migrated.push(id);
139
140
  }
140
141
  migrated.sort((a, b) => a.localeCompare(b));
@@ -187,8 +188,7 @@ export class Task {
187
188
  /* scan the task base directory (after eager migration) for
188
189
  "TASK-<id>.md" files matching the configured "files" miniglob */
189
190
  static scan(log) {
190
- if (Task.needsMigration(log))
191
- Task.migrateAll(log);
191
+ Task.migrateAll(log);
192
192
  const { basedir, files } = Task.spec(log);
193
193
  const dir = path.join(Task.projectRoot(), basedir);
194
194
  if (!fs.existsSync(dir))
@@ -254,15 +254,6 @@ export class Task {
254
254
  });
255
255
  }
256
256
  }
257
- /* read all of stdin as a UTF-8 string */
258
- const readStdin = () => {
259
- return new Promise((resolve, reject) => {
260
- const chunks = [];
261
- process.stdin.on("data", (chunk) => chunks.push(chunk));
262
- process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
263
- process.stdin.on("error", (err) => reject(err));
264
- });
265
- };
266
257
  /* CLI command "ase task" */
267
258
  export default class TaskCommand {
268
259
  log;
package/dst/ase.js CHANGED
@@ -59,12 +59,16 @@ const main = async () => {
59
59
  /* parse program arguments */
60
60
  await program.parseAsync(process.argv);
61
61
  /* gracefully terminate */
62
- process.exit(0);
62
+ await log.close();
63
+ process.exit(process.exitCode ?? 0);
63
64
  };
64
- main().catch((err) => {
65
- if (err instanceof CommanderError)
65
+ main().catch(async (err) => {
66
+ if (err instanceof CommanderError) {
67
+ await log.close();
66
68
  process.exit(err.exitCode);
69
+ }
67
70
  const message = err instanceof Error ? err.message : String(err);
68
71
  log.write("error", message);
72
+ await log.close();
69
73
  process.exit(1);
70
74
  });
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.46",
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.46",
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.46",
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.46",
3
+ "version": "0.9.48",
4
4
  "description": "Agentic Software Engineering (ASE)",
5
5
  "keywords": [ "agentic", "software", "engineering" ],
6
6
  "homepage": "https://ase.tools",
@@ -11,12 +11,12 @@ lint
11
11
 
12
12
  # [plugin] build project
13
13
  build : lint
14
- ( for name in $(cd skills; ls -1 | grep -v ase-meta-intent | sort); do
14
+ ( for name in $(cd skills; ls -1 | grep -v ase-help-intent | sort); do
15
15
  echo "<skill name=\"$name\">"
16
16
  cat skills/$name/help.md | sed -e '/^## SEE ALSO/,$d'
17
17
  echo "</skill>"
18
18
  done
19
- ) >skills/ase-meta-intent/data.md
19
+ ) >skills/ase-help-intent/data.md
20
20
 
21
21
  # [plugin] remove all generated files
22
22
  clean
@@ -140,6 +140,15 @@ Skill Sequential Processing
140
140
  - *IMPORTANT*: For each <step/> you *MUST* use the `TaskUpdate` tool
141
141
  for updating its status *during* processing, once a <step/> finished.
142
142
 
143
+ - *IMPORTANT*: Whenever a skill *stops early* -- because a <step/>
144
+ instructed you to *STOP* processing (on an error, a cancelled
145
+ dialog, a short-circuit branch, or any other early exit) -- you
146
+ *MUST*, *before* emitting the final <template/>, use the `TaskUpdate`
147
+ tool to set the status of *every* still `pending` or `in_progress`
148
+ task of the current <flow/> to `deleted`, so that no task of a
149
+ finished skill remains open. Steps that were *skipped* because their
150
+ `condition` attribute was not met *MUST* be treated the same way.
151
+
143
152
  - *IMPORTANT*: You *MUST* *strictly sequentially* execute every <step/> in
144
153
  a <flow/>. You *MUST* not implicitly skip any <step/> during
145
154
  processing, except you were explicitly requested to do this or the
@@ -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.46",
9
+ "version": "0.9.48",
10
10
  "license": "Apache-2.0",
11
11
  "author": {
12
12
  "name": "Dr. Ralf S. Engelschall",
@@ -1,5 +1,5 @@
1
1
  ---
2
- name: ase-meta-intent
2
+ name: ase-help-intent
3
3
  argument-hint: "[--help|-h] <intent>"
4
4
  description: >
5
5
  Match a free-text intent against the accumulated help of all ASE
@@ -20,12 +20,12 @@ allowed-tools:
20
20
  @${CLAUDE_SKILL_DIR}/../../meta/ase-dialog.md
21
21
  @${CLAUDE_SKILL_DIR}/../../meta/ase-getopt.md
22
22
 
23
- <skill name="ase-meta-intent">
23
+ <skill name="ase-help-intent">
24
24
  Match an Intent to an ASE Command
25
25
  </skill>
26
26
 
27
27
  <expand name="getopt"
28
- arg1="ase-meta-intent"
28
+ arg1="ase-help-intent"
29
29
  arg2="">
30
30
  $ARGUMENTS
31
31
  </expand>
@@ -54,7 +54,7 @@ catalog you match <intent/> against:
54
54
  processing the entire current skill:
55
55
 
56
56
  <template>
57
- ⧉ **ASE**: ✪ skill: **ase-meta-intent**, ▶ ERROR: expected a `<intent>` argument
57
+ ⧉ **ASE**: ✪ skill: **ase-help-intent**, ▶ ERROR: expected a `<intent>` argument
58
58
  </template>
59
59
  </if>
60
60
 
@@ -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*:
@@ -1,19 +1,19 @@
1
1
 
2
2
  ## NAME
3
3
 
4
- `ase-meta-intent` - Match an Intent to an ASE Command
4
+ `ase-help-intent` - Match an Intent to an ASE Command
5
5
 
6
6
  ## SYNOPSIS
7
7
 
8
- `ase-meta-intent`
8
+ `ase-help-intent`
9
9
  [`--help`|`-h`]
10
10
  *intent*
11
11
 
12
12
  ## DESCRIPTION
13
13
 
14
- The `ase-meta-intent` skill matches a free-text *intent* against the
14
+ The `ase-help-intent` skill matches a free-text *intent* against the
15
15
  *accumulated help* of all ASE skills -- the concatenation of every
16
- skill's `help.md` file into `skills/ase-meta-intent/data.md`, built by
16
+ skill's `help.md` file into `skills/ase-help-intent/data.md`, built by
17
17
  `npm start build` in `plugin/` -- and generates the *single* best-fitting
18
18
  `/ase:ase-xxx-xxx` command that realizes the intent, complete with
19
19
  concrete option flags and positional arguments derived from the selected
@@ -42,15 +42,16 @@ entirely through the intent argument and the interactive dialog.
42
42
  Route an intent to the matching command and pick from the dialog:
43
43
 
44
44
  ```text
45
- ❯ /ase-meta-intent lint the TypeScript sources for high-severity issues only
45
+ ❯ /ase-help-intent lint the TypeScript sources for high-severity issues only
46
46
  ```
47
47
 
48
48
  Route a planning intent to the matching command:
49
49
 
50
50
  ```text
51
- ❯ /ase-meta-intent explain how the authentication module works
51
+ ❯ /ase-help-intent explain how the authentication module works
52
52
  ```
53
53
 
54
54
  ## SEE ALSO
55
55
 
56
- [`ase-meta-proximity`](../ase-meta-proximity/help.md), [`ase-meta-search`](../ase-meta-search/help.md), [`ase-code-craft`](../ase-code-craft/help.md).
56
+ [`ase-help-skill`](../ase-help-skill/help.md), [`ase-meta-proximity`](../ase-meta-proximity/help.md), [`ase-meta-search`](../ase-meta-search/help.md),
57
+ [`ase-code-craft`](../ase-code-craft/help.md).