@rse/ase 0.0.8 → 0.0.9

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";
@@ -27,6 +28,16 @@ export const projectClassification = {
27
28
  target: ["prototype", "mvp", "product"]
28
29
  }
29
30
  };
31
+ /* agent classification taxonomy */
32
+ export const agentClassification = {
33
+ persona: {
34
+ style: ["writer", "engineer", "telegrapher", "caveman"],
35
+ creativity: ["none", "lite", "full"]
36
+ },
37
+ process: {
38
+ autonomy: ["assistant", "hotl", "agent"]
39
+ }
40
+ };
30
41
  /* classification presets */
31
42
  export const projectClassificationPresets = {
32
43
  vibe: {
@@ -39,7 +50,10 @@ export const projectClassificationPresets = {
39
50
  "project.process.actors": "person",
40
51
  "project.process.control": "agent",
41
52
  "project.process.drive": "spec",
42
- "project.result.target": "prototype"
53
+ "project.result.target": "prototype",
54
+ "agent.persona.style": "writer",
55
+ "agent.persona.creativity": "full",
56
+ "agent.process.autonomy": "agent",
43
57
  },
44
58
  pro: {
45
59
  "project.id": "example",
@@ -51,7 +65,25 @@ export const projectClassificationPresets = {
51
65
  "project.process.actors": "person",
52
66
  "project.process.control": "human",
53
67
  "project.process.drive": "code",
54
- "project.result.target": "product"
68
+ "project.result.target": "product",
69
+ "agent.persona.style": "engineer",
70
+ "agent.persona.creativity": "none",
71
+ "agent.process.autonomy": "assistant",
72
+ },
73
+ default: {
74
+ "project.id": "example",
75
+ "project.name": "Example Project",
76
+ "project.source.ambition": "artist",
77
+ "project.source.boxing": "white",
78
+ "project.source.size": "medium",
79
+ "project.source.structure": "framework",
80
+ "project.process.actors": "person",
81
+ "project.process.control": "human",
82
+ "project.process.drive": "code",
83
+ "project.result.target": "product",
84
+ "agent.persona.style": "engineer",
85
+ "agent.persona.creativity": "none",
86
+ "agent.process.autonomy": "assistant",
55
87
  },
56
88
  industry: {
57
89
  "project.id": "example",
@@ -63,8 +95,77 @@ export const projectClassificationPresets = {
63
95
  "project.process.actors": "crew",
64
96
  "project.process.control": "hitl",
65
97
  "project.process.drive": "code",
66
- "project.result.target": "mvp"
98
+ "project.result.target": "mvp",
99
+ "agent.persona.style": "engineer",
100
+ "agent.persona.creativity": "none",
101
+ "agent.process.autonomy": "hotl",
102
+ }
103
+ };
104
+ /* canonical ordering rank of a scope kind */
105
+ const scopeRank = (kind) => ({ default: -1, user: 0, project: 1, task: 2, session: 3 })[kind];
106
+ /* parse a single scope term */
107
+ const parseScopeTerm = (value) => {
108
+ if (value === "user")
109
+ return { kind: "user" };
110
+ else if (value === "project")
111
+ return { kind: "project" };
112
+ const m = /^(session|task):([A-Za-z0-9._-]+)$/.exec(value);
113
+ if (m !== null)
114
+ return { kind: m[1], id: m[2] };
115
+ throw new Error(`invalid --scope term "${value}" ` +
116
+ "(expected: \"user\", \"project\", \"task:<id>\", or \"session:<id>\")");
117
+ };
118
+ /* detect whether a project context exists, i.e. either we are inside
119
+ a Git working tree or a ".ase" directory is present at or above cwd */
120
+ const hasProjectContext = () => {
121
+ try {
122
+ const result = execaSync("git", ["rev-parse", "--show-toplevel"], { stderr: "ignore" });
123
+ if (result.stdout.trim() !== "")
124
+ return true;
125
+ }
126
+ catch {
127
+ /* not inside a Git working tree */
67
128
  }
129
+ let dir = fs.realpathSync(process.cwd());
130
+ for (;;) {
131
+ if (fs.existsSync(path.join(dir, ".ase")))
132
+ return true;
133
+ const parent = path.dirname(dir);
134
+ if (parent === dir)
135
+ return false;
136
+ dir = parent;
137
+ }
138
+ };
139
+ /* parse a raw "--scope" option value into a canonical Scope chain;
140
+ accepts a comma-separated list of terms in any order. The "user"
141
+ term is always implicitly added at the bottom of the chain; the
142
+ "project" term is implicitly added only when a project context
143
+ exists (Git repository or ".ase" directory at or above cwd), and
144
+ an explicit "project" term requires that same context */
145
+ const parseScope = (value) => {
146
+ const projectActive = hasProjectContext();
147
+ const input = (value === undefined || value === "") ?
148
+ (projectActive ? "project" : "user") :
149
+ value.trim();
150
+ if (input === "")
151
+ throw new Error("invalid --scope: value must not be empty");
152
+ const terms = input.split(",").map((s) => parseScopeTerm(s.trim()));
153
+ const seen = new Set();
154
+ for (const t of terms) {
155
+ if (seen.has(t.kind))
156
+ throw new Error(`invalid --scope: duplicate term of kind "${t.kind}"`);
157
+ seen.add(t.kind);
158
+ }
159
+ if (seen.has("project") && !projectActive)
160
+ throw new Error("invalid --scope: \"project\" requires a project context " +
161
+ "(a Git repository or a \".ase\" directory at or above the current directory)");
162
+ if (!seen.has("project") && projectActive)
163
+ terms.unshift({ kind: "project" });
164
+ if (!seen.has("user"))
165
+ terms.unshift({ kind: "user" });
166
+ terms.sort((a, b) => scopeRank(a.kind) - scopeRank(b.kind));
167
+ terms.unshift({ kind: "default" });
168
+ return terms;
68
169
  };
69
170
  /* schema for ".ase/config.yaml" */
70
171
  export const configSchema = v.nullish(v.strictObject({
@@ -85,25 +186,87 @@ export const configSchema = v.nullish(v.strictObject({
85
186
  result: v.optional(v.strictObject({
86
187
  target: v.optional(v.picklist(projectClassification.result.target))
87
188
  }))
189
+ })),
190
+ agent: v.optional(v.strictObject({
191
+ persona: v.optional(v.strictObject({
192
+ style: v.optional(v.picklist(agentClassification.persona.style)),
193
+ creativity: v.optional(v.picklist(agentClassification.persona.creativity))
194
+ })),
195
+ process: v.optional(v.strictObject({
196
+ autonomy: v.optional(v.picklist(agentClassification.process.autonomy))
197
+ }))
88
198
  }))
89
199
  }));
90
- /* encapsulate read/write access to a project-local ".ase/<name>.yaml" file */
200
+ /* encapsulate read/write access to a stack of "<name>.yaml" configuration files,
201
+ each associated with a scope term; reads cascade along user < project < task < session,
202
+ writes are confined to the target (strongest) scope term */
91
203
  export class Config {
204
+ /* public state */
92
205
  filename;
93
- doc;
206
+ /* private state */
207
+ name;
208
+ scope;
94
209
  schema;
95
210
  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();
211
+ docs;
212
+ target;
213
+ /* creation */
214
+ constructor(name, schema, log, scope = [{ kind: "project" }]) {
215
+ if (scope.length === 0)
216
+ throw new Error("invalid scope: chain must not be empty");
217
+ this.name = name;
218
+ this.scope = scope[0].kind === "default" ? scope : [{ kind: "default" }, ...scope];
105
219
  this.schema = schema ?? null;
106
220
  this.log = log;
221
+ const tgt = this.scope[this.scope.length - 1];
222
+ this.filename = this.resolveFilename(name, tgt);
223
+ this.docs = [{ scope: tgt, filename: this.filename, doc: new Document() }];
224
+ this.target = 0;
225
+ }
226
+ /* render a scope term as a short textual label */
227
+ static scopeLabel(term) {
228
+ if (term.kind === "default" || term.kind === "user" || term.kind === "project")
229
+ return term.kind;
230
+ return `${term.kind}:${term.id}`;
231
+ }
232
+ /* resolve the per-OS user-scope base directory */
233
+ userConfigDir() {
234
+ if (process.platform === "darwin")
235
+ /* macOS */
236
+ return path.join(os.homedir(), "Library", "Application Support", "ase");
237
+ else if (process.platform === "win32")
238
+ /* Windows */
239
+ return path.join(process.env.APPDATA ?? os.homedir(), "ase");
240
+ else {
241
+ /* Linux */
242
+ const xdg = process.env.XDG_CONFIG_HOME;
243
+ const base = xdg !== undefined && xdg !== "" ? xdg : path.join(os.homedir(), ".config");
244
+ return path.join(base, "ase");
245
+ }
246
+ }
247
+ /* resolve the configuration filename based on the selected scope term */
248
+ resolveFilename(name, term) {
249
+ if (term.kind === "default")
250
+ throw new Error("internal error: \"default\" scope has no filename");
251
+ if (term.kind === "user")
252
+ return path.join(this.userConfigDir(), `${name}.yaml`);
253
+ else if (term.kind === "project") {
254
+ const rel = path.join(".ase", `${name}.yaml`);
255
+ const cwd = process.cwd();
256
+ const top = this.gitToplevel();
257
+ const found = top !== null ?
258
+ this.findUpward(cwd, top, rel) :
259
+ (fs.existsSync(path.join(cwd, rel)) ? path.join(cwd, rel) : null);
260
+ return found ?? path.join(top ?? cwd, rel);
261
+ }
262
+ else {
263
+ const sub = term.kind === "session" ? "sessions" : "tasks";
264
+ const top = this.gitToplevel();
265
+ if (top !== null)
266
+ return path.join(top, ".ase", sub, term.id, `${name}.yaml`);
267
+ else
268
+ return path.join(this.userConfigDir(), sub, term.id, `${name}.yaml`);
269
+ }
107
270
  }
108
271
  /* upward-walk on filesystem for a file path relative to a start directory,
109
272
  bounded above (inclusive) by a stop directory */
@@ -135,31 +298,67 @@ export class Config {
135
298
  return null;
136
299
  }
137
300
  }
138
- /* read configuration file into memory */
301
+ /* read the full scope chain into memory; the requested mode applies
302
+ to the target scope only, inherited scopes are always lenient */
139
303
  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();
304
+ const chain = this.scope;
305
+ const docs = [];
306
+ for (let i = 0; i < chain.length; i++) {
307
+ const sc = chain[i];
308
+ if (sc.kind === "default") {
309
+ const doc = new Document();
310
+ doc.contents = doc.createNode({});
311
+ const preset = projectClassificationPresets.default;
312
+ for (const [k, val] of Object.entries(preset)) {
313
+ const segments = k.split(".");
314
+ for (let j = 1; j < segments.length; j++) {
315
+ const prefix = segments.slice(0, j);
316
+ const node = doc.getIn(prefix, true);
317
+ if (node === undefined)
318
+ doc.setIn(prefix, doc.createNode({}));
319
+ }
320
+ doc.setIn(segments, doc.createNode(val));
321
+ }
322
+ docs.push({ scope: sc, filename: "", doc });
323
+ continue;
324
+ }
325
+ const filename = this.resolveFilename(this.name, sc);
326
+ const isTarget = (i === chain.length - 1);
327
+ const perDocMode = isTarget ? mode : "lenient";
328
+ const text = fs.existsSync(filename) ? fs.readFileSync(filename, "utf8") : "";
329
+ let doc = parseDocument(text);
330
+ if (doc.errors.length > 0) {
331
+ const msg = `invalid YAML in ${filename}: ${doc.errors[0].message}`;
332
+ if (perDocMode === "strict")
333
+ throw new Error(msg);
334
+ this.log.write("warning", msg);
335
+ doc = new Document();
336
+ }
337
+ docs.push({ scope: sc, filename, doc });
338
+ }
339
+ this.docs = docs;
340
+ this.target = docs.length - 1;
341
+ for (let i = 0; i < docs.length; i++) {
342
+ const isTarget = (i === this.target);
343
+ const perDocMode = isTarget ? mode : "lenient";
344
+ this.validateDoc(docs[i].doc, docs[i].filename, perDocMode);
148
345
  }
149
- this.validate(mode);
150
346
  }
151
- /* write in-memory configuration back to file */
347
+ /* write in-memory configuration back to the target scope's file */
152
348
  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");
349
+ const td = this.docs[this.target];
350
+ if (td.scope.kind === "default")
351
+ throw new Error("internal error: \"default\" scope is not writable");
352
+ this.validateDoc(td.doc, td.filename, "strict");
353
+ fs.mkdirSync(path.dirname(td.filename), { recursive: true });
354
+ fs.writeFileSync(td.filename, td.doc.toString({ indent: 4 }), "utf8");
156
355
  }
157
- /* validate in-memory configuration against the optional schema */
158
- validate(mode = "strict") {
356
+ /* validate a single YAML document against the optional schema */
357
+ validateDoc(doc, filename, mode = "strict") {
159
358
  if (this.schema === null)
160
359
  return;
161
360
  for (;;) {
162
- const result = v.safeParse(this.schema, this.doc.toJS());
361
+ const result = v.safeParse(this.schema, doc.toJS());
163
362
  if (result.success)
164
363
  return;
165
364
  if (mode === "strict") {
@@ -167,15 +366,15 @@ export class Config {
167
366
  const dotPath = (i.path ?? []).map((p) => String(p.key)).join(".");
168
367
  return dotPath ? `${dotPath}: ${i.message}` : i.message;
169
368
  }).join("; ");
170
- throw new Error(`invalid configuration in ${this.filename}: ${issues}`);
369
+ throw new Error(`invalid configuration in ${filename}: ${issues}`);
171
370
  }
172
371
  let progressed = false;
173
372
  for (const i of result.issues) {
174
373
  const segs = (i.path ?? []).map((p) => String(p.key));
175
374
  const dotPath = segs.join(".");
176
- this.log.write("warning", `invalid entry in ${this.filename}: ${dotPath ? `${dotPath}: ` : ""}${i.message}`);
375
+ this.log.write("warning", `invalid entry in ${filename}: ${dotPath ? `${dotPath}: ` : ""}${i.message}`);
177
376
  if (segs.length > 0) {
178
- this.doc.deleteIn(segs);
377
+ doc.deleteIn(segs);
179
378
  progressed = true;
180
379
  }
181
380
  else
@@ -228,16 +427,66 @@ export class Config {
228
427
  throw new Error(`ambiguous key "${key}" matches: ${matches.map((m) => m.join(".")).join(", ")}`);
229
428
  return matches[0].join(".");
230
429
  }
231
- /* retrieve a value at a dotted key, or the root contents if no key given */
430
+ /* retrieve the effective value at a dotted key (strongest scope wins),
431
+ or the target scope's root contents if no key is given */
232
432
  get(key) {
233
433
  if (key === undefined)
234
- return this.doc.contents;
235
- return this.doc.getIn(this.resolveKey(key).split("."));
434
+ return this.docs[this.target].doc.contents;
435
+ const segs = this.resolveKey(key).split(".");
436
+ for (let i = this.docs.length - 1; i >= 0; i--) {
437
+ const v = this.docs[i].doc.getIn(segs);
438
+ if (v !== undefined)
439
+ return v;
440
+ }
441
+ return undefined;
442
+ }
443
+ /* retrieve the effective value together with the scope it came from */
444
+ getWithOrigin(key) {
445
+ const segs = this.resolveKey(key).split(".");
446
+ for (let i = this.docs.length - 1; i >= 0; i--) {
447
+ const v = this.docs[i].doc.getIn(segs);
448
+ if (v !== undefined)
449
+ return { value: v, scope: this.docs[i].scope };
450
+ }
451
+ return undefined;
452
+ }
453
+ /* enumerate the effective leaf entries across the full scope chain;
454
+ each returned entry identifies the originating scope */
455
+ entries() {
456
+ const keys = new Set();
457
+ const walk = (node, prefix) => {
458
+ if (isMap(node))
459
+ for (const item of node.items) {
460
+ const k = [...prefix, String(item.key)];
461
+ if (isMap(item.value))
462
+ walk(item.value, k);
463
+ else if (isScalar(item.value))
464
+ keys.add(k.join("."));
465
+ else
466
+ throw new Error(`key "${k.join(".")}" has unsupported node type`);
467
+ }
468
+ };
469
+ for (const d of this.docs)
470
+ walk(d.doc.contents, []);
471
+ const result = [];
472
+ for (const k of keys) {
473
+ const segs = k.split(".");
474
+ for (let i = this.docs.length - 1; i >= 0; i--) {
475
+ const v = this.docs[i].doc.getIn(segs);
476
+ if (v !== undefined) {
477
+ result.push({ key: k, value: v, scope: this.docs[i].scope });
478
+ break;
479
+ }
480
+ }
481
+ }
482
+ result.sort((a, b) => a.key.localeCompare(b.key));
483
+ return result;
236
484
  }
237
- /* set a value at a dotted key, creating intermediate maps as needed */
485
+ /* set a value at a dotted key in the target scope, creating intermediate maps as needed */
238
486
  set(key, value) {
239
487
  const segments = this.resolveKey(key).split(".");
240
- const next = this.doc.clone();
488
+ const td = this.docs[this.target];
489
+ const next = td.doc.clone();
241
490
  for (let i = 1; i < segments.length; i++) {
242
491
  const prefix = segments.slice(0, i);
243
492
  const node = next.getIn(prefix, true);
@@ -247,27 +496,28 @@ export class Config {
247
496
  next.setIn(prefix, next.createNode({}));
248
497
  }
249
498
  next.setIn(segments, value);
250
- const saved = this.doc;
251
- this.doc = next;
499
+ const saved = td.doc;
500
+ td.doc = next;
252
501
  try {
253
- this.validate("strict");
502
+ this.validateDoc(td.doc, td.filename, "strict");
254
503
  }
255
504
  catch (err) {
256
- this.doc = saved;
505
+ td.doc = saved;
257
506
  throw err;
258
507
  }
259
508
  }
260
- /* delete a value at a dotted key */
509
+ /* delete a value at a dotted key from the target scope */
261
510
  delete(key) {
262
- const next = this.doc.clone();
511
+ const td = this.docs[this.target];
512
+ const next = td.doc.clone();
263
513
  next.deleteIn(this.resolveKey(key).split("."));
264
- const saved = this.doc;
265
- this.doc = next;
514
+ const saved = td.doc;
515
+ td.doc = next;
266
516
  try {
267
- this.validate("strict");
517
+ this.validateDoc(td.doc, td.filename, "strict");
268
518
  }
269
519
  catch (err) {
270
- this.doc = saved;
520
+ td.doc = saved;
271
521
  throw err;
272
522
  }
273
523
  }
@@ -283,7 +533,11 @@ export default class ConfigCommand {
283
533
  /* register CLI top-level command "ase config" */
284
534
  const configCmd = program
285
535
  .command("config")
286
- .description("Manage ASE configuration")
536
+ .option("--scope <scope>", "configuration scope chain: comma-separated list of \"user\", \"project\", " +
537
+ "\"task:<id>\", and/or \"session:<id>\" terms (e.g. \"task:N,session:M\"); " +
538
+ "\"user\" is always implicitly included and \"project\" is implicitly " +
539
+ "included whenever a project context (Git repo or upward \".ase\" directory) exists")
540
+ .description("manage ASE configuration")
287
541
  .action((_opts, cmd) => {
288
542
  cmd.outputHelp();
289
543
  process.exit(1);
@@ -291,13 +545,14 @@ export default class ConfigCommand {
291
545
  /* register CLI sub-command "ase config init" */
292
546
  configCmd
293
547
  .command("init")
294
- .description("Initialize configuration with preset values (vibe|pro|industry)")
295
- .argument("<type>", "Preset type (vibe|pro|industry)")
296
- .action((type) => {
548
+ .description("initialize configuration with preset values (default|vibe|pro|industry)")
549
+ .argument("<type>", "Preset type (default|vibe|pro|industry)")
550
+ .action((type, _opts, cmd) => {
551
+ const scope = parseScope(cmd.optsWithGlobals().scope);
297
552
  const preset = projectClassificationPresets[type];
298
553
  if (preset === undefined)
299
- throw new Error(`unknown preset "${type}" (expected: vibe|pro|industry)`);
300
- const cfg = new Config("config", configSchema, this.log);
554
+ throw new Error(`unknown preset "${type}" (expected: default|vibe|pro|industry)`);
555
+ const cfg = new Config("config", configSchema, this.log, scope);
301
556
  cfg.read();
302
557
  for (const [k, val] of Object.entries(preset))
303
558
  cfg.set(k, val);
@@ -306,37 +561,30 @@ export default class ConfigCommand {
306
561
  /* register CLI sub-command "ase config list" */
307
562
  configCmd
308
563
  .command("list")
309
- .description("List all configured values as flat dotted keys")
310
- .action(() => {
311
- const cfg = new Config("config", configSchema, this.log);
564
+ .description("list all configured values as flat dotted keys")
565
+ .action((_opts, cmd) => {
566
+ const scope = parseScope(cmd.optsWithGlobals().scope);
567
+ const cfg = new Config("config", configSchema, this.log, scope);
312
568
  cfg.read();
313
569
  const table = new Table({
314
- head: ["KEY", "VALUE"],
570
+ head: ["KEY", "VALUE", "SCOPE"],
315
571
  chars: { "mid": "", "left-mid": "", "mid-mid": "", "right-mid": "" },
316
572
  style: { head: ["blue"] }
317
573
  });
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(), "");
574
+ for (const e of cfg.entries()) {
575
+ const v = isScalar(e.value) ? e.value.value : e.value;
576
+ table.push([e.key, String(v), Config.scopeLabel(e.scope)]);
577
+ }
331
578
  process.stdout.write(`${table.toString()}\n`);
332
579
  });
333
580
  /* register CLI sub-command "ase config edit" */
334
581
  configCmd
335
582
  .command("edit")
336
- .description("Edit configuration file with $EDITOR")
337
- .action(async () => {
583
+ .description("edit configuration file with $EDITOR")
584
+ .action(async (_opts, cmd) => {
585
+ const scope = parseScope(cmd.optsWithGlobals().scope);
338
586
  const editor = process.env.EDITOR ?? process.env.VISUAL ?? "vi";
339
- const cfg = new Config("config", configSchema, this.log);
587
+ const cfg = new Config("config", configSchema, this.log, scope);
340
588
  fs.mkdirSync(path.dirname(cfg.filename), { recursive: true });
341
589
  if (!fs.existsSync(cfg.filename))
342
590
  fs.writeFileSync(cfg.filename, "", "utf8");
@@ -364,10 +612,11 @@ export default class ConfigCommand {
364
612
  /* register CLI sub-command "ase config get" */
365
613
  configCmd
366
614
  .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);
615
+ .description("print the value at a dotted configuration key")
616
+ .argument("<key>", "configuration key (dotted path)")
617
+ .action((key, _opts, cmd) => {
618
+ const scope = parseScope(cmd.optsWithGlobals().scope);
619
+ const cfg = new Config("config", configSchema, this.log, scope);
371
620
  cfg.read();
372
621
  const val = cfg.get(key);
373
622
  if (val === undefined)
@@ -379,11 +628,12 @@ export default class ConfigCommand {
379
628
  /* register CLI sub-command "ase config set" */
380
629
  configCmd
381
630
  .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);
631
+ .description("set the value at a dotted configuration key")
632
+ .argument("<key>", "configuration key (dotted path)")
633
+ .argument("<value>", "configuration value")
634
+ .action((key, value, _opts, cmd) => {
635
+ const scope = parseScope(cmd.optsWithGlobals().scope);
636
+ const cfg = new Config("config", configSchema, this.log, scope);
387
637
  cfg.read();
388
638
  cfg.set(key, value);
389
639
  cfg.write();
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. 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
@@ -57,7 +57,13 @@ The following top-level commands exist for service management:
57
57
  .SH "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
+ \fB.ase/config.yaml\fR: Per-project \fIASE\fR configuration (scope \fBproject\fR). 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). Agent classification keys: \fBagent.persona.style\fR (\fBwriter\fR|\fBengineer\fR|\fBtelegrapher\fR|\fBcaveman\fR), \fBagent.persona.creativity\fR (\fBnone\fR|\fBlite\fR|\fBfull\fR), and \fBagent.process.autonomy\fR (\fBassistant\fR|\fBhotl\fR|\fBagent\fR).
61
+ .IP \(bu 4
62
+ \fB.ase/sessions/\fR\fIid\fR\fB/config.yaml\fR: Per-session \fIASE\fR configuration (scope \fBsession:\fR\fIid\fR), located relative to the Git top-level directory. Outside a Git repository, the file is placed under the per-user configuration directory at \fBsessions/\fR\fIid\fR\fB/config.yaml\fR.
63
+ .IP \(bu 4
64
+ \fB.ase/tasks/\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 under the per-user configuration directory at \fBtasks/\fR\fIid\fR\fB/config.yaml\fR.
65
+ .IP \(bu 4
66
+ \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
67
  .IP \(bu 4
62
68
  \fB.ase/service.yaml\fR: Per-project service state. Recognized key: \fBport\fR (integer in \fB1024\fR..\fB65535\fR).
63
69
  .IP \(bu 4
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.9",
10
10
  "license": "GPL-3.0-only",
11
11
  "author": {
12
12
  "name": "Dr. Ralf S. Engelschall",