@rse/ase 0.0.27 → 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-task.js CHANGED
@@ -4,96 +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
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.read();
135
+ cfg.set("agent.task", id);
136
+ cfg.write();
137
+ }
138
+ }
97
139
  /* read all of stdin as a UTF-8 string */
98
140
  const readStdin = () => {
99
141
  return new Promise((resolve, reject) => {
@@ -114,7 +156,7 @@ export default class TaskCommand {
114
156
  /* register CLI top-level command "ase task" */
115
157
  const task = program
116
158
  .command("task")
117
- .description("Manage persisted tasks under ~/.ase/task/<id>/plan.md")
159
+ .description("Manage persisted tasks under <project>/.ase/task/<id>/plan.md")
118
160
  .action(() => {
119
161
  task.outputHelp();
120
162
  process.exit(1);
@@ -125,7 +167,7 @@ export default class TaskCommand {
125
167
  .description("List all persisted task ids, one per line")
126
168
  .option("-v, --verbose", "also show the plan.md modification time as (YYYY-MM-DD HH:MM)")
127
169
  .action((opts) => {
128
- const items = taskList(opts.verbose ?? false);
170
+ const items = Task.list(opts.verbose ?? false);
129
171
  for (const item of items) {
130
172
  if (opts.verbose)
131
173
  process.stdout.write(`${item.id}\t(${item.mtime})\n`);
@@ -140,7 +182,7 @@ export default class TaskCommand {
140
182
  .description("Load a task by id and write it to stdout")
141
183
  .argument("<id>", "Task identifier")
142
184
  .action((id) => {
143
- const text = taskLoad(id);
185
+ const text = Task.load(id);
144
186
  process.stdout.write(text);
145
187
  process.exit(0);
146
188
  });
@@ -150,7 +192,7 @@ export default class TaskCommand {
150
192
  .description("Edit a task by id with $EDITOR")
151
193
  .argument("<id>", "Task identifier")
152
194
  .action((id) => {
153
- const file = taskPath(id);
195
+ const file = Task.path(id);
154
196
  const editor = process.env.EDITOR ?? process.env.VISUAL ?? "vi";
155
197
  fs.mkdirSync(path.dirname(file), { recursive: true });
156
198
  if (!fs.existsSync(file))
@@ -166,7 +208,7 @@ export default class TaskCommand {
166
208
  .argument("<id>", "Task identifier")
167
209
  .action(async (id) => {
168
210
  const text = await readStdin();
169
- taskSave(id, text);
211
+ Task.save(id, text);
170
212
  this.log.write("info", `task: saved "${id}"`);
171
213
  process.exit(0);
172
214
  });
@@ -176,7 +218,7 @@ export default class TaskCommand {
176
218
  .description("Delete a task by id")
177
219
  .argument("<id>", "Task identifier")
178
220
  .action((id) => {
179
- const removed = taskDelete(id);
221
+ const removed = Task.delete(id);
180
222
  if (removed)
181
223
  this.log.write("info", `task: removed "${id}"`);
182
224
  else
@@ -186,13 +228,24 @@ export default class TaskCommand {
186
228
  /* register CLI sub-command "ase task purge" */
187
229
  task
188
230
  .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);
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);
196
249
  if (removed.length === 0)
197
250
  this.log.write("info", "task: no tasks to purge");
198
251
  else
@@ -202,3 +255,160 @@ export default class TaskCommand {
202
255
  });
203
256
  }
204
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.27",
9
+ "version": "0.0.28",
10
10
  "license": "GPL-3.0-only",
11
11
  "author": {
12
12
  "name": "Dr. Ralf S. Engelschall",