@rse/ase 0.0.26 → 0.0.28

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.
package/dst/ase-setup.js CHANGED
@@ -55,15 +55,16 @@ export default class SetupCommand {
55
55
  this.log.write("info", `setup: ${ignoreError} (skipped)`);
56
56
  return;
57
57
  }
58
- const exitCode = typeof err?.exitCode === "number" ? err.exitCode : -1;
58
+ const e = err;
59
+ const exitCode = typeof e.exitCode === "number" ? e.exitCode : -1;
59
60
  this.log.write("error", `setup: command failed: exit code: ${exitCode}`);
60
- if (typeof err?.stdout === "string" && err.stdout.length > 0) {
61
+ if (typeof e.stdout === "string" && e.stdout.length > 0) {
61
62
  this.log.write("error", "setup: command failed: stdout:");
62
- process.stdout.write(err.stdout);
63
+ process.stdout.write(e.stdout);
63
64
  }
64
- if (typeof err?.stderr === "string" && err.stderr.length > 0) {
65
+ if (typeof e.stderr === "string" && e.stderr.length > 0) {
65
66
  this.log.write("error", "setup: command failed: stderr:");
66
- process.stderr.write(err.stderr);
67
+ process.stderr.write(e.stderr);
67
68
  }
68
69
  throw err;
69
70
  }
@@ -37,15 +37,25 @@ const readStdin = async () => {
37
37
  /* detect terminal column width via /dev/tty (stdout is a pipe under Claude Code) */
38
38
  const detectTermWidth = () => {
39
39
  let width = 0;
40
+ let tty = null;
40
41
  try {
41
- const tty = fs.openSync("/dev/tty", "r");
42
+ tty = fs.openSync("/dev/tty", "r");
42
43
  const out = execFileSync("tput", ["cols"], { stdio: [tty, "pipe", "ignore"] });
43
- fs.closeSync(tty);
44
- width = parseInt(out.toString("utf8").trim()) || 0;
44
+ width = Number.parseInt(out.toString("utf8").trim(), 10) || 0;
45
45
  }
46
46
  catch (_e) {
47
47
  /* no controlling terminal */
48
48
  }
49
+ finally {
50
+ if (tty !== null) {
51
+ try {
52
+ fs.closeSync(tty);
53
+ }
54
+ catch (_e) {
55
+ /* best-effort */
56
+ }
57
+ }
58
+ }
49
59
  return width;
50
60
  };
51
61
  /* format a token count as a compact human-readable string (e.g. 334k, 104.9M) */
package/dst/ase-task.js CHANGED
@@ -4,92 +4,138 @@
4
4
  ** Licensed under GPL 3.0 <https://spdx.org/licenses/GPL-3.0-only>
5
5
  */
6
6
  import path from "node:path";
7
- import os from "node:os";
8
7
  import fs from "node:fs";
9
8
  import { execaSync } from "execa";
10
- /* validate the task id to keep it safe as a filename component */
11
- const validateId = (id) => {
12
- if (typeof id !== "string" || id.length === 0)
13
- throw new Error("task: id must be a non-empty string");
14
- if (!/^[A-Za-z0-9-]+$/.test(id))
15
- throw new Error("task: id must match [A-Za-z0-9-]+");
16
- };
17
- /* resolve the on-disk path for a given task id */
18
- const taskPath = (id) => {
19
- validateId(id);
20
- return path.join(os.homedir(), ".ase", "task", id, "plan.md");
21
- };
22
- /* load a task; returns empty string if no task exists */
23
- export const taskLoad = (id) => {
24
- const file = taskPath(id);
25
- if (!fs.existsSync(file))
26
- return "";
27
- return fs.readFileSync(file, "utf8");
28
- };
29
- /* save a task as UTF-8 text under the given id; the task's home
30
- directory ~/.ase/task/<id>/ is owned by ASE and removed in full
31
- by taskDelete, so callers must not place foreign files there */
32
- export const taskSave = (id, text) => {
33
- if (typeof text !== "string")
34
- throw new Error("task: text must be a string");
35
- const file = taskPath(id);
36
- fs.mkdirSync(path.dirname(file), { recursive: true });
37
- fs.writeFileSync(file, text, "utf8");
38
- };
39
- /* delete a task by id; removes the entire task home directory
40
- ~/.ase/task/<id>/ (owned by ASE); returns true if a task existed */
41
- export const taskDelete = (id) => {
42
- const file = taskPath(id);
43
- if (!fs.existsSync(file))
44
- return false;
45
- fs.rmSync(path.dirname(file), { recursive: true, force: true });
46
- return true;
47
- };
48
- /* list all persisted task ids in lexicographic order */
49
- export const taskList = () => {
50
- const dir = path.join(os.homedir(), ".ase", "task");
51
- if (!fs.existsSync(dir))
52
- return [];
53
- const ids = [];
54
- for (const entry of fs.readdirSync(dir)) {
55
- if (!/^[A-Za-z0-9-]+$/.test(entry))
56
- continue;
57
- const file = path.join(dir, entry, "plan.md");
9
+ import { DateTime } from "luxon";
10
+ import { isScalar } from "yaml";
11
+ import { z } from "zod";
12
+ import { Config, configSchema, parseScope } from "./ase-config.js";
13
+ /* reusable functionality: persisted task plans under
14
+ <project>/.ase/task/<id>/plan.md */
15
+ export class Task {
16
+ /* validate the task id to keep it safe as a filename component */
17
+ static validateId(id) {
18
+ if (typeof id !== "string" || id.length === 0)
19
+ throw new Error("task: id must be a non-empty string");
20
+ if (!/^[A-Za-z0-9-]+$/.test(id))
21
+ throw new Error("task: id must match [A-Za-z0-9-]+");
22
+ }
23
+ /* determine the project root (Git top-level if inside a Git
24
+ working tree, otherwise the current working directory) */
25
+ static projectRoot() {
26
+ try {
27
+ const result = execaSync("git", ["rev-parse", "--show-toplevel"], { stderr: "ignore" });
28
+ const top = result.stdout.trim();
29
+ if (top !== "")
30
+ return top;
31
+ }
32
+ catch {
33
+ /* not inside a Git working tree */
34
+ }
35
+ return process.cwd();
36
+ }
37
+ /* resolve the on-disk base directory for task storage */
38
+ static baseDir() {
39
+ return path.join(Task.projectRoot(), ".ase", "task");
40
+ }
41
+ /* resolve the on-disk path for a given task id */
42
+ static path(id) {
43
+ Task.validateId(id);
44
+ return path.join(Task.baseDir(), id, "plan.md");
45
+ }
46
+ /* load a task; returns empty string if no task exists */
47
+ static load(id) {
48
+ const file = Task.path(id);
58
49
  if (!fs.existsSync(file))
59
- continue;
60
- const st = fs.statSync(file);
61
- if (!st.isFile())
62
- continue;
63
- ids.push(entry);
50
+ return "";
51
+ return fs.readFileSync(file, "utf8");
64
52
  }
65
- ids.sort();
66
- return ids;
67
- };
68
- /* purge tasks whose modification time is older than the given cutoff in
69
- milliseconds; returns the list of removed task ids */
70
- export const taskPurge = (maxAgeMs) => {
71
- const dir = path.join(os.homedir(), ".ase", "task");
72
- if (!fs.existsSync(dir))
73
- return [];
74
- const cutoff = Date.now() - maxAgeMs;
75
- const removed = [];
76
- for (const entry of fs.readdirSync(dir)) {
77
- if (!/^[A-Za-z0-9-]+$/.test(entry))
78
- continue;
79
- const sub = path.join(dir, entry);
80
- const file = path.join(sub, "plan.md");
53
+ /* save a task as UTF-8 text under the given id; the task's home
54
+ directory <project>/.ase/task/<id>/ is owned by ASE and removed
55
+ in full by Task.delete, so callers must not place foreign files there */
56
+ static save(id, text) {
57
+ if (typeof text !== "string")
58
+ throw new Error("task: text must be a string");
59
+ const file = Task.path(id);
60
+ fs.mkdirSync(path.dirname(file), { recursive: true });
61
+ fs.writeFileSync(file, text, "utf8");
62
+ }
63
+ /* delete a task by id; removes the entire task home directory
64
+ <project>/.ase/task/<id>/ (owned by ASE); returns true if a task existed */
65
+ static delete(id) {
66
+ const file = Task.path(id);
81
67
  if (!fs.existsSync(file))
82
- continue;
83
- const st = fs.statSync(file);
84
- if (!st.isFile())
85
- continue;
86
- if (st.mtimeMs < cutoff) {
87
- fs.rmSync(sub, { recursive: true, force: true });
88
- removed.push(entry);
68
+ return false;
69
+ fs.rmSync(path.dirname(file), { recursive: true, force: true });
70
+ return true;
71
+ }
72
+ /* list all persisted tasks in lexicographic id order; if verbose is true,
73
+ each entry's `mtime` is set to the `plan.md` modification time formatted
74
+ as "YYYY-MM-DD HH:MM", otherwise it is left undefined */
75
+ static list(verbose = false) {
76
+ const dir = Task.baseDir();
77
+ if (!fs.existsSync(dir))
78
+ return [];
79
+ const out = [];
80
+ for (const entry of fs.readdirSync(dir)) {
81
+ if (!/^[A-Za-z0-9-]+$/.test(entry))
82
+ continue;
83
+ const file = path.join(dir, entry, "plan.md");
84
+ if (!fs.existsSync(file))
85
+ continue;
86
+ const st = fs.statSync(file);
87
+ if (!st.isFile())
88
+ continue;
89
+ const mtime = verbose ? DateTime.fromJSDate(st.mtime).toFormat("yyyy-LL-dd HH:mm") : undefined;
90
+ out.push({ id: entry, mtime });
89
91
  }
92
+ out.sort((a, b) => a.id.localeCompare(b.id));
93
+ return out;
90
94
  }
91
- return removed;
92
- };
95
+ /* purge tasks whose modification time is older than the given cutoff in
96
+ milliseconds; returns the list of removed task ids */
97
+ static purge(maxAgeMs) {
98
+ const dir = Task.baseDir();
99
+ if (!fs.existsSync(dir))
100
+ return [];
101
+ const cutoff = Date.now() - maxAgeMs;
102
+ const removed = [];
103
+ for (const entry of fs.readdirSync(dir)) {
104
+ if (!/^[A-Za-z0-9-]+$/.test(entry))
105
+ continue;
106
+ const sub = path.join(dir, entry);
107
+ const file = path.join(sub, "plan.md");
108
+ if (!fs.existsSync(file))
109
+ continue;
110
+ const st = fs.statSync(file);
111
+ if (!st.isFile())
112
+ continue;
113
+ if (st.mtimeMs < cutoff) {
114
+ fs.rmSync(sub, { recursive: true, force: true });
115
+ removed.push(entry);
116
+ }
117
+ }
118
+ return removed;
119
+ }
120
+ /* get the active task id for a given session, or empty string if none */
121
+ static getId(log, session) {
122
+ const scope = parseScope(`session:${session}`);
123
+ const cfg = new Config("config", configSchema, log, scope);
124
+ cfg.read();
125
+ const val = cfg.get("agent.task");
126
+ if (val === undefined)
127
+ return "";
128
+ return String(isScalar(val) ? val.value : val);
129
+ }
130
+ /* set the active task id for a given session */
131
+ static setId(log, session, id) {
132
+ const scope = parseScope(`session:${session}`);
133
+ const cfg = new Config("config", configSchema, log, scope);
134
+ cfg.read();
135
+ cfg.set("agent.task", id);
136
+ cfg.write();
137
+ }
138
+ }
93
139
  /* read all of stdin as a UTF-8 string */
94
140
  const readStdin = () => {
95
141
  return new Promise((resolve, reject) => {
@@ -110,7 +156,7 @@ export default class TaskCommand {
110
156
  /* register CLI top-level command "ase task" */
111
157
  const task = program
112
158
  .command("task")
113
- .description("Manage persisted tasks under ~/.ase/task/<id>/plan.md")
159
+ .description("Manage persisted tasks under <project>/.ase/task/<id>/plan.md")
114
160
  .action(() => {
115
161
  task.outputHelp();
116
162
  process.exit(1);
@@ -119,10 +165,15 @@ export default class TaskCommand {
119
165
  task
120
166
  .command("list")
121
167
  .description("List all persisted task ids, one per line")
122
- .action(() => {
123
- const ids = taskList();
124
- for (const id of ids)
125
- process.stdout.write(`${id}\n`);
168
+ .option("-v, --verbose", "also show the plan.md modification time as (YYYY-MM-DD HH:MM)")
169
+ .action((opts) => {
170
+ const items = Task.list(opts.verbose ?? false);
171
+ for (const item of items) {
172
+ if (opts.verbose)
173
+ process.stdout.write(`${item.id}\t(${item.mtime})\n`);
174
+ else
175
+ process.stdout.write(`${item.id}\n`);
176
+ }
126
177
  process.exit(0);
127
178
  });
128
179
  /* register CLI sub-command "ase task load" */
@@ -131,7 +182,7 @@ export default class TaskCommand {
131
182
  .description("Load a task by id and write it to stdout")
132
183
  .argument("<id>", "Task identifier")
133
184
  .action((id) => {
134
- const text = taskLoad(id);
185
+ const text = Task.load(id);
135
186
  process.stdout.write(text);
136
187
  process.exit(0);
137
188
  });
@@ -141,7 +192,7 @@ export default class TaskCommand {
141
192
  .description("Edit a task by id with $EDITOR")
142
193
  .argument("<id>", "Task identifier")
143
194
  .action((id) => {
144
- const file = taskPath(id);
195
+ const file = Task.path(id);
145
196
  const editor = process.env.EDITOR ?? process.env.VISUAL ?? "vi";
146
197
  fs.mkdirSync(path.dirname(file), { recursive: true });
147
198
  if (!fs.existsSync(file))
@@ -157,7 +208,7 @@ export default class TaskCommand {
157
208
  .argument("<id>", "Task identifier")
158
209
  .action(async (id) => {
159
210
  const text = await readStdin();
160
- taskSave(id, text);
211
+ Task.save(id, text);
161
212
  this.log.write("info", `task: saved "${id}"`);
162
213
  process.exit(0);
163
214
  });
@@ -167,7 +218,7 @@ export default class TaskCommand {
167
218
  .description("Delete a task by id")
168
219
  .argument("<id>", "Task identifier")
169
220
  .action((id) => {
170
- const removed = taskDelete(id);
221
+ const removed = Task.delete(id);
171
222
  if (removed)
172
223
  this.log.write("info", `task: removed "${id}"`);
173
224
  else
@@ -177,13 +228,24 @@ export default class TaskCommand {
177
228
  /* register CLI sub-command "ase task purge" */
178
229
  task
179
230
  .command("purge")
180
- .description("Remove all tasks with a modification time older than <days> (default: 31)")
181
- .argument("[<days>]", "Maximum task age in days", "31")
182
- .action((days) => {
183
- const n = Number.parseInt(days, 10);
184
- if (!Number.isFinite(n) || n < 0)
185
- throw new Error("task: <days> must be a non-negative integer");
186
- const removed = taskPurge(n * 24 * 60 * 60 * 1000);
231
+ .description("Remove all tasks with a modification time older than <age> (default: 31d); " +
232
+ "<age> is <number><unit> with unit h (hour), d (day), m (month), y (year)")
233
+ .argument("[<age>]", "Maximum task age as <number><unit>", "31d")
234
+ .action((age) => {
235
+ const m = /^(\d+)([hdmy])$/.exec(age);
236
+ if (m === null)
237
+ throw new Error("task: <age> must match <number><unit> with unit h, d, m, or y");
238
+ const n = Number.parseInt(m[1], 10);
239
+ const unit = m[2];
240
+ const hour = 60 * 60 * 1000;
241
+ const day = 24 * hour;
242
+ const month = 30 * day;
243
+ const year = 365 * day;
244
+ const factor = unit === "h" ? hour :
245
+ unit === "d" ? day :
246
+ unit === "m" ? month :
247
+ year;
248
+ const removed = Task.purge(n * factor);
187
249
  if (removed.length === 0)
188
250
  this.log.write("info", "task: no tasks to purge");
189
251
  else
@@ -193,3 +255,160 @@ export default class TaskCommand {
193
255
  });
194
256
  }
195
257
  }
258
+ /* MCP registration entry point for task tools */
259
+ export class TaskMCP {
260
+ log;
261
+ constructor(log) {
262
+ this.log = log;
263
+ }
264
+ register(mcp) {
265
+ mcp.registerTool("task_list", {
266
+ title: "ASE task list",
267
+ description: "List all persisted tasks. " +
268
+ "Returns a `tasks` array (in lexicographic `id` order) where each item has the " +
269
+ "task `id`. If `verbose` is `true`, each item additionally has an `mtime` field " +
270
+ "(last modification time of the task's `plan.md`, formatted as `YYYY-MM-DD HH:MM`). " +
271
+ "Returns an empty array if no tasks exist.",
272
+ inputSchema: {
273
+ verbose: z.boolean().optional()
274
+ .describe("if true, also include the `mtime` field per task (default: false)")
275
+ },
276
+ outputSchema: {
277
+ tasks: z.array(z.object({
278
+ id: z.string().describe("task identifier"),
279
+ mtime: z.string().optional()
280
+ .describe("plan.md modification time (`YYYY-MM-DD HH:MM`); only present if `verbose` is true")
281
+ })).describe("all persisted tasks in lexicographic id order")
282
+ }
283
+ }, async (args) => {
284
+ try {
285
+ const verbose = args.verbose ?? false;
286
+ const items = Task.list(verbose);
287
+ const tasks = verbose ?
288
+ items.map((item) => ({ id: item.id, mtime: item.mtime })) :
289
+ items.map((item) => ({ id: item.id }));
290
+ const result = { tasks };
291
+ return {
292
+ structuredContent: result,
293
+ content: [{ type: "text", text: JSON.stringify(result) }]
294
+ };
295
+ }
296
+ catch (err) {
297
+ const message = err instanceof Error ? err.message : String(err);
298
+ return {
299
+ isError: true,
300
+ content: [{ type: "text", text: `task_list: ERROR: ${message}` }]
301
+ };
302
+ }
303
+ });
304
+ mcp.registerTool("task_load", {
305
+ title: "ASE task load",
306
+ description: "Load a previously persisted task by `id`. " +
307
+ "Returns the task as `text`; returns an empty string if no task exists for the `id`.",
308
+ inputSchema: {
309
+ id: z.string()
310
+ .describe("task identifier (allowed characters: A-Z, a-z, 0-9, '-')")
311
+ }
312
+ }, async (args) => {
313
+ try {
314
+ const text = Task.load(args.id);
315
+ return {
316
+ content: [{ type: "text", text }]
317
+ };
318
+ }
319
+ catch (err) {
320
+ const message = err instanceof Error ? err.message : String(err);
321
+ return {
322
+ isError: true,
323
+ content: [{ type: "text", text: `task_load: ERROR: ${message}` }]
324
+ };
325
+ }
326
+ });
327
+ mcp.registerTool("task_save", {
328
+ title: "ASE task save",
329
+ description: "Persist a task as `text` under `id`. " +
330
+ "Overwrites any existing task for the same `id`.",
331
+ inputSchema: {
332
+ id: z.string()
333
+ .describe("task identifier (allowed characters: A-Z, a-z, 0-9, '-')"),
334
+ text: z.string()
335
+ .describe("text content of the task")
336
+ }
337
+ }, async (args) => {
338
+ try {
339
+ Task.save(args.id, args.text);
340
+ return {
341
+ content: [{ type: "text", text: `task_save: OK: saved task "${args.id}"` }]
342
+ };
343
+ }
344
+ catch (err) {
345
+ const message = err instanceof Error ? err.message : String(err);
346
+ return {
347
+ isError: true,
348
+ content: [{ type: "text", text: `task_save: ERROR: ${message}` }]
349
+ };
350
+ }
351
+ });
352
+ mcp.registerTool("task_delete", {
353
+ title: "ASE task delete",
354
+ description: "Delete a previously persisted task by `id`. " +
355
+ "Returns a status `text` indicating whether a task existed and was removed.",
356
+ inputSchema: {
357
+ id: z.string()
358
+ .describe("task identifier (allowed characters: A-Z, a-z, 0-9, '-')")
359
+ }
360
+ }, async (args) => {
361
+ try {
362
+ const removed = Task.delete(args.id);
363
+ const msg = removed ?
364
+ `task_delete: OK: removed task "${args.id}"` :
365
+ `task_delete: WARNING: no task "${args.id}" to remove`;
366
+ return {
367
+ content: [{ type: "text", text: msg }]
368
+ };
369
+ }
370
+ catch (err) {
371
+ const message = err instanceof Error ? err.message : String(err);
372
+ return {
373
+ isError: true,
374
+ content: [{ type: "text", text: `task_delete: ERROR: ${message}` }]
375
+ };
376
+ }
377
+ });
378
+ mcp.registerTool("task_id", {
379
+ title: "ASE task id get/set",
380
+ description: "Get or set the active ASE task `id` for a given `session`. " +
381
+ "If `id` is provided, it sets the task id in the given `session`, " +
382
+ "otherwise it returns the current task `id` of the `session`.",
383
+ inputSchema: {
384
+ id: z.string().optional()
385
+ .describe("task identifier to set (allowed characters: A-Z, a-z, 0-9, '-'); " +
386
+ "if omitted, the current task id is returned"),
387
+ session: z.string()
388
+ .describe("session identifier (allowed characters: A-Z, a-z, 0-9, '-')")
389
+ }
390
+ }, async (args) => {
391
+ try {
392
+ if (args.id !== undefined) {
393
+ Task.setId(this.log, args.session, args.id);
394
+ const msg = `task_id: OK: set agent.task to "${args.id}" ` +
395
+ `for session "${args.session}"`;
396
+ return {
397
+ content: [{ type: "text", text: msg }]
398
+ };
399
+ }
400
+ const text = Task.getId(this.log, args.session);
401
+ return {
402
+ content: [{ type: "text", text }]
403
+ };
404
+ }
405
+ catch (err) {
406
+ const message = err instanceof Error ? err.message : String(err);
407
+ return {
408
+ isError: true,
409
+ content: [{ type: "text", text: `task_id: ERROR: ${message}` }]
410
+ };
411
+ }
412
+ });
413
+ }
414
+ }
package/dst/ase.js CHANGED
@@ -42,14 +42,14 @@ const main = async () => {
42
42
  log.logFile(opts.logFile);
43
43
  });
44
44
  /* register top-level commands */
45
+ new SetupCommand(log).register(program);
45
46
  new ConfigCommand(log).register(program);
46
- new ServiceCommand(log).register(program);
47
47
  new MCPCommand(log).register(program);
48
+ new ServiceCommand(log).register(program);
48
49
  new HookCommand(log).register(program);
49
- new DiagramCommand(log).register(program);
50
- new SetupCommand(log).register(program);
51
50
  new StatuslineCommand(log).register(program);
52
51
  new TaskCommand(log).register(program);
52
+ new DiagramCommand(log).register(program);
53
53
  /* parse program arguments */
54
54
  await program.parseAsync(process.argv);
55
55
  /* gracefully terminate */
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "homepage": "http://github.com/rse/ase",
7
7
  "repository": { "url": "git+https://github.com/rse/ase.git", "type": "git" },
8
8
  "bugs": { "url": "http://github.com/rse/ase/issues" },
9
- "version": "0.0.26",
9
+ "version": "0.0.28",
10
10
  "license": "GPL-3.0-only",
11
11
  "author": {
12
12
  "name": "Dr. Ralf S. Engelschall",
@@ -30,7 +30,7 @@
30
30
  "nodemon": "3.1.14",
31
31
  "shx": "0.4.0",
32
32
 
33
- "@types/node": "25.7.0",
33
+ "@types/node": "25.8.0",
34
34
  "@types/luxon": "3.7.1",
35
35
  "@types/which": "3.0.4",
36
36
  "@types/update-notifier": "6.0.8"
@@ -42,7 +42,7 @@
42
42
  "execa": "9.6.1",
43
43
  "mkdirp": "3.0.1",
44
44
  "@hapi/hapi": "21.4.9",
45
- "axios": "1.16.0",
45
+ "axios": "1.16.1",
46
46
  "beautiful-mermaid": "1.1.3",
47
47
  "cli-table3": "0.6.5",
48
48
  "chalk": "5.6.2",