@technomoron/agent-run 0.99.32 → 0.99.34

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.
@@ -50,6 +50,12 @@ exports.knowledgeChanges = zod_1.z.object({
50
50
  tags: zod_1.z.array(zod_1.z.string().max(100)).max(50).optional(),
51
51
  applies_to: zod_1.z.array(zod_1.z.string().max(500)).max(50).optional()
52
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();
53
59
  exports.knowledgeDirectories = {
54
60
  rule: 'rules', preference: 'preferences', convention: 'conventions', constraint: 'constraints',
55
61
  decision: 'decisions', spec: 'specs', review: 'reviews', memory: 'memory', observation: 'observations'
@@ -57,14 +63,35 @@ exports.knowledgeDirectories = {
57
63
  function readMarkdown(text) {
58
64
  const normalized = text.replace(/\r\n/g, '\n');
59
65
  const match = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(normalized);
66
+ if (normalized.startsWith('---\n') && !match)
67
+ throw new Error('Unterminated YAML front matter');
60
68
  return match ? { metadata: (0, yaml_1.parse)(match[1], { maxAliasCount: 50 }), content: normalized.slice(match[0].length).trim() }
61
69
  : { metadata: null, content: normalized.trim() };
62
70
  }
63
71
  function hash(text) { return (0, node_crypto_1.createHash)('sha256').update(text).digest('hex'); }
64
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
+ }
65
90
  constructor(configRoot, cwd) {
66
91
  this.configRoot = configRoot;
67
92
  this.cwd = cwd;
93
+ this.knowledgeErrors = [];
94
+ this.todoErrors = [];
68
95
  this.configRoot = path.resolve(configRoot);
69
96
  this.projectRoot = (0, config_1.registeredBrainProject)(this.configRoot, cwd)?.root ?? (0, project_1.findProjectRoot)(cwd);
70
97
  const resolved = (0, project_1.resolveProfileResult)(this.projectRoot, this.configRoot).profile;
@@ -107,14 +134,31 @@ class BrainStore {
107
134
  throw new Error(`Scope ${scope} is not available in this environment`);
108
135
  return this.safePath(directory);
109
136
  }
110
- files(directory) {
111
- this.safePath(directory);
112
- 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);
113
160
  return [];
114
- return fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)).flatMap((entry) => {
115
- const file = this.safePath(directory, entry.name);
116
- return entry.isDirectory() ? this.files(file) : entry.isFile() && entry.name.endsWith('.md') ? [file] : [];
117
- });
161
+ }
118
162
  }
119
163
  read(file) {
120
164
  const target = this.safePath(file);
@@ -155,72 +199,113 @@ class BrainStore {
155
199
  }
156
200
  }
157
201
  allKnowledge() {
202
+ this.knowledgeErrors.length = 0;
158
203
  const result = [];
159
204
  for (const { scope, directory } of this.scopes) {
160
205
  const directories = [...Object.entries(exports.knowledgeDirectories), ['memory', path.join('notes', 'memory')]];
161
206
  if (scope === 'global')
162
207
  directories.push(['memory', path.relative(directory, path.join(this.configRoot, 'notes', 'memory'))]);
163
208
  for (const [type, folder] of directories) {
164
- for (const file of this.files(path.join(directory, folder))) {
165
- const text = this.read(file);
166
- const document = readMarkdown(text);
167
- const source = path.relative(this.configRoot, file);
168
- const legacy = document.metadata !== null && typeof document.metadata === 'object' && !Array.isArray(document.metadata)
169
- && !('id' in document.metadata) && !('type' in document.metadata);
170
- const legacyStatus = legacy ? document.metadata.status : undefined;
171
- const metadata = document.metadata === null || legacy ? {
172
- id: `document-${hash(source).slice(0, 24)}`, scope, type,
173
- title: /^#\s+(.+)$/m.exec(document.content)?.[1] ?? path.basename(file, '.md'),
174
- status: type === 'review' && ['addressed', 'resolved', 'done', 'closed'].includes(String(legacyStatus)) ? 'deprecated' : 'active',
175
- created: '', updated: ''
176
- } : { title: /^#\s+(.+)$/m.exec(document.content)?.[1] ?? path.basename(file, '.md'), ...document.metadata };
177
- const parsed = exports.knowledgeMetadata.safeParse(metadata);
178
- if (!parsed.success)
179
- throw new Error(`Invalid knowledge metadata in ${source}: ${parsed.error.message}`);
180
- if (parsed.data.scope !== scope)
181
- throw new Error(`Knowledge scope does not match its directory: ${source}`);
182
- 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
+ }
183
236
  }
184
237
  }
185
238
  }
186
- 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
+ }
187
244
  for (const item of result) {
188
- if (ids.has(item.id))
189
- throw new Error(`Duplicate knowledge id: ${item.id}`);
190
- 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));
191
247
  }
192
- return result;
248
+ return result.filter((item) => counts.get(item.id) === 1);
193
249
  }
194
250
  search(query, options = {}) {
195
- const items = this.allKnowledge();
251
+ return this.searchItems(this.allKnowledge(), query, options);
252
+ }
253
+ searchItems(items, query, options = {}) {
196
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;
197
283
  this.db.exec('BEGIN IMMEDIATE');
198
284
  try {
199
- 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);
200
290
  const insert = this.db.prepare('INSERT INTO knowledge(source, environment, title, content, tags) VALUES (?, ?, ?, ?, ?)');
201
- for (const item of items)
202
- insert.run(item.source, environment, item.title, item.content, [...item.tags, item.finding ?? ''].join(' ').trim());
291
+ for (const item of added)
292
+ insert.run(item.source, environment, item.title, item.content, item.tags);
203
293
  this.db.exec('COMMIT');
204
294
  }
205
295
  catch (error) {
206
296
  this.db.exec('ROLLBACK');
207
297
  throw error;
208
298
  }
209
- const terms = query.match(/[\p{L}\p{N}_-]+/gu)?.slice(0, 50) ?? [];
210
- 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')
211
- .all(terms.map((term) => `"${term.replace(/"/g, '""')}"`).join(' OR '), environment).map((row) => String(row.source)) : items.map((item) => item.source);
212
- const bySource = new Map(items.map((item) => [item.source, item]));
213
- return ranked.map((source) => bySource.get(source)).filter((item) => (options.includeDeprecated || item.status === 'active') && (!options.scopes || options.scopes.includes(item.scope)) &&
214
- (!options.types || options.types.includes(item.type))).slice(0, options.limit ?? 50);
215
299
  }
216
300
  context(task, files = [], symbols = []) {
217
- const all = this.allKnowledge().filter((item) => item.status === 'active');
301
+ const scanned = this.allKnowledge();
302
+ const all = scanned.filter((item) => item.status === 'active');
218
303
  const always = all.filter((item) => item.recall === 'always');
219
304
  const matching = all.filter((item) => item.applies_to.some((pattern) => files.some((file) => {
220
305
  const relative = path.isAbsolute(file) ? path.relative(this.projectRoot, file) : file;
221
306
  return path.matchesGlob(relative, pattern);
222
307
  })));
223
- 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()];
224
309
  let size = 0;
225
310
  const items = selected.filter((item) => {
226
311
  const length = JSON.stringify(item).length;
@@ -283,7 +368,13 @@ class BrainStore {
283
368
  .filter((item) => item.scope === scope && item.type === 'review' && item.finding?.startsWith(prefix))
284
369
  .map((item) => Number.parseInt(item.finding.slice(prefix.length), 10))
285
370
  .filter((sequence) => Number.isSafeInteger(sequence));
286
- return `${prefix}${Math.max(0, ...used) + 1}`;
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}`;
287
378
  }
288
379
  /** Review findings in severity then sequence order. Open findings only unless states are given. */
289
380
  reviews(options = {}) {
@@ -294,6 +385,65 @@ class BrainStore {
294
385
  .filter((item) => (options.state ?? ['open']).includes(item.state ?? 'open'))
295
386
  .sort((left, right) => severityOrder[left.severity] - severityOrder[right.severity] || sequence(left) - sequence(right));
296
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
+ }
297
447
  resolveReview(id, revision, state, reason) {
298
448
  return this.writeLocked(() => {
299
449
  const item = this.get(id);
@@ -301,9 +451,7 @@ class BrainStore {
301
451
  throw new Error('Only review findings can be resolved.');
302
452
  if (item.revision !== revision)
303
453
  throw new Error('Knowledge changed; read the current revision before updating.');
304
- const { source, revision: _revision, content, ...metadata } = item;
305
- this.atomicWrite(source, `---\n${(0, yaml_1.stringify)({ ...metadata, state, status: 'deprecated', updated: new Date().toISOString(), ...(reason ? { reason } : {}) })}---\n\n${content}\n`);
306
- return this.get(id);
454
+ return this.archiveReview(item, state, reason ?? '', new Date().toISOString());
307
455
  });
308
456
  }
309
457
  deprecate(id, revision, reason) {
@@ -335,8 +483,9 @@ class BrainStore {
335
483
  }
336
484
  get(id) {
337
485
  const item = this.allKnowledge().find((entry) => entry.id === id);
486
+ this.assertReadable(id, this.knowledgeErrors);
338
487
  if (!item)
339
- 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' : ''}`);
340
489
  return item;
341
490
  }
342
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,6 +79,8 @@ 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 });
@@ -86,6 +113,8 @@ function importTodos(store, input) {
86
113
  return store.writeLocked(() => {
87
114
  const result = { imported: [], updated: [], conflicts: [] };
88
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.');
89
118
  for (const item of incoming) {
90
119
  if (!item.source)
91
120
  throw new Error('Imported tasks require an external source');
package/dist/commands.js CHANGED
@@ -89,15 +89,15 @@ function runTool(parsed) {
89
89
  prepareConfigRoot(configRoot);
90
90
  requireProfile(command, agentDir);
91
91
  migrateProfileOnStart(configRoot, agentDir, false);
92
- const preview = (0, renderer_1.renderProfile)(projectRoot, agentDir, true, command);
92
+ const preview = (0, renderer_1.renderProfile)(projectRoot, agentDir, true);
93
93
  ensureToolEnabled(preview.context, command);
94
94
  if (wrapperArgs.generate) {
95
- printUpdateSummary((0, renderer_1.syncAgentProfile)(projectRoot, agentDir));
95
+ printUpdateSummary((0, renderer_1.syncRenderedProfile)(preview));
96
96
  return;
97
97
  }
98
98
  checkLocalAiFiles(command, projectRoot, agentDir, preview.context, wrapperArgs.local);
99
99
  const realBinary = (0, process_1.findRealBinary)(command);
100
- const rendered = (0, renderer_1.syncAgentProfile)(projectRoot, agentDir);
100
+ const rendered = (0, renderer_1.syncRenderedProfile)(preview);
101
101
  const runtime = rendered.runtimes[command];
102
102
  if (!runtime) {
103
103
  (0, utils_1.fail)(`no generated ${command} runtime found for profile ${rendered.profile}`);
@@ -186,7 +186,7 @@ function runGenerate(parsed) {
186
186
  if (projectRoot === null) {
187
187
  return;
188
188
  }
189
- const agentDir = initializeProfileSource(projectRoot, true);
189
+ const agentDir = initializeProfileSource(projectRoot);
190
190
  migrateProfileOnStart((0, project_1.defaultConfigRoot)(projectRoot), agentDir, false);
191
191
  printUpdateSummary((0, renderer_1.syncAgentProfile)(projectRoot, agentDir));
192
192
  }
@@ -200,7 +200,6 @@ function runSetup(parsed) {
200
200
  (0, utils_1.fail)(`starter config skeleton not found: ${sourcePath}`);
201
201
  }
202
202
  (0, config_tree_1.copySkeletonTree)(sourcePath, configRoot, new Set(['starter']));
203
- (0, config_tree_1.ensurePortableSystemdFiles)(configRoot);
204
203
  const agentDir = path.join(configRoot, profile);
205
204
  migrateProfileOnStart(configRoot, agentDir, false);
206
205
  (0, config_tree_1.copySkeletonTree)(path.join(sourcePath, 'starter', 'basic-project'), agentDir);
@@ -265,7 +264,7 @@ function runEdit(parsed) {
265
264
  if (projectRoot === null) {
266
265
  return;
267
266
  }
268
- const agentDir = initializeProfileSource(projectRoot, false);
267
+ const agentDir = initializeProfileSource(projectRoot);
269
268
  migrateProfileOnStart((0, project_1.defaultConfigRoot)(projectRoot), agentDir, false);
270
269
  const editPath = (0, manifest_1.createDefaultLocalFile)(agentDir);
271
270
  (0, renderer_1.syncAgentProfile)(projectRoot, agentDir);
@@ -387,7 +386,6 @@ function prepareConfigRoot(configRoot) {
387
386
  (0, config_tree_1.ensureConfigRootLayout)(configRoot);
388
387
  (0, config_tree_1.ensureConfigRootGitignore)(configRoot);
389
388
  (0, config_tree_1.ensureDefaultGlobalTemplates)(configRoot);
390
- (0, config_tree_1.ensurePortableSystemdFiles)(configRoot);
391
389
  }
392
390
  function activeProject(targetPath, action) {
393
391
  const projectRoot = (0, project_1.findProjectRoot)(targetPath);
@@ -398,8 +396,8 @@ function activeProject(targetPath, action) {
398
396
  process.stdout.write(`SKIP ignored ${projectRoot}\n`);
399
397
  return null;
400
398
  }
401
- function initializeProfileSource(projectRoot, preferProjectConfigRoot) {
402
- const configRoot = (0, project_1.defaultConfigRoot)(projectRoot, { preferProjectRoot: preferProjectConfigRoot });
399
+ function initializeProfileSource(projectRoot) {
400
+ const configRoot = (0, project_1.defaultConfigRoot)(projectRoot);
403
401
  const resolved = (0, project_1.resolveProfileResult)(projectRoot, configRoot, false);
404
402
  if (!resolved.profile)
405
403
  throw new Error(resolved.reason);
@@ -11,7 +11,7 @@ const templates_1 = require("../templates");
11
11
  function buildCanonicalInstructions(env, configRoot, manifest, context, trace) {
12
12
  const sections = [(0, templates_1.renderTemplateFile)(env, configRoot, manifest.agent.base, context, trace)];
13
13
  for (const include of manifest.agent.includes) {
14
- const includePath = (0, templates_1.resolveConfigPath)(configRoot, include, context);
14
+ const includePath = (0, templates_1.resolveConfigPath)(configRoot, include, context, env);
15
15
  if (!fs.existsSync(includePath)) {
16
16
  if (include.includes('AGENTS-MODS.md') || include.includes(constants_1.LOCAL_TEMPLATE_FILE_NAME)) {
17
17
  continue;
@@ -45,6 +45,8 @@ function renderJsonMcp(servers, target) {
45
45
  function renderTomlMcp(servers, target) {
46
46
  const sections = [];
47
47
  for (const [name, server] of Object.entries(servers)) {
48
+ if (!server.enabled)
49
+ continue;
48
50
  const lines = [`[mcp_servers.${tomlKey(name)}]`];
49
51
  if (server.transport === 'stdio') {
50
52
  lines.push(`command = ${tomlString(server.command ?? '')}`);
@@ -64,9 +66,6 @@ function renderTomlMcp(servers, target) {
64
66
  lines.push(`${target === 'codex' ? 'http_headers' : 'headers'} = ${tomlInlineTable(server.headers)}`);
65
67
  }
66
68
  }
67
- if (!server.enabled) {
68
- lines.push('enabled = false');
69
- }
70
69
  sections.push(lines.join('\n'));
71
70
  }
72
71
  return sections.length > 0 ? `${sections.join('\n\n')}\n` : '';
@@ -7,14 +7,15 @@ const fs = require("fs");
7
7
  const path = require("path");
8
8
  const templates_1 = require("../templates");
9
9
  function buildCanonicalSkills(env, configRoot, manifest, context, trace) {
10
+ const skillEnv = (0, templates_1.createSkillNunjucksEnv)(configRoot, context.profileDir, trace);
10
11
  return manifest.skills.install.map((name) => {
11
12
  const canonical = `global/skills/${name}/SKILL.md`;
12
13
  const sourceTemplate = manifest.skills.overrides[name] ?? (fs.existsSync(path.join(configRoot, canonical)) ? canonical : `${canonical}.njk`);
13
- const sourcePath = (0, templates_1.resolveConfigPath)(configRoot, sourceTemplate, context);
14
+ const sourcePath = (0, templates_1.resolveConfigPath)(configRoot, sourceTemplate, context, env);
14
15
  if (!fs.existsSync(sourcePath)) {
15
16
  throw new Error(`missing skill template for ${name}: ${sourcePath}`);
16
17
  }
17
- const renderedContent = (0, templates_1.renderTemplateFile)(env, configRoot, sourceTemplate, context, trace);
18
+ const renderedContent = skillEnv.render(path.relative(configRoot, sourcePath), context);
18
19
  (0, templates_1.assertNoUnexpandedTemplateVars)(`skill ${name}`, renderedContent);
19
20
  validateRenderedSkill(renderedContent, sourcePath);
20
21
  return { name, sourcePath, renderedContent, description: extractSkillDescription(renderedContent) };
@@ -3,7 +3,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ensureConfigRootLayout = ensureConfigRootLayout;
4
4
  exports.ensureConfigRootGitignore = ensureConfigRootGitignore;
5
5
  exports.ensureDefaultGlobalTemplates = ensureDefaultGlobalTemplates;
6
- exports.ensurePortableSystemdFiles = ensurePortableSystemdFiles;
7
6
  exports.migrateOldTemplates = migrateOldTemplates;
8
7
  exports.findLegacyProfileDirs = findLegacyProfileDirs;
9
8
  exports.findProfileDirs = findProfileDirs;
@@ -75,27 +74,6 @@ function ensureDefaultGlobalTemplates(configRoot) {
75
74
  }
76
75
  }
77
76
  }
78
- function ensurePortableSystemdFiles(configRoot) {
79
- const packageRoot = path.resolve(__dirname, '..');
80
- const files = new Map([
81
- ['install-systemd-jobs.sh', { source: 'scripts/install-systemd-jobs.sh', mode: 0o755 }],
82
- ['update-ai-tools.sh', { source: 'scripts/update-ai-tools.sh', mode: 0o755 }],
83
- ['ensure-agent-brain.sh', { source: 'scripts/ensure-agent-brain.sh', mode: 0o755 }],
84
- ['agent-brain.service', { source: 'ops/systemd/agent-brain.service', mode: 0o644 }],
85
- ['ai-tools-update.service', { source: 'ops/systemd/ai-tools-update.service', mode: 0o644 }],
86
- ['ai-tools-update.timer', { source: 'ops/systemd/ai-tools-update.timer', mode: 0o644 }]
87
- ]);
88
- for (const [targetName, asset] of files) {
89
- const sourcePath = path.join(packageRoot, asset.source);
90
- const targetPath = path.join(configRoot, targetName);
91
- if (!fs.existsSync(sourcePath)) {
92
- continue;
93
- }
94
- fs.copyFileSync(sourcePath, targetPath);
95
- fs.chmodSync(targetPath, asset.mode);
96
- (0, utils_1.verbose)(`updated ${targetPath}`);
97
- }
98
- }
99
77
  function migrateOldTemplates(configRoot) {
100
78
  const oldCodeTemplate = path.join(configRoot, 'templates', 'AGENTS-CODE.md');
101
79
  const newCodeTemplate = path.join(configRoot, 'global', 'agents', 'code.md.njk');