@rse/ase 0.9.5 → 0.9.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dst/ase-artifact.js +331 -0
- package/dst/ase-config.js +56 -10
- package/dst/ase-service.js +2 -0
- package/dst/ase-task.js +139 -64
- package/dst/ase.js +2 -0
- package/package.json +5 -3
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.github/plugin/plugin.json +1 -1
- package/plugin/meta/ase-format-meta.md +5 -2
- package/plugin/meta/{ase-format-plan.md → ase-format-task.md} +7 -6
- package/plugin/package.json +1 -1
- package/plugin/skills/ase-code-craft/SKILL.md +1 -1
- package/plugin/skills/ase-code-refactor/SKILL.md +1 -1
- package/plugin/skills/ase-code-resolve/SKILL.md +1 -1
- package/plugin/skills/ase-task-condense/SKILL.md +1 -1
- package/plugin/skills/ase-task-delete/help.md +0 -4
- package/plugin/skills/ase-task-edit/SKILL.md +4 -4
- package/plugin/skills/ase-task-edit/help.md +0 -4
- package/plugin/skills/ase-task-grill/SKILL.md +1 -1
- package/plugin/skills/ase-task-grill/help.md +0 -4
- package/plugin/skills/ase-task-implement/SKILL.md +1 -1
- package/plugin/skills/ase-task-implement/help.md +5 -6
- package/plugin/skills/ase-task-preflight/SKILL.md +1 -1
- package/plugin/skills/ase-task-reboot/SKILL.md +1 -1
|
@@ -0,0 +1,331 @@
|
|
|
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 picomatch from "picomatch";
|
|
9
|
+
import { isScalar } from "yaml";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { Config, configSchema } from "./ase-config.js";
|
|
12
|
+
import { Task } from "./ase-task.js";
|
|
13
|
+
/* the recognized artifact kinds, in descending precedence order;
|
|
14
|
+
"othr" is the implicit catch-all and is always resolved last */
|
|
15
|
+
export const artifactKinds = ["spec", "arch", "code", "docs", "infr", "othr"];
|
|
16
|
+
/* the five configured kinds (i.e. all kinds except the implicit "othr") */
|
|
17
|
+
const configuredKinds = ["spec", "arch", "code", "docs", "infr"];
|
|
18
|
+
/* reusable functionality: resolve artifact kinds to project-relative
|
|
19
|
+
file lists, driven by the "project.artifact.<kind>" configuration */
|
|
20
|
+
export class Artifact {
|
|
21
|
+
/* validate a requested kind against the known set */
|
|
22
|
+
static validateKind(kind) {
|
|
23
|
+
if (!artifactKinds.includes(kind))
|
|
24
|
+
throw new Error(`artifact: unknown kind "${kind}" ` +
|
|
25
|
+
`(expected one of: ${artifactKinds.join(", ")})`);
|
|
26
|
+
return kind;
|
|
27
|
+
}
|
|
28
|
+
/* translate a single ".gitignore" line into a picomatch-backed rule,
|
|
29
|
+
honoring the anchored-vs-floating, directory-only, and negation
|
|
30
|
+
semantics of ".gitignore" patterns */
|
|
31
|
+
static compileIgnoreRule(line) {
|
|
32
|
+
let pattern = line.trim();
|
|
33
|
+
if (pattern === "" || pattern.startsWith("#"))
|
|
34
|
+
return null;
|
|
35
|
+
let negated = false;
|
|
36
|
+
if (pattern.startsWith("!")) {
|
|
37
|
+
negated = true;
|
|
38
|
+
pattern = pattern.slice(1);
|
|
39
|
+
}
|
|
40
|
+
let dirOnly = false;
|
|
41
|
+
if (pattern.endsWith("/")) {
|
|
42
|
+
dirOnly = true;
|
|
43
|
+
pattern = pattern.slice(0, -1);
|
|
44
|
+
}
|
|
45
|
+
const anchored = pattern.includes("/");
|
|
46
|
+
if (pattern.startsWith("/"))
|
|
47
|
+
pattern = pattern.slice(1);
|
|
48
|
+
const glob = anchored ? pattern : `**/${pattern}`;
|
|
49
|
+
const isMatch = picomatch(glob, { dot: true });
|
|
50
|
+
return { matcher: (p) => isMatch(p), negated, dirOnly };
|
|
51
|
+
}
|
|
52
|
+
/* load the ".gitignore" rules located directly in a directory */
|
|
53
|
+
static loadIgnoreRules(dir) {
|
|
54
|
+
const file = path.join(dir, ".gitignore");
|
|
55
|
+
if (!fs.existsSync(file))
|
|
56
|
+
return [];
|
|
57
|
+
const rules = [];
|
|
58
|
+
for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
|
|
59
|
+
const rule = Artifact.compileIgnoreRule(line);
|
|
60
|
+
if (rule !== null)
|
|
61
|
+
rules.push(rule);
|
|
62
|
+
}
|
|
63
|
+
return rules;
|
|
64
|
+
}
|
|
65
|
+
/* decide whether a project-relative path is ignored by the given
|
|
66
|
+
ordered ".gitignore" rule set (last matching rule wins) */
|
|
67
|
+
static isIgnored(rel, isDir, rules) {
|
|
68
|
+
let ignored = false;
|
|
69
|
+
for (const rule of rules) {
|
|
70
|
+
if (rule.dirOnly && !isDir)
|
|
71
|
+
continue;
|
|
72
|
+
if (rule.matcher(rel))
|
|
73
|
+
ignored = !rule.negated;
|
|
74
|
+
}
|
|
75
|
+
return ignored;
|
|
76
|
+
}
|
|
77
|
+
/* build the file universe by walking the project tree from the
|
|
78
|
+
project root, honoring ".gitignore" rules (root plus nested) and
|
|
79
|
+
always pruning ".git". Yields POSIX project-relative, sorted,
|
|
80
|
+
de-duplicated file paths */
|
|
81
|
+
static universe() {
|
|
82
|
+
const root = Task.projectRoot();
|
|
83
|
+
const files = new Set();
|
|
84
|
+
const walk = (dir, relDir, inherited) => {
|
|
85
|
+
const rules = [...inherited, ...Artifact.loadIgnoreRules(dir)];
|
|
86
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
87
|
+
if (entry.name === ".git")
|
|
88
|
+
continue;
|
|
89
|
+
const rel = relDir === "" ? entry.name : `${relDir}/${entry.name}`;
|
|
90
|
+
const isDir = entry.isDirectory();
|
|
91
|
+
if (Artifact.isIgnored(rel, isDir, rules))
|
|
92
|
+
continue;
|
|
93
|
+
if (isDir)
|
|
94
|
+
walk(path.join(dir, entry.name), rel, rules);
|
|
95
|
+
else if (entry.isFile())
|
|
96
|
+
files.add(rel);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
walk(root, "", []);
|
|
100
|
+
return [...files].sort((a, b) => a.localeCompare(b));
|
|
101
|
+
}
|
|
102
|
+
/* raw-resolve a single kind's configuration spec against the file
|
|
103
|
+
universe via "seed-then-mutate" miniglob semantics: the spec is a
|
|
104
|
+
whitespace-separated list of tokens. */
|
|
105
|
+
static rawResolve(spec, all) {
|
|
106
|
+
const tokens = spec.split(/\s+/).filter((t) => t !== "");
|
|
107
|
+
if (tokens.length === 0)
|
|
108
|
+
return new Set();
|
|
109
|
+
const result = new Set(tokens[0].startsWith("!") ? all : []);
|
|
110
|
+
for (const token of tokens) {
|
|
111
|
+
const negated = token.startsWith("!");
|
|
112
|
+
const glob = negated ? token.slice(1) : token;
|
|
113
|
+
if (glob === "")
|
|
114
|
+
continue;
|
|
115
|
+
const isMatch = picomatch(glob, { dot: true });
|
|
116
|
+
for (const file of all) {
|
|
117
|
+
if (!isMatch(file))
|
|
118
|
+
continue;
|
|
119
|
+
if (negated)
|
|
120
|
+
result.delete(file);
|
|
121
|
+
else
|
|
122
|
+
result.add(file);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
/* read a single scalar configuration value as a plain string */
|
|
128
|
+
static configString(cfg, key) {
|
|
129
|
+
const val = cfg.get(key);
|
|
130
|
+
if (val === undefined)
|
|
131
|
+
return "";
|
|
132
|
+
return String(isScalar(val) ? val.value : val);
|
|
133
|
+
}
|
|
134
|
+
/* read the configured "basedir" anchor and "files" miniglob spec
|
|
135
|
+
for a single kind; "basedir" is project-root-relative (POSIX,
|
|
136
|
+
"" ≡ project root) and "files" resolves relative to "basedir" */
|
|
137
|
+
static spec(log, kind) {
|
|
138
|
+
const cfg = new Config("config", configSchema, log);
|
|
139
|
+
cfg.read();
|
|
140
|
+
const basedir = Artifact.configString(cfg, `project.artifact.${kind}.basedir`)
|
|
141
|
+
.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "")
|
|
142
|
+
.replace(/^\.$/, "");
|
|
143
|
+
const files = Artifact.configString(cfg, `project.artifact.${kind}.files`);
|
|
144
|
+
return { basedir, files };
|
|
145
|
+
}
|
|
146
|
+
/* raw-resolve a single kind's "basedir"/"files" spec against the
|
|
147
|
+
file universe: the "files" miniglob resolves relative to
|
|
148
|
+
"basedir", then matches are re-prefixed with "basedir" to stay
|
|
149
|
+
project-relative */
|
|
150
|
+
static resolveKind(basedir, files, all) {
|
|
151
|
+
if (basedir === "")
|
|
152
|
+
return Artifact.rawResolve(files, all);
|
|
153
|
+
const prefix = `${basedir}/`;
|
|
154
|
+
const local = all
|
|
155
|
+
.filter((file) => file.startsWith(prefix))
|
|
156
|
+
.map((file) => file.slice(prefix.length));
|
|
157
|
+
const result = new Set();
|
|
158
|
+
for (const file of Artifact.rawResolve(files, local))
|
|
159
|
+
result.add(`${prefix}${file}`);
|
|
160
|
+
return result;
|
|
161
|
+
}
|
|
162
|
+
/* resolve the requested kinds to project-relative file lists */
|
|
163
|
+
static list(log, kinds) {
|
|
164
|
+
const all = Artifact.universe();
|
|
165
|
+
/* raw-resolve all five configured kinds */
|
|
166
|
+
const raw = new Map();
|
|
167
|
+
for (const kind of configuredKinds) {
|
|
168
|
+
const { basedir, files } = Artifact.spec(log, kind);
|
|
169
|
+
raw.set(kind, Artifact.resolveKind(basedir, files, all));
|
|
170
|
+
}
|
|
171
|
+
/* partition the universe by descending precedence */
|
|
172
|
+
const claimed = new Set();
|
|
173
|
+
const part = new Map();
|
|
174
|
+
for (const kind of configuredKinds) {
|
|
175
|
+
const own = [];
|
|
176
|
+
for (const file of raw.get(kind).size > 0 ? all : []) {
|
|
177
|
+
if (raw.get(kind).has(file) && !claimed.has(file)) {
|
|
178
|
+
own.push(file);
|
|
179
|
+
claimed.add(file);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
part.set(kind, own);
|
|
183
|
+
}
|
|
184
|
+
part.set("othr", all.filter((file) => !claimed.has(file)));
|
|
185
|
+
/* project onto the requested kinds */
|
|
186
|
+
return kinds.map((kind) => ({
|
|
187
|
+
kind,
|
|
188
|
+
files: part.get(kind) ?? []
|
|
189
|
+
}));
|
|
190
|
+
}
|
|
191
|
+
/* resolve a base-relative "filename" within a kind's "basedir" to a
|
|
192
|
+
project-root-relative POSIX path; the implicit "othr" catch-all
|
|
193
|
+
has no configured "basedir" and is therefore rejected */
|
|
194
|
+
static name(log, kind, filename) {
|
|
195
|
+
if (kind === "othr")
|
|
196
|
+
throw new Error("artifact: kind \"othr\" has no configured basedir");
|
|
197
|
+
const file = filename.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
198
|
+
if (file === "")
|
|
199
|
+
throw new Error("artifact: filename must not be empty");
|
|
200
|
+
const { basedir } = Artifact.spec(log, kind);
|
|
201
|
+
return basedir === "" ? file : `${basedir}/${file}`;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/* CLI command "ase artifact" */
|
|
205
|
+
export default class ArtifactCommand {
|
|
206
|
+
log;
|
|
207
|
+
constructor(log) {
|
|
208
|
+
this.log = log;
|
|
209
|
+
}
|
|
210
|
+
/* register commands */
|
|
211
|
+
register(program) {
|
|
212
|
+
/* register CLI top-level command "ase artifact" */
|
|
213
|
+
const artifact = program
|
|
214
|
+
.command("artifact")
|
|
215
|
+
.description("Resolve project artifact kinds to project-relative file lists")
|
|
216
|
+
.action(() => {
|
|
217
|
+
artifact.outputHelp();
|
|
218
|
+
process.exit(1);
|
|
219
|
+
});
|
|
220
|
+
/* register CLI sub-command "ase artifact list" */
|
|
221
|
+
artifact
|
|
222
|
+
.command("list")
|
|
223
|
+
.description("Resolve one or more artifact kinds to project-relative file paths")
|
|
224
|
+
.option("--kind <kinds>", "comma-separated list of artifact kinds " +
|
|
225
|
+
`(${artifactKinds.join("|")})`, artifactKinds.join(","))
|
|
226
|
+
.action((opts) => {
|
|
227
|
+
const kinds = opts.kind.split(",").map((k) => Artifact.validateKind(k.trim()));
|
|
228
|
+
const result = Artifact.list(this.log, kinds);
|
|
229
|
+
const single = result.length === 1;
|
|
230
|
+
for (const { kind, files } of result) {
|
|
231
|
+
if (!single)
|
|
232
|
+
process.stdout.write(`# ${kind}:\n`);
|
|
233
|
+
for (const file of files)
|
|
234
|
+
process.stdout.write(`- ${file}\n`);
|
|
235
|
+
}
|
|
236
|
+
process.exit(0);
|
|
237
|
+
});
|
|
238
|
+
/* register CLI sub-command "ase artifact name" */
|
|
239
|
+
artifact
|
|
240
|
+
.command("name")
|
|
241
|
+
.description("Resolve a base-relative filename within an artifact kind to a project-relative path")
|
|
242
|
+
.argument("<filename>", "base-relative filename within the kind's basedir")
|
|
243
|
+
.option("--kind <kind>", "artifact kind " +
|
|
244
|
+
`(${configuredKinds.join("|")})`, "code")
|
|
245
|
+
.action((filename, opts) => {
|
|
246
|
+
const kind = Artifact.validateKind(opts.kind.trim());
|
|
247
|
+
process.stdout.write(`${Artifact.name(this.log, kind, filename)}\n`);
|
|
248
|
+
process.exit(0);
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/* MCP registration entry point for artifact tools */
|
|
253
|
+
export class ArtifactMCP {
|
|
254
|
+
log;
|
|
255
|
+
constructor(log) {
|
|
256
|
+
this.log = log;
|
|
257
|
+
}
|
|
258
|
+
/* register MCP tools */
|
|
259
|
+
register(mcp) {
|
|
260
|
+
mcp.registerTool("ase_artifact_list", {
|
|
261
|
+
title: "ASE artifact list",
|
|
262
|
+
description: "Resolve one or more artifact `kind`s to project-relative file lists. " +
|
|
263
|
+
"Recognized kinds are `spec`, `arch`, `code`, `docs`, `infr`, and `othr`. " +
|
|
264
|
+
"Returns an `artifacts` array of `{ kind, files }` objects. " +
|
|
265
|
+
"If `kind` is omitted or empty, all kinds are resolved.",
|
|
266
|
+
inputSchema: {
|
|
267
|
+
kind: z.array(z.string()).optional()
|
|
268
|
+
.describe("list of artifact kinds (`spec`, `arch`, `code`, `docs`, `infr`, " +
|
|
269
|
+
"`othr`); if omitted or empty, all kinds are resolved")
|
|
270
|
+
},
|
|
271
|
+
outputSchema: {
|
|
272
|
+
artifacts: z.array(z.object({
|
|
273
|
+
kind: z.string().describe("artifact kind"),
|
|
274
|
+
files: z.array(z.string()).describe("project-relative file paths for the kind")
|
|
275
|
+
})).describe("resolved artifacts, one entry per requested kind")
|
|
276
|
+
}
|
|
277
|
+
}, async (args) => {
|
|
278
|
+
try {
|
|
279
|
+
const requested = args.kind !== undefined && args.kind.length > 0 ?
|
|
280
|
+
args.kind : [...artifactKinds];
|
|
281
|
+
const kinds = requested.map((k) => Artifact.validateKind(k));
|
|
282
|
+
const result = { artifacts: Artifact.list(this.log, kinds) };
|
|
283
|
+
return {
|
|
284
|
+
structuredContent: result,
|
|
285
|
+
content: [{ type: "text", text: JSON.stringify(result) }]
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
catch (err) {
|
|
289
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
290
|
+
return {
|
|
291
|
+
isError: true,
|
|
292
|
+
content: [{ type: "text", text: `ERROR: ${message}` }]
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
mcp.registerTool("ase_artifact_name", {
|
|
297
|
+
title: "ASE artifact name",
|
|
298
|
+
description: "Resolve a base-relative `filename` within an artifact `kind` to a project-relative path " +
|
|
299
|
+
"by prefixing it with the kind's configured `basedir`. " +
|
|
300
|
+
"Recognized kinds are `spec`, `arch`, `code`, `docs`, and `infr` " +
|
|
301
|
+
"(the implicit `othr` catch-all has no basedir and is rejected). " +
|
|
302
|
+
"If `kind` is omitted, it defaults to `code`. " +
|
|
303
|
+
"Returns the resolved path as `name`.",
|
|
304
|
+
inputSchema: {
|
|
305
|
+
kind: z.string().optional()
|
|
306
|
+
.describe("artifact kind (`spec`, `arch`, `code`, `docs`, `infr`); defaults to `code`"),
|
|
307
|
+
filename: z.string()
|
|
308
|
+
.describe("base-relative filename within the kind's basedir")
|
|
309
|
+
},
|
|
310
|
+
outputSchema: {
|
|
311
|
+
name: z.string().describe("project-relative file path")
|
|
312
|
+
}
|
|
313
|
+
}, async (args) => {
|
|
314
|
+
try {
|
|
315
|
+
const kind = Artifact.validateKind(args.kind ?? "code");
|
|
316
|
+
const result = { name: Artifact.name(this.log, kind, args.filename) };
|
|
317
|
+
return {
|
|
318
|
+
structuredContent: result,
|
|
319
|
+
content: [{ type: "text", text: JSON.stringify(result) }]
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
catch (err) {
|
|
323
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
324
|
+
return {
|
|
325
|
+
isError: true,
|
|
326
|
+
content: [{ type: "text", text: `ERROR: ${message}` }]
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
package/dst/ase-config.js
CHANGED
|
@@ -25,29 +25,41 @@ export const agentClassification = {
|
|
|
25
25
|
/* classification presets */
|
|
26
26
|
export const projectClassificationPresets = {
|
|
27
27
|
vibe: {
|
|
28
|
+
"agent.persona": "writer",
|
|
28
29
|
"project.id": "example",
|
|
29
30
|
"project.name": "Example Project",
|
|
30
|
-
"project.boxing": "black"
|
|
31
|
-
"agent.persona": "writer"
|
|
31
|
+
"project.boxing": "black"
|
|
32
32
|
},
|
|
33
33
|
pro: {
|
|
34
|
+
"agent.persona": "engineer",
|
|
34
35
|
"project.id": "example",
|
|
35
36
|
"project.name": "Example Project",
|
|
36
|
-
"project.boxing": "white"
|
|
37
|
-
"agent.persona": "engineer"
|
|
37
|
+
"project.boxing": "white"
|
|
38
38
|
},
|
|
39
39
|
default: {
|
|
40
|
+
"agent.task": "default",
|
|
41
|
+
"agent.persona": "engineer",
|
|
40
42
|
"project.id": "example",
|
|
41
43
|
"project.name": "Example Project",
|
|
42
44
|
"project.boxing": "white",
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
+
"project.artifact.task.basedir": ".ase/task",
|
|
46
|
+
"project.artifact.task.files": "*.md",
|
|
47
|
+
"project.artifact.spec.basedir": "doc/spec",
|
|
48
|
+
"project.artifact.spec.files": "*.{md,txt}",
|
|
49
|
+
"project.artifact.arch.basedir": "doc/arch",
|
|
50
|
+
"project.artifact.arch.files": "*.{md,txt}",
|
|
51
|
+
"project.artifact.code.basedir": "src",
|
|
52
|
+
"project.artifact.code.files": "** !**/etc/** !**/{.gitignore,.npmignore,package.json}",
|
|
53
|
+
"project.artifact.docs.basedir": "doc",
|
|
54
|
+
"project.artifact.docs.files": "** **/{README,LICENSE,CHANGELOG}.{md,txt} !{spec,arch}/**",
|
|
55
|
+
"project.artifact.infr.basedir": "",
|
|
56
|
+
"project.artifact.infr.files": "**/{.github,.claude*,etc}/** **/{AGENTS.md,{package,tsconfig*}.json,.{git,npm}ignore}"
|
|
45
57
|
},
|
|
46
58
|
industry: {
|
|
59
|
+
"agent.persona": "engineer",
|
|
47
60
|
"project.id": "example",
|
|
48
61
|
"project.name": "Example Project",
|
|
49
|
-
"project.boxing": "grey"
|
|
50
|
-
"agent.persona": "engineer"
|
|
62
|
+
"project.boxing": "grey"
|
|
51
63
|
}
|
|
52
64
|
};
|
|
53
65
|
/* hard-coded map: which scope kinds each variable may be SET on
|
|
@@ -55,7 +67,19 @@ export const projectClassificationPresets = {
|
|
|
55
67
|
keys absent from this map default to all non-"default" scope kinds */
|
|
56
68
|
export const configWritableScopes = {
|
|
57
69
|
"agent.task": ["session"],
|
|
58
|
-
"agent.skill": ["session"]
|
|
70
|
+
"agent.skill": ["session"],
|
|
71
|
+
"project.artifact.task.basedir": ["user", "project"],
|
|
72
|
+
"project.artifact.task.files": ["user", "project"],
|
|
73
|
+
"project.artifact.spec.basedir": ["user", "project"],
|
|
74
|
+
"project.artifact.spec.files": ["user", "project"],
|
|
75
|
+
"project.artifact.arch.basedir": ["user", "project"],
|
|
76
|
+
"project.artifact.arch.files": ["user", "project"],
|
|
77
|
+
"project.artifact.code.basedir": ["user", "project"],
|
|
78
|
+
"project.artifact.code.files": ["user", "project"],
|
|
79
|
+
"project.artifact.docs.basedir": ["user", "project"],
|
|
80
|
+
"project.artifact.docs.files": ["user", "project"],
|
|
81
|
+
"project.artifact.infr.basedir": ["user", "project"],
|
|
82
|
+
"project.artifact.infr.files": ["user", "project"]
|
|
59
83
|
};
|
|
60
84
|
/* default set of scope kinds writable for any unrestricted key */
|
|
61
85
|
const configWritableScopesDefault = ["user", "project", "task", "session"];
|
|
@@ -130,7 +154,15 @@ export const configSchema = v.nullish(v.strictObject({
|
|
|
130
154
|
project: v.optional(v.strictObject({
|
|
131
155
|
id: v.optional(v.pipe(v.string(), v.minLength(1))),
|
|
132
156
|
name: v.optional(v.pipe(v.string(), v.minLength(1))),
|
|
133
|
-
boxing: v.optional(v.picklist(projectClassification.boxing))
|
|
157
|
+
boxing: v.optional(v.picklist(projectClassification.boxing)),
|
|
158
|
+
artifact: v.optional(v.strictObject({
|
|
159
|
+
spec: v.optional(v.strictObject({ basedir: v.optional(v.string()), files: v.optional(v.string()) })),
|
|
160
|
+
arch: v.optional(v.strictObject({ basedir: v.optional(v.string()), files: v.optional(v.string()) })),
|
|
161
|
+
code: v.optional(v.strictObject({ basedir: v.optional(v.string()), files: v.optional(v.string()) })),
|
|
162
|
+
docs: v.optional(v.strictObject({ basedir: v.optional(v.string()), files: v.optional(v.string()) })),
|
|
163
|
+
infr: v.optional(v.strictObject({ basedir: v.optional(v.string()), files: v.optional(v.string()) })),
|
|
164
|
+
task: v.optional(v.strictObject({ basedir: v.optional(v.string()), files: v.optional(v.string()) }))
|
|
165
|
+
}))
|
|
134
166
|
})),
|
|
135
167
|
agent: v.optional(v.strictObject({
|
|
136
168
|
persona: v.optional(v.picklist(agentClassification.persona)),
|
|
@@ -614,6 +646,20 @@ export default class ConfigCommand {
|
|
|
614
646
|
cfg.write();
|
|
615
647
|
});
|
|
616
648
|
});
|
|
649
|
+
/* register CLI sub-command "ase config delete" */
|
|
650
|
+
configCmd
|
|
651
|
+
.command("delete")
|
|
652
|
+
.description("delete the value at a dotted configuration key")
|
|
653
|
+
.argument("<key>", "configuration key (dotted path)")
|
|
654
|
+
.action((key, _opts, cmd) => {
|
|
655
|
+
const scope = parseScope(cmd.optsWithGlobals().scope);
|
|
656
|
+
const cfg = new Config("config", configSchema, this.log, scope);
|
|
657
|
+
cfg.lock(() => {
|
|
658
|
+
cfg.read();
|
|
659
|
+
cfg.delete(key);
|
|
660
|
+
cfg.write();
|
|
661
|
+
});
|
|
662
|
+
});
|
|
617
663
|
}
|
|
618
664
|
}
|
|
619
665
|
/* MCP registration entry point for layered YAML configuration access */
|
package/dst/ase-service.js
CHANGED
|
@@ -18,6 +18,7 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
|
|
|
18
18
|
import { Config, configSchema, ConfigMCP } from "./ase-config.js";
|
|
19
19
|
import { DiagramMCP } from "./ase-diagram.js";
|
|
20
20
|
import { TaskMCP } from "./ase-task.js";
|
|
21
|
+
import { ArtifactMCP } from "./ase-artifact.js";
|
|
21
22
|
import { KVMCP } from "./ase-kv.js";
|
|
22
23
|
import PersonaMCP from "./ase-persona.js";
|
|
23
24
|
import { TimestampMCP } from "./ase-timestamp.js";
|
|
@@ -236,6 +237,7 @@ export default class ServiceCommand {
|
|
|
236
237
|
new ServiceMCP({ projectId: ctx.projectId, port: ctx.port, startTime }).register(mcp);
|
|
237
238
|
new DiagramMCP().register(mcp);
|
|
238
239
|
new TaskMCP(this.log).register(mcp);
|
|
240
|
+
new ArtifactMCP(this.log).register(mcp);
|
|
239
241
|
new KVMCP().register(mcp);
|
|
240
242
|
new PersonaMCP(this.log).register(mcp);
|
|
241
243
|
new TimestampMCP().register(mcp);
|
package/dst/ase-task.js
CHANGED
|
@@ -7,11 +7,13 @@ import path from "node:path";
|
|
|
7
7
|
import fs from "node:fs";
|
|
8
8
|
import { execaSync } from "execa";
|
|
9
9
|
import { DateTime } from "luxon";
|
|
10
|
+
import picomatch from "picomatch";
|
|
10
11
|
import { isScalar } from "yaml";
|
|
11
12
|
import { z } from "zod";
|
|
12
13
|
import { Config, configSchema, parseScope } from "./ase-config.js";
|
|
13
14
|
/* reusable functionality: persisted task plans under
|
|
14
|
-
<project
|
|
15
|
+
<project>/<basedir>/TASK-<id>.md (driven by the
|
|
16
|
+
"project.artifact.task.{basedir,files}" configuration) */
|
|
15
17
|
export class Task {
|
|
16
18
|
/* validate the task id to keep it safe as a filename component */
|
|
17
19
|
static validateId(id) {
|
|
@@ -34,100 +36,173 @@ export class Task {
|
|
|
34
36
|
}
|
|
35
37
|
return process.cwd();
|
|
36
38
|
}
|
|
39
|
+
/* read the configured "basedir" anchor and "files" miniglob spec for
|
|
40
|
+
task storage; "basedir" is project-root-relative (POSIX, defaults
|
|
41
|
+
to ".ase/task") and "files" constrains the task filenames
|
|
42
|
+
(defaults to "*.md") */
|
|
43
|
+
static spec(log) {
|
|
44
|
+
const cfg = new Config("config", configSchema, log);
|
|
45
|
+
cfg.read();
|
|
46
|
+
const read = (key) => {
|
|
47
|
+
const val = cfg.get(key);
|
|
48
|
+
if (val === undefined)
|
|
49
|
+
return "";
|
|
50
|
+
return String(isScalar(val) ? val.value : val);
|
|
51
|
+
};
|
|
52
|
+
const basedir = (read("project.artifact.task.basedir") || ".ase/task")
|
|
53
|
+
.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
|
54
|
+
const files = read("project.artifact.task.files") || "*.md";
|
|
55
|
+
return { basedir, files };
|
|
56
|
+
}
|
|
37
57
|
/* resolve the on-disk base directory for task storage */
|
|
38
|
-
static baseDir() {
|
|
39
|
-
return path.join(Task.projectRoot(),
|
|
58
|
+
static baseDir(log) {
|
|
59
|
+
return path.join(Task.projectRoot(), Task.spec(log).basedir);
|
|
40
60
|
}
|
|
41
|
-
/* resolve the on-disk path for a given task id
|
|
42
|
-
|
|
61
|
+
/* resolve the on-disk path for a given task id; as a side effect,
|
|
62
|
+
eagerly migrate any legacy <basedir>/<id>/plan.md files to the
|
|
63
|
+
current <basedir>/TASK-<id>.md layout on first access (guarded by
|
|
64
|
+
a cheap check, so it is a no-op once the store is migrated) */
|
|
65
|
+
static path(log, id) {
|
|
43
66
|
Task.validateId(id);
|
|
44
|
-
|
|
67
|
+
if (Task.needsMigration(log))
|
|
68
|
+
Task.migrateAll(log);
|
|
69
|
+
return path.join(Task.baseDir(log), `TASK-${id}.md`);
|
|
70
|
+
}
|
|
71
|
+
/* cheaply check whether any legacy <basedir>/<id>/plan.md file still
|
|
72
|
+
exists in the task base directory and thus needs migration */
|
|
73
|
+
static needsMigration(log) {
|
|
74
|
+
const dir = Task.baseDir(log);
|
|
75
|
+
if (!fs.existsSync(dir))
|
|
76
|
+
return false;
|
|
77
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
78
|
+
if (!entry.isDirectory() || !/^[A-Za-z0-9-]+$/.test(entry.name))
|
|
79
|
+
continue;
|
|
80
|
+
if (fs.existsSync(path.join(dir, entry.name, "plan.md")))
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
/* migrate all legacy <basedir>/<id>/plan.md task files to the current
|
|
86
|
+
<basedir>/TASK-<id>.md layout; an existing TASK-<id>.md is never
|
|
87
|
+
overwritten; the now-empty <id>/ directory is removed afterwards;
|
|
88
|
+
returns the list of migrated task ids in lexicographic order */
|
|
89
|
+
static migrateAll(log) {
|
|
90
|
+
const dir = Task.baseDir(log);
|
|
91
|
+
if (!fs.existsSync(dir))
|
|
92
|
+
return [];
|
|
93
|
+
const migrated = [];
|
|
94
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
95
|
+
if (!entry.isDirectory() || !/^[A-Za-z0-9-]+$/.test(entry.name))
|
|
96
|
+
continue;
|
|
97
|
+
const id = entry.name;
|
|
98
|
+
const oldFile = path.join(dir, id, "plan.md");
|
|
99
|
+
const newFile = path.join(dir, `TASK-${id}.md`);
|
|
100
|
+
if (!fs.existsSync(oldFile))
|
|
101
|
+
continue;
|
|
102
|
+
if (fs.existsSync(newFile)) {
|
|
103
|
+
log.write("warning", `task: not migrating "${id}": target "TASK-${id}.md" already exists`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
fs.renameSync(oldFile, newFile);
|
|
107
|
+
fs.rmSync(path.join(dir, id), { recursive: true, force: true });
|
|
108
|
+
migrated.push(id);
|
|
109
|
+
}
|
|
110
|
+
migrated.sort((a, b) => a.localeCompare(b));
|
|
111
|
+
return migrated;
|
|
45
112
|
}
|
|
46
113
|
/* load a task; returns empty string if no task exists */
|
|
47
|
-
static load(id) {
|
|
48
|
-
const file = Task.path(id);
|
|
114
|
+
static load(log, id) {
|
|
115
|
+
const file = Task.path(log, id);
|
|
49
116
|
if (!fs.existsSync(file))
|
|
50
117
|
return "";
|
|
51
118
|
return fs.readFileSync(file, "utf8");
|
|
52
119
|
}
|
|
53
|
-
/* save a task as UTF-8 text under the given id
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
static save(id, text) {
|
|
120
|
+
/* save a task as UTF-8 text under the given id into the
|
|
121
|
+
<project>/<basedir>/TASK-<id>.md file */
|
|
122
|
+
static save(log, id, text) {
|
|
57
123
|
if (typeof text !== "string")
|
|
58
124
|
throw new Error("task: text must be a string");
|
|
59
|
-
const file = Task.path(id);
|
|
125
|
+
const file = Task.path(log, id);
|
|
60
126
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
61
127
|
fs.writeFileSync(file, text, "utf8");
|
|
62
128
|
}
|
|
63
|
-
/* delete a task by id; removes the
|
|
64
|
-
<project
|
|
65
|
-
static delete(id) {
|
|
66
|
-
const file = Task.path(id);
|
|
129
|
+
/* delete a task by id; removes the single
|
|
130
|
+
<project>/<basedir>/TASK-<id>.md file; returns true if a task existed */
|
|
131
|
+
static delete(log, id) {
|
|
132
|
+
const file = Task.path(log, id);
|
|
67
133
|
if (!fs.existsSync(file))
|
|
68
134
|
return false;
|
|
69
|
-
fs.rmSync(
|
|
135
|
+
fs.rmSync(file, { force: true });
|
|
70
136
|
return true;
|
|
71
137
|
}
|
|
72
|
-
/* rename a task by moving
|
|
73
|
-
<project
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
138
|
+
/* rename a task by moving its <project>/<basedir>/TASK-<oldId>.md file
|
|
139
|
+
to <project>/<basedir>/TASK-<newId>.md; the embedded
|
|
140
|
+
"# ✪ TASK <id>:" heading inside the plan content is rewritten to
|
|
141
|
+
the new id; returns true on success, false if the source task does
|
|
142
|
+
not exist; throws if the target id already exists */
|
|
143
|
+
static rename(log, oldId, newId) {
|
|
144
|
+
const oldFile = Task.path(log, oldId);
|
|
145
|
+
const newFile = Task.path(log, newId);
|
|
146
|
+
if (!fs.existsSync(oldFile))
|
|
80
147
|
return false;
|
|
81
|
-
if (fs.existsSync(
|
|
148
|
+
if (fs.existsSync(newFile))
|
|
82
149
|
throw new Error(`task: target id "${newId}" already exists`);
|
|
83
|
-
fs.
|
|
84
|
-
|
|
150
|
+
const text = fs.readFileSync(oldFile, "utf8");
|
|
151
|
+
const updated = text.replace(/(^#\s+(?:✪\s+)?TASK\s+)[A-Za-z0-9-]+(\s*:)/m, `$1${newId}$2`);
|
|
152
|
+
fs.mkdirSync(path.dirname(newFile), { recursive: true });
|
|
153
|
+
fs.writeFileSync(newFile, updated, "utf8");
|
|
154
|
+
fs.rmSync(oldFile, { force: true });
|
|
85
155
|
return true;
|
|
86
156
|
}
|
|
87
157
|
/* list all persisted tasks in lexicographic id order; if verbose is true,
|
|
88
|
-
each entry's `mtime` is set to the
|
|
89
|
-
as "YYYY-MM-DD HH:MM", otherwise it is left undefined */
|
|
90
|
-
static list(verbose = false) {
|
|
91
|
-
|
|
158
|
+
each entry's `mtime` is set to the task file's modification time
|
|
159
|
+
formatted as "YYYY-MM-DD HH:MM", otherwise it is left undefined */
|
|
160
|
+
static list(log, verbose = false) {
|
|
161
|
+
if (Task.needsMigration(log))
|
|
162
|
+
Task.migrateAll(log);
|
|
163
|
+
const { basedir, files } = Task.spec(log);
|
|
164
|
+
const dir = path.join(Task.projectRoot(), basedir);
|
|
92
165
|
if (!fs.existsSync(dir))
|
|
93
166
|
return [];
|
|
167
|
+
const isMatch = picomatch(files, { dot: true });
|
|
94
168
|
const out = [];
|
|
95
169
|
for (const entry of fs.readdirSync(dir)) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const file = path.join(dir, entry, "plan.md");
|
|
99
|
-
if (!fs.existsSync(file))
|
|
170
|
+
const m = /^TASK-([A-Za-z0-9-]+)\.md$/.exec(entry);
|
|
171
|
+
if (m === null || !isMatch(entry))
|
|
100
172
|
continue;
|
|
173
|
+
const file = path.join(dir, entry);
|
|
101
174
|
const st = fs.statSync(file);
|
|
102
175
|
if (!st.isFile())
|
|
103
176
|
continue;
|
|
104
177
|
const mtime = verbose ? DateTime.fromJSDate(st.mtime).toFormat("yyyy-LL-dd HH:mm") : undefined;
|
|
105
|
-
out.push({ id:
|
|
178
|
+
out.push({ id: m[1], mtime });
|
|
106
179
|
}
|
|
107
180
|
out.sort((a, b) => a.id.localeCompare(b.id));
|
|
108
181
|
return out;
|
|
109
182
|
}
|
|
110
183
|
/* purge tasks whose modification time is older than the given cutoff in
|
|
111
184
|
milliseconds; returns the list of removed task ids */
|
|
112
|
-
static purge(maxAgeMs) {
|
|
113
|
-
|
|
185
|
+
static purge(log, maxAgeMs) {
|
|
186
|
+
if (Task.needsMigration(log))
|
|
187
|
+
Task.migrateAll(log);
|
|
188
|
+
const { basedir, files } = Task.spec(log);
|
|
189
|
+
const dir = path.join(Task.projectRoot(), basedir);
|
|
114
190
|
if (!fs.existsSync(dir))
|
|
115
191
|
return [];
|
|
192
|
+
const isMatch = picomatch(files, { dot: true });
|
|
116
193
|
const cutoff = Date.now() - maxAgeMs;
|
|
117
194
|
const removed = [];
|
|
118
195
|
for (const entry of fs.readdirSync(dir)) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const sub = path.join(dir, entry);
|
|
122
|
-
const file = path.join(sub, "plan.md");
|
|
123
|
-
if (!fs.existsSync(file))
|
|
196
|
+
const m = /^TASK-([A-Za-z0-9-]+)\.md$/.exec(entry);
|
|
197
|
+
if (m === null || !isMatch(entry))
|
|
124
198
|
continue;
|
|
199
|
+
const file = path.join(dir, entry);
|
|
125
200
|
const st = fs.statSync(file);
|
|
126
201
|
if (!st.isFile())
|
|
127
202
|
continue;
|
|
128
203
|
if (st.mtimeMs < cutoff) {
|
|
129
|
-
fs.rmSync(
|
|
130
|
-
removed.push(
|
|
204
|
+
fs.rmSync(file, { force: true });
|
|
205
|
+
removed.push(m[1]);
|
|
131
206
|
}
|
|
132
207
|
}
|
|
133
208
|
return removed;
|
|
@@ -173,7 +248,7 @@ export default class TaskCommand {
|
|
|
173
248
|
/* register CLI top-level command "ase task" */
|
|
174
249
|
const task = program
|
|
175
250
|
.command("task")
|
|
176
|
-
.description("Manage persisted tasks under <project
|
|
251
|
+
.description("Manage persisted tasks under <project>/<basedir>/TASK-<id>.md")
|
|
177
252
|
.action(() => {
|
|
178
253
|
task.outputHelp();
|
|
179
254
|
process.exit(1);
|
|
@@ -182,9 +257,9 @@ export default class TaskCommand {
|
|
|
182
257
|
task
|
|
183
258
|
.command("list")
|
|
184
259
|
.description("List all persisted task ids, one per line")
|
|
185
|
-
.option("-v, --verbose", "also show the
|
|
260
|
+
.option("-v, --verbose", "also show the task file modification time as (YYYY-MM-DD HH:MM)")
|
|
186
261
|
.action((opts) => {
|
|
187
|
-
const items = Task.list(opts.verbose ?? false);
|
|
262
|
+
const items = Task.list(this.log, opts.verbose ?? false);
|
|
188
263
|
for (const item of items) {
|
|
189
264
|
if (opts.verbose)
|
|
190
265
|
process.stdout.write(`${item.id}\t(${item.mtime})\n`);
|
|
@@ -199,7 +274,7 @@ export default class TaskCommand {
|
|
|
199
274
|
.description("Load a task by id and write it to stdout")
|
|
200
275
|
.argument("<id>", "Task identifier")
|
|
201
276
|
.action((id) => {
|
|
202
|
-
const text = Task.load(id);
|
|
277
|
+
const text = Task.load(this.log, id);
|
|
203
278
|
process.stdout.write(text);
|
|
204
279
|
process.exit(0);
|
|
205
280
|
});
|
|
@@ -209,7 +284,7 @@ export default class TaskCommand {
|
|
|
209
284
|
.description("Edit a task by id with $EDITOR")
|
|
210
285
|
.argument("<id>", "Task identifier")
|
|
211
286
|
.action((id) => {
|
|
212
|
-
const file = Task.path(id);
|
|
287
|
+
const file = Task.path(this.log, id);
|
|
213
288
|
const editor = process.env.EDITOR ?? process.env.VISUAL ?? "vi";
|
|
214
289
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
215
290
|
if (!fs.existsSync(file))
|
|
@@ -225,7 +300,7 @@ export default class TaskCommand {
|
|
|
225
300
|
.argument("<id>", "Task identifier")
|
|
226
301
|
.action(async (id) => {
|
|
227
302
|
const text = await readStdin();
|
|
228
|
-
Task.save(id, text);
|
|
303
|
+
Task.save(this.log, id, text);
|
|
229
304
|
this.log.write("info", `task: saved "${id}"`);
|
|
230
305
|
process.exit(0);
|
|
231
306
|
});
|
|
@@ -235,7 +310,7 @@ export default class TaskCommand {
|
|
|
235
310
|
.description("Delete a task by id")
|
|
236
311
|
.argument("<id>", "Task identifier")
|
|
237
312
|
.action((id) => {
|
|
238
|
-
const removed = Task.delete(id);
|
|
313
|
+
const removed = Task.delete(this.log, id);
|
|
239
314
|
if (removed)
|
|
240
315
|
this.log.write("info", `task: removed "${id}"`);
|
|
241
316
|
else
|
|
@@ -249,7 +324,7 @@ export default class TaskCommand {
|
|
|
249
324
|
.argument("<old>", "Old task identifier")
|
|
250
325
|
.argument("<new>", "New task identifier")
|
|
251
326
|
.action((oldId, newId) => {
|
|
252
|
-
const renamed = Task.rename(oldId, newId);
|
|
327
|
+
const renamed = Task.rename(this.log, oldId, newId);
|
|
253
328
|
if (renamed)
|
|
254
329
|
this.log.write("info", `task: renamed "${oldId}" to "${newId}"`);
|
|
255
330
|
else
|
|
@@ -276,7 +351,7 @@ export default class TaskCommand {
|
|
|
276
351
|
unit === "d" ? day :
|
|
277
352
|
unit === "m" ? month :
|
|
278
353
|
year;
|
|
279
|
-
const removed = Task.purge(n * factor);
|
|
354
|
+
const removed = Task.purge(this.log, n * factor);
|
|
280
355
|
if (removed.length === 0)
|
|
281
356
|
this.log.write("info", "task: no tasks to purge");
|
|
282
357
|
else
|
|
@@ -300,7 +375,7 @@ export class TaskMCP {
|
|
|
300
375
|
description: "List all persisted tasks. " +
|
|
301
376
|
"Returns a `tasks` array (in lexicographic `id` order) where each item has the " +
|
|
302
377
|
"task `id`. If `verbose` is `true`, each item additionally has an `mtime` field " +
|
|
303
|
-
"(last modification time of the task's `
|
|
378
|
+
"(last modification time of the task's `TASK-<id>.md` file, formatted as `YYYY-MM-DD HH:MM`). " +
|
|
304
379
|
"Returns an empty array if no tasks exist.",
|
|
305
380
|
inputSchema: {
|
|
306
381
|
verbose: z.boolean().optional()
|
|
@@ -310,13 +385,13 @@ export class TaskMCP {
|
|
|
310
385
|
tasks: z.array(z.object({
|
|
311
386
|
id: z.string().describe("task identifier"),
|
|
312
387
|
mtime: z.string().optional()
|
|
313
|
-
.describe("
|
|
388
|
+
.describe("`TASK-<id>.md` modification time (`YYYY-MM-DD HH:MM`); only present if `verbose` is true")
|
|
314
389
|
})).describe("all persisted tasks in lexicographic id order")
|
|
315
390
|
}
|
|
316
391
|
}, async (args) => {
|
|
317
392
|
try {
|
|
318
393
|
const verbose = args.verbose ?? false;
|
|
319
|
-
const items = Task.list(verbose);
|
|
394
|
+
const items = Task.list(this.log, verbose);
|
|
320
395
|
const tasks = verbose ?
|
|
321
396
|
items.map((item) => ({ id: item.id, mtime: item.mtime ?? "" })) :
|
|
322
397
|
items.map((item) => ({ id: item.id }));
|
|
@@ -345,7 +420,7 @@ export class TaskMCP {
|
|
|
345
420
|
}
|
|
346
421
|
}, async (args) => {
|
|
347
422
|
try {
|
|
348
|
-
const text = Task.load(args.id);
|
|
423
|
+
const text = Task.load(this.log, args.id);
|
|
349
424
|
return {
|
|
350
425
|
content: [{ type: "text", text }]
|
|
351
426
|
};
|
|
@@ -371,7 +446,7 @@ export class TaskMCP {
|
|
|
371
446
|
}
|
|
372
447
|
}, async (args) => {
|
|
373
448
|
try {
|
|
374
|
-
Task.save(args.id, args.text);
|
|
449
|
+
Task.save(this.log, args.id, args.text);
|
|
375
450
|
return {
|
|
376
451
|
content: [{ type: "text", text: `task_save: OK: saved task "${args.id}"` }]
|
|
377
452
|
};
|
|
@@ -395,7 +470,7 @@ export class TaskMCP {
|
|
|
395
470
|
}
|
|
396
471
|
}, async (args) => {
|
|
397
472
|
try {
|
|
398
|
-
const removed = Task.delete(args.id);
|
|
473
|
+
const removed = Task.delete(this.log, args.id);
|
|
399
474
|
const msg = removed ?
|
|
400
475
|
`task_delete: OK: removed task "${args.id}"` :
|
|
401
476
|
`task_delete: WARNING: no task "${args.id}" to remove`;
|
|
@@ -415,7 +490,7 @@ export class TaskMCP {
|
|
|
415
490
|
mcp.registerTool("ase_task_rename", {
|
|
416
491
|
title: "ASE task rename",
|
|
417
492
|
description: "Rename a previously persisted task from `old` to `new` by atomically moving the " +
|
|
418
|
-
"task
|
|
493
|
+
"task `TASK-<id>.md` file. Returns a status `text` indicating whether the rename succeeded. " +
|
|
419
494
|
"Fails with an error if the target id already exists.",
|
|
420
495
|
inputSchema: {
|
|
421
496
|
old: z.string()
|
|
@@ -425,7 +500,7 @@ export class TaskMCP {
|
|
|
425
500
|
}
|
|
426
501
|
}, async (args) => {
|
|
427
502
|
try {
|
|
428
|
-
const renamed = Task.rename(args.old, args.new);
|
|
503
|
+
const renamed = Task.rename(this.log, args.old, args.new);
|
|
429
504
|
const msg = renamed ?
|
|
430
505
|
`task_rename: OK: renamed task "${args.old}" to "${args.new}"` :
|
|
431
506
|
`task_rename: WARNING: no task "${args.old}" to rename`;
|
package/dst/ase.js
CHANGED
|
@@ -14,6 +14,7 @@ import DiagramCommand from "./ase-diagram.js";
|
|
|
14
14
|
import SetupCommand from "./ase-setup.js";
|
|
15
15
|
import StatuslineCommand from "./ase-statusline.js";
|
|
16
16
|
import TaskCommand from "./ase-task.js";
|
|
17
|
+
import ArtifactCommand from "./ase-artifact.js";
|
|
17
18
|
import pkg from "../package.json" with { type: "json" };
|
|
18
19
|
/* globally initialize logger */
|
|
19
20
|
const log = new Log("ase", "warning", "-");
|
|
@@ -49,6 +50,7 @@ const main = async () => {
|
|
|
49
50
|
new HookCommand(log).register(program);
|
|
50
51
|
new StatuslineCommand(log).register(program);
|
|
51
52
|
new TaskCommand(log).register(program);
|
|
53
|
+
new ArtifactCommand(log).register(program);
|
|
52
54
|
new DiagramCommand(log).register(program);
|
|
53
55
|
/* parse program arguments */
|
|
54
56
|
await program.parseAsync(process.argv);
|
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.9.
|
|
9
|
+
"version": "0.9.6",
|
|
10
10
|
"license": "GPL-3.0-only",
|
|
11
11
|
"author": {
|
|
12
12
|
"name": "Dr. Ralf S. Engelschall",
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
"@types/shell-quote": "1.7.5",
|
|
38
38
|
"@types/proper-lockfile": "4.1.4",
|
|
39
39
|
"@types/write-file-atomic": "4.0.3",
|
|
40
|
-
"@types/pacote": "11.1.8"
|
|
40
|
+
"@types/pacote": "11.1.8",
|
|
41
|
+
"@types/picomatch": "4.0.3"
|
|
41
42
|
},
|
|
42
43
|
"dependencies": {
|
|
43
44
|
"commander": "15.0.0",
|
|
@@ -60,7 +61,8 @@
|
|
|
60
61
|
"proper-lockfile": "4.1.2",
|
|
61
62
|
"write-file-atomic": "8.0.0",
|
|
62
63
|
"pacote": "21.5.0",
|
|
63
|
-
"ofetch": "1.5.1"
|
|
64
|
+
"ofetch": "1.5.1",
|
|
65
|
+
"picomatch": "4.0.4"
|
|
64
66
|
},
|
|
65
67
|
"engines": {
|
|
66
68
|
"npm": ">=10.0.0",
|
|
@@ -16,12 +16,15 @@ Artifact Meta Information
|
|
|
16
16
|
(SAS)", "Architecture Description", or "Architecture Decision
|
|
17
17
|
Record (ADR)".
|
|
18
18
|
|
|
19
|
-
- `
|
|
19
|
+
- `Source Code` (`CODE`), aka "Software Implementation Results (IMP)",
|
|
20
|
+
"Code", or "Software".
|
|
20
21
|
|
|
21
22
|
- `Documentation` (`DOCS`), aka "Software Documentation Results (DOC)".
|
|
22
23
|
|
|
24
|
+
- `Tasks` (`TASK`), aka "Task Plans", "Issues", or "User Stories".
|
|
25
|
+
|
|
23
26
|
Each **Artifact Set** has a unique identifier <artifact-set-id/>,
|
|
24
|
-
which is one of `SPEC`, `ARCH`, `
|
|
27
|
+
which is one of `SPEC`, `ARCH`, `CODE`, `DOCS`, or `TASK`.
|
|
25
28
|
|
|
26
29
|
- **Artifact**:
|
|
27
30
|
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
Task
|
|
3
|
+
----
|
|
4
4
|
|
|
5
|
-
Every *task
|
|
5
|
+
Every *task* uses a strict and fixed format:
|
|
6
6
|
|
|
7
7
|
<format>
|
|
8
8
|
|
|
9
|
-
# ✪ TASK
|
|
9
|
+
# ✪ TASK <task-id/>: <title/>
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
✳ Created: **<timestamp-created/>**
|
|
12
|
+
✎ Modified: **<timestamp-modified/>**
|
|
12
13
|
|
|
13
14
|
## ※ CONTEXT
|
|
14
15
|
|
|
@@ -30,7 +31,7 @@ Every *task plan* uses a strict and fixed format:
|
|
|
30
31
|
|
|
31
32
|
</format>
|
|
32
33
|
|
|
33
|
-
You *MUST* honor the following hints on this *task
|
|
34
|
+
You *MUST* honor the following hints on this *task* format:
|
|
34
35
|
|
|
35
36
|
- You *MUST* always keep the first empty line and the last empty line.
|
|
36
37
|
If one of them is missing, add it back.
|
package/plugin/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.9.
|
|
9
|
+
"version": "0.9.6",
|
|
10
10
|
"license": "GPL-3.0-only",
|
|
11
11
|
"author": {
|
|
12
12
|
"name": "Dr. Ralf S. Engelschall",
|
|
@@ -38,7 +38,7 @@ to `true`, <getopt-option-dry/> to `true`, and <getopt-option-next/> to
|
|
|
38
38
|
<request><getopt-arguments/></request>
|
|
39
39
|
</objective>
|
|
40
40
|
|
|
41
|
-
@${CLAUDE_SKILL_DIR}/../../meta/ase-format-
|
|
41
|
+
@${CLAUDE_SKILL_DIR}/../../meta/ase-format-task.md
|
|
42
42
|
|
|
43
43
|
Procedure
|
|
44
44
|
---------
|
|
@@ -38,7 +38,7 @@ to `true`, <getopt-option-dry/> to `true`, and <getopt-option-next/> to
|
|
|
38
38
|
<problem><getopt-arguments/></problem>
|
|
39
39
|
</objective>
|
|
40
40
|
|
|
41
|
-
@${CLAUDE_SKILL_DIR}/../../meta/ase-format-
|
|
41
|
+
@${CLAUDE_SKILL_DIR}/../../meta/ase-format-task.md
|
|
42
42
|
|
|
43
43
|
Procedure
|
|
44
44
|
---------
|
|
@@ -17,10 +17,6 @@ session context) is used. When the deleted task is the current task
|
|
|
17
17
|
and not the `default` task, the current task id is automatically
|
|
18
18
|
switched back to `default`.
|
|
19
19
|
|
|
20
|
-
The task plan is stored in `.ase/tasks/`*id*`/plan.md` files of the
|
|
21
|
-
project and can be alternatively deleted with the
|
|
22
|
-
"`ase task delete` *id*" command from outside the agent tool.
|
|
23
|
-
|
|
24
20
|
## ARGUMENTS
|
|
25
21
|
|
|
26
22
|
*id*:
|
|
@@ -34,7 +34,7 @@ continued refinement, finalization, or hand-off to implementation or
|
|
|
34
34
|
preflight.
|
|
35
35
|
</objective>
|
|
36
36
|
|
|
37
|
-
@${CLAUDE_SKILL_DIR}/../../meta/ase-format-
|
|
37
|
+
@${CLAUDE_SKILL_DIR}/../../meta/ase-format-task.md
|
|
38
38
|
|
|
39
39
|
Procedure
|
|
40
40
|
---------
|
|
@@ -271,7 +271,7 @@ Set <content-dirty>true</content-dirty>.
|
|
|
271
271
|
</template>
|
|
272
272
|
</if>
|
|
273
273
|
|
|
274
|
-
4. <if condition="<content/> does not contain '# ✪ TASK
|
|
274
|
+
4. <if condition="<content/> does not contain '# ✪ TASK' AND <instruction/> is empty">
|
|
275
275
|
Set <instruction><content/></instruction> (set instruction to content).
|
|
276
276
|
Set <content></content> (set content to empty).
|
|
277
277
|
Set <content-dirty>true</content-dirty>.
|
|
@@ -327,9 +327,9 @@ Set <content-dirty>true</content-dirty>.
|
|
|
327
327
|
Use the following <template/>:
|
|
328
328
|
|
|
329
329
|
<template>
|
|
330
|
-
<ase-tpl-head title="TASK
|
|
330
|
+
<ase-tpl-head title="TASK"/>
|
|
331
331
|
<content/>
|
|
332
|
-
<ase-tpl-foot title="TASK
|
|
332
|
+
<ase-tpl-foot title="TASK"/>
|
|
333
333
|
</template>
|
|
334
334
|
|
|
335
335
|
4. *Determine next step*:
|
|
@@ -19,10 +19,6 @@ through a *chat-driven loop*. The user steers each round via an
|
|
|
19
19
|
interactive dialog that offers continued refinement, finalization, or
|
|
20
20
|
hand-off to implementation or preflight.
|
|
21
21
|
|
|
22
|
-
The task plan is stored in `.ase/tasks/`*id*`/plan.md` files of the
|
|
23
|
-
project and can be alternatively edited with the "`ase task edit` *id*"
|
|
24
|
-
command from outside the agent tool.
|
|
25
|
-
|
|
26
22
|
## OPTIONS
|
|
27
23
|
|
|
28
24
|
`--plan`|`-p` *option*:
|
|
@@ -31,7 +31,7 @@ Interview the user relentlessly about every essential aspect of the
|
|
|
31
31
|
task plan until reaching a shared understanding.
|
|
32
32
|
</objective>
|
|
33
33
|
|
|
34
|
-
@${CLAUDE_SKILL_DIR}/../../meta/ase-format-
|
|
34
|
+
@${CLAUDE_SKILL_DIR}/../../meta/ase-format-task.md
|
|
35
35
|
|
|
36
36
|
Procedure
|
|
37
37
|
---------
|
|
@@ -27,10 +27,6 @@ lets the user pick via an interactive dialog. It honors checks for
|
|
|
27
27
|
updated and persisted, and the user is offered a hand-off to editing,
|
|
28
28
|
implementation, or preflight.
|
|
29
29
|
|
|
30
|
-
The task plan is stored in `.ase/tasks/`*id*`/plan.md` files of the
|
|
31
|
-
project and can be alternatively edited with the "`ase task edit` *id*"
|
|
32
|
-
command from outside the agent tool.
|
|
33
|
-
|
|
34
30
|
## OPTIONS
|
|
35
31
|
|
|
36
32
|
`--next`|`-n` *option*[,...]:
|
|
@@ -12,12 +12,11 @@
|
|
|
12
12
|
|
|
13
13
|
## DESCRIPTION
|
|
14
14
|
|
|
15
|
-
The `ase-task-implement` skill performs the *final implementation*
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
plain plan content always overrules the draft.
|
|
15
|
+
The `ase-task-implement` skill performs the *final implementation* of
|
|
16
|
+
a task plan by modifying the corresponding *artifacts* with a complete
|
|
17
|
+
*change set*. The plan is loaded and any optional `IMPLEMENTATION DRAFT`
|
|
18
|
+
section produced by `ase-task-preflight` is used as a hint - the plain
|
|
19
|
+
plan content always overrules the draft.
|
|
21
20
|
|
|
22
21
|
If the task plan deliberately *omits* the `※ VERIFICATION` section
|
|
23
22
|
(as produced by `ase-code-craft`, `ase-code-refactor`,
|