@rse/ase 0.0.16 → 0.0.18

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
@@ -13,100 +13,45 @@ import * as v from "valibot";
13
13
  import Table from "cli-table3";
14
14
  /* classification taxonomy */
15
15
  export const projectClassification = {
16
- source: {
17
- ambition: ["artist", "craftsman", "engineer"],
18
- boxing: ["white", "grey", "black"],
19
- size: ["small", "medium", "large"],
20
- structure: ["bare", "library", "framework"]
21
- },
22
- process: {
23
- actors: ["person", "team", "crew"],
24
- drive: ["spec", "code", "test"]
25
- },
26
- result: {
27
- target: ["prototype", "mvp", "product"]
28
- }
16
+ boxing: ["white", "grey", "black"]
29
17
  };
30
18
  /* agent classification taxonomy */
31
19
  export const agentClassification = {
32
- persona: {
33
- style: ["writer", "engineer", "telegrapher", "caveman"],
34
- creativity: ["none", "lite", "full"]
35
- },
36
- process: {
37
- autonomy: ["assistant", "hotl", "agent"]
38
- }
20
+ persona: ["writer", "engineer", "telegrapher", "caveman"]
39
21
  };
40
22
  /* classification presets */
41
23
  export const projectClassificationPresets = {
42
24
  vibe: {
43
25
  "project.id": "example",
44
26
  "project.name": "Example Project",
45
- "project.source.ambition": "engineer",
46
- "project.source.boxing": "black",
47
- "project.source.size": "small",
48
- "project.source.structure": "bare",
49
- "project.process.actors": "person",
50
- "project.process.drive": "spec",
51
- "project.result.target": "prototype",
52
- "agent.persona.style": "writer",
53
- "agent.persona.creativity": "full",
54
- "agent.process.autonomy": "agent"
27
+ "project.boxing": "black",
28
+ "agent.persona": "writer"
55
29
  },
56
30
  pro: {
57
31
  "project.id": "example",
58
32
  "project.name": "Example Project",
59
- "project.source.ambition": "artist",
60
- "project.source.boxing": "white",
61
- "project.source.size": "medium",
62
- "project.source.structure": "framework",
63
- "project.process.actors": "person",
64
- "project.process.drive": "code",
65
- "project.result.target": "product",
66
- "agent.persona.style": "engineer",
67
- "agent.persona.creativity": "none",
68
- "agent.process.autonomy": "assistant"
33
+ "project.boxing": "white",
34
+ "agent.persona": "engineer"
69
35
  },
70
36
  default: {
71
37
  "project.id": "example",
72
38
  "project.name": "Example Project",
73
- "project.source.ambition": "artist",
74
- "project.source.boxing": "white",
75
- "project.source.size": "medium",
76
- "project.source.structure": "framework",
77
- "project.process.actors": "person",
78
- "project.process.drive": "code",
79
- "project.result.target": "product",
80
- "project.artifact.build": "{etc/**,README.md,AGENTS.md,LICENSE.txt,package.json}",
81
- "project.artifact.code": "src/**/*",
82
- "project.artifact.docs": "doc/user/**/*.md",
83
- "project.artifact.spec": "doc/spec/**/*.md",
84
- "project.artifact.arch": "doc/arch/**/*.md",
85
- "agent.persona.style": "engineer",
86
- "agent.persona.creativity": "none",
87
- "agent.process.autonomy": "assistant",
88
- "task.id": "default"
39
+ "project.boxing": "white",
40
+ "agent.persona": "engineer",
41
+ "agent.task": "default"
89
42
  },
90
43
  industry: {
91
44
  "project.id": "example",
92
45
  "project.name": "Example Project",
93
- "project.source.ambition": "craftsman",
94
- "project.source.boxing": "grey",
95
- "project.source.size": "large",
96
- "project.source.structure": "framework",
97
- "project.process.actors": "crew",
98
- "project.process.drive": "code",
99
- "project.result.target": "mvp",
100
- "agent.persona.style": "engineer",
101
- "agent.persona.creativity": "none",
102
- "agent.process.autonomy": "hotl"
46
+ "project.boxing": "grey",
47
+ "agent.persona": "engineer"
103
48
  }
104
49
  };
105
50
  /* hard-coded map: which scope kinds each variable may be SET on
106
51
  (reads always cascade through the full chain, this restricts writes only);
107
52
  keys absent from this map default to all non-"default" scope kinds */
108
53
  export const configWritableScopes = {
109
- "task.id": ["session"]
54
+ "agent.task": ["session"]
110
55
  };
111
56
  /* default set of scope kinds writable for any unrestricted key */
112
57
  const configWritableScopesDefault = ["user", "project", "task", "session"];
@@ -151,7 +96,7 @@ const hasProjectContext = () => {
151
96
  "project" term is implicitly added only when a project context
152
97
  exists (Git repository or ".ase" directory at or above cwd), and
153
98
  an explicit "project" term requires that same context */
154
- const parseScope = (value) => {
99
+ export const parseScope = (value) => {
155
100
  const projectActive = hasProjectContext();
156
101
  const input = (value === undefined || value === "") ?
157
102
  (projectActive ? "project" : "user") :
@@ -181,38 +126,11 @@ export const configSchema = v.nullish(v.strictObject({
181
126
  project: v.optional(v.strictObject({
182
127
  id: v.optional(v.pipe(v.string(), v.minLength(1))),
183
128
  name: v.optional(v.pipe(v.string(), v.minLength(1))),
184
- source: v.optional(v.strictObject({
185
- ambition: v.optional(v.picklist(projectClassification.source.ambition)),
186
- boxing: v.optional(v.picklist(projectClassification.source.boxing)),
187
- size: v.optional(v.picklist(projectClassification.source.size)),
188
- structure: v.optional(v.picklist(projectClassification.source.structure))
189
- })),
190
- process: v.optional(v.strictObject({
191
- actors: v.optional(v.picklist(projectClassification.process.actors)),
192
- drive: v.optional(v.picklist(projectClassification.process.drive))
193
- })),
194
- result: v.optional(v.strictObject({
195
- target: v.optional(v.picklist(projectClassification.result.target))
196
- })),
197
- artifact: v.optional(v.strictObject({
198
- build: v.optional(v.pipe(v.string(), v.minLength(1))),
199
- code: v.optional(v.pipe(v.string(), v.minLength(1))),
200
- docs: v.optional(v.pipe(v.string(), v.minLength(1))),
201
- spec: v.optional(v.pipe(v.string(), v.minLength(1))),
202
- arch: v.optional(v.pipe(v.string(), v.minLength(1)))
203
- }))
129
+ boxing: v.optional(v.picklist(projectClassification.boxing))
204
130
  })),
205
131
  agent: v.optional(v.strictObject({
206
- persona: v.optional(v.strictObject({
207
- style: v.optional(v.picklist(agentClassification.persona.style)),
208
- creativity: v.optional(v.picklist(agentClassification.persona.creativity))
209
- })),
210
- process: v.optional(v.strictObject({
211
- autonomy: v.optional(v.picklist(agentClassification.process.autonomy))
212
- }))
213
- })),
214
- task: v.optional(v.strictObject({
215
- id: v.optional(v.pipe(v.string(), v.minLength(1)))
132
+ persona: v.optional(v.picklist(agentClassification.persona)),
133
+ task: v.optional(v.pipe(v.string(), v.minLength(1)))
216
134
  }))
217
135
  }));
218
136
  /* encapsulate read/write access to a stack of "<name>.yaml" configuration files,
package/dst/ase-hook.js CHANGED
@@ -6,14 +6,40 @@
6
6
  import path from "node:path";
7
7
  import fs from "node:fs";
8
8
  import { execaSync } from "execa";
9
+ import Version from "./ase-version.js";
10
+ import { Config, configSchema, parseScope } from "./ase-config.js";
9
11
  /* CLI command "ase hook" */
10
12
  export default class HookCommand {
11
13
  log;
12
14
  constructor(log) {
13
15
  this.log = log;
14
16
  }
17
+ /* recursively expand "@<path>" file references in a Markdown text,
18
+ resolving paths relative to the directory of the containing file */
19
+ expandReferences(text, baseDir, visited = new Set()) {
20
+ return text.replace(/@([^\s]+)/g, (match, ref) => {
21
+ let resolved = ref;
22
+ if (resolved.startsWith("~/"))
23
+ resolved = path.join(process.env.HOME ?? "", resolved.slice(2));
24
+ const abs = path.isAbsolute(resolved) ? resolved : path.resolve(baseDir, resolved);
25
+ if (visited.has(abs))
26
+ return match;
27
+ if (!fs.existsSync(abs))
28
+ return match;
29
+ let content;
30
+ try {
31
+ content = fs.readFileSync(abs, "utf8");
32
+ }
33
+ catch (_e) {
34
+ return match;
35
+ }
36
+ const next = new Set(visited);
37
+ next.add(abs);
38
+ return this.expandReferences(content, path.dirname(abs), next);
39
+ });
40
+ }
15
41
  /* handler for "ase hook session-start" */
16
- doSessionStart() {
42
+ async doSessionStart() {
17
43
  /* determine plugin root */
18
44
  const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT ?? "";
19
45
  if (pluginRoot === "")
@@ -25,34 +51,85 @@ export default class HookCommand {
25
51
  const pkg = fs.readFileSync(filePkg, "utf8");
26
52
  let md = fs.readFileSync(fileMd, "utf8");
27
53
  /* determine own version */
28
- const version = JSON.parse(pkg).version ?? "";
54
+ const versionCurrentPlugin = JSON.parse(pkg).version ?? "";
55
+ const versionCurrentTool = Version.current();
56
+ const versionLatestTool = await Version.latest();
57
+ /* sanity check situation */
58
+ const versionHints = [];
59
+ if (versionCurrentPlugin !== versionCurrentTool)
60
+ versionHints.push("**WARNING:** version *mismatch*: " +
61
+ `tool: **${versionCurrentPlugin}**, plugin: **${versionCurrentTool}**`);
62
+ if (versionCurrentTool !== versionLatestTool)
63
+ versionHints.push(`**NOTICE:** *latest* version: **${versionLatestTool}**, please update!`);
64
+ if (process.env.ASE_SETUP_DEV !== undefined)
65
+ versionHints.push("**NOTICE:** *development* setup");
66
+ const versionHint = versionHints.length > 0 ? "(" + versionHints.join(", ") + ")" : "";
29
67
  /* read session information */
30
68
  const stdin = fs.readFileSync(0, "utf8");
31
69
  const input = stdin.trim() !== "" ? JSON.parse(stdin) : {};
32
70
  /* determine session id */
33
71
  const sessionId = input.session_id ?? "";
72
+ /* establish config context */
73
+ const cfg = new Config("config", configSchema, this.log, parseScope(`session:${sessionId}`));
74
+ try {
75
+ cfg.read();
76
+ }
77
+ catch (_e) {
78
+ /* best-effort: ignore failures */
79
+ }
34
80
  /* determine task id */
35
81
  const taskId = process.env.ASE_TASK_ID ?? "default";
36
82
  try {
37
- execaSync("ase", ["config", `--scope=session:${sessionId}`, "set", "task.id", taskId], { stdio: ["ignore", "ignore", "ignore"] });
83
+ cfg.set("agent.task", taskId);
84
+ cfg.write();
38
85
  }
39
86
  catch (_e) {
40
87
  /* best-effort: ignore failures */
41
88
  }
42
- /* provide session and task id to Claude Code shell commands */
89
+ /* determine project id */
90
+ const cwd = input.cwd ?? process.cwd();
91
+ let projectDir = cwd;
92
+ try {
93
+ const result = execaSync("git", ["rev-parse", "--show-toplevel"], {
94
+ stderr: "ignore", cwd
95
+ });
96
+ if (result.stdout.trim() !== "")
97
+ projectDir = result.stdout.trim();
98
+ }
99
+ catch {
100
+ /* not inside a Git working tree */
101
+ }
102
+ const projectId = path.basename(projectDir);
103
+ /* determine user id */
104
+ const userId = process.env.USER ?? process.env.LOGNAME ?? "unknown";
105
+ /* determine agent persona style */
106
+ let persona = process.env.ASE_PERSONA_STYLE ?? "engineer";
107
+ const val = cfg.get("agent.persona");
108
+ if (typeof val === "string")
109
+ persona = val;
110
+ /* provide ASE information to Claude Code shell commands */
43
111
  const envFile = process.env.CLAUDE_ENV_FILE ?? "";
44
112
  if (envFile !== "") {
45
- const script = `export ASE_VERSION="${version}"\n` +
46
- `export ASE_SESSION_ID="${sessionId}"\n` +
47
- `export ASE_TASK_ID="${taskId}"\n`;
113
+ const script = `export ASE_VERSION="${versionCurrentPlugin}"\n` +
114
+ `export ASE_USER_ID="${userId}"\n` +
115
+ `export ASE_PROJECT_ID="${projectId}"\n` +
116
+ `export ASE_TASK_ID="${taskId}"\n` +
117
+ `export ASE_SESSION_ID="${sessionId}"\n`;
48
118
  fs.appendFileSync(envFile, script, "utf8");
49
119
  }
50
120
  /* prepend ASE information to constitution markdown */
51
121
  md =
52
- `<ase-version>${version}</ase-version>\n` +
122
+ `<ase-version>${versionCurrentPlugin}</ase-version>\n` +
123
+ `<ase-version-hint>${versionHint}</ase-version-hint>\n` +
124
+ `<ase-persona-style>${persona}</ase-persona-style>\n` +
125
+ `<ase-user-id>${userId}</ase-user-id>\n` +
126
+ `<ase-project-id>${projectId}</ase-project-id>\n` +
53
127
  `<ase-task-id>${taskId}</ase-task-id>\n` +
54
128
  `<ase-session-id>${sessionId}</ase-session-id>\n` +
55
129
  "\n" + md;
130
+ /* expand all @<file> references manually */
131
+ md = this.expandReferences(md, path.dirname(fileMd));
132
+ fs.writeFileSync("/tmp/xxx", md, "utf8");
56
133
  /* inject markdown into session context */
57
134
  process.stdout.write(JSON.stringify({
58
135
  "hookSpecificOutput": {
@@ -110,8 +187,8 @@ export default class HookCommand {
110
187
  hookCmd
111
188
  .command("session-start")
112
189
  .description("handle Claude Code SessionStart hook event")
113
- .action(() => {
114
- process.exit(this.doSessionStart());
190
+ .action(async () => {
191
+ process.exit(await this.doSessionStart());
115
192
  });
116
193
  /* register CLI sub-command "ase hook pre-tool-use" */
117
194
  hookCmd
@@ -0,0 +1,143 @@
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 path from "node:path";
7
+ import os from "node:os";
8
+ import fs from "node:fs";
9
+ /* validate the plan id to keep it safe as a filename component */
10
+ const validateId = (id) => {
11
+ if (typeof id !== "string" || id.length === 0)
12
+ throw new Error("plan: id must be a non-empty string");
13
+ if (!/^[A-Za-z0-9-]+$/.test(id))
14
+ throw new Error("plan: id must match [A-Za-z0-9-]+");
15
+ };
16
+ /* resolve the on-disk path for a given plan id */
17
+ const planPath = (id) => {
18
+ validateId(id);
19
+ return path.join(os.homedir(), ".ase", "plan", `${id}.md`);
20
+ };
21
+ /* load a plan; returns empty string if no plan exists */
22
+ export const planLoad = (id) => {
23
+ const file = planPath(id);
24
+ if (!fs.existsSync(file))
25
+ return "";
26
+ return fs.readFileSync(file, "utf8");
27
+ };
28
+ /* save a plan as UTF-8 text under the given id */
29
+ export const planSave = (id, text) => {
30
+ if (typeof text !== "string")
31
+ throw new Error("plan: text must be a string");
32
+ const file = planPath(id);
33
+ fs.mkdirSync(path.dirname(file), { recursive: true });
34
+ fs.writeFileSync(file, text, "utf8");
35
+ };
36
+ /* delete a plan by id; returns true if a plan existed and was removed */
37
+ export const planDelete = (id) => {
38
+ const file = planPath(id);
39
+ if (!fs.existsSync(file))
40
+ return false;
41
+ fs.rmSync(file);
42
+ return true;
43
+ };
44
+ /* purge plans whose modification time is older than the given cutoff in
45
+ milliseconds; returns the list of removed plan ids */
46
+ export const planPurge = (maxAgeMs) => {
47
+ const dir = path.join(os.homedir(), ".ase", "plan");
48
+ if (!fs.existsSync(dir))
49
+ return [];
50
+ const cutoff = Date.now() - maxAgeMs;
51
+ const removed = [];
52
+ for (const entry of fs.readdirSync(dir)) {
53
+ if (!entry.endsWith(".md"))
54
+ continue;
55
+ const file = path.join(dir, entry);
56
+ const st = fs.statSync(file);
57
+ if (!st.isFile())
58
+ continue;
59
+ if (st.mtimeMs < cutoff) {
60
+ fs.rmSync(file);
61
+ removed.push(entry.slice(0, -3));
62
+ }
63
+ }
64
+ return removed;
65
+ };
66
+ /* read all of stdin as a UTF-8 string */
67
+ const readStdin = () => {
68
+ return new Promise((resolve, reject) => {
69
+ const chunks = [];
70
+ process.stdin.on("data", (chunk) => chunks.push(chunk));
71
+ process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
72
+ process.stdin.on("error", (err) => reject(err));
73
+ });
74
+ };
75
+ /* CLI command "ase plan" */
76
+ export default class PlanCommand {
77
+ log;
78
+ constructor(log) {
79
+ this.log = log;
80
+ }
81
+ /* register commands */
82
+ register(program) {
83
+ /* register CLI top-level command "ase plan" */
84
+ const plan = program
85
+ .command("plan")
86
+ .description("Manage persisted plans under ~/.ase/plan/<id>.md")
87
+ .action(() => {
88
+ plan.outputHelp();
89
+ process.exit(1);
90
+ });
91
+ /* register CLI sub-command "ase plan load" */
92
+ plan
93
+ .command("load")
94
+ .description("Load a plan by id and write it to stdout")
95
+ .argument("<id>", "Plan identifier")
96
+ .action((id) => {
97
+ const text = planLoad(id);
98
+ process.stdout.write(text);
99
+ process.exit(0);
100
+ });
101
+ /* register CLI sub-command "ase plan save" */
102
+ plan
103
+ .command("save")
104
+ .description("Save a plan by id, reading content from stdin")
105
+ .argument("<id>", "Plan identifier")
106
+ .action(async (id) => {
107
+ const text = await readStdin();
108
+ planSave(id, text);
109
+ this.log.write("info", `plan: saved "${id}"`);
110
+ process.exit(0);
111
+ });
112
+ /* register CLI sub-command "ase plan delete" */
113
+ plan
114
+ .command("delete")
115
+ .description("Delete a plan by id")
116
+ .argument("<id>", "Plan identifier")
117
+ .action((id) => {
118
+ const removed = planDelete(id);
119
+ if (removed)
120
+ this.log.write("info", `plan: removed "${id}"`);
121
+ else
122
+ this.log.write("info", `plan: no plan "${id}" to remove`);
123
+ process.exit(removed ? 0 : 1);
124
+ });
125
+ /* register CLI sub-command "ase plan purge" */
126
+ plan
127
+ .command("purge")
128
+ .description("Remove all plans with a modification time older than <days> (default: 31)")
129
+ .argument("[<days>]", "Maximum plan age in days", "31")
130
+ .action((days) => {
131
+ const n = Number.parseInt(days, 10);
132
+ if (!Number.isFinite(n) || n < 0)
133
+ throw new Error("plan: <days> must be a non-negative integer");
134
+ const removed = planPurge(n * 24 * 60 * 60 * 1000);
135
+ if (removed.length === 0)
136
+ this.log.write("info", "plan: no plans to purge");
137
+ else
138
+ for (const id of removed)
139
+ this.log.write("info", `plan: purged "${id}"`);
140
+ process.exit(0);
141
+ });
142
+ }
143
+ }
@@ -10,14 +10,15 @@ import { fileURLToPath } from "node:url";
10
10
  import { spawn } from "node:child_process";
11
11
  import Hapi from "@hapi/hapi";
12
12
  import axios from "axios";
13
- import { isMap } from "yaml";
13
+ import { isMap, isScalar } from "yaml";
14
14
  import * as v from "valibot";
15
15
  import prettyMs from "pretty-ms";
16
16
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
17
17
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
18
18
  import { z } from "zod";
19
- import { Config, configSchema } from "./ase-config.js";
19
+ import { Config, configSchema, parseScope } from "./ase-config.js";
20
20
  import { renderDiagram } from "./ase-diagram.js";
21
+ import { taskLoad, taskSave, taskDelete, taskList } from "./ase-task.js";
21
22
  import pkg from "../package.json" with { type: "json" };
22
23
  const SERVE_ENV = "ASE_SERVICE_SERVE";
23
24
  const PORT_ENV = "ASE_SERVICE_PORT";
@@ -260,6 +261,198 @@ export default class ServiceCommand {
260
261
  };
261
262
  }
262
263
  });
264
+ mcp.registerTool("task_list", {
265
+ title: "ASE task list",
266
+ description: "List all persisted task `id`s. " +
267
+ "Returns the ids as `text`, one per line, in lexicographic order; " +
268
+ "returns an empty string if no tasks exist.",
269
+ inputSchema: {}
270
+ }, async () => {
271
+ try {
272
+ const ids = taskList();
273
+ return {
274
+ content: [{ type: "text", text: ids.join("\n") }]
275
+ };
276
+ }
277
+ catch (err) {
278
+ const message = err instanceof Error ? err.message : String(err);
279
+ return {
280
+ isError: true,
281
+ content: [{ type: "text", text: `task_list: ERROR: ${message}` }]
282
+ };
283
+ }
284
+ });
285
+ mcp.registerTool("task_load", {
286
+ title: "ASE task load",
287
+ description: "Load a previously persisted task by `id`. " +
288
+ "Returns the task as `text`; returns an empty string if no task exists for the `id`.",
289
+ inputSchema: {
290
+ id: z.string()
291
+ .describe("task identifier (allowed characters: A-Z, a-z, 0-9, '-')")
292
+ }
293
+ }, async (args) => {
294
+ try {
295
+ const text = taskLoad(args.id);
296
+ return {
297
+ content: [{ type: "text", text }]
298
+ };
299
+ }
300
+ catch (err) {
301
+ const message = err instanceof Error ? err.message : String(err);
302
+ return {
303
+ isError: true,
304
+ content: [{ type: "text", text: `task_load: ERROR: ${message}` }]
305
+ };
306
+ }
307
+ });
308
+ mcp.registerTool("task_save", {
309
+ title: "ASE task save",
310
+ description: "Persist a task as `text` under `id`. " +
311
+ "Overwrites any existing task for the same `id`.",
312
+ inputSchema: {
313
+ id: z.string()
314
+ .describe("task identifier (allowed characters: A-Z, a-z, 0-9, '-')"),
315
+ text: z.string()
316
+ .describe("text content of the task")
317
+ }
318
+ }, async (args) => {
319
+ try {
320
+ taskSave(args.id, args.text);
321
+ return {
322
+ content: [{ type: "text", text: `task_save: OK: saved task "${args.id}"` }]
323
+ };
324
+ }
325
+ catch (err) {
326
+ const message = err instanceof Error ? err.message : String(err);
327
+ return {
328
+ isError: true,
329
+ content: [{ type: "text", text: `task_save: FAILED: ${message}` }]
330
+ };
331
+ }
332
+ });
333
+ mcp.registerTool("task_delete", {
334
+ title: "ASE task delete",
335
+ description: "Delete a previously persisted task by `id`. " +
336
+ "Returns a status `text` indicating whether a task existed and was removed.",
337
+ inputSchema: {
338
+ id: z.string()
339
+ .describe("task identifier (allowed characters: A-Z, a-z, 0-9, '-')")
340
+ }
341
+ }, async (args) => {
342
+ try {
343
+ const removed = taskDelete(args.id);
344
+ const msg = removed ?
345
+ `task_delete: OK: removed task "${args.id}"` :
346
+ `task_delete: WARNING: no task "${args.id}" to remove`;
347
+ return {
348
+ content: [{ type: "text", text: msg }]
349
+ };
350
+ }
351
+ catch (err) {
352
+ const message = err instanceof Error ? err.message : String(err);
353
+ return {
354
+ isError: true,
355
+ content: [{ type: "text", text: `task_delete: ERROR: ${message}` }]
356
+ };
357
+ }
358
+ });
359
+ mcp.registerTool("persona", {
360
+ title: "ASE persona style get/set",
361
+ description: "Get or set the active ASE agent persona `style`. " +
362
+ "If `style` is provided, it sets the persona style, " +
363
+ "otherwise it returns the current persona `style`. " +
364
+ "If `session` is provided, the operation is scoped to that session, " +
365
+ "otherwise it operates on the broadest scope (user/project cascade). " +
366
+ "Allowed styles: \"writer\" (decorative, eloquent, explaining), " +
367
+ "\"engineer\" (brief, factual, accurate), " +
368
+ "\"telegrapher\" (very brief, factual, abbreviating), " +
369
+ "\"caveman\" (ultra brief, rough, stuttering).",
370
+ inputSchema: {
371
+ style: z.enum(["writer", "engineer", "telegrapher", "caveman"]).optional()
372
+ .describe("persona style to set; if omitted, the current persona style is returned"),
373
+ session: z.string().optional()
374
+ .describe("session identifier (allowed characters: A-Z, a-z, 0-9, '-'); " +
375
+ "if omitted, the operation is not scoped to a specific session")
376
+ }
377
+ }, async (args) => {
378
+ try {
379
+ const scope = args.session !== undefined ?
380
+ parseScope(`session:${args.session}`) :
381
+ parseScope(undefined);
382
+ const cfg = new Config("config", configSchema, this.log, scope);
383
+ cfg.read();
384
+ if (args.style !== undefined) {
385
+ cfg.set("agent.persona", args.style);
386
+ cfg.write();
387
+ const where = args.session !== undefined ?
388
+ ` for session "${args.session}"` : "";
389
+ const msg = `persona: OK: set agent.persona to "${args.style}"${where}`;
390
+ return {
391
+ content: [{ type: "text", text: msg }]
392
+ };
393
+ }
394
+ const val = cfg.get("agent.persona");
395
+ if (val === undefined)
396
+ return {
397
+ content: [{ type: "text", text: "" }]
398
+ };
399
+ const text = String(isScalar(val) ? val.value : val);
400
+ return {
401
+ content: [{ type: "text", text }]
402
+ };
403
+ }
404
+ catch (err) {
405
+ const message = err instanceof Error ? err.message : String(err);
406
+ return {
407
+ isError: true,
408
+ content: [{ type: "text", text: `persona: ERROR: ${message}` }]
409
+ };
410
+ }
411
+ });
412
+ mcp.registerTool("task_id", {
413
+ title: "ASE task id get/set",
414
+ description: "Get or set the active ASE task `id` for a given `session`. " +
415
+ "If `id` is provided, it set the task id in the given `session`, " +
416
+ "otherwise it returns the current task `id` of the `session`.",
417
+ inputSchema: {
418
+ id: z.string().optional()
419
+ .describe("task identifier to set (allowed characters: A-Z, a-z, 0-9, '-'); " +
420
+ "if omitted, the current task id is returned"),
421
+ session: z.string()
422
+ .describe("session identifier (allowed characters: A-Z, a-z, 0-9, '-')")
423
+ }
424
+ }, async (args) => {
425
+ try {
426
+ const scope = parseScope(`session:${args.session}`);
427
+ const cfg = new Config("config", configSchema, this.log, scope);
428
+ cfg.read();
429
+ if (args.id !== undefined) {
430
+ cfg.set("agent.task", args.id);
431
+ cfg.write();
432
+ const msg = `task_id: OK: set agent.task to "${args.id}" ` +
433
+ `for session "${args.session}"`;
434
+ return {
435
+ content: [{ type: "text", text: msg }]
436
+ };
437
+ }
438
+ const val = cfg.get("agent.task");
439
+ if (val === undefined)
440
+ return {
441
+ content: [{ type: "text", text: "" }]
442
+ };
443
+ const text = String(isScalar(val) ? val.value : val);
444
+ return {
445
+ content: [{ type: "text", text }]
446
+ };
447
+ }
448
+ catch (err) {
449
+ const message = err instanceof Error ? err.message : String(err);
450
+ return {
451
+ isError: true,
452
+ content: [{ type: "text", text: `task_id: ERROR: ${message}` }]
453
+ };
454
+ }
455
+ });
263
456
  return mcp;
264
457
  };
265
458
  /* listen to HTTP/REST endpoints */
package/dst/ase-setup.js CHANGED
@@ -6,28 +6,47 @@
6
6
  import path from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { execa } from "execa";
9
- import pkg from "../package.json" with { type: "json" };
9
+ import which from "which";
10
+ import Version from "./ase-version.js";
10
11
  /* CLI command "ase setup" */
11
12
  export default class SetupCommand {
12
13
  log;
13
14
  constructor(log) {
14
15
  this.log = log;
15
16
  }
16
- /* run a sub-process with inherited stdio so users see live output */
17
- async run(cmd, args, cwd) {
18
- this.log.write("info", `setup: running: $ ${cmd} ${args.join(" ")}` +
19
- (cwd !== undefined ? ` (cwd: ${cwd})` : ""));
20
- await execa(cmd, args, { stdio: "inherit", cwd });
17
+ /* ensure a tool is available */
18
+ async ensureTool(tool) {
19
+ return which(tool).catch(() => {
20
+ throw new Error(`mandatory tool "${tool}" not found in $PATH`);
21
+ });
21
22
  }
22
- /* capture stdout of a sub-process */
23
- async capture(cmd, args, cwd) {
24
- this.log.write("info", `setup: running: $ ${cmd} ${args.join(" ")}` +
23
+ /* run a sub-process, suppressing output on success and emitting it on failure */
24
+ async run(cmd, args, opts = {}) {
25
+ const { cwd, quiet = false } = opts;
26
+ this.log.write("info", `setup: $ ${cmd} ${args.join(" ")}` +
25
27
  (cwd !== undefined ? ` (cwd: ${cwd})` : ""));
26
- const r = await execa(cmd, args, { stdio: ["ignore", "pipe", "pipe"], cwd });
27
- return r.stdout.trim();
28
+ if (quiet) {
29
+ await execa(cmd, args, { stdio: "ignore", cwd, reject: false });
30
+ return;
31
+ }
32
+ await execa(cmd, args, { stdio: "pipe", cwd }).catch((err) => {
33
+ const exitCode = typeof err?.exitCode === "number" ? err.exitCode : -1;
34
+ this.log.write("error", `setup: command failed: exit code: ${exitCode}`);
35
+ if (typeof err?.stdout === "string" && err.stdout.length > 0) {
36
+ this.log.write("error", "setup: command failed: stdout:");
37
+ process.stdout.write(err.stdout);
38
+ }
39
+ if (typeof err?.stderr === "string" && err.stderr.length > 0) {
40
+ this.log.write("error", "setup: command failed: stderr:");
41
+ process.stderr.write(err.stderr);
42
+ }
43
+ throw err;
44
+ });
28
45
  }
29
46
  /* handler for "ase setup install" */
30
47
  async doInstall(dev) {
48
+ await this.ensureTool("npm");
49
+ await this.ensureTool("claude");
31
50
  this.log.write("info", `setup: install${dev ? "[dev]" : ""}: ` +
32
51
  `installing ASE Claude Code plugin (origin: ${dev ? "local" : "remote"})`);
33
52
  const source = dev ? process.cwd() : "rse/ase";
@@ -37,12 +56,18 @@ export default class SetupCommand {
37
56
  }
38
57
  /* handler for "ase setup update" */
39
58
  async doUpdate(force, dev) {
59
+ await this.ensureTool("npm");
60
+ await this.ensureTool("claude");
61
+ /* best-effort stop of background service */
62
+ this.log.write("info", `setup: update${dev ? "[dev]" : ""}: ` +
63
+ "stopping potentially running ASE service");
64
+ await this.run("ase", ["service", "stop"], { quiet: true });
40
65
  if (dev) {
41
66
  /* update ASE CLI Tool */
42
67
  this.log.write("info", "setup: update[dev]: re-build ASE CLI tool (origin: local)");
43
68
  const tooldir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
44
- await this.run("npm", ["install"], tooldir);
45
- await this.run("npm", ["start", "build"], tooldir);
69
+ await this.run("npm", ["install"], { cwd: tooldir });
70
+ await this.run("npm", ["start", "build"], { cwd: tooldir });
46
71
  /* in development mode the local plugin files are already current
47
72
  but there is no version change in the plugin manifest,
48
73
  so just re-install the plugin to let Claude Code update its copy */
@@ -52,15 +77,8 @@ export default class SetupCommand {
52
77
  }
53
78
  else {
54
79
  /* perform NPM version check */
55
- const current = pkg.version;
56
- let latest = "";
57
- try {
58
- latest = await this.capture("npm", ["view", "@rse/ase", "version"]);
59
- }
60
- catch (err) {
61
- const message = err instanceof Error ? err.message : String(err);
62
- this.log.write("warning", `setup: update: failed to query latest ASE version: ${message}`);
63
- }
80
+ const current = Version.current();
81
+ const latest = await Version.latest();
64
82
  if (!force && latest !== "" && latest === current) {
65
83
  this.log.write("info", `setup: update: ASE already at latest version ${current}`);
66
84
  return 0;
@@ -77,6 +95,12 @@ export default class SetupCommand {
77
95
  }
78
96
  /* handler for "ase setup uninstall" */
79
97
  async doUninstall(dev) {
98
+ await this.ensureTool("npm");
99
+ await this.ensureTool("claude");
100
+ /* best-effort stop of background service */
101
+ this.log.write("info", `setup: uninstall${dev ? "[dev]" : ""}: ` +
102
+ "stopping potentially running ASE service");
103
+ await this.run("ase", ["service", "stop"], { quiet: true });
80
104
  /* uninstall ASE Claude Code plugin */
81
105
  this.log.write("info", `setup: uninstall${dev ? "[dev]" : ""}: ` +
82
106
  `uninstalling ASE Claude Code plugin (origin: ${dev ? "local" : "remote"})`);
@@ -0,0 +1,195 @@
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 path from "node:path";
7
+ import os from "node:os";
8
+ import fs from "node:fs";
9
+ 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");
58
+ if (!fs.existsSync(file))
59
+ continue;
60
+ const st = fs.statSync(file);
61
+ if (!st.isFile())
62
+ continue;
63
+ ids.push(entry);
64
+ }
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");
81
+ 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);
89
+ }
90
+ }
91
+ return removed;
92
+ };
93
+ /* read all of stdin as a UTF-8 string */
94
+ const readStdin = () => {
95
+ return new Promise((resolve, reject) => {
96
+ const chunks = [];
97
+ process.stdin.on("data", (chunk) => chunks.push(chunk));
98
+ process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
99
+ process.stdin.on("error", (err) => reject(err));
100
+ });
101
+ };
102
+ /* CLI command "ase task" */
103
+ export default class TaskCommand {
104
+ log;
105
+ constructor(log) {
106
+ this.log = log;
107
+ }
108
+ /* register commands */
109
+ register(program) {
110
+ /* register CLI top-level command "ase task" */
111
+ const task = program
112
+ .command("task")
113
+ .description("Manage persisted tasks under ~/.ase/task/<id>/plan.md")
114
+ .action(() => {
115
+ task.outputHelp();
116
+ process.exit(1);
117
+ });
118
+ /* register CLI sub-command "ase task list" */
119
+ task
120
+ .command("list")
121
+ .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`);
126
+ process.exit(0);
127
+ });
128
+ /* register CLI sub-command "ase task load" */
129
+ task
130
+ .command("load")
131
+ .description("Load a task by id and write it to stdout")
132
+ .argument("<id>", "Task identifier")
133
+ .action((id) => {
134
+ const text = taskLoad(id);
135
+ process.stdout.write(text);
136
+ process.exit(0);
137
+ });
138
+ /* register CLI sub-command "ase task edit" */
139
+ task
140
+ .command("edit")
141
+ .description("Edit a task by id with $EDITOR")
142
+ .argument("<id>", "Task identifier")
143
+ .action((id) => {
144
+ const file = taskPath(id);
145
+ const editor = process.env.EDITOR ?? process.env.VISUAL ?? "vi";
146
+ fs.mkdirSync(path.dirname(file), { recursive: true });
147
+ if (!fs.existsSync(file))
148
+ fs.writeFileSync(file, "", "utf8");
149
+ execaSync(editor, [file], { stdio: "inherit" });
150
+ this.log.write("info", `task: edited "${id}"`);
151
+ process.exit(0);
152
+ });
153
+ /* register CLI sub-command "ase task save" */
154
+ task
155
+ .command("save")
156
+ .description("Save a task by id, reading content from stdin")
157
+ .argument("<id>", "Task identifier")
158
+ .action(async (id) => {
159
+ const text = await readStdin();
160
+ taskSave(id, text);
161
+ this.log.write("info", `task: saved "${id}"`);
162
+ process.exit(0);
163
+ });
164
+ /* register CLI sub-command "ase task delete" */
165
+ task
166
+ .command("delete")
167
+ .description("Delete a task by id")
168
+ .argument("<id>", "Task identifier")
169
+ .action((id) => {
170
+ const removed = taskDelete(id);
171
+ if (removed)
172
+ this.log.write("info", `task: removed "${id}"`);
173
+ else
174
+ this.log.write("info", `task: no task "${id}" to remove`);
175
+ process.exit(removed ? 0 : 1);
176
+ });
177
+ /* register CLI sub-command "ase task purge" */
178
+ task
179
+ .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);
187
+ if (removed.length === 0)
188
+ this.log.write("info", "task: no tasks to purge");
189
+ else
190
+ for (const id of removed)
191
+ this.log.write("info", `task: purged "${id}"`);
192
+ process.exit(0);
193
+ });
194
+ }
195
+ }
@@ -0,0 +1,27 @@
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 { execa } from "execa";
7
+ import pkg from "../package.json" with { type: "json" };
8
+ /* determination of current and available ASE versions */
9
+ export default class Version {
10
+ /* return current ASE version */
11
+ static current() {
12
+ return pkg.version;
13
+ }
14
+ /* return latest ASE version available on the NPM registry */
15
+ static async latest() {
16
+ let latest = "";
17
+ try {
18
+ const r = await execa("npm", ["view", "@rse/ase", "version"], { stdio: ["ignore", "pipe", "pipe"] });
19
+ latest = r.stdout.trim();
20
+ }
21
+ catch (err) {
22
+ const message = err instanceof Error ? err.message : String(err);
23
+ throw new Error(`failed to query latest ASE version: ${message}`, { cause: err });
24
+ }
25
+ return latest;
26
+ }
27
+ }
package/dst/ase.js CHANGED
@@ -12,6 +12,7 @@ import MCPCommand from "./ase-mcp.js";
12
12
  import HookCommand from "./ase-hook.js";
13
13
  import DiagramCommand from "./ase-diagram.js";
14
14
  import SetupCommand from "./ase-setup.js";
15
+ import TaskCommand from "./ase-task.js";
15
16
  import pkg from "../package.json" with { type: "json" };
16
17
  /* globally initialize logger */
17
18
  const log = new Log("ase", "warning", "-");
@@ -46,6 +47,7 @@ const main = async () => {
46
47
  new HookCommand(log).register(program);
47
48
  new DiagramCommand(log).register(program);
48
49
  new SetupCommand(log).register(program);
50
+ new TaskCommand(log).register(program);
49
51
  /* parse program arguments */
50
52
  await program.parseAsync(process.argv);
51
53
  /* 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.16",
9
+ "version": "0.0.18",
10
10
  "license": "GPL-3.0-only",
11
11
  "author": {
12
12
  "name": "Dr. Ralf S. Engelschall",
@@ -32,7 +32,8 @@
32
32
  "shx": "0.4.0",
33
33
 
34
34
  "@types/node": "25.6.0",
35
- "@types/luxon": "3.7.1"
35
+ "@types/luxon": "3.7.1",
36
+ "@types/which": "3.0.4"
36
37
  },
37
38
  "dependencies": {
38
39
  "commander": "14.0.3",
@@ -48,7 +49,8 @@
48
49
  "pretty-ms": "9.3.0",
49
50
  "luxon": "3.7.2",
50
51
  "@modelcontextprotocol/sdk": "1.29.0",
51
- "zod": "4.4.2"
52
+ "zod": "4.4.2",
53
+ "which": "6.0.1"
52
54
  },
53
55
  "engines": {
54
56
  "npm": ">=10.0.0",