@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.
Files changed (48) hide show
  1. package/dst/ase-config-schema.js +1 -1
  2. package/dst/ase-hook.js +12 -3
  3. package/dst/ase-setup-settings.js +111 -1
  4. package/dst/ase-setup.js +19 -7
  5. package/dst/ase-spec.js +184 -49
  6. package/dst/ase-task-board-tui-model.js +8 -1
  7. package/dst/ase-task-board-tui.js +26 -2
  8. package/dst/ase-task-board-web-client.js +10 -10
  9. package/dst/man/ase-artifact.md +1 -1
  10. package/dst/man/ase-config.md +5 -3
  11. package/dst/man/ase-mcp.md +1 -1
  12. package/dst/man/ase-setup-install.md +10 -1
  13. package/dst/man/ase-setup-status.md +6 -0
  14. package/dst/man/ase-setup-uninstall.md +3 -1
  15. package/dst/man/ase-setup-update.md +3 -0
  16. package/dst/man/ase-setup.md +4 -4
  17. package/dst/man/ase-spec-export.md +14 -3
  18. package/dst/man/ase-spec-init.md +10 -1
  19. package/dst/man/ase-spec-lint.md +2 -1
  20. package/dst/man/ase-spec-preview.md +10 -1
  21. package/dst/man/ase-spec-query.md +96 -0
  22. package/dst/man/ase-spec.md +32 -11
  23. package/dst/man/ase.md +3 -3
  24. package/package.json +21 -18
  25. package/plugin/.claude-plugin/icon.png +0 -0
  26. package/plugin/.claude-plugin/plugin.json +1 -1
  27. package/plugin/.codex-plugin/plugin.json +1 -1
  28. package/plugin/.github/plugin/plugin.json +1 -1
  29. package/plugin/meta/ase-format-spec.md +29 -5
  30. package/plugin/meta/ase-format-specbook.md +427 -47
  31. package/plugin/meta/ase-format-specbook.yaml +180 -78
  32. package/plugin/mods/ase-mods-common.ts +474 -0
  33. package/plugin/mods/ase-mods-evaluate.ts +227 -0
  34. package/plugin/mods/ase-mods-grill.ts +283 -0
  35. package/plugin/mods/ase-mods-help-browse.ts +410 -0
  36. package/plugin/mods/ase-mods-help-compose.ts +561 -0
  37. package/plugin/mods/ase-mods-help-find.ts +434 -0
  38. package/plugin/mods/ase-mods-help-hint.ts +134 -0
  39. package/plugin/mods/ase-mods-help-show.ts +142 -0
  40. package/plugin/mods/ase-mods.ts +220 -381
  41. package/plugin/skills/ase-code-lint/SKILL.md +15 -15
  42. package/plugin/skills/ase-code-lint/help.md +21 -8
  43. package/plugin/skills/ase-help-intent/SKILL.md +2 -1
  44. package/plugin/skills/ase-help-intent/data.md +42 -16
  45. package/plugin/skills/ase-spec-activate/SKILL.md +4 -1
  46. package/plugin/skills/ase-spec-lint/SKILL.md +15 -15
  47. package/plugin/skills/ase-spec-lint/help.md +21 -8
  48. package/plugin/mods/claude-code.d.ts +0 -13440
@@ -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": "*.{md,txt,svg,png,jpg}",
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 hint is emitted only if the guidance level asks for it */
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-description>\" for skill command proposals" +
474
- "\n⧉ ASE: ▷ hint: use \"/ase-help-skill [<skill-name>]\" for skill catalog or skill manpage" : "");
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 output style */
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 (Anthropic Claude Code CLI only) */
42
- if (tool === "claude")
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 (Anthropic Claude Code CLI only) */
101
- if (tool === "claude")
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 (Anthropic Claude Code CLI only) */
134
- if (tool === "claude")
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 the literal "std" for the
32
- full SpecBook standard schema (not the agent-compressed plugin meta file
33
- "ase-format-specbook.yaml") or a file path relative to the project
34
- root, and an unset or empty value means "std" */
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 === "std" ?
44
- "std" :
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
- static async init(log) {
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
- collecting the emitted environment notices if requested */
201
- static export(log, formats, notices) {
202
- return Spec.unmarked(Spec.api(log, notices).then((api) => api.export({
203
- config: Spec.configFiles(log),
204
- basedir: Artifact.basedir(log, "spec"),
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
- config: Spec.configFiles(log),
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
- /* serve the HTML export of the specification as a live preview,
224
- kept in sync with its sources and pushed into the connected
225
- browsers as an in-place document update */
226
- static preview(log, addr, port) {
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
- config: Spec.configFiles(log),
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
- .action(async () => {
257
- const files = await Spec.init(this.log);
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\" inside the specification base directory)", (value, previous) => previous.concat(value), new Array())
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")]).map(parseOutputSpec);
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
- const distinct = Array.from(new Set(outputs.map(({ format }) => format)));
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 { format, output } of outputs) {
304
- const data = buffers[distinct.indexOf(format)];
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, distinct, outputs.map(({ output }) => output), write);
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, distinct));
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
- const [data] = await Spec.export(this.log, [spec.format], notices);
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 height = view === "graph" ? layout?.graph.height : flowLayout?.graph.height;
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) */