@rse/ase 0.9.65 → 0.9.67

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.
Files changed (77) hide show
  1. package/dst/ase-hook.js +1 -1
  2. package/dst/ase-metric.js +103 -0
  3. package/dst/ase-mint.js +106 -0
  4. package/dst/ase-service.js +4 -0
  5. package/dst/ase-setup.js +133 -1
  6. package/dst/ase-spec.js +104 -40
  7. package/dst/ase-statusline.js +11 -2
  8. package/dst/ase.js +4 -0
  9. package/package.json +13 -12
  10. package/plugin/.claude-plugin/plugin.json +1 -1
  11. package/plugin/.codex-plugin/plugin.json +1 -1
  12. package/plugin/.github/plugin/plugin.json +1 -1
  13. package/plugin/agents/ase-docs-proofread.md +13 -7
  14. package/plugin/agents/ase-docs-refine.md +165 -0
  15. package/plugin/agents/ase-docs-shorten.md +182 -0
  16. package/plugin/package.json +4 -4
  17. package/plugin/skills/ase-arch-analyze/help.md +1 -1
  18. package/plugin/skills/ase-arch-discover/help.md +4 -4
  19. package/plugin/skills/ase-code-analyze/help.md +1 -1
  20. package/plugin/skills/ase-code-craft/help.md +7 -7
  21. package/plugin/skills/ase-code-dissect/help.md +5 -5
  22. package/plugin/skills/ase-code-edit/help.md +7 -7
  23. package/plugin/skills/ase-code-explain/help.md +1 -1
  24. package/plugin/skills/ase-code-insight/help.md +1 -1
  25. package/plugin/skills/ase-code-lint/help.md +5 -5
  26. package/plugin/skills/ase-code-refactor/help.md +6 -6
  27. package/plugin/skills/ase-code-resolve/help.md +6 -6
  28. package/plugin/skills/ase-docs-distill/help.md +2 -2
  29. package/plugin/skills/ase-docs-proofread/SKILL.md +16 -12
  30. package/plugin/skills/ase-docs-proofread/help.md +18 -8
  31. package/plugin/skills/ase-docs-refine/SKILL.md +396 -0
  32. package/plugin/skills/ase-docs-refine/help.md +104 -0
  33. package/plugin/skills/ase-docs-shorten/SKILL.md +527 -0
  34. package/plugin/skills/ase-docs-shorten/help.md +120 -0
  35. package/plugin/skills/ase-help-intent/help.md +1 -1
  36. package/plugin/skills/ase-help-skill/catalog.md +4 -1
  37. package/plugin/skills/ase-help-skill/help.md +1 -1
  38. package/plugin/skills/ase-meta-brainstorm/help.md +5 -5
  39. package/plugin/skills/ase-meta-chat/help.md +2 -2
  40. package/plugin/skills/ase-meta-config/help.md +3 -3
  41. package/plugin/skills/ase-meta-diaboli/help.md +2 -2
  42. package/plugin/skills/ase-meta-diff/help.md +3 -3
  43. package/plugin/skills/ase-meta-eli5/help.md +2 -2
  44. package/plugin/skills/ase-meta-evaluate/help.md +1 -1
  45. package/plugin/skills/ase-meta-mint/SKILL.md +157 -0
  46. package/plugin/skills/ase-meta-mint/help.md +114 -0
  47. package/plugin/skills/ase-meta-proximity/help.md +3 -3
  48. package/plugin/skills/ase-meta-quorum/help.md +2 -2
  49. package/plugin/skills/ase-meta-quotes/help.md +4 -4
  50. package/plugin/skills/ase-meta-review/help.md +1 -1
  51. package/plugin/skills/ase-meta-search/help.md +2 -2
  52. package/plugin/skills/ase-meta-steelman/help.md +3 -3
  53. package/plugin/skills/ase-meta-why/help.md +3 -3
  54. package/plugin/skills/ase-meta-workflow/SKILL.md +3 -2
  55. package/plugin/skills/ase-meta-workflow/help.md +4 -4
  56. package/plugin/skills/ase-meta-workflow/workflow.txt +8 -2
  57. package/plugin/skills/ase-spec-edit/SKILL.md +1 -1
  58. package/plugin/skills/ase-spec-edit/help.md +6 -6
  59. package/plugin/skills/ase-sync-export/SKILL.md +3 -1
  60. package/plugin/skills/ase-sync-export/help.md +1 -1
  61. package/plugin/skills/ase-sync-import/SKILL.md +1 -1
  62. package/plugin/skills/ase-sync-import/help.md +2 -2
  63. package/plugin/skills/ase-sync-reconcile/SKILL.md +1 -1
  64. package/plugin/skills/ase-sync-reconcile/help.md +4 -4
  65. package/plugin/skills/ase-task-condense/SKILL.md +7 -4
  66. package/plugin/skills/ase-task-condense/help.md +2 -2
  67. package/plugin/skills/ase-task-delete/help.md +1 -1
  68. package/plugin/skills/ase-task-dissect/help.md +5 -5
  69. package/plugin/skills/ase-task-edit/help.md +4 -4
  70. package/plugin/skills/ase-task-grill/help.md +3 -3
  71. package/plugin/skills/ase-task-id/help.md +1 -1
  72. package/plugin/skills/ase-task-implement/help.md +3 -3
  73. package/plugin/skills/ase-task-list/help.md +3 -3
  74. package/plugin/skills/ase-task-preflight/help.md +2 -2
  75. package/plugin/skills/ase-task-reboot/help.md +2 -2
  76. package/plugin/skills/ase-task-rename/help.md +1 -1
  77. package/plugin/skills/ase-task-view/help.md +2 -2
package/dst/ase-hook.js CHANGED
@@ -473,7 +473,7 @@ export default class HookCommand {
473
473
  }
474
474
  /* the edit-capable skills whose active state lets the pre-tool-use
475
475
  hook auto-approve subsequent "Edit" invocations */
476
- editCapableSkills = ["ase-code-lint", "ase-docs-proofread"];
476
+ editCapableSkills = ["ase-code-lint", "ase-docs-shorten", "ase-docs-refine", "ase-docs-proofread"];
477
477
  /* determine whether an ASE tool invocation described by the parsed
478
478
  hook input should be auto-approved, and (if so) the human-readable
479
479
  reason. The input field names and value shapes differ between
@@ -0,0 +1,103 @@
1
+ /*
2
+ ** Agentic Software Engineering (ASE)
3
+ ** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
+ */
6
+ import fs from "node:fs";
7
+ import { z } from "zod";
8
+ import { writeStdout } from "./ase-stdio.js";
9
+ /* measure the metrics of the text "content", where "words" are the
10
+ whitespace-separated tokens, "lines" count a trailing line without a
11
+ final newline as a full line, "chars" are Unicode characters (code
12
+ points), and "bytes" are the UTF-8 octets of the content itself,
13
+ overridable with the raw octet size of an underlying file */
14
+ export const measure = (content, bytes = Buffer.byteLength(content, "utf8")) => {
15
+ const words = content.split(/\s+/).filter((word) => word !== "").length;
16
+ const lines = content === "" ? 0 : content.replace(/\n$/, "").split("\n").length;
17
+ const chars = Array.from(content).length;
18
+ return { words, lines, chars, bytes };
19
+ };
20
+ /* measure the metrics of the content of a file, counting its raw
21
+ octets as "bytes", as the file encoding is not necessarily UTF-8 */
22
+ export const measureFile = (file) => {
23
+ let buffer;
24
+ try {
25
+ buffer = fs.readFileSync(file);
26
+ }
27
+ catch (err) {
28
+ const message = err instanceof Error ? err.message : String(err);
29
+ throw new Error(`metric: cannot read file "${file}": ${message}`, { cause: err });
30
+ }
31
+ return measure(buffer.toString("utf8"), buffer.length);
32
+ };
33
+ /* measure the metrics of the mutually exclusive inputs "file" and
34
+ "text", of which exactly one has to be given */
35
+ export const measureInput = (file, text) => {
36
+ if (file !== undefined && text !== undefined)
37
+ throw new Error("metric: \"file\" and \"text\" are mutually exclusive");
38
+ else if (file !== undefined)
39
+ return measureFile(file);
40
+ else if (text !== undefined)
41
+ return measure(text);
42
+ else
43
+ throw new Error("metric: either \"file\" or \"text\" has to be given");
44
+ };
45
+ /* CLI command "ase metric" */
46
+ export default class MetricCommand {
47
+ /* register commands */
48
+ register(program) {
49
+ program
50
+ .command("metric")
51
+ .description("Measure the words, lines, characters, and bytes of a file or a text")
52
+ .option("-f, --file <file>", "measure the content of <file> instead of the arguments")
53
+ .argument("[text...]", "text to measure (mutually exclusive with --file)")
54
+ .action(async (text, opts) => {
55
+ const result = measureInput(opts.file, text.length > 0 ? text.join(" ") : undefined);
56
+ await writeStdout(`${JSON.stringify(result)}\n`);
57
+ });
58
+ }
59
+ }
60
+ /* render a caught error as an MCP tool error result */
61
+ const mcpToolError = (err) => ({
62
+ isError: true,
63
+ content: [{ type: "text", text: `ERROR: ${err instanceof Error ? err.message : String(err)}` }]
64
+ });
65
+ /* MCP registration entry point for metric tool */
66
+ export class MetricMCP {
67
+ register(mcp) {
68
+ mcp.registerTool("ase_text_metric", {
69
+ title: "ASE text metric",
70
+ description: "Measure the length metrics of a text, given either as the path `file` or as the " +
71
+ "literal `text`, which are mutually exclusive and of which exactly one has to be given. " +
72
+ "Returns `words` (the whitespace-separated tokens), `lines` (the newline-separated " +
73
+ "lines, where a trailing line without a final newline still counts as one line), " +
74
+ "`chars` (the Unicode characters, i.e. code points), and `bytes` (the octets: the raw " +
75
+ "file size for `file`, the UTF-8 length for `text`). " +
76
+ "You MUST call this tool whenever a text length is needed, instead of counting or " +
77
+ "estimating the length yourself.",
78
+ inputSchema: {
79
+ file: z.string().optional()
80
+ .describe("path of the file whose content is measured (mutually exclusive with `text`)"),
81
+ text: z.string().optional()
82
+ .describe("literal text which is measured (mutually exclusive with `file`)")
83
+ },
84
+ outputSchema: {
85
+ words: z.number().describe("number of whitespace-separated tokens"),
86
+ lines: z.number().describe("number of lines"),
87
+ chars: z.number().describe("number of Unicode characters (code points)"),
88
+ bytes: z.number().describe("number of octets")
89
+ }
90
+ }, async (args) => {
91
+ try {
92
+ const result = measureInput(args.file, args.text);
93
+ return {
94
+ structuredContent: result,
95
+ content: [{ type: "text", text: JSON.stringify(result) }]
96
+ };
97
+ }
98
+ catch (err) {
99
+ return mcpToolError(err);
100
+ }
101
+ });
102
+ }
103
+ }
@@ -0,0 +1,106 @@
1
+ /*
2
+ ** Agentic Software Engineering (ASE)
3
+ ** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
+ */
6
+ import { createHash } from "node:crypto";
7
+ import { Option } from "commander";
8
+ import { z } from "zod";
9
+ import UUID from "pure-uuid";
10
+ import { writeStdout } from "./ase-stdio.js";
11
+ /* the fixed ASE namespace UUID, itself a UUID V5 over the ASE home URL
12
+ within the standard URL namespace, so that all hint-derived UUIDs
13
+ stay stable over time and disjoint from those of other applications */
14
+ const NAMESPACE = new UUID(5, "ns:URL", "https://ase.tools").format("std");
15
+ /* mint "count" identifiers of "type" out of "hint": an empty hint
16
+ yields "count" random UUIDs (V4), while a non-empty hint yields
17
+ exactly one deterministic hash (UUID V5, SHA-1), as hashing is a
18
+ pure function of the hint and hence cannot produce distinct results
19
+ for it -- a larger count is clamped and reported back as a warning */
20
+ export const mint = (type, hint, count) => {
21
+ if (!Number.isInteger(count) || count < 1)
22
+ throw new Error("mint: count has to be a positive integer");
23
+ if (type === "sha1" && hint === "")
24
+ throw new Error("mint: type \"sha1\" requires a non-empty hint");
25
+ let warning = "";
26
+ if (hint !== "" && count > 1) {
27
+ warning = `WARNING: type "${type}" is deterministic for a non-empty hint, ` +
28
+ `so the requested count ${count} was reduced to 1`;
29
+ count = 1;
30
+ }
31
+ const ids = [];
32
+ for (let i = 0; i < count; i++) {
33
+ if (type === "uuid")
34
+ ids.push(hint === "" ?
35
+ new UUID(4).format("std") :
36
+ new UUID(5, NAMESPACE, hint).format("std"));
37
+ else
38
+ ids.push(createHash("sha1").update(hint).digest("hex"));
39
+ }
40
+ return { ids, warning };
41
+ };
42
+ /* CLI command "ase mint" */
43
+ export default class MintCommand {
44
+ /* register commands */
45
+ register(program) {
46
+ program
47
+ .command("mint")
48
+ .description("Mint hash-derived identifiers (UUID, SHA-1) out of a hint")
49
+ .addOption(new Option("-t, --type <type>", "identifier type")
50
+ .choices(["uuid", "sha1"]).default("uuid"))
51
+ .option("-c, --count <count>", "number of identifiers to mint (empty hint only)", "1")
52
+ .argument("[hint...]", "hint the identifiers are derived from")
53
+ .action(async (hint, opts) => {
54
+ const count = Number(opts.count);
55
+ const result = mint(opts.type, hint.join(" "), count);
56
+ if (result.warning !== "")
57
+ process.stderr.write(`ase mint: ${result.warning}\n`);
58
+ await writeStdout(`${result.ids.join("\n")}\n`);
59
+ });
60
+ }
61
+ }
62
+ /* MCP registration entry point for mint tool */
63
+ export class MintMCP {
64
+ register(mcp) {
65
+ mcp.registerTool("ase_mint", {
66
+ title: "ASE mint",
67
+ description: "Mint one or more hash-derived identifiers out of a free-text `hint`. " +
68
+ "For `type` `uuid`, a deterministic UUID V5 within the fixed ASE namespace " +
69
+ "is derived from the hint, or a random UUID V4 if the hint is empty. " +
70
+ "For `type` `sha1`, the 40-character hexadecimal SHA-1 digest of the hint " +
71
+ "is derived, which requires a non-empty hint. " +
72
+ "Pass `count` to mint more than one random UUID V4 at once, which is " +
73
+ "possible for an empty hint only, as hashing a non-empty hint is " +
74
+ "deterministic and hence always yields exactly one identifier. " +
75
+ "Returns the minted identifiers as `text`, one per line, optionally " +
76
+ "preceded by a line starting with `WARNING:`.",
77
+ inputSchema: {
78
+ type: z.enum(["uuid", "sha1"]).default("uuid")
79
+ .describe("identifier type: `uuid` (UUID V5, or V4 for an empty hint) or " +
80
+ "`sha1` (SHA-1 hexadecimal digest), default `uuid`"),
81
+ hint: z.string().default("")
82
+ .describe("free-text hint the identifiers are derived from " +
83
+ "(an empty hint is allowed for `uuid` only)"),
84
+ count: z.number().int().positive().max(100).default(1)
85
+ .describe("number of identifiers to mint (at most 100, default 1); " +
86
+ "honored for an empty hint only, else clamped to 1")
87
+ }
88
+ }, async (args) => {
89
+ try {
90
+ const result = mint(args.type, args.hint, args.count);
91
+ const text = (result.warning !== "" ? [result.warning] : [])
92
+ .concat(result.ids).join("\n");
93
+ return {
94
+ content: [{ type: "text", text }]
95
+ };
96
+ }
97
+ catch (err) {
98
+ const message = err instanceof Error ? err.message : String(err);
99
+ return {
100
+ isError: true,
101
+ content: [{ type: "text", text: `ERROR: ${message}` }]
102
+ };
103
+ }
104
+ });
105
+ }
106
+ }
@@ -29,6 +29,8 @@ import { SleepMCP } from "./ase-sleep.js";
29
29
  import { GetoptMCP } from "./ase-getopt.js";
30
30
  import { SkillsMCP } from "./ase-skills.js";
31
31
  import { WorktreeMCP } from "./ase-worktree.js";
32
+ import { MintMCP } from "./ase-mint.js";
33
+ import { MetricMCP } from "./ase-metric.js";
32
34
  import pkg from "../package.json" with { type: "json" };
33
35
  /* shared service host */
34
36
  export const SERVICE_HOST = "127.0.0.1";
@@ -276,6 +278,8 @@ export default class ServiceCommand {
276
278
  new GetoptMCP().register(mcp);
277
279
  new SkillsMCP().register(mcp);
278
280
  new WorktreeMCP().register(mcp);
281
+ new MintMCP().register(mcp);
282
+ new MetricMCP().register(mcp);
279
283
  new ConfigMCP(this.log).register(mcp);
280
284
  return mcp;
281
285
  };
package/dst/ase-setup.js CHANGED
@@ -240,6 +240,87 @@ export default class SetupCommand {
240
240
  }
241
241
  return 0;
242
242
  }
243
+ /* handler for "ase setup status" */
244
+ async doStatus(tool) {
245
+ const spec = toolSpecs[tool];
246
+ await this.ensureTool(spec.cli);
247
+ this.log.write("info", `setup: status: probing ASE registrations for ${spec.label}`);
248
+ const table = new Table({
249
+ head: ["KIND", "ID", "SCOPE", "STATUS"],
250
+ colWidths: [13, 34, 10, 16],
251
+ wordWrap: true,
252
+ chars: { "mid": "", "left-mid": "", "mid-mid": "", "right-mid": "" },
253
+ style: { head: ["blue"] }
254
+ });
255
+ /* report the ASE plugin registrations, with an absent plugin
256
+ still rendered as an explicit row instead of being omitted */
257
+ const plugins = await this.pluginStatus(tool);
258
+ if (plugins.length === 0)
259
+ table.push(["PLUGIN", chalk.bold("ase@ase"), "(n/a)", "not installed"]);
260
+ else
261
+ for (const plugin of plugins)
262
+ table.push(["PLUGIN", chalk.bold("ase@ase"), plugin.scope, plugin.status]);
263
+ /* report the registered MCP servers, probed concurrently and
264
+ silently skipping every server which is not registered at all */
265
+ const scopes = await Promise.all(this.mcpServers.map((handle) => this.mcpScope(tool, handle.server)));
266
+ for (let i = 0; i < this.mcpServers.length; i++) {
267
+ const scope = scopes[i];
268
+ if (scope === undefined)
269
+ continue;
270
+ table.push(["MCP", chalk.bold(this.mcpServers[i].server), scope, "registered"]);
271
+ }
272
+ /* report the ASE statusline registrations */
273
+ for (const statusline of await this.statuslineStatus(tool))
274
+ table.push(["STATUSLINE", chalk.bold(statusline.file), statusline.scope, statusline.status]);
275
+ process.stdout.write(`${table.toString()}\n`);
276
+ return 0;
277
+ }
278
+ /* probe the ASE plugin registrations by scraping the human-readable
279
+ output of "<cli> plugin list", whose format differs per tool */
280
+ async pluginStatus(tool) {
281
+ const result = await execa(toolSpecs[tool].cli, ["plugin", "list"], { stdio: "pipe", reject: false });
282
+ const lines = (result.stdout ?? "").split(/\r?\n/);
283
+ const entries = [];
284
+ if (tool === "claude") {
285
+ /* the Anthropic Claude Code CLI renders one indented block per
286
+ plugin, carrying an explicit "Scope:" and "Status:" field */
287
+ for (let i = 0; i < lines.length; i++) {
288
+ if (!/(^|\s)ase@ase\s*$/.test(lines[i]))
289
+ continue;
290
+ let scope = "(unknown)";
291
+ let status = "(unknown)";
292
+ for (let j = i + 1; j < lines.length; j++) {
293
+ const m = lines[j].match(/^\s+(\w+):\s*(.+?)\s*$/);
294
+ if (m === null)
295
+ break;
296
+ /* strip the leading "✔"/"✘" glyph of the status value */
297
+ const value = m[2].replace(/^[^\p{L}\p{N}]+/u, "");
298
+ if (m[1] === "Scope")
299
+ scope = value;
300
+ else if (m[1] === "Status")
301
+ status = value;
302
+ }
303
+ entries.push({ scope, status });
304
+ }
305
+ }
306
+ else if (tool === "copilot") {
307
+ /* the GitHub Copilot CLI renders a flat bullet list which
308
+ carries neither a scope nor an enabled/disabled state */
309
+ for (const line of lines)
310
+ if (/(^|\s)ase@ase(\s|\(|$)/.test(line))
311
+ entries.push({ scope: "(n/a)", status: "installed" });
312
+ }
313
+ else {
314
+ /* the OpenAI Codex CLI renders a per-marketplace table whose
315
+ second column carries the combined installation status */
316
+ for (const line of lines) {
317
+ const m = line.match(/^ase@ase\s+(not installed|installed(?:,\s*\w+)?)/);
318
+ if (m !== null && m[1] !== "not installed")
319
+ entries.push({ scope: "(n/a)", status: m[1] });
320
+ }
321
+ }
322
+ return entries;
323
+ }
243
324
  /* handler for "ase setup mcp list" */
244
325
  async doMcpList() {
245
326
  const table = new Table({
@@ -335,6 +416,19 @@ export default class SetupCommand {
335
416
  const result = await execa(toolSpecs[tool].cli, ["mcp", "get", name], { stdio: "ignore", reject: false });
336
417
  return result.exitCode === 0;
337
418
  }
419
+ /* probe the registration scope of an MCP server by scraping the
420
+ human-readable output of "<cli> mcp get <name>"; returns "undefined"
421
+ for a server which is not registered with the tool at all */
422
+ async mcpScope(tool, name) {
423
+ const result = await execa(toolSpecs[tool].cli, ["mcp", "get", name], { stdio: "pipe", reject: false });
424
+ if (result.exitCode !== 0)
425
+ return undefined;
426
+ /* the Anthropic Claude Code CLI reports "Scope: <scope> config (...)",
427
+ the GitHub Copilot CLI reports "Source: <scope>", and the
428
+ OpenAI Codex CLI reports no scope information at all */
429
+ const m = (result.stdout ?? "").match(/^\s*(?:Scope|Source):\s*(\S+)/m);
430
+ return m !== null ? m[1].toLowerCase() : "(n/a)";
431
+ }
338
432
  /* register an MCP server with the tool, supporting both the "stdio"
339
433
  (a local subprocess command) and "http" (a remote URL, optionally
340
434
  with HTTP headers) transports; the per-tool command line differs
@@ -575,7 +669,7 @@ export default class SetupCommand {
575
669
  statuslineFormatDflt = [
576
670
  "<blue>%u</blue> <red>%p</red> <black>%T</black> %s",
577
671
  "%m %e %t",
578
- "%P %h %c"
672
+ "%P %h %B %c"
579
673
  ];
580
674
  /* resolve the tool settings file for a given installation scope */
581
675
  statuslineSettingsFile(tool, scope) {
@@ -785,6 +879,36 @@ export default class SetupCommand {
785
879
  this.log.write("info", `setup: statusline: deactivate: removing ASE "statusLine" from ${file}`);
786
880
  return 0;
787
881
  }
882
+ /* probe the ASE statusline registrations by inspecting the tool
883
+ settings files of all applicable installation scopes */
884
+ async statuslineStatus(tool) {
885
+ /* the OpenAI Codex CLI has no scriptable statusline mechanism at all */
886
+ if (tool === "codex")
887
+ return [];
888
+ const home = os.homedir();
889
+ const scopes = tool === "claude" ? ["user", "project", "local"] : ["user"];
890
+ const entries = [];
891
+ for (const scope of scopes) {
892
+ const file = this.statuslineSettingsFile(tool, scope);
893
+ let root;
894
+ try {
895
+ root = await this.statuslineReadAst(file);
896
+ }
897
+ catch {
898
+ /* an unparsable settings file carries no usable state */
899
+ continue;
900
+ }
901
+ const member = this.statuslineFindMember(root);
902
+ if (member === undefined)
903
+ continue;
904
+ entries.push({
905
+ file: file.startsWith(home + path.sep) ? `~${file.slice(home.length)}` : file,
906
+ scope: tool === "claude" ? scope : "(n/a)",
907
+ status: this.statuslineIsOwned(member) ? "activated" : "foreign"
908
+ });
909
+ }
910
+ return entries;
911
+ }
788
912
  /* parse and validate the --tool option */
789
913
  parseTool(value) {
790
914
  if (value !== "claude" && value !== "copilot" && value !== "codex")
@@ -870,6 +994,14 @@ export default class SetupCommand {
870
994
  .action(async (opts) => {
871
995
  process.exit(await this.doToggle(this.parseTool(opts.tool), this.parseScope(opts.scope), "disable"));
872
996
  });
997
+ /* register CLI sub-command "ase setup status" */
998
+ setupCmd
999
+ .command("status")
1000
+ .description("report the ASE plugin, MCP server, and statusline registrations for a tool")
1001
+ .option("-t, --tool <tool>", "target tool (\"claude\", \"copilot\", or \"codex\")", toolDflt)
1002
+ .action(async (opts) => {
1003
+ process.exit(await this.doStatus(this.parseTool(opts.tool)));
1004
+ });
873
1005
  /* register CLI sub-command "ase setup mcp" */
874
1006
  const mcpCmd = setupCmd
875
1007
  .command("mcp")
package/dst/ase-spec.js CHANGED
@@ -8,43 +8,53 @@ import fs from "node:fs";
8
8
  import { isScalar } from "yaml";
9
9
  import { z } from "zod";
10
10
  import sourceCodeError from "source-code-error";
11
- import { SpecBook, renderDiagnostic, renderVerbose, formats, parseOutputSpec } from "@rse/specbook";
11
+ import { SpecBook, renderDiagnostic, renderVerbose, formats, parseOutputSpec, previewAddr, previewPort } from "@rse/specbook";
12
12
  import { Config, configSchema } from "./ase-config.js";
13
13
  import { Task } from "./ase-task.js";
14
14
  import { Artifact } from "./ase-artifact.js";
15
15
  import { Meta } from "./ase-meta.js";
16
16
  import { writeStdout } from "./ase-stdio.js";
17
- /* reusable functionality: lint and export the SpecBook-based project
18
- specification, located via the "project.artifact.spec.basedir" and
19
- "project.artifact.spec.schema" configuration */
17
+ /* reusable functionality: lint, export, and preview the SpecBook-based
18
+ project specification, located via the "project.artifact.spec.basedir"
19
+ and "project.artifact.spec.schema" configuration */
20
20
  export class Spec {
21
- /* resolve the YAML schema configuration file: the configured
21
+ /* resolve the YAML schema configuration files: the configured
22
22
  "project.artifact.spec.schema" relative to the project root, or
23
23
  the bundled standard "ase-format-specbook.yaml" plugin meta file
24
24
  if unset or empty */
25
- static configFile(log) {
25
+ static configFiles(log) {
26
26
  const cfg = new Config("config", configSchema, log);
27
27
  cfg.read();
28
28
  const val = cfg.get("project.artifact.spec.schema");
29
29
  const file = val === undefined ? "" : String(isScalar(val) ? val.value : val);
30
30
  if (file === "")
31
- return Meta.resolve("ase-format-specbook.yaml");
32
- return path.resolve(Task.projectRoot(), file);
31
+ return [Meta.resolve("ase-format-specbook.yaml")];
32
+ return [path.resolve(Task.projectRoot(), file)];
33
33
  }
34
+ /* the ASE log level each SpecBook verbosity level maps onto: the
35
+ "none" level carries the messages a bare SpecBook CLI run always
36
+ prints, which are mere progress chatter under ASE and hence are
37
+ not logged at all, while the regular and detailed processing
38
+ messages reach the info log and the tracing ones the debug log */
39
+ static logLevel = {
40
+ none: null,
41
+ notice: "info",
42
+ detail: "info",
43
+ trace: "debug"
44
+ };
34
45
  /* create the SpecBook API instance, routing its verbose processing
35
- messages into the info log if requested, else into the debug log,
36
- while its "notice" messages always reach the warning log and, for
37
- consumers which never see the log, the given collector */
38
- static api(log, verbose, notices) {
46
+ messages into the log according to their verbosity level, while
47
+ its "none" level messages, for consumers which never see the log,
48
+ additionally reach the given collector */
49
+ static api(log, notices) {
39
50
  return new SpecBook({
40
51
  verbose: (cmd, msg, level) => {
41
52
  const text = renderVerbose(msg);
42
- if (level === "notice") {
43
- log.write("warning", `specbook: ${cmd}: ${text}`);
53
+ if (level === "none")
44
54
  notices?.push(text);
45
- }
46
- else
47
- log.write(verbose ? "info" : "debug", `specbook: ${cmd}: ${text}`);
55
+ const logLevel = Spec.logLevel[level];
56
+ if (logLevel !== null)
57
+ log.write(logLevel, `specbook: ${cmd}: ${text}`);
48
58
  }
49
59
  });
50
60
  }
@@ -72,9 +82,9 @@ export class Spec {
72
82
  }
73
83
  /* lint the specification Markdown files below the "spec" artifact
74
84
  base directory against the schema configuration */
75
- static async lint(log, verbose = false) {
76
- const result = await Spec.unmarked(Spec.api(log, verbose).lint({
77
- config: Spec.configFile(log),
85
+ static async lint(log) {
86
+ const result = await Spec.unmarked(Spec.api(log).lint({
87
+ config: Spec.configFiles(log),
78
88
  basedir: Artifact.basedir(log, "spec")
79
89
  }));
80
90
  return result.diagnostics.map((d) => ({ ...d, file: Spec.relativize(d.file) }));
@@ -94,6 +104,7 @@ export class Spec {
94
104
  if (code === "")
95
105
  return `${renderDiagnostic(diagnostic)}\n`;
96
106
  return sourceCodeError({
107
+ type: diagnostic.severity === "warning" ? "WARNING" : "ERROR",
97
108
  message: diagnostic.message,
98
109
  filename: diagnostic.file,
99
110
  code,
@@ -105,13 +116,37 @@ export class Spec {
105
116
  /* export the specification Markdown files below the "spec" artifact
106
117
  base directory into the requested formats, one buffer per format,
107
118
  collecting the emitted environment notices if requested */
108
- static export(log, formats, verbose = false, notices) {
109
- return Spec.unmarked(Spec.api(log, verbose, notices).export({
110
- config: Spec.configFile(log),
119
+ static export(log, formats, notices) {
120
+ return Spec.unmarked(Spec.api(log, notices).export({
121
+ config: Spec.configFiles(log),
111
122
  basedir: Artifact.basedir(log, "spec"),
112
123
  formats
113
124
  }));
114
125
  }
126
+ /* export the specification like "export" and then keep the export
127
+ in sync with its sources, handing every fresh set of buffers to
128
+ "onExport", where "outputs" names the files "onExport" writes,
129
+ so an output which is itself an observed source can be refused */
130
+ static watch(log, formats, outputs, onExport) {
131
+ return Spec.unmarked(Spec.api(log).watch({
132
+ config: Spec.configFiles(log),
133
+ basedir: Artifact.basedir(log, "spec"),
134
+ formats,
135
+ outputs,
136
+ onExport
137
+ }));
138
+ }
139
+ /* serve the HTML export of the specification as a live preview,
140
+ kept in sync with its sources and pushed into the connected
141
+ browsers as an in-place document update */
142
+ static preview(log, addr, port) {
143
+ return Spec.unmarked(Spec.api(log).preview({
144
+ config: Spec.configFiles(log),
145
+ basedir: Artifact.basedir(log, "spec"),
146
+ addr,
147
+ port
148
+ }));
149
+ }
115
150
  }
116
151
  /* CLI command "ase spec" */
117
152
  export default class SpecCommand {
@@ -124,7 +159,7 @@ export default class SpecCommand {
124
159
  /* register CLI top-level command "ase spec" */
125
160
  const spec = program
126
161
  .command("spec")
127
- .description("Lint and export the SpecBook-based project specification")
162
+ .description("Lint, export, and preview the SpecBook-based project specification")
128
163
  .action(() => {
129
164
  spec.outputHelp();
130
165
  process.exit(1);
@@ -133,14 +168,14 @@ export default class SpecCommand {
133
168
  spec
134
169
  .command("lint")
135
170
  .description("Lint the specification Markdown files against the SpecBook schema configuration")
136
- .option("-v, --verbose", "print verbose processing information and each diagnostic with its affected source snippet")
171
+ .option("-v, --verbose", "print each diagnostic with its affected source snippet")
137
172
  .action(async (opts) => {
138
- const diagnostics = await Spec.lint(this.log, opts.verbose === true);
173
+ const diagnostics = await Spec.lint(this.log);
139
174
  for (const diagnostic of diagnostics)
140
175
  await writeStdout(opts.verbose === true ?
141
176
  Spec.render(diagnostic, process.stdout.isTTY === true) :
142
177
  `${renderDiagnostic(diagnostic)}\n`);
143
- if (diagnostics.length > 0)
178
+ if (diagnostics.some((diagnostic) => diagnostic.severity === "error"))
144
179
  process.exitCode = 1;
145
180
  });
146
181
  /* register CLI sub-command "ase spec export" */
@@ -150,21 +185,47 @@ export default class SpecCommand {
150
185
  .option("-o, --output <[format:]file>", "output file (\"-\" for stdout, repeatable), with the format inferred " +
151
186
  "from the filename extension unless explicitly prefixed " +
152
187
  "(default: \"index.html\" inside the specification base directory)", (value, previous) => previous.concat(value), new Array())
153
- .option("-v, --verbose", "print verbose processing information")
188
+ .option("-w, --watch", "keep the outputs in sync by re-exporting on every source change")
154
189
  .action(async (opts) => {
155
190
  const outputs = (opts.output.length > 0 ? opts.output :
156
191
  [path.join(Artifact.basedir(this.log, "spec"), "index.html")]).map(parseOutputSpec);
192
+ /* a re-export has to land somewhere it can be picked up again,
193
+ which a one-shot stdout stream cannot provide */
194
+ if (opts.watch === true && outputs.some(({ output }) => output === "-"))
195
+ throw new Error("the watch mode requires regular output files " +
196
+ "(\"-\" for stdout is not supported)");
197
+ /* parse the input once and export each distinct format once */
157
198
  const distinct = Array.from(new Set(outputs.map(({ format }) => format)));
158
- const buffers = await Spec.export(this.log, distinct, opts.verbose === true);
159
- for (const { format, output } of outputs) {
160
- const data = buffers[distinct.indexOf(format)];
161
- if (output === "-")
162
- await writeStdout(data);
163
- else {
164
- await fs.promises.writeFile(output, data);
165
- this.log.write("info", `spec: exported specification into "${output}" (${data.length} bytes)`);
199
+ const write = async (buffers) => {
200
+ for (const { format, output } of outputs) {
201
+ const data = buffers[distinct.indexOf(format)];
202
+ if (output === "-")
203
+ await writeStdout(data);
204
+ else {
205
+ await fs.promises.writeFile(output, data);
206
+ this.log.write("info", `spec: exported specification into "${output}" (${data.length} bytes)`);
207
+ }
166
208
  }
209
+ };
210
+ if (opts.watch === true) {
211
+ await Spec.watch(this.log, distinct, outputs.map(({ output }) => output), write);
212
+ await new Promise(() => { });
167
213
  }
214
+ else
215
+ await write(await Spec.export(this.log, distinct));
216
+ });
217
+ /* register CLI sub-command "ase spec preview" */
218
+ spec
219
+ .command("preview")
220
+ .description("Serve the HTML export of the specification Markdown files as a live preview")
221
+ .option("-a, --addr <ip-addr>", "IP address to listen on", previewAddr)
222
+ .option("-p, --port <tcp-port>", "TCP port to listen on", String(previewPort))
223
+ .action(async (opts) => {
224
+ const port = Number(opts.port);
225
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
226
+ throw new Error(`invalid TCP port "${opts.port}"`);
227
+ await Spec.preview(this.log, opts.addr, port);
228
+ await new Promise(() => { });
168
229
  });
169
230
  }
170
231
  }
@@ -186,11 +247,13 @@ export class SpecMCP {
186
247
  description: "Lint the SpecBook specification Markdown files of the project (located via the " +
187
248
  "`project.artifact.spec.basedir` configuration) against the SpecBook YAML schema " +
188
249
  "configuration (`project.artifact.spec.schema`, defaulting to the bundled `ase-format-specbook.yaml`). " +
189
- "Returns a `diagnostics` array of `{ file, line, column, message }` objects (with " +
190
- "project-relative `file`), rendered as bullet points in `text`. With `verbose`, " +
250
+ "Returns a `diagnostics` array of `{ file, line, column, severity, message }` objects (with " +
251
+ "project-relative `file` and a `severity` of `error` or `warning`), rendered as bullet " +
252
+ "points in `text`. With `verbose`, " +
191
253
  "each diagnostic additionally carries a multi-line `snippet` rendering with the " +
192
254
  "affected source lines, which is also used for `text`. An empty array " +
193
- "(`text` of `specification valid`) means the specification is valid.",
255
+ "(`text` of `specification valid`) means the specification is valid; only " +
256
+ "diagnostics of `error` severity make an export fail.",
194
257
  inputSchema: {
195
258
  verbose: z.boolean().optional()
196
259
  .describe("if true, render each diagnostic with its affected source snippet (default: false)")
@@ -200,6 +263,7 @@ export class SpecMCP {
200
263
  file: z.string().describe("project-relative file path"),
201
264
  line: z.number().describe("line number (1-based)"),
202
265
  column: z.number().describe("column number (1-based)"),
266
+ severity: z.enum(["error", "warning"]).describe("diagnostic severity"),
203
267
  message: z.string().describe("diagnostic message"),
204
268
  snippet: z.string().optional()
205
269
  .describe("multi-line rendering with the affected source snippet (with `verbose` only)")
@@ -251,7 +315,7 @@ export class SpecMCP {
251
315
  const spec = args.format !== undefined || args.output === undefined ?
252
316
  { format: args.format ?? "json", output: args.output } :
253
317
  parseOutputSpec(args.output);
254
- const [data] = await Spec.export(this.log, [spec.format], false, notices);
318
+ const [data] = await Spec.export(this.log, [spec.format], notices);
255
319
  if (spec.output !== undefined && spec.output !== "-") {
256
320
  await fs.promises.writeFile(path.resolve(Task.projectRoot(), spec.output), data);
257
321
  return {