@rse/ase 0.0.48 → 0.0.49

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-config.js CHANGED
@@ -336,10 +336,7 @@ export class Config {
336
336
  doc.deleteIn(segs);
337
337
  progressed = true;
338
338
  }
339
- else
340
- /* root-level issue cannot be deleted; skip it and process
341
- remaining issues so progressed is tracked correctly */
342
- continue;
339
+ /* root-level issues cannot be deleted; processing continues with the remaining issues */
343
340
  }
344
341
  if (!progressed)
345
342
  return;
@@ -278,9 +278,9 @@ export class DiagramMCP {
278
278
  "Use for visualizing " +
279
279
  "structure/layout/components/dependencies as a Flowchart, " +
280
280
  "control-flow/branching/concurrency as a Flowchart, " +
281
- "state-machine/states/transitions as an UML State Diagram, " +
282
- "data-flow/actors/messages/protocols as an UML Sequence Diagram, " +
283
- "data-structure/classes/methods as an UML Class Diagram, " +
281
+ "state-machine/states/transitions as a UML State Diagram, " +
282
+ "data-flow/actors/messages/protocols as a UML Sequence Diagram, " +
283
+ "data-structure/classes/methods as a UML Class Diagram, " +
284
284
  "data-model/entities/relationships as an ER Diagram, or " +
285
285
  "metrics/distributions/time-series as an XY-Chart. " +
286
286
  "Pass the Mermaid diagram specification as `diagram`. " +
package/dst/ase-getopt.js CHANGED
@@ -108,9 +108,9 @@ export class GetoptMCP {
108
108
  }
109
109
  ranges.push({ start, end: i });
110
110
  }
111
- const tail = cmd.args.length;
112
- if (tail > 0 && ranges.length >= tail) {
113
- const first = ranges[ranges.length - tail].start;
111
+ const consumed = argsVec.length - cmd.args.length;
112
+ if (cmd.args.length > 0 && consumed >= 0 && consumed < ranges.length) {
113
+ const first = ranges[consumed].start;
114
114
  argsVerbatim = argsRaw.slice(first);
115
115
  }
116
116
  }
package/dst/ase-hook.js CHANGED
@@ -8,6 +8,7 @@ import fs from "node:fs";
8
8
  import os from "node:os";
9
9
  import { execaSync } from "execa";
10
10
  import { quote } from "shell-quote";
11
+ import * as v from "valibot";
11
12
  import Version from "./ase-version.js";
12
13
  import { Config, configSchema, parseScope } from "./ase-config.js";
13
14
  const toolSpecs = {
@@ -36,10 +37,36 @@ export default class HookCommand {
36
37
  constructor(log) {
37
38
  this.log = log;
38
39
  }
40
+ /* validate a session id against the accepted character set */
41
+ isValidSessionId(id) {
42
+ return /^[A-Za-z0-9._-]+$/.test(id);
43
+ }
44
+ /* best-effort JSON parse with valibot schema validation: returns
45
+ an empty object on blank input, malformed JSON, or schema
46
+ mismatch, so callers can treat the result uniformly. Extra
47
+ properties in the data are tolerated; only the declared schema
48
+ entries are required to match. */
49
+ parseJSON(text, schema) {
50
+ const empty = {};
51
+ if (text.trim() === "")
52
+ return empty;
53
+ let raw;
54
+ try {
55
+ raw = JSON.parse(text);
56
+ }
57
+ catch (_e) {
58
+ /* best-effort: return empty object on malformed JSON */
59
+ return empty;
60
+ }
61
+ const result = v.safeParse(schema, raw);
62
+ if (!result.success)
63
+ return empty;
64
+ return result.output;
65
+ }
39
66
  /* recursively expand "@<path>" file references in a Markdown text,
40
67
  resolving paths relative to the directory of the containing file */
41
68
  expandReferences(text, baseDir, visited = new Set()) {
42
- return text.replace(/@([^\s]+)/g, (match, ref) => {
69
+ return text.replace(/@(\S+)/g, (match, ref) => {
43
70
  let resolved = ref;
44
71
  if (resolved.startsWith("~/"))
45
72
  resolved = path.join(process.env.HOME ?? "", resolved.slice(2));
@@ -71,10 +98,23 @@ export default class HookCommand {
71
98
  const filePkg = path.join(pluginRoot, ".claude-plugin", "plugin.json");
72
99
  const fileMd = path.join(pluginRoot, "meta", "ase-constitution.md");
73
100
  /* read external files */
74
- const pkg = fs.readFileSync(filePkg, "utf8");
75
- let md = fs.readFileSync(fileMd, "utf8");
101
+ let pkg;
102
+ let md;
103
+ try {
104
+ pkg = fs.readFileSync(filePkg, "utf8");
105
+ }
106
+ catch (_e) {
107
+ throw new Error(`failed to read plugin manifest: ${filePkg}`);
108
+ }
109
+ try {
110
+ md = fs.readFileSync(fileMd, "utf8");
111
+ }
112
+ catch (_e) {
113
+ throw new Error(`failed to read constitution file: ${fileMd}`);
114
+ }
76
115
  /* determine own version */
77
- const versionCurrentPlugin = JSON.parse(pkg).version ?? "";
116
+ const pkgObj = this.parseJSON(pkg, v.object({ version: v.optional(v.string()) }));
117
+ const versionCurrentPlugin = pkgObj.version ?? "";
78
118
  const versionCurrentTool = Version.current();
79
119
  const versionLatestTool = await Version.latest();
80
120
  /* sanity check situation */
@@ -90,11 +130,15 @@ export default class HookCommand {
90
130
  /* read session information (Claude Code uses snake_case fields,
91
131
  Copilot CLI uses camelCase fields) */
92
132
  const stdin = fs.readFileSync(0, "utf8");
93
- const input = stdin.trim() !== "" ? JSON.parse(stdin) : {};
133
+ const input = this.parseJSON(stdin, v.object({
134
+ session_id: v.optional(v.string()),
135
+ sessionId: v.optional(v.string()),
136
+ cwd: v.optional(v.string())
137
+ }));
94
138
  /* determine session id */
95
- const sessionId = input.session_id ?? input.sessionId ?? "";
139
+ const sessionId = this.pickSessionId(input);
96
140
  /* establish config context (session-scoped only if a valid sessionId is present) */
97
- const hasSession = /^[A-Za-z0-9._-]+$/.test(sessionId);
141
+ const hasSession = this.isValidSessionId(sessionId);
98
142
  const cfg = new Config("config", configSchema, this.log, hasSession ? parseScope(`session:${sessionId}`) : parseScope(undefined));
99
143
  cfg.lock(() => {
100
144
  cfg.read();
@@ -198,10 +242,8 @@ export default class HookCommand {
198
242
  this.writeAgentStatus("ready");
199
243
  /* safety net: clear any lingering "agent.skill" marker so a
200
244
  crashed or aborted skill loop does not leave information active */
201
- const stdin = fs.readFileSync(0, "utf8");
202
- const input = stdin.trim() !== "" ? JSON.parse(stdin) : {};
203
- const sessionId = input.session_id ?? input.sessionId ?? "";
204
- if (/^[A-Za-z0-9._-]+$/.test(sessionId)) {
245
+ const sessionId = this.readSessionIdFromStdin();
246
+ if (this.isValidSessionId(sessionId)) {
205
247
  try {
206
248
  const cfg = new Config("config", configSchema, this.log, parseScope(`session:${sessionId}`));
207
249
  cfg.lock(() => {
@@ -220,14 +262,10 @@ export default class HookCommand {
220
262
  }
221
263
  /* handler for "ase hook session-end" (both tools) */
222
264
  doSessionEnd(_tool) {
223
- /* read session information (Claude Code uses snake_case fields,
224
- Copilot CLI uses camelCase fields) */
225
- const stdin = fs.readFileSync(0, "utf8");
226
- const input = stdin.trim() !== "" ? JSON.parse(stdin) : {};
227
265
  /* determine session id */
228
- const sessionId = input.session_id ?? input.sessionId ?? "";
266
+ const sessionId = this.readSessionIdFromStdin();
229
267
  /* remove the session directory ~/.ase/session/<id> (only for a valid sessionId) */
230
- if (/^[A-Za-z0-9._-]+$/.test(sessionId)) {
268
+ if (this.isValidSessionId(sessionId)) {
231
269
  const dir = path.join(os.homedir(), ".ase", "session", sessionId);
232
270
  try {
233
271
  fs.rmSync(dir, { recursive: true, force: true });
@@ -238,18 +276,32 @@ export default class HookCommand {
238
276
  }
239
277
  return 0;
240
278
  }
279
+ /* pick the session id from a parsed payload (Claude Code uses
280
+ snake_case fields, Copilot CLI uses camelCase fields) */
281
+ pickSessionId(input) {
282
+ return input.session_id ?? input.sessionId ?? "";
283
+ }
284
+ /* read session id from stdin JSON payload */
285
+ readSessionIdFromStdin() {
286
+ const stdin = fs.readFileSync(0, "utf8");
287
+ const input = this.parseJSON(stdin, v.object({
288
+ session_id: v.optional(v.string()),
289
+ sessionId: v.optional(v.string())
290
+ }));
291
+ return this.pickSessionId(input);
292
+ }
241
293
  /* read the session-scoped "agent.skill" config value */
242
294
  readActiveSkill(sessionId) {
243
- if (!/^[A-Za-z0-9._-]+$/.test(sessionId))
295
+ if (!this.isValidSessionId(sessionId))
244
296
  return "";
245
297
  try {
246
298
  const cfg = new Config("config", configSchema, this.log, parseScope(`session:${sessionId}`));
247
299
  let val = "";
248
300
  cfg.lock(() => {
249
301
  cfg.read();
250
- const v = cfg.get("agent.skill");
251
- if (typeof v === "string")
252
- val = v;
302
+ const skill = cfg.get("agent.skill");
303
+ if (typeof skill === "string")
304
+ val = skill;
253
305
  });
254
306
  return val;
255
307
  }
@@ -262,21 +314,21 @@ export default class HookCommand {
262
314
  const spec = toolSpecs[tool];
263
315
  /* read tool invocation information */
264
316
  const stdin = fs.readFileSync(0, "utf8");
265
- const input = stdin.trim() !== "" ? JSON.parse(stdin) : {};
317
+ const input = this.parseJSON(stdin, v.looseObject({
318
+ session_id: v.optional(v.string()),
319
+ sessionId: v.optional(v.string())
320
+ }));
266
321
  /* determine whether to auto-approve the tool invocation
267
322
  (field names and value shapes differ between tools) */
268
323
  const toolName = typeof input[spec.toolNameField] === "string" ?
269
324
  input[spec.toolNameField] : "";
270
325
  let toolInput = {};
271
326
  const rawInput = input[spec.toolInputField];
272
- if (spec.toolInputIsString && typeof rawInput === "string") {
273
- try {
274
- toolInput = JSON.parse(rawInput);
275
- }
276
- catch (_e) {
277
- /* best-effort: leave toolInput empty on parse failure */
278
- }
279
- }
327
+ if (spec.toolInputIsString && typeof rawInput === "string")
328
+ toolInput = this.parseJSON(rawInput, v.object({
329
+ command: v.optional(v.string()),
330
+ skill: v.optional(v.string())
331
+ }));
280
332
  else if (!spec.toolInputIsString && typeof rawInput === "object" && rawInput !== null)
281
333
  toolInput = rawInput;
282
334
  let approve = false;
@@ -294,9 +346,9 @@ export default class HookCommand {
294
346
  reason = "ASE MCP tool invocation auto-approved";
295
347
  }
296
348
  else if (toolName === "Edit") {
297
- const sessionId = input.session_id ?? input.sessionId ?? "";
349
+ const sessionId = this.pickSessionId(input);
298
350
  const activeSkill = this.readActiveSkill(sessionId);
299
- if (activeSkill === "ase-docs-proofread") {
351
+ if (activeSkill === "ase-docs-proofread" || activeSkill === "ase-code-lint") {
300
352
  approve = true;
301
353
  reason = `${activeSkill}: user already consented via AskUserQuestion`;
302
354
  }
@@ -329,7 +381,7 @@ export default class HookCommand {
329
381
  register(program) {
330
382
  /* default for --tool derived from ASE_TOOL environment variable */
331
383
  const envTool = process.env.ASE_TOOL ?? "";
332
- const toolDflt = envTool !== "" ? envTool : "claude";
384
+ const toolDflt = envTool !== "" ? this.parseTool(envTool) : "claude";
333
385
  /* register CLI top-level command "ase hook" */
334
386
  const hookCmd = program
335
387
  .command("hook")
package/dst/ase-mcp.js CHANGED
@@ -26,9 +26,9 @@ export default class MCPCommand {
26
26
  const projectId = rawId ?? path.basename(process.cwd());
27
27
  const rawPort = svc.get("port");
28
28
  const port = rawPort ?? null;
29
- return { projectId, port, svc };
29
+ return { projectId, port };
30
30
  }
31
- /* spawn "ase service start" detached and wait for it to come up */
31
+ /* run "ase service start" and wait for the service to come up */
32
32
  async ensureService() {
33
33
  let ctx = this.loadContext();
34
34
  /* fast path: already running */
@@ -52,6 +52,10 @@ export default class MCPCommand {
52
52
  throw new Error(`mcp: service not responding on port ${ctx.port} after start`);
53
53
  return { projectId: ctx.projectId, port: ctx.port };
54
54
  }
55
+ /* coerce an unknown thrown value into an Error */
56
+ asError(e) {
57
+ return e instanceof Error ? e : new Error(String(e));
58
+ }
55
59
  /* bridge stdio to a Streamable HTTP MCP endpoint on the local service */
56
60
  async runBridge() {
57
61
  /* ensure the service is running */
@@ -60,8 +64,9 @@ export default class MCPCommand {
60
64
  const server = new StdioServerTransport();
61
65
  /* track active client and bridge-level closed state */
62
66
  let client = null;
63
- let closedByUs = false; /* set when we initiated the client close */
64
- let bridgeDone = false; /* set when stdio side closes */
67
+ let closedByUs = false; /* set when we initiated the client close */
68
+ let bridgeDone = false; /* set when stdio side closes */
69
+ let reconnecting = false; /* set while a reconnect chain is active */
65
70
  /* cleanly shut down the whole bridge */
66
71
  const shutdown = async () => {
67
72
  if (bridgeDone)
@@ -79,11 +84,9 @@ export default class MCPCommand {
79
84
  const connectClient = async () => {
80
85
  const url = new URL(`http://${HOST}:${port}/mcp`);
81
86
  const next = new StreamableHTTPClientTransport(url);
82
- client = next;
83
87
  next.onmessage = (msg) => {
84
- server.send(msg).catch((_err) => {
85
- const err = _err instanceof Error ? _err : new Error(String(_err));
86
- this.log.write("error", `mcp: stdout send: ${err.message}`);
88
+ server.send(msg).catch((err) => {
89
+ this.log.write("error", `mcp: stdout send: ${this.asError(err).message}`);
87
90
  });
88
91
  };
89
92
  next.onerror = (err) => {
@@ -91,12 +94,14 @@ export default class MCPCommand {
91
94
  };
92
95
  /* service closed the connection — try to recover */
93
96
  next.onclose = () => {
94
- if (closedByUs || bridgeDone)
97
+ if (client !== next || closedByUs || bridgeDone || reconnecting)
95
98
  return;
99
+ reconnecting = true;
96
100
  this.log.write("warning", "mcp: http connection lost — reconnecting");
97
- reconnect().catch(() => { });
101
+ reconnect(0, () => { reconnecting = false; }).catch(() => { });
98
102
  };
99
103
  await next.start();
104
+ client = next;
100
105
  };
101
106
  /* reconnect loop: restart service if needed, then reconnect client */
102
107
  const reconnect = async (attempt = 0, done) => {
@@ -111,22 +116,21 @@ export default class MCPCommand {
111
116
  port = ctx.port;
112
117
  closedByUs = true;
113
118
  await client?.close();
114
- closedByUs = false;
115
119
  await connectClient();
120
+ closedByUs = false;
116
121
  this.log.write("info", "mcp: reconnected to service");
117
122
  done?.();
118
123
  }
119
- catch (_err) {
120
- const err = _err instanceof Error ? _err : new Error(String(_err));
121
- this.log.write("error", `mcp: reconnect failed: ${err.message}`);
124
+ catch (err) {
125
+ closedByUs = false;
126
+ this.log.write("error", `mcp: reconnect failed: ${this.asError(err).message}`);
122
127
  reconnect(attempt + 1, done).catch(() => { });
123
128
  }
124
129
  };
125
130
  /* wire stdio server */
126
131
  server.onmessage = (msg) => {
127
- client?.send(msg).catch((_err) => {
128
- const err = _err instanceof Error ? _err : new Error(String(_err));
129
- this.log.write("error", `mcp: http send: ${err.message}`);
132
+ client?.send(msg).catch((err) => {
133
+ this.log.write("error", `mcp: http send: ${this.asError(err).message}`);
130
134
  });
131
135
  };
132
136
  server.onerror = (err) => {
@@ -140,7 +144,6 @@ export default class MCPCommand {
140
144
  await connectClient();
141
145
  /* periodically probe the service; trigger reconnect if it is gone */
142
146
  const HEALTH_INTERVAL_MS = 30_000;
143
- let reconnecting = false;
144
147
  const healthTimer = setInterval(async () => {
145
148
  if (bridgeDone || reconnecting)
146
149
  return;
@@ -153,7 +156,10 @@ export default class MCPCommand {
153
156
  reconnect(0, () => { reconnecting = false; }).catch(() => { });
154
157
  }
155
158
  }
156
- catch { /* ignore probe errors */ }
159
+ catch (err) {
160
+ /* ignore transient probe/context errors but record them */
161
+ this.log.write("debug", `mcp: health check error: ${this.asError(err).message}`);
162
+ }
157
163
  }, HEALTH_INTERVAL_MS);
158
164
  healthTimer.unref();
159
165
  /* await stdio to be closed */
@@ -165,7 +171,7 @@ export default class MCPCommand {
165
171
  /* shutdown services */
166
172
  clearInterval(healthTimer);
167
173
  await shutdown();
168
- return 0;
174
+ return 0; /* unreachable, kept only to satisfy the Promise<number> return type */
169
175
  }
170
176
  /* register commands */
171
177
  register(program) {
@@ -258,7 +258,13 @@ export default class ServiceCommand {
258
258
  handler: (_request, h) => {
259
259
  this.log.write("info", "service: stop requested");
260
260
  setImmediate(async () => {
261
- await server.stop({ timeout: 1000 });
261
+ try {
262
+ await server.stop({ timeout: 1000 });
263
+ }
264
+ catch (err) {
265
+ const e = err;
266
+ this.log.write("error", `service: stop failed: ${e.message}`);
267
+ }
262
268
  process.exit(0);
263
269
  });
264
270
  return h.response({ ok: true }).code(200);
@@ -439,7 +445,6 @@ export default class ServiceCommand {
439
445
  lastErr = new Error(`${reason}${detail}`);
440
446
  }
441
447
  finally {
442
- child.removeListener("exit", onExit);
443
448
  if (!success && !exited) {
444
449
  child.kill("SIGTERM");
445
450
  await Promise.race([
@@ -455,6 +460,7 @@ export default class ServiceCommand {
455
460
  child.unref();
456
461
  }
457
462
  }
463
+ child.removeListener("exit", onExit);
458
464
  if (!success)
459
465
  Service.clearPort(ctx.svc);
460
466
  }
@@ -175,7 +175,7 @@ export default class StatuslineCommand {
175
175
  /* parse and validate the --tool option */
176
176
  parseTool(value) {
177
177
  if (value !== "claude" && value !== "copilot")
178
- throw new Error(`invalid --tool value: "${value}" (expected "claude" or "copilot")`);
178
+ throw new InvalidArgumentError(`invalid --tool value: "${value}" (expected "claude" or "copilot")`);
179
179
  return value;
180
180
  }
181
181
  /* register commands */
@@ -357,7 +357,7 @@ export default class StatuslineCommand {
357
357
  S: () => {
358
358
  const pct5h = data.rate_limits?.five_hour?.used_percentage;
359
359
  if (pct5h !== undefined)
360
- emit(`${prefix("⏲", "session")}${c.bold(`${pct5h.toFixed(1)}%`)}`);
360
+ emit(`${prefix("⏲", "session-usage")}${c.bold(`${pct5h.toFixed(1)}%`)}`);
361
361
  },
362
362
  D: () => {
363
363
  const until5h = data.rate_limits?.five_hour?.resets_at ?? "";
@@ -368,7 +368,7 @@ export default class StatuslineCommand {
368
368
  W: () => {
369
369
  const pctWk = data.rate_limits?.seven_day?.used_percentage;
370
370
  if (pctWk !== undefined)
371
- emit(`${prefix("⏲", "weekly")}${c.bold(`${pctWk.toFixed(1)}%`)}`);
371
+ emit(`${prefix("⏲", "weekly-usage")}${c.bold(`${pctWk.toFixed(1)}%`)}`);
372
372
  },
373
373
  Q: () => {
374
374
  const untilWk = data.rate_limits?.seven_day?.resets_at ?? "";
@@ -390,11 +390,11 @@ export default class StatuslineCommand {
390
390
  /* ==== VERSION CONTROL ==== */
391
391
  a: () => {
392
392
  const linesAdded = data.cost?.total_lines_added ?? 0;
393
- emit(`${prefix("⊕", "added")}${c.bold(linesAdded)}`);
393
+ emit(`${prefix("⊕", "added")}${c.bold(String(linesAdded))}`);
394
394
  },
395
395
  r: () => {
396
396
  const linesRemoved = data.cost?.total_lines_removed ?? 0;
397
- emit(`${prefix("⊖", "removed")}${c.bold(linesRemoved)}`);
397
+ emit(`${prefix("⊖", "removed")}${c.bold(String(linesRemoved))}`);
398
398
  },
399
399
  b: () => {
400
400
  const g = getGit();
@@ -435,6 +435,15 @@ export default class StatuslineCommand {
435
435
  }
436
436
  };
437
437
  /* walk each template line and render */
438
+ const closeSpan = () => {
439
+ if (span !== null) {
440
+ const wrapped = span.color === "default" ?
441
+ span.buf :
442
+ (c[span.color])(span.buf);
443
+ span = null;
444
+ appendOutput(wrapped);
445
+ }
446
+ };
438
447
  for (const line of tmpl) {
439
448
  let i = 0;
440
449
  while (i < line.length) {
@@ -443,15 +452,8 @@ export default class StatuslineCommand {
443
452
  if (ch === "<") {
444
453
  const m = line.slice(i).match(/^<(\/?)([a-z]+)>/);
445
454
  if (m !== null && COLORS.has(m[2])) {
446
- if (m[1] === "/") {
447
- if (span !== null) {
448
- const wrapped = span.color === "default" ?
449
- span.buf :
450
- (c[span.color])(span.buf);
451
- span = null;
452
- appendOutput(wrapped);
453
- }
454
- }
455
+ if (m[1] === "/")
456
+ closeSpan();
455
457
  else if (span === null)
456
458
  span = { color: m[2], buf: "" };
457
459
  i += m[0].length;
@@ -468,13 +470,7 @@ export default class StatuslineCommand {
468
470
  }
469
471
  }
470
472
  /* flush any unterminated span at end of line */
471
- if (span !== null) {
472
- const wrapped = span.color === "default" ?
473
- span.buf :
474
- (c[span.color])(span.buf);
475
- span = null;
476
- appendOutput(wrapped);
477
- }
473
+ closeSpan();
478
474
  out += "\n";
479
475
  col = 0;
480
476
  }
package/dst/ase-task.js CHANGED
@@ -69,6 +69,21 @@ export class Task {
69
69
  fs.rmSync(path.dirname(file), { recursive: true, force: true });
70
70
  return true;
71
71
  }
72
+ /* rename a task by moving the entire task home directory
73
+ <project>/.ase/task/<oldId>/ to <project>/.ase/task/<newId>/;
74
+ returns true on success, false if the source task does not exist;
75
+ throws if the target id already exists */
76
+ static rename(oldId, newId) {
77
+ const oldDir = path.dirname(Task.path(oldId));
78
+ const newDir = path.dirname(Task.path(newId));
79
+ if (!fs.existsSync(oldDir))
80
+ return false;
81
+ if (fs.existsSync(newDir))
82
+ throw new Error(`task: target id "${newId}" already exists`);
83
+ fs.mkdirSync(path.dirname(newDir), { recursive: true });
84
+ fs.renameSync(oldDir, newDir);
85
+ return true;
86
+ }
72
87
  /* list all persisted tasks in lexicographic id order; if verbose is true,
73
88
  each entry's `mtime` is set to the `plan.md` modification time formatted
74
89
  as "YYYY-MM-DD HH:MM", otherwise it is left undefined */
@@ -227,6 +242,20 @@ export default class TaskCommand {
227
242
  this.log.write("info", `task: no task "${id}" to remove`);
228
243
  process.exit(removed ? 0 : 1);
229
244
  });
245
+ /* register CLI sub-command "ase task rename" */
246
+ task
247
+ .command("rename")
248
+ .description("Rename a task from <old> to <new>")
249
+ .argument("<old>", "Old task identifier")
250
+ .argument("<new>", "New task identifier")
251
+ .action((oldId, newId) => {
252
+ const renamed = Task.rename(oldId, newId);
253
+ if (renamed)
254
+ this.log.write("info", `task: renamed "${oldId}" to "${newId}"`);
255
+ else
256
+ this.log.write("info", `task: no task "${oldId}" to rename`);
257
+ process.exit(renamed ? 0 : 1);
258
+ });
230
259
  /* register CLI sub-command "ase task purge" */
231
260
  task
232
261
  .command("purge")
@@ -289,7 +318,7 @@ export class TaskMCP {
289
318
  const verbose = args.verbose ?? false;
290
319
  const items = Task.list(verbose);
291
320
  const tasks = verbose ?
292
- items.map((item) => ({ id: item.id, mtime: item.mtime })) :
321
+ items.map((item) => ({ id: item.id, mtime: item.mtime ?? "" })) :
293
322
  items.map((item) => ({ id: item.id }));
294
323
  const result = { tasks };
295
324
  return {
@@ -381,6 +410,36 @@ export class TaskMCP {
381
410
  };
382
411
  }
383
412
  });
413
+ /* task rename */
414
+ mcp.registerTool("task_rename", {
415
+ title: "ASE task rename",
416
+ description: "Rename a previously persisted task from `old` to `new` by atomically moving the " +
417
+ "task home directory. Returns a status `text` indicating whether the rename succeeded. " +
418
+ "Fails with an error if the target id already exists.",
419
+ inputSchema: {
420
+ old: z.string()
421
+ .describe("old task identifier (allowed characters: A-Z, a-z, 0-9, '-')"),
422
+ new: z.string()
423
+ .describe("new task identifier (allowed characters: A-Z, a-z, 0-9, '-')")
424
+ }
425
+ }, async (args) => {
426
+ try {
427
+ const renamed = Task.rename(args.old, args.new);
428
+ const msg = renamed ?
429
+ `task_rename: OK: renamed task "${args.old}" to "${args.new}"` :
430
+ "WARNING: task not found";
431
+ return {
432
+ content: [{ type: "text", text: msg }]
433
+ };
434
+ }
435
+ catch (err) {
436
+ const message = err instanceof Error ? err.message : String(err);
437
+ return {
438
+ isError: true,
439
+ content: [{ type: "text", text: `ERROR: ${message}` }]
440
+ };
441
+ }
442
+ });
384
443
  /* task id get/set */
385
444
  mcp.registerTool("task_id", {
386
445
  title: "ASE task id get/set",
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.48",
9
+ "version": "0.0.49",
10
10
  "license": "GPL-3.0-only",
11
11
  "author": {
12
12
  "name": "Dr. Ralf S. Engelschall",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ase",
3
- "version": "0.0.48",
3
+ "version": "0.0.49",
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.0.48",
3
+ "version": "0.0.49",
4
4
  "description": "Agentic Software Engineering (ASE)",
5
5
  "keywords": [ "agentic", "software", "engineering" ],
6
6
  "homepage": "https://ase.tools",