@rse/ase 0.0.8 → 0.0.10

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 CHANGED
@@ -4,6 +4,7 @@
4
4
  ** Licensed under GPL 3.0 <https://spdx.org/licenses/GPL-3.0-only>
5
5
  */
6
6
  import path from "node:path";
7
+ import os from "node:os";
7
8
  import fs from "node:fs";
8
9
  import readline from "node:readline/promises";
9
10
  import { Document, parseDocument, isMap, isScalar } from "yaml";
@@ -20,13 +21,22 @@ export const projectClassification = {
20
21
  },
21
22
  process: {
22
23
  actors: ["person", "team", "crew"],
23
- control: ["human", "hitl", "agent"],
24
24
  drive: ["spec", "code", "test"]
25
25
  },
26
26
  result: {
27
27
  target: ["prototype", "mvp", "product"]
28
28
  }
29
29
  };
30
+ /* agent classification taxonomy */
31
+ export const agentClassification = {
32
+ persona: {
33
+ style: ["writer", "engineer", "telegrapher", "caveman"],
34
+ creativity: ["none", "lite", "full"]
35
+ },
36
+ process: {
37
+ autonomy: ["assistant", "hotl", "agent"]
38
+ }
39
+ };
30
40
  /* classification presets */
31
41
  export const projectClassificationPresets = {
32
42
  vibe: {
@@ -37,9 +47,11 @@ export const projectClassificationPresets = {
37
47
  "project.source.size": "small",
38
48
  "project.source.structure": "bare",
39
49
  "project.process.actors": "person",
40
- "project.process.control": "agent",
41
50
  "project.process.drive": "spec",
42
- "project.result.target": "prototype"
51
+ "project.result.target": "prototype",
52
+ "agent.persona.style": "writer",
53
+ "agent.persona.creativity": "full",
54
+ "agent.process.autonomy": "agent"
43
55
  },
44
56
  pro: {
45
57
  "project.id": "example",
@@ -49,9 +61,31 @@ export const projectClassificationPresets = {
49
61
  "project.source.size": "medium",
50
62
  "project.source.structure": "framework",
51
63
  "project.process.actors": "person",
52
- "project.process.control": "human",
53
64
  "project.process.drive": "code",
54
- "project.result.target": "product"
65
+ "project.result.target": "product",
66
+ "agent.persona.style": "engineer",
67
+ "agent.persona.creativity": "none",
68
+ "agent.process.autonomy": "assistant"
69
+ },
70
+ default: {
71
+ "project.id": "example",
72
+ "project.name": "Example Project",
73
+ "project.source.ambition": "artist",
74
+ "project.source.boxing": "white",
75
+ "project.source.size": "medium",
76
+ "project.source.structure": "framework",
77
+ "project.process.actors": "person",
78
+ "project.process.drive": "code",
79
+ "project.result.target": "product",
80
+ "project.artifact.build": "{etc/**,README.md,AGENTS.md,LICENSE.txt,package.json}",
81
+ "project.artifact.code": "src/**/*",
82
+ "project.artifact.docs": "doc/user/**/*.md",
83
+ "project.artifact.spec": "doc/spec/**/*.md",
84
+ "project.artifact.arch": "doc/arch/**/*.md",
85
+ "agent.persona.style": "engineer",
86
+ "agent.persona.creativity": "none",
87
+ "agent.process.autonomy": "assistant",
88
+ "task.id": "default"
55
89
  },
56
90
  industry: {
57
91
  "project.id": "example",
@@ -61,11 +95,87 @@ export const projectClassificationPresets = {
61
95
  "project.source.size": "large",
62
96
  "project.source.structure": "framework",
63
97
  "project.process.actors": "crew",
64
- "project.process.control": "hitl",
65
98
  "project.process.drive": "code",
66
- "project.result.target": "mvp"
99
+ "project.result.target": "mvp",
100
+ "agent.persona.style": "engineer",
101
+ "agent.persona.creativity": "none",
102
+ "agent.process.autonomy": "hotl"
67
103
  }
68
104
  };
105
+ /* hard-coded map: which scope kinds each variable may be SET on
106
+ (reads always cascade through the full chain, this restricts writes only);
107
+ keys absent from this map default to all non-"default" scope kinds */
108
+ export const configWritableScopes = {
109
+ "task.id": ["session"]
110
+ };
111
+ /* default set of scope kinds writable for any unrestricted key */
112
+ const configWritableScopesDefault = ["user", "project", "task", "session"];
113
+ /* canonical ordering rank of a scope kind */
114
+ const scopeRank = (kind) => ({ default: -1, user: 0, project: 1, task: 2, session: 3 })[kind];
115
+ /* parse a single scope term */
116
+ const parseScopeTerm = (value) => {
117
+ if (value === "user")
118
+ return { kind: "user" };
119
+ else if (value === "project")
120
+ return { kind: "project" };
121
+ const m = /^(session|task):([A-Za-z0-9._-]+)$/.exec(value);
122
+ if (m !== null)
123
+ return { kind: m[1], id: m[2] };
124
+ throw new Error(`invalid --scope term "${value}" ` +
125
+ "(expected: \"user\", \"project\", \"task:<id>\", or \"session:<id>\")");
126
+ };
127
+ /* detect whether a project context exists, i.e. either we are inside
128
+ a Git working tree or a ".ase" directory is present at or above cwd */
129
+ const hasProjectContext = () => {
130
+ try {
131
+ const result = execaSync("git", ["rev-parse", "--show-toplevel"], { stderr: "ignore" });
132
+ if (result.stdout.trim() !== "")
133
+ return true;
134
+ }
135
+ catch {
136
+ /* not inside a Git working tree */
137
+ }
138
+ let dir = fs.realpathSync(process.cwd());
139
+ for (;;) {
140
+ if (fs.existsSync(path.join(dir, ".ase")))
141
+ return true;
142
+ const parent = path.dirname(dir);
143
+ if (parent === dir)
144
+ return false;
145
+ dir = parent;
146
+ }
147
+ };
148
+ /* parse a raw "--scope" option value into a canonical Scope chain;
149
+ accepts a comma-separated list of terms in any order. The "user"
150
+ term is always implicitly added at the bottom of the chain; the
151
+ "project" term is implicitly added only when a project context
152
+ exists (Git repository or ".ase" directory at or above cwd), and
153
+ an explicit "project" term requires that same context */
154
+ const parseScope = (value) => {
155
+ const projectActive = hasProjectContext();
156
+ const input = (value === undefined || value === "") ?
157
+ (projectActive ? "project" : "user") :
158
+ value.trim();
159
+ if (input === "")
160
+ throw new Error("invalid --scope: value must not be empty");
161
+ const terms = input.split(",").map((s) => parseScopeTerm(s.trim()));
162
+ const seen = new Set();
163
+ for (const t of terms) {
164
+ if (seen.has(t.kind))
165
+ throw new Error(`invalid --scope: duplicate term of kind "${t.kind}"`);
166
+ seen.add(t.kind);
167
+ }
168
+ if (seen.has("project") && !projectActive)
169
+ throw new Error("invalid --scope: \"project\" requires a project context " +
170
+ "(a Git repository or a \".ase\" directory at or above the current directory)");
171
+ if (!seen.has("project") && projectActive)
172
+ terms.unshift({ kind: "project" });
173
+ if (!seen.has("user"))
174
+ terms.unshift({ kind: "user" });
175
+ terms.sort((a, b) => scopeRank(a.kind) - scopeRank(b.kind));
176
+ terms.unshift({ kind: "default" });
177
+ return terms;
178
+ };
69
179
  /* schema for ".ase/config.yaml" */
70
180
  export const configSchema = v.nullish(v.strictObject({
71
181
  project: v.optional(v.strictObject({
@@ -79,31 +189,100 @@ export const configSchema = v.nullish(v.strictObject({
79
189
  })),
80
190
  process: v.optional(v.strictObject({
81
191
  actors: v.optional(v.picklist(projectClassification.process.actors)),
82
- control: v.optional(v.picklist(projectClassification.process.control)),
83
192
  drive: v.optional(v.picklist(projectClassification.process.drive))
84
193
  })),
85
194
  result: v.optional(v.strictObject({
86
195
  target: v.optional(v.picklist(projectClassification.result.target))
196
+ })),
197
+ artifact: v.optional(v.strictObject({
198
+ build: v.optional(v.pipe(v.string(), v.minLength(1))),
199
+ code: v.optional(v.pipe(v.string(), v.minLength(1))),
200
+ docs: v.optional(v.pipe(v.string(), v.minLength(1))),
201
+ spec: v.optional(v.pipe(v.string(), v.minLength(1))),
202
+ arch: v.optional(v.pipe(v.string(), v.minLength(1)))
203
+ }))
204
+ })),
205
+ agent: v.optional(v.strictObject({
206
+ persona: v.optional(v.strictObject({
207
+ style: v.optional(v.picklist(agentClassification.persona.style)),
208
+ creativity: v.optional(v.picklist(agentClassification.persona.creativity))
209
+ })),
210
+ process: v.optional(v.strictObject({
211
+ autonomy: v.optional(v.picklist(agentClassification.process.autonomy))
87
212
  }))
213
+ })),
214
+ task: v.optional(v.strictObject({
215
+ id: v.optional(v.pipe(v.string(), v.minLength(1)))
88
216
  }))
89
217
  }));
90
- /* encapsulate read/write access to a project-local ".ase/<name>.yaml" file */
218
+ /* encapsulate read/write access to a stack of "<name>.yaml" configuration files,
219
+ each associated with a scope term; reads cascade along user < project < task < session,
220
+ writes are confined to the target (strongest) scope term */
91
221
  export class Config {
222
+ /* public state */
92
223
  filename;
93
- doc;
224
+ /* private state */
225
+ name;
226
+ scope;
94
227
  schema;
95
228
  log;
96
- constructor(name, schema, log) {
97
- const rel = path.join(".ase", `${name}.yaml`);
98
- const cwd = process.cwd();
99
- const top = this.gitToplevel();
100
- const found = top !== null ?
101
- this.findUpward(cwd, top, rel) :
102
- (fs.existsSync(path.join(cwd, rel)) ? path.join(cwd, rel) : null);
103
- this.filename = found ?? path.join(top ?? cwd, rel);
104
- this.doc = new Document();
229
+ docs;
230
+ target;
231
+ /* creation */
232
+ constructor(name, schema, log, scope = [{ kind: "project" }]) {
233
+ if (scope.length === 0)
234
+ throw new Error("invalid scope: chain must not be empty");
235
+ this.name = name;
236
+ this.scope = scope[0].kind === "default" ? scope : [{ kind: "default" }, ...scope];
105
237
  this.schema = schema ?? null;
106
238
  this.log = log;
239
+ const tgt = this.scope[this.scope.length - 1];
240
+ this.filename = this.resolveFilename(name, tgt);
241
+ this.docs = [{ scope: tgt, filename: this.filename, doc: new Document() }];
242
+ this.target = 0;
243
+ }
244
+ /* render a scope term as a short textual label */
245
+ static scopeLabel(term) {
246
+ if (term.kind === "default" || term.kind === "user" || term.kind === "project")
247
+ return term.kind;
248
+ return `${term.kind}:${term.id}`;
249
+ }
250
+ /* resolve the per-OS user-scope base directory */
251
+ userConfigDir() {
252
+ if (process.platform === "darwin")
253
+ /* macOS */
254
+ return path.join(os.homedir(), "Library", "Application Support", "ase");
255
+ else if (process.platform === "win32")
256
+ /* Windows */
257
+ return path.join(process.env.APPDATA ?? os.homedir(), "ase");
258
+ else {
259
+ /* Linux */
260
+ const xdg = process.env.XDG_CONFIG_HOME;
261
+ const base = xdg !== undefined && xdg !== "" ? xdg : path.join(os.homedir(), ".config");
262
+ return path.join(base, "ase");
263
+ }
264
+ }
265
+ /* resolve the configuration filename based on the selected scope term */
266
+ resolveFilename(name, term) {
267
+ if (term.kind === "default")
268
+ throw new Error("internal error: \"default\" scope has no filename");
269
+ if (term.kind === "user")
270
+ return path.join(this.userConfigDir(), `${name}.yaml`);
271
+ else if (term.kind === "project") {
272
+ const rel = path.join(".ase", `${name}.yaml`);
273
+ const cwd = process.cwd();
274
+ const top = this.gitToplevel();
275
+ const found = top !== null ?
276
+ this.findUpward(cwd, top, rel) :
277
+ (fs.existsSync(path.join(cwd, rel)) ? path.join(cwd, rel) : null);
278
+ return found ?? path.join(top ?? cwd, rel);
279
+ }
280
+ else if (term.kind === "task") {
281
+ const top = this.gitToplevel() ?? process.cwd();
282
+ return path.join(top, ".ase", "task", term.id, `${name}.yaml`);
283
+ }
284
+ else
285
+ return path.join(os.homedir(), ".ase", "session", term.id, `${name}.yaml`);
107
286
  }
108
287
  /* upward-walk on filesystem for a file path relative to a start directory,
109
288
  bounded above (inclusive) by a stop directory */
@@ -135,31 +314,67 @@ export class Config {
135
314
  return null;
136
315
  }
137
316
  }
138
- /* read configuration file into memory */
317
+ /* read the full scope chain into memory; the requested mode applies
318
+ to the target scope only, inherited scopes are always lenient */
139
319
  read(mode = "lenient") {
140
- const text = fs.existsSync(this.filename) ? fs.readFileSync(this.filename, "utf8") : "";
141
- this.doc = parseDocument(text);
142
- if (this.doc.errors.length > 0) {
143
- const msg = `invalid YAML in ${this.filename}: ${this.doc.errors[0].message}`;
144
- if (mode === "strict")
145
- throw new Error(msg);
146
- this.log.write("warning", msg);
147
- this.doc = new Document();
320
+ const chain = this.scope;
321
+ const docs = [];
322
+ for (let i = 0; i < chain.length; i++) {
323
+ const sc = chain[i];
324
+ if (sc.kind === "default") {
325
+ const doc = new Document();
326
+ doc.contents = doc.createNode({});
327
+ const preset = projectClassificationPresets.default;
328
+ for (const [k, val] of Object.entries(preset)) {
329
+ const segments = k.split(".");
330
+ for (let j = 1; j < segments.length; j++) {
331
+ const prefix = segments.slice(0, j);
332
+ const node = doc.getIn(prefix, true);
333
+ if (node === undefined)
334
+ doc.setIn(prefix, doc.createNode({}));
335
+ }
336
+ doc.setIn(segments, doc.createNode(val));
337
+ }
338
+ docs.push({ scope: sc, filename: "", doc });
339
+ continue;
340
+ }
341
+ const filename = this.resolveFilename(this.name, sc);
342
+ const isTarget = (i === chain.length - 1);
343
+ const perDocMode = isTarget ? mode : "lenient";
344
+ const text = fs.existsSync(filename) ? fs.readFileSync(filename, "utf8") : "";
345
+ let doc = parseDocument(text);
346
+ if (doc.errors.length > 0) {
347
+ const msg = `invalid YAML in ${filename}: ${doc.errors[0].message}`;
348
+ if (perDocMode === "strict")
349
+ throw new Error(msg);
350
+ this.log.write("warning", msg);
351
+ doc = new Document();
352
+ }
353
+ docs.push({ scope: sc, filename, doc });
354
+ }
355
+ this.docs = docs;
356
+ this.target = docs.length - 1;
357
+ for (let i = 0; i < docs.length; i++) {
358
+ const isTarget = (i === this.target);
359
+ const perDocMode = isTarget ? mode : "lenient";
360
+ this.validateDoc(docs[i].doc, docs[i].filename, perDocMode);
148
361
  }
149
- this.validate(mode);
150
362
  }
151
- /* write in-memory configuration back to file */
363
+ /* write in-memory configuration back to the target scope's file */
152
364
  write() {
153
- this.validate("strict");
154
- fs.mkdirSync(path.dirname(this.filename), { recursive: true });
155
- fs.writeFileSync(this.filename, this.doc.toString({ indent: 4 }), "utf8");
365
+ const td = this.docs[this.target];
366
+ if (td.scope.kind === "default")
367
+ throw new Error("internal error: \"default\" scope is not writable");
368
+ this.validateDoc(td.doc, td.filename, "strict");
369
+ fs.mkdirSync(path.dirname(td.filename), { recursive: true });
370
+ fs.writeFileSync(td.filename, td.doc.toString({ indent: 4 }), "utf8");
156
371
  }
157
- /* validate in-memory configuration against the optional schema */
158
- validate(mode = "strict") {
372
+ /* validate a single YAML document against the optional schema */
373
+ validateDoc(doc, filename, mode = "strict") {
159
374
  if (this.schema === null)
160
375
  return;
161
376
  for (;;) {
162
- const result = v.safeParse(this.schema, this.doc.toJS());
377
+ const result = v.safeParse(this.schema, doc.toJS());
163
378
  if (result.success)
164
379
  return;
165
380
  if (mode === "strict") {
@@ -167,15 +382,15 @@ export class Config {
167
382
  const dotPath = (i.path ?? []).map((p) => String(p.key)).join(".");
168
383
  return dotPath ? `${dotPath}: ${i.message}` : i.message;
169
384
  }).join("; ");
170
- throw new Error(`invalid configuration in ${this.filename}: ${issues}`);
385
+ throw new Error(`invalid configuration in ${filename}: ${issues}`);
171
386
  }
172
387
  let progressed = false;
173
388
  for (const i of result.issues) {
174
389
  const segs = (i.path ?? []).map((p) => String(p.key));
175
390
  const dotPath = segs.join(".");
176
- this.log.write("warning", `invalid entry in ${this.filename}: ${dotPath ? `${dotPath}: ` : ""}${i.message}`);
391
+ this.log.write("warning", `invalid entry in ${filename}: ${dotPath ? `${dotPath}: ` : ""}${i.message}`);
177
392
  if (segs.length > 0) {
178
- this.doc.deleteIn(segs);
393
+ doc.deleteIn(segs);
179
394
  progressed = true;
180
395
  }
181
396
  else
@@ -228,16 +443,84 @@ export class Config {
228
443
  throw new Error(`ambiguous key "${key}" matches: ${matches.map((m) => m.join(".")).join(", ")}`);
229
444
  return matches[0].join(".");
230
445
  }
231
- /* retrieve a value at a dotted key, or the root contents if no key given */
446
+ /* retrieve the effective value at a dotted key (strongest scope wins),
447
+ or the target scope's root contents if no key is given */
232
448
  get(key) {
233
449
  if (key === undefined)
234
- return this.doc.contents;
235
- return this.doc.getIn(this.resolveKey(key).split("."));
450
+ return this.docs[this.target].doc.contents;
451
+ const segs = this.resolveKey(key).split(".");
452
+ for (let i = this.docs.length - 1; i >= 0; i--) {
453
+ const v = this.docs[i].doc.getIn(segs);
454
+ if (v !== undefined)
455
+ return v;
456
+ }
457
+ return undefined;
458
+ }
459
+ /* retrieve the effective value together with the scope it came from */
460
+ getWithOrigin(key) {
461
+ const segs = this.resolveKey(key).split(".");
462
+ for (let i = this.docs.length - 1; i >= 0; i--) {
463
+ const v = this.docs[i].doc.getIn(segs);
464
+ if (v !== undefined)
465
+ return { value: v, scope: this.docs[i].scope };
466
+ }
467
+ return undefined;
468
+ }
469
+ /* enumerate the effective leaf entries across the full scope chain;
470
+ each returned entry identifies the originating scope */
471
+ entries() {
472
+ const keys = new Set();
473
+ const walk = (node, prefix) => {
474
+ if (isMap(node))
475
+ for (const item of node.items) {
476
+ const k = [...prefix, String(item.key)];
477
+ if (isMap(item.value))
478
+ walk(item.value, k);
479
+ else if (isScalar(item.value))
480
+ keys.add(k.join("."));
481
+ else
482
+ throw new Error(`key "${k.join(".")}" has unsupported node type`);
483
+ }
484
+ };
485
+ for (const d of this.docs)
486
+ walk(d.doc.contents, []);
487
+ const result = [];
488
+ for (const k of keys) {
489
+ const segs = k.split(".");
490
+ for (let i = this.docs.length - 1; i >= 0; i--) {
491
+ const v = this.docs[i].doc.getIn(segs);
492
+ if (v !== undefined) {
493
+ result.push({ key: k, value: v, scope: this.docs[i].scope });
494
+ break;
495
+ }
496
+ }
497
+ }
498
+ result.sort((a, b) => a.key.localeCompare(b.key));
499
+ return result;
236
500
  }
237
- /* set a value at a dotted key, creating intermediate maps as needed */
501
+ /* determine whether a key is writable on a given scope kind */
502
+ isWritableOn(key, kind) {
503
+ if (kind === "default")
504
+ return false;
505
+ const resolved = this.resolveKey(key);
506
+ const allowed = configWritableScopes[resolved] ?? configWritableScopesDefault;
507
+ return allowed.includes(kind);
508
+ }
509
+ /* enforce write-scope policy for the current target scope */
510
+ assertWritable(key) {
511
+ const td = this.docs[this.target];
512
+ const resolved = this.resolveKey(key);
513
+ const allowed = configWritableScopes[resolved] ?? configWritableScopesDefault;
514
+ if (!allowed.includes(td.scope.kind))
515
+ throw new Error(`cannot set "${resolved}" on scope "${Config.scopeLabel(td.scope)}": ` +
516
+ `this key is only writable on scope(s): ${allowed.join(", ")}`);
517
+ }
518
+ /* set a value at a dotted key in the target scope, creating intermediate maps as needed */
238
519
  set(key, value) {
520
+ this.assertWritable(key);
239
521
  const segments = this.resolveKey(key).split(".");
240
- const next = this.doc.clone();
522
+ const td = this.docs[this.target];
523
+ const next = td.doc.clone();
241
524
  for (let i = 1; i < segments.length; i++) {
242
525
  const prefix = segments.slice(0, i);
243
526
  const node = next.getIn(prefix, true);
@@ -247,27 +530,29 @@ export class Config {
247
530
  next.setIn(prefix, next.createNode({}));
248
531
  }
249
532
  next.setIn(segments, value);
250
- const saved = this.doc;
251
- this.doc = next;
533
+ const saved = td.doc;
534
+ td.doc = next;
252
535
  try {
253
- this.validate("strict");
536
+ this.validateDoc(td.doc, td.filename, "strict");
254
537
  }
255
538
  catch (err) {
256
- this.doc = saved;
539
+ td.doc = saved;
257
540
  throw err;
258
541
  }
259
542
  }
260
- /* delete a value at a dotted key */
543
+ /* delete a value at a dotted key from the target scope */
261
544
  delete(key) {
262
- const next = this.doc.clone();
545
+ this.assertWritable(key);
546
+ const td = this.docs[this.target];
547
+ const next = td.doc.clone();
263
548
  next.deleteIn(this.resolveKey(key).split("."));
264
- const saved = this.doc;
265
- this.doc = next;
549
+ const saved = td.doc;
550
+ td.doc = next;
266
551
  try {
267
- this.validate("strict");
552
+ this.validateDoc(td.doc, td.filename, "strict");
268
553
  }
269
554
  catch (err) {
270
- this.doc = saved;
555
+ td.doc = saved;
271
556
  throw err;
272
557
  }
273
558
  }
@@ -283,7 +568,11 @@ export default class ConfigCommand {
283
568
  /* register CLI top-level command "ase config" */
284
569
  const configCmd = program
285
570
  .command("config")
286
- .description("Manage ASE configuration")
571
+ .option("--scope <scope>", "configuration scope chain: comma-separated list of \"user\", \"project\", " +
572
+ "\"task:<id>\", and/or \"session:<id>\" terms (e.g. \"task:N,session:M\"); " +
573
+ "\"user\" is always implicitly included and \"project\" is implicitly " +
574
+ "included whenever a project context (Git repo or upward \".ase\" directory) exists")
575
+ .description("manage ASE configuration")
287
576
  .action((_opts, cmd) => {
288
577
  cmd.outputHelp();
289
578
  process.exit(1);
@@ -291,52 +580,50 @@ export default class ConfigCommand {
291
580
  /* register CLI sub-command "ase config init" */
292
581
  configCmd
293
582
  .command("init")
294
- .description("Initialize configuration with preset values (vibe|pro|industry)")
295
- .argument("<type>", "Preset type (vibe|pro|industry)")
296
- .action((type) => {
583
+ .description("initialize configuration with preset values (default|vibe|pro|industry)")
584
+ .argument("<type>", "Preset type (default|vibe|pro|industry)")
585
+ .action((type, _opts, cmd) => {
586
+ const scope = parseScope(cmd.optsWithGlobals().scope);
297
587
  const preset = projectClassificationPresets[type];
298
588
  if (preset === undefined)
299
- throw new Error(`unknown preset "${type}" (expected: vibe|pro|industry)`);
300
- const cfg = new Config("config", configSchema, this.log);
589
+ throw new Error(`unknown preset "${type}" (expected: default|vibe|pro|industry)`);
590
+ const cfg = new Config("config", configSchema, this.log, scope);
301
591
  cfg.read();
302
- for (const [k, val] of Object.entries(preset))
592
+ const targetKind = scope[scope.length - 1].kind;
593
+ for (const [k, val] of Object.entries(preset)) {
594
+ if (!cfg.isWritableOn(k, targetKind))
595
+ continue;
303
596
  cfg.set(k, val);
597
+ }
304
598
  cfg.write();
305
599
  });
306
600
  /* register CLI sub-command "ase config list" */
307
601
  configCmd
308
602
  .command("list")
309
- .description("List all configured values as flat dotted keys")
310
- .action(() => {
311
- const cfg = new Config("config", configSchema, this.log);
603
+ .description("list all configured values as flat dotted keys")
604
+ .action((_opts, cmd) => {
605
+ const scope = parseScope(cmd.optsWithGlobals().scope);
606
+ const cfg = new Config("config", configSchema, this.log, scope);
312
607
  cfg.read();
313
608
  const table = new Table({
314
- head: ["KEY", "VALUE"],
609
+ head: ["KEY", "VALUE", "SCOPE"],
315
610
  chars: { "mid": "", "left-mid": "", "mid-mid": "", "right-mid": "" },
316
611
  style: { head: ["blue"] }
317
612
  });
318
- const list = (node, prefix) => {
319
- if (isMap(node))
320
- for (const item of node.items) {
321
- const k = prefix ? `${prefix}.${item.key}` : String(item.key);
322
- if (isMap(item.value))
323
- list(item.value, k);
324
- else if (!isScalar(item.value))
325
- throw new Error(`key "${k}" has unsupported node type`);
326
- else
327
- table.push([k, String(item.value.value)]);
328
- }
329
- };
330
- list(cfg.get(), "");
613
+ for (const e of cfg.entries()) {
614
+ const v = isScalar(e.value) ? e.value.value : e.value;
615
+ table.push([e.key, String(v), Config.scopeLabel(e.scope)]);
616
+ }
331
617
  process.stdout.write(`${table.toString()}\n`);
332
618
  });
333
619
  /* register CLI sub-command "ase config edit" */
334
620
  configCmd
335
621
  .command("edit")
336
- .description("Edit configuration file with $EDITOR")
337
- .action(async () => {
622
+ .description("edit configuration file with $EDITOR")
623
+ .action(async (_opts, cmd) => {
624
+ const scope = parseScope(cmd.optsWithGlobals().scope);
338
625
  const editor = process.env.EDITOR ?? process.env.VISUAL ?? "vi";
339
- const cfg = new Config("config", configSchema, this.log);
626
+ const cfg = new Config("config", configSchema, this.log, scope);
340
627
  fs.mkdirSync(path.dirname(cfg.filename), { recursive: true });
341
628
  if (!fs.existsSync(cfg.filename))
342
629
  fs.writeFileSync(cfg.filename, "", "utf8");
@@ -364,10 +651,11 @@ export default class ConfigCommand {
364
651
  /* register CLI sub-command "ase config get" */
365
652
  configCmd
366
653
  .command("get")
367
- .description("Print the value at a dotted configuration key")
368
- .argument("<key>", "Configuration key (dotted path)")
369
- .action((key) => {
370
- const cfg = new Config("config", configSchema, this.log);
654
+ .description("print the value at a dotted configuration key")
655
+ .argument("<key>", "configuration key (dotted path)")
656
+ .action((key, _opts, cmd) => {
657
+ const scope = parseScope(cmd.optsWithGlobals().scope);
658
+ const cfg = new Config("config", configSchema, this.log, scope);
371
659
  cfg.read();
372
660
  const val = cfg.get(key);
373
661
  if (val === undefined)
@@ -379,11 +667,12 @@ export default class ConfigCommand {
379
667
  /* register CLI sub-command "ase config set" */
380
668
  configCmd
381
669
  .command("set")
382
- .description("Set the value at a dotted configuration key")
383
- .argument("<key>", "Configuration key (dotted path)")
384
- .argument("<value>", "Configuration value")
385
- .action((key, value) => {
386
- const cfg = new Config("config", configSchema, this.log);
670
+ .description("set the value at a dotted configuration key")
671
+ .argument("<key>", "configuration key (dotted path)")
672
+ .argument("<value>", "configuration value")
673
+ .action((key, value, _opts, cmd) => {
674
+ const scope = parseScope(cmd.optsWithGlobals().scope);
675
+ const cfg = new Config("config", configSchema, this.log, scope);
387
676
  cfg.read();
388
677
  cfg.set(key, value);
389
678
  cfg.write();
@@ -0,0 +1,83 @@
1
+ /*
2
+ ** Agentic Software Engineering (ASE)
3
+ ** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under GPL 3.0 <https://spdx.org/licenses/GPL-3.0-only>
5
+ */
6
+ import path from "node:path";
7
+ import fs from "node:fs";
8
+ import { execaSync } from "execa";
9
+ /* CLI command "ase hook" */
10
+ export default class HookCommand {
11
+ log;
12
+ constructor(log) {
13
+ this.log = log;
14
+ }
15
+ /* handler for "ase hook session-start" */
16
+ doSessionStart() {
17
+ /* determine plugin root */
18
+ const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT ?? "";
19
+ if (pluginRoot === "")
20
+ throw new Error("CLAUDE_PLUGIN_ROOT environment variable is not set");
21
+ /* determine path to external files */
22
+ const filePkg = path.join(pluginRoot, ".claude-plugin", "plugin.json");
23
+ const fileMd = path.join(pluginRoot, "meta", "ase-constitution.md");
24
+ /* read external files */
25
+ const pkg = fs.readFileSync(filePkg, "utf8");
26
+ let md = fs.readFileSync(fileMd, "utf8");
27
+ /* determine own version */
28
+ const version = JSON.parse(pkg).version ?? "";
29
+ /* read session information */
30
+ const stdin = fs.readFileSync(0, "utf8");
31
+ const input = stdin.trim() !== "" ? JSON.parse(stdin) : {};
32
+ /* determine session id */
33
+ const sessionId = input.session_id ?? "";
34
+ /* determine task id */
35
+ const taskId = process.env.ASE_TASK_ID ?? "default";
36
+ try {
37
+ execaSync("ase", ["config", `--scope=session:${sessionId}`, "set", "task.id", taskId], { stdio: ["ignore", "ignore", "ignore"] });
38
+ }
39
+ catch (_e) {
40
+ /* best-effort: ignore failures */
41
+ }
42
+ /* provide session and task id to Claude Code shell commands */
43
+ const envFile = process.env.CLAUDE_ENV_FILE ?? "";
44
+ if (envFile !== "") {
45
+ const script = `export ASE_VERSION="${version}"\n` +
46
+ `export ASE_SESSION_ID="${sessionId}"\n` +
47
+ `export ASE_TASK_ID="${taskId}"\n`;
48
+ fs.appendFileSync(envFile, script, "utf8");
49
+ }
50
+ /* prepend ASE information to constitution markdown */
51
+ md =
52
+ `<ase-version>${version}</ase-version>\n` +
53
+ `<ase-task-id>${taskId}</ase-task-id>\n` +
54
+ `<ase-session-id>${sessionId}</ase-session-id>\n` +
55
+ "\n" + md;
56
+ /* inject markdown into session context */
57
+ process.stdout.write(JSON.stringify({
58
+ "hookSpecificOutput": {
59
+ "hookEventName": "SessionStart",
60
+ "additionalContext": md
61
+ }
62
+ }));
63
+ return 0;
64
+ }
65
+ /* register commands */
66
+ register(program) {
67
+ /* register CLI top-level command "ase hook" */
68
+ const hookCmd = program
69
+ .command("hook")
70
+ .description("Claude Code hook entry points")
71
+ .action(() => {
72
+ hookCmd.outputHelp();
73
+ process.exit(1);
74
+ });
75
+ /* register CLI sub-command "ase hook session-start" */
76
+ hookCmd
77
+ .command("session-start")
78
+ .description("handle Claude Code SessionStart hook event")
79
+ .action(() => {
80
+ process.exit(this.doSessionStart());
81
+ });
82
+ }
83
+ }
package/dst/ase.1 CHANGED
@@ -26,13 +26,13 @@ The following top-level command-line options exist:
26
26
  The following top-level commands exist for configuration handling:
27
27
  .RS 0
28
28
  .IP \(bu 4
29
- \fBase config\fR: Manage \fIASE\fR configuration stored in \fB.ase/config.yaml\fR. Without a subcommand, prints usage information. The file is validated against a schema: on read, unknown or invalid entries are warned about and silently dropped from the in-memory view; on set/write, they cause a fatal error.
29
+ \fBase config\fR: Manage \fIASE\fR configuration stored in \fB.ase/config.yaml\fR. Without a subcommand, prints usage information. The file is validated against a schema: on read, unknown or invalid entries are warned about and silently dropped from the in-memory view; on set/write, they cause a fatal error. Recognized keys are grouped under three top-level sections: \fBproject.*\fR (project identity, classification, and artifact globs), \fBagent.*\fR (agent persona and process), and \fBtask.*\fR (currently \fBtask.id\fR, the active task identifier). All \fBase config\fR subcommands accept a \fB--scope\fR \fIscope\fR option that selects the scope chain. The \fIscope\fR value is a comma-separated list of scope terms, in any order; each term is one of \fBuser\fR, \fBproject\fR, \fBtask:\fR\fIid\fR, or \fBsession:\fR\fIid\fR (where \fIid\fR matches \fB\[lB]A-Za-z0-9._-\[rB]+\fR). At most one term per kind is allowed. The chain is canonicalized into the fixed inheritance order \fBuser\fR < \fBproject\fR < \fBtask\fR < \fBsession\fR. \fBuser\fR is always implicitly added at the bottom of the chain. \fBproject\fR is implicitly added only when a \fIproject context\fR exists -- i.e. when the current working directory is inside a Git repository, or a \fB.ase\fR directory is found at or above it. Specifying \fBproject\fR explicitly without a project context is an error. Without an explicit \fB--scope\fR, the target defaults to \fBproject\fR when a project context exists, otherwise to \fBuser\fR. Reads cascade from the strongest (rightmost) scope down to the weakest and return the first value that is defined. Writes (\fBset\fR, \fBdelete\fR, \fBedit\fR, \fBinit\fR) are always confined to the strongest (target) scope's own file -- intermediate and weaker scopes are never modified. See \fIFILES\fR below for the resulting paths. Example: \fB--scope task:T1,session:S1\fR yields the chain \fBuser\fR -> \fBproject\fR -> \fBtask:T1\fR -> \fBsession:S1\fR, with \fBsession:S1\fR as the write target.
30
30
  .IP \(bu 4
31
31
  \fBase config init\fR \fItype\fR: Initialize \fB.ase/config.yaml\fR with preset values for all recognized keys. The \fItype\fR argument selects the preset: \fBvibe\fR (solo rookie: small black-box prototype, bare code, fully agent-driven, spec-driven, engineer ambition), \fBpro\fR (solo expert: medium white-box product, framework-based, human-controlled, code-driven, artist ambition), or \fBindustry\fR (team crew: large grey-box MVP, framework-based, human-in-the-loop, code-driven, craftsman ambition).
32
32
  .IP \(bu 4
33
33
  \fBase config edit\fR: Open \fB.ase/config.yaml\fR in the editor defined by the \fB$EDITOR\fR or \fB$VISUAL\fR environment variable (falling back to \fBvi\fR). The file and its parent directory are created if missing. After the editor exits, the file is re-read and schema warnings are reported.
34
34
  .IP \(bu 4
35
- \fBase config list\fR: List all configured values as flat dotted keys, rendered as a two-column table of \fBkey\fR and \fBvalue\fR.
35
+ \fBase config list\fR: List all effective configured values across the scope inheritance chain, rendered as a three-column table of \fBkey\fR, \fBvalue\fR, and \fBorigin\fR. The \fBorigin\fR column identifies the scope (\fBuser\fR, \fBproject\fR, \fBtask:\fR\fIid\fR, or \fBsession:\fR\fIid\fR) that supplied each value. For overlapping keys only the value from the strongest scope is shown.
36
36
  .IP \(bu 4
37
37
  \fBase config get\fR \fIkey\fR: Print the value at the given dotted \fIkey\fR. Fails with an error if \fIkey\fR does not resolve to a leaf value.
38
38
  .IP \(bu 4
@@ -54,12 +54,22 @@ The following top-level commands exist for service management:
54
54
  \fBase service stop\fR: Stop the background service via HTTP \fBGET /stop\fR. Exits silently with status 0 on successful stop. If no port is configured or the port is not responding, prints an informational message and exits with status 0.
55
55
  .RE 0
56
56
 
57
- .SH "FILES"
57
+ .SH "CONFIGURATION FILES"
58
58
  .RS 0
59
59
  .IP \(bu 4
60
- \fB.ase/config.yaml\fR: Per-project \fIASE\fR configuration. Read upward from the current working directory. Recognized keys: \fBproject.id\fR (non-empty string, uniqued project id), \fBproject.name\fR (non-empty string, descriptive project name), \fBproject.source.ambition\fR (\fBartist\fR|\fBcraftsman\fR|\fBengineer\fR), \fBproject.source.boxing\fR (\fBwhite\fR|\fBgrey\fR|\fBblack\fR), \fBproject.source.size\fR (\fBsmall\fR|\fBmedium\fR|\fBlarge\fR), \fBproject.source.structure\fR (\fBbare\fR|\fBlibrary\fR|\fBframework\fR), \fBproject.process.actors\fR (\fBperson\fR|\fBteam\fR|\fBcrew\fR), \fBproject.process.control\fR (\fBhuman\fR|\fBhitl\fR|\fBagent\fR), \fBproject.process.drive\fR (\fBspec\fR|\fBcode\fR|\fBtest\fR), and \fBproject.result.target\fR (\fBprototype\fR|\fBmvp\fR|\fBproduct\fR).
60
+ \fBuser\fR: \fIper-user configuration directory\fR\fB/config.yaml\fR: Per-user \fIASE\fR configuration (scope \fBuser\fR). The per-user configuration directory is \fB~/Library/Application Support/ase\fR on macOS, \fB%APPDATA%\[rs]ase\fR on Windows, and \fB$XDG_CONFIG_HOME/ase\fR (falling back to \fB~/.config/ase\fR) on Linux and other Unix systems.
61
61
  .IP \(bu 4
62
- \fB.ase/service.yaml\fR: Per-project service state. Recognized key: \fBport\fR (integer in \fB1024\fR..\fB65535\fR).
62
+ \fBproject\fR: \fB.ase/config.yaml\fR: Per-project \fIASE\fR configuration (scope \fBproject\fR). Read upward from the current working directory.
63
+ .IP \(bu 4
64
+ \fBtask\fR: \fB.ase/task/\fR\fIid\fR\fB/config.yaml\fR: Per-task \fIASE\fR configuration (scope \fBtask:\fR\fIid\fR), located relative to the Git top-level directory. Outside a Git repository, the file is placed relative to the current working directory.
65
+ .IP \(bu 4
66
+ \fBsession\fR: \fB~/.ase/session/\fR\fIid\fR\fB/config.yaml\fR: Per-session \fIASE\fR configuration (scope \fBsession:\fR\fIid\fR), located under the user's home directory (independent of any project context).
67
+ .RE 0
68
+
69
+ .SH "STATE FILES"
70
+ .RS 0
71
+ .IP \(bu 4
72
+ \fB.ase/service.yaml\fR: Per-project service state.
63
73
  .IP \(bu 4
64
74
  \fB.ase/service.log\fR: Stdout/stderr log of the detached background service.
65
75
  .RE 0
package/dst/ase.js CHANGED
@@ -8,6 +8,7 @@ import { Command, CommanderError, Option } from "commander";
8
8
  import Log from "./ase-log.js";
9
9
  import ConfigCommand from "./ase-config.js";
10
10
  import ServiceCommand from "./ase-service.js";
11
+ import HookCommand from "./ase-hook.js";
11
12
  import pkg from "../package.json" with { type: "json" };
12
13
  /* globally initialize logger */
13
14
  const log = new Log("ase", "warning", "-");
@@ -38,6 +39,7 @@ const main = async () => {
38
39
  /* register top-level commands */
39
40
  new ConfigCommand(log).register(program);
40
41
  new ServiceCommand(log).register(program);
42
+ new HookCommand(log).register(program);
41
43
  /* parse program arguments */
42
44
  await program.parseAsync(process.argv);
43
45
  /* gracefully terminate */
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "homepage": "http://github.com/rse/ase",
7
7
  "repository": { "url": "git+https://github.com/rse/ase.git", "type": "git" },
8
8
  "bugs": { "url": "http://github.com/rse/ase/issues" },
9
- "version": "0.0.8",
9
+ "version": "0.0.10",
10
10
  "license": "GPL-3.0-only",
11
11
  "author": {
12
12
  "name": "Dr. Ralf S. Engelschall",