@rse/ase 0.0.17 → 0.0.19

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"];
@@ -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
@@ -80,7 +80,7 @@ export default class HookCommand {
80
80
  /* determine task id */
81
81
  const taskId = process.env.ASE_TASK_ID ?? "default";
82
82
  try {
83
- cfg.set("task.id", taskId);
83
+ cfg.set("agent.task", taskId);
84
84
  cfg.write();
85
85
  }
86
86
  catch (_e) {
@@ -103,8 +103,8 @@ export default class HookCommand {
103
103
  /* determine user id */
104
104
  const userId = process.env.USER ?? process.env.LOGNAME ?? "unknown";
105
105
  /* determine agent persona style */
106
- let persona = "engineer";
107
- const val = cfg.get("agent.persona.style");
106
+ let persona = process.env.ASE_PERSONA_STYLE ?? "engineer";
107
+ const val = cfg.get("agent.persona");
108
108
  if (typeof val === "string")
109
109
  persona = val;
110
110
  /* provide ASE information to Claude Code shell commands */
@@ -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
@@ -21,9 +21,14 @@ export default class SetupCommand {
21
21
  });
22
22
  }
23
23
  /* run a sub-process, suppressing output on success and emitting it on failure */
24
- async run(cmd, args, cwd) {
24
+ async run(cmd, args, opts = {}) {
25
+ const { cwd, quiet = false } = opts;
25
26
  this.log.write("info", `setup: $ ${cmd} ${args.join(" ")}` +
26
27
  (cwd !== undefined ? ` (cwd: ${cwd})` : ""));
28
+ if (quiet) {
29
+ await execa(cmd, args, { stdio: "ignore", cwd, reject: false });
30
+ return;
31
+ }
27
32
  await execa(cmd, args, { stdio: "pipe", cwd }).catch((err) => {
28
33
  const exitCode = typeof err?.exitCode === "number" ? err.exitCode : -1;
29
34
  this.log.write("error", `setup: command failed: exit code: ${exitCode}`);
@@ -53,12 +58,16 @@ export default class SetupCommand {
53
58
  async doUpdate(force, dev) {
54
59
  await this.ensureTool("npm");
55
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 });
56
65
  if (dev) {
57
66
  /* update ASE CLI Tool */
58
67
  this.log.write("info", "setup: update[dev]: re-build ASE CLI tool (origin: local)");
59
68
  const tooldir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
60
- await this.run("npm", ["install"], tooldir);
61
- await this.run("npm", ["start", "build"], tooldir);
69
+ await this.run("npm", ["install"], { cwd: tooldir });
70
+ await this.run("npm", ["start", "build"], { cwd: tooldir });
62
71
  /* in development mode the local plugin files are already current
63
72
  but there is no version change in the plugin manifest,
64
73
  so just re-install the plugin to let Claude Code update its copy */
@@ -88,6 +97,10 @@ export default class SetupCommand {
88
97
  async doUninstall(dev) {
89
98
  await this.ensureTool("npm");
90
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 });
91
104
  /* uninstall ASE Claude Code plugin */
92
105
  this.log.write("info", `setup: uninstall${dev ? "[dev]" : ""}: ` +
93
106
  `uninstalling ASE Claude Code plugin (origin: ${dev ? "local" : "remote"})`);
@@ -0,0 +1,134 @@
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 fs from "node:fs";
7
+ import path from "node:path";
8
+ import { execFileSync } from "node:child_process";
9
+ import { execaSync } from "execa";
10
+ import { Config, configSchema, parseScope } from "./ase-config.js";
11
+ /* read stdin into a single string */
12
+ const readStdin = async () => {
13
+ const chunks = [];
14
+ for await (const chunk of process.stdin)
15
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
16
+ return Buffer.concat(chunks).toString("utf8");
17
+ };
18
+ /* detect terminal column width via /dev/tty (stdout is a pipe under Claude Code) */
19
+ const detectTermWidth = () => {
20
+ let width = 0;
21
+ try {
22
+ const tty = fs.openSync("/dev/tty", "r");
23
+ const out = execFileSync("tput", ["cols"], { stdio: [tty, "pipe", "ignore"] });
24
+ fs.closeSync(tty);
25
+ width = parseInt(out.toString("utf8").trim()) || 0;
26
+ }
27
+ catch (_e) {
28
+ /* no controlling terminal */
29
+ }
30
+ return width;
31
+ };
32
+ /* command-line handling */
33
+ export default class StatuslineCommand {
34
+ log;
35
+ constructor(log) {
36
+ this.log = log;
37
+ }
38
+ /* register commands */
39
+ register(program) {
40
+ program
41
+ .command("statusline")
42
+ .description("Render Claude Code statusline from stdin JSON")
43
+ .action(async () => {
44
+ /* read all of stdin */
45
+ const input = await readStdin();
46
+ /* parse JSON data */
47
+ let data;
48
+ try {
49
+ data = JSON.parse(input);
50
+ }
51
+ catch (err) {
52
+ const message = err instanceof Error ? err.message : String(err);
53
+ this.log.write("error", `statusline: invalid JSON on stdin: ${message}`);
54
+ process.exit(1);
55
+ }
56
+ /* fetch information from data */
57
+ const dir = path.basename(data.workspace?.current_dir ?? "");
58
+ const model = data.model?.display_name ?? "";
59
+ const pct = Math.floor(data.context_window?.used_percentage ?? 0);
60
+ const effort = data.effort?.level ?? "unknown";
61
+ const thinking = (data.thinking?.enabled ?? false) === true ? "yes" : "no";
62
+ const sessionId = data.session_id ?? "unknown";
63
+ /* optionally determine ASE task id and persona style via in-process Config */
64
+ let taskId = process.env.ASE_TASK_ID ?? "";
65
+ let persona = process.env.ASE_PERSONA_STYLE ?? "";
66
+ try {
67
+ const cfg = new Config("config", configSchema, this.log, parseScope(`session:${sessionId}`));
68
+ cfg.read("lenient");
69
+ const t = String(cfg.get("agent.task") ?? "").trim();
70
+ const p = String(cfg.get("agent.persona") ?? "").trim();
71
+ if (t !== "")
72
+ taskId = t;
73
+ if (p !== "")
74
+ persona = p;
75
+ }
76
+ catch (_e) {
77
+ /* cascade unavailable; keep env-var fallbacks */
78
+ }
79
+ /* optionally determine terminal width */
80
+ const width = detectTermWidth();
81
+ /* configure ANSI sequences */
82
+ const RESET = "\x1b[0m";
83
+ const BOLD = "\x1b[1m";
84
+ const BLACK = "\x1b[30m";
85
+ const BLUE = "\x1b[34m";
86
+ const YELLOW = "\x1b[33m";
87
+ const RED = "\x1b[31m";
88
+ /* determine context bar information */
89
+ const barSize = 20;
90
+ const barColor = pct >= 80 ? RED : pct >= 60 ? YELLOW : pct >= 40 ? BLUE : RESET;
91
+ const filled = Math.round(pct / 100 * barSize);
92
+ const bar = "█".repeat(filled) + "░".repeat(barSize - filled);
93
+ /* generate output */
94
+ let output = "";
95
+ output += `${BLUE}※ user: ${BOLD}${process.env.USER ?? process.env.LOGNAME ?? "unknown"}${RESET} `;
96
+ if (width > 0 && width < 30)
97
+ output += "\n";
98
+ output += `${RED}⚑ project: ${BOLD}${dir}${RESET} `;
99
+ if (width > 0 && width < 60)
100
+ output += "\n";
101
+ if (taskId !== "") {
102
+ output += `${BLACK}◉ task: ${BOLD}${taskId}${RESET} `;
103
+ if (width > 0 && width < 90)
104
+ output += "\n";
105
+ }
106
+ output += `⏻ session: ${BOLD}${sessionId}${RESET}\n`;
107
+ output += `⚙ model: ${BOLD}${model}${RESET} `;
108
+ if (width > 0 && width < 30)
109
+ output += "\n";
110
+ output += `⚒ effort: ${BOLD}${effort}${RESET} `;
111
+ if (width > 0 && width < 60)
112
+ output += "\n";
113
+ output += `⚛ thinking: ${BOLD}${thinking}${RESET}\n`;
114
+ if (persona !== "") {
115
+ output += `☯ persona: ${BOLD}${persona}${RESET} `;
116
+ if (width > 0 && width < 30)
117
+ output += "\n";
118
+ }
119
+ output += `${barColor}◔ context: ${bar} ${pct}%${RESET}\n`;
120
+ /* send output */
121
+ process.stdout.write(output);
122
+ /* optionally publish task id to the calling tmux pane as a per-pane user
123
+ option, so someone (like claudeX) can pick it up via #{@ase_task_id} */
124
+ if (process.env.TMUX !== undefined
125
+ && process.env.TMUX !== ""
126
+ && process.env.TMUX_PANE !== undefined
127
+ && process.env.TMUX_PANE !== "") {
128
+ const tid = taskId !== "" ? taskId : "default";
129
+ execaSync("tmux", ["set-option", "-p", "-t", process.env.TMUX_PANE,
130
+ "@ase_task_id", tid], { stdio: "ignore", reject: false });
131
+ }
132
+ });
133
+ }
134
+ }
@@ -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
+ }
package/dst/ase.js CHANGED
@@ -12,6 +12,8 @@ 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 StatuslineCommand from "./ase-statusline.js";
16
+ import TaskCommand from "./ase-task.js";
15
17
  import pkg from "../package.json" with { type: "json" };
16
18
  /* globally initialize logger */
17
19
  const log = new Log("ase", "warning", "-");
@@ -46,6 +48,8 @@ const main = async () => {
46
48
  new HookCommand(log).register(program);
47
49
  new DiagramCommand(log).register(program);
48
50
  new SetupCommand(log).register(program);
51
+ new StatuslineCommand(log).register(program);
52
+ new TaskCommand(log).register(program);
49
53
  /* parse program arguments */
50
54
  await program.parseAsync(process.argv);
51
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.17",
9
+ "version": "0.0.19",
10
10
  "license": "GPL-3.0-only",
11
11
  "author": {
12
12
  "name": "Dr. Ralf S. Engelschall",