@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 +379 -90
- package/dst/ase-hook.js +83 -0
- package/dst/ase.1 +15 -5
- package/dst/ase.js +2 -0
- package/package.json +1 -1
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
|
|
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
|
-
|
|
224
|
+
/* private state */
|
|
225
|
+
name;
|
|
226
|
+
scope;
|
|
94
227
|
schema;
|
|
95
228
|
log;
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
this.
|
|
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
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
if (
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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.
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
158
|
-
|
|
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,
|
|
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 ${
|
|
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 ${
|
|
391
|
+
this.log.write("warning", `invalid entry in ${filename}: ${dotPath ? `${dotPath}: ` : ""}${i.message}`);
|
|
177
392
|
if (segs.length > 0) {
|
|
178
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
/*
|
|
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
|
|
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 =
|
|
251
|
-
|
|
533
|
+
const saved = td.doc;
|
|
534
|
+
td.doc = next;
|
|
252
535
|
try {
|
|
253
|
-
this.
|
|
536
|
+
this.validateDoc(td.doc, td.filename, "strict");
|
|
254
537
|
}
|
|
255
538
|
catch (err) {
|
|
256
|
-
|
|
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
|
-
|
|
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 =
|
|
265
|
-
|
|
549
|
+
const saved = td.doc;
|
|
550
|
+
td.doc = next;
|
|
266
551
|
try {
|
|
267
|
-
this.
|
|
552
|
+
this.validateDoc(td.doc, td.filename, "strict");
|
|
268
553
|
}
|
|
269
554
|
catch (err) {
|
|
270
|
-
|
|
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
|
-
.
|
|
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("
|
|
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
|
-
|
|
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("
|
|
310
|
-
.action(() => {
|
|
311
|
-
const
|
|
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
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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("
|
|
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("
|
|
368
|
-
.argument("<key>", "
|
|
369
|
-
.action((key) => {
|
|
370
|
-
const
|
|
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("
|
|
383
|
-
.argument("<key>", "
|
|
384
|
-
.argument("<value>", "
|
|
385
|
-
.action((key, value) => {
|
|
386
|
-
const
|
|
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();
|
package/dst/ase-hook.js
ADDED
|
@@ -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
|
|
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
|
|
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/
|
|
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.
|
|
9
|
+
"version": "0.0.10",
|
|
10
10
|
"license": "GPL-3.0-only",
|
|
11
11
|
"author": {
|
|
12
12
|
"name": "Dr. Ralf S. Engelschall",
|