@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.
- package/dst/ase-artifact.js +7 -56
- package/dst/ase-config.js +6 -9
- package/dst/ase-hook.js +2 -2
- package/dst/ase-ignore.js +129 -0
- package/dst/ase-setup.js +10 -22
- package/dst/ase-spec.js +26 -10
- package/dst/ase-table.js +80 -0
- package/package.json +7 -7
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/.github/plugin/plugin.json +1 -1
- package/plugin/meta/ase-format-spec.md +19 -14
- package/plugin/skills/ase-help-intent/data.md +7 -6
- package/plugin/skills/ase-spec-activate/SKILL.md +14 -12
- package/plugin/skills/ase-spec-activate/help.md +7 -6
package/dst/ase-artifact.js
CHANGED
|
@@ -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
|
|
82
|
-
|
|
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, ...
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
|
|
639
|
+
rows.push([e.key, String(val), Config.scopeLabel(e.scope)]);
|
|
643
640
|
}
|
|
644
|
-
await writeStdout(
|
|
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
|
|
296
|
-
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
|
|
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
|
-
|
|
253
|
+
rows.push(["PLUGIN", chalk.bold("ase@ase"), "(n/a)", "not installed"]);
|
|
260
254
|
else
|
|
261
255
|
for (const plugin of plugins)
|
|
262
|
-
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
process.stdout.write(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
22
|
-
"project.artifact.spec.schema"
|
|
23
|
-
the
|
|
24
|
-
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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`,
|
|
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`, " +
|
package/dst/ase-table.js
ADDED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
46
|
-
"yaml": "2.9.
|
|
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.
|
|
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.
|
|
69
|
+
"@rse/specbook": "1.2.10",
|
|
70
70
|
"source-code-error": "1.2.0",
|
|
71
71
|
"pure-uuid": "2.0.0"
|
|
72
72
|
},
|
|
@@ -4,25 +4,30 @@
|
|
|
4
4
|
SpecBook Project Instantiation
|
|
5
5
|
==============================
|
|
6
6
|
|
|
7
|
-
- The **SpecBook SCHEMA Model** of this project depends on
|
|
8
|
-
|
|
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
|
|
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
|
-
-
|
|
17
|
+
- For each entry `std` in the list:
|
|
20
18
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
(relative to the
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
3176
|
-
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
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-
|
|
71
|
-
of the project (resolved as described
|
|
72
|
-
|
|
73
|
-
`custom` for a project-specific one. Do not
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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-
|
|
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**:
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|