@rse/ase 1.0.4 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,6 +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 { Ignore } from "./ase-ignore.js";
13
14
  import { writeStdout } from "./ase-stdio.js";
14
15
  /* the recognized artifact kinds, in descending precedence order;
15
16
  "othr" is the implicit catch-all and is always resolved last */
@@ -26,72 +27,22 @@ export class Artifact {
26
27
  `(expected one of: ${artifactKinds.join(", ")})`);
27
28
  return kind;
28
29
  }
29
- /* translate a single ".gitignore" line into a picomatch-backed rule,
30
- honoring the anchored-vs-floating, directory-only, and negation
31
- semantics of ".gitignore" patterns; anchored patterns resolve
32
- relative to the "base" directory holding the ".gitignore" */
33
- static compileIgnoreRule(line, base) {
34
- let pattern = line.trim();
35
- if (pattern === "" || pattern.startsWith("#"))
36
- return null;
37
- let negated = false;
38
- if (pattern.startsWith("!")) {
39
- negated = true;
40
- pattern = pattern.slice(1);
41
- }
42
- let dirOnly = false;
43
- if (pattern.endsWith("/")) {
44
- dirOnly = true;
45
- pattern = pattern.slice(0, -1);
46
- }
47
- const anchored = pattern.includes("/");
48
- if (pattern.startsWith("/"))
49
- pattern = pattern.slice(1);
50
- const glob = anchored ? (base === "" ? pattern : `${base}/${pattern}`) : `**/${pattern}`;
51
- const isMatch = picomatch(glob, { dot: true });
52
- return { matcher: (p) => isMatch(p), negated, dirOnly };
53
- }
54
- /* load the ".gitignore" rules located directly in a directory,
55
- given as absolute "dir" and project-relative "relDir" */
56
- static loadIgnoreRules(dir, relDir) {
57
- const file = path.join(dir, ".gitignore");
58
- if (!fs.existsSync(file))
59
- return [];
60
- const rules = [];
61
- for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
62
- const rule = Artifact.compileIgnoreRule(line, relDir);
63
- if (rule !== null)
64
- rules.push(rule);
65
- }
66
- return rules;
67
- }
68
- /* decide whether a project-relative path is ignored by the given
69
- ordered ".gitignore" rule set (last matching rule wins) */
70
- static isIgnored(rel, isDir, rules) {
71
- let ignored = false;
72
- for (const rule of rules) {
73
- if (rule.dirOnly && !isDir)
74
- continue;
75
- if (rule.matcher(rel))
76
- ignored = !rule.negated;
77
- }
78
- return ignored;
79
- }
80
30
  /* build the file universe by walking the project tree from the
81
- project root, honoring ".gitignore" rules (root plus nested) and
82
- always pruning ".git". Yields POSIX project-relative, sorted,
31
+ project root, honoring the Git exclude rules (the repository-wide
32
+ ones plus the root and nested ".gitignore" files) and always
33
+ pruning ".git". Yields POSIX project-relative, sorted,
83
34
  de-duplicated file paths */
84
35
  static universe() {
85
36
  const root = Task.projectRoot();
86
37
  const files = new Set();
87
38
  const walk = (dir, relDir, inherited) => {
88
- const rules = [...inherited, ...Artifact.loadIgnoreRules(dir, relDir)];
39
+ const rules = [...inherited, ...Ignore.rules(dir, relDir)];
89
40
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
90
41
  if (entry.name === ".git")
91
42
  continue;
92
43
  const rel = relDir === "" ? entry.name : `${relDir}/${entry.name}`;
93
44
  const isDir = entry.isDirectory();
94
- if (Artifact.isIgnored(rel, isDir, rules))
45
+ if (Ignore.matches(rel, isDir, rules))
95
46
  continue;
96
47
  if (isDir)
97
48
  walk(path.join(dir, entry.name), rel, rules);
@@ -99,7 +50,7 @@ export class Artifact {
99
50
  files.add(rel);
100
51
  }
101
52
  };
102
- walk(root, "", []);
53
+ walk(root, "", Ignore.base(root));
103
54
  return [...files].sort((a, b) => a.localeCompare(b));
104
55
  }
105
56
  /* raw-resolve a single kind's configuration spec against the file
package/dst/ase-config.js CHANGED
@@ -10,12 +10,12 @@ import readline from "node:readline/promises";
10
10
  import { Document, parseDocument, isMap, isScalar } from "yaml";
11
11
  import { execaSync } from "execa";
12
12
  import * as v from "valibot";
13
- import Table from "cli-table3";
14
13
  import writeFileAtomic from "write-file-atomic";
15
14
  import lockfile from "proper-lockfile";
16
15
  import { z } from "zod";
17
16
  import { LRUCache } from "lru-cache";
18
17
  import { writeStdout } from "./ase-stdio.js";
18
+ import { renderTable } from "./ase-table.js";
19
19
  /* classification taxonomy */
20
20
  export const projectClassification = {
21
21
  boxing: ["white", "grey", "black"]
@@ -184,7 +184,8 @@ const artifactSchema = v.optional(v.strictObject({
184
184
  files: v.optional(v.string())
185
185
  }));
186
186
  /* schema for the "spec" artifact kind, additionally carrying the
187
- SpecBook YAML "schema" configuration file (empty: bundled standard) */
187
+ whitespace-separated list of SpecBook YAML "schema" configuration
188
+ files, with "std" naming the bundled standard one (empty: "std") */
188
189
  const artifactSpecSchema = v.optional(v.strictObject({
189
190
  basedir: v.optional(v.string()),
190
191
  files: v.optional(v.string()),
@@ -632,16 +633,12 @@ export default class ConfigCommand {
632
633
  const scope = parseScope(cmd.optsWithGlobals().scope);
633
634
  const cfg = new Config("config", configSchema, this.log, scope);
634
635
  cfg.read();
635
- const table = new Table({
636
- head: ["KEY", "VALUE", "SCOPE"],
637
- chars: { "mid": "", "left-mid": "", "mid-mid": "", "right-mid": "" },
638
- style: { head: ["blue"] }
639
- });
636
+ const rows = [];
640
637
  for (const e of cfg.entries()) {
641
638
  const val = isScalar(e.value) ? e.value.value : e.value;
642
- table.push([e.key, String(val), Config.scopeLabel(e.scope)]);
639
+ rows.push([e.key, String(val), Config.scopeLabel(e.scope)]);
643
640
  }
644
- await writeStdout(`${table.toString()}\n`);
641
+ await writeStdout(renderTable(["KEY", "VALUE", "SCOPE"], rows));
645
642
  });
646
643
  /* register CLI sub-command "ase config edit" */
647
644
  configCmd
package/dst/ase-hook.js CHANGED
@@ -208,9 +208,11 @@ export default class HookCommand {
208
208
  /* determine path to external files */
209
209
  const filePkg = path.join(pluginRoot, ".claude-plugin", "plugin.json");
210
210
  const fileMd = path.join(pluginRoot, "meta", "ase-constitution.md");
211
+ const fileStyle = path.join(pluginRoot, "output-styles", "ase-terse.md");
211
212
  /* read external files */
212
213
  let pkg;
213
214
  let md;
215
+ let style;
214
216
  try {
215
217
  pkg = fs.readFileSync(filePkg, "utf8");
216
218
  }
@@ -223,6 +225,12 @@ export default class HookCommand {
223
225
  catch (err) {
224
226
  throw new Error(`failed to read constitution file: ${fileMd}`, { cause: err });
225
227
  }
228
+ try {
229
+ style = fs.readFileSync(fileStyle, "utf8");
230
+ }
231
+ catch (err) {
232
+ throw new Error(`failed to read output style file: ${fileStyle}`, { cause: err });
233
+ }
226
234
  /* determine own version */
227
235
  const pkgObj = this.parseJSON(pkg, v.object({ version: v.optional(v.string()) }));
228
236
  const versionCurrentPlugin = pkgObj.version ?? "";
@@ -292,8 +300,8 @@ export default class HookCommand {
292
300
  const persona = setting("agent.persona", "ASE_PERSONA_STYLE", "engineer");
293
301
  const guidance = setting("agent.guidance", "ASE_GUIDANCE_LEVEL", "normal");
294
302
  const boxing = setting("project.boxing", "ASE_PROJECT_BOXING", "white");
295
- /* determine the specification base directory and the SpecBook
296
- schema configuration (empty: the bundled standard schema) */
303
+ /* determine the specification base directory and the whitespace-separated
304
+ list of SpecBook schema configurations (empty: the bundled standard schema) */
297
305
  const specBasedir = String(cfg.get("project.artifact.spec.basedir") ?? "");
298
306
  const specSchema = String(cfg.get("project.artifact.spec.schema") ?? "");
299
307
  /* determine headless mode */
@@ -340,6 +348,12 @@ export default class HookCommand {
340
348
  "\n" + md;
341
349
  /* expand all @<file> references manually */
342
350
  md = this.expandReferences(md, path.dirname(fileMd));
351
+ /* append the output style to the constitution markdown (GitHub
352
+ Copilot CLI and OpenAI Codex CLI only -- Anthropic Claude Code CLI
353
+ applies the plugin output style natively), stripping the
354
+ YAML frontmatter which only Anthropic Claude Code CLI understands */
355
+ if (tool !== "claude")
356
+ md += "\n" + style.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "");
343
357
  /* build the deterministic ASE banner (rendered directly by the
344
358
  agent harness, independent of any model decision, so it is
345
359
  guaranteed to appear once in every non-headless session;
@@ -0,0 +1,129 @@
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 path from "node:path";
7
+ import os from "node:os";
8
+ import fs from "node:fs";
9
+ import { execaSync } from "execa";
10
+ import picomatch from "picomatch";
11
+ import { LRUCache } from "lru-cache";
12
+ /* reusable functionality: decide which project-relative paths Git
13
+ excludes from the project, honoring the same three rule sources and
14
+ the same precedence order as Git itself -- the global excludes file,
15
+ the repository-local "info/exclude", and the per-directory
16
+ ".gitignore" files from the project root down to the path */
17
+ export class Ignore {
18
+ /* translate a single Git exclude line into a picomatch-backed rule,
19
+ honoring the anchored-vs-floating, directory-only, and negation
20
+ semantics of the pattern; an anchored pattern resolves relative
21
+ to the "base" directory its rule file governs */
22
+ static compile(line, base) {
23
+ let pattern = line.trim();
24
+ if (pattern === "" || pattern.startsWith("#"))
25
+ return null;
26
+ let negated = false;
27
+ if (pattern.startsWith("!")) {
28
+ negated = true;
29
+ pattern = pattern.slice(1);
30
+ }
31
+ let dirOnly = false;
32
+ if (pattern.endsWith("/")) {
33
+ dirOnly = true;
34
+ pattern = pattern.slice(0, -1);
35
+ }
36
+ const anchored = pattern.includes("/");
37
+ if (pattern.startsWith("/"))
38
+ pattern = pattern.slice(1);
39
+ const glob = anchored ? (base === "" ? pattern : `${base}/${pattern}`) : `**/${pattern}`;
40
+ const isMatch = picomatch(glob, { dot: true });
41
+ return { matcher: (p) => isMatch(p), negated, dirOnly };
42
+ }
43
+ /* load the rules of a single exclude file, given as absolute "file"
44
+ and the project-relative "base" its anchored patterns resolve
45
+ against; a missing file simply contributes no rules */
46
+ static load(file, base) {
47
+ if (file === "" || !fs.existsSync(file))
48
+ return [];
49
+ const rules = [];
50
+ for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
51
+ const rule = Ignore.compile(line, base);
52
+ if (rule !== null)
53
+ rules.push(rule);
54
+ }
55
+ return rules;
56
+ }
57
+ /* load the ".gitignore" rules located directly in a directory,
58
+ given as absolute "dir" and project-relative "relDir" */
59
+ static rules(dir, relDir) {
60
+ return Ignore.load(path.join(dir, ".gitignore"), relDir);
61
+ }
62
+ /* resolve the global Git excludes file: the configured
63
+ "core.excludesFile", or else the XDG location Git falls back
64
+ onto when that configuration value is unset */
65
+ static globalFile() {
66
+ let file = "";
67
+ try {
68
+ file = execaSync("git", ["config", "--get", "core.excludesFile"], { stderr: "ignore" }).stdout.trim();
69
+ }
70
+ catch {
71
+ /* no such configuration value */
72
+ }
73
+ if (file === "") {
74
+ const xdg = process.env.XDG_CONFIG_HOME;
75
+ return xdg !== undefined && xdg !== "" ?
76
+ path.join(xdg, "git", "ignore") :
77
+ path.join(os.homedir(), ".config", "git", "ignore");
78
+ }
79
+ if (file.startsWith("~/"))
80
+ file = path.join(os.homedir(), file.slice(2));
81
+ return file;
82
+ }
83
+ /* resolve the repository-local exclude file, held by the Git
84
+ *common* directory, so that a Git worktree shares the exclude
85
+ file of the main working tree it was created from */
86
+ static infoFile(root) {
87
+ try {
88
+ const dir = execaSync("git", ["rev-parse", "--git-common-dir"], { cwd: root, stderr: "ignore" }).stdout.trim();
89
+ if (dir !== "")
90
+ return path.join(path.resolve(root, dir), "info", "exclude");
91
+ }
92
+ catch {
93
+ /* not inside a Git working tree */
94
+ }
95
+ return "";
96
+ }
97
+ /* cached repository-wide rules (TTL-bounded, as the ASE service is
98
+ long-running and both the Git configuration and the exclude files
99
+ can change underneath it; each determination spawns Git, too) */
100
+ static baseCache = new LRUCache({ max: 4, ttl: 10 * 1000 });
101
+ /* the rules which no directory of the project owns and which hence
102
+ govern the project as a whole, in ascending Git precedence: the
103
+ global excludes file first, the repository-local "info/exclude"
104
+ second, so a later rule overrides an earlier one as it does in
105
+ Git; both anchor their patterns at the project root */
106
+ static base(root) {
107
+ const cached = Ignore.baseCache.get(root);
108
+ if (cached !== undefined)
109
+ return cached;
110
+ const rules = [
111
+ ...Ignore.load(Ignore.globalFile(), ""),
112
+ ...Ignore.load(Ignore.infoFile(root), "")
113
+ ];
114
+ Ignore.baseCache.set(root, rules);
115
+ return rules;
116
+ }
117
+ /* decide whether a project-relative path is excluded by the given
118
+ ordered rule set, where the last matching rule wins */
119
+ static matches(rel, isDir, rules) {
120
+ let ignored = false;
121
+ for (const rule of rules) {
122
+ if (rule.dirOnly && !isDir)
123
+ continue;
124
+ if (rule.matcher(rel))
125
+ ignored = !rule.negated;
126
+ }
127
+ return ignored;
128
+ }
129
+ }