@rse/ase 0.9.43 → 0.9.45

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 (86) hide show
  1. package/dst/ase-artifact.js +28 -24
  2. package/dst/ase-config.js +27 -36
  3. package/dst/ase-diagram.js +15 -35
  4. package/dst/ase-getopt.js +10 -15
  5. package/dst/ase-hook.js +29 -27
  6. package/dst/ase-kv.js +24 -29
  7. package/dst/ase-log.js +10 -3
  8. package/dst/ase-markdown.js +135 -102
  9. package/dst/ase-mcp.js +3 -10
  10. package/dst/ase-meta.js +6 -4
  11. package/dst/ase-persona.js +7 -11
  12. package/dst/ase-service.js +28 -28
  13. package/dst/ase-setup.js +65 -28
  14. package/dst/ase-skills.js +2 -2
  15. package/dst/ase-statusline.js +26 -34
  16. package/dst/ase-task.js +60 -63
  17. package/dst/ase-version.js +2 -1
  18. package/dst/ase.js +7 -11
  19. package/package.json +12 -10
  20. package/plugin/.claude-plugin/plugin.json +1 -1
  21. package/plugin/.codex-plugin/plugin.json +1 -1
  22. package/plugin/.github/plugin/plugin.json +2 -2
  23. package/plugin/agents/ase-code-lint.md +15 -4
  24. package/plugin/agents/ase-docs-proofread.md +9 -1
  25. package/plugin/agents/ase-meta-chat.md +19 -13
  26. package/plugin/agents/ase-meta-diagram.md +1 -1
  27. package/plugin/agents/ase-meta-search.md +1 -2
  28. package/plugin/etc/eslint.mjs +0 -2
  29. package/plugin/meta/ase-common-task.md +145 -0
  30. package/plugin/meta/ase-constitution.md +5 -2
  31. package/plugin/meta/ase-control.md +1 -1
  32. package/plugin/meta/ase-dialog.md +7 -7
  33. package/plugin/meta/ase-format-arch.md +12 -12
  34. package/plugin/meta/ase-format-spec.md +23 -23
  35. package/plugin/meta/ase-format-task.md +9 -11
  36. package/plugin/meta/ase-getopt.md +1 -1
  37. package/plugin/meta/ase-persona.md +3 -3
  38. package/plugin/meta/ase-skill.md +3 -3
  39. package/plugin/meta/ase-tenets.md +4 -4
  40. package/plugin/package.json +8 -8
  41. package/plugin/skills/ase-arch-analyze/SKILL.md +2 -3
  42. package/plugin/skills/ase-arch-analyze/help.md +2 -2
  43. package/plugin/skills/ase-arch-discover/SKILL.md +2 -2
  44. package/plugin/skills/ase-arch-discover/help.md +3 -2
  45. package/plugin/skills/ase-code-analyze/SKILL.md +0 -1
  46. package/plugin/skills/ase-code-craft/SKILL.md +11 -11
  47. package/plugin/skills/ase-code-lint/SKILL.md +31 -13
  48. package/plugin/skills/ase-code-refactor/SKILL.md +13 -13
  49. package/plugin/skills/ase-code-refactor/help.md +1 -1
  50. package/plugin/skills/ase-code-resolve/SKILL.md +10 -10
  51. package/plugin/skills/ase-code-resolve/help.md +1 -1
  52. package/plugin/skills/ase-docs-distill/SKILL.md +6 -6
  53. package/plugin/skills/ase-docs-distill/help.md +6 -4
  54. package/plugin/skills/ase-docs-proofread/SKILL.md +23 -8
  55. package/plugin/skills/ase-meta-brainstorm/help.md +1 -1
  56. package/plugin/skills/ase-meta-changelog/SKILL.md +9 -5
  57. package/plugin/skills/ase-meta-commit/SKILL.md +8 -6
  58. package/plugin/skills/ase-meta-compat/SKILL.md +14 -5
  59. package/plugin/skills/ase-meta-diaboli/SKILL.md +6 -6
  60. package/plugin/skills/ase-meta-diff/SKILL.md +10 -10
  61. package/plugin/skills/ase-meta-evaluate/SKILL.md +6 -9
  62. package/plugin/skills/ase-meta-persona/SKILL.md +1 -1
  63. package/plugin/skills/ase-meta-quorum/SKILL.md +43 -12
  64. package/plugin/skills/ase-meta-search/SKILL.md +1 -1
  65. package/plugin/skills/ase-meta-search/help.md +4 -2
  66. package/plugin/skills/ase-meta-steelman/SKILL.md +5 -5
  67. package/plugin/skills/ase-meta-steelman/help.md +5 -3
  68. package/plugin/skills/ase-meta-why/SKILL.md +2 -2
  69. package/plugin/skills/ase-meta-why/help.md +3 -1
  70. package/plugin/skills/ase-sync-export/SKILL.md +20 -17
  71. package/plugin/skills/ase-sync-import/SKILL.md +20 -16
  72. package/plugin/skills/ase-sync-import/help.md +2 -2
  73. package/plugin/skills/ase-sync-reconcile/SKILL.md +10 -10
  74. package/plugin/skills/ase-sync-reconcile/help.md +1 -1
  75. package/plugin/skills/ase-task-condense/SKILL.md +39 -85
  76. package/plugin/skills/ase-task-delete/SKILL.md +1 -1
  77. package/plugin/skills/ase-task-edit/SKILL.md +43 -51
  78. package/plugin/skills/ase-task-grill/SKILL.md +11 -13
  79. package/plugin/skills/ase-task-id/SKILL.md +7 -3
  80. package/plugin/skills/ase-task-implement/SKILL.md +18 -111
  81. package/plugin/skills/ase-task-implement/help.md +2 -2
  82. package/plugin/skills/ase-task-list/SKILL.md +3 -0
  83. package/plugin/skills/ase-task-preflight/SKILL.md +22 -115
  84. package/plugin/skills/ase-task-reboot/SKILL.md +39 -71
  85. package/plugin/skills/ase-task-reboot/help.md +2 -1
  86. package/plugin/skills/ase-task-view/SKILL.md +7 -7
@@ -67,6 +67,23 @@ const TICK_MS = 60 * 1000;
67
67
  const PORT_MIN = 42000;
68
68
  const PORT_MAX = 44000;
69
69
  const PORT_TRIES = 20;
70
+ /* load the optional "config.yaml" and "service.yaml" files and derive
71
+ the service identity context (project id, port, service config) */
72
+ export const loadServiceContext = (log) => {
73
+ /* load files */
74
+ const cfg = new Config("config", configSchema, log);
75
+ cfg.read();
76
+ const svc = new Config("service", serviceSchema, log);
77
+ svc.read();
78
+ /* determine project id */
79
+ const projectId = cfg.get("project.id") ?? path.basename(process.cwd());
80
+ /* determine service port */
81
+ const port = svc.get("port") ?? null;
82
+ /* determine path to ".ase" directory */
83
+ const aseDir = path.dirname(svc.filename);
84
+ /* return context information */
85
+ return { projectId, port, svc, aseDir };
86
+ };
70
87
  /* reusable functionality: port allocation, persistence, and process spawning */
71
88
  export class Service {
72
89
  /* try binding a single candidate port to verify availability */
@@ -135,21 +152,23 @@ export class Service {
135
152
  fd = fs.openSync(logFile, "r");
136
153
  const size = fs.fstatSync(fd).size;
137
154
  const CHUNK = 8192;
138
- const buf = Buffer.alloc(CHUNK);
155
+ const chunks = [];
139
156
  let pos = size;
140
- let tail = "";
141
157
  let count = 0;
142
158
  while (pos > 0 && count <= lines) {
143
159
  const n = Math.min(CHUNK, pos);
160
+ const buf = Buffer.alloc(n);
144
161
  pos -= n;
145
162
  fs.readSync(fd, buf, 0, n, pos);
146
- const chunk = buf.toString("utf8", 0, n);
147
- tail = chunk + tail;
148
- for (let i = 0; i < chunk.length; i++)
149
- if (chunk.charCodeAt(i) === 10)
163
+ chunks.unshift(buf);
164
+ for (let i = 0; i < n; i++)
165
+ if (buf[i] === 10)
150
166
  count++;
151
167
  }
152
- const all = tail.split("\n").filter((l) => l.length > 0);
168
+ /* decode once after re-assembly to avoid splitting
169
+ multi-byte UTF-8 sequences at chunk boundaries */
170
+ const all = Buffer.concat(chunks).toString("utf8")
171
+ .split("\n").filter((l) => l.length > 0);
153
172
  return all.slice(-lines).join("\n");
154
173
  }
155
174
  catch {
@@ -193,26 +212,7 @@ export default class ServiceCommand {
193
212
  }
194
213
  /* load optional ".ase/config.yaml" and ".ase/service.yaml" files */
195
214
  loadContext() {
196
- /* load files */
197
- const cfg = new Config("config", configSchema, this.log);
198
- cfg.read();
199
- const svc = new Config("service", serviceSchema, this.log);
200
- svc.read();
201
- /* determine project id */
202
- const rawId = cfg.get("project.id");
203
- const projectId = rawId ?? path.basename(process.cwd());
204
- /* determine service port */
205
- const rawPort = svc.get("port");
206
- const port = rawPort ?? null;
207
- /* determine path to ".ase" directory */
208
- const aseDir = path.dirname(svc.filename);
209
- /* return context information */
210
- return {
211
- projectId,
212
- port,
213
- svc,
214
- aseDir
215
- };
215
+ return loadServiceContext(this.log);
216
216
  }
217
217
  /* service-side: bind HAPI server until "/stop" command is received or idle timeout happens */
218
218
  async runService(ctx) {
@@ -433,7 +433,7 @@ export default class ServiceCommand {
433
433
  await new Promise((resolve) => setTimeout(resolve, 100));
434
434
  if (exited)
435
435
  break;
436
- const s = await probe(port, ctx.projectId);
436
+ const s = await probe(port, ctx.projectId).catch(() => null);
437
437
  if (s === true) {
438
438
  this.log.write("info", `service: started on port ${port}`);
439
439
  child.unref();
package/dst/ase-setup.js CHANGED
@@ -104,7 +104,7 @@ export default class SetupCommand {
104
104
  /* run a sub-process, suppressing output on success and emitting it on failure */
105
105
  async run(cmd, args, opts = {}) {
106
106
  const { cwd, quiet = false, retries = 1, ignoreError } = opts;
107
- const argsLog = args.map((arg) => arg.replace(/(_KEY=)(\S+)/, (_, k, v) => k + "*".repeat(v.length)));
107
+ const argsLog = args.map((arg) => arg.replace(/(\w*key=)([^\s&]+)/gi, (_, k, v) => k + "*".repeat(v.length)));
108
108
  this.log.write("info", `setup: $ ${cmd} ${argsLog.join(" ")}` +
109
109
  (cwd !== undefined ? ` (cwd: ${cwd})` : ""));
110
110
  for (let i = 0; i < retries; i++) {
@@ -182,7 +182,7 @@ export default class SetupCommand {
182
182
  /* best-effort stop of background service */
183
183
  this.log.write("info", `setup: update${dev ? "[dev]" : ""}: ` +
184
184
  "stopping potentially running ASE service");
185
- await this.run("ase", ["service", "stop"], { quiet: true });
185
+ await this.run("ase", ["service", "stop"], { quiet: true, ignoreError: "ASE service not running" });
186
186
  if (dev) {
187
187
  /* update ASE CLI Tool */
188
188
  this.log.write("info", `setup: update[dev]: used ASE version: ${Version.current()}`);
@@ -226,33 +226,20 @@ export default class SetupCommand {
226
226
  }
227
227
  return 0;
228
228
  }
229
- /* handler for "ase setup enable" (both tools) */
230
- async doEnable(tool, scope) {
229
+ /* handler for "ase setup enable|disable" (both tools) */
230
+ async doToggle(tool, scope, action) {
231
231
  this.requireClaudeScope(tool, scope);
232
232
  const spec = toolSpecs[tool];
233
233
  await this.ensureTool(spec.cli);
234
- this.log.write("info", `setup: enable: enabling ASE ${spec.label} plugin`);
234
+ this.log.write("info", `setup: ${action}: ${action === "enable" ? "enabling" : "disabling"} ` +
235
+ `ASE ${spec.label} plugin`);
235
236
  /* the GitHub Copilot CLI and OpenAI Codex CLI have no "plugin
236
- enable" subcommand, so (re-)install the plugin instead */
237
+ enable"/"plugin disable" subcommand, so (re-)install or
238
+ uninstall the plugin instead */
237
239
  const scopeArgs = scope !== "user" ? ["--scope", scope] : [];
238
240
  const args = tool === "claude" ?
239
- ["plugin", "enable", "ase@ase", ...scopeArgs] :
240
- ["plugin", spec.pInstall, "ase@ase"];
241
- await this.run(spec.cli, args, { retries: tool === "claude" ? 1 : 3 });
242
- return 0;
243
- }
244
- /* handler for "ase setup disable" (both tools) */
245
- async doDisable(tool, scope) {
246
- this.requireClaudeScope(tool, scope);
247
- const spec = toolSpecs[tool];
248
- await this.ensureTool(spec.cli);
249
- this.log.write("info", `setup: disable: disabling ASE ${spec.label} plugin`);
250
- /* the GitHub Copilot CLI and OpenAI Codex CLI have no "plugin
251
- disable" subcommand, so uninstall the plugin instead */
252
- const scopeArgs = scope !== "user" ? ["--scope", scope] : [];
253
- const args = tool === "claude" ?
254
- ["plugin", "disable", "ase@ase", ...scopeArgs] :
255
- ["plugin", spec.pRemove, "ase@ase"];
241
+ ["plugin", action, "ase@ase", ...scopeArgs] :
242
+ ["plugin", action === "enable" ? spec.pInstall : spec.pRemove, "ase@ase"];
256
243
  await this.run(spec.cli, args, { retries: tool === "claude" ? 1 : 3 });
257
244
  return 0;
258
245
  }
@@ -266,7 +253,7 @@ export default class SetupCommand {
266
253
  /* best-effort stop of background service */
267
254
  this.log.write("info", `setup: uninstall${dev ? "[dev]" : ""}: ` +
268
255
  "stopping potentially running ASE service");
269
- await this.run("ase", ["service", "stop"], { quiet: true });
256
+ await this.run("ase", ["service", "stop"], { quiet: true, ignoreError: "ASE service not running" });
270
257
  /* uninstall ASE plugin */
271
258
  this.log.write("info", `setup: uninstall${dev ? "[dev]" : ""}: ` +
272
259
  `uninstalling ASE ${spec.label} plugin (origin: ${dev ? "local" : "remote/bundled"})`);
@@ -466,6 +453,29 @@ export default class SetupCommand {
466
453
  await this.mcpRemove(tool, spec.server, scope);
467
454
  };
468
455
  }
456
+ /* build a chat-model MCP handler which bridges to a locally
457
+ installed AI agent harness CLI via the mcp-to-harness stdio
458
+ scaffold; the API key environment variable carries no key at all,
459
+ but instead the harness model identifier, with the special value
460
+ "default" selecting the default model of the harness */
461
+ harnessMcpHandler(harness) {
462
+ return async (spec, tool, scope, action, _envKey, envVal) => {
463
+ if (action === "activate")
464
+ await this.mcpAdd(tool, spec.server, {}, {
465
+ type: "stdio", command: [
466
+ "npx", "-y", "mcp-to-harness",
467
+ "--service", spec.name,
468
+ "--mcp-tool", "query",
469
+ "--harness", harness,
470
+ ...(envVal !== "default" ? [
471
+ "--harness-model", envVal
472
+ ] : [])
473
+ ]
474
+ }, scope);
475
+ else
476
+ await this.mcpRemove(tool, spec.server, scope);
477
+ };
478
+ }
469
479
  /* registry of pre-defined MCP servers: maps each server id onto its
470
480
  dedicated handler which performs the activate/deactivate operation */
471
481
  mcpServers = [
@@ -523,6 +533,33 @@ export default class SetupCommand {
523
533
  skills: ["ase-meta-chat", "ase-meta-quorum"],
524
534
  handler: this.chatMcpHandler({ url: "https://api.z.ai/api/paas/v4/", api: "completion", model: "glm-5.1" }, { model: "z-ai/glm-5.1" })
525
535
  },
536
+ {
537
+ id: "anthropic-claude",
538
+ name: "Anthropic Claude",
539
+ version: "latest",
540
+ env: ["ANTHROPIC_CLAUDE"],
541
+ server: "chat-anthropic-claude",
542
+ skills: ["ase-meta-chat", "ase-meta-quorum"],
543
+ handler: this.harnessMcpHandler("claude")
544
+ },
545
+ {
546
+ id: "openai-codex",
547
+ name: "OpenAI Codex",
548
+ version: "latest",
549
+ env: ["OPENAI_CODEX"],
550
+ server: "chat-openai-codex",
551
+ skills: ["ase-meta-chat", "ase-meta-quorum"],
552
+ handler: this.harnessMcpHandler("codex")
553
+ },
554
+ {
555
+ id: "github-copilot",
556
+ name: "GitHub Copilot",
557
+ version: "latest",
558
+ env: ["GITHUB_COPILOT"],
559
+ server: "chat-github-copilot",
560
+ skills: ["ase-meta-chat", "ase-meta-quorum"],
561
+ handler: this.harnessMcpHandler("copilot")
562
+ },
526
563
  {
527
564
  id: "brave",
528
565
  name: "Brave",
@@ -565,7 +602,7 @@ export default class SetupCommand {
565
602
  skills: ["ase-meta-search", "ase-meta-evaluate", "ase-arch-discover"],
566
603
  handler: async (spec, tool, scope, action, _envKey, envVal) => {
567
604
  if (action === "activate")
568
- await this.mcpAdd(tool, spec.server, {}, { type: "http", url: `https://mcp.exa.ai/mcp?exaApiKey=${envVal}` }, scope);
605
+ await this.mcpAdd(tool, spec.server, {}, { type: "http", url: `https://mcp.exa.ai/mcp?exaApiKey=${encodeURIComponent(envVal)}` }, scope);
569
606
  else
570
607
  await this.mcpRemove(tool, spec.server, scope);
571
608
  }
@@ -699,7 +736,7 @@ export default class SetupCommand {
699
736
  async statuslineWriteAst(file, root) {
700
737
  await mkdirp(path.dirname(file));
701
738
  const text = JsonAsty.unparse(root);
702
- writeFileAtomic.sync(file, text, { encoding: "utf8" });
739
+ await writeFileAtomic(file, text, { encoding: "utf8" });
703
740
  }
704
741
  /* handler for "ase setup statusline activate" */
705
742
  async doStatuslineActivate(tool, scope, opts, format) {
@@ -863,7 +900,7 @@ export default class SetupCommand {
863
900
  .option("-t, --tool <tool>", "target tool (\"claude\", \"copilot\", or \"codex\")", toolDflt)
864
901
  .option("-s, --scope <scope>", "target scope (\"user\", \"project\", or \"local\")", "user")
865
902
  .action(async (opts) => {
866
- process.exit(await this.doEnable(this.parseTool(opts.tool), this.parseScope(opts.scope)));
903
+ process.exit(await this.doToggle(this.parseTool(opts.tool), this.parseScope(opts.scope), "enable"));
867
904
  });
868
905
  /* register CLI sub-command "ase setup disable" */
869
906
  setupCmd
@@ -872,7 +909,7 @@ export default class SetupCommand {
872
909
  .option("-t, --tool <tool>", "target tool (\"claude\", \"copilot\", or \"codex\")", toolDflt)
873
910
  .option("-s, --scope <scope>", "target scope (\"user\", \"project\", or \"local\")", "user")
874
911
  .action(async (opts) => {
875
- process.exit(await this.doDisable(this.parseTool(opts.tool), this.parseScope(opts.scope)));
912
+ process.exit(await this.doToggle(this.parseTool(opts.tool), this.parseScope(opts.scope), "disable"));
876
913
  });
877
914
  /* register CLI sub-command "ase setup mcp" */
878
915
  const mcpCmd = setupCmd
package/dst/ase-skills.js CHANGED
@@ -16,7 +16,7 @@ export class Skills {
16
16
  static httpActive = 0;
17
17
  static httpQueue = [];
18
18
  static async httpLimit(fn) {
19
- if (Skills.httpActive >= Skills.HTTP_CONCURRENCY)
19
+ while (Skills.httpActive >= Skills.HTTP_CONCURRENCY)
20
20
  await new Promise((resolve) => Skills.httpQueue.push(resolve));
21
21
  Skills.httpActive++;
22
22
  try {
@@ -295,7 +295,7 @@ export class Skills {
295
295
  We therefore default a missing `recentness` to the decay value at
296
296
  one half-life (`0.5`), a conservative midpoint that keeps the
297
297
  entry rankable without rewarding the missing date. */
298
- const lifespan = (!Number.isNaN(cMs) && !Number.isNaN(uMs)) ? Math.max(0, uMs - cMs) : 1;
298
+ const lifespan = (!Number.isNaN(cMs) && !Number.isNaN(uMs)) ? Math.max(1, uMs - cMs) : 1;
299
299
  const recentness = !Number.isNaN(uMs) ? Math.exp(-Math.max(0, (now - uMs) / msPerDay) / halfLife) : 0.5;
300
300
  let rank = d * s * lifespan * recentness;
301
301
  /* hard, caller-tunable staleness penalty on top of the soft
@@ -11,6 +11,7 @@ import { InvalidArgumentError } from "commander";
11
11
  import { execaSync } from "execa";
12
12
  import { Chalk } from "chalk";
13
13
  import { Config, configSchema, parseScope } from "./ase-config.js";
14
+ import { writeStdout } from "./ase-stdout.js";
14
15
  import pkg from "../package.json" with { type: "json" };
15
16
  /* forced-color chalk instance: stdout is a pipe under Anthropic Claude Code CLI,
16
17
  so chalk auto-detection would yield level 0; force level 1 to keep
@@ -58,18 +59,22 @@ const detectTermWidth = () => {
58
59
  }
59
60
  return width;
60
61
  };
61
- /* format a token count as a compact human-readable string (e.g. 334k, 104.9M) */
62
- const formatTokens = (n) => {
62
+ /* format a count as a compact human-readable string with the given scaling base */
63
+ const formatScaled = (n, base) => {
63
64
  if (!Number.isFinite(n) || n < 0)
64
65
  return "0";
65
- if (n >= 1_000_000_000)
66
- return `${(n / 1_000_000_000).toFixed(1)}G`;
67
- if (n >= 1_000_000)
68
- return `${(n / 1_000_000).toFixed(1)}M`;
69
- if (n >= 1_000)
70
- return `${(n / 1_000).toFixed(1)}k`;
66
+ if (n >= base ** 3)
67
+ return `${(n / base ** 3).toFixed(1)}G`;
68
+ if (n >= base ** 2)
69
+ return `${(n / base ** 2).toFixed(1)}M`;
70
+ if (n >= base)
71
+ return `${(n / base).toFixed(1)}k`;
71
72
  return `${n}`;
72
73
  };
74
+ /* format a token count as a compact human-readable string (e.g. 334k, 104.9M) */
75
+ const formatTokens = (n) => formatScaled(n, 1000);
76
+ /* format a byte count as a compact human-readable string (e.g. 33.2G, 512M) */
77
+ const formatBytes = (n) => formatScaled(n, 1024);
73
78
  /* format a millisecond duration as a compact human-readable string (e.g. 6d 12hr 7m, 4hr 27m, 12m 30s) */
74
79
  const formatDurationMs = (ms) => {
75
80
  if (!Number.isFinite(ms) || ms < 0)
@@ -122,33 +127,18 @@ const formatCostUsd = (n) => {
122
127
  return "$0.00";
123
128
  return `$${n.toFixed(2)}`;
124
129
  };
125
- /* format a byte count as a compact human-readable string (e.g. 33.2G, 512M) */
126
- const formatBytes = (n) => {
127
- if (!Number.isFinite(n) || n < 0)
128
- return "0";
129
- if (n >= 1024 ** 3)
130
- return `${(n / 1024 ** 3).toFixed(1)}G`;
131
- if (n >= 1024 ** 2)
132
- return `${(n / 1024 ** 2).toFixed(1)}M`;
133
- if (n >= 1024)
134
- return `${(n / 1024).toFixed(1)}k`;
135
- return `${n}`;
136
- };
137
130
  /* probe local git status for the given working directory */
138
131
  const probeGit = (cwd) => {
132
+ const gitCmd = (...args) => execFileSync("git", ["-C", cwd, ...args], { stdio: ["ignore", "pipe", "ignore"], timeout: 1000 }).toString("utf8");
139
133
  try {
140
- const branch = execFileSync("git", ["-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"], { stdio: ["ignore", "pipe", "ignore"], timeout: 1000 })
141
- .toString("utf8").trim();
142
- const porc = execFileSync("git", ["-C", cwd, "status", "--porcelain"], { stdio: ["ignore", "pipe", "ignore"], timeout: 1000 })
143
- .toString("utf8");
134
+ const branch = gitCmd("rev-parse", "--abbrev-ref", "HEAD").trim();
135
+ const porc = gitCmd("status", "--porcelain");
144
136
  const lines = porc.split("\n").filter((l) => l.length > 0);
145
137
  const untracked = lines.filter((l) => l.startsWith("??")).length;
146
- const dirty = lines.length > 0;
147
138
  let added = 0;
148
139
  let removed = 0;
149
140
  try {
150
- const shortstat = execFileSync("git", ["-C", cwd, "diff", "--shortstat", "HEAD"], { stdio: ["ignore", "pipe", "ignore"], timeout: 1000 })
151
- .toString("utf8");
141
+ const shortstat = gitCmd("diff", "--shortstat", "HEAD");
152
142
  const mAdd = shortstat.match(/(\d+)\s+insertion/);
153
143
  const mDel = shortstat.match(/(\d+)\s+deletion/);
154
144
  if (mAdd !== null)
@@ -159,10 +149,10 @@ const probeGit = (cwd) => {
159
149
  catch (_e) {
160
150
  /* no HEAD yet or git failure; leave counts at 0 */
161
151
  }
162
- return { branch, dirty, untracked, added, removed };
152
+ return { branch, untracked, added, removed };
163
153
  }
164
154
  catch (_e) {
165
- return { branch: "", dirty: false, untracked: 0, added: 0, removed: 0 };
155
+ return { branch: "", untracked: 0, added: 0, removed: 0 };
166
156
  }
167
157
  };
168
158
  /* probe local memory usage in bytes (used/total) using OS-portable helpers */
@@ -190,9 +180,11 @@ export default class StatuslineCommand {
190
180
  }
191
181
  /* register commands */
192
182
  register(program) {
193
- /* default for --tool derived from ASE_TOOL environment variable */
183
+ /* default for --tool derived from ASE_TOOL environment variable
184
+ (validated lazily by "parseTool" in the action, so an invalid
185
+ value cannot break unrelated "ase" commands at startup) */
194
186
  const envTool = process.env.ASE_TOOL ?? "";
195
- const toolDflt = envTool !== "" ? this.parseTool(envTool) : "claude";
187
+ const toolDflt = envTool !== "" ? envTool : "claude";
196
188
  program
197
189
  .command("statusline")
198
190
  .description("Render Anthropic Claude Code CLI or GitHub Copilot CLI statusline from stdin JSON")
@@ -339,7 +331,7 @@ export default class StatuslineCommand {
339
331
  emit(`${prefix("⚒", "effort")}${c.bold(effort)}`);
340
332
  },
341
333
  t: () => {
342
- const thinking = (data.thinking?.enabled ?? false) === true ? "yes" : "no";
334
+ const thinking = data.thinking?.enabled === true ? "yes" : "no";
343
335
  emit(`${prefix("⚛", "thinking")}${c.bold(thinking)}`);
344
336
  },
345
337
  /* ==== OUTPUT ==== */
@@ -355,7 +347,7 @@ export default class StatuslineCommand {
355
347
  },
356
348
  /* ==== CONTEXT ==== */
357
349
  c: () => {
358
- const pct = Math.floor(data.context_window?.used_percentage ?? 0);
350
+ const pct = Math.min(100, Math.max(0, Math.floor(data.context_window?.used_percentage ?? 0)));
359
351
  const barSize = 20;
360
352
  const filled = Math.round(pct / 100 * barSize);
361
353
  const bar = "█".repeat(filled) + "░".repeat(barSize - filled);
@@ -485,7 +477,7 @@ export default class StatuslineCommand {
485
477
  col = 0;
486
478
  }
487
479
  /* send output */
488
- process.stdout.write(out);
480
+ await writeStdout(out);
489
481
  /* optionally publish task id to the calling tmux pane as a per-pane user
490
482
  option, so something (like claudeX) can pick it up via #{@ase_task_id} */
491
483
  if (process.env.TMUX !== undefined
package/dst/ase-task.js CHANGED
@@ -10,6 +10,7 @@ import { DateTime } from "luxon";
10
10
  import picomatch from "picomatch";
11
11
  import { isScalar } from "yaml";
12
12
  import { z } from "zod";
13
+ import { LRUCache } from "lru-cache";
13
14
  import { Config, configSchema, parseScope } from "./ase-config.js";
14
15
  import { Markdown } from "./ase-markdown.js";
15
16
  import { writeStdout } from "./ase-stdout.js";
@@ -24,19 +25,36 @@ export class Task {
24
25
  if (!/^[A-Za-z0-9_-]+$/.test(id))
25
26
  throw new Error("task: id must match [A-Za-z0-9_-]+");
26
27
  }
28
+ /* validate the session id to keep it safe as a config scope term */
29
+ static validateSession(session) {
30
+ if (typeof session !== "string" || session.length === 0)
31
+ throw new Error("task: session must be a non-empty string");
32
+ if (!/^[A-Za-z0-9_-]+$/.test(session))
33
+ throw new Error("task: session must match [A-Za-z0-9_-]+");
34
+ }
35
+ /* cached project root determination (TTL-bounded, as the ASE
36
+ service is long-running and the Git context can change) */
37
+ static projectRootCache = new LRUCache({ max: 4, ttl: 10 * 1000 });
27
38
  /* determine the project root (Git top-level if inside a Git
28
- working tree, otherwise the current working directory) */
39
+ working tree, otherwise the current working directory);
40
+ cached, as each determination spawns a Git subprocess */
29
41
  static projectRoot() {
42
+ const cwd = process.cwd();
43
+ const cached = Task.projectRootCache.get(cwd);
44
+ if (cached !== undefined)
45
+ return cached;
46
+ let root = cwd;
30
47
  try {
31
48
  const result = execaSync("git", ["rev-parse", "--show-toplevel"], { stderr: "ignore" });
32
49
  const top = result.stdout.trim();
33
50
  if (top !== "")
34
- return top;
51
+ root = top;
35
52
  }
36
53
  catch {
37
54
  /* not inside a Git working tree */
38
55
  }
39
- return process.cwd();
56
+ Task.projectRootCache.set(cwd, root);
57
+ return root;
40
58
  }
41
59
  /* read the configured "basedir" anchor and "files" miniglob spec for
42
60
  task storage; "basedir" is project-root-relative (POSIX, defaults
@@ -166,10 +184,9 @@ export class Task {
166
184
  fs.rmSync(oldFile, { force: true });
167
185
  return true;
168
186
  }
169
- /* list all persisted tasks in lexicographic id order; if verbose is true,
170
- each entry's `mtime` is set to the task file's modification time
171
- formatted as "YYYY-MM-DD HH:MM", otherwise it is left undefined */
172
- static list(log, verbose = false) {
187
+ /* scan the task base directory (after eager migration) for
188
+ "TASK-<id>.md" files matching the configured "files" miniglob */
189
+ static scan(log) {
173
190
  if (Task.needsMigration(log))
174
191
  Task.migrateAll(log);
175
192
  const { basedir, files } = Task.spec(log);
@@ -186,41 +203,37 @@ export class Task {
186
203
  const st = fs.statSync(file);
187
204
  if (!st.isFile())
188
205
  continue;
189
- const mtime = verbose ? DateTime.fromJSDate(st.mtime).toFormat("yyyy-LL-dd HH:mm") : undefined;
190
- out.push({ id: m[1], mtime });
206
+ out.push({ id: m[1], file, st });
191
207
  }
208
+ return out;
209
+ }
210
+ /* list all persisted tasks in lexicographic id order; if verbose is true,
211
+ each entry's `mtime` is set to the task file's modification time
212
+ formatted as "YYYY-MM-DD HH:MM", otherwise it is left undefined */
213
+ static list(log, verbose = false) {
214
+ const out = Task.scan(log).map((entry) => ({
215
+ id: entry.id,
216
+ mtime: verbose ? DateTime.fromJSDate(entry.st.mtime).toFormat("yyyy-LL-dd HH:mm") : undefined
217
+ }));
192
218
  out.sort((a, b) => a.id.localeCompare(b.id));
193
219
  return out;
194
220
  }
195
221
  /* purge tasks whose modification time is older than the given cutoff in
196
222
  milliseconds; returns the list of removed task ids */
197
223
  static purge(log, maxAgeMs) {
198
- if (Task.needsMigration(log))
199
- Task.migrateAll(log);
200
- const { basedir, files } = Task.spec(log);
201
- const dir = path.join(Task.projectRoot(), basedir);
202
- if (!fs.existsSync(dir))
203
- return [];
204
- const isMatch = picomatch(files, { dot: true });
205
224
  const cutoff = Date.now() - maxAgeMs;
206
225
  const removed = [];
207
- for (const entry of fs.readdirSync(dir)) {
208
- const m = /^TASK-([A-Za-z0-9_-]+)\.md$/.exec(entry);
209
- if (m === null || !isMatch(entry))
210
- continue;
211
- const file = path.join(dir, entry);
212
- const st = fs.statSync(file);
213
- if (!st.isFile())
214
- continue;
215
- if (st.mtimeMs < cutoff) {
216
- fs.rmSync(file, { force: true });
217
- removed.push(m[1]);
226
+ for (const entry of Task.scan(log)) {
227
+ if (entry.st.mtimeMs < cutoff) {
228
+ fs.rmSync(entry.file, { force: true });
229
+ removed.push(entry.id);
218
230
  }
219
231
  }
220
232
  return removed;
221
233
  }
222
234
  /* get the active task id for a given session, or empty string if none */
223
235
  static getId(log, session) {
236
+ Task.validateSession(session);
224
237
  const scope = parseScope(`session:${session}`);
225
238
  const cfg = new Config("config", configSchema, log, scope);
226
239
  cfg.read();
@@ -231,6 +244,7 @@ export class Task {
231
244
  }
232
245
  /* set the active task id for a given session */
233
246
  static setId(log, session, id) {
247
+ Task.validateSession(session);
234
248
  const scope = parseScope(`session:${session}`);
235
249
  const cfg = new Config("config", configSchema, log, scope);
236
250
  cfg.lock(() => {
@@ -357,10 +371,8 @@ export default class TaskCommand {
357
371
  const day = 24 * hour;
358
372
  const month = 30 * day;
359
373
  const year = 365 * day;
360
- const factor = unit === "h" ? hour :
361
- unit === "d" ? day :
362
- unit === "m" ? month :
363
- year;
374
+ const factors = { h: hour, d: day, m: month, y: year };
375
+ const factor = factors[unit];
364
376
  const removed = Task.purge(this.log, n * factor);
365
377
  if (removed.length === 0)
366
378
  this.log.write("info", "task: no tasks to purge");
@@ -371,6 +383,14 @@ export default class TaskCommand {
371
383
  });
372
384
  }
373
385
  }
386
+ /* render a caught error as an MCP tool error result */
387
+ const mcpError = (err) => {
388
+ const message = err instanceof Error ? err.message : String(err);
389
+ return {
390
+ isError: true,
391
+ content: [{ type: "text", text: `ERROR: ${message}` }]
392
+ };
393
+ };
374
394
  /* MCP registration entry point for task tools */
375
395
  export class TaskMCP {
376
396
  log;
@@ -412,11 +432,7 @@ export class TaskMCP {
412
432
  };
413
433
  }
414
434
  catch (err) {
415
- const message = err instanceof Error ? err.message : String(err);
416
- return {
417
- isError: true,
418
- content: [{ type: "text", text: `ERROR: ${message}` }]
419
- };
435
+ return mcpError(err);
420
436
  }
421
437
  });
422
438
  /* task load */
@@ -437,11 +453,7 @@ export class TaskMCP {
437
453
  };
438
454
  }
439
455
  catch (err) {
440
- const message = err instanceof Error ? err.message : String(err);
441
- return {
442
- isError: true,
443
- content: [{ type: "text", text: `ERROR: ${message}` }]
444
- };
456
+ return mcpError(err);
445
457
  }
446
458
  });
447
459
  /* task save */
@@ -463,11 +475,7 @@ export class TaskMCP {
463
475
  };
464
476
  }
465
477
  catch (err) {
466
- const message = err instanceof Error ? err.message : String(err);
467
- return {
468
- isError: true,
469
- content: [{ type: "text", text: `ERROR: ${message}` }]
470
- };
478
+ return mcpError(err);
471
479
  }
472
480
  });
473
481
  /* task delete */
@@ -490,18 +498,15 @@ export class TaskMCP {
490
498
  };
491
499
  }
492
500
  catch (err) {
493
- const message = err instanceof Error ? err.message : String(err);
494
- return {
495
- isError: true,
496
- content: [{ type: "text", text: `ERROR: ${message}` }]
497
- };
501
+ return mcpError(err);
498
502
  }
499
503
  });
500
504
  /* task rename */
501
505
  mcp.registerTool("ase_task_rename", {
502
506
  title: "ASE task rename",
503
- description: "Rename a previously persisted task from `old` to `new` by atomically moving the " +
504
- "task `TASK-<id>.md` file. Returns a status `text` indicating whether the rename succeeded. " +
507
+ description: "Rename a previously persisted task from `old` to `new` by moving the " +
508
+ "task `TASK-<id>.md` file and rewriting its embedded task heading. " +
509
+ "Returns a status `text` indicating whether the rename succeeded. " +
505
510
  "Fails with an error if the target id already exists.",
506
511
  inputSchema: {
507
512
  old: z.string()
@@ -520,11 +525,7 @@ export class TaskMCP {
520
525
  };
521
526
  }
522
527
  catch (err) {
523
- const message = err instanceof Error ? err.message : String(err);
524
- return {
525
- isError: true,
526
- content: [{ type: "text", text: `ERROR: ${message}` }]
527
- };
528
+ return mcpError(err);
528
529
  }
529
530
  });
530
531
  /* task id get/set */
@@ -556,11 +557,7 @@ export class TaskMCP {
556
557
  };
557
558
  }
558
559
  catch (err) {
559
- const message = err instanceof Error ? err.message : String(err);
560
- return {
561
- isError: true,
562
- content: [{ type: "text", text: `ERROR: ${message}` }]
563
- };
560
+ return mcpError(err);
564
561
  }
565
562
  });
566
563
  }
@@ -11,7 +11,8 @@ export default class Version {
11
11
  static current() {
12
12
  return pkg.version;
13
13
  }
14
- /* return latest ASE version available on the NPM registry */
14
+ /* return latest ASE version known from the NPM registry
15
+ (cached background check, falling back to the current version) */
15
16
  static async latest() {
16
17
  const notifier = updateNotifier({
17
18
  pkg,