@rse/ase 2.0.4 → 2.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dst/ase-config-schema.js +1 -1
- package/dst/ase-hook.js +12 -3
- package/dst/ase-setup-settings.js +111 -1
- package/dst/ase-setup.js +19 -7
- package/dst/ase-spec.js +184 -49
- package/dst/ase-task-board-tui-model.js +8 -1
- package/dst/ase-task-board-tui.js +26 -2
- package/dst/ase-task-board-web-client.js +10 -10
- package/dst/man/ase-artifact.md +1 -1
- package/dst/man/ase-config.md +5 -3
- package/dst/man/ase-mcp.md +1 -1
- package/dst/man/ase-setup-install.md +10 -1
- package/dst/man/ase-setup-status.md +6 -0
- package/dst/man/ase-setup-uninstall.md +3 -1
- package/dst/man/ase-setup-update.md +3 -0
- package/dst/man/ase-setup.md +4 -4
- package/dst/man/ase-spec-export.md +14 -3
- package/dst/man/ase-spec-init.md +10 -1
- package/dst/man/ase-spec-lint.md +2 -1
- package/dst/man/ase-spec-preview.md +10 -1
- package/dst/man/ase-spec-query.md +96 -0
- package/dst/man/ase-spec.md +32 -11
- package/dst/man/ase.md +3 -3
- package/package.json +21 -18
- package/plugin/.claude-plugin/icon.png +0 -0
- 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 +29 -5
- package/plugin/meta/ase-format-specbook.md +427 -47
- package/plugin/meta/ase-format-specbook.yaml +180 -78
- package/plugin/mods/ase-mods-common.ts +474 -0
- package/plugin/mods/ase-mods-evaluate.ts +227 -0
- package/plugin/mods/ase-mods-grill.ts +283 -0
- package/plugin/mods/ase-mods-help-browse.ts +410 -0
- package/plugin/mods/ase-mods-help-compose.ts +561 -0
- package/plugin/mods/ase-mods-help-find.ts +434 -0
- package/plugin/mods/ase-mods-help-hint.ts +134 -0
- package/plugin/mods/ase-mods-help-show.ts +142 -0
- package/plugin/mods/ase-mods.ts +220 -381
- package/plugin/skills/ase-code-lint/SKILL.md +15 -15
- package/plugin/skills/ase-code-lint/help.md +21 -8
- package/plugin/skills/ase-help-intent/SKILL.md +2 -1
- package/plugin/skills/ase-help-intent/data.md +42 -16
- package/plugin/skills/ase-spec-activate/SKILL.md +4 -1
- package/plugin/skills/ase-spec-lint/SKILL.md +15 -15
- package/plugin/skills/ase-spec-lint/help.md +21 -8
- package/plugin/mods/claude-code.d.ts +0 -13440
package/dst/ase-config-schema.js
CHANGED
|
@@ -61,7 +61,7 @@ export const projectClassificationPresets = {
|
|
|
61
61
|
"project.task.effort.line": "0.025",
|
|
62
62
|
"project.task.effort.iteration": "2.5",
|
|
63
63
|
"project.artifact.spec.basedir": "docs/specbook",
|
|
64
|
-
"project.artifact.spec.files": "
|
|
64
|
+
"project.artifact.spec.files": "**/*.{md,txt,svg,png,jpg}",
|
|
65
65
|
"project.artifact.spec.schema": "",
|
|
66
66
|
"project.artifact.code.basedir": "src",
|
|
67
67
|
"project.artifact.code.files": "** !**/etc/** !**/{.gitignore,.npmignore,package.json}",
|
package/dst/ase-hook.js
CHANGED
|
@@ -462,7 +462,8 @@ export default class HookCommand {
|
|
|
462
462
|
guaranteed to appear once in every non-headless session;
|
|
463
463
|
Anthropic Claude Code CLI and OpenAI Codex CLI surface a top-level
|
|
464
464
|
"systemMessage" field for this -- GitHub Copilot CLI has no equivalent);
|
|
465
|
-
the trailing help
|
|
465
|
+
the trailing help hints are emitted only if the guidance level asks for them,
|
|
466
|
+
and the inline ones only for Anthropic Claude Code CLI, which alone runs the mods */
|
|
466
467
|
const banner = "\n" +
|
|
467
468
|
`\n⧉ ASE: ⎈ version: ${version}${versionHint !== "" ? " " + versionHint.replace(/\*/g, "") : ""}` +
|
|
468
469
|
`\n⧉ ASE: ※ user: ${userId}, ⚑ project: ${projectId}` +
|
|
@@ -470,8 +471,16 @@ export default class HookCommand {
|
|
|
470
471
|
`\n⧉ ASE: ☯ persona: ${persona}, ▶ guidance: ${guidance}, ▢ boxing: ${boxing}` +
|
|
471
472
|
(guidance === "normal" || guidance === "verbose" ?
|
|
472
473
|
"\n" +
|
|
473
|
-
"\n⧉ ASE: ▷ hint: use \"/ase-help-intent <intent
|
|
474
|
-
"\n⧉ ASE: ▷ hint: use \"/ase-help-skill [<skill
|
|
474
|
+
"\n⧉ ASE: ▷ hint: use \"/ase-help-intent <intent>\" for conversational skill command proposals" +
|
|
475
|
+
"\n⧉ ASE: ▷ hint: use \"/ase-help-skill [<skill>]\" for conversational skill catalog or manpage lookup" +
|
|
476
|
+
(tool === "claude" ?
|
|
477
|
+
"\n⧉ ASE: ▷ hint: use \"<intent> !!\" for inline skill command proposals" : "") +
|
|
478
|
+
(tool === "claude" ?
|
|
479
|
+
"\n⧉ ASE: ▷ hint: use \"/<skill> !!\" for inline skill command composition" : "") +
|
|
480
|
+
(tool === "claude" ?
|
|
481
|
+
"\n⧉ ASE: ▷ hint: use \"/<skill> //\" for inline skill manpage lookup" : "") +
|
|
482
|
+
(tool === "claude" ?
|
|
483
|
+
"\n⧉ ASE: ▷ hint: use \"[<filter>] //\" for inline skill manpage browsing" : "") : "");
|
|
475
484
|
/* inject markdown into session context.
|
|
476
485
|
Anthropic Claude Code CLI and OpenAI Codex CLI expect the context nested in
|
|
477
486
|
"hookSpecificOutput"; GitHub Copilot CLI expects a flat top-level
|
|
@@ -11,7 +11,7 @@ import { mkdirp } from "mkdirp";
|
|
|
11
11
|
import JsonAsty from "json-asty";
|
|
12
12
|
import { toolSpecs } from "./ase-setup-common.js";
|
|
13
13
|
import { telemetryEndpoint, telemetryEnabled } from "./ase-service-otel.js";
|
|
14
|
-
/* settings file handling of "ase setup statusline", "ase setup telemetry", and the
|
|
14
|
+
/* settings file handling of "ase setup statusline", "ase setup telemetry", the output style, and the function hooks */
|
|
15
15
|
export class SetupSettings {
|
|
16
16
|
log;
|
|
17
17
|
constructor(log) {
|
|
@@ -358,6 +358,116 @@ export class SetupSettings {
|
|
|
358
358
|
}
|
|
359
359
|
return entries;
|
|
360
360
|
}
|
|
361
|
+
/* the environment variable enabling the function hooks of the plugin-shipped mods
|
|
362
|
+
(Anthropic Claude Code CLI only, as the other tools have no function hooks concept) */
|
|
363
|
+
functionHooksVar = "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS";
|
|
364
|
+
/* determine the value of the function hooks variable in an "env" object */
|
|
365
|
+
functionHooksValue(env) {
|
|
366
|
+
const val = this.settingsFindMember(env, this.functionHooksVar)?.query("/ * [ pos() == 2 ]")[0];
|
|
367
|
+
return val !== undefined ? String(val.get("value") ?? "") : undefined;
|
|
368
|
+
}
|
|
369
|
+
/* enable the function hooks of the plugin-shipped mods by setting their
|
|
370
|
+
variable in the "env" of the settings file of the scope */
|
|
371
|
+
async functionHooksActivate(action, scope) {
|
|
372
|
+
const file = this.settingsFile("claude", scope);
|
|
373
|
+
const root = await this.settingsReadAst(file);
|
|
374
|
+
/* reject a non-object "env" member */
|
|
375
|
+
let member = this.settingsFindMember(root, "env");
|
|
376
|
+
let env = member?.query("/ * [ pos() == 2 ]")[0];
|
|
377
|
+
if (member !== undefined && (env === undefined || env.type() !== "object")) {
|
|
378
|
+
this.log.write("warning", `setup: ${action}: a non-object "env" ` +
|
|
379
|
+
`is present in ${file}: preserving it (skipped)`);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
/* leave an ASE variable as is and preserve a foreign value: skip and warn */
|
|
383
|
+
const value = env !== undefined ? this.functionHooksValue(env) : undefined;
|
|
384
|
+
if (value !== undefined) {
|
|
385
|
+
if (value !== "1")
|
|
386
|
+
this.log.write("warning", `setup: ${action}: a non-ASE "${this.functionHooksVar}" ` +
|
|
387
|
+
`is already present in "env" of ${file}: preserving it (skipped)`);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
/* insert a fresh (still empty) "env" member */
|
|
391
|
+
if (member === undefined || env === undefined) {
|
|
392
|
+
env = root.create("object").set({ prolog: "{", epilog: "}" });
|
|
393
|
+
member = root.create("member").add(root.create("string").set({ body: JSON.stringify("env"), value: "env", epilog: ": " }), env);
|
|
394
|
+
this.settingsAddMember(root, member, false);
|
|
395
|
+
}
|
|
396
|
+
/* add the variable */
|
|
397
|
+
const key = root.create("string").set({
|
|
398
|
+
body: JSON.stringify(this.functionHooksVar),
|
|
399
|
+
value: this.functionHooksVar,
|
|
400
|
+
epilog: ": "
|
|
401
|
+
});
|
|
402
|
+
const val = root.create("string").set({ body: JSON.stringify("1"), value: "1" });
|
|
403
|
+
env.add(root.create("member").add(key, val));
|
|
404
|
+
this.settingsNormalizeObject(env, 1, this.settingsTrailing(root, member));
|
|
405
|
+
await this.settingsWriteAst(file, root);
|
|
406
|
+
this.log.write("info", `setup: ${action}: enabling function hooks in "env" of ${file}`);
|
|
407
|
+
}
|
|
408
|
+
/* disable the function hooks of the plugin-shipped mods by removing
|
|
409
|
+
their variable from the "env" of the settings file of the scope */
|
|
410
|
+
async functionHooksDeactivate(action, scope) {
|
|
411
|
+
const file = this.settingsFile("claude", scope);
|
|
412
|
+
/* a missing settings file means nothing to remove */
|
|
413
|
+
try {
|
|
414
|
+
await fs.access(file);
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
const root = await this.settingsReadAst(file);
|
|
420
|
+
const member = this.settingsFindMember(root, "env");
|
|
421
|
+
const env = member?.query("/ * [ pos() == 2 ]")[0];
|
|
422
|
+
if (member === undefined || env === undefined || env.type() !== "object")
|
|
423
|
+
return;
|
|
424
|
+
const target = this.settingsFindMember(env, this.functionHooksVar);
|
|
425
|
+
if (target === undefined)
|
|
426
|
+
return;
|
|
427
|
+
/* preserve a foreign value: skip and warn */
|
|
428
|
+
if (this.functionHooksValue(env) !== "1") {
|
|
429
|
+
this.log.write("warning", `setup: ${action}: a non-ASE "${this.functionHooksVar}" ` +
|
|
430
|
+
`is present in "env" of ${file}: preserving it (skipped)`);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
/* remove the variable and, if it became empty, the "env" member */
|
|
434
|
+
env.del(target);
|
|
435
|
+
if (env.query("/ member").length === 0)
|
|
436
|
+
this.settingsRemoveMember(root, member);
|
|
437
|
+
else
|
|
438
|
+
this.settingsNormalizeObject(env, 1, this.settingsTrailing(root, member));
|
|
439
|
+
await this.settingsWriteAst(file, root);
|
|
440
|
+
this.log.write("info", `setup: ${action}: disabling function hooks in "env" of ${file}`);
|
|
441
|
+
}
|
|
442
|
+
/* probe the function hooks variables by inspecting the tool
|
|
443
|
+
settings files of all installation scopes (Anthropic Claude Code CLI only) */
|
|
444
|
+
async functionHooksStatus(tool) {
|
|
445
|
+
if (tool !== "claude")
|
|
446
|
+
return [];
|
|
447
|
+
const scopes = ["user", "project", "local"];
|
|
448
|
+
const entries = [];
|
|
449
|
+
for (const scope of scopes) {
|
|
450
|
+
const file = this.settingsFile(tool, scope);
|
|
451
|
+
let root;
|
|
452
|
+
try {
|
|
453
|
+
root = await this.settingsReadAst(file);
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
/* an unparsable settings file carries no usable state */
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
const env = this.settingsFindMember(root, "env")?.query("/ * [ pos() == 2 ]")[0];
|
|
460
|
+
const value = env !== undefined && env.type() === "object" ? this.functionHooksValue(env) : undefined;
|
|
461
|
+
if (value === undefined)
|
|
462
|
+
continue;
|
|
463
|
+
entries.push({
|
|
464
|
+
file: this.fileDisplay(file),
|
|
465
|
+
scope,
|
|
466
|
+
status: value === "1" ? "enabled" : "foreign"
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
return entries;
|
|
470
|
+
}
|
|
361
471
|
/* auto-detect the scope in which the ASE plugin is currently
|
|
362
472
|
installed, so an omitted --scope targets the existing installation
|
|
363
473
|
instead of blindly assuming the per-user scope; only the Anthropic
|
package/dst/ase-setup.js
CHANGED
|
@@ -38,9 +38,12 @@ export default class SetupCommand {
|
|
|
38
38
|
const scopeArgs = tool === "claude" && scope !== "user" ? ["--scope", scope] : [];
|
|
39
39
|
await this.runner.run(spec.cli, ["plugin", "marketplace", "add", source, ...scopeArgs]);
|
|
40
40
|
await this.runner.run(spec.cli, ["plugin", spec.pInstall, "ase@ase", ...scopeArgs], { retries: 3 });
|
|
41
|
-
/* select the plugin-shipped ASE output style
|
|
42
|
-
|
|
41
|
+
/* select the plugin-shipped ASE output style and enable the function
|
|
42
|
+
hooks of the plugin-shipped mods (Anthropic Claude Code CLI only) */
|
|
43
|
+
if (tool === "claude") {
|
|
43
44
|
await this.settings.outputStyleActivate(`install${dev ? "[dev]" : ""}`, scope);
|
|
45
|
+
await this.settings.functionHooksActivate(`install${dev ? "[dev]" : ""}`, scope);
|
|
46
|
+
}
|
|
44
47
|
return 0;
|
|
45
48
|
}
|
|
46
49
|
/* handler for "ase setup update" (all tools) */
|
|
@@ -97,9 +100,12 @@ export default class SetupCommand {
|
|
|
97
100
|
await this.runner.run(spec.cli, ["plugin", spec.pInstall, "ase@ase", ...scopeArgs], { retries: 3 });
|
|
98
101
|
}
|
|
99
102
|
}
|
|
100
|
-
/* select the plugin-shipped ASE output style
|
|
101
|
-
|
|
103
|
+
/* select the plugin-shipped ASE output style and enable the function
|
|
104
|
+
hooks of the plugin-shipped mods (Anthropic Claude Code CLI only) */
|
|
105
|
+
if (tool === "claude") {
|
|
102
106
|
await this.settings.outputStyleActivate(`update${dev ? "[dev]" : ""}`, scope);
|
|
107
|
+
await this.settings.functionHooksActivate(`update${dev ? "[dev]" : ""}`, scope);
|
|
108
|
+
}
|
|
103
109
|
return 0;
|
|
104
110
|
}
|
|
105
111
|
/* handler for "ase setup enable|disable" (all tools) */
|
|
@@ -130,9 +136,12 @@ export default class SetupCommand {
|
|
|
130
136
|
this.log.write("info", `setup: uninstall${dev ? "[dev]" : ""}: ` +
|
|
131
137
|
"stopping potentially running ASE service");
|
|
132
138
|
await this.runner.run("ase", ["service", "stop"], { quiet: true, ignoreError: "ASE service not running" });
|
|
133
|
-
/* deselect the plugin-shipped ASE output style
|
|
134
|
-
|
|
139
|
+
/* deselect the plugin-shipped ASE output style and disable the function
|
|
140
|
+
hooks of the plugin-shipped mods (Anthropic Claude Code CLI only) */
|
|
141
|
+
if (tool === "claude") {
|
|
135
142
|
await this.settings.outputStyleDeactivate(`uninstall${dev ? "[dev]" : ""}`, scope);
|
|
143
|
+
await this.settings.functionHooksDeactivate(`uninstall${dev ? "[dev]" : ""}`, scope);
|
|
144
|
+
}
|
|
136
145
|
/* uninstall ASE plugin */
|
|
137
146
|
this.log.write("info", `setup: uninstall${dev ? "[dev]" : ""}: ` +
|
|
138
147
|
`uninstalling ASE ${spec.label} plugin (origin: ${dev ? "local" : "remote/bundled"})`);
|
|
@@ -172,6 +181,9 @@ export default class SetupCommand {
|
|
|
172
181
|
/* report the ASE output style selections */
|
|
173
182
|
for (const outputStyle of await this.settings.outputStyleStatus(tool))
|
|
174
183
|
rows.push(["OUTPUTSTYLE", chalk.bold(outputStyle.file), outputStyle.scope, outputStyle.status]);
|
|
184
|
+
/* report the function hooks enablements */
|
|
185
|
+
for (const functionHooks of await this.settings.functionHooksStatus(tool))
|
|
186
|
+
rows.push(["FUNCTIONHOOKS", chalk.bold(functionHooks.file), functionHooks.scope, functionHooks.status]);
|
|
175
187
|
/* report the ASE statusline registrations */
|
|
176
188
|
for (const statusline of await this.settings.statuslineStatus(tool))
|
|
177
189
|
rows.push(["STATUSLINE", chalk.bold(statusline.file), statusline.scope, statusline.status]);
|
|
@@ -307,7 +319,7 @@ export default class SetupCommand {
|
|
|
307
319
|
/* register CLI sub-command "ase setup status" */
|
|
308
320
|
setupCmd
|
|
309
321
|
.command("status")
|
|
310
|
-
.description("report the ASE plugin, MCP server, output style, statusline, and telemetry registrations for a tool")
|
|
322
|
+
.description("report the ASE plugin, MCP server, output style, function hooks, statusline, and telemetry registrations for a tool")
|
|
311
323
|
.option("-t, --tool <tool>", "target tool (\"claude\", \"copilot\", or \"codex\")", toolDflt)
|
|
312
324
|
.action(async (opts) => {
|
|
313
325
|
process.exit(await this.doStatus(parseTool(opts.tool)));
|
package/dst/ase-spec.js
CHANGED
|
@@ -16,22 +16,33 @@ import { configSchema } from "./ase-config-schema.js";
|
|
|
16
16
|
import { Task } from "./ase-task.js";
|
|
17
17
|
import { Artifact } from "./ase-artifact.js";
|
|
18
18
|
import { writeStdout } from "./ase-lib-stdio.js";
|
|
19
|
-
/* reusable functionality: initialize, lint, export, and preview the
|
|
19
|
+
/* reusable functionality: initialize, lint, export, query, and preview the
|
|
20
20
|
SpecBook-based project specification, located via the
|
|
21
21
|
"project.artifact.spec.basedir" and "project.artifact.spec.schema"
|
|
22
22
|
configuration */
|
|
23
23
|
export class Spec {
|
|
24
|
-
/* the SpecBook export formats, mirrored here (and checked for completeness
|
|
24
|
+
/* the SpecBook export and query formats, mirrored here (and checked for completeness
|
|
25
25
|
against SpecBook at compile-time) to not load SpecBook for the MCP schema */
|
|
26
26
|
static formats = Object.keys({
|
|
27
27
|
json: true, json5: true, yaml: true, toon: true, html: true, pdf: true, md: true
|
|
28
28
|
});
|
|
29
|
+
static queryFormats = Object.keys({
|
|
30
|
+
json: true, json5: true, yaml: true, toon: true
|
|
31
|
+
});
|
|
32
|
+
/* whether a schema configuration entry is a reference SpecBook resolves
|
|
33
|
+
itself (mirroring its internal check): the bundled "std", "meta", and
|
|
34
|
+
"pm" ones, or a remote "npm:", "github:", or "http[s]://" one */
|
|
35
|
+
static isRemote(entry) {
|
|
36
|
+
return entry === "std" || entry === "meta" || entry === "pm"
|
|
37
|
+
|| (/^(?:npm|github):|^https?:\/\//).test(entry);
|
|
38
|
+
}
|
|
29
39
|
/* resolve the YAML schema configuration files: the whitespace-separated
|
|
30
40
|
entries of the configured "project.artifact.spec.schema", merged in
|
|
31
|
-
the given order, where each entry is either
|
|
32
|
-
full SpecBook standard schema
|
|
33
|
-
"ase-format-specbook.yaml") or a file
|
|
34
|
-
root, and an unset or
|
|
41
|
+
the given order, where each entry is either a reference SpecBook resolves
|
|
42
|
+
itself (like "std" for the full SpecBook standard schema, not the
|
|
43
|
+
agent-compressed plugin meta file "ase-format-specbook.yaml") or a file
|
|
44
|
+
path or glob pattern relative to the project root, and an unset or
|
|
45
|
+
empty value means "std" */
|
|
35
46
|
static configFiles(log) {
|
|
36
47
|
const cfg = new Config("config", configSchema, log);
|
|
37
48
|
cfg.read();
|
|
@@ -40,10 +51,21 @@ export class Spec {
|
|
|
40
51
|
const entries = list.split(/\s+/).filter((entry) => entry !== "");
|
|
41
52
|
if (entries.length === 0)
|
|
42
53
|
entries.push("std");
|
|
43
|
-
return entries.map((entry) => entry
|
|
44
|
-
|
|
54
|
+
return entries.map((entry) => Spec.isRemote(entry) ?
|
|
55
|
+
entry :
|
|
45
56
|
path.resolve(Task.projectRoot(), entry));
|
|
46
57
|
}
|
|
58
|
+
/* the source options of the reading commands: the specification below
|
|
59
|
+
the "spec" artifact base directory, or, under "schema", the effective
|
|
60
|
+
schema configuration itself (to which no base directory applies) */
|
|
61
|
+
static source(log, schema) {
|
|
62
|
+
return {
|
|
63
|
+
config: Spec.configFiles(log),
|
|
64
|
+
basedir: schema ? undefined : Artifact.basedir(log, "spec"),
|
|
65
|
+
gitignore: Spec.gitignore,
|
|
66
|
+
schema
|
|
67
|
+
};
|
|
68
|
+
}
|
|
47
69
|
/* the ASE log level each SpecBook verbosity level maps onto: the
|
|
48
70
|
"none" level carries the messages a bare SpecBook CLI run always
|
|
49
71
|
prints -- the environment notices plus the lifecycle and failure
|
|
@@ -104,15 +126,17 @@ export class Spec {
|
|
|
104
126
|
/* initialize the absent artifact files of the schema configuration below
|
|
105
127
|
the "spec" artifact base directory, letting SpecBook persist its project
|
|
106
128
|
options file ".specbook.yaml" into a throw-away working directory, as
|
|
107
|
-
ASE keeps these options in its own configuration
|
|
108
|
-
|
|
129
|
+
ASE keeps these options in its own configuration, where "sample"
|
|
130
|
+
scaffolds one sample object per allowed object kind into the files */
|
|
131
|
+
static async init(log, sample = false) {
|
|
109
132
|
const basedir = Artifact.basedir(log, "spec");
|
|
110
133
|
const cwd = await fs.promises.mkdtemp(path.join(os.tmpdir(), "ase-spec-"));
|
|
111
134
|
try {
|
|
112
135
|
const result = await Spec.unmarked(Spec.api(log).then((api) => api.init({
|
|
113
136
|
config: Spec.configFiles(log),
|
|
114
137
|
basedir,
|
|
115
|
-
cwd
|
|
138
|
+
cwd,
|
|
139
|
+
sample
|
|
116
140
|
})));
|
|
117
141
|
return result.files.map((file) => Spec.relativize(path.join(basedir, file)));
|
|
118
142
|
}
|
|
@@ -196,38 +220,46 @@ export class Spec {
|
|
|
196
220
|
});
|
|
197
221
|
}
|
|
198
222
|
/* export the specification Markdown files below the "spec" artifact
|
|
199
|
-
base directory into the requested formats, one buffer per format
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
gitignore: Spec.gitignore,
|
|
206
|
-
formats
|
|
223
|
+
base directory into the requested formats, one buffer per format */
|
|
224
|
+
static export(log, formats, options = {}) {
|
|
225
|
+
return Spec.unmarked(Spec.api(log, options.notices).then((api) => api.export({
|
|
226
|
+
...Spec.source(log, options.schema ?? false),
|
|
227
|
+
formats,
|
|
228
|
+
rebase: options.rebase
|
|
207
229
|
})));
|
|
208
230
|
}
|
|
209
231
|
/* export the specification like "export" and then keep the export
|
|
210
232
|
in sync with its sources, handing every fresh set of buffers to
|
|
211
233
|
"onExport", where "outputs" names the files "onExport" writes,
|
|
212
234
|
so an output which is itself an observed source can be refused */
|
|
213
|
-
static watch(log, formats, outputs, onExport) {
|
|
214
|
-
return Spec.unmarked(Spec.api(log).then((api) => api.watch({
|
|
215
|
-
|
|
216
|
-
basedir: Artifact.basedir(log, "spec"),
|
|
217
|
-
gitignore: Spec.gitignore,
|
|
235
|
+
static watch(log, formats, outputs, onExport, options = {}) {
|
|
236
|
+
return Spec.unmarked(Spec.api(log, options.notices).then((api) => api.watch({
|
|
237
|
+
...Spec.source(log, options.schema ?? false),
|
|
218
238
|
formats,
|
|
239
|
+
rebase: options.rebase,
|
|
219
240
|
outputs,
|
|
220
241
|
onExport
|
|
221
242
|
})));
|
|
222
243
|
}
|
|
223
|
-
/*
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
244
|
+
/* query the specification Markdown files below the "spec" artifact
|
|
245
|
+
base directory (or, under "schema", the effective schema configuration)
|
|
246
|
+
with an ASTq selector and its "{name}" parameters, rendering the matched
|
|
247
|
+
nodes in a serialization format and collecting the emitted notices
|
|
248
|
+
(like the lint warnings) if requested */
|
|
249
|
+
static query(log, selector, params, format, notices, schema = false) {
|
|
250
|
+
return Spec.unmarked(Spec.api(log, notices).then(async (api) => {
|
|
251
|
+
const { renderNodes } = await import("@rse/specbook");
|
|
252
|
+
const document = await api.load(Spec.source(log, schema));
|
|
253
|
+
return renderNodes(document.query(selector, params), format);
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
/* serve the HTML export of the specification (or, under "schema", of
|
|
257
|
+
the effective schema configuration) as a live preview, kept in sync
|
|
258
|
+
with its sources and pushed into the connected browsers as an
|
|
259
|
+
in-place document update */
|
|
260
|
+
static preview(log, addr, port, schema = false) {
|
|
227
261
|
return Spec.unmarked(Spec.api(log).then((api) => api.preview({
|
|
228
|
-
|
|
229
|
-
basedir: Artifact.basedir(log, "spec"),
|
|
230
|
-
gitignore: Spec.gitignore,
|
|
262
|
+
...Spec.source(log, schema),
|
|
231
263
|
addr,
|
|
232
264
|
port
|
|
233
265
|
})));
|
|
@@ -244,7 +276,7 @@ export default class SpecCommand {
|
|
|
244
276
|
/* register CLI top-level command "ase spec" */
|
|
245
277
|
const spec = program
|
|
246
278
|
.command("spec")
|
|
247
|
-
.description("Initialize, lint, export, and preview the SpecBook-based project specification")
|
|
279
|
+
.description("Initialize, lint, export, query, and preview the SpecBook-based project specification")
|
|
248
280
|
.action(() => {
|
|
249
281
|
spec.outputHelp();
|
|
250
282
|
process.exit(1);
|
|
@@ -253,8 +285,9 @@ export default class SpecCommand {
|
|
|
253
285
|
spec
|
|
254
286
|
.command("init")
|
|
255
287
|
.description("Initialize the absent specification Markdown files of the SpecBook schema configuration")
|
|
256
|
-
.
|
|
257
|
-
|
|
288
|
+
.option("-s, --sample", "scaffold one sample object per allowed object kind into the created files")
|
|
289
|
+
.action(async (opts) => {
|
|
290
|
+
const files = await Spec.init(this.log, opts.sample === true);
|
|
258
291
|
await writeStdout(files.length > 0 ?
|
|
259
292
|
`initialized artifact file(s): ${files.join(", ")}\n` :
|
|
260
293
|
"no artifact files were created\n");
|
|
@@ -286,36 +319,75 @@ export default class SpecCommand {
|
|
|
286
319
|
.description("Export the specification Markdown files as JSON, JSON5, YAML, TOON, HTML, PDF, or normalized Markdown")
|
|
287
320
|
.option("-o, --output <[format:]file>", "output file (\"-\" for stdout, repeatable), with the format inferred " +
|
|
288
321
|
"from the filename extension unless explicitly prefixed " +
|
|
289
|
-
"(default: \"index.html\"
|
|
322
|
+
"(default: \"index.html\", resp. \"schema.html\" under \"--schema\", " +
|
|
323
|
+
"inside the specification base directory)", (value, previous) => previous.concat(value), new Array())
|
|
290
324
|
.option("-w, --watch", "keep the outputs in sync by re-exporting on every source change")
|
|
325
|
+
.option("-S, --schema", "export the effective schema configuration itself instead of the specification")
|
|
291
326
|
.action(async (opts) => {
|
|
292
327
|
const { parseOutputSpec } = await import("@rse/specbook");
|
|
328
|
+
const schema = opts.schema === true;
|
|
293
329
|
const outputs = (opts.output.length > 0 ? opts.output :
|
|
294
|
-
[path.join(Artifact.basedir(this.log, "spec"), "index.html")])
|
|
330
|
+
[path.join(Artifact.basedir(this.log, "spec"), schema ? "schema.html" : "index.html")])
|
|
331
|
+
.map(parseOutputSpec);
|
|
295
332
|
/* a re-export has to land somewhere it can be picked up again,
|
|
296
333
|
which a one-shot stdout stream cannot provide */
|
|
297
334
|
if (opts.watch === true && outputs.some(({ output }) => output === "-"))
|
|
298
335
|
throw new Error("the watch mode requires regular output files " +
|
|
299
336
|
"(\"-\" for stdout is not supported)");
|
|
300
|
-
/* parse the input once and export each distinct format once
|
|
301
|
-
|
|
337
|
+
/* parse the input once and export each distinct format once, the
|
|
338
|
+
normalized Markdown once per output directory, as its image
|
|
339
|
+
references are re-based onto it (but never for stdout) */
|
|
340
|
+
const rebaseOf = ({ format, output }) => format === "md" && output !== "-" ? path.dirname(path.resolve(output)) : undefined;
|
|
341
|
+
const keyOf = (spec) => `${spec.format}:${rebaseOf(spec) ?? ""}`;
|
|
342
|
+
const distinct = outputs.filter((spec, i) => outputs.findIndex((other) => keyOf(other) === keyOf(spec)) === i);
|
|
302
343
|
const write = async (buffers) => {
|
|
303
|
-
for (const
|
|
304
|
-
const data = buffers[distinct.
|
|
305
|
-
if (output === "-")
|
|
344
|
+
for (const spec of outputs) {
|
|
345
|
+
const data = buffers[distinct.findIndex((other) => keyOf(other) === keyOf(spec))];
|
|
346
|
+
if (spec.output === "-")
|
|
306
347
|
await writeStdout(data);
|
|
307
348
|
else {
|
|
308
|
-
await fs.promises.writeFile(output, data);
|
|
309
|
-
this.log.write("info", `spec: exported specification into "${output}" (${data.length} bytes)`);
|
|
349
|
+
await fs.promises.writeFile(spec.output, data);
|
|
350
|
+
this.log.write("info", `spec: exported specification into "${spec.output}" (${data.length} bytes)`);
|
|
310
351
|
}
|
|
311
352
|
}
|
|
312
353
|
};
|
|
354
|
+
const formats = distinct.map(({ format }) => format);
|
|
355
|
+
const rebase = distinct.map(rebaseOf);
|
|
313
356
|
if (opts.watch === true) {
|
|
314
|
-
await Spec.watch(this.log,
|
|
357
|
+
await Spec.watch(this.log, formats, outputs.map(({ output }) => output), write, { schema, rebase });
|
|
315
358
|
await new Promise(() => { });
|
|
316
359
|
}
|
|
317
360
|
else
|
|
318
|
-
await write(await Spec.export(this.log,
|
|
361
|
+
await write(await Spec.export(this.log, formats, { schema, rebase }));
|
|
362
|
+
});
|
|
363
|
+
/* register CLI sub-command "ase spec query" */
|
|
364
|
+
spec
|
|
365
|
+
.command("query")
|
|
366
|
+
.description("Query the specification Markdown files with an ASTq selector, " +
|
|
367
|
+
"writing the matched nodes as JSON, JSON5, YAML, or TOON")
|
|
368
|
+
.argument("<selector>", "ASTq selector of the nodes to write")
|
|
369
|
+
.option("-P, --param <name=value>", "value of a \"{name}\" parameter of the selector (repeatable)", (value, previous) => previous.concat(value), new Array())
|
|
370
|
+
.option("-f, --format <format>", `output format (${Spec.queryFormats.join(", ")}; ` +
|
|
371
|
+
"default: inferred from the output file like for \"export\", else json)")
|
|
372
|
+
.option("-o, --output <[format:]file>", "output file (\"-\" for stdout)", "-")
|
|
373
|
+
.option("-S, --schema", "query the effective schema configuration itself instead of the specification")
|
|
374
|
+
.action(async (selector, opts) => {
|
|
375
|
+
const { parseQueryOutput } = await import("@rse/specbook");
|
|
376
|
+
const output = parseQueryOutput(opts.output, opts.format);
|
|
377
|
+
const params = {};
|
|
378
|
+
for (const param of opts.param) {
|
|
379
|
+
const m = (/^([^=]+)=(.*)$/s).exec(param);
|
|
380
|
+
if (m === null)
|
|
381
|
+
throw new Error(`invalid query parameter "${param}" (expected "<name>=<value>")`);
|
|
382
|
+
params[m[1]] = m[2];
|
|
383
|
+
}
|
|
384
|
+
const text = await Spec.query(this.log, selector, params, output.format, undefined, opts.schema === true);
|
|
385
|
+
if (output.output === "-")
|
|
386
|
+
await writeStdout(text);
|
|
387
|
+
else {
|
|
388
|
+
await fs.promises.writeFile(output.output, text);
|
|
389
|
+
this.log.write("info", `spec: queried specification into "${output.output}" (${Buffer.byteLength(text)} bytes)`);
|
|
390
|
+
}
|
|
319
391
|
});
|
|
320
392
|
/* register CLI sub-command "ase spec preview" */
|
|
321
393
|
spec
|
|
@@ -323,12 +395,13 @@ export default class SpecCommand {
|
|
|
323
395
|
.description("Serve the HTML export of the specification Markdown files as a live preview")
|
|
324
396
|
.option("-a, --addr <ip-addr>", "IP address to listen on (default: SpecBook preview address)")
|
|
325
397
|
.option("-p, --port <tcp-port>", "TCP port to listen on (default: SpecBook preview port)")
|
|
398
|
+
.option("-S, --schema", "preview the effective schema configuration itself instead of the specification")
|
|
326
399
|
.action(async (opts) => {
|
|
327
400
|
const { previewAddr, previewPort } = await import("@rse/specbook");
|
|
328
401
|
const port = Number(opts.port ?? previewPort);
|
|
329
402
|
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
330
403
|
throw new Error(`invalid TCP port "${opts.port}"`);
|
|
331
|
-
await Spec.preview(this.log, opts.addr ?? previewAddr, port);
|
|
404
|
+
await Spec.preview(this.log, opts.addr ?? previewAddr, port, opts.schema === true);
|
|
332
405
|
await new Promise(() => { });
|
|
333
406
|
});
|
|
334
407
|
}
|
|
@@ -406,12 +479,15 @@ export class SpecMCP {
|
|
|
406
479
|
"(PDF as a base64-encoded resource). The export fails on any lint diagnostic. " +
|
|
407
480
|
"The `notices` array carries the environment notices emitted during the export " +
|
|
408
481
|
"(like a PDF rendering falling back onto a system-installed browser), which are " +
|
|
409
|
-
"worth reporting to the user verbatim."
|
|
482
|
+
"worth reporting to the user verbatim. With `schema`, the effective SpecBook YAML " +
|
|
483
|
+
"schema configuration itself is exported instead, as a SpecBook document.",
|
|
410
484
|
inputSchema: {
|
|
411
485
|
format: z.enum(Spec.formats).optional()
|
|
412
486
|
.describe("output format (default: inferred from the `output` file extension, else `json`)"),
|
|
413
487
|
output: z.string().optional()
|
|
414
|
-
.describe("output file path (\"-\" or omitted returns the result directly)")
|
|
488
|
+
.describe("output file path (\"-\" or omitted returns the result directly)"),
|
|
489
|
+
schema: z.boolean().optional()
|
|
490
|
+
.describe("if true, export the effective schema configuration instead of the specification (default: false)")
|
|
415
491
|
},
|
|
416
492
|
outputSchema: {
|
|
417
493
|
notices: z.array(z.string())
|
|
@@ -426,7 +502,11 @@ export class SpecMCP {
|
|
|
426
502
|
const spec = args.format !== undefined || args.output === undefined ?
|
|
427
503
|
{ format: args.format ?? "json", output: args.output } :
|
|
428
504
|
parseOutputSpec(args.output);
|
|
429
|
-
|
|
505
|
+
/* the image references of the normalized Markdown are re-based
|
|
506
|
+
onto the directory of the output file */
|
|
507
|
+
const rebase = spec.format === "md" && spec.output !== undefined && spec.output !== "-" ?
|
|
508
|
+
path.dirname(path.resolve(Task.projectRoot(), spec.output)) : undefined;
|
|
509
|
+
const [data] = await Spec.export(this.log, [spec.format], { notices, schema: args.schema ?? false, rebase: [rebase] });
|
|
430
510
|
if (spec.output !== undefined && spec.output !== "-") {
|
|
431
511
|
await fs.promises.writeFile(path.resolve(Task.projectRoot(), spec.output), data);
|
|
432
512
|
return {
|
|
@@ -452,5 +532,60 @@ export class SpecMCP {
|
|
|
452
532
|
return mcpToolError(err);
|
|
453
533
|
}
|
|
454
534
|
});
|
|
535
|
+
mcp.registerTool("ase_specbook_query", {
|
|
536
|
+
title: "ASE SpecBook query",
|
|
537
|
+
description: "Query the SpecBook specification Markdown files of the project (located via the " +
|
|
538
|
+
"`project.artifact.spec.basedir` configuration), strictly validated like for the export, " +
|
|
539
|
+
"with an XPath-like ASTq `selector` (like `// USE-CASE [ @STATUS == 'Done' ]`). " +
|
|
540
|
+
"The root node is of type `specification`, its children are of type `artifact` (attributes " +
|
|
541
|
+
"`created`, `modified`, and `file`), and the objects below are typed by their kind, with " +
|
|
542
|
+
"whitespace dashed (attributes `kind`, `id`, `anchor`, `paren`, `name`, `primary`, " +
|
|
543
|
+
"`description`, `rationale`, `elaboration`, and one per property key), where the query " +
|
|
544
|
+
"function `refs()` yields the objects the references of an object resolve to. A `{name}` " +
|
|
545
|
+
"in the selector is substituted by the corresponding entry of `params`. The matched nodes " +
|
|
546
|
+
"are rendered as a JSON (or JSON5, YAML, or TOON) array (their subtrees included, the " +
|
|
547
|
+
"embedded file contents dropped), written to the `output` file (a relative path resolves " +
|
|
548
|
+
"against the project root) if given, else returned directly. With `schema`, the effective " +
|
|
549
|
+
"SpecBook YAML schema configuration itself is queried instead, as a SpecBook document of " +
|
|
550
|
+
"the bundled meta schema configuration. The `notices` array carries the messages emitted " +
|
|
551
|
+
"during the loading (like the warning diagnostics of the specification).",
|
|
552
|
+
inputSchema: {
|
|
553
|
+
selector: z.string()
|
|
554
|
+
.describe("ASTq selector of the nodes to return"),
|
|
555
|
+
params: z.record(z.string(), z.string()).optional()
|
|
556
|
+
.describe("values of the `{name}` parameters of the selector (default: none)"),
|
|
557
|
+
format: z.enum(Spec.queryFormats).optional()
|
|
558
|
+
.describe("output format (default: inferred from the `output` file extension, else `json`)"),
|
|
559
|
+
output: z.string().optional()
|
|
560
|
+
.describe("output file path (\"-\" or omitted returns the result directly)"),
|
|
561
|
+
schema: z.boolean().optional()
|
|
562
|
+
.describe("if true, query the effective schema configuration instead of the specification (default: false)")
|
|
563
|
+
},
|
|
564
|
+
outputSchema: {
|
|
565
|
+
notices: z.array(z.string())
|
|
566
|
+
.describe("messages emitted during the loading, empty if there were none")
|
|
567
|
+
}
|
|
568
|
+
}, async (args) => {
|
|
569
|
+
const notices = new Array();
|
|
570
|
+
try {
|
|
571
|
+
const { parseQueryOutput } = await import("@rse/specbook");
|
|
572
|
+
const spec = parseQueryOutput(args.output ?? "-", args.format);
|
|
573
|
+
const text = await Spec.query(this.log, args.selector, args.params ?? {}, spec.format, notices, args.schema ?? false);
|
|
574
|
+
if (spec.output !== "-") {
|
|
575
|
+
await fs.promises.writeFile(path.resolve(Task.projectRoot(), spec.output), text);
|
|
576
|
+
return {
|
|
577
|
+
structuredContent: { notices },
|
|
578
|
+
content: [{ type: "text", text: `queried specification into "${spec.output}" (${Buffer.byteLength(text)} bytes)` }]
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
return {
|
|
582
|
+
structuredContent: { notices },
|
|
583
|
+
content: [{ type: "text", text }]
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
catch (err) {
|
|
587
|
+
return mcpToolError(err);
|
|
588
|
+
}
|
|
589
|
+
});
|
|
455
590
|
}
|
|
456
591
|
}
|
|
@@ -947,7 +947,8 @@ export const useBoardState = (log, initial, relay) => {
|
|
|
947
947
|
const viewW = innerW - 4;
|
|
948
948
|
let { x, y } = scroll;
|
|
949
949
|
const box = view === "graph" ? places.get(sel.id) : view === "flow" ? flowPlaces.get(flowSel) : undefined;
|
|
950
|
-
const
|
|
950
|
+
const graph = view === "graph" ? layout?.graph : view === "flow" ? flowLayout?.graph : undefined;
|
|
951
|
+
const height = graph?.height;
|
|
951
952
|
const follow = `${view}:${flowSel}`;
|
|
952
953
|
if (box !== undefined && height !== undefined && (view !== "flow" || followed.current !== follow)) {
|
|
953
954
|
const top = Math.max(0, box.top - 2);
|
|
@@ -965,6 +966,12 @@ export const useBoardState = (log, initial, relay) => {
|
|
|
965
966
|
}
|
|
966
967
|
if (view !== "flow")
|
|
967
968
|
followed.current = "";
|
|
969
|
+
/* keep the viewport within the bounds of the shown graph, as the scroll position is shared
|
|
970
|
+
by both graphs (and else, a position carried over from the other one may show only its tail) */
|
|
971
|
+
if (graph !== undefined) {
|
|
972
|
+
x = Math.max(0, Math.min(Math.max(0, graph.width + 2 - viewW), x));
|
|
973
|
+
y = Math.max(0, Math.min(Math.max(0, graph.height + 1 - viewH), y));
|
|
974
|
+
}
|
|
968
975
|
if (x !== scroll.x || y !== scroll.y)
|
|
969
976
|
setScroll({ x, y });
|
|
970
977
|
/* scroll the viewport of the agent graph freely (within its bounds) */
|