@rse/ase 2.0.5 → 2.0.7

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 (74) hide show
  1. package/dst/ase-agent-definition-api.d.ts +2 -0
  2. package/dst/ase-agent-definition-core.js +24 -3
  3. package/dst/ase-agent-fleet.js +2 -1
  4. package/dst/ase-agent-runtime-engine.js +22 -4
  5. package/dst/ase-agent-runtime-tool.js +10 -8
  6. package/dst/ase-agent-runtime.js +8 -4
  7. package/dst/ase-config-schema.js +1 -1
  8. package/dst/ase-man-catalog.js +1 -1
  9. package/dst/ase-setup-settings.js +111 -1
  10. package/dst/ase-setup.js +19 -7
  11. package/dst/ase-spec.js +184 -49
  12. package/dst/ase-task-board-tui-model.js +8 -1
  13. package/dst/ase-task-board-tui.js +26 -2
  14. package/dst/ase-task-board-web-client.js +10 -10
  15. package/dst/ase-task-store-plugin-gitea.js +3 -3
  16. package/dst/man/ase-agent-adhoc.md +25 -6
  17. package/dst/man/ase-agent-condenser.md +7 -0
  18. package/dst/man/ase-agent-editor.md +7 -0
  19. package/dst/man/ase-agent-estimator.md +7 -0
  20. package/dst/man/ase-agent-fleet.md +7 -4
  21. package/dst/man/ase-agent-implementer.md +7 -0
  22. package/dst/man/ase-agent-integrator.md +7 -0
  23. package/dst/man/ase-agent-qualifier.md +7 -0
  24. package/dst/man/ase-agent-rebooter.md +7 -0
  25. package/dst/man/ase-agent-researcher.md +7 -0
  26. package/dst/man/ase-artifact.md +1 -1
  27. package/dst/man/ase-config.md +5 -3
  28. package/dst/man/ase-mcp.md +1 -1
  29. package/dst/man/ase-setup-install.md +10 -1
  30. package/dst/man/ase-setup-status.md +6 -0
  31. package/dst/man/ase-setup-uninstall.md +3 -1
  32. package/dst/man/ase-setup-update.md +3 -0
  33. package/dst/man/ase-setup.md +4 -4
  34. package/dst/man/ase-spec-export.md +14 -3
  35. package/dst/man/ase-spec-init.md +10 -1
  36. package/dst/man/ase-spec-lint.md +2 -1
  37. package/dst/man/ase-spec-preview.md +10 -1
  38. package/dst/man/ase-spec-query.md +96 -0
  39. package/dst/man/ase-spec.md +32 -11
  40. package/dst/man/ase.md +3 -3
  41. package/package.json +20 -17
  42. package/plugin/.claude-plugin/icon.png +0 -0
  43. package/plugin/.claude-plugin/plugin.json +1 -1
  44. package/plugin/.codex-plugin/plugin.json +1 -1
  45. package/plugin/.github/plugin/plugin.json +1 -1
  46. package/plugin/meta/ase-common-task.md +9 -1
  47. package/plugin/meta/ase-control.md +14 -0
  48. package/plugin/meta/ase-format-spec.md +29 -5
  49. package/plugin/meta/ase-format-specbook.md +427 -47
  50. package/plugin/meta/ase-format-specbook.yaml +189 -87
  51. package/plugin/meta/ase-skill.md +7 -0
  52. package/plugin/mods/ase-mods-common.ts +36 -6
  53. package/plugin/mods/ase-mods-evaluate.ts +227 -0
  54. package/plugin/mods/ase-mods-grill.ts +1 -30
  55. package/plugin/mods/ase-mods-help-browse.ts +18 -13
  56. package/plugin/mods/ase-mods-help-find.ts +17 -9
  57. package/plugin/mods/ase-mods-help-hint.ts +1 -1
  58. package/plugin/mods/ase-mods.ts +23 -17
  59. package/plugin/skills/ase-code-edit/SKILL.md +53 -16
  60. package/plugin/skills/ase-code-edit/help.md +26 -13
  61. package/plugin/skills/ase-code-lint/SKILL.md +7 -3
  62. package/plugin/skills/ase-help-intent/data.md +170 -28
  63. package/plugin/skills/ase-help-skill/catalog.md +1 -0
  64. package/plugin/skills/ase-meta-workflow/workflow.txt +2 -0
  65. package/plugin/skills/ase-repo-changelog/SKILL.md +29 -10
  66. package/plugin/skills/ase-repo-changelog/help.md +5 -1
  67. package/plugin/skills/ase-repo-commit/SKILL.md +4 -1
  68. package/plugin/skills/ase-repo-dissect/help.md +2 -1
  69. package/plugin/skills/ase-repo-stage/SKILL.md +459 -0
  70. package/plugin/skills/ase-repo-stage/help.md +120 -0
  71. package/plugin/skills/ase-spec-activate/SKILL.md +4 -1
  72. package/plugin/skills/ase-spec-edit/SKILL.md +20 -12
  73. package/plugin/skills/ase-spec-edit/help.md +22 -14
  74. package/plugin/mods/claude-code.d.ts +0 -13440
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) */
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import React from "react";
7
7
  import { render, Box, Text, useInput, useStdin } from "ink";
8
+ import stringWidth from "string-width";
8
9
  import { Task } from "./ase-task.js";
9
10
  import { buildBoard, toneOf, viewLabels } from "./ase-task-board-core.js";
10
11
  import pkg from "../package.json" with { type: "json" };
@@ -160,6 +161,25 @@ const App = ({ log, initial, project, relay }) => {
160
161
  });
161
162
  return renderScreen(ctx, project);
162
163
  };
164
+ /* wrap stdout to erase the tail of each changed line ("CSI <n> G" plus "CSI K" plus
165
+ its styled text) after instead of before its text, as otherwise the line is shown
166
+ blanked in terminals redrawing between the reads of a frame, like tmux popups;
167
+ a text reaching the last column needs no erasing, which would there erase its
168
+ last character, as the cursor rests on it */
169
+ const lateErasing = (stream) => {
170
+ /* eslint-disable-next-line no-control-regex */
171
+ const changed = /\x1b\[(\d+)G\x1b\[K((?:[^\x1b\n]|\x1b\[[\d;:]*m)*)/g;
172
+ const write = (data, ...args) => stream.write(typeof data !== "string" ? data :
173
+ data.replace(changed, (_all, col, text) => `\x1b[${col}G${text}` + (Number(col) - 1 + stringWidth(text) < stream.columns ? "\x1b[K" : "")), ...args);
174
+ return new Proxy(stream, {
175
+ get: (target, prop) => {
176
+ if (prop === "write")
177
+ return write;
178
+ const value = Reflect.get(target, prop, target);
179
+ return typeof value === "function" ? value.bind(target) : value;
180
+ }
181
+ });
182
+ };
163
183
  /* run the terminal board until the user quits */
164
184
  export const runTUI = async (log, relay) => {
165
185
  const initial = await buildBoard(log);
@@ -175,9 +195,13 @@ export const runTUI = async (log, relay) => {
175
195
  process.once("exit", reset);
176
196
  try {
177
197
  /* enable the kitty keyboard protocol (if the terminal supports it),
178
- as only under it the Shift modifier of RETURN is reported */
198
+ as only under it the Shift modifier of RETURN is reported, and
199
+ rewrite only the changed lines of a frame, as otherwise every frame
200
+ is erased and redrawn entirely, which flickers in terminals not
201
+ honoring synchronized output (DEC mode 2026), like tmux popups */
179
202
  const app = render(h(App, { log, initial, project, relay }), {
180
- alternateScreen: true, exitOnCtrlC: true, patchConsole: true, kittyKeyboard: { mode: "auto" }
203
+ alternateScreen: true, exitOnCtrlC: true, patchConsole: true, kittyKeyboard: { mode: "auto" },
204
+ incrementalRendering: true, stdout: lateErasing(process.stdout)
181
205
  });
182
206
  await app.waitUntilExit();
183
207
  }