@rse/ase 0.0.27 → 0.0.29

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-task.js CHANGED
@@ -4,96 +4,140 @@
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
9
  import { DateTime } from "luxon";
11
- /* validate the task id to keep it safe as a filename component */
12
- const validateId = (id) => {
13
- if (typeof id !== "string" || id.length === 0)
14
- throw new Error("task: id must be a non-empty string");
15
- if (!/^[A-Za-z0-9-]+$/.test(id))
16
- throw new Error("task: id must match [A-Za-z0-9-]+");
17
- };
18
- /* resolve the on-disk path for a given task id */
19
- const taskPath = (id) => {
20
- validateId(id);
21
- return path.join(os.homedir(), ".ase", "task", id, "plan.md");
22
- };
23
- /* load a task; returns empty string if no task exists */
24
- export const taskLoad = (id) => {
25
- const file = taskPath(id);
26
- if (!fs.existsSync(file))
27
- return "";
28
- return fs.readFileSync(file, "utf8");
29
- };
30
- /* save a task as UTF-8 text under the given id; the task's home
31
- directory ~/.ase/task/<id>/ is owned by ASE and removed in full
32
- by taskDelete, so callers must not place foreign files there */
33
- export const taskSave = (id, text) => {
34
- if (typeof text !== "string")
35
- throw new Error("task: text must be a string");
36
- const file = taskPath(id);
37
- fs.mkdirSync(path.dirname(file), { recursive: true });
38
- fs.writeFileSync(file, text, "utf8");
39
- };
40
- /* delete a task by id; removes the entire task home directory
41
- ~/.ase/task/<id>/ (owned by ASE); returns true if a task existed */
42
- export const taskDelete = (id) => {
43
- const file = taskPath(id);
44
- if (!fs.existsSync(file))
45
- return false;
46
- fs.rmSync(path.dirname(file), { recursive: true, force: true });
47
- return true;
48
- };
49
- /* list all persisted tasks in lexicographic id order; if verbose is true,
50
- each entry's `mtime` is set to the `plan.md` modification time formatted
51
- as "YYYY-MM-DD HH:MM", otherwise it is left undefined */
52
- export const taskList = (verbose = false) => {
53
- const dir = path.join(os.homedir(), ".ase", "task");
54
- if (!fs.existsSync(dir))
55
- return [];
56
- const out = [];
57
- for (const entry of fs.readdirSync(dir)) {
58
- if (!/^[A-Za-z0-9-]+$/.test(entry))
59
- continue;
60
- const file = path.join(dir, entry, "plan.md");
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);
61
49
  if (!fs.existsSync(file))
62
- continue;
63
- const st = fs.statSync(file);
64
- if (!st.isFile())
65
- continue;
66
- const mtime = verbose ? DateTime.fromJSDate(st.mtime).toFormat("yyyy-LL-dd HH:mm") : undefined;
67
- out.push({ id: entry, mtime });
50
+ return "";
51
+ return fs.readFileSync(file, "utf8");
68
52
  }
69
- out.sort((a, b) => a.id.localeCompare(b.id));
70
- return out;
71
- };
72
- /* purge tasks whose modification time is older than the given cutoff in
73
- milliseconds; returns the list of removed task ids */
74
- export const taskPurge = (maxAgeMs) => {
75
- const dir = path.join(os.homedir(), ".ase", "task");
76
- if (!fs.existsSync(dir))
77
- return [];
78
- const cutoff = Date.now() - maxAgeMs;
79
- const removed = [];
80
- for (const entry of fs.readdirSync(dir)) {
81
- if (!/^[A-Za-z0-9-]+$/.test(entry))
82
- continue;
83
- const sub = path.join(dir, entry);
84
- 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);
85
67
  if (!fs.existsSync(file))
86
- continue;
87
- const st = fs.statSync(file);
88
- if (!st.isFile())
89
- continue;
90
- if (st.mtimeMs < cutoff) {
91
- fs.rmSync(sub, { recursive: true, force: true });
92
- 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 });
93
91
  }
92
+ out.sort((a, b) => a.id.localeCompare(b.id));
93
+ return out;
94
94
  }
95
- return removed;
96
- };
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.lock(() => {
135
+ cfg.read();
136
+ cfg.set("agent.task", id);
137
+ cfg.write();
138
+ });
139
+ }
140
+ }
97
141
  /* read all of stdin as a UTF-8 string */
98
142
  const readStdin = () => {
99
143
  return new Promise((resolve, reject) => {
@@ -114,7 +158,7 @@ export default class TaskCommand {
114
158
  /* register CLI top-level command "ase task" */
115
159
  const task = program
116
160
  .command("task")
117
- .description("Manage persisted tasks under ~/.ase/task/<id>/plan.md")
161
+ .description("Manage persisted tasks under <project>/.ase/task/<id>/plan.md")
118
162
  .action(() => {
119
163
  task.outputHelp();
120
164
  process.exit(1);
@@ -125,7 +169,7 @@ export default class TaskCommand {
125
169
  .description("List all persisted task ids, one per line")
126
170
  .option("-v, --verbose", "also show the plan.md modification time as (YYYY-MM-DD HH:MM)")
127
171
  .action((opts) => {
128
- const items = taskList(opts.verbose ?? false);
172
+ const items = Task.list(opts.verbose ?? false);
129
173
  for (const item of items) {
130
174
  if (opts.verbose)
131
175
  process.stdout.write(`${item.id}\t(${item.mtime})\n`);
@@ -140,7 +184,7 @@ export default class TaskCommand {
140
184
  .description("Load a task by id and write it to stdout")
141
185
  .argument("<id>", "Task identifier")
142
186
  .action((id) => {
143
- const text = taskLoad(id);
187
+ const text = Task.load(id);
144
188
  process.stdout.write(text);
145
189
  process.exit(0);
146
190
  });
@@ -150,7 +194,7 @@ export default class TaskCommand {
150
194
  .description("Edit a task by id with $EDITOR")
151
195
  .argument("<id>", "Task identifier")
152
196
  .action((id) => {
153
- const file = taskPath(id);
197
+ const file = Task.path(id);
154
198
  const editor = process.env.EDITOR ?? process.env.VISUAL ?? "vi";
155
199
  fs.mkdirSync(path.dirname(file), { recursive: true });
156
200
  if (!fs.existsSync(file))
@@ -166,7 +210,7 @@ export default class TaskCommand {
166
210
  .argument("<id>", "Task identifier")
167
211
  .action(async (id) => {
168
212
  const text = await readStdin();
169
- taskSave(id, text);
213
+ Task.save(id, text);
170
214
  this.log.write("info", `task: saved "${id}"`);
171
215
  process.exit(0);
172
216
  });
@@ -176,7 +220,7 @@ export default class TaskCommand {
176
220
  .description("Delete a task by id")
177
221
  .argument("<id>", "Task identifier")
178
222
  .action((id) => {
179
- const removed = taskDelete(id);
223
+ const removed = Task.delete(id);
180
224
  if (removed)
181
225
  this.log.write("info", `task: removed "${id}"`);
182
226
  else
@@ -186,13 +230,24 @@ export default class TaskCommand {
186
230
  /* register CLI sub-command "ase task purge" */
187
231
  task
188
232
  .command("purge")
189
- .description("Remove all tasks with a modification time older than <days> (default: 31)")
190
- .argument("[<days>]", "Maximum task age in days", "31")
191
- .action((days) => {
192
- const n = Number.parseInt(days, 10);
193
- if (!Number.isFinite(n) || n < 0)
194
- throw new Error("task: <days> must be a non-negative integer");
195
- const removed = taskPurge(n * 24 * 60 * 60 * 1000);
233
+ .description("Remove all tasks with a modification time older than <age> (default: 31d); " +
234
+ "<age> is <number><unit> with unit h (hour), d (day), m (month), y (year)")
235
+ .argument("[<age>]", "Maximum task age as <number><unit>", "31d")
236
+ .action((age) => {
237
+ const m = /^(\d+)([hdmy])$/.exec(age);
238
+ if (m === null)
239
+ throw new Error("task: <age> must match <number><unit> with unit h, d, m, or y");
240
+ const n = Number.parseInt(m[1], 10);
241
+ const unit = m[2];
242
+ const hour = 60 * 60 * 1000;
243
+ const day = 24 * hour;
244
+ const month = 30 * day;
245
+ const year = 365 * day;
246
+ const factor = unit === "h" ? hour :
247
+ unit === "d" ? day :
248
+ unit === "m" ? month :
249
+ year;
250
+ const removed = Task.purge(n * factor);
196
251
  if (removed.length === 0)
197
252
  this.log.write("info", "task: no tasks to purge");
198
253
  else
@@ -202,3 +257,160 @@ export default class TaskCommand {
202
257
  });
203
258
  }
204
259
  }
260
+ /* MCP registration entry point for task tools */
261
+ export class TaskMCP {
262
+ log;
263
+ constructor(log) {
264
+ this.log = log;
265
+ }
266
+ register(mcp) {
267
+ mcp.registerTool("task_list", {
268
+ title: "ASE task list",
269
+ description: "List all persisted tasks. " +
270
+ "Returns a `tasks` array (in lexicographic `id` order) where each item has the " +
271
+ "task `id`. If `verbose` is `true`, each item additionally has an `mtime` field " +
272
+ "(last modification time of the task's `plan.md`, formatted as `YYYY-MM-DD HH:MM`). " +
273
+ "Returns an empty array if no tasks exist.",
274
+ inputSchema: {
275
+ verbose: z.boolean().optional()
276
+ .describe("if true, also include the `mtime` field per task (default: false)")
277
+ },
278
+ outputSchema: {
279
+ tasks: z.array(z.object({
280
+ id: z.string().describe("task identifier"),
281
+ mtime: z.string().optional()
282
+ .describe("plan.md modification time (`YYYY-MM-DD HH:MM`); only present if `verbose` is true")
283
+ })).describe("all persisted tasks in lexicographic id order")
284
+ }
285
+ }, async (args) => {
286
+ try {
287
+ const verbose = args.verbose ?? false;
288
+ const items = Task.list(verbose);
289
+ const tasks = verbose ?
290
+ items.map((item) => ({ id: item.id, mtime: item.mtime })) :
291
+ items.map((item) => ({ id: item.id }));
292
+ const result = { tasks };
293
+ return {
294
+ structuredContent: result,
295
+ content: [{ type: "text", text: JSON.stringify(result) }]
296
+ };
297
+ }
298
+ catch (err) {
299
+ const message = err instanceof Error ? err.message : String(err);
300
+ return {
301
+ isError: true,
302
+ content: [{ type: "text", text: `task_list: ERROR: ${message}` }]
303
+ };
304
+ }
305
+ });
306
+ mcp.registerTool("task_load", {
307
+ title: "ASE task load",
308
+ description: "Load a previously persisted task by `id`. " +
309
+ "Returns the task as `text`; returns an empty string if no task exists for the `id`.",
310
+ inputSchema: {
311
+ id: z.string()
312
+ .describe("task identifier (allowed characters: A-Z, a-z, 0-9, '-')")
313
+ }
314
+ }, async (args) => {
315
+ try {
316
+ const text = Task.load(args.id);
317
+ return {
318
+ content: [{ type: "text", text }]
319
+ };
320
+ }
321
+ catch (err) {
322
+ const message = err instanceof Error ? err.message : String(err);
323
+ return {
324
+ isError: true,
325
+ content: [{ type: "text", text: `task_load: ERROR: ${message}` }]
326
+ };
327
+ }
328
+ });
329
+ mcp.registerTool("task_save", {
330
+ title: "ASE task save",
331
+ description: "Persist a task as `text` under `id`. " +
332
+ "Overwrites any existing task for the same `id`.",
333
+ inputSchema: {
334
+ id: z.string()
335
+ .describe("task identifier (allowed characters: A-Z, a-z, 0-9, '-')"),
336
+ text: z.string()
337
+ .describe("text content of the task")
338
+ }
339
+ }, async (args) => {
340
+ try {
341
+ Task.save(args.id, args.text);
342
+ return {
343
+ content: [{ type: "text", text: `task_save: OK: saved task "${args.id}"` }]
344
+ };
345
+ }
346
+ catch (err) {
347
+ const message = err instanceof Error ? err.message : String(err);
348
+ return {
349
+ isError: true,
350
+ content: [{ type: "text", text: `task_save: ERROR: ${message}` }]
351
+ };
352
+ }
353
+ });
354
+ mcp.registerTool("task_delete", {
355
+ title: "ASE task delete",
356
+ description: "Delete a previously persisted task by `id`. " +
357
+ "Returns a status `text` indicating whether a task existed and was removed.",
358
+ inputSchema: {
359
+ id: z.string()
360
+ .describe("task identifier (allowed characters: A-Z, a-z, 0-9, '-')")
361
+ }
362
+ }, async (args) => {
363
+ try {
364
+ const removed = Task.delete(args.id);
365
+ const msg = removed ?
366
+ `task_delete: OK: removed task "${args.id}"` :
367
+ `task_delete: WARNING: no task "${args.id}" to remove`;
368
+ return {
369
+ content: [{ type: "text", text: msg }]
370
+ };
371
+ }
372
+ catch (err) {
373
+ const message = err instanceof Error ? err.message : String(err);
374
+ return {
375
+ isError: true,
376
+ content: [{ type: "text", text: `task_delete: ERROR: ${message}` }]
377
+ };
378
+ }
379
+ });
380
+ mcp.registerTool("task_id", {
381
+ title: "ASE task id get/set",
382
+ description: "Get or set the active ASE task `id` for a given `session`. " +
383
+ "If `id` is provided, it sets the task id in the given `session`, " +
384
+ "otherwise it returns the current task `id` of the `session`.",
385
+ inputSchema: {
386
+ id: z.string().optional()
387
+ .describe("task identifier to set (allowed characters: A-Z, a-z, 0-9, '-'); " +
388
+ "if omitted, the current task id is returned"),
389
+ session: z.string()
390
+ .describe("session identifier (allowed characters: A-Z, a-z, 0-9, '-')")
391
+ }
392
+ }, async (args) => {
393
+ try {
394
+ if (args.id !== undefined) {
395
+ Task.setId(this.log, args.session, args.id);
396
+ const msg = `task_id: OK: set agent.task to "${args.id}" ` +
397
+ `for session "${args.session}"`;
398
+ return {
399
+ content: [{ type: "text", text: msg }]
400
+ };
401
+ }
402
+ const text = Task.getId(this.log, args.session);
403
+ return {
404
+ content: [{ type: "text", text }]
405
+ };
406
+ }
407
+ catch (err) {
408
+ const message = err instanceof Error ? err.message : String(err);
409
+ return {
410
+ isError: true,
411
+ content: [{ type: "text", text: `task_id: ERROR: ${message}` }]
412
+ };
413
+ }
414
+ });
415
+ }
416
+ }
@@ -0,0 +1,36 @@
1
+ /*
2
+ ** Agentic Software Engineering (ASE)
3
+ ** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under GPL 3.0 <https://spdx.org/licenses/GPL-3.0-only>
5
+ */
6
+ import { z } from "zod";
7
+ import { DateTime } from "luxon";
8
+ /* MCP registration entry point for timestamp tool */
9
+ export class TimestampMCP {
10
+ register(mcp) {
11
+ mcp.registerTool("timestamp", {
12
+ title: "ASE timestamp",
13
+ description: "Return the current local date/time formatted via a Luxon format string. " +
14
+ "Pass the Luxon format tokens as `format` (default: `yyyy-LL-dd HH:mm`). " +
15
+ "Returns the formatted timestamp as `text`.",
16
+ inputSchema: {
17
+ format: z.string().default("yyyy-LL-dd HH:mm")
18
+ .describe("Luxon format tokens (default: `yyyy-LL-dd HH:mm`)")
19
+ }
20
+ }, async (args) => {
21
+ try {
22
+ const text = DateTime.now().toFormat(args.format);
23
+ return {
24
+ content: [{ type: "text", text }]
25
+ };
26
+ }
27
+ catch (err) {
28
+ const message = err instanceof Error ? err.message : String(err);
29
+ return {
30
+ isError: true,
31
+ content: [{ type: "text", text: `timestamp: format failed: ${message}` }]
32
+ };
33
+ }
34
+ });
35
+ }
36
+ }
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.27",
9
+ "version": "0.0.29",
10
10
  "license": "GPL-3.0-only",
11
11
  "author": {
12
12
  "name": "Dr. Ralf S. Engelschall",
@@ -33,7 +33,10 @@
33
33
  "@types/node": "25.8.0",
34
34
  "@types/luxon": "3.7.1",
35
35
  "@types/which": "3.0.4",
36
- "@types/update-notifier": "6.0.8"
36
+ "@types/update-notifier": "6.0.8",
37
+ "@types/shell-quote": "1.7.5",
38
+ "@types/proper-lockfile": "4.1.4",
39
+ "@types/write-file-atomic": "4.0.3"
37
40
  },
38
41
  "dependencies": {
39
42
  "commander": "14.0.3",
@@ -51,7 +54,10 @@
51
54
  "@modelcontextprotocol/sdk": "1.29.0",
52
55
  "zod": "4.4.3",
53
56
  "which": "7.0.0",
54
- "update-notifier": "7.3.1"
57
+ "update-notifier": "7.3.1",
58
+ "shell-quote": "1.8.3",
59
+ "proper-lockfile": "4.1.2",
60
+ "write-file-atomic": "4.0.0"
55
61
  },
56
62
  "engines": {
57
63
  "npm": ">=10.0.0",