@rse/ase 0.0.27 → 0.0.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dst/ase-config.js CHANGED
@@ -11,6 +11,8 @@ import { Document, parseDocument, isMap, isScalar } from "yaml";
11
11
  import { execaSync } from "execa";
12
12
  import * as v from "valibot";
13
13
  import Table from "cli-table3";
14
+ import writeFileAtomic from "write-file-atomic";
15
+ import lockfile from "proper-lockfile";
14
16
  /* classification taxonomy */
15
17
  export const projectClassification = {
16
18
  boxing: ["white", "grey", "black"]
@@ -207,8 +209,9 @@ export class Config {
207
209
  findUpward(start, stop, rel) {
208
210
  let dir = fs.realpathSync(start);
209
211
  const end = fs.realpathSync(stop);
210
- const between = path.relative(end, dir);
211
- const steps = between === "" ? 0 : between.split(path.sep).length;
212
+ const steps = dir.split(path.sep).length - end.split(path.sep).length;
213
+ if (steps < 0)
214
+ return null;
212
215
  for (let i = 0; i <= steps; i++) {
213
216
  const candidate = path.join(dir, rel);
214
217
  if (fs.existsSync(candidate))
@@ -280,6 +283,23 @@ export class Config {
280
283
  this.validateDoc(docs[i].doc, docs[i].filename, perDocMode);
281
284
  }
282
285
  }
286
+ /* acquire a cross-process advisory lock on the target scope's file,
287
+ execute the callback, then release the lock */
288
+ lock(cb) {
289
+ const td = this.docs[this.target];
290
+ if (td.scope.kind === "default")
291
+ throw new Error("internal error: \"default\" scope is not lockable");
292
+ fs.mkdirSync(path.dirname(td.filename), { recursive: true });
293
+ if (!fs.existsSync(td.filename))
294
+ fs.writeFileSync(td.filename, "", "utf8");
295
+ const release = lockfile.lockSync(td.filename);
296
+ try {
297
+ cb();
298
+ }
299
+ finally {
300
+ release();
301
+ }
302
+ }
283
303
  /* write in-memory configuration back to the target scope's file */
284
304
  write() {
285
305
  const td = this.docs[this.target];
@@ -287,7 +307,7 @@ export class Config {
287
307
  throw new Error("internal error: \"default\" scope is not writable");
288
308
  this.validateDoc(td.doc, td.filename, "strict");
289
309
  fs.mkdirSync(path.dirname(td.filename), { recursive: true });
290
- fs.writeFileSync(td.filename, td.doc.toString({ indent: 4 }), "utf8");
310
+ writeFileAtomic.sync(td.filename, td.doc.toString({ indent: 4 }), { encoding: "utf8" });
291
311
  }
292
312
  /* validate a single YAML document against the optional schema */
293
313
  validateDoc(doc, filename, mode = "strict") {
@@ -314,9 +334,9 @@ export class Config {
314
334
  progressed = true;
315
335
  }
316
336
  else
317
- /* root-level issue is structurally unrecoverable: do not wipe
318
- the document, let the next strict validate() surface it */
319
- return;
337
+ /* root-level issue cannot be deleted; skip it and process
338
+ remaining issues so progressed is tracked correctly */
339
+ continue;
320
340
  }
321
341
  if (!progressed)
322
342
  return;
@@ -509,14 +529,16 @@ export default class ConfigCommand {
509
529
  if (preset === undefined)
510
530
  throw new Error(`unknown preset "${type}" (expected: default|vibe|pro|industry)`);
511
531
  const cfg = new Config("config", configSchema, this.log, scope);
512
- cfg.read();
513
- const targetKind = scope[scope.length - 1].kind;
514
- for (const [k, val] of Object.entries(preset)) {
515
- if (!cfg.isWritableOn(k, targetKind))
516
- continue;
517
- cfg.set(k, val);
518
- }
519
- cfg.write();
532
+ cfg.lock(() => {
533
+ cfg.read();
534
+ const targetKind = scope[scope.length - 1].kind;
535
+ for (const [k, val] of Object.entries(preset)) {
536
+ if (!cfg.isWritableOn(k, targetKind))
537
+ continue;
538
+ cfg.set(k, val);
539
+ }
540
+ cfg.write();
541
+ });
520
542
  });
521
543
  /* register CLI sub-command "ase config list" */
522
544
  configCmd
@@ -594,9 +616,11 @@ export default class ConfigCommand {
594
616
  .action((key, value, _opts, cmd) => {
595
617
  const scope = parseScope(cmd.optsWithGlobals().scope);
596
618
  const cfg = new Config("config", configSchema, this.log, scope);
597
- cfg.read();
598
- cfg.set(key, value);
599
- cfg.write();
619
+ cfg.lock(() => {
620
+ cfg.read();
621
+ cfg.set(key, value);
622
+ cfg.write();
623
+ });
600
624
  });
601
625
  }
602
626
  }
@@ -6,6 +6,7 @@
6
6
  import fs from "node:fs";
7
7
  import { InvalidArgumentError } from "commander";
8
8
  import { renderMermaidASCII } from "beautiful-mermaid";
9
+ import { z } from "zod";
9
10
  /* custom argument parser for Commander: non-negative integer */
10
11
  const parseInteger = (name) => (value) => {
11
12
  const n = Number.parseInt(value, 10);
@@ -19,57 +20,6 @@ const parseColorMode = (name) => (value) => {
19
20
  throw new InvalidArgumentError(`${name} must be "none", "ansi16", or "ansi256"`);
20
21
  return value;
21
22
  };
22
- /* detect terminal column width */
23
- export const detectTermWidth = () => {
24
- let width = 0;
25
- /* attempt 1: query environment variable */
26
- if (process.env.ASE_TERM_WIDTH !== undefined) {
27
- const cols = Number.parseInt(process.env.ASE_TERM_WIDTH, 10);
28
- if (Number.isFinite(cols) && cols > 0)
29
- width = cols;
30
- }
31
- /* attempt 2: query stdout */
32
- if (width === 0 && process.stdout.isTTY) {
33
- const cols = process.stdout.columns;
34
- if (typeof cols === "number" && cols > 0)
35
- width = cols;
36
- }
37
- return width;
38
- };
39
- /* detect terminal row height */
40
- export const detectTermHeight = () => {
41
- let height = 0;
42
- /* attempt 1: query environment variable */
43
- if (process.env.ASE_TERM_HEIGHT !== undefined) {
44
- const rows = Number.parseInt(process.env.ASE_TERM_HEIGHT, 10);
45
- if (Number.isFinite(rows) && rows > 0)
46
- height = rows;
47
- }
48
- /* attempt 2: query stdout */
49
- if (height === 0 && process.stdout.isTTY) {
50
- const rows = process.stdout.rows;
51
- if (typeof rows === "number" && rows > 0)
52
- height = rows;
53
- }
54
- return height;
55
- };
56
- /* detect terminal color capability */
57
- const detectColorMode = () => {
58
- let mode = "none";
59
- /* attempt 1: query environment variable (explicitly) */
60
- if (process.env.ASE_TERM_COLORS !== undefined)
61
- if (process.env.ASE_TERM_COLORS.match(/^(?:none|ansi16|ansi256)$/) !== null)
62
- mode = process.env.ASE_TERM_COLORS;
63
- /* attempt 2: query stdout */
64
- if (mode === "none" && process.stdout.isTTY) {
65
- const depth = process.stdout.getColorDepth();
66
- if (depth >= 8)
67
- mode = "ansi256";
68
- else if (depth >= 4)
69
- mode = "ansi16";
70
- }
71
- return mode;
72
- };
73
23
  /* truncate a single rendered line to a maximum visible column,
74
24
  preserving ANSI escape sequences (CSI ...m) and appending an ANSI
75
25
  reset sequence if any styling was active at the truncation point */
@@ -135,70 +85,124 @@ const visibleWidth = (line) => {
135
85
  }
136
86
  return visible;
137
87
  };
138
- /* pure rendering helper: turn a Mermaid source string plus options into
139
- a rendered Unicode/ASCII diagram string. Throws on render failure. */
140
- export const renderDiagram = (src, opts) => {
141
- /* create diagram rendering */
142
- let out = renderMermaidASCII(src, {
143
- useAscii: opts.ascii,
144
- paddingX: opts.nodeMarginX,
145
- paddingY: opts.nodeMarginY,
146
- boxBorderPadding: opts.nodePadding,
147
- colorMode: opts.colorMode,
148
- theme: opts.colorMode !== "none" ? {
149
- fg: "#000000",
150
- border: "#a0a0a0",
151
- junction: "#a0a0a0",
152
- arrow: "#404040",
153
- line: "#707070",
154
- corner: "#707070"
155
- } : {
156
- fg: "#000000",
157
- border: "#000000",
158
- junction: "#000000",
159
- arrow: "#000000",
160
- line: "#000000",
161
- corner: "#000000"
88
+ /* reusable functionality: Mermaid diagram rendering as Unicode/ASCII art */
89
+ export class Diagram {
90
+ /* detect terminal column width */
91
+ static detectTermWidth() {
92
+ let width = 0;
93
+ /* attempt 1: query environment variable */
94
+ if (process.env.ASE_TERM_WIDTH !== undefined) {
95
+ const cols = Number.parseInt(process.env.ASE_TERM_WIDTH, 10);
96
+ if (Number.isFinite(cols) && cols > 0)
97
+ width = cols;
162
98
  }
163
- });
164
- /* optionally clip diagram rendering */
165
- const termWidth = opts.terminalWidth;
166
- const termHeight = opts.terminalHeight;
167
- if (termWidth > 0 || termHeight > 0) {
168
- const maxWidth = termWidth > 0 ? termWidth - opts.diagramClipX : 0;
169
- const maxHeight = termHeight > 0 ? termHeight - opts.diagramClipY : 0;
170
- const trailingNL = out.endsWith("\n");
171
- let lines = (trailingNL ? out.slice(0, -1) : out).split("\n");
172
- let widthWarn = "";
173
- let heightWarn = "";
174
- if (maxWidth > 0) {
175
- const widest = lines.reduce((m, l) => Math.max(m, visibleWidth(l)), 0);
176
- if (widest > maxWidth)
177
- widthWarn =
178
- `ase diagram: WARNING: rendered diagram width ${widest} exceeds budget ${maxWidth}; ` +
179
- "rightmost content was clipped. Please regenerate the Mermaid source to fit " +
180
- `within ${maxWidth} chars by preferring a portrait orientation ` +
181
- "(\"flowchart TB\", top-to-bottom) over landscape (\"LR\"/\"RL\"/\"BT\"), " +
182
- "reducing siblings per row, abbreviating node labels, or restructuring " +
183
- "into nested subgraph hierarchies.";
184
- lines = lines.map((l) => truncateAnsiLine(l, maxWidth));
99
+ /* attempt 2: query stdout */
100
+ if (width === 0 && process.stdout.isTTY) {
101
+ const cols = process.stdout.columns;
102
+ if (typeof cols === "number" && cols > 0)
103
+ width = cols;
185
104
  }
186
- if (maxHeight > 0 && lines.length > maxHeight) {
187
- const overflow = lines.length - maxHeight;
188
- heightWarn =
189
- `ase diagram: WARNING: rendered diagram height ${lines.length} exceeds budget ${maxHeight}; ` +
190
- `bottom ${overflow} line(s) were clipped. Please regenerate the Mermaid source to fit ` +
191
- `within ${maxHeight} lines by reducing depth or splitting into multiple diagrams.`;
192
- lines = lines.slice(0, maxHeight);
105
+ return width;
106
+ }
107
+ /* detect terminal row height */
108
+ static detectTermHeight() {
109
+ let height = 0;
110
+ /* attempt 1: query environment variable */
111
+ if (process.env.ASE_TERM_HEIGHT !== undefined) {
112
+ const rows = Number.parseInt(process.env.ASE_TERM_HEIGHT, 10);
113
+ if (Number.isFinite(rows) && rows > 0)
114
+ height = rows;
115
+ }
116
+ /* attempt 2: query stdout */
117
+ if (height === 0 && process.stdout.isTTY) {
118
+ const rows = process.stdout.rows;
119
+ if (typeof rows === "number" && rows > 0)
120
+ height = rows;
193
121
  }
194
- out = lines.join("\n") + (trailingNL ? "\n" : "");
195
- if (widthWarn !== "")
196
- out += "\n" + widthWarn + "\n";
197
- if (heightWarn !== "")
198
- out += "\n" + heightWarn + "\n";
122
+ return height;
199
123
  }
200
- return out;
201
- };
124
+ /* detect terminal color capability */
125
+ static detectColorMode() {
126
+ let mode = "none";
127
+ /* attempt 1: query environment variable (explicitly) */
128
+ if (process.env.ASE_TERM_COLORS !== undefined)
129
+ if (process.env.ASE_TERM_COLORS.match(/^(?:none|ansi16|ansi256)$/) !== null)
130
+ mode = process.env.ASE_TERM_COLORS;
131
+ /* attempt 2: query stdout */
132
+ if (mode === "none" && process.stdout.isTTY) {
133
+ const depth = process.stdout.getColorDepth();
134
+ if (depth >= 8)
135
+ mode = "ansi256";
136
+ else if (depth >= 4)
137
+ mode = "ansi16";
138
+ }
139
+ return mode;
140
+ }
141
+ /* pure rendering helper: turn a Mermaid source string plus options into
142
+ a rendered Unicode/ASCII diagram string. Throws on render failure. */
143
+ static render(src, opts) {
144
+ /* create diagram rendering */
145
+ let out = renderMermaidASCII(src, {
146
+ useAscii: opts.ascii,
147
+ paddingX: opts.nodeMarginX,
148
+ paddingY: opts.nodeMarginY,
149
+ boxBorderPadding: opts.nodePadding,
150
+ colorMode: opts.colorMode,
151
+ theme: opts.colorMode !== "none" ? {
152
+ fg: "#000000",
153
+ border: "#a0a0a0",
154
+ junction: "#a0a0a0",
155
+ arrow: "#404040",
156
+ line: "#707070",
157
+ corner: "#707070"
158
+ } : {
159
+ fg: "#000000",
160
+ border: "#000000",
161
+ junction: "#000000",
162
+ arrow: "#000000",
163
+ line: "#000000",
164
+ corner: "#000000"
165
+ }
166
+ });
167
+ /* optionally clip diagram rendering */
168
+ const termWidth = opts.terminalWidth;
169
+ const termHeight = opts.terminalHeight;
170
+ if (termWidth > 0 || termHeight > 0) {
171
+ const maxWidth = termWidth > 0 ? termWidth - opts.diagramClipX : 0;
172
+ const maxHeight = termHeight > 0 ? termHeight - opts.diagramClipY : 0;
173
+ const trailingNL = out.endsWith("\n");
174
+ let lines = (trailingNL ? out.slice(0, -1) : out).split("\n");
175
+ let widthWarn = "";
176
+ let heightWarn = "";
177
+ if (maxWidth > 0) {
178
+ const widest = lines.reduce((m, l) => Math.max(m, visibleWidth(l)), 0);
179
+ if (widest > maxWidth)
180
+ widthWarn =
181
+ `ase diagram: WARNING: rendered diagram width ${widest} exceeds budget ${maxWidth}; ` +
182
+ "rightmost content was clipped. Please regenerate the Mermaid source to fit " +
183
+ `within ${maxWidth} chars by preferring a portrait orientation ` +
184
+ "(\"flowchart TB\", top-to-bottom) over landscape (\"LR\"/\"RL\"/\"BT\"), " +
185
+ "reducing siblings per row, abbreviating node labels, or restructuring " +
186
+ "into nested subgraph hierarchies.";
187
+ lines = lines.map((l) => truncateAnsiLine(l, maxWidth));
188
+ }
189
+ if (maxHeight > 0 && lines.length > maxHeight) {
190
+ const overflow = lines.length - maxHeight;
191
+ heightWarn =
192
+ `ase diagram: WARNING: rendered diagram height ${lines.length} exceeds budget ${maxHeight}; ` +
193
+ `bottom ${overflow} line(s) were clipped. Please regenerate the Mermaid source to fit ` +
194
+ `within ${maxHeight} lines by reducing depth or splitting into multiple diagrams.`;
195
+ lines = lines.slice(0, maxHeight);
196
+ }
197
+ out = lines.join("\n") + (trailingNL ? "\n" : "");
198
+ if (widthWarn !== "")
199
+ out += "\n" + widthWarn + "\n";
200
+ if (heightWarn !== "")
201
+ out += "\n" + heightWarn + "\n";
202
+ }
203
+ return out;
204
+ }
205
+ }
202
206
  /* read stdin into a single string */
203
207
  const readStdin = async () => {
204
208
  const chunks = [];
@@ -219,14 +223,14 @@ export default class DiagramCommand {
219
223
  .description("Render Mermaid diagram specification as Unicode/ASCII art")
220
224
  .option("-i, --input <file>", "read Mermaid source from file instead of stdin")
221
225
  .option("-a, --ascii", "emit plain ASCII (+-|) instead of Unicode box-drawing", false)
222
- .option("-c, --color-mode <mode>", "force color mode (\"none\", \"ansi16\", or \"ansi256\")", parseColorMode("--color-mode"), detectColorMode())
226
+ .option("-c, --color-mode <mode>", "force color mode (\"none\", \"ansi16\", or \"ansi256\")", parseColorMode("--color-mode"), Diagram.detectColorMode())
223
227
  .option("--node-margin-x <n>", "horizontal margin between nodes of <n> characters", parseInteger("--node-margin-x"), 3)
224
228
  .option("--node-margin-y <n>", "vertical margin between nodes of <n> lines", parseInteger("--node-margin-y"), 3)
225
229
  .option("--node-padding <n>", "horizontal and vertical inner node padding with <n> characters", parseInteger("--node-padding"), 1)
226
230
  .option("--diagram-clip-x <n>", "extra horizontal clipping of diagram to terminal width minus <n> characters", parseInteger("--diagram-clip-x"), 0)
227
231
  .option("--diagram-clip-y <n>", "extra vertical clipping of diagram to terminal height minus <n> lines", parseInteger("--diagram-clip-y"), 0)
228
- .option("--terminal-width <n>", "width of terminal of <n> characters (for diagram clipping)", parseInteger("--terminal-width"), detectTermWidth())
229
- .option("--terminal-height <n>", "height of terminal of <n> lines (for diagram clipping)", parseInteger("--terminal-height"), detectTermHeight())
232
+ .option("--terminal-width <n>", "width of terminal of <n> characters (for diagram clipping)", parseInteger("--terminal-width"), Diagram.detectTermWidth())
233
+ .option("--terminal-height <n>", "height of terminal of <n> lines (for diagram clipping)", parseInteger("--terminal-height"), Diagram.detectTermHeight())
230
234
  .action(async (opts) => {
231
235
  /* fetch Mermaid diagram specification from stdin */
232
236
  let src;
@@ -241,7 +245,7 @@ export default class DiagramCommand {
241
245
  /* create diagram rendering */
242
246
  let out;
243
247
  try {
244
- out = renderDiagram(src, {
248
+ out = Diagram.render(src, {
245
249
  ascii: opts.ascii ?? false,
246
250
  colorMode: opts.colorMode,
247
251
  nodeMarginX: opts.nodeMarginX,
@@ -265,3 +269,68 @@ export default class DiagramCommand {
265
269
  });
266
270
  }
267
271
  }
272
+ /* MCP registration entry point for diagram tools */
273
+ export class DiagramMCP {
274
+ register(mcp) {
275
+ mcp.registerTool("diagram", {
276
+ title: "ASE diagram render",
277
+ description: "Render a Mermaid diagram as Unicode/ASCII art. " +
278
+ "Use for visualizing " +
279
+ "structure/layout/components/dependencies as a Flowchart, " +
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, " +
284
+ "data-model/entities/relationships as an ER Diagram, or " +
285
+ "metrics/distributions/time-series as an XY-Chart. " +
286
+ "Pass the Mermaid diagram specification as `diagram`. " +
287
+ "Returns the rendered art as `text`.",
288
+ inputSchema: {
289
+ diagram: z.string()
290
+ .describe("Mermaid diagram specification"),
291
+ ascii: z.boolean().default(false)
292
+ .describe("emit plain ASCII (+-|) instead of Unicode box-drawing characters"),
293
+ colorMode: z.enum(["none", "ansi16", "ansi256"]).default("none")
294
+ .describe("color mode for ANSI escape sequences in the rendered output"),
295
+ nodeMarginX: z.number().int().min(0).default(3)
296
+ .describe("horizontal margin between nodes, in characters"),
297
+ nodeMarginY: z.number().int().min(0).default(3)
298
+ .describe("vertical margin between nodes, in lines"),
299
+ nodePadding: z.number().int().min(0).default(1)
300
+ .describe("inner horizontal and vertical padding within each node, in characters"),
301
+ diagramClipX: z.number().int().min(0).default(0)
302
+ .describe("extra horizontal clipping: subtract this many characters from `terminalWidth`"),
303
+ diagramClipY: z.number().int().min(0).default(0)
304
+ .describe("extra vertical clipping: subtract this many lines from `terminalHeight`"),
305
+ terminalWidth: z.number().int().min(0).default(Diagram.detectTermWidth())
306
+ .describe("terminal width in characters; 0 disables horizontal clipping; defaults to ASE_TERM_WIDTH env var if set"),
307
+ terminalHeight: z.number().int().min(0).default(Diagram.detectTermHeight())
308
+ .describe("terminal height in lines; 0 disables vertical clipping; defaults to ASE_TERM_HEIGHT env var if set")
309
+ }
310
+ }, async (args) => {
311
+ try {
312
+ const out = Diagram.render(args.diagram, {
313
+ ascii: args.ascii,
314
+ colorMode: args.colorMode,
315
+ nodeMarginX: args.nodeMarginX,
316
+ nodeMarginY: args.nodeMarginY,
317
+ nodePadding: args.nodePadding,
318
+ diagramClipX: args.diagramClipX,
319
+ diagramClipY: args.diagramClipY,
320
+ terminalWidth: args.terminalWidth,
321
+ terminalHeight: args.terminalHeight
322
+ });
323
+ return {
324
+ content: [{ type: "text", text: out }]
325
+ };
326
+ }
327
+ catch (err) {
328
+ const message = err instanceof Error ? err.message : String(err);
329
+ return {
330
+ isError: true,
331
+ content: [{ type: "text", text: `diagram: render failed: ${message}` }]
332
+ };
333
+ }
334
+ });
335
+ }
336
+ }
package/dst/ase-foo.js ADDED
@@ -0,0 +1,21 @@
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
+ /* command-line handling */
7
+ export default class FooCommand {
8
+ log;
9
+ constructor(log) {
10
+ this.log = log;
11
+ }
12
+ /* register commands */
13
+ register(program) {
14
+ program
15
+ .command("foo")
16
+ .description("Print a nice Hello World message")
17
+ .action(() => {
18
+ process.stdout.write("Hello, World!\n");
19
+ });
20
+ }
21
+ }
package/dst/ase-hook.js CHANGED
@@ -5,7 +5,9 @@
5
5
  */
6
6
  import path from "node:path";
7
7
  import fs from "node:fs";
8
+ import os from "node:os";
8
9
  import { execaSync } from "execa";
10
+ import { quote } from "shell-quote";
9
11
  import Version from "./ase-version.js";
10
12
  import { Config, configSchema, parseScope } from "./ase-config.js";
11
13
  const toolSpecs = {
@@ -94,23 +96,16 @@ export default class HookCommand {
94
96
  /* establish config context (session-scoped only if a valid sessionId is present) */
95
97
  const hasSession = /^[A-Za-z0-9._-]+$/.test(sessionId);
96
98
  const cfg = new Config("config", configSchema, this.log, hasSession ? parseScope(`session:${sessionId}`) : parseScope(undefined));
97
- try {
99
+ cfg.lock(() => {
98
100
  cfg.read();
99
- }
100
- catch (_e) {
101
- /* best-effort: ignore failures */
102
- }
101
+ });
103
102
  /* determine task id (only persist when scoped to a real session) */
104
103
  const taskId = process.env.ASE_TASK_ID ?? "default";
105
- if (hasSession) {
106
- try {
104
+ if (hasSession)
105
+ cfg.lock(() => {
107
106
  cfg.set("agent.task", taskId);
108
107
  cfg.write();
109
- }
110
- catch (_e) {
111
- /* best-effort: ignore failures */
112
- }
113
- }
108
+ });
114
109
  /* determine project id */
115
110
  const cwd = input.cwd ?? process.cwd();
116
111
  let projectDir = cwd;
@@ -138,12 +133,12 @@ export default class HookCommand {
138
133
  (Claude Code only -- Copilot CLI has no equivalent mechanism) */
139
134
  const envFile = tool === "claude" ? (process.env.CLAUDE_ENV_FILE ?? "") : "";
140
135
  if (envFile !== "") {
141
- const script = `export ASE_VERSION="${versionCurrentPlugin}"\n` +
142
- `export ASE_USER_ID="${userId}"\n` +
143
- `export ASE_PROJECT_ID="${projectId}"\n` +
144
- `export ASE_TASK_ID="${taskId}"\n` +
145
- `export ASE_SESSION_ID="${sessionId}"\n` +
146
- `export ASE_HEADLESS="${headless}"\n`;
136
+ const script = `export ASE_VERSION=${quote([versionCurrentPlugin])}\n` +
137
+ `export ASE_USER_ID=${quote([userId])}\n` +
138
+ `export ASE_PROJECT_ID=${quote([projectId])}\n` +
139
+ `export ASE_TASK_ID=${quote([taskId])}\n` +
140
+ `export ASE_SESSION_ID=${quote([sessionId])}\n` +
141
+ `export ASE_HEADLESS=${quote([headless])}\n`;
147
142
  fs.appendFileSync(envFile, script, "utf8");
148
143
  }
149
144
  /* prepend ASE information to constitution markdown */
@@ -173,6 +168,26 @@ export default class HookCommand {
173
168
  process.stdout.write(JSON.stringify(payload));
174
169
  return 0;
175
170
  }
171
+ /* handler for "ase hook session-end" (both tools) */
172
+ doSessionEnd(_tool) {
173
+ /* read session information (Claude Code uses snake_case fields,
174
+ Copilot CLI uses camelCase fields) */
175
+ const stdin = fs.readFileSync(0, "utf8");
176
+ const input = stdin.trim() !== "" ? JSON.parse(stdin) : {};
177
+ /* determine session id */
178
+ const sessionId = input.session_id ?? input.sessionId ?? "";
179
+ /* remove the session directory ~/.ase/session/<id> (only for a valid sessionId) */
180
+ if (/^[A-Za-z0-9._-]+$/.test(sessionId)) {
181
+ const dir = path.join(os.homedir(), ".ase", "session", sessionId);
182
+ try {
183
+ fs.rmSync(dir, { recursive: true, force: true });
184
+ }
185
+ catch (_e) {
186
+ /* best-effort: ignore failures */
187
+ }
188
+ }
189
+ return 0;
190
+ }
176
191
  /* handler for "ase hook pre-tool-use" (both tools) */
177
192
  doPreToolUse(tool) {
178
193
  const spec = toolSpecs[tool];
@@ -254,6 +269,14 @@ export default class HookCommand {
254
269
  .action(async (opts) => {
255
270
  process.exit(await this.doSessionStart(this.parseTool(opts.tool)));
256
271
  });
272
+ /* register CLI sub-command "ase hook session-end" */
273
+ hookCmd
274
+ .command("session-end")
275
+ .description("handle SessionEnd hook event")
276
+ .option("-t, --tool <tool>", "target tool (\"claude\" or \"copilot\")", toolDflt)
277
+ .action((opts) => {
278
+ process.exit(this.doSessionEnd(this.parseTool(opts.tool)));
279
+ });
257
280
  /* register CLI sub-command "ase hook pre-tool-use" */
258
281
  hookCmd
259
282
  .command("pre-tool-use")