@rse/ase 0.9.47 → 0.9.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.
Files changed (47) hide show
  1. package/dst/ase-artifact.js +3 -1
  2. package/dst/ase-compat.js +1 -1
  3. package/dst/ase-config.js +76 -13
  4. package/dst/ase-diagram.js +10 -15
  5. package/dst/ase-getopt.js +66 -60
  6. package/dst/ase-guidance.js +89 -0
  7. package/dst/ase-hook.js +46 -66
  8. package/dst/ase-kv.js +18 -43
  9. package/dst/ase-mcp.js +1 -1
  10. package/dst/ase-meta.js +1 -1
  11. package/dst/ase-service.js +4 -6
  12. package/dst/ase-setup.js +24 -39
  13. package/dst/ase-skills.js +1 -1
  14. package/dst/ase-statusline.js +42 -18
  15. package/dst/ase-stdio.js +25 -0
  16. package/dst/ase-task.js +11 -11
  17. package/dst/ase.js +1 -1
  18. package/package.json +1 -1
  19. package/plugin/.claude-plugin/plugin.json +1 -1
  20. package/plugin/.codex-plugin/plugin.json +1 -1
  21. package/plugin/.github/plugin/plugin.json +1 -1
  22. package/plugin/meta/ase-common-task.md +9 -0
  23. package/plugin/meta/ase-constitution.md +17 -6
  24. package/plugin/meta/ase-getopt.md +11 -1
  25. package/plugin/meta/ase-skill.md +92 -4
  26. package/plugin/package.json +1 -1
  27. package/plugin/skills/ase-arch-analyze/SKILL.md +8 -6
  28. package/plugin/skills/ase-arch-discover/SKILL.md +12 -0
  29. package/plugin/skills/ase-code-analyze/SKILL.md +6 -4
  30. package/plugin/skills/ase-code-lint/SKILL.md +16 -0
  31. package/plugin/skills/ase-docs-proofread/SKILL.md +10 -0
  32. package/plugin/skills/ase-help-intent/SKILL.md +3 -8
  33. package/plugin/skills/ase-help-skill/SKILL.md +10 -2
  34. package/plugin/skills/ase-help-skill/catalog.md +1 -1
  35. package/plugin/skills/ase-meta-brainstorm/SKILL.md +8 -0
  36. package/plugin/skills/ase-meta-commit/SKILL.md +13 -0
  37. package/plugin/skills/ase-meta-config/SKILL.md +165 -0
  38. package/plugin/skills/ase-meta-config/help.md +136 -0
  39. package/plugin/skills/ase-meta-diaboli/help.md +1 -1
  40. package/plugin/skills/ase-meta-diff/SKILL.md +22 -0
  41. package/plugin/skills/ase-meta-review/SKILL.md +13 -1
  42. package/plugin/skills/ase-sync-export/SKILL.md +14 -0
  43. package/plugin/skills/ase-sync-import/SKILL.md +10 -0
  44. package/plugin/skills/ase-task-list/SKILL.md +21 -0
  45. package/plugin/skills/ase-task-view/SKILL.md +21 -0
  46. package/plugin/skills/ase-meta-persona/SKILL.md +0 -84
  47. package/plugin/skills/ase-meta-persona/help.md +0 -60
@@ -10,7 +10,7 @@ import { isScalar } from "yaml";
10
10
  import { z } from "zod";
11
11
  import { Config, configSchema } from "./ase-config.js";
12
12
  import { Task } from "./ase-task.js";
13
- import { writeStdout } from "./ase-stdout.js";
13
+ import { writeStdout } from "./ase-stdio.js";
14
14
  /* the recognized artifact kinds, in descending precedence order;
15
15
  "othr" is the implicit catch-all and is always resolved last */
16
16
  export const artifactKinds = ["spec", "arch", "code", "docs", "infr", "othr"];
@@ -141,6 +141,8 @@ export class Artifact {
141
141
  const basedir = Artifact.configString(cfg, `project.artifact.${kind}.basedir`)
142
142
  .replace(/\\/g, "/").replace(/^\/+|\/+$/g, "")
143
143
  .replace(/^\.$/, "");
144
+ if (basedir.split("/").includes(".."))
145
+ throw new Error(`artifact: configured "basedir" "${basedir}" must not escape the project root`);
144
146
  const files = Artifact.configString(cfg, `project.artifact.${kind}.files`);
145
147
  return { basedir, files };
146
148
  }
package/dst/ase-compat.js CHANGED
@@ -3,7 +3,7 @@
3
3
  ** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
4
  ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
5
  */
6
- import { writeStdout } from "./ase-stdout.js";
6
+ import { writeStdout } from "./ase-stdio.js";
7
7
  /* the canonical expected values for every ase-meta-compat probe,
8
8
  keyed by "<category>/<probe-name>" as used in the skill */
9
9
  const EXPECTED = {
package/dst/ase-config.js CHANGED
@@ -14,14 +14,16 @@ import Table from "cli-table3";
14
14
  import writeFileAtomic from "write-file-atomic";
15
15
  import lockfile from "proper-lockfile";
16
16
  import { z } from "zod";
17
- import { writeStdout } from "./ase-stdout.js";
17
+ import { LRUCache } from "lru-cache";
18
+ import { writeStdout } from "./ase-stdio.js";
18
19
  /* classification taxonomy */
19
20
  export const projectClassification = {
20
21
  boxing: ["white", "grey", "black"]
21
22
  };
22
23
  /* agent classification taxonomy */
23
24
  export const agentClassification = {
24
- persona: ["writer", "engineer", "journalist", "telegrapher", "caveman"]
25
+ persona: ["writer", "engineer", "journalist", "telegrapher", "caveman"],
26
+ guidance: ["none", "minimal", "normal", "verbose"]
25
27
  };
26
28
  /* classification presets */
27
29
  export const projectClassificationPresets = {
@@ -40,6 +42,7 @@ export const projectClassificationPresets = {
40
42
  default: {
41
43
  "agent.task": "default",
42
44
  "agent.persona": "engineer",
45
+ "agent.guidance": "normal",
43
46
  "project.id": "example",
44
47
  "project.name": "Example Project",
45
48
  "project.boxing": "white",
@@ -93,20 +96,33 @@ const parseScopeTerm = (value) => {
93
96
  else if (value === "project")
94
97
  return { kind: "project" };
95
98
  const m = /^(session|task):([A-Za-z0-9._-]+)$/.exec(value);
96
- if (m !== null)
99
+ if (m !== null) {
100
+ if (m[2] === "." || m[2] === "..")
101
+ throw new Error(`invalid --scope term "${value}": id must not be "." or ".."`);
97
102
  return { kind: m[1], id: m[2] };
103
+ }
98
104
  throw new Error(`invalid --scope term "${value}" ` +
99
105
  "(expected: \"user\", \"project\", \"task:<id>\", or \"session:<id>\")");
100
106
  };
101
- /* determine the Git top-level directory, if inside a Git repository */
107
+ /* determine the Git top-level directory, if inside a Git repository;
108
+ cached per working directory ("" ≡ not inside a Git working tree), as
109
+ each determination spawns a Git subprocess and the Git context can
110
+ change over the lifetime of the long-running ASE service */
111
+ const gitToplevelCache = new LRUCache({ max: 4, ttl: 2 * 1000 });
102
112
  const gitToplevel = () => {
113
+ const cwd = process.cwd();
114
+ const cached = gitToplevelCache.get(cwd);
115
+ if (cached !== undefined)
116
+ return cached === "" ? null : cached;
117
+ let top = "";
103
118
  try {
104
- const result = execaSync("git", ["rev-parse", "--show-toplevel"], { stderr: "ignore" });
105
- return result.stdout.trim() || null;
119
+ top = execaSync("git", ["rev-parse", "--show-toplevel"], { stderr: "ignore" }).stdout.trim();
106
120
  }
107
121
  catch {
108
- return null;
122
+ /* not inside a Git working tree */
109
123
  }
124
+ gitToplevelCache.set(cwd, top);
125
+ return top === "" ? null : top;
110
126
  };
111
127
  /* detect whether a project context exists, i.e. either we are inside
112
128
  a Git working tree or a ".ase" directory is present at or above cwd */
@@ -154,6 +170,11 @@ export const parseScope = (value) => {
154
170
  terms.unshift({ kind: "default" });
155
171
  return terms;
156
172
  };
173
+ /* schema for a single artifact kind's "basedir"/"files" specification */
174
+ const artifactSchema = v.optional(v.strictObject({
175
+ basedir: v.optional(v.string()),
176
+ files: v.optional(v.string())
177
+ }));
157
178
  /* schema for ".ase/config.yaml" */
158
179
  export const configSchema = v.nullish(v.strictObject({
159
180
  project: v.optional(v.strictObject({
@@ -161,16 +182,17 @@ export const configSchema = v.nullish(v.strictObject({
161
182
  name: v.optional(v.pipe(v.string(), v.minLength(1))),
162
183
  boxing: v.optional(v.picklist(projectClassification.boxing)),
163
184
  artifact: v.optional(v.strictObject({
164
- spec: v.optional(v.strictObject({ basedir: v.optional(v.string()), files: v.optional(v.string()) })),
165
- arch: v.optional(v.strictObject({ basedir: v.optional(v.string()), files: v.optional(v.string()) })),
166
- code: v.optional(v.strictObject({ basedir: v.optional(v.string()), files: v.optional(v.string()) })),
167
- docs: v.optional(v.strictObject({ basedir: v.optional(v.string()), files: v.optional(v.string()) })),
168
- infr: v.optional(v.strictObject({ basedir: v.optional(v.string()), files: v.optional(v.string()) })),
169
- task: v.optional(v.strictObject({ basedir: v.optional(v.string()), files: v.optional(v.string()) }))
185
+ spec: artifactSchema,
186
+ arch: artifactSchema,
187
+ code: artifactSchema,
188
+ docs: artifactSchema,
189
+ infr: artifactSchema,
190
+ task: artifactSchema
170
191
  }))
171
192
  })),
172
193
  agent: v.optional(v.strictObject({
173
194
  persona: v.optional(v.picklist(agentClassification.persona)),
195
+ guidance: v.optional(v.picklist(agentClassification.guidance)),
174
196
  task: v.optional(v.pipe(v.string(), v.minLength(1))),
175
197
  skill: v.optional(v.pipe(v.string(), v.minLength(1)))
176
198
  }))
@@ -749,5 +771,46 @@ export class ConfigMCP {
749
771
  return mcpToolError(err);
750
772
  }
751
773
  });
774
+ /* config list */
775
+ mcp.registerTool("ase_config_list", {
776
+ title: "ASE config list",
777
+ description: "List all effective configuration entries of the layered configuration, " +
778
+ "cascading through the default/user/project/task/session chain up to and " +
779
+ "including the requested `scope`. Returns an `entries` array (in lexicographic " +
780
+ "`key` order) where each item has the dotted `key`, its effective `value`, and " +
781
+ "the `scope` label (\"default\", \"user\", \"project\", \"task:<id>\", or " +
782
+ "\"session:<id>\") that supplied it. For overlapping keys only the value of the " +
783
+ "strongest scope is reported.",
784
+ inputSchema: {
785
+ scope: z.string()
786
+ .describe("scope chain (e.g. \"session:<id>\", \"task:<id>\", \"project\", \"user\")")
787
+ },
788
+ outputSchema: {
789
+ entries: z.array(z.object({
790
+ key: z.string().describe("dotted configuration key"),
791
+ value: z.string().describe("effective configuration value"),
792
+ scope: z.string().describe("scope label which supplied the value")
793
+ })).describe("all effective configuration entries in lexicographic key order")
794
+ }
795
+ }, async (args) => {
796
+ try {
797
+ const scope = parseScope(args.scope);
798
+ const cfg = new Config("config", configSchema, this.log, scope);
799
+ cfg.read();
800
+ const entries = cfg.entries().map((e) => ({
801
+ key: e.key,
802
+ value: String(isScalar(e.value) ? e.value.value : e.value),
803
+ scope: Config.scopeLabel(e.scope)
804
+ }));
805
+ const result = { entries };
806
+ return {
807
+ structuredContent: result,
808
+ content: [{ type: "text", text: JSON.stringify(result) }]
809
+ };
810
+ }
811
+ catch (err) {
812
+ return mcpToolError(err);
813
+ }
814
+ });
752
815
  }
753
816
  }
@@ -7,7 +7,7 @@ import fs from "node:fs";
7
7
  import { InvalidArgumentError } from "commander";
8
8
  import { renderMermaidASCII, renderMermaidSVG } from "beautiful-mermaid";
9
9
  import { z } from "zod";
10
- import getStdin from "get-stdin";
10
+ import { readStdin } from "./ase-stdio.js";
11
11
  /* custom argument parser for Commander: non-negative integer */
12
12
  const parseInteger = (name) => (value) => {
13
13
  const n = Number.parseInt(value, 10);
@@ -150,6 +150,8 @@ export class Diagram {
150
150
  clipping below are meaningful only for ASCII art) */
151
151
  if (opts.format === "svg")
152
152
  return renderMermaidSVG(src);
153
+ /* determine theme colors (gray shades once colors are enabled) */
154
+ const colored = opts.colorMode !== "none";
153
155
  /* create diagram rendering */
154
156
  let out = renderMermaidASCII(src, {
155
157
  useAscii: opts.ascii,
@@ -157,20 +159,13 @@ export class Diagram {
157
159
  paddingY: opts.nodeMarginY,
158
160
  boxBorderPadding: opts.nodePadding,
159
161
  colorMode: opts.colorMode,
160
- theme: opts.colorMode !== "none" ? {
162
+ theme: {
161
163
  fg: "#000000",
162
- border: "#a0a0a0",
163
- junction: "#a0a0a0",
164
- arrow: "#404040",
165
- line: "#707070",
166
- corner: "#707070"
167
- } : {
168
- fg: "#000000",
169
- border: "#000000",
170
- junction: "#000000",
171
- arrow: "#000000",
172
- line: "#000000",
173
- corner: "#000000"
164
+ border: colored ? "#a0a0a0" : "#000000",
165
+ junction: colored ? "#a0a0a0" : "#000000",
166
+ arrow: colored ? "#404040" : "#000000",
167
+ line: colored ? "#707070" : "#000000",
168
+ corner: colored ? "#707070" : "#000000"
174
169
  }
175
170
  });
176
171
  /* optionally clip diagram rendering */
@@ -248,7 +243,7 @@ export default class DiagramCommand {
248
243
  }
249
244
  }
250
245
  else
251
- src = await getStdin();
246
+ src = await readStdin();
252
247
  if (src.trim() === "") {
253
248
  this.log.write("error", "diagram: empty Mermaid diagram specification");
254
249
  process.exit(1);
package/dst/ase-getopt.js CHANGED
@@ -6,6 +6,70 @@
6
6
  import { z } from "zod";
7
7
  import { Command, Option } from "commander";
8
8
  import { parse as shParse, quote as shQuote } from "shell-quote";
9
+ /* tokenize a raw input string into [start,end) token ranges, preserving
10
+ the quoting so the original text can later be sliced verbatim */
11
+ const tokenizeRanges = (input) => {
12
+ const ranges = [];
13
+ let i = 0;
14
+ while (i < input.length) {
15
+ while (i < input.length && /\s/.test(input[i]))
16
+ i++;
17
+ if (i >= input.length)
18
+ break;
19
+ const start = i;
20
+ while (i < input.length && !/\s/.test(input[i])) {
21
+ const ch = input[i];
22
+ if (ch === "\"" || ch === "'") {
23
+ const quote = ch;
24
+ i++;
25
+ while (i < input.length && input[i] !== quote) {
26
+ if (input[i] === "\\" && i + 1 < input.length)
27
+ i++;
28
+ i++;
29
+ }
30
+ if (i < input.length)
31
+ i++;
32
+ }
33
+ else
34
+ i++;
35
+ }
36
+ ranges.push({ start, end: i });
37
+ }
38
+ return ranges;
39
+ };
40
+ /* strip surrounding quotes/escapes from a raw token range so it can be
41
+ compared against an option flag spelling */
42
+ const unquote = (s) => {
43
+ let out = "";
44
+ let j = 0;
45
+ while (j < s.length) {
46
+ const ch = s[j];
47
+ if (ch === "\"" || ch === "'") {
48
+ const quote = ch;
49
+ j++;
50
+ while (j < s.length && s[j] !== quote) {
51
+ if (s[j] === "\\" && j + 1 < s.length) {
52
+ out += s[j + 1];
53
+ j += 2;
54
+ }
55
+ else {
56
+ out += s[j];
57
+ j++;
58
+ }
59
+ }
60
+ j++;
61
+ }
62
+ else if (ch === "\\" && j + 1 < s.length) {
63
+ out += s[j + 1];
64
+ j += 2;
65
+ }
66
+ else {
67
+ out += ch;
68
+ j++;
69
+ }
70
+ }
71
+ return out;
72
+ };
9
73
  /* MCP registration entry point for the option-parser tool */
10
74
  export class GetoptMCP {
11
75
  register(mcp) {
@@ -117,65 +181,7 @@ export class GetoptMCP {
117
181
  let argsVerbatim = "";
118
182
  if (argsRaw !== null) {
119
183
  /* tokenize raw input into [start,end) ranges, preserving quotes */
120
- const ranges = [];
121
- let i = 0;
122
- while (i < argsRaw.length) {
123
- while (i < argsRaw.length && /\s/.test(argsRaw[i]))
124
- i++;
125
- if (i >= argsRaw.length)
126
- break;
127
- const start = i;
128
- while (i < argsRaw.length && !/\s/.test(argsRaw[i])) {
129
- const ch = argsRaw[i];
130
- if (ch === "\"" || ch === "'") {
131
- const quote = ch;
132
- i++;
133
- while (i < argsRaw.length && argsRaw[i] !== quote) {
134
- if (argsRaw[i] === "\\" && i + 1 < argsRaw.length)
135
- i++;
136
- i++;
137
- }
138
- if (i < argsRaw.length)
139
- i++;
140
- }
141
- else
142
- i++;
143
- }
144
- ranges.push({ start, end: i });
145
- }
146
- /* helper function: strip surrounding quotes/escapes from a raw
147
- range so it can be compared against an option flag spelling */
148
- const unquote = (s) => {
149
- let out = "";
150
- let j = 0;
151
- while (j < s.length) {
152
- const ch = s[j];
153
- if (ch === "\"" || ch === "'") {
154
- const quote = ch;
155
- j++;
156
- while (j < s.length && s[j] !== quote) {
157
- if (s[j] === "\\" && j + 1 < s.length) {
158
- out += s[j + 1];
159
- j += 2;
160
- }
161
- else {
162
- out += s[j];
163
- j++;
164
- }
165
- }
166
- j++;
167
- }
168
- else if (ch === "\\" && j + 1 < s.length) {
169
- out += s[j + 1];
170
- j += 2;
171
- }
172
- else {
173
- out += ch;
174
- j++;
175
- }
176
- }
177
- return out;
178
- };
184
+ const ranges = tokenizeRanges(argsRaw);
179
185
  /* walk the raw ranges, consuming leading option tokens (and any
180
186
  separate value tokens they take) until the first positional
181
187
  is reached, then slice the original input from there -- this
@@ -202,7 +208,7 @@ export class GetoptMCP {
202
208
  argsVerbatim = argsRaw.slice(ranges[idx].start);
203
209
  }
204
210
  else
205
- argsVerbatim = cmd.args.join(" ");
211
+ argsVerbatim = shQuote(cmd.args);
206
212
  /* build markdown info rendering of parsed options */
207
213
  const opts = cmd.opts();
208
214
  const info = Object.entries(opts)
@@ -0,0 +1,89 @@
1
+ /*
2
+ ** Agentic Software Engineering (ASE)
3
+ ** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
+ */
6
+ import { isScalar } from "yaml";
7
+ import { z } from "zod";
8
+ import { Config, configSchema, parseScope, agentClassification } from "./ase-config.js";
9
+ /* reusable functionality: ASE agent guidance level get/set */
10
+ export class Guidance {
11
+ /* allowed guidance level values */
12
+ static levels = agentClassification.guidance;
13
+ /* get the effective guidance level for an optional session;
14
+ returns the default "normal" if nothing is configured */
15
+ static get(log, session) {
16
+ const scope = parseScope(session !== undefined ? `session:${session}` : undefined);
17
+ const cfg = new Config("config", configSchema, log, scope);
18
+ cfg.read();
19
+ const val = cfg.get("agent.guidance");
20
+ if (val === undefined)
21
+ return "normal";
22
+ const level = String(isScalar(val) ? val.value : val);
23
+ return Guidance.levels.find((l) => l === level) ?? "normal";
24
+ }
25
+ /* set the guidance level on the strongest scope of an optional session */
26
+ static set(log, level, session) {
27
+ const scope = parseScope(session !== undefined ? `session:${session}` : undefined);
28
+ const cfg = new Config("config", configSchema, log, scope);
29
+ cfg.lock(() => {
30
+ cfg.read();
31
+ cfg.set("agent.guidance", level);
32
+ cfg.write();
33
+ });
34
+ }
35
+ }
36
+ /* MCP registration entry point for guidance tools */
37
+ export default class GuidanceMCP {
38
+ log;
39
+ constructor(log) {
40
+ this.log = log;
41
+ }
42
+ register(mcp) {
43
+ mcp.registerTool("ase_guidance", {
44
+ title: "ASE guidance level get/set",
45
+ description: "Get or set the active ASE agent guidance `level`. " +
46
+ "If `level` is provided, it sets the guidance level, " +
47
+ "otherwise it returns the current guidance `level`. " +
48
+ "If `session` is provided, the operation is scoped to that session, " +
49
+ "otherwise it operates on the strongest/closest scope (user/project cascade). " +
50
+ "The guidance level controls the amount of unsolicited help hints -- " +
51
+ "pointers to the available ASE skills, their options, and the recommended " +
52
+ "next operations -- the agent emits. " +
53
+ "Allowed levels: \"none\" (no help hints at all), " +
54
+ "\"minimal\" (the single most essential help hint only), " +
55
+ "\"normal\" (the essential help hints only), " +
56
+ "\"verbose\" (all available help hints).",
57
+ inputSchema: {
58
+ level: z.enum(Guidance.levels).optional()
59
+ .describe("guidance level to set; if omitted, the current guidance level is returned"),
60
+ session: z.string().regex(/^[A-Za-z0-9._-]+$/).optional()
61
+ .describe("session identifier (allowed characters: A-Z, a-z, 0-9, '.', '_', '-'); " +
62
+ "if omitted, the operation is not scoped to a specific session")
63
+ }
64
+ }, async (args) => {
65
+ try {
66
+ if (args.level !== undefined) {
67
+ Guidance.set(this.log, args.level, args.session);
68
+ const where = args.session !== undefined ?
69
+ ` for session "${args.session}"` : "";
70
+ const msg = `OK: set agent.guidance to "${args.level}"${where}`;
71
+ return {
72
+ content: [{ type: "text", text: msg }]
73
+ };
74
+ }
75
+ const text = Guidance.get(this.log, args.session);
76
+ return {
77
+ content: [{ type: "text", text }]
78
+ };
79
+ }
80
+ catch (err) {
81
+ const message = err instanceof Error ? err.message : String(err);
82
+ return {
83
+ isError: true,
84
+ content: [{ type: "text", text: `ERROR: ${message}` }]
85
+ };
86
+ }
87
+ });
88
+ }
89
+ }
package/dst/ase-hook.js CHANGED
@@ -8,11 +8,10 @@ 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 getStdin from "get-stdin";
12
11
  import * as v from "valibot";
13
12
  import Version from "./ase-version.js";
14
13
  import { Config, configSchema, parseScope } from "./ase-config.js";
15
- import { writeStdout } from "./ase-stdout.js";
14
+ import { readStdin, writeStdout } from "./ase-stdio.js";
16
15
  const addonMcpServers = [
17
16
  "chat-alibaba-qwen",
18
17
  "chat-deepseek",
@@ -86,14 +85,9 @@ export default class HookCommand {
86
85
  isValidSessionId(id) {
87
86
  return /^[A-Za-z0-9._-]+$/.test(id);
88
87
  }
89
- /* read the entire stdin payload asynchronously */
90
- readStdin() {
91
- /* best-effort: treat an unreadable/closed stdin as empty */
92
- return getStdin().catch(() => "");
93
- }
94
88
  /* drain and discard the stdin event payload */
95
89
  async drainStdin() {
96
- await this.readStdin();
90
+ await readStdin().catch(() => "");
97
91
  }
98
92
  /* best-effort JSON parse with valibot schema validation: returns
99
93
  an empty object on blank input, malformed JSON, or schema
@@ -211,7 +205,7 @@ export default class HookCommand {
211
205
  const versionHint = versionHints.length > 0 ? "(" + versionHints.join(", ") + ")" : "";
212
206
  /* read session information (Anthropic Claude Code CLI uses snake_case fields,
213
207
  GitHub Copilot CLI uses camelCase fields) */
214
- const stdin = await this.readStdin();
208
+ const stdin = await readStdin().catch(() => "");
215
209
  const input = this.parseJSON(stdin, v.object({
216
210
  session_id: v.optional(v.string()),
217
211
  sessionId: v.optional(v.string()),
@@ -255,8 +249,9 @@ export default class HookCommand {
255
249
  const val = cfg.get(key);
256
250
  return typeof val === "string" ? val : (process.env[envVar] ?? dflt);
257
251
  };
258
- /* determine agent persona style and project boxing transparency */
252
+ /* determine agent persona style, agent guidance level, and project boxing transparency */
259
253
  const persona = setting("agent.persona", "ASE_PERSONA_STYLE", "engineer");
254
+ const guidance = setting("agent.guidance", "ASE_GUIDANCE_LEVEL", "normal");
260
255
  const boxing = setting("project.boxing", "ASE_PROJECT_BOXING", "white");
261
256
  /* determine headless mode */
262
257
  const headless = (process.env.ASE_HEADLESS ?? "false") === "true" ? "true" : "false";
@@ -273,7 +268,13 @@ export default class HookCommand {
273
268
  `export ASE_SESSION_ID=${quote([sessionId])}\n` +
274
269
  `export ASE_HEADLESS=${quote([headless])}\n` +
275
270
  `export ASE_AGENT_TOOL=${quote([tool])}\n`;
276
- fs.appendFileSync(envFile, script, "utf8");
271
+ try {
272
+ fs.appendFileSync(envFile, script, "utf8");
273
+ }
274
+ catch (err) {
275
+ const message = err instanceof Error ? err.message : String(err);
276
+ this.log.write("warning", `hook: failed to write environment file: ${message}`);
277
+ }
277
278
  }
278
279
  /* prepend ASE information to constitution markdown */
279
280
  md =
@@ -281,6 +282,7 @@ export default class HookCommand {
281
282
  `<ase-version-hint>${versionHint}</ase-version-hint>\n` +
282
283
  `<ase-plugin-root>${pluginRoot}</ase-plugin-root>\n` +
283
284
  `<ase-persona-style>${persona}</ase-persona-style>\n` +
285
+ `<ase-guidance-level>${guidance}</ase-guidance-level>\n` +
284
286
  `<ase-user-id>${userId}</ase-user-id>\n` +
285
287
  `<ase-project-id>${projectId}</ase-project-id>\n` +
286
288
  `<ase-project-boxing>${boxing}</ase-project-boxing>\n` +
@@ -295,11 +297,18 @@ export default class HookCommand {
295
297
  agent harness, independent of any model decision, so it is
296
298
  guaranteed to appear once in every non-headless session;
297
299
  Anthropic Claude Code CLI and OpenAI Codex CLI surface a top-level
298
- "systemMessage" field for this -- GitHub Copilot CLI has no equivalent) */
299
- const banner = `\n⧉ ASE: version: ${versionCurrentPlugin}${versionHint !== "" ? " " + versionHint.replaceAll(/\*/g, "") : ""}` +
300
+ "systemMessage" field for this -- GitHub Copilot CLI has no equivalent);
301
+ the trailing help hint is emitted only if the guidance level asks for it */
302
+ const banner = "\n" +
303
+ `\n⧉ ASE: ⎈ version: ${versionCurrentPlugin}${versionHint !== "" ? " " + versionHint.replaceAll(/\*/g, "") : ""}` +
300
304
  `\n⧉ ASE: ※ user: ${userId}, ⚑ project: ${projectId}` +
301
305
  `\n⧉ ASE: ◉ task: ${taskId}, ⏻ session: ${sessionId}` +
302
- `\n⧉ ASE: ☯ persona: ${persona}, ▢ boxing: ${boxing}`;
306
+ `\n⧉ ASE: ☯ persona: ${persona}, ▶ guidance: ${guidance}, ▢ boxing: ${boxing}` +
307
+ (guidance === "normal" || guidance === "verbose" ?
308
+ "\n" +
309
+ "\n⧉ ASE: ▷ hint: use \"/ase-help-intent <intent-description>\" for skill command proposal" +
310
+ "\n⧉ ASE: ▷ hint: use \"/ase-help-skill [<skill-name>]\" for skill catalog or skill manpage" : "") +
311
+ "\n";
303
312
  /* inject markdown into session context.
304
313
  Anthropic Claude Code CLI and OpenAI Codex CLI expect the context nested in
305
314
  "hookSpecificOutput"; GitHub Copilot CLI expects a flat top-level
@@ -314,8 +323,10 @@ export default class HookCommand {
314
323
  };
315
324
  /* attach the deterministic banner as a top-level "systemMessage"
316
325
  (only for the harnesses that support it and only when not
317
- running headless -- mirroring the constitution box condition) */
318
- if ((tool === "claude" || tool === "codex") && headless !== "true")
326
+ running headless -- complementing the constitution box condition,
327
+ which covers exactly the remaining harness GitHub Copilot CLI
328
+ by letting the model emit the banner itself) */
329
+ if ((tool === "claude" || tool === "codex") && headless !== "true" && guidance !== "none")
319
330
  payload.systemMessage = banner;
320
331
  await writeStdout(JSON.stringify(payload));
321
332
  return 0;
@@ -371,7 +382,7 @@ export default class HookCommand {
371
382
  }
372
383
  /* read session id from stdin JSON payload */
373
384
  async readSessionIdFromStdin() {
374
- const stdin = await this.readStdin();
385
+ const stdin = await readStdin().catch(() => "");
375
386
  const input = this.parseJSON(stdin, v.object({
376
387
  session_id: v.optional(v.string()),
377
388
  sessionId: v.optional(v.string())
@@ -458,7 +469,7 @@ export default class HookCommand {
458
469
  loosely-typed input object shared by the tool-approval handlers */
459
470
  async readHookInput(tool) {
460
471
  const spec = toolSpecs[tool];
461
- const stdin = await this.readStdin();
472
+ const stdin = await readStdin().catch(() => "");
462
473
  const input = this.parseJSON(stdin, v.looseObject({
463
474
  session_id: v.optional(v.string()),
464
475
  sessionId: v.optional(v.string())
@@ -543,53 +554,22 @@ export default class HookCommand {
543
554
  hookCmd.outputHelp();
544
555
  process.exit(1);
545
556
  });
546
- /* register CLI sub-command "ase hook session-start" */
547
- hookCmd
548
- .command("session-start")
549
- .description("handle SessionStart hook event")
550
- .option("-t, --tool <tool>", "target tool (\"claude\", \"copilot\", or \"codex\")", toolDflt)
551
- .action(async (opts) => {
552
- process.exitCode = await this.doSessionStart(this.parseTool(opts.tool));
553
- });
554
- /* register CLI sub-command "ase hook session-end" */
555
- hookCmd
556
- .command("session-end")
557
- .description("handle SessionEnd hook event")
558
- .option("-t, --tool <tool>", "target tool (\"claude\", \"copilot\", or \"codex\")", toolDflt)
559
- .action(async (opts) => {
560
- process.exitCode = await this.doSessionEnd(this.parseTool(opts.tool));
561
- });
562
- /* register CLI sub-command "ase hook pre-tool-use" */
563
- hookCmd
564
- .command("pre-tool-use")
565
- .description("handle tool PreToolUse hook event")
566
- .option("-t, --tool <tool>", "target tool (\"claude\", \"copilot\", or \"codex\")", toolDflt)
567
- .action(async (opts) => {
568
- process.exitCode = await this.doPreToolUse(this.parseTool(opts.tool));
569
- });
570
- /* register CLI sub-command "ase hook permission-request" */
571
- hookCmd
572
- .command("permission-request")
573
- .description("handle tool PermissionRequest hook event (Codex CLI)")
574
- .option("-t, --tool <tool>", "target tool (\"claude\", \"copilot\", or \"codex\")", toolDflt)
575
- .action(async (opts) => {
576
- process.exitCode = await this.doPermissionRequest(this.parseTool(opts.tool));
577
- });
578
- /* register CLI sub-command "ase hook user-prompt-submit" */
579
- hookCmd
580
- .command("user-prompt-submit")
581
- .description("handle UserPromptSubmit hook event (mark agent as busy)")
582
- .option("-t, --tool <tool>", "target tool (\"claude\", \"copilot\", or \"codex\")", toolDflt)
583
- .action(async (opts) => {
584
- process.exitCode = await this.doUserPromptSubmit(this.parseTool(opts.tool));
585
- });
586
- /* register CLI sub-command "ase hook stop" */
587
- hookCmd
588
- .command("stop")
589
- .description("handle Stop hook event (mark agent as ready)")
590
- .option("-t, --tool <tool>", "target tool (\"claude\", \"copilot\", or \"codex\")", toolDflt)
591
- .action(async (opts) => {
592
- process.exitCode = await this.doStop(this.parseTool(opts.tool));
593
- });
557
+ /* register CLI sub-commands "ase hook <event>" */
558
+ const subCmds = [
559
+ { name: "session-start", desc: "handle SessionStart hook event", handler: (tool) => this.doSessionStart(tool) },
560
+ { name: "session-end", desc: "handle SessionEnd hook event", handler: (tool) => this.doSessionEnd(tool) },
561
+ { name: "pre-tool-use", desc: "handle tool PreToolUse hook event", handler: (tool) => this.doPreToolUse(tool) },
562
+ { name: "permission-request", desc: "handle tool PermissionRequest hook event", handler: (tool) => this.doPermissionRequest(tool) },
563
+ { name: "user-prompt-submit", desc: "handle UserPromptSubmit hook event", handler: (tool) => this.doUserPromptSubmit(tool) },
564
+ { name: "stop", desc: "handle Stop hook event", handler: (tool) => this.doStop(tool) }
565
+ ];
566
+ for (const { name, desc, handler } of subCmds)
567
+ hookCmd
568
+ .command(name)
569
+ .description(desc)
570
+ .option("-t, --tool <tool>", "target tool (\"claude\", \"copilot\", or \"codex\")", toolDflt)
571
+ .action(async (opts) => {
572
+ process.exitCode = await handler(this.parseTool(opts.tool));
573
+ });
594
574
  }
595
575
  }