@rse/ase 0.0.46 → 0.0.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dst/ase-config.js +103 -6
- package/dst/ase-diagram.js +3 -3
- package/dst/ase-getopt.js +8 -4
- package/dst/ase-hook.js +122 -23
- package/dst/ase-mcp.js +26 -20
- package/dst/ase-service.js +10 -3
- package/dst/ase-skills.js +182 -42
- package/dst/ase-statusline.js +17 -21
- package/dst/ase-task.js +60 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.github/plugin/plugin.json +1 -1
- package/plugin/agents/ase-code-lint.md +370 -0
- package/plugin/agents/ase-docs-proofread.md +100 -0
- package/plugin/meta/ase-constitution.md +7 -7
- package/plugin/meta/ase-control.md +1 -1
- package/plugin/meta/ase-dialog.md +2 -2
- package/plugin/meta/ase-persona.md +2 -2
- package/plugin/meta/ase-plan.md +2 -2
- package/plugin/meta/ase-skill.md +2 -2
- package/plugin/package.json +1 -1
- package/plugin/skills/ase-arch-analyze/SKILL.md +4 -4
- package/plugin/skills/ase-arch-discover/SKILL.md +14 -3
- package/plugin/skills/ase-code-analyze/SKILL.md +2 -2
- package/plugin/skills/ase-code-explain/SKILL.md +1 -1
- package/plugin/skills/ase-code-lint/SKILL.md +179 -298
- package/plugin/skills/ase-code-resolve/SKILL.md +1 -1
- package/plugin/skills/ase-docs-proofread/SKILL.md +151 -51
- package/plugin/skills/ase-meta-changes/SKILL.md +4 -4
- package/plugin/skills/ase-meta-diagram/SKILL.md +5 -5
- package/plugin/skills/ase-meta-evaluate/SKILL.md +2 -2
- package/plugin/skills/ase-meta-persona/SKILL.md +1 -1
- package/plugin/skills/ase-meta-quorum/SKILL.md +1 -1
- package/plugin/skills/ase-task-rename/SKILL.md +92 -0
package/dst/ase-config.js
CHANGED
|
@@ -13,6 +13,7 @@ import * as v from "valibot";
|
|
|
13
13
|
import Table from "cli-table3";
|
|
14
14
|
import writeFileAtomic from "write-file-atomic";
|
|
15
15
|
import lockfile from "proper-lockfile";
|
|
16
|
+
import { z } from "zod";
|
|
16
17
|
/* classification taxonomy */
|
|
17
18
|
export const projectClassification = {
|
|
18
19
|
boxing: ["white", "grey", "black"]
|
|
@@ -53,7 +54,8 @@ export const projectClassificationPresets = {
|
|
|
53
54
|
(reads always cascade through the full chain; this restricts writes only);
|
|
54
55
|
keys absent from this map default to all non-"default" scope kinds */
|
|
55
56
|
export const configWritableScopes = {
|
|
56
|
-
"agent.task": ["session"]
|
|
57
|
+
"agent.task": ["session"],
|
|
58
|
+
"agent.skill": ["session"]
|
|
57
59
|
};
|
|
58
60
|
/* default set of scope kinds writable for any unrestricted key */
|
|
59
61
|
const configWritableScopesDefault = ["user", "project", "task", "session"];
|
|
@@ -132,7 +134,8 @@ export const configSchema = v.nullish(v.strictObject({
|
|
|
132
134
|
})),
|
|
133
135
|
agent: v.optional(v.strictObject({
|
|
134
136
|
persona: v.optional(v.picklist(agentClassification.persona)),
|
|
135
|
-
task: v.optional(v.pipe(v.string(), v.minLength(1)))
|
|
137
|
+
task: v.optional(v.pipe(v.string(), v.minLength(1))),
|
|
138
|
+
skill: v.optional(v.pipe(v.string(), v.minLength(1)))
|
|
136
139
|
}))
|
|
137
140
|
}));
|
|
138
141
|
/* encapsulate read/write access to a stack of "<name>.yaml" configuration files,
|
|
@@ -333,10 +336,7 @@ export class Config {
|
|
|
333
336
|
doc.deleteIn(segs);
|
|
334
337
|
progressed = true;
|
|
335
338
|
}
|
|
336
|
-
|
|
337
|
-
/* root-level issue cannot be deleted; skip it and process
|
|
338
|
-
remaining issues so progressed is tracked correctly */
|
|
339
|
-
continue;
|
|
339
|
+
/* root-level issues cannot be deleted; processing continues with the remaining issues */
|
|
340
340
|
}
|
|
341
341
|
if (!progressed)
|
|
342
342
|
return;
|
|
@@ -626,3 +626,100 @@ export default class ConfigCommand {
|
|
|
626
626
|
});
|
|
627
627
|
}
|
|
628
628
|
}
|
|
629
|
+
/* MCP registration entry point for layered YAML configuration access */
|
|
630
|
+
export class ConfigMCP {
|
|
631
|
+
log;
|
|
632
|
+
constructor(log) {
|
|
633
|
+
this.log = log;
|
|
634
|
+
}
|
|
635
|
+
/* register the MCP tools */
|
|
636
|
+
register(mcp) {
|
|
637
|
+
/* config get */
|
|
638
|
+
mcp.registerTool("config_get", {
|
|
639
|
+
title: "ASE config get",
|
|
640
|
+
description: "Read the effective value of a dotted configuration `key` from the layered " +
|
|
641
|
+
"configuration, cascading through default/user/project/task/session chain up to and " +
|
|
642
|
+
"including the requested `scope`. Returns the value as JSON-encoded `text`; " +
|
|
643
|
+
"returns an empty string if no value is set.",
|
|
644
|
+
inputSchema: {
|
|
645
|
+
key: z.string()
|
|
646
|
+
.describe("dotted configuration key (e.g. \"agent.skill\")"),
|
|
647
|
+
scope: z.string()
|
|
648
|
+
.describe("scope chain (e.g. \"session:<id>\", \"task:<id>\", \"project\", \"user\")")
|
|
649
|
+
}
|
|
650
|
+
}, async (args) => {
|
|
651
|
+
try {
|
|
652
|
+
const scope = parseScope(args.scope);
|
|
653
|
+
const cfg = new Config("config", configSchema, this.log, scope);
|
|
654
|
+
let text = "";
|
|
655
|
+
cfg.lock(() => {
|
|
656
|
+
cfg.read();
|
|
657
|
+
const val = cfg.get(args.key);
|
|
658
|
+
text = val === undefined ? "" : JSON.stringify(val);
|
|
659
|
+
});
|
|
660
|
+
return { content: [{ type: "text", text }] };
|
|
661
|
+
}
|
|
662
|
+
catch (err) {
|
|
663
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
664
|
+
return { isError: true, content: [{ type: "text", text: `config_get: ERROR: ${message}` }] };
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
/* config set */
|
|
668
|
+
mcp.registerTool("config_set", {
|
|
669
|
+
title: "ASE config set",
|
|
670
|
+
description: "Write `val` to a dotted configuration `key` at the target `scope` " +
|
|
671
|
+
"(the strongest scope term in the chain). The value is validated against " +
|
|
672
|
+
"the configuration schema before being persisted.",
|
|
673
|
+
inputSchema: {
|
|
674
|
+
key: z.string()
|
|
675
|
+
.describe("dotted configuration key (e.g. \"agent.skill\")"),
|
|
676
|
+
val: z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.any()), z.record(z.string(), z.any())])
|
|
677
|
+
.describe("value to store under `key`"),
|
|
678
|
+
scope: z.string()
|
|
679
|
+
.describe("scope chain (e.g. \"session:<id>\", \"task:<id>\", \"project\", \"user\")")
|
|
680
|
+
}
|
|
681
|
+
}, async (args) => {
|
|
682
|
+
try {
|
|
683
|
+
const scope = parseScope(args.scope);
|
|
684
|
+
const cfg = new Config("config", configSchema, this.log, scope);
|
|
685
|
+
cfg.lock(() => {
|
|
686
|
+
cfg.read();
|
|
687
|
+
cfg.set(args.key, args.val);
|
|
688
|
+
cfg.write();
|
|
689
|
+
});
|
|
690
|
+
return { content: [{ type: "text", text: `config_set: OK: stored "${args.key}" on scope "${args.scope}"` }] };
|
|
691
|
+
}
|
|
692
|
+
catch (err) {
|
|
693
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
694
|
+
return { isError: true, content: [{ type: "text", text: `config_set: ERROR: ${message}` }] };
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
/* config delete */
|
|
698
|
+
mcp.registerTool("config_delete", {
|
|
699
|
+
title: "ASE config delete",
|
|
700
|
+
description: "Delete the value at a dotted configuration `key` from the target `scope` " +
|
|
701
|
+
"(the strongest scope term in the chain). No-op if the key is not present.",
|
|
702
|
+
inputSchema: {
|
|
703
|
+
key: z.string()
|
|
704
|
+
.describe("dotted configuration key (e.g. \"agent.skill\")"),
|
|
705
|
+
scope: z.string()
|
|
706
|
+
.describe("scope chain (e.g. \"session:<id>\", \"task:<id>\", \"project\", \"user\")")
|
|
707
|
+
}
|
|
708
|
+
}, async (args) => {
|
|
709
|
+
try {
|
|
710
|
+
const scope = parseScope(args.scope);
|
|
711
|
+
const cfg = new Config("config", configSchema, this.log, scope);
|
|
712
|
+
cfg.lock(() => {
|
|
713
|
+
cfg.read();
|
|
714
|
+
cfg.delete(args.key);
|
|
715
|
+
cfg.write();
|
|
716
|
+
});
|
|
717
|
+
return { content: [{ type: "text", text: `config_delete: OK: removed "${args.key}" on scope "${args.scope}"` }] };
|
|
718
|
+
}
|
|
719
|
+
catch (err) {
|
|
720
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
721
|
+
return { isError: true, content: [{ type: "text", text: `config_delete: ERROR: ${message}` }] };
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
}
|
package/dst/ase-diagram.js
CHANGED
|
@@ -278,9 +278,9 @@ export class DiagramMCP {
|
|
|
278
278
|
"Use for visualizing " +
|
|
279
279
|
"structure/layout/components/dependencies as a Flowchart, " +
|
|
280
280
|
"control-flow/branching/concurrency as a Flowchart, " +
|
|
281
|
-
"state-machine/states/transitions as
|
|
282
|
-
"data-flow/actors/messages/protocols as
|
|
283
|
-
"data-structure/classes/methods as
|
|
281
|
+
"state-machine/states/transitions as a UML State Diagram, " +
|
|
282
|
+
"data-flow/actors/messages/protocols as a UML Sequence Diagram, " +
|
|
283
|
+
"data-structure/classes/methods as a UML Class Diagram, " +
|
|
284
284
|
"data-model/entities/relationships as an ER Diagram, or " +
|
|
285
285
|
"metrics/distributions/time-series as an XY-Chart. " +
|
|
286
286
|
"Pass the Mermaid diagram specification as `diagram`. " +
|
package/dst/ase-getopt.js
CHANGED
|
@@ -34,7 +34,11 @@ export class GetoptMCP {
|
|
|
34
34
|
/* normalize args */
|
|
35
35
|
const argsRaw = typeof args.args === "string" ? args.args : null;
|
|
36
36
|
const argsVec = typeof args.args === "string" ?
|
|
37
|
-
shParse(args.args)
|
|
37
|
+
shParse(args.args)
|
|
38
|
+
.map((e) => typeof e === "string" ? e :
|
|
39
|
+
(e !== null && typeof e === "object" && "op" in e && e.op === "glob" ?
|
|
40
|
+
e.pattern : null))
|
|
41
|
+
.filter((e) => e !== null) :
|
|
38
42
|
args.args;
|
|
39
43
|
/* build a fresh commander program */
|
|
40
44
|
const cmd = new Command(args.name)
|
|
@@ -104,9 +108,9 @@ export class GetoptMCP {
|
|
|
104
108
|
}
|
|
105
109
|
ranges.push({ start, end: i });
|
|
106
110
|
}
|
|
107
|
-
const
|
|
108
|
-
if (
|
|
109
|
-
const first = ranges[
|
|
111
|
+
const consumed = argsVec.length - cmd.args.length;
|
|
112
|
+
if (cmd.args.length > 0 && consumed >= 0 && consumed < ranges.length) {
|
|
113
|
+
const first = ranges[consumed].start;
|
|
110
114
|
argsVerbatim = argsRaw.slice(first);
|
|
111
115
|
}
|
|
112
116
|
}
|
package/dst/ase-hook.js
CHANGED
|
@@ -8,6 +8,7 @@ import fs from "node:fs";
|
|
|
8
8
|
import os from "node:os";
|
|
9
9
|
import { execaSync } from "execa";
|
|
10
10
|
import { quote } from "shell-quote";
|
|
11
|
+
import * as v from "valibot";
|
|
11
12
|
import Version from "./ase-version.js";
|
|
12
13
|
import { Config, configSchema, parseScope } from "./ase-config.js";
|
|
13
14
|
const toolSpecs = {
|
|
@@ -36,10 +37,36 @@ export default class HookCommand {
|
|
|
36
37
|
constructor(log) {
|
|
37
38
|
this.log = log;
|
|
38
39
|
}
|
|
40
|
+
/* validate a session id against the accepted character set */
|
|
41
|
+
isValidSessionId(id) {
|
|
42
|
+
return /^[A-Za-z0-9._-]+$/.test(id);
|
|
43
|
+
}
|
|
44
|
+
/* best-effort JSON parse with valibot schema validation: returns
|
|
45
|
+
an empty object on blank input, malformed JSON, or schema
|
|
46
|
+
mismatch, so callers can treat the result uniformly. Extra
|
|
47
|
+
properties in the data are tolerated; only the declared schema
|
|
48
|
+
entries are required to match. */
|
|
49
|
+
parseJSON(text, schema) {
|
|
50
|
+
const empty = {};
|
|
51
|
+
if (text.trim() === "")
|
|
52
|
+
return empty;
|
|
53
|
+
let raw;
|
|
54
|
+
try {
|
|
55
|
+
raw = JSON.parse(text);
|
|
56
|
+
}
|
|
57
|
+
catch (_e) {
|
|
58
|
+
/* best-effort: return empty object on malformed JSON */
|
|
59
|
+
return empty;
|
|
60
|
+
}
|
|
61
|
+
const result = v.safeParse(schema, raw);
|
|
62
|
+
if (!result.success)
|
|
63
|
+
return empty;
|
|
64
|
+
return result.output;
|
|
65
|
+
}
|
|
39
66
|
/* recursively expand "@<path>" file references in a Markdown text,
|
|
40
67
|
resolving paths relative to the directory of the containing file */
|
|
41
68
|
expandReferences(text, baseDir, visited = new Set()) {
|
|
42
|
-
return text.replace(/@(
|
|
69
|
+
return text.replace(/@(\S+)/g, (match, ref) => {
|
|
43
70
|
let resolved = ref;
|
|
44
71
|
if (resolved.startsWith("~/"))
|
|
45
72
|
resolved = path.join(process.env.HOME ?? "", resolved.slice(2));
|
|
@@ -71,10 +98,23 @@ export default class HookCommand {
|
|
|
71
98
|
const filePkg = path.join(pluginRoot, ".claude-plugin", "plugin.json");
|
|
72
99
|
const fileMd = path.join(pluginRoot, "meta", "ase-constitution.md");
|
|
73
100
|
/* read external files */
|
|
74
|
-
|
|
75
|
-
let md
|
|
101
|
+
let pkg;
|
|
102
|
+
let md;
|
|
103
|
+
try {
|
|
104
|
+
pkg = fs.readFileSync(filePkg, "utf8");
|
|
105
|
+
}
|
|
106
|
+
catch (_e) {
|
|
107
|
+
throw new Error(`failed to read plugin manifest: ${filePkg}`);
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
md = fs.readFileSync(fileMd, "utf8");
|
|
111
|
+
}
|
|
112
|
+
catch (_e) {
|
|
113
|
+
throw new Error(`failed to read constitution file: ${fileMd}`);
|
|
114
|
+
}
|
|
76
115
|
/* determine own version */
|
|
77
|
-
const
|
|
116
|
+
const pkgObj = this.parseJSON(pkg, v.object({ version: v.optional(v.string()) }));
|
|
117
|
+
const versionCurrentPlugin = pkgObj.version ?? "";
|
|
78
118
|
const versionCurrentTool = Version.current();
|
|
79
119
|
const versionLatestTool = await Version.latest();
|
|
80
120
|
/* sanity check situation */
|
|
@@ -90,11 +130,15 @@ export default class HookCommand {
|
|
|
90
130
|
/* read session information (Claude Code uses snake_case fields,
|
|
91
131
|
Copilot CLI uses camelCase fields) */
|
|
92
132
|
const stdin = fs.readFileSync(0, "utf8");
|
|
93
|
-
const input =
|
|
133
|
+
const input = this.parseJSON(stdin, v.object({
|
|
134
|
+
session_id: v.optional(v.string()),
|
|
135
|
+
sessionId: v.optional(v.string()),
|
|
136
|
+
cwd: v.optional(v.string())
|
|
137
|
+
}));
|
|
94
138
|
/* determine session id */
|
|
95
|
-
const sessionId =
|
|
139
|
+
const sessionId = this.pickSessionId(input);
|
|
96
140
|
/* establish config context (session-scoped only if a valid sessionId is present) */
|
|
97
|
-
const hasSession =
|
|
141
|
+
const hasSession = this.isValidSessionId(sessionId);
|
|
98
142
|
const cfg = new Config("config", configSchema, this.log, hasSession ? parseScope(`session:${sessionId}`) : parseScope(undefined));
|
|
99
143
|
cfg.lock(() => {
|
|
100
144
|
cfg.read();
|
|
@@ -196,18 +240,32 @@ export default class HookCommand {
|
|
|
196
240
|
/* handler for "ase hook stop" (both tools) */
|
|
197
241
|
doStop(_tool) {
|
|
198
242
|
this.writeAgentStatus("ready");
|
|
243
|
+
/* safety net: clear any lingering "agent.skill" marker so a
|
|
244
|
+
crashed or aborted skill loop does not leave information active */
|
|
245
|
+
const sessionId = this.readSessionIdFromStdin();
|
|
246
|
+
if (this.isValidSessionId(sessionId)) {
|
|
247
|
+
try {
|
|
248
|
+
const cfg = new Config("config", configSchema, this.log, parseScope(`session:${sessionId}`));
|
|
249
|
+
cfg.lock(() => {
|
|
250
|
+
cfg.read();
|
|
251
|
+
if (typeof cfg.get("agent.skill") === "string") {
|
|
252
|
+
cfg.delete("agent.skill");
|
|
253
|
+
cfg.write();
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
catch (_e) {
|
|
258
|
+
/* best-effort: ignore failures */
|
|
259
|
+
}
|
|
260
|
+
}
|
|
199
261
|
return 0;
|
|
200
262
|
}
|
|
201
263
|
/* handler for "ase hook session-end" (both tools) */
|
|
202
264
|
doSessionEnd(_tool) {
|
|
203
|
-
/* read session information (Claude Code uses snake_case fields,
|
|
204
|
-
Copilot CLI uses camelCase fields) */
|
|
205
|
-
const stdin = fs.readFileSync(0, "utf8");
|
|
206
|
-
const input = stdin.trim() !== "" ? JSON.parse(stdin) : {};
|
|
207
265
|
/* determine session id */
|
|
208
|
-
const sessionId =
|
|
266
|
+
const sessionId = this.readSessionIdFromStdin();
|
|
209
267
|
/* remove the session directory ~/.ase/session/<id> (only for a valid sessionId) */
|
|
210
|
-
if (
|
|
268
|
+
if (this.isValidSessionId(sessionId)) {
|
|
211
269
|
const dir = path.join(os.homedir(), ".ase", "session", sessionId);
|
|
212
270
|
try {
|
|
213
271
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
@@ -218,26 +276,59 @@ export default class HookCommand {
|
|
|
218
276
|
}
|
|
219
277
|
return 0;
|
|
220
278
|
}
|
|
279
|
+
/* pick the session id from a parsed payload (Claude Code uses
|
|
280
|
+
snake_case fields, Copilot CLI uses camelCase fields) */
|
|
281
|
+
pickSessionId(input) {
|
|
282
|
+
return input.session_id ?? input.sessionId ?? "";
|
|
283
|
+
}
|
|
284
|
+
/* read session id from stdin JSON payload */
|
|
285
|
+
readSessionIdFromStdin() {
|
|
286
|
+
const stdin = fs.readFileSync(0, "utf8");
|
|
287
|
+
const input = this.parseJSON(stdin, v.object({
|
|
288
|
+
session_id: v.optional(v.string()),
|
|
289
|
+
sessionId: v.optional(v.string())
|
|
290
|
+
}));
|
|
291
|
+
return this.pickSessionId(input);
|
|
292
|
+
}
|
|
293
|
+
/* read the session-scoped "agent.skill" config value */
|
|
294
|
+
readActiveSkill(sessionId) {
|
|
295
|
+
if (!this.isValidSessionId(sessionId))
|
|
296
|
+
return "";
|
|
297
|
+
try {
|
|
298
|
+
const cfg = new Config("config", configSchema, this.log, parseScope(`session:${sessionId}`));
|
|
299
|
+
let val = "";
|
|
300
|
+
cfg.lock(() => {
|
|
301
|
+
cfg.read();
|
|
302
|
+
const skill = cfg.get("agent.skill");
|
|
303
|
+
if (typeof skill === "string")
|
|
304
|
+
val = skill;
|
|
305
|
+
});
|
|
306
|
+
return val;
|
|
307
|
+
}
|
|
308
|
+
catch (_e) {
|
|
309
|
+
return "";
|
|
310
|
+
}
|
|
311
|
+
}
|
|
221
312
|
/* handler for "ase hook pre-tool-use" (both tools) */
|
|
222
313
|
doPreToolUse(tool) {
|
|
223
314
|
const spec = toolSpecs[tool];
|
|
224
315
|
/* read tool invocation information */
|
|
225
316
|
const stdin = fs.readFileSync(0, "utf8");
|
|
226
|
-
const input =
|
|
317
|
+
const input = this.parseJSON(stdin, v.looseObject({
|
|
318
|
+
session_id: v.optional(v.string()),
|
|
319
|
+
sessionId: v.optional(v.string())
|
|
320
|
+
}));
|
|
227
321
|
/* determine whether to auto-approve the tool invocation
|
|
228
322
|
(field names and value shapes differ between tools) */
|
|
229
323
|
const toolName = typeof input[spec.toolNameField] === "string" ?
|
|
230
324
|
input[spec.toolNameField] : "";
|
|
231
325
|
let toolInput = {};
|
|
232
326
|
const rawInput = input[spec.toolInputField];
|
|
233
|
-
if (spec.toolInputIsString && typeof rawInput === "string")
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
/* best-effort: leave toolInput empty on parse failure */
|
|
239
|
-
}
|
|
240
|
-
}
|
|
327
|
+
if (spec.toolInputIsString && typeof rawInput === "string")
|
|
328
|
+
toolInput = this.parseJSON(rawInput, v.object({
|
|
329
|
+
command: v.optional(v.string()),
|
|
330
|
+
skill: v.optional(v.string())
|
|
331
|
+
}));
|
|
241
332
|
else if (!spec.toolInputIsString && typeof rawInput === "object" && rawInput !== null)
|
|
242
333
|
toolInput = rawInput;
|
|
243
334
|
let approve = false;
|
|
@@ -254,6 +345,14 @@ export default class HookCommand {
|
|
|
254
345
|
approve = true;
|
|
255
346
|
reason = "ASE MCP tool invocation auto-approved";
|
|
256
347
|
}
|
|
348
|
+
else if (toolName === "Edit") {
|
|
349
|
+
const sessionId = this.pickSessionId(input);
|
|
350
|
+
const activeSkill = this.readActiveSkill(sessionId);
|
|
351
|
+
if (activeSkill === "ase-docs-proofread" || activeSkill === "ase-code-lint") {
|
|
352
|
+
approve = true;
|
|
353
|
+
reason = `${activeSkill}: user already consented via AskUserQuestion`;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
257
356
|
/* emit permission decision (or stay silent to defer to default flow).
|
|
258
357
|
Claude Code expects the decision nested in "hookSpecificOutput";
|
|
259
358
|
Copilot CLI expects flat top-level fields. */
|
|
@@ -282,7 +381,7 @@ export default class HookCommand {
|
|
|
282
381
|
register(program) {
|
|
283
382
|
/* default for --tool derived from ASE_TOOL environment variable */
|
|
284
383
|
const envTool = process.env.ASE_TOOL ?? "";
|
|
285
|
-
const toolDflt = envTool !== "" ? envTool : "claude";
|
|
384
|
+
const toolDflt = envTool !== "" ? this.parseTool(envTool) : "claude";
|
|
286
385
|
/* register CLI top-level command "ase hook" */
|
|
287
386
|
const hookCmd = program
|
|
288
387
|
.command("hook")
|
package/dst/ase-mcp.js
CHANGED
|
@@ -26,9 +26,9 @@ export default class MCPCommand {
|
|
|
26
26
|
const projectId = rawId ?? path.basename(process.cwd());
|
|
27
27
|
const rawPort = svc.get("port");
|
|
28
28
|
const port = rawPort ?? null;
|
|
29
|
-
return { projectId, port
|
|
29
|
+
return { projectId, port };
|
|
30
30
|
}
|
|
31
|
-
/*
|
|
31
|
+
/* run "ase service start" and wait for the service to come up */
|
|
32
32
|
async ensureService() {
|
|
33
33
|
let ctx = this.loadContext();
|
|
34
34
|
/* fast path: already running */
|
|
@@ -52,6 +52,10 @@ export default class MCPCommand {
|
|
|
52
52
|
throw new Error(`mcp: service not responding on port ${ctx.port} after start`);
|
|
53
53
|
return { projectId: ctx.projectId, port: ctx.port };
|
|
54
54
|
}
|
|
55
|
+
/* coerce an unknown thrown value into an Error */
|
|
56
|
+
asError(e) {
|
|
57
|
+
return e instanceof Error ? e : new Error(String(e));
|
|
58
|
+
}
|
|
55
59
|
/* bridge stdio to a Streamable HTTP MCP endpoint on the local service */
|
|
56
60
|
async runBridge() {
|
|
57
61
|
/* ensure the service is running */
|
|
@@ -60,8 +64,9 @@ export default class MCPCommand {
|
|
|
60
64
|
const server = new StdioServerTransport();
|
|
61
65
|
/* track active client and bridge-level closed state */
|
|
62
66
|
let client = null;
|
|
63
|
-
let closedByUs = false; /*
|
|
64
|
-
let bridgeDone = false; /*
|
|
67
|
+
let closedByUs = false; /* set when we initiated the client close */
|
|
68
|
+
let bridgeDone = false; /* set when stdio side closes */
|
|
69
|
+
let reconnecting = false; /* set while a reconnect chain is active */
|
|
65
70
|
/* cleanly shut down the whole bridge */
|
|
66
71
|
const shutdown = async () => {
|
|
67
72
|
if (bridgeDone)
|
|
@@ -79,11 +84,9 @@ export default class MCPCommand {
|
|
|
79
84
|
const connectClient = async () => {
|
|
80
85
|
const url = new URL(`http://${HOST}:${port}/mcp`);
|
|
81
86
|
const next = new StreamableHTTPClientTransport(url);
|
|
82
|
-
client = next;
|
|
83
87
|
next.onmessage = (msg) => {
|
|
84
|
-
server.send(msg).catch((
|
|
85
|
-
|
|
86
|
-
this.log.write("error", `mcp: stdout send: ${err.message}`);
|
|
88
|
+
server.send(msg).catch((err) => {
|
|
89
|
+
this.log.write("error", `mcp: stdout send: ${this.asError(err).message}`);
|
|
87
90
|
});
|
|
88
91
|
};
|
|
89
92
|
next.onerror = (err) => {
|
|
@@ -91,12 +94,14 @@ export default class MCPCommand {
|
|
|
91
94
|
};
|
|
92
95
|
/* service closed the connection — try to recover */
|
|
93
96
|
next.onclose = () => {
|
|
94
|
-
if (closedByUs || bridgeDone)
|
|
97
|
+
if (client !== next || closedByUs || bridgeDone || reconnecting)
|
|
95
98
|
return;
|
|
99
|
+
reconnecting = true;
|
|
96
100
|
this.log.write("warning", "mcp: http connection lost — reconnecting");
|
|
97
|
-
reconnect().catch(() => { });
|
|
101
|
+
reconnect(0, () => { reconnecting = false; }).catch(() => { });
|
|
98
102
|
};
|
|
99
103
|
await next.start();
|
|
104
|
+
client = next;
|
|
100
105
|
};
|
|
101
106
|
/* reconnect loop: restart service if needed, then reconnect client */
|
|
102
107
|
const reconnect = async (attempt = 0, done) => {
|
|
@@ -111,22 +116,21 @@ export default class MCPCommand {
|
|
|
111
116
|
port = ctx.port;
|
|
112
117
|
closedByUs = true;
|
|
113
118
|
await client?.close();
|
|
114
|
-
closedByUs = false;
|
|
115
119
|
await connectClient();
|
|
120
|
+
closedByUs = false;
|
|
116
121
|
this.log.write("info", "mcp: reconnected to service");
|
|
117
122
|
done?.();
|
|
118
123
|
}
|
|
119
|
-
catch (
|
|
120
|
-
|
|
121
|
-
this.log.write("error", `mcp: reconnect failed: ${err.message}`);
|
|
124
|
+
catch (err) {
|
|
125
|
+
closedByUs = false;
|
|
126
|
+
this.log.write("error", `mcp: reconnect failed: ${this.asError(err).message}`);
|
|
122
127
|
reconnect(attempt + 1, done).catch(() => { });
|
|
123
128
|
}
|
|
124
129
|
};
|
|
125
130
|
/* wire stdio server */
|
|
126
131
|
server.onmessage = (msg) => {
|
|
127
|
-
client?.send(msg).catch((
|
|
128
|
-
|
|
129
|
-
this.log.write("error", `mcp: http send: ${err.message}`);
|
|
132
|
+
client?.send(msg).catch((err) => {
|
|
133
|
+
this.log.write("error", `mcp: http send: ${this.asError(err).message}`);
|
|
130
134
|
});
|
|
131
135
|
};
|
|
132
136
|
server.onerror = (err) => {
|
|
@@ -140,7 +144,6 @@ export default class MCPCommand {
|
|
|
140
144
|
await connectClient();
|
|
141
145
|
/* periodically probe the service; trigger reconnect if it is gone */
|
|
142
146
|
const HEALTH_INTERVAL_MS = 30_000;
|
|
143
|
-
let reconnecting = false;
|
|
144
147
|
const healthTimer = setInterval(async () => {
|
|
145
148
|
if (bridgeDone || reconnecting)
|
|
146
149
|
return;
|
|
@@ -153,7 +156,10 @@ export default class MCPCommand {
|
|
|
153
156
|
reconnect(0, () => { reconnecting = false; }).catch(() => { });
|
|
154
157
|
}
|
|
155
158
|
}
|
|
156
|
-
catch {
|
|
159
|
+
catch (err) {
|
|
160
|
+
/* ignore transient probe/context errors but record them */
|
|
161
|
+
this.log.write("debug", `mcp: health check error: ${this.asError(err).message}`);
|
|
162
|
+
}
|
|
157
163
|
}, HEALTH_INTERVAL_MS);
|
|
158
164
|
healthTimer.unref();
|
|
159
165
|
/* await stdio to be closed */
|
|
@@ -165,7 +171,7 @@ export default class MCPCommand {
|
|
|
165
171
|
/* shutdown services */
|
|
166
172
|
clearInterval(healthTimer);
|
|
167
173
|
await shutdown();
|
|
168
|
-
return 0;
|
|
174
|
+
return 0; /* unreachable, kept only to satisfy the Promise<number> return type */
|
|
169
175
|
}
|
|
170
176
|
/* register commands */
|
|
171
177
|
register(program) {
|
package/dst/ase-service.js
CHANGED
|
@@ -15,7 +15,7 @@ import prettyMs from "pretty-ms";
|
|
|
15
15
|
import * as v from "valibot";
|
|
16
16
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
17
17
|
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
18
|
-
import { Config, configSchema } from "./ase-config.js";
|
|
18
|
+
import { Config, configSchema, ConfigMCP } from "./ase-config.js";
|
|
19
19
|
import { DiagramMCP } from "./ase-diagram.js";
|
|
20
20
|
import { TaskMCP } from "./ase-task.js";
|
|
21
21
|
import { KVMCP } from "./ase-kv.js";
|
|
@@ -241,6 +241,7 @@ export default class ServiceCommand {
|
|
|
241
241
|
new TimestampMCP().register(mcp);
|
|
242
242
|
new GetoptMCP().register(mcp);
|
|
243
243
|
new SkillsMCP().register(mcp);
|
|
244
|
+
new ConfigMCP(this.log).register(mcp);
|
|
244
245
|
return mcp;
|
|
245
246
|
};
|
|
246
247
|
/* listen to HTTP/REST endpoints */
|
|
@@ -257,7 +258,13 @@ export default class ServiceCommand {
|
|
|
257
258
|
handler: (_request, h) => {
|
|
258
259
|
this.log.write("info", "service: stop requested");
|
|
259
260
|
setImmediate(async () => {
|
|
260
|
-
|
|
261
|
+
try {
|
|
262
|
+
await server.stop({ timeout: 1000 });
|
|
263
|
+
}
|
|
264
|
+
catch (err) {
|
|
265
|
+
const e = err;
|
|
266
|
+
this.log.write("error", `service: stop failed: ${e.message}`);
|
|
267
|
+
}
|
|
261
268
|
process.exit(0);
|
|
262
269
|
});
|
|
263
270
|
return h.response({ ok: true }).code(200);
|
|
@@ -438,7 +445,6 @@ export default class ServiceCommand {
|
|
|
438
445
|
lastErr = new Error(`${reason}${detail}`);
|
|
439
446
|
}
|
|
440
447
|
finally {
|
|
441
|
-
child.removeListener("exit", onExit);
|
|
442
448
|
if (!success && !exited) {
|
|
443
449
|
child.kill("SIGTERM");
|
|
444
450
|
await Promise.race([
|
|
@@ -454,6 +460,7 @@ export default class ServiceCommand {
|
|
|
454
460
|
child.unref();
|
|
455
461
|
}
|
|
456
462
|
}
|
|
463
|
+
child.removeListener("exit", onExit);
|
|
457
464
|
if (!success)
|
|
458
465
|
Service.clearPort(ctx.svc);
|
|
459
466
|
}
|