@rse/ase 1.0.4 → 1.0.5

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.
@@ -10,6 +10,7 @@ import { isScalar } from "yaml";
10
10
  import { z } from "zod";
11
11
  import { Config, configSchema } from "./ase-config.js";
12
12
  import { Task } from "./ase-task.js";
13
+ import { Ignore } from "./ase-ignore.js";
13
14
  import { writeStdout } from "./ase-stdio.js";
14
15
  /* the recognized artifact kinds, in descending precedence order;
15
16
  "othr" is the implicit catch-all and is always resolved last */
@@ -26,72 +27,22 @@ export class Artifact {
26
27
  `(expected one of: ${artifactKinds.join(", ")})`);
27
28
  return kind;
28
29
  }
29
- /* translate a single ".gitignore" line into a picomatch-backed rule,
30
- honoring the anchored-vs-floating, directory-only, and negation
31
- semantics of ".gitignore" patterns; anchored patterns resolve
32
- relative to the "base" directory holding the ".gitignore" */
33
- static compileIgnoreRule(line, base) {
34
- let pattern = line.trim();
35
- if (pattern === "" || pattern.startsWith("#"))
36
- return null;
37
- let negated = false;
38
- if (pattern.startsWith("!")) {
39
- negated = true;
40
- pattern = pattern.slice(1);
41
- }
42
- let dirOnly = false;
43
- if (pattern.endsWith("/")) {
44
- dirOnly = true;
45
- pattern = pattern.slice(0, -1);
46
- }
47
- const anchored = pattern.includes("/");
48
- if (pattern.startsWith("/"))
49
- pattern = pattern.slice(1);
50
- const glob = anchored ? (base === "" ? pattern : `${base}/${pattern}`) : `**/${pattern}`;
51
- const isMatch = picomatch(glob, { dot: true });
52
- return { matcher: (p) => isMatch(p), negated, dirOnly };
53
- }
54
- /* load the ".gitignore" rules located directly in a directory,
55
- given as absolute "dir" and project-relative "relDir" */
56
- static loadIgnoreRules(dir, relDir) {
57
- const file = path.join(dir, ".gitignore");
58
- if (!fs.existsSync(file))
59
- return [];
60
- const rules = [];
61
- for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
62
- const rule = Artifact.compileIgnoreRule(line, relDir);
63
- if (rule !== null)
64
- rules.push(rule);
65
- }
66
- return rules;
67
- }
68
- /* decide whether a project-relative path is ignored by the given
69
- ordered ".gitignore" rule set (last matching rule wins) */
70
- static isIgnored(rel, isDir, rules) {
71
- let ignored = false;
72
- for (const rule of rules) {
73
- if (rule.dirOnly && !isDir)
74
- continue;
75
- if (rule.matcher(rel))
76
- ignored = !rule.negated;
77
- }
78
- return ignored;
79
- }
80
30
  /* build the file universe by walking the project tree from the
81
- project root, honoring ".gitignore" rules (root plus nested) and
82
- always pruning ".git". Yields POSIX project-relative, sorted,
31
+ project root, honoring the Git exclude rules (the repository-wide
32
+ ones plus the root and nested ".gitignore" files) and always
33
+ pruning ".git". Yields POSIX project-relative, sorted,
83
34
  de-duplicated file paths */
84
35
  static universe() {
85
36
  const root = Task.projectRoot();
86
37
  const files = new Set();
87
38
  const walk = (dir, relDir, inherited) => {
88
- const rules = [...inherited, ...Artifact.loadIgnoreRules(dir, relDir)];
39
+ const rules = [...inherited, ...Ignore.rules(dir, relDir)];
89
40
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
90
41
  if (entry.name === ".git")
91
42
  continue;
92
43
  const rel = relDir === "" ? entry.name : `${relDir}/${entry.name}`;
93
44
  const isDir = entry.isDirectory();
94
- if (Artifact.isIgnored(rel, isDir, rules))
45
+ if (Ignore.matches(rel, isDir, rules))
95
46
  continue;
96
47
  if (isDir)
97
48
  walk(path.join(dir, entry.name), rel, rules);
@@ -99,7 +50,7 @@ export class Artifact {
99
50
  files.add(rel);
100
51
  }
101
52
  };
102
- walk(root, "", []);
53
+ walk(root, "", Ignore.base(root));
103
54
  return [...files].sort((a, b) => a.localeCompare(b));
104
55
  }
105
56
  /* raw-resolve a single kind's configuration spec against the file
package/dst/ase-config.js CHANGED
@@ -10,12 +10,12 @@ import readline from "node:readline/promises";
10
10
  import { Document, parseDocument, isMap, isScalar } from "yaml";
11
11
  import { execaSync } from "execa";
12
12
  import * as v from "valibot";
13
- import Table from "cli-table3";
14
13
  import writeFileAtomic from "write-file-atomic";
15
14
  import lockfile from "proper-lockfile";
16
15
  import { z } from "zod";
17
16
  import { LRUCache } from "lru-cache";
18
17
  import { writeStdout } from "./ase-stdio.js";
18
+ import { renderTable } from "./ase-table.js";
19
19
  /* classification taxonomy */
20
20
  export const projectClassification = {
21
21
  boxing: ["white", "grey", "black"]
@@ -184,7 +184,8 @@ const artifactSchema = v.optional(v.strictObject({
184
184
  files: v.optional(v.string())
185
185
  }));
186
186
  /* schema for the "spec" artifact kind, additionally carrying the
187
- SpecBook YAML "schema" configuration file (empty: bundled standard) */
187
+ whitespace-separated list of SpecBook YAML "schema" configuration
188
+ files, with "std" naming the bundled standard one (empty: "std") */
188
189
  const artifactSpecSchema = v.optional(v.strictObject({
189
190
  basedir: v.optional(v.string()),
190
191
  files: v.optional(v.string()),
@@ -632,16 +633,12 @@ export default class ConfigCommand {
632
633
  const scope = parseScope(cmd.optsWithGlobals().scope);
633
634
  const cfg = new Config("config", configSchema, this.log, scope);
634
635
  cfg.read();
635
- const table = new Table({
636
- head: ["KEY", "VALUE", "SCOPE"],
637
- chars: { "mid": "", "left-mid": "", "mid-mid": "", "right-mid": "" },
638
- style: { head: ["blue"] }
639
- });
636
+ const rows = [];
640
637
  for (const e of cfg.entries()) {
641
638
  const val = isScalar(e.value) ? e.value.value : e.value;
642
- table.push([e.key, String(val), Config.scopeLabel(e.scope)]);
639
+ rows.push([e.key, String(val), Config.scopeLabel(e.scope)]);
643
640
  }
644
- await writeStdout(`${table.toString()}\n`);
641
+ await writeStdout(renderTable(["KEY", "VALUE", "SCOPE"], rows));
645
642
  });
646
643
  /* register CLI sub-command "ase config edit" */
647
644
  configCmd
package/dst/ase-hook.js CHANGED
@@ -292,8 +292,8 @@ export default class HookCommand {
292
292
  const persona = setting("agent.persona", "ASE_PERSONA_STYLE", "engineer");
293
293
  const guidance = setting("agent.guidance", "ASE_GUIDANCE_LEVEL", "normal");
294
294
  const boxing = setting("project.boxing", "ASE_PROJECT_BOXING", "white");
295
- /* determine the specification base directory and the SpecBook
296
- schema configuration (empty: the bundled standard schema) */
295
+ /* determine the specification base directory and the whitespace-separated
296
+ list of SpecBook schema configurations (empty: the bundled standard schema) */
297
297
  const specBasedir = String(cfg.get("project.artifact.spec.basedir") ?? "");
298
298
  const specSchema = String(cfg.get("project.artifact.spec.schema") ?? "");
299
299
  /* determine headless mode */
@@ -0,0 +1,129 @@
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 path from "node:path";
7
+ import os from "node:os";
8
+ import fs from "node:fs";
9
+ import { execaSync } from "execa";
10
+ import picomatch from "picomatch";
11
+ import { LRUCache } from "lru-cache";
12
+ /* reusable functionality: decide which project-relative paths Git
13
+ excludes from the project, honoring the same three rule sources and
14
+ the same precedence order as Git itself -- the global excludes file,
15
+ the repository-local "info/exclude", and the per-directory
16
+ ".gitignore" files from the project root down to the path */
17
+ export class Ignore {
18
+ /* translate a single Git exclude line into a picomatch-backed rule,
19
+ honoring the anchored-vs-floating, directory-only, and negation
20
+ semantics of the pattern; an anchored pattern resolves relative
21
+ to the "base" directory its rule file governs */
22
+ static compile(line, base) {
23
+ let pattern = line.trim();
24
+ if (pattern === "" || pattern.startsWith("#"))
25
+ return null;
26
+ let negated = false;
27
+ if (pattern.startsWith("!")) {
28
+ negated = true;
29
+ pattern = pattern.slice(1);
30
+ }
31
+ let dirOnly = false;
32
+ if (pattern.endsWith("/")) {
33
+ dirOnly = true;
34
+ pattern = pattern.slice(0, -1);
35
+ }
36
+ const anchored = pattern.includes("/");
37
+ if (pattern.startsWith("/"))
38
+ pattern = pattern.slice(1);
39
+ const glob = anchored ? (base === "" ? pattern : `${base}/${pattern}`) : `**/${pattern}`;
40
+ const isMatch = picomatch(glob, { dot: true });
41
+ return { matcher: (p) => isMatch(p), negated, dirOnly };
42
+ }
43
+ /* load the rules of a single exclude file, given as absolute "file"
44
+ and the project-relative "base" its anchored patterns resolve
45
+ against; a missing file simply contributes no rules */
46
+ static load(file, base) {
47
+ if (file === "" || !fs.existsSync(file))
48
+ return [];
49
+ const rules = [];
50
+ for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
51
+ const rule = Ignore.compile(line, base);
52
+ if (rule !== null)
53
+ rules.push(rule);
54
+ }
55
+ return rules;
56
+ }
57
+ /* load the ".gitignore" rules located directly in a directory,
58
+ given as absolute "dir" and project-relative "relDir" */
59
+ static rules(dir, relDir) {
60
+ return Ignore.load(path.join(dir, ".gitignore"), relDir);
61
+ }
62
+ /* resolve the global Git excludes file: the configured
63
+ "core.excludesFile", or else the XDG location Git falls back
64
+ onto when that configuration value is unset */
65
+ static globalFile() {
66
+ let file = "";
67
+ try {
68
+ file = execaSync("git", ["config", "--get", "core.excludesFile"], { stderr: "ignore" }).stdout.trim();
69
+ }
70
+ catch {
71
+ /* no such configuration value */
72
+ }
73
+ if (file === "") {
74
+ const xdg = process.env.XDG_CONFIG_HOME;
75
+ return xdg !== undefined && xdg !== "" ?
76
+ path.join(xdg, "git", "ignore") :
77
+ path.join(os.homedir(), ".config", "git", "ignore");
78
+ }
79
+ if (file.startsWith("~/"))
80
+ file = path.join(os.homedir(), file.slice(2));
81
+ return file;
82
+ }
83
+ /* resolve the repository-local exclude file, held by the Git
84
+ *common* directory, so that a Git worktree shares the exclude
85
+ file of the main working tree it was created from */
86
+ static infoFile(root) {
87
+ try {
88
+ const dir = execaSync("git", ["rev-parse", "--git-common-dir"], { cwd: root, stderr: "ignore" }).stdout.trim();
89
+ if (dir !== "")
90
+ return path.join(path.resolve(root, dir), "info", "exclude");
91
+ }
92
+ catch {
93
+ /* not inside a Git working tree */
94
+ }
95
+ return "";
96
+ }
97
+ /* cached repository-wide rules (TTL-bounded, as the ASE service is
98
+ long-running and both the Git configuration and the exclude files
99
+ can change underneath it; each determination spawns Git, too) */
100
+ static baseCache = new LRUCache({ max: 4, ttl: 10 * 1000 });
101
+ /* the rules which no directory of the project owns and which hence
102
+ govern the project as a whole, in ascending Git precedence: the
103
+ global excludes file first, the repository-local "info/exclude"
104
+ second, so a later rule overrides an earlier one as it does in
105
+ Git; both anchor their patterns at the project root */
106
+ static base(root) {
107
+ const cached = Ignore.baseCache.get(root);
108
+ if (cached !== undefined)
109
+ return cached;
110
+ const rules = [
111
+ ...Ignore.load(Ignore.globalFile(), ""),
112
+ ...Ignore.load(Ignore.infoFile(root), "")
113
+ ];
114
+ Ignore.baseCache.set(root, rules);
115
+ return rules;
116
+ }
117
+ /* decide whether a project-relative path is excluded by the given
118
+ ordered rule set, where the last matching rule wins */
119
+ static matches(rel, isDir, rules) {
120
+ let ignored = false;
121
+ for (const rule of rules) {
122
+ if (rule.dirOnly && !isDir)
123
+ continue;
124
+ if (rule.matcher(rel))
125
+ ignored = !rule.negated;
126
+ }
127
+ return ignored;
128
+ }
129
+ }
package/dst/ase-setup.js CHANGED
@@ -10,12 +10,12 @@ import { fileURLToPath } from "node:url";
10
10
  import { execa } from "execa";
11
11
  import which from "which";
12
12
  import * as dotenvx from "@dotenvx/dotenvx";
13
- import Table from "cli-table3";
14
13
  import chalk from "chalk";
15
14
  import writeFileAtomic from "write-file-atomic";
16
15
  import { mkdirp } from "mkdirp";
17
16
  import JsonAsty from "json-asty";
18
17
  import Version from "./ase-version.js";
18
+ import { renderTable } from "./ase-table.js";
19
19
  const toolSpecs = {
20
20
  "claude": { cli: "claude", label: "Anthropic Claude Code CLI", pInstall: "install", pRemove: "uninstall", pUpdate: "update" },
21
21
  "copilot": { cli: "copilot", label: "GitHub Copilot CLI", pInstall: "install", pRemove: "uninstall", pUpdate: "update" },
@@ -245,21 +245,15 @@ export default class SetupCommand {
245
245
  const spec = toolSpecs[tool];
246
246
  await this.ensureTool(spec.cli);
247
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
- });
248
+ const rows = [];
255
249
  /* report the ASE plugin registrations, with an absent plugin
256
250
  still rendered as an explicit row instead of being omitted */
257
251
  const plugins = await this.pluginStatus(tool);
258
252
  if (plugins.length === 0)
259
- table.push(["PLUGIN", chalk.bold("ase@ase"), "(n/a)", "not installed"]);
253
+ rows.push(["PLUGIN", chalk.bold("ase@ase"), "(n/a)", "not installed"]);
260
254
  else
261
255
  for (const plugin of plugins)
262
- table.push(["PLUGIN", chalk.bold("ase@ase"), plugin.scope, plugin.status]);
256
+ rows.push(["PLUGIN", chalk.bold("ase@ase"), plugin.scope, plugin.status]);
263
257
  /* report the registered MCP servers, probed concurrently and
264
258
  silently skipping every server which is not registered at all */
265
259
  const scopes = await Promise.all(this.mcpServers.map((handle) => this.mcpScope(tool, handle.server)));
@@ -267,12 +261,12 @@ export default class SetupCommand {
267
261
  const scope = scopes[i];
268
262
  if (scope === undefined)
269
263
  continue;
270
- table.push(["MCP", chalk.bold(this.mcpServers[i].server), scope, "registered"]);
264
+ rows.push(["MCP", chalk.bold(this.mcpServers[i].server), scope, "registered"]);
271
265
  }
272
266
  /* report the ASE statusline registrations */
273
267
  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`);
268
+ rows.push(["STATUSLINE", chalk.bold(statusline.file), statusline.scope, statusline.status]);
269
+ process.stdout.write(renderTable(["KIND", "ID", "SCOPE", "STATUS"], rows));
276
270
  return 0;
277
271
  }
278
272
  /* probe the ASE plugin registrations by scraping the human-readable
@@ -323,15 +317,9 @@ export default class SetupCommand {
323
317
  }
324
318
  /* handler for "ase setup mcp list" */
325
319
  async doMcpList() {
326
- const table = new Table({
327
- head: ["ID", "NAME", "VERS", "MCP", "KEY", "SKILLS"],
328
- colWidths: [16, 16, 8, 21, 17, 20],
329
- wordWrap: true,
330
- chars: { "mid": "", "left-mid": "", "mid-mid": "", "right-mid": "" },
331
- style: { head: ["blue"] }
332
- });
320
+ const rows = [];
333
321
  for (const handle of this.mcpServers)
334
- table.push([
322
+ rows.push([
335
323
  chalk.bold(handle.id),
336
324
  handle.name,
337
325
  handle.version ?? "(unknown)",
@@ -339,7 +327,7 @@ export default class SetupCommand {
339
327
  handle.env.join(", "),
340
328
  handle.skills.join(", ")
341
329
  ]);
342
- process.stdout.write(`${table.toString()}\n`);
330
+ process.stdout.write(renderTable(["ID", "NAME", "VERS", "MCP", "KEY", "SKILLS"], rows));
343
331
  return 0;
344
332
  }
345
333
  /* handler for "ase setup mcp activate|deactivate [<servers>]" */
package/dst/ase-spec.js CHANGED
@@ -18,18 +18,22 @@ import { writeStdout } from "./ase-stdio.js";
18
18
  project specification, located via the "project.artifact.spec.basedir"
19
19
  and "project.artifact.spec.schema" configuration */
20
20
  export class Spec {
21
- /* resolve the YAML schema configuration files: the configured
22
- "project.artifact.spec.schema" relative to the project root, or
23
- the bundled standard "ase-format-specbook.yaml" plugin meta file
24
- if unset or empty */
21
+ /* resolve the YAML schema configuration files: the whitespace-separated
22
+ entries of the configured "project.artifact.spec.schema", merged in
23
+ the given order, where each entry is either the literal "std" for the
24
+ bundled standard "ase-format-specbook.yaml" plugin meta file or a file
25
+ path relative to the project root, and an unset or empty value means "std" */
25
26
  static configFiles(log) {
26
27
  const cfg = new Config("config", configSchema, log);
27
28
  cfg.read();
28
29
  const val = cfg.get("project.artifact.spec.schema");
29
- const file = val === undefined ? "" : String(isScalar(val) ? val.value : val);
30
- if (file === "")
31
- return [Meta.resolve("ase-format-specbook.yaml")];
32
- return [path.resolve(Task.projectRoot(), file)];
30
+ const list = val === undefined ? "" : String(isScalar(val) ? val.value : val);
31
+ const entries = list.split(/\s+/).filter((entry) => entry !== "");
32
+ if (entries.length === 0)
33
+ entries.push("std");
34
+ return entries.map((entry) => entry === "std" ?
35
+ Meta.resolve("ase-format-specbook.yaml") :
36
+ path.resolve(Task.projectRoot(), entry));
33
37
  }
34
38
  /* the ASE log level each SpecBook verbosity level maps onto: the
35
39
  "none" level carries the messages a bare SpecBook CLI run always
@@ -80,12 +84,19 @@ export class Spec {
80
84
  return file;
81
85
  return rel.replace(/\\/g, "/");
82
86
  }
87
+ /* every specification command honors the Git exclude rules, so that
88
+ SpecBook resolves the very same artifact files as "ase artifact
89
+ list --kind spec": both consult ".gitignore", so a specification
90
+ the project does not track is invisible to both instead of only
91
+ to one of them */
92
+ static gitignore = true;
83
93
  /* lint the specification Markdown files below the "spec" artifact
84
94
  base directory against the schema configuration */
85
95
  static async lint(log) {
86
96
  const result = await Spec.unmarked(Spec.api(log).lint({
87
97
  config: Spec.configFiles(log),
88
- basedir: Artifact.basedir(log, "spec")
98
+ basedir: Artifact.basedir(log, "spec"),
99
+ gitignore: Spec.gitignore
89
100
  }));
90
101
  return result.diagnostics.map((d) => ({ ...d, file: Spec.relativize(d.file) }));
91
102
  }
@@ -120,6 +131,7 @@ export class Spec {
120
131
  return Spec.unmarked(Spec.api(log, notices).export({
121
132
  config: Spec.configFiles(log),
122
133
  basedir: Artifact.basedir(log, "spec"),
134
+ gitignore: Spec.gitignore,
123
135
  formats
124
136
  }));
125
137
  }
@@ -131,6 +143,7 @@ export class Spec {
131
143
  return Spec.unmarked(Spec.api(log).watch({
132
144
  config: Spec.configFiles(log),
133
145
  basedir: Artifact.basedir(log, "spec"),
146
+ gitignore: Spec.gitignore,
134
147
  formats,
135
148
  outputs,
136
149
  onExport
@@ -143,6 +156,7 @@ export class Spec {
143
156
  return Spec.unmarked(Spec.api(log).preview({
144
157
  config: Spec.configFiles(log),
145
158
  basedir: Artifact.basedir(log, "spec"),
159
+ gitignore: Spec.gitignore,
146
160
  addr,
147
161
  port
148
162
  }));
@@ -246,7 +260,9 @@ export class SpecMCP {
246
260
  title: "ASE SpecBook lint",
247
261
  description: "Lint the SpecBook specification Markdown files of the project (located via the " +
248
262
  "`project.artifact.spec.basedir` configuration) against the SpecBook YAML schema " +
249
- "configuration (`project.artifact.spec.schema`, defaulting to the bundled `ase-format-specbook.yaml`). " +
263
+ "configuration (`project.artifact.spec.schema`, a whitespace-separated list of schema files " +
264
+ "merged in order, with `std` naming the bundled standard `ase-format-specbook.yaml`, " +
265
+ "which is also the default). " +
250
266
  "Returns a `diagnostics` array of `{ file, line, column, severity, message }` objects (with " +
251
267
  "project-relative `file` and a `severity` of `error` or `warning`), rendered as bullet " +
252
268
  "points in `text`. With `verbose`, " +
@@ -0,0 +1,80 @@
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 Table from "cli-table3";
7
+ import { Diagram } from "./ase-diagram.js";
8
+ /* assumed terminal width in case the output is not a terminal at all */
9
+ const termWidthFallback = 80;
10
+ /* content width a column is at most shrunk down to, unless its own
11
+ content or its column head already is narrower anyway */
12
+ const colWidthMinimum = 8;
13
+ /* measure the visible width of a cell content, ignoring ANSI escape
14
+ sequences and honoring the longest line of a multi-line content */
15
+ const cellWidth = (text) => text.split("\n").reduce((width, line) =>
16
+ /* eslint-disable-next-line no-control-regex */
17
+ Math.max(width, line.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "").length), 0);
18
+ /* check whether a cell content carries a single token which does not
19
+ fit the column and hence can be wrapped on character boundaries only */
20
+ const hasOverlongToken = (text, width) => text.split(/\s+/).some((token) => cellWidth(token) > width);
21
+ /* hard-break all over-long tokens of a cell content, so the content as
22
+ a whole can still be wrapped on its regular word boundaries; tokens
23
+ carrying ANSI escape sequences are left alone, as breaking them
24
+ would tear apart their escape sequences */
25
+ const breakOverlongTokens = (text, width) => text.split(/(\s+)/).map((token) => {
26
+ if (cellWidth(token) <= width || /\s/.test(token) || token.includes("\x1b"))
27
+ return token;
28
+ const chunks = [];
29
+ for (let i = 0; i < token.length; i += width)
30
+ chunks.push(token.slice(i, i + width));
31
+ return chunks.join("\n");
32
+ }).join("");
33
+ /* reduce the column content widths until their sum fits the available
34
+ budget, by binary-searching the largest common upper cap which still
35
+ fits and which never cuts a column below its individual minimum */
36
+ const capWidths = (natural, minimum, budget) => {
37
+ const capped = (cap) => natural.map((width, i) => Math.max(Math.min(width, cap), Math.min(width, minimum[i])));
38
+ const total = (widths) => widths.reduce((sum, width) => sum + width, 0);
39
+ let lo = 1;
40
+ let hi = Math.max(...natural, 1);
41
+ while (lo < hi) {
42
+ const mid = Math.floor((lo + hi + 1) / 2);
43
+ if (total(capped(mid)) <= budget)
44
+ lo = mid;
45
+ else
46
+ hi = mid - 1;
47
+ }
48
+ return capped(lo);
49
+ };
50
+ /* render a table which never exceeds the terminal width, by wrapping
51
+ over-wide cell contents over multiple lines instead */
52
+ export const renderTable = (head, rows) => {
53
+ const cols = head.length;
54
+ /* determine the natural, still unwrapped content width per column */
55
+ const natural = head.map((text, i) => rows.reduce((width, row) => Math.max(width, cellWidth(row[i] ?? "")), cellWidth(text)));
56
+ /* shrink the columns down to the width still remaining after the
57
+ border characters and the padding spaces of all columns */
58
+ const term = Diagram.detectTermWidth() || termWidthFallback;
59
+ const budget = Math.max(term - (3 * cols + 1), cols);
60
+ const minimum = head.map((text) => Math.max(cellWidth(text), colWidthMinimum));
61
+ const widths = capWidths(natural, minimum, budget);
62
+ /* render the table, falling back to wrapping on character
63
+ boundaries in just those cells where a token still overflows */
64
+ const table = new Table({
65
+ head,
66
+ colWidths: widths.map((width) => width + 2),
67
+ wordWrap: true,
68
+ chars: { "mid": "", "left-mid": "", "mid-mid": "", "right-mid": "" },
69
+ style: { head: ["blue"] }
70
+ });
71
+ for (const row of rows)
72
+ table.push(row.map((text, i) => {
73
+ const content = breakOverlongTokens(text, widths[i]);
74
+ return {
75
+ content,
76
+ wrapOnWordBoundary: !hasOverlongToken(content, widths[i])
77
+ };
78
+ }));
79
+ return `${table.toString()}\n`;
80
+ };
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "homepage": "https://ase.tools",
7
7
  "repository": { "url": "git+https://github.com/rse/ase.git", "type": "git" },
8
8
  "bugs": { "url": "https://github.com/rse/ase/issues" },
9
- "version": "1.0.4",
9
+ "version": "1.0.5",
10
10
  "license": "Apache-2.0",
11
11
  "author": {
12
12
  "name": "Dr. Ralf S. Engelschall",
@@ -26,11 +26,11 @@
26
26
  "globals": "17.12.0",
27
27
  "typescript": "6.0.3",
28
28
 
29
- "@rse/stx": "1.1.6",
29
+ "@rse/stx": "1.1.7",
30
30
  "nodemon": "3.1.14",
31
31
  "shx": "0.4.0",
32
32
 
33
- "@types/node": "26.5.0",
33
+ "@types/node": "26.5.1",
34
34
  "@types/luxon": "3.7.5",
35
35
  "@types/which": "3.0.4",
36
36
  "@types/update-notifier": "6.0.8",
@@ -42,8 +42,8 @@
42
42
  },
43
43
  "dependencies": {
44
44
  "commander": "15.0.0",
45
- "@dotenvx/dotenvx": "2.23.0",
46
- "yaml": "2.9.0",
45
+ "@dotenvx/dotenvx": "2.24.1",
46
+ "yaml": "2.9.1",
47
47
  "valibot": "1.5.0",
48
48
  "execa": "10.0.1",
49
49
  "mkdirp": "3.0.1",
@@ -55,7 +55,7 @@
55
55
  "luxon": "3.7.2",
56
56
  "@modelcontextprotocol/sdk": "1.30.0",
57
57
  "json-asty": "1.3.4",
58
- "zod": "4.6.1",
58
+ "zod": "4.6.4",
59
59
  "which": "7.0.0",
60
60
  "update-notifier": "7.3.1",
61
61
  "shell-quote": "1.10.0",
@@ -66,7 +66,7 @@
66
66
  "ofetch": "1.5.1",
67
67
  "picomatch": "4.0.7",
68
68
  "lru-cache": "11.5.2",
69
- "@rse/specbook": "1.2.9",
69
+ "@rse/specbook": "1.2.10",
70
70
  "source-code-error": "1.2.0",
71
71
  "pure-uuid": "2.0.0"
72
72
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ase",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "Agentic Software Engineering (ASE)",
5
5
  "keywords": [ "agentic", "software", "engineering" ],
6
6
  "homepage": "https://ase.tools",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ase",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "Agentic Software Engineering (ASE)",
5
5
  "keywords": [ "agentic", "software", "engineering" ],
6
6
  "homepage": "https://ase.tools",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ase",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "Agentic Software Engineering (ASE)",
5
5
  "keywords": [ "agentic", "software", "engineering" ],
6
6
  "homepage": "https://ase.tools",
@@ -4,25 +4,30 @@
4
4
  SpecBook Project Instantiation
5
5
  ==============================
6
6
 
7
- - The **SpecBook SCHEMA Model** of this project depends on
8
- the value of the <ase-spec-schema/> placeholder (holding the
9
- `project.artifact.spec.schema` configuration value):
7
+ - The **SpecBook SCHEMA Model** of this project depends on the value
8
+ of the <ase-spec-schema/> placeholder (holding the
9
+ `project.artifact.spec.schema` configuration value), which is a
10
+ *whitespace-separated list* of schema entries, merged in the given
11
+ order into *one* **SpecBook SCHEMA Model**:
10
12
 
11
13
  - If <ase-spec-schema/> is *empty*:
12
14
 
13
- Then the **SpecBook SCHEMA Model** is the standard YAML
14
- schema configuration bundled with **ASE** in the file
15
- `meta/ase-format-specbook.yaml` (relative to the plugin root),
16
- which you *MUST* read via the `Read` tool before working on the
17
- specification of the **SpecBook SPEC Model**.
15
+ Then the list is treated as the single entry `std`.
18
16
 
19
- - If <ase-spec-schema/> is *not empty*:
17
+ - For each entry `std` in the list:
20
18
 
21
- Then the **SpecBook SCHEMA Model** is the custom YAML schema
22
- configuration of the project in the file <ase-spec-schema/>
23
- (relative to the project root), which you *MUST* read via
24
- the `Read` tool before working on the specification of the
25
- **SpecBook SPEC Model**.
19
+ The entry names the standard YAML schema configuration bundled
20
+ with **ASE** in the file `meta/ase-format-specbook.yaml`
21
+ (relative to the plugin root).
22
+
23
+ - For each *other* entry in the list:
24
+
25
+ The entry names a custom YAML schema configuration file of the
26
+ project (relative to the project root).
27
+
28
+ You *MUST* read all resulting schema configuration files via the
29
+ `Read` tool before working on the specification of the **SpecBook
30
+ SPEC Model**.
26
31
 
27
32
  - The **SpecBook SPEC Model** of this project is the set of specification
28
33
  Markdown files in the directory <ase-spec-basedir/> (relative
@@ -3172,12 +3172,13 @@ Generate a user-wide skill and overwrite a previous generation:
3172
3172
  The `ase-spec-activate` skill activates the know-how about the
3173
3173
  *SpecBook*-based specification (`SPEC`) of the project in the current
3174
3174
  session: it loads the *SpecBook* format contract, reads the *SpecBook*
3175
- schema configuration of the project (the standard schema bundled with
3176
- *ASE*, or the custom schema configured via `project.artifact.spec.schema`),
3177
- and resolves the list of `SPEC` artifacts in the configured specification
3178
- base directory. The activation is reported in a `SPEC ACTIVATED` box,
3179
- listing the schema file (marked `standard` or `custom`) and the base
3180
- directory with the number of resolved artifacts.
3175
+ schema configuration of the project (the schema files configured via
3176
+ `project.artifact.spec.schema`, where the entry `std` -- also the default
3177
+ -- names the standard schema bundled with *ASE*), and resolves the list
3178
+ of `SPEC` artifacts in the configured specification base directory. The
3179
+ activation is reported in a `SPEC ACTIVATED` box, listing the schema
3180
+ files (each marked `standard` or `custom`) and the base directory with
3181
+ the number of resolved artifacts.
3181
3182
 
3182
3183
  Once activated, the specification can be worked with *ad-hoc* in plain
3183
3184
  conversation for the remainder of the session: reading, querying, and
@@ -67,19 +67,21 @@ an ad-hoc change, and it *never* touches the artifact kinds `CODE`,
67
67
 
68
68
  3. **Activate Schema:**
69
69
 
70
- 1. Set <schema-file/> to the path of the **SpecBook SCHEMA Model**
71
- of the project (resolved as described in `ase-format-spec.md`)
72
- and <schema-kind/> to `standard` for the bundled schema or
73
- `custom` for a project-specific one. Do not output anything.
74
-
75
- 2. <if condition="the content of <schema-file/> was *not* already read into the current context -- neither by an earlier run of this skill nor by a dedicated specification skill">
76
- Read the **SpecBook SCHEMA Model** in <schema-file/> via the
77
- `Read` tool to learn the allowed object kinds, properties,
78
- nestings, and value constraints. Do not output anything.
70
+ 1. Set <schema-files/> to the list of file paths constituting the
71
+ **SpecBook SCHEMA Model** of the project (resolved as described
72
+ in `ase-format-spec.md`), each annotated with `standard` for the
73
+ bundled schema or `custom` for a project-specific one. Do not
74
+ output anything.
75
+
76
+ 2. <if condition="the content of a file of <schema-files/> was *not* already read into the current context -- neither by an earlier run of this skill nor by a dedicated specification skill">
77
+ Read those files of the **SpecBook SCHEMA Model** in
78
+ <schema-files/> via the `Read` tool to learn the allowed object
79
+ kinds, properties, nestings, and value constraints. Do not
80
+ output anything.
79
81
  </if>
80
82
  <else>
81
- Do *not* read <schema-file/> again -- its content is already
82
- active in the context. Do not output anything.
83
+ Do *not* read the files of <schema-files/> again -- their content
84
+ is already active in the context. Do not output anything.
83
85
  </else>
84
86
 
85
87
  4. **Activate Artifacts:**
@@ -98,7 +100,7 @@ an ad-hoc change, and it *never* touches the artifact kinds `CODE`,
98
100
  <template>
99
101
  <ase-tpl-boxed title="SPEC" subtitle="ACTIVATED">
100
102
 
101
- **SCHEMA**: `<schema-file/>` (<schema-kind/>)
103
+ **SCHEMA**: <schema-files/> (one `<file/>` (`standard`|`custom`) entry per line, aligned)
102
104
  **SPEC**: `<ase-spec-basedir/>` (<artifact-count/> artifacts)
103
105
 
104
106
  </ase-tpl-boxed>
@@ -14,12 +14,13 @@
14
14
  The `ase-spec-activate` skill activates the know-how about the
15
15
  *SpecBook*-based specification (`SPEC`) of the project in the current
16
16
  session: it loads the *SpecBook* format contract, reads the *SpecBook*
17
- schema configuration of the project (the standard schema bundled with
18
- *ASE*, or the custom schema configured via `project.artifact.spec.schema`),
19
- and resolves the list of `SPEC` artifacts in the configured specification
20
- base directory. The activation is reported in a `SPEC ACTIVATED` box,
21
- listing the schema file (marked `standard` or `custom`) and the base
22
- directory with the number of resolved artifacts.
17
+ schema configuration of the project (the schema files configured via
18
+ `project.artifact.spec.schema`, where the entry `std` -- also the default
19
+ -- names the standard schema bundled with *ASE*), and resolves the list
20
+ of `SPEC` artifacts in the configured specification base directory. The
21
+ activation is reported in a `SPEC ACTIVATED` box, listing the schema
22
+ files (each marked `standard` or `custom`) and the base directory with
23
+ the number of resolved artifacts.
23
24
 
24
25
  Once activated, the specification can be worked with *ad-hoc* in plain
25
26
  conversation for the remainder of the session: reading, querying, and