@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.
@@ -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
- exports.knowledgeMetadata = exports.knowledgeInput.omit({ content: true }).extend({
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
- this.safePath(directory);
93
- if (!fs.existsSync(directory))
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
- return fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)).flatMap((entry) => {
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
- const text = this.read(file);
147
- const document = readMarkdown(text);
148
- const source = path.relative(this.configRoot, file);
149
- const legacy = document.metadata !== null && typeof document.metadata === 'object' && !Array.isArray(document.metadata)
150
- && !('id' in document.metadata) && !('type' in document.metadata);
151
- const legacyStatus = legacy ? document.metadata.status : undefined;
152
- const metadata = document.metadata === null || legacy ? {
153
- id: `document-${hash(source).slice(0, 24)}`, scope, type,
154
- title: /^#\s+(.+)$/m.exec(document.content)?.[1] ?? path.basename(file, '.md'),
155
- status: type === 'review' && ['addressed', 'resolved', 'done', 'closed'].includes(String(legacyStatus)) ? 'deprecated' : 'active',
156
- created: '', updated: ''
157
- } : { title: /^#\s+(.+)$/m.exec(document.content)?.[1] ?? path.basename(file, '.md'), ...document.metadata };
158
- const parsed = exports.knowledgeMetadata.safeParse(metadata);
159
- if (!parsed.success)
160
- throw new Error(`Invalid knowledge metadata in ${source}: ${parsed.error.message}`);
161
- if (parsed.data.scope !== scope)
162
- throw new Error(`Knowledge scope does not match its directory: ${source}`);
163
- result.push({ ...parsed.data, content: legacy ? text.trim() : document.content, source, revision: hash(text) });
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 ids = new Set();
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 (ids.has(item.id))
170
- throw new Error(`Duplicate knowledge id: ${item.id}`);
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
- const items = this.allKnowledge();
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
- this.db.prepare('DELETE FROM knowledge WHERE environment = ?').run(environment);
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 items)
183
- insert.run(item.source, environment, item.title, item.content, item.tags.join(' '));
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 all = this.allKnowledge().filter((item) => item.status === 'active');
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.search([task, ...symbols].join(' '))].map((item) => [item.id, item])).values()];
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 item = { ...metadata, id: (0, node_crypto_1.randomUUID)(), status: 'active', created: now, updated: now };
226
- const file = path.join(this.scopeDirectory(value.scope), exports.knowledgeDirectories[value.type], `${item.id}.md`);
227
- this.atomicWrite(file, `---\n${(0, yaml_1.stringify)(item)}---\n\n${content}\n`);
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 promoted = { ...metadata, id: (0, node_crypto_1.randomUUID)(), scope, authority: 'user', promoted_from: id, updated: new Date().toISOString() };
252
- this.atomicWrite(path.join(this.scopeDirectory(scope), exports.knowledgeDirectories[item.type], `${promoted.id}.md`), `---\n${(0, yaml_1.stringify)(promoted)}---\n\n${content}\n`);
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
  }
@@ -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
- return store.scopes.flatMap(({ scope, directory }) => store.files(path.join(directory, 'todo')).map((file) => {
30
- const text = store.read(file);
31
- const document = (0, store_1.readMarkdown)(text);
32
- const metadata = todoMetadata.parse(document.metadata);
33
- if (metadata.scope !== scope || path.basename(file) !== `${metadata.id}.md`)
34
- throw new Error(`Todo metadata does not match its location: ${file}`);
35
- return { ...metadata, description: document.content, revision: (0, node_crypto_1.createHash)('sha256').update(text).digest('hex') };
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
- exports.todoChanges = exports.todoInput.omit({ scope: true, source: true }).partial();
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 ${agentRunVersion()}\n`);
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 ${agentRunVersion()}`,
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;