@technomoron/agent-run 0.99.31 → 0.99.33
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/CHANGES +48 -0
- package/README.md +124 -5
- package/dist/agent-run.js +1 -2
- package/dist/agents/claude.js +1 -5
- package/dist/brain/cli.js +52 -20
- package/dist/brain/git.js +8 -2
- package/dist/brain/projects.js +1 -1
- package/dist/brain/server.js +27 -6
- package/dist/brain/skills.js +2 -1
- package/dist/brain/store.js +282 -53
- package/dist/brain/todos.js +48 -8
- package/dist/cli.js +3 -12
- package/dist/commands.js +7 -9
- package/dist/config/render-instructions.js +1 -1
- package/dist/config/render-mcp.js +2 -3
- package/dist/config/render-skills.js +3 -2
- package/dist/config-tree.js +0 -22
- package/dist/constants.js +1 -2
- package/dist/guards.js +114 -8
- package/dist/process.js +0 -5
- package/dist/project.js +2 -23
- package/dist/renderer.js +7 -2
- package/dist/templates.js +35 -7
- package/dist/version.js +22 -0
- package/examples/basic-config/agent-config/starter/basic-project/agent-run.jsonc +1 -1
- package/package.json +2 -3
- package/scripts/install-systemd-jobs.sh +1 -8
- /package/examples/basic-config/agent-config/starter/basic-project/{overrides/triage.SKILL.md.njk → skills/triage/SKILL.md.njk} +0 -0
package/dist/brain/store.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.BrainStore = exports.knowledgeDirectories = exports.knowledgeMetadata = exports.knowledgeInput = exports.typeSchema = exports.scopeSchema = void 0;
|
|
3
|
+
exports.BrainStore = exports.knowledgeDirectories = exports.knowledgeChanges = exports.knowledgeMetadata = exports.knowledgeInput = exports.reviewStateSchema = exports.severitySchema = exports.typeSchema = exports.scopeSchema = void 0;
|
|
4
4
|
exports.readMarkdown = readMarkdown;
|
|
5
5
|
const fs = require("node:fs");
|
|
6
6
|
const path = require("node:path");
|
|
@@ -13,6 +13,10 @@ const utils_1 = require("../utils");
|
|
|
13
13
|
const config_1 = require("./config");
|
|
14
14
|
exports.scopeSchema = zod_1.z.enum(['global', 'project', 'default']);
|
|
15
15
|
exports.typeSchema = zod_1.z.enum(['rule', 'preference', 'convention', 'constraint', 'decision', 'spec', 'review', 'memory', 'observation']);
|
|
16
|
+
exports.severitySchema = zod_1.z.enum(['critical', 'high', 'medium', 'low']);
|
|
17
|
+
exports.reviewStateSchema = zod_1.z.enum(['open', 'fixed', 'wontfix']);
|
|
18
|
+
const severityPrefix = { critical: 'C', high: 'H', medium: 'M', low: 'L' };
|
|
19
|
+
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
16
20
|
exports.knowledgeInput = zod_1.z.object({
|
|
17
21
|
scope: exports.scopeSchema,
|
|
18
22
|
type: exports.typeSchema,
|
|
@@ -20,17 +24,38 @@ exports.knowledgeInput = zod_1.z.object({
|
|
|
20
24
|
content: zod_1.z.string().trim().min(1).max(200000),
|
|
21
25
|
authority: zod_1.z.enum(['user', 'inferred']).default('inferred'),
|
|
22
26
|
recall: zod_1.z.enum(['always', 'relevant']).default('relevant'),
|
|
27
|
+
severity: exports.severitySchema.optional(),
|
|
23
28
|
tags: zod_1.z.array(zod_1.z.string().max(100)).max(50).default([]),
|
|
24
|
-
applies_to: zod_1.z.array(zod_1.z.string().max(500)).max(50).default([])
|
|
29
|
+
applies_to: zod_1.z.array(zod_1.z.string().max(500)).max(50).default([]),
|
|
30
|
+
// Naming only. The id in the front matter stays the identity, so files can be renamed freely.
|
|
31
|
+
filename: zod_1.z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Use lower-case words separated by single dashes, with no extension').max(100).optional()
|
|
25
32
|
}).strict();
|
|
26
|
-
|
|
33
|
+
// finding and state stay optional so review files written before they existed keep parsing.
|
|
34
|
+
exports.knowledgeMetadata = exports.knowledgeInput.omit({ content: true, filename: true }).extend({
|
|
27
35
|
id: zod_1.z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/),
|
|
28
36
|
status: zod_1.z.enum(['active', 'deprecated']).default('active'),
|
|
37
|
+
finding: zod_1.z.string().regex(/^[CHML][1-9][0-9]*$/).optional(),
|
|
38
|
+
state: exports.reviewStateSchema.optional(),
|
|
29
39
|
created: zod_1.z.string().default(''),
|
|
30
40
|
updated: zod_1.z.string().default(''),
|
|
31
41
|
reason: zod_1.z.string().optional(),
|
|
32
42
|
promoted_from: zod_1.z.string().optional()
|
|
33
43
|
}).strict();
|
|
44
|
+
// Spelled out rather than derived from knowledgeInput: .partial() keeps .default(), so a
|
|
45
|
+
// derived schema would silently reset every field the caller did not mention.
|
|
46
|
+
exports.knowledgeChanges = zod_1.z.object({
|
|
47
|
+
title: zod_1.z.string().trim().min(1).max(300).optional(),
|
|
48
|
+
content: zod_1.z.string().trim().min(1).max(200000).optional(),
|
|
49
|
+
recall: zod_1.z.enum(['always', 'relevant']).optional(),
|
|
50
|
+
tags: zod_1.z.array(zod_1.z.string().max(100)).max(50).optional(),
|
|
51
|
+
applies_to: zod_1.z.array(zod_1.z.string().max(500)).max(50).optional()
|
|
52
|
+
}).strict();
|
|
53
|
+
const reviewClosureSchema = zod_1.z.object({
|
|
54
|
+
id: zod_1.z.string(), finding: zod_1.z.string().regex(/^[CHML][1-9][0-9]*$/), severity: exports.severitySchema,
|
|
55
|
+
scope: exports.scopeSchema, title: zod_1.z.string(), state: zod_1.z.enum(['fixed', 'wontfix']),
|
|
56
|
+
closed: zod_1.z.string(), reason: zod_1.z.string(), source: zod_1.z.string(), revision: zod_1.z.string()
|
|
57
|
+
}).strict();
|
|
58
|
+
const reviewCountersSchema = zod_1.z.object({ C: zod_1.z.number().int().nonnegative(), H: zod_1.z.number().int().nonnegative(), M: zod_1.z.number().int().nonnegative(), L: zod_1.z.number().int().nonnegative() }).strict();
|
|
34
59
|
exports.knowledgeDirectories = {
|
|
35
60
|
rule: 'rules', preference: 'preferences', convention: 'conventions', constraint: 'constraints',
|
|
36
61
|
decision: 'decisions', spec: 'specs', review: 'reviews', memory: 'memory', observation: 'observations'
|
|
@@ -38,14 +63,35 @@ exports.knowledgeDirectories = {
|
|
|
38
63
|
function readMarkdown(text) {
|
|
39
64
|
const normalized = text.replace(/\r\n/g, '\n');
|
|
40
65
|
const match = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(normalized);
|
|
66
|
+
if (normalized.startsWith('---\n') && !match)
|
|
67
|
+
throw new Error('Unterminated YAML front matter');
|
|
41
68
|
return match ? { metadata: (0, yaml_1.parse)(match[1], { maxAliasCount: 50 }), content: normalized.slice(match[0].length).trim() }
|
|
42
69
|
: { metadata: null, content: normalized.trim() };
|
|
43
70
|
}
|
|
44
71
|
function hash(text) { return (0, node_crypto_1.createHash)('sha256').update(text).digest('hex'); }
|
|
45
72
|
class BrainStore {
|
|
73
|
+
brokenFile(file, error, id) {
|
|
74
|
+
const message = error instanceof zod_1.z.ZodError
|
|
75
|
+
? error.issues.map((issue) => `${issue.path.join('.') || 'metadata'}: ${issue.message}`).join('; ')
|
|
76
|
+
: error instanceof Error ? error.message.split('\n')[0] : String(error);
|
|
77
|
+
return { source: path.relative(this.configRoot, file), status: 'broken', error: message, ...(id ? { id } : {}) };
|
|
78
|
+
}
|
|
79
|
+
withDiagnostics(value) {
|
|
80
|
+
const brokenFiles = [...this.knowledgeErrors, ...this.todoErrors];
|
|
81
|
+
if (!brokenFiles.length)
|
|
82
|
+
return value;
|
|
83
|
+
return Array.isArray(value) ? { items: value, brokenFiles } : { ...value, brokenFiles };
|
|
84
|
+
}
|
|
85
|
+
assertReadable(id, errors) {
|
|
86
|
+
const matches = errors.filter((item) => item.id === id || item.source === id || path.basename(item.source, '.md') === id);
|
|
87
|
+
if (matches.length)
|
|
88
|
+
throw new Error(`Cannot read or update broken file: ${matches.map((item) => `${item.source}: ${item.error}`).join('; ')}. Correct the file on disk first.`);
|
|
89
|
+
}
|
|
46
90
|
constructor(configRoot, cwd) {
|
|
47
91
|
this.configRoot = configRoot;
|
|
48
92
|
this.cwd = cwd;
|
|
93
|
+
this.knowledgeErrors = [];
|
|
94
|
+
this.todoErrors = [];
|
|
49
95
|
this.configRoot = path.resolve(configRoot);
|
|
50
96
|
this.projectRoot = (0, config_1.registeredBrainProject)(this.configRoot, cwd)?.root ?? (0, project_1.findProjectRoot)(cwd);
|
|
51
97
|
const resolved = (0, project_1.resolveProfileResult)(this.projectRoot, this.configRoot).profile;
|
|
@@ -88,14 +134,31 @@ class BrainStore {
|
|
|
88
134
|
throw new Error(`Scope ${scope} is not available in this environment`);
|
|
89
135
|
return this.safePath(directory);
|
|
90
136
|
}
|
|
91
|
-
files(directory) {
|
|
92
|
-
|
|
93
|
-
|
|
137
|
+
files(directory, options = {}) {
|
|
138
|
+
const { onError } = options;
|
|
139
|
+
try {
|
|
140
|
+
this.safePath(directory);
|
|
141
|
+
if (!fs.existsSync(directory))
|
|
142
|
+
return [];
|
|
143
|
+
return fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)).flatMap((entry) => {
|
|
144
|
+
try {
|
|
145
|
+
const file = this.safePath(directory, entry.name);
|
|
146
|
+
return entry.isDirectory() ? this.files(file, options) : entry.isFile() && (options.includeAllFiles || entry.name.endsWith('.md')) ? [file] : [];
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
if (!onError)
|
|
150
|
+
throw error;
|
|
151
|
+
onError(path.join(directory, entry.name), error);
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
if (!onError)
|
|
158
|
+
throw error;
|
|
159
|
+
onError(directory, error);
|
|
94
160
|
return [];
|
|
95
|
-
|
|
96
|
-
const file = this.safePath(directory, entry.name);
|
|
97
|
-
return entry.isDirectory() ? this.files(file) : entry.isFile() && entry.name.endsWith('.md') ? [file] : [];
|
|
98
|
-
});
|
|
161
|
+
}
|
|
99
162
|
}
|
|
100
163
|
read(file) {
|
|
101
164
|
const target = this.safePath(file);
|
|
@@ -136,72 +199,113 @@ class BrainStore {
|
|
|
136
199
|
}
|
|
137
200
|
}
|
|
138
201
|
allKnowledge() {
|
|
202
|
+
this.knowledgeErrors.length = 0;
|
|
139
203
|
const result = [];
|
|
140
204
|
for (const { scope, directory } of this.scopes) {
|
|
141
205
|
const directories = [...Object.entries(exports.knowledgeDirectories), ['memory', path.join('notes', 'memory')]];
|
|
142
206
|
if (scope === 'global')
|
|
143
207
|
directories.push(['memory', path.relative(directory, path.join(this.configRoot, 'notes', 'memory'))]);
|
|
144
208
|
for (const [type, folder] of directories) {
|
|
145
|
-
for (const file of this.files(path.join(directory, folder))) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
209
|
+
for (const file of this.files(path.join(directory, folder), { onError: (file, error) => this.knowledgeErrors.push(this.brokenFile(file, error)) })) {
|
|
210
|
+
let id;
|
|
211
|
+
try {
|
|
212
|
+
const text = this.read(file);
|
|
213
|
+
const document = readMarkdown(text);
|
|
214
|
+
if (document.metadata && typeof document.metadata === 'object' && 'id' in document.metadata && typeof document.metadata.id === 'string')
|
|
215
|
+
id = document.metadata.id;
|
|
216
|
+
const source = path.relative(this.configRoot, file);
|
|
217
|
+
const legacy = document.metadata !== null && typeof document.metadata === 'object' && !Array.isArray(document.metadata)
|
|
218
|
+
&& !('id' in document.metadata) && !('type' in document.metadata);
|
|
219
|
+
const legacyStatus = legacy ? document.metadata.status : undefined;
|
|
220
|
+
const metadata = document.metadata === null || legacy ? {
|
|
221
|
+
id: `document-${hash(source).slice(0, 24)}`, scope, type,
|
|
222
|
+
title: /^#\s+(.+)$/m.exec(document.content)?.[1] ?? path.basename(file, '.md'),
|
|
223
|
+
status: type === 'review' && ['addressed', 'resolved', 'done', 'closed'].includes(String(legacyStatus)) ? 'deprecated' : 'active',
|
|
224
|
+
created: '', updated: ''
|
|
225
|
+
} : { title: /^#\s+(.+)$/m.exec(document.content)?.[1] ?? path.basename(file, '.md'), ...document.metadata };
|
|
226
|
+
const parsed = exports.knowledgeMetadata.safeParse(metadata);
|
|
227
|
+
if (!parsed.success)
|
|
228
|
+
throw parsed.error;
|
|
229
|
+
if (parsed.data.scope !== scope)
|
|
230
|
+
throw new Error(`Knowledge scope does not match its directory: ${source}`);
|
|
231
|
+
result.push({ ...parsed.data, content: legacy ? text.trim() : document.content, source, revision: hash(text) });
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
this.knowledgeErrors.push(this.brokenFile(file, error, id));
|
|
235
|
+
}
|
|
164
236
|
}
|
|
165
237
|
}
|
|
166
238
|
}
|
|
167
|
-
const
|
|
239
|
+
const counts = new Map();
|
|
240
|
+
for (const item of [...result, ...this.knowledgeErrors]) {
|
|
241
|
+
if (item.id)
|
|
242
|
+
counts.set(item.id, (counts.get(item.id) ?? 0) + 1);
|
|
243
|
+
}
|
|
168
244
|
for (const item of result) {
|
|
169
|
-
if (
|
|
170
|
-
|
|
171
|
-
ids.add(item.id);
|
|
245
|
+
if (counts.get(item.id) > 1)
|
|
246
|
+
this.knowledgeErrors.push(this.brokenFile(path.join(this.configRoot, item.source), new Error(`Duplicate knowledge id: ${item.id}`), item.id));
|
|
172
247
|
}
|
|
173
|
-
return result;
|
|
248
|
+
return result.filter((item) => counts.get(item.id) === 1);
|
|
174
249
|
}
|
|
175
250
|
search(query, options = {}) {
|
|
176
|
-
|
|
251
|
+
return this.searchItems(this.allKnowledge(), query, options);
|
|
252
|
+
}
|
|
253
|
+
searchItems(items, query, options = {}) {
|
|
177
254
|
const environment = this.profile ?? 'default';
|
|
255
|
+
this.refreshIndex(items, environment);
|
|
256
|
+
const terms = query.match(/[\p{L}\p{N}_-]+/gu)?.slice(0, 50) ?? [];
|
|
257
|
+
const ranked = terms.length ? this.db.prepare('SELECT source FROM knowledge WHERE knowledge MATCH ? AND environment = ? ORDER BY bm25(knowledge, 0, 0, 5, 1, 3), source')
|
|
258
|
+
.all(terms.map((term) => `"${term.replace(/"/g, '""')}"`).join(' OR '), environment).map((row) => String(row.source)) : items.map((item) => item.source);
|
|
259
|
+
const bySource = new Map(items.map((item) => [item.source, item]));
|
|
260
|
+
return ranked.map((source) => bySource.get(source)).filter((item) => item !== undefined).filter((item) => (options.includeDeprecated || item.status === 'active') && (!options.scopes || options.scopes.includes(item.scope)) &&
|
|
261
|
+
(!options.types || options.types.includes(item.type))).slice(0, options.limit ?? 50);
|
|
262
|
+
}
|
|
263
|
+
refreshIndex(items, environment) {
|
|
264
|
+
const desired = new Map(items.map((item) => [item.source, {
|
|
265
|
+
source: item.source, title: item.title, content: item.content, tags: [...item.tags, item.finding ?? ''].join(' ').trim()
|
|
266
|
+
}]));
|
|
267
|
+
const changes = () => {
|
|
268
|
+
const current = this.db.prepare('SELECT rowid, source, title, content, tags FROM knowledge WHERE environment = ?').all(environment);
|
|
269
|
+
const unchanged = new Set();
|
|
270
|
+
const removed = current.filter((row) => {
|
|
271
|
+
const item = desired.get(String(row.source));
|
|
272
|
+
if (item && !unchanged.has(item.source) && row.title === item.title && row.content === item.content && row.tags === item.tags) {
|
|
273
|
+
unchanged.add(item.source);
|
|
274
|
+
return false;
|
|
275
|
+
}
|
|
276
|
+
return true;
|
|
277
|
+
});
|
|
278
|
+
return { removed, added: [...desired.values()].filter((item) => !unchanged.has(item.source)) };
|
|
279
|
+
};
|
|
280
|
+
const pending = changes();
|
|
281
|
+
if (!pending.removed.length && !pending.added.length)
|
|
282
|
+
return;
|
|
178
283
|
this.db.exec('BEGIN IMMEDIATE');
|
|
179
284
|
try {
|
|
180
|
-
|
|
285
|
+
// Another connection may have refreshed the index while we waited for the lock.
|
|
286
|
+
const { removed, added } = changes();
|
|
287
|
+
const remove = this.db.prepare('DELETE FROM knowledge WHERE rowid = ?');
|
|
288
|
+
for (const row of removed)
|
|
289
|
+
remove.run(row.rowid);
|
|
181
290
|
const insert = this.db.prepare('INSERT INTO knowledge(source, environment, title, content, tags) VALUES (?, ?, ?, ?, ?)');
|
|
182
|
-
for (const item of
|
|
183
|
-
insert.run(item.source, environment, item.title, item.content, item.tags
|
|
291
|
+
for (const item of added)
|
|
292
|
+
insert.run(item.source, environment, item.title, item.content, item.tags);
|
|
184
293
|
this.db.exec('COMMIT');
|
|
185
294
|
}
|
|
186
295
|
catch (error) {
|
|
187
296
|
this.db.exec('ROLLBACK');
|
|
188
297
|
throw error;
|
|
189
298
|
}
|
|
190
|
-
const terms = query.match(/[\p{L}\p{N}_-]+/gu)?.slice(0, 50) ?? [];
|
|
191
|
-
const ranked = terms.length ? this.db.prepare('SELECT source FROM knowledge WHERE knowledge MATCH ? AND environment = ? ORDER BY bm25(knowledge, 0, 0, 5, 1, 3), source')
|
|
192
|
-
.all(terms.map((term) => `"${term.replace(/"/g, '""')}"`).join(' OR '), environment).map((row) => String(row.source)) : items.map((item) => item.source);
|
|
193
|
-
const bySource = new Map(items.map((item) => [item.source, item]));
|
|
194
|
-
return ranked.map((source) => bySource.get(source)).filter((item) => (options.includeDeprecated || item.status === 'active') && (!options.scopes || options.scopes.includes(item.scope)) &&
|
|
195
|
-
(!options.types || options.types.includes(item.type))).slice(0, options.limit ?? 50);
|
|
196
299
|
}
|
|
197
300
|
context(task, files = [], symbols = []) {
|
|
198
|
-
const
|
|
301
|
+
const scanned = this.allKnowledge();
|
|
302
|
+
const all = scanned.filter((item) => item.status === 'active');
|
|
199
303
|
const always = all.filter((item) => item.recall === 'always');
|
|
200
304
|
const matching = all.filter((item) => item.applies_to.some((pattern) => files.some((file) => {
|
|
201
305
|
const relative = path.isAbsolute(file) ? path.relative(this.projectRoot, file) : file;
|
|
202
306
|
return path.matchesGlob(relative, pattern);
|
|
203
307
|
})));
|
|
204
|
-
const selected = [...new Map([...always, ...matching, ...this.
|
|
308
|
+
const selected = [...new Map([...always, ...matching, ...this.searchItems(scanned, [task, ...symbols].join(' '))].map((item) => [item.id, item])).values()];
|
|
205
309
|
let size = 0;
|
|
206
310
|
const items = selected.filter((item) => {
|
|
207
311
|
const length = JSON.stringify(item).length;
|
|
@@ -219,15 +323,137 @@ class BrainStore {
|
|
|
219
323
|
}
|
|
220
324
|
if (value.scope === 'global' && value.authority !== 'user')
|
|
221
325
|
throw new Error('Global writes require explicit user authority.');
|
|
326
|
+
if (value.type === 'review' && !value.severity)
|
|
327
|
+
throw new Error('Review findings require a severity: critical, high, medium, or low.');
|
|
328
|
+
if (value.type !== 'review' && value.severity)
|
|
329
|
+
throw new Error('Severity applies only to review findings.');
|
|
222
330
|
return this.writeLocked(() => {
|
|
223
331
|
const now = new Date().toISOString();
|
|
224
|
-
const { content, ...metadata } = value;
|
|
225
|
-
const
|
|
226
|
-
const
|
|
227
|
-
this.
|
|
332
|
+
const { content, filename, ...metadata } = value;
|
|
333
|
+
const review = value.severity ? { finding: this.nextFinding(value.scope, value.severity), state: 'open' } : {};
|
|
334
|
+
const item = { ...metadata, ...review, id: (0, node_crypto_1.randomUUID)(), status: 'active', created: now, updated: now };
|
|
335
|
+
const directory = path.join(this.scopeDirectory(value.scope), exports.knowledgeDirectories[value.type]);
|
|
336
|
+
this.atomicWrite(this.availableFile(directory, filename, item.id), `---\n${(0, yaml_1.stringify)(item)}---\n\n${content}\n`);
|
|
228
337
|
return this.allKnowledge().find((entry) => entry.id === item.id);
|
|
229
338
|
});
|
|
230
339
|
}
|
|
340
|
+
/** Chosen name when one is given and free, otherwise the same name with the id appended. */
|
|
341
|
+
availableFile(directory, filename, id) {
|
|
342
|
+
if (!filename)
|
|
343
|
+
return path.join(directory, `${id}.md`);
|
|
344
|
+
const preferred = path.join(directory, `${filename}.md`);
|
|
345
|
+
return fs.existsSync(preferred) ? path.join(directory, `${filename}-${id}.md`) : preferred;
|
|
346
|
+
}
|
|
347
|
+
/** Revise an item in place, keeping its id, filename, scope, type, authority, and history. */
|
|
348
|
+
amend(id, revision, changes) {
|
|
349
|
+
const parsed = exports.knowledgeChanges.parse(changes);
|
|
350
|
+
if (Object.keys(parsed).length === 0)
|
|
351
|
+
throw new Error('Supply at least one field to change.');
|
|
352
|
+
return this.writeLocked(() => {
|
|
353
|
+
const item = this.get(id);
|
|
354
|
+
if (item.revision !== revision)
|
|
355
|
+
throw new Error('Knowledge changed; read the current revision before amending.');
|
|
356
|
+
if (item.status !== 'active')
|
|
357
|
+
throw new Error('Deprecated knowledge cannot be amended.');
|
|
358
|
+
const { source, revision: _revision, content, ...metadata } = item;
|
|
359
|
+
const { content: nextContent, ...nextMetadata } = parsed;
|
|
360
|
+
this.atomicWrite(source, `---\n${(0, yaml_1.stringify)({ ...metadata, ...nextMetadata, updated: new Date().toISOString() })}---\n\n${nextContent ?? content}\n`);
|
|
361
|
+
return this.get(id);
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
/** Next unused finding label for a severity, counting resolved findings so numbers are never reused. */
|
|
365
|
+
nextFinding(scope, severity) {
|
|
366
|
+
const prefix = severityPrefix[severity];
|
|
367
|
+
const used = this.allKnowledge()
|
|
368
|
+
.filter((item) => item.scope === scope && item.type === 'review' && item.finding?.startsWith(prefix))
|
|
369
|
+
.map((item) => Number.parseInt(item.finding.slice(prefix.length), 10))
|
|
370
|
+
.filter((sequence) => Number.isSafeInteger(sequence));
|
|
371
|
+
if (this.knowledgeErrors.some((item) => (0, utils_1.isSamePathOrDescendant)(path.join(this.configRoot, item.source), path.join(this.scopeDirectory(scope), 'reviews')))) {
|
|
372
|
+
throw new Error('Cannot assign a review number while review files are broken. List reviews and correct the reported files first.');
|
|
373
|
+
}
|
|
374
|
+
const counters = this.reviewCounters(scope);
|
|
375
|
+
const next = Math.max(counters[prefix], 0, ...used) + 1;
|
|
376
|
+
this.reserveFinding(scope, `${prefix}${next}`);
|
|
377
|
+
return `${prefix}${next}`;
|
|
378
|
+
}
|
|
379
|
+
/** Review findings in severity then sequence order. Open findings only unless states are given. */
|
|
380
|
+
reviews(options = {}) {
|
|
381
|
+
const sequence = (item) => Number.parseInt(item.finding.slice(1), 10);
|
|
382
|
+
return this.allKnowledge()
|
|
383
|
+
.filter((item) => item.type === 'review' && item.finding && item.severity)
|
|
384
|
+
.filter((item) => !options.severity || options.severity.includes(item.severity))
|
|
385
|
+
.filter((item) => (options.state ?? ['open']).includes(item.state ?? 'open'))
|
|
386
|
+
.sort((left, right) => severityOrder[left.severity] - severityOrder[right.severity] || sequence(left) - sequence(right));
|
|
387
|
+
}
|
|
388
|
+
reviewCounters(scope) {
|
|
389
|
+
const file = this.safePath(this.scopeDirectory(scope), 'review-counters.json');
|
|
390
|
+
return fs.existsSync(file) ? reviewCountersSchema.parse(JSON.parse(fs.readFileSync(file, 'utf8'))) : { C: 0, H: 0, M: 0, L: 0 };
|
|
391
|
+
}
|
|
392
|
+
reserveFinding(scope, finding) {
|
|
393
|
+
const counters = this.reviewCounters(scope);
|
|
394
|
+
const prefix = finding[0];
|
|
395
|
+
const number = Number(finding.slice(1));
|
|
396
|
+
if (!Number.isSafeInteger(number) || number < 1)
|
|
397
|
+
throw new Error('Invalid review number');
|
|
398
|
+
counters[prefix] = Math.max(counters[prefix], number);
|
|
399
|
+
this.atomicWrite(path.join(this.scopeDirectory(scope), 'review-counters.json'), JSON.stringify(counters) + '\n');
|
|
400
|
+
}
|
|
401
|
+
closedReviews(scope) {
|
|
402
|
+
const file = this.safePath(this.scopeDirectory(scope), 'review-history.jsonl');
|
|
403
|
+
if (!fs.existsSync(file))
|
|
404
|
+
return [];
|
|
405
|
+
return fs.readFileSync(file, 'utf8').split('\n').filter((line) => line.trim()).map((line) => {
|
|
406
|
+
const record = reviewClosureSchema.parse(JSON.parse(line));
|
|
407
|
+
if (record.scope !== scope)
|
|
408
|
+
throw new Error(`Review history scope mismatch: ${file}`);
|
|
409
|
+
return record;
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
reviewHistory(options = {}) {
|
|
413
|
+
const query = options.query?.toLowerCase();
|
|
414
|
+
const items = this.scopes.filter((entry) => !options.scope || entry.scope === options.scope)
|
|
415
|
+
.flatMap((entry) => this.closedReviews(entry.scope))
|
|
416
|
+
.filter((item) => (!options.state || item.state === options.state) && (!query || [item.id, item.finding, item.title, item.reason].some((value) => value.toLowerCase().includes(query))))
|
|
417
|
+
.sort((a, b) => b.closed.localeCompare(a.closed) || a.id.localeCompare(b.id));
|
|
418
|
+
const offset = options.offset ?? 0;
|
|
419
|
+
return { items: items.slice(offset, offset + (options.limit ?? 100)), total: items.length };
|
|
420
|
+
}
|
|
421
|
+
archiveReview(item, state, reason, closed) {
|
|
422
|
+
if (!item.finding || !item.severity)
|
|
423
|
+
throw new Error('Only numbered review findings can be archived.');
|
|
424
|
+
const records = this.closedReviews(item.scope);
|
|
425
|
+
const existing = records.find((record) => record.id === item.id);
|
|
426
|
+
if (existing && (existing.revision !== item.revision || existing.state !== state))
|
|
427
|
+
throw new Error('Archived review conflicts with the current file; inspect review history before updating.');
|
|
428
|
+
const record = existing ?? { id: item.id, finding: item.finding, severity: item.severity, scope: item.scope, title: item.title, state, closed, reason, source: item.source, revision: item.revision };
|
|
429
|
+
// Persist the number and closure before removing the full finding. A retry can finish deletion.
|
|
430
|
+
this.reserveFinding(item.scope, item.finding);
|
|
431
|
+
if (!existing)
|
|
432
|
+
this.atomicWrite(path.join(this.scopeDirectory(item.scope), 'review-history.jsonl'), [...records, record].map((entry) => JSON.stringify(entry)).join('\n') + '\n');
|
|
433
|
+
fs.unlinkSync(this.safePath(item.source));
|
|
434
|
+
return record;
|
|
435
|
+
}
|
|
436
|
+
archiveReviews(scope) {
|
|
437
|
+
return this.writeLocked(() => {
|
|
438
|
+
const history = this.closedReviews(scope);
|
|
439
|
+
return this.allKnowledge().filter((item) => item.scope === scope && item.type === 'review' && item.finding)
|
|
440
|
+
.filter((item) => item.state === 'fixed' || item.state === 'wontfix' || history.some((record) => record.id === item.id))
|
|
441
|
+
.map((item) => {
|
|
442
|
+
const prior = history.find((record) => record.id === item.id);
|
|
443
|
+
return this.archiveReview(item, prior?.state ?? item.state, prior?.reason ?? item.reason ?? '', prior?.closed ?? item.updated);
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
resolveReview(id, revision, state, reason) {
|
|
448
|
+
return this.writeLocked(() => {
|
|
449
|
+
const item = this.get(id);
|
|
450
|
+
if (item.type !== 'review' || !item.finding)
|
|
451
|
+
throw new Error('Only review findings can be resolved.');
|
|
452
|
+
if (item.revision !== revision)
|
|
453
|
+
throw new Error('Knowledge changed; read the current revision before updating.');
|
|
454
|
+
return this.archiveReview(item, state, reason ?? '', new Date().toISOString());
|
|
455
|
+
});
|
|
456
|
+
}
|
|
231
457
|
deprecate(id, revision, reason) {
|
|
232
458
|
return this.writeLocked(() => {
|
|
233
459
|
const item = this.get(id);
|
|
@@ -248,15 +474,18 @@ class BrainStore {
|
|
|
248
474
|
if (item.scope === scope)
|
|
249
475
|
throw new Error('Knowledge already belongs to this scope.');
|
|
250
476
|
const { source: _source, revision: _revision, content, ...metadata } = item;
|
|
251
|
-
const
|
|
252
|
-
|
|
477
|
+
const renumbered = item.severity ? { finding: this.nextFinding(scope, item.severity) } : {};
|
|
478
|
+
const promoted = { ...metadata, ...renumbered, id: (0, node_crypto_1.randomUUID)(), scope, authority: 'user', promoted_from: id, updated: new Date().toISOString() };
|
|
479
|
+
const target = path.join(this.scopeDirectory(scope), exports.knowledgeDirectories[item.type]);
|
|
480
|
+
this.atomicWrite(this.availableFile(target, path.basename(item.source, '.md'), promoted.id), `---\n${(0, yaml_1.stringify)(promoted)}---\n\n${content}\n`);
|
|
253
481
|
return this.get(promoted.id);
|
|
254
482
|
});
|
|
255
483
|
}
|
|
256
484
|
get(id) {
|
|
257
485
|
const item = this.allKnowledge().find((entry) => entry.id === id);
|
|
486
|
+
this.assertReadable(id, this.knowledgeErrors);
|
|
258
487
|
if (!item)
|
|
259
|
-
throw new Error(`Knowledge not found in the active scopes: ${id}`);
|
|
488
|
+
throw new Error(`Knowledge not found in the active scopes: ${id}${this.knowledgeErrors.length ? '. Some files are broken; list or search knowledge for their paths and errors' : ''}`);
|
|
260
489
|
return item;
|
|
261
490
|
}
|
|
262
491
|
}
|
package/dist/brain/todos.js
CHANGED
|
@@ -26,17 +26,42 @@ exports.todoInput = zod_1.z.object({
|
|
|
26
26
|
const remoteTask = exports.todoInput.pick({ title: true, description: true, status: true, labels: true, due: true }).strip();
|
|
27
27
|
const todoMetadata = exports.todoInput.omit({ description: true }).extend({ id: zod_1.z.uuid(), created: zod_1.z.string(), updated: zod_1.z.string(), remote: remoteTask.optional() });
|
|
28
28
|
function listTodos(store) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
29
|
+
store.todoErrors.length = 0;
|
|
30
|
+
const sources = new Map();
|
|
31
|
+
const todos = store.scopes.flatMap(({ scope, directory }) => store.files(path.join(directory, 'todo'), { onError: (file, error) => store.todoErrors.push(store.brokenFile(file, error)) }).flatMap((file) => {
|
|
32
|
+
let id;
|
|
33
|
+
try {
|
|
34
|
+
const text = store.read(file);
|
|
35
|
+
const document = (0, store_1.readMarkdown)(text);
|
|
36
|
+
if (document.metadata && typeof document.metadata === 'object' && 'id' in document.metadata && typeof document.metadata.id === 'string')
|
|
37
|
+
id = document.metadata.id;
|
|
38
|
+
const metadata = todoMetadata.parse(document.metadata);
|
|
39
|
+
if (metadata.scope !== scope || path.basename(file) !== `${metadata.id}.md`)
|
|
40
|
+
throw new Error(`Todo metadata does not match its location: ${file}`);
|
|
41
|
+
const todo = { ...metadata, description: document.content, revision: (0, node_crypto_1.createHash)('sha256').update(text).digest('hex') };
|
|
42
|
+
sources.set(todo, file);
|
|
43
|
+
return [todo];
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
store.todoErrors.push(store.brokenFile(file, error, id));
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
36
49
|
}));
|
|
50
|
+
const counts = new Map();
|
|
51
|
+
for (const item of [...todos, ...store.todoErrors]) {
|
|
52
|
+
if (item.id)
|
|
53
|
+
counts.set(item.id, (counts.get(item.id) ?? 0) + 1);
|
|
54
|
+
}
|
|
55
|
+
return todos.filter((todo) => {
|
|
56
|
+
if (counts.get(todo.id) === 1)
|
|
57
|
+
return true;
|
|
58
|
+
store.todoErrors.push(store.brokenFile(sources.get(todo), new Error(`Duplicate todo id: ${todo.id}`), todo.id));
|
|
59
|
+
return false;
|
|
60
|
+
});
|
|
37
61
|
}
|
|
38
62
|
function getTodo(store, id) {
|
|
39
63
|
const todo = listTodos(store).find((item) => item.id === id);
|
|
64
|
+
store.assertReadable(id, store.todoErrors);
|
|
40
65
|
if (!todo)
|
|
41
66
|
throw new Error(`Todo not found: ${id}`);
|
|
42
67
|
return todo;
|
|
@@ -54,12 +79,25 @@ function addTodo(store, input) {
|
|
|
54
79
|
const existing = listTodos(store).find((item) => item.scope === value.scope && item.source?.type === value.source?.type && item.source?.external_id === value.source?.external_id);
|
|
55
80
|
if (existing)
|
|
56
81
|
return existing;
|
|
82
|
+
if (store.todoErrors.length)
|
|
83
|
+
throw new Error('Cannot deduplicate an external task while todo files are broken. List tasks and correct the reported files first.');
|
|
57
84
|
}
|
|
58
85
|
const now = new Date().toISOString();
|
|
59
86
|
return writeTodo(store, { ...value, id: (0, node_crypto_1.randomUUID)(), created: now, updated: now });
|
|
60
87
|
});
|
|
61
88
|
}
|
|
62
|
-
|
|
89
|
+
// Spelled out rather than todoInput.omit(...).partial(): .partial() keeps .default(), so
|
|
90
|
+
// updating one field would reset description, priority, labels, and notes to their defaults.
|
|
91
|
+
exports.todoChanges = zod_1.z.object({
|
|
92
|
+
title: zod_1.z.string().trim().min(1).max(300).optional(),
|
|
93
|
+
description: zod_1.z.string().trim().max(200000).optional(),
|
|
94
|
+
status: zod_1.z.enum(['todo', 'doing', 'blocked', 'done', 'cancelled']).optional(),
|
|
95
|
+
priority: zod_1.z.enum(['low', 'medium', 'high', 'critical']).optional(),
|
|
96
|
+
owner: zod_1.z.string().optional(),
|
|
97
|
+
due: zod_1.z.iso.date().optional(),
|
|
98
|
+
labels: zod_1.z.array(zod_1.z.string()).optional(),
|
|
99
|
+
notes: zod_1.z.string().optional()
|
|
100
|
+
}).strict();
|
|
63
101
|
function updateTodo(store, id, revision, changes) {
|
|
64
102
|
const parsed = exports.todoChanges.parse(changes);
|
|
65
103
|
return store.writeLocked(() => {
|
|
@@ -75,6 +113,8 @@ function importTodos(store, input) {
|
|
|
75
113
|
return store.writeLocked(() => {
|
|
76
114
|
const result = { imported: [], updated: [], conflicts: [] };
|
|
77
115
|
const existing = listTodos(store);
|
|
116
|
+
if (store.todoErrors.length)
|
|
117
|
+
throw new Error('Cannot import tasks while todo files are broken. List tasks and correct the reported files first.');
|
|
78
118
|
for (const item of incoming) {
|
|
79
119
|
if (!item.source)
|
|
80
120
|
throw new Error('Imported tasks require an external source');
|
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.parseInvocation = parseInvocation;
|
|
4
|
-
const fs = require("fs");
|
|
5
4
|
const path = require("path");
|
|
6
5
|
const constants_1 = require("./constants");
|
|
6
|
+
const version_1 = require("./version");
|
|
7
7
|
const registry_1 = require("./agents/registry");
|
|
8
8
|
const types_1 = require("./agents/types");
|
|
9
9
|
const project_1 = require("./project");
|
|
@@ -299,7 +299,7 @@ function isVersionFlag(value) {
|
|
|
299
299
|
return value === '-V' || value === '--version';
|
|
300
300
|
}
|
|
301
301
|
function printVersion() {
|
|
302
|
-
process.stdout.write(`agent-run ${
|
|
302
|
+
process.stdout.write(`agent-run ${(0, version_1.packageVersion)()}\n`);
|
|
303
303
|
process.exit(0);
|
|
304
304
|
}
|
|
305
305
|
function printHelp(topic) {
|
|
@@ -347,7 +347,7 @@ function renderHelp(topic) {
|
|
|
347
347
|
return help[topic].join('\n');
|
|
348
348
|
}
|
|
349
349
|
return [
|
|
350
|
-
`agent-run ${
|
|
350
|
+
`agent-run ${(0, version_1.packageVersion)()}`,
|
|
351
351
|
'',
|
|
352
352
|
'Usage:',
|
|
353
353
|
' agent-run <codex|claude|gemini|grok|check|status|setup|generate|edit|update> [options]',
|
|
@@ -393,15 +393,6 @@ function renderHelp(topic) {
|
|
|
393
393
|
''
|
|
394
394
|
].join('\n');
|
|
395
395
|
}
|
|
396
|
-
function agentRunVersion() {
|
|
397
|
-
try {
|
|
398
|
-
const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', 'package.json'), 'utf8'));
|
|
399
|
-
return typeof pkg.version === 'string' && pkg.version ? pkg.version : constants_1.PACKAGE_VERSION;
|
|
400
|
-
}
|
|
401
|
-
catch {
|
|
402
|
-
return constants_1.PACKAGE_VERSION;
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
396
|
function normalizeCommandName(value) {
|
|
406
397
|
if (!value) {
|
|
407
398
|
return null;
|