@karmaniverous/jeeves-meta 0.8.0 → 0.10.0
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/README.md +4 -2
- package/dist/cli/jeeves-meta/index.js +482 -225
- package/dist/index.d.ts +85 -55
- package/dist/index.js +478 -225
- package/package.json +1 -1
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
|
-
import fs, { readFileSync, readdirSync, unlinkSync,
|
|
4
|
-
import path, { dirname, join, resolve, relative } from 'node:path';
|
|
3
|
+
import fs, { readFileSync, readdirSync, unlinkSync, existsSync, writeFileSync, statSync, mkdirSync, watchFile } from 'node:fs';
|
|
4
|
+
import path, { dirname, join, resolve, relative, posix } from 'node:path';
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
|
-
import 'node:fs/promises';
|
|
7
|
+
import { unlink, readFile, mkdir, writeFile, copyFile } from 'node:fs/promises';
|
|
8
8
|
import process$1 from 'node:process';
|
|
9
9
|
import { createHash, randomUUID } from 'node:crypto';
|
|
10
10
|
import { tmpdir } from 'node:os';
|
|
@@ -72,6 +72,15 @@ const loggingSchema = z.object({
|
|
|
72
72
|
/** Optional file path for log output. */
|
|
73
73
|
file: z.string().optional(),
|
|
74
74
|
});
|
|
75
|
+
/** Zod schema for a single auto-seed policy rule. */
|
|
76
|
+
const autoSeedRuleSchema = z.object({
|
|
77
|
+
/** Glob pattern matched against watcher walk results. */
|
|
78
|
+
match: z.string(),
|
|
79
|
+
/** Optional steering prompt for seeded metas. */
|
|
80
|
+
steer: z.string().optional(),
|
|
81
|
+
/** Optional cross-references for seeded metas. */
|
|
82
|
+
crossRefs: z.array(z.string()).optional(),
|
|
83
|
+
});
|
|
75
84
|
/** Zod schema for jeeves-meta service configuration (superset of MetaConfig). */
|
|
76
85
|
const serviceConfigSchema = metaConfigSchema.extend({
|
|
77
86
|
/** HTTP port for the service (default: 1938). */
|
|
@@ -88,6 +97,11 @@ const serviceConfigSchema = metaConfigSchema.extend({
|
|
|
88
97
|
watcherHealthIntervalMs: z.number().int().min(0).default(60_000),
|
|
89
98
|
/** Logging configuration. */
|
|
90
99
|
logging: loggingSchema.default(() => loggingSchema.parse({})),
|
|
100
|
+
/**
|
|
101
|
+
* Auto-seed policy: declarative rules for auto-creating .meta/ directories.
|
|
102
|
+
* Rules are evaluated in order; last match wins for steer/crossRefs.
|
|
103
|
+
*/
|
|
104
|
+
autoSeed: z.array(autoSeedRuleSchema).optional().default([]),
|
|
91
105
|
});
|
|
92
106
|
|
|
93
107
|
/**
|
|
@@ -338,14 +352,12 @@ function listArchiveFiles(metaPath) {
|
|
|
338
352
|
* @param maxArchive - Maximum snapshots to retain.
|
|
339
353
|
* @returns Number of files pruned.
|
|
340
354
|
*/
|
|
341
|
-
function pruneArchive(metaPath, maxArchive) {
|
|
355
|
+
async function pruneArchive(metaPath, maxArchive) {
|
|
342
356
|
const files = listArchiveFiles(metaPath);
|
|
343
357
|
const toRemove = files.length - maxArchive;
|
|
344
358
|
if (toRemove <= 0)
|
|
345
359
|
return 0;
|
|
346
|
-
|
|
347
|
-
unlinkSync(files[i]);
|
|
348
|
-
}
|
|
360
|
+
await Promise.all(files.slice(0, toRemove).map(unlink));
|
|
349
361
|
return toRemove;
|
|
350
362
|
}
|
|
351
363
|
|
|
@@ -360,11 +372,11 @@ function pruneArchive(metaPath, maxArchive) {
|
|
|
360
372
|
* @param metaPath - Absolute path to the .meta directory.
|
|
361
373
|
* @returns The latest archived meta, or null if no archives exist.
|
|
362
374
|
*/
|
|
363
|
-
function readLatestArchive(metaPath) {
|
|
375
|
+
async function readLatestArchive(metaPath) {
|
|
364
376
|
const files = listArchiveFiles(metaPath);
|
|
365
377
|
if (files.length === 0)
|
|
366
378
|
return null;
|
|
367
|
-
const raw =
|
|
379
|
+
const raw = await readFile(files[files.length - 1], 'utf8');
|
|
368
380
|
return JSON.parse(raw);
|
|
369
381
|
}
|
|
370
382
|
|
|
@@ -383,9 +395,9 @@ function readLatestArchive(metaPath) {
|
|
|
383
395
|
* @param meta - Current meta.json content.
|
|
384
396
|
* @returns The archive file path.
|
|
385
397
|
*/
|
|
386
|
-
function createSnapshot(metaPath, meta) {
|
|
398
|
+
async function createSnapshot(metaPath, meta) {
|
|
387
399
|
const archiveDir = join(metaPath, 'archive');
|
|
388
|
-
|
|
400
|
+
await mkdir(archiveDir, { recursive: true });
|
|
389
401
|
const now = new Date().toISOString().replace(/[:.]/g, '-');
|
|
390
402
|
const archiveFile = join(archiveDir, now + '.json');
|
|
391
403
|
const archived = {
|
|
@@ -393,10 +405,79 @@ function createSnapshot(metaPath, meta) {
|
|
|
393
405
|
_archived: true,
|
|
394
406
|
_archivedAt: new Date().toISOString(),
|
|
395
407
|
};
|
|
396
|
-
|
|
408
|
+
await writeFile(archiveFile, JSON.stringify(archived, null, 2) + '\n');
|
|
397
409
|
return archiveFile;
|
|
398
410
|
}
|
|
399
411
|
|
|
412
|
+
/**
|
|
413
|
+
* Compute summary statistics from an array of MetaEntry objects.
|
|
414
|
+
*
|
|
415
|
+
* Shared between listMetas() (full list) and route handlers (filtered lists).
|
|
416
|
+
*
|
|
417
|
+
* @module discovery/computeSummary
|
|
418
|
+
*/
|
|
419
|
+
/**
|
|
420
|
+
* Compute summary statistics from a list of meta entries.
|
|
421
|
+
*
|
|
422
|
+
* @param entries - Enriched meta entries (full or filtered).
|
|
423
|
+
* @param depthWeight - Config depth weight for effective staleness calculation.
|
|
424
|
+
* @returns Aggregated summary statistics.
|
|
425
|
+
*/
|
|
426
|
+
function computeSummary(entries, depthWeight) {
|
|
427
|
+
let staleCount = 0;
|
|
428
|
+
let errorCount = 0;
|
|
429
|
+
let lockedCount = 0;
|
|
430
|
+
let neverSynthesizedCount = 0;
|
|
431
|
+
let totalArchitectTokens = 0;
|
|
432
|
+
let totalBuilderTokens = 0;
|
|
433
|
+
let totalCriticTokens = 0;
|
|
434
|
+
let stalestPath = null;
|
|
435
|
+
let stalestEffective = -1;
|
|
436
|
+
let lastSynthesizedPath = null;
|
|
437
|
+
let lastSynthesizedAt = null;
|
|
438
|
+
for (const e of entries) {
|
|
439
|
+
if (e.stalenessSeconds > 0)
|
|
440
|
+
staleCount++;
|
|
441
|
+
if (e.hasError)
|
|
442
|
+
errorCount++;
|
|
443
|
+
if (e.locked)
|
|
444
|
+
lockedCount++;
|
|
445
|
+
if (e.lastSynthesized === null)
|
|
446
|
+
neverSynthesizedCount++;
|
|
447
|
+
totalArchitectTokens += e.architectTokens ?? 0;
|
|
448
|
+
totalBuilderTokens += e.builderTokens ?? 0;
|
|
449
|
+
totalCriticTokens += e.criticTokens ?? 0;
|
|
450
|
+
// Track last synthesized
|
|
451
|
+
if (e.lastSynthesized &&
|
|
452
|
+
(!lastSynthesizedAt || e.lastSynthesized > lastSynthesizedAt)) {
|
|
453
|
+
lastSynthesizedAt = e.lastSynthesized;
|
|
454
|
+
lastSynthesizedPath = e.path;
|
|
455
|
+
}
|
|
456
|
+
// Track stalest (effective staleness for scheduling)
|
|
457
|
+
const depthFactor = Math.pow(1 + depthWeight, e.depth);
|
|
458
|
+
const effectiveStaleness = e.stalenessSeconds * depthFactor * e.emphasis;
|
|
459
|
+
if (effectiveStaleness > stalestEffective) {
|
|
460
|
+
stalestEffective = effectiveStaleness;
|
|
461
|
+
stalestPath = e.path;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return {
|
|
465
|
+
total: entries.length,
|
|
466
|
+
stale: staleCount,
|
|
467
|
+
errors: errorCount,
|
|
468
|
+
locked: lockedCount,
|
|
469
|
+
neverSynthesized: neverSynthesizedCount,
|
|
470
|
+
tokens: {
|
|
471
|
+
architect: totalArchitectTokens,
|
|
472
|
+
builder: totalBuilderTokens,
|
|
473
|
+
critic: totalCriticTokens,
|
|
474
|
+
},
|
|
475
|
+
stalestPath,
|
|
476
|
+
lastSynthesizedPath,
|
|
477
|
+
lastSynthesizedAt,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
400
481
|
/**
|
|
401
482
|
* Normalize file paths to forward slashes for consistency with watcher-indexed paths.
|
|
402
483
|
*
|
|
@@ -589,14 +670,15 @@ function cleanupStaleLocks(metaPaths, logger) {
|
|
|
589
670
|
* @module readMetaJson
|
|
590
671
|
*/
|
|
591
672
|
/**
|
|
592
|
-
* Read and parse a meta.json file from a `.meta/` directory path.
|
|
673
|
+
* Read and parse a meta.json file from a `.meta/` directory path (async).
|
|
593
674
|
*
|
|
594
675
|
* @param metaPath - Path to the `.meta/` directory.
|
|
595
676
|
* @returns Parsed meta.json content.
|
|
596
677
|
* @throws If the file doesn't exist or contains invalid JSON.
|
|
597
678
|
*/
|
|
598
|
-
function readMetaJson(metaPath) {
|
|
599
|
-
|
|
679
|
+
async function readMetaJson(metaPath) {
|
|
680
|
+
const raw = await readFile(join(metaPath, 'meta.json'), 'utf8');
|
|
681
|
+
return JSON.parse(raw);
|
|
600
682
|
}
|
|
601
683
|
|
|
602
684
|
/**
|
|
@@ -701,21 +783,10 @@ async function listMetas(config, watcher) {
|
|
|
701
783
|
const tree = buildOwnershipTree(metaPaths);
|
|
702
784
|
// Step 3: Read and enrich each meta from disk
|
|
703
785
|
const entries = [];
|
|
704
|
-
let staleCount = 0;
|
|
705
|
-
let errorCount = 0;
|
|
706
|
-
let lockedCount = 0;
|
|
707
|
-
let neverSynthesizedCount = 0;
|
|
708
|
-
let totalArchTokens = 0;
|
|
709
|
-
let totalBuilderTokens = 0;
|
|
710
|
-
let totalCriticTokens = 0;
|
|
711
|
-
let lastSynthPath = null;
|
|
712
|
-
let lastSynthAt = null;
|
|
713
|
-
let stalestPath = null;
|
|
714
|
-
let stalestEffective = -1;
|
|
715
786
|
for (const node of tree.nodes.values()) {
|
|
716
787
|
let meta;
|
|
717
788
|
try {
|
|
718
|
-
meta = readMetaJson(node.metaPath);
|
|
789
|
+
meta = await readMetaJson(node.metaPath);
|
|
719
790
|
}
|
|
720
791
|
catch {
|
|
721
792
|
// Skip unreadable metas
|
|
@@ -739,32 +810,6 @@ async function listMetas(config, watcher) {
|
|
|
739
810
|
const archTokens = meta._architectTokens ?? 0;
|
|
740
811
|
const buildTokens = meta._builderTokens ?? 0;
|
|
741
812
|
const critTokens = meta._criticTokens ?? 0;
|
|
742
|
-
// Accumulate summary stats
|
|
743
|
-
if (stalenessSeconds > 0)
|
|
744
|
-
staleCount++;
|
|
745
|
-
if (hasError)
|
|
746
|
-
errorCount++;
|
|
747
|
-
if (locked)
|
|
748
|
-
lockedCount++;
|
|
749
|
-
if (neverSynth)
|
|
750
|
-
neverSynthesizedCount++;
|
|
751
|
-
totalArchTokens += archTokens;
|
|
752
|
-
totalBuilderTokens += buildTokens;
|
|
753
|
-
totalCriticTokens += critTokens;
|
|
754
|
-
// Track last synthesized
|
|
755
|
-
if (meta._generatedAt) {
|
|
756
|
-
if (!lastSynthAt || meta._generatedAt > lastSynthAt) {
|
|
757
|
-
lastSynthAt = meta._generatedAt;
|
|
758
|
-
lastSynthPath = node.metaPath;
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
// Track stalest (effective staleness for scheduling)
|
|
762
|
-
const depthFactor = Math.pow(1 + config.depthWeight, depth);
|
|
763
|
-
const effectiveStaleness = stalenessSeconds * depthFactor * emphasis;
|
|
764
|
-
if (effectiveStaleness > stalestEffective) {
|
|
765
|
-
stalestEffective = effectiveStaleness;
|
|
766
|
-
stalestPath = node.metaPath;
|
|
767
|
-
}
|
|
768
813
|
entries.push({
|
|
769
814
|
path: node.metaPath,
|
|
770
815
|
depth,
|
|
@@ -782,21 +827,7 @@ async function listMetas(config, watcher) {
|
|
|
782
827
|
});
|
|
783
828
|
}
|
|
784
829
|
return {
|
|
785
|
-
summary:
|
|
786
|
-
total: entries.length,
|
|
787
|
-
stale: staleCount,
|
|
788
|
-
errors: errorCount,
|
|
789
|
-
locked: lockedCount,
|
|
790
|
-
neverSynthesized: neverSynthesizedCount,
|
|
791
|
-
tokens: {
|
|
792
|
-
architect: totalArchTokens,
|
|
793
|
-
builder: totalBuilderTokens,
|
|
794
|
-
critic: totalCriticTokens,
|
|
795
|
-
},
|
|
796
|
-
stalestPath,
|
|
797
|
-
lastSynthesizedPath: lastSynthPath,
|
|
798
|
-
lastSynthesizedAt: lastSynthAt,
|
|
799
|
-
},
|
|
830
|
+
summary: computeSummary(entries, config.depthWeight),
|
|
800
831
|
entries,
|
|
801
832
|
tree,
|
|
802
833
|
};
|
|
@@ -900,12 +931,17 @@ function filterInScope(node, files) {
|
|
|
900
931
|
/**
|
|
901
932
|
* Get all files in scope for a meta node via watcher walk.
|
|
902
933
|
*/
|
|
903
|
-
async function getScopeFiles(node, watcher) {
|
|
934
|
+
async function getScopeFiles(node, watcher, logger) {
|
|
935
|
+
const walkStart = Date.now();
|
|
904
936
|
const allFiles = await watcher.walk([`${node.ownerPath}/**`]);
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
937
|
+
const scopeFiles = filterInScope(node, allFiles);
|
|
938
|
+
logger?.debug({
|
|
939
|
+
ownerPath: node.ownerPath,
|
|
940
|
+
allFiles: allFiles.length,
|
|
941
|
+
scopeFiles: scopeFiles.length,
|
|
942
|
+
durationMs: Date.now() - walkStart,
|
|
943
|
+
}, 'scope files enumerated');
|
|
944
|
+
return { scopeFiles, allFiles };
|
|
909
945
|
}
|
|
910
946
|
/**
|
|
911
947
|
* Get files modified since a given timestamp within a meta node's scope.
|
|
@@ -1230,6 +1266,22 @@ function condenseScopeFiles(files, maxIndividual = 30) {
|
|
|
1230
1266
|
.map(([pattern, count]) => pattern + ' (' + count.toString() + ' files)')
|
|
1231
1267
|
.join('\n');
|
|
1232
1268
|
}
|
|
1269
|
+
/**
|
|
1270
|
+
* Read a meta.json file and extract its `_content` field.
|
|
1271
|
+
*
|
|
1272
|
+
* @param metaJsonPath - Absolute path to a meta.json file.
|
|
1273
|
+
* @returns The `_content` string, or null if missing/unreadable.
|
|
1274
|
+
*/
|
|
1275
|
+
async function readMetaContent(metaJsonPath) {
|
|
1276
|
+
try {
|
|
1277
|
+
const raw = await readFile(metaJsonPath, 'utf8');
|
|
1278
|
+
const meta = JSON.parse(raw);
|
|
1279
|
+
return meta._content ?? null;
|
|
1280
|
+
}
|
|
1281
|
+
catch {
|
|
1282
|
+
return null;
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1233
1285
|
/**
|
|
1234
1286
|
* Build the context package for a synthesis cycle.
|
|
1235
1287
|
*
|
|
@@ -1238,22 +1290,43 @@ function condenseScopeFiles(files, maxIndividual = 30) {
|
|
|
1238
1290
|
* @param watcher - WatcherClient for scope enumeration.
|
|
1239
1291
|
* @returns The computed context package.
|
|
1240
1292
|
*/
|
|
1241
|
-
async function buildContextPackage(node, meta, watcher) {
|
|
1293
|
+
async function buildContextPackage(node, meta, watcher, logger) {
|
|
1242
1294
|
// Scope and delta files via watcher walk
|
|
1243
|
-
const
|
|
1295
|
+
const scopeStart = Date.now();
|
|
1296
|
+
const { scopeFiles } = await getScopeFiles(node, watcher, logger);
|
|
1244
1297
|
const deltaFiles = getDeltaFiles(meta._generatedAt, scopeFiles);
|
|
1245
|
-
|
|
1298
|
+
logger?.debug({
|
|
1299
|
+
scopeFiles: scopeFiles.length,
|
|
1300
|
+
deltaFiles: deltaFiles.length,
|
|
1301
|
+
durationMs: Date.now() - scopeStart,
|
|
1302
|
+
}, 'scope and delta files computed');
|
|
1303
|
+
// Child meta outputs (parallel reads)
|
|
1246
1304
|
const childMetas = {};
|
|
1247
|
-
|
|
1248
|
-
const
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1305
|
+
const childEntries = await Promise.all(node.children.map(async (child) => {
|
|
1306
|
+
const content = await readMetaContent(join(child.metaPath, 'meta.json'));
|
|
1307
|
+
return [child.ownerPath, content];
|
|
1308
|
+
}));
|
|
1309
|
+
for (const [path, content] of childEntries) {
|
|
1310
|
+
childMetas[path] = content;
|
|
1311
|
+
}
|
|
1312
|
+
// Cross-referenced meta outputs (parallel reads)
|
|
1313
|
+
const crossRefMetas = {};
|
|
1314
|
+
const seen = new Set();
|
|
1315
|
+
const crossRefPaths = [];
|
|
1316
|
+
for (const refPath of meta._crossRefs ?? []) {
|
|
1317
|
+
if (refPath === node.ownerPath || refPath === node.metaPath)
|
|
1318
|
+
continue;
|
|
1319
|
+
if (seen.has(refPath))
|
|
1320
|
+
continue;
|
|
1321
|
+
seen.add(refPath);
|
|
1322
|
+
crossRefPaths.push(refPath);
|
|
1323
|
+
}
|
|
1324
|
+
const crossRefEntries = await Promise.all(crossRefPaths.map(async (refPath) => {
|
|
1325
|
+
const content = await readMetaContent(join(refPath, '.meta', 'meta.json'));
|
|
1326
|
+
return [refPath, content];
|
|
1327
|
+
}));
|
|
1328
|
+
for (const [path, content] of crossRefEntries) {
|
|
1329
|
+
crossRefMetas[path] = content;
|
|
1257
1330
|
}
|
|
1258
1331
|
// Archive paths
|
|
1259
1332
|
const archives = listArchiveFiles(node.metaPath);
|
|
@@ -1262,6 +1335,7 @@ async function buildContextPackage(node, meta, watcher) {
|
|
|
1262
1335
|
scopeFiles,
|
|
1263
1336
|
deltaFiles,
|
|
1264
1337
|
childMetas,
|
|
1338
|
+
crossRefMetas,
|
|
1265
1339
|
previousContent: meta._content ?? null,
|
|
1266
1340
|
previousFeedback: meta._feedback ?? null,
|
|
1267
1341
|
steer: meta._steer ?? null,
|
|
@@ -1275,6 +1349,15 @@ async function buildContextPackage(node, meta, watcher) {
|
|
|
1275
1349
|
*
|
|
1276
1350
|
* @module orchestrator/buildTask
|
|
1277
1351
|
*/
|
|
1352
|
+
/** Append a keyed record of meta outputs as subsections, if non-empty. */
|
|
1353
|
+
function appendMetaSections(sections, heading, metas) {
|
|
1354
|
+
if (Object.keys(metas).length === 0)
|
|
1355
|
+
return;
|
|
1356
|
+
sections.push('', heading);
|
|
1357
|
+
for (const [path, content] of Object.entries(metas)) {
|
|
1358
|
+
sections.push(`### ${path}`, typeof content === 'string' ? content : '(not yet synthesized)');
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1278
1361
|
/** Append optional context sections shared across all step prompts. */
|
|
1279
1362
|
function appendSharedSections(sections, ctx, options) {
|
|
1280
1363
|
const opts = {
|
|
@@ -1283,6 +1366,7 @@ function appendSharedSections(sections, ctx, options) {
|
|
|
1283
1366
|
includePreviousFeedback: true,
|
|
1284
1367
|
feedbackHeading: '## PREVIOUS FEEDBACK',
|
|
1285
1368
|
includeChildMetas: true,
|
|
1369
|
+
includeCrossRefs: true,
|
|
1286
1370
|
...options,
|
|
1287
1371
|
};
|
|
1288
1372
|
if (opts.includeSteer && ctx.steer) {
|
|
@@ -1294,11 +1378,11 @@ function appendSharedSections(sections, ctx, options) {
|
|
|
1294
1378
|
if (opts.includePreviousFeedback && ctx.previousFeedback) {
|
|
1295
1379
|
sections.push('', opts.feedbackHeading, ctx.previousFeedback);
|
|
1296
1380
|
}
|
|
1297
|
-
if (opts.includeChildMetas
|
|
1298
|
-
sections
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1381
|
+
if (opts.includeChildMetas) {
|
|
1382
|
+
appendMetaSections(sections, '## CHILD META OUTPUTS', ctx.childMetas);
|
|
1383
|
+
}
|
|
1384
|
+
if (opts.includeCrossRefs) {
|
|
1385
|
+
appendMetaSections(sections, '## CROSS-REFERENCED METAS', ctx.crossRefMetas);
|
|
1302
1386
|
}
|
|
1303
1387
|
}
|
|
1304
1388
|
/**
|
|
@@ -1382,6 +1466,7 @@ function buildCriticTask(ctx, meta, config) {
|
|
|
1382
1466
|
includePreviousContent: false,
|
|
1383
1467
|
feedbackHeading: '## YOUR PREVIOUS FEEDBACK',
|
|
1384
1468
|
includeChildMetas: false,
|
|
1469
|
+
includeCrossRefs: false,
|
|
1385
1470
|
});
|
|
1386
1471
|
sections.push('', '## OUTPUT FORMAT', 'Return your evaluation as Markdown text. Be specific and actionable.');
|
|
1387
1472
|
return sections.join('\n');
|
|
@@ -1413,10 +1498,15 @@ const metaErrorSchema = z.object({
|
|
|
1413
1498
|
/** Zod schema for the reserved (underscore-prefixed) meta.json properties. */
|
|
1414
1499
|
const metaJsonSchema = z
|
|
1415
1500
|
.object({
|
|
1416
|
-
/** Stable identity.
|
|
1417
|
-
_id: z.uuid(),
|
|
1501
|
+
/** Stable identity. Auto-generated on first synthesis if not provided. */
|
|
1502
|
+
_id: z.uuid().optional(),
|
|
1418
1503
|
/** Human-provided steering prompt. Optional. */
|
|
1419
1504
|
_steer: z.string().optional(),
|
|
1505
|
+
/**
|
|
1506
|
+
* Explicit cross-references to other meta owner paths.
|
|
1507
|
+
* Referenced metas' _content is included as architect/builder context.
|
|
1508
|
+
*/
|
|
1509
|
+
_crossRefs: z.array(z.string()).optional(),
|
|
1420
1510
|
/** Architect system prompt used this turn. Defaults from config. */
|
|
1421
1511
|
_architect: z.string().optional(),
|
|
1422
1512
|
/**
|
|
@@ -1502,10 +1592,10 @@ const metaJsonSchema = z
|
|
|
1502
1592
|
* @returns The updated MetaJson.
|
|
1503
1593
|
* @throws If validation fails (malformed output).
|
|
1504
1594
|
*/
|
|
1505
|
-
function mergeAndWrite(options) {
|
|
1595
|
+
async function mergeAndWrite(options) {
|
|
1506
1596
|
const merged = {
|
|
1507
|
-
// Preserve human-set fields
|
|
1508
|
-
_id: options.current._id,
|
|
1597
|
+
// Preserve human-set fields (auto-generate _id on first synthesis)
|
|
1598
|
+
_id: options.current._id ?? randomUUID(),
|
|
1509
1599
|
_steer: options.current._steer,
|
|
1510
1600
|
_depth: options.current._depth,
|
|
1511
1601
|
_emphasis: options.current._emphasis,
|
|
@@ -1578,7 +1668,7 @@ function mergeAndWrite(options) {
|
|
|
1578
1668
|
}
|
|
1579
1669
|
// Write to specified path (lock staging) or default meta.json
|
|
1580
1670
|
const filePath = options.outputPath ?? join(options.metaPath, 'meta.json');
|
|
1581
|
-
|
|
1671
|
+
await writeFile(filePath, JSON.stringify(result.data, null, 2) + '\n');
|
|
1582
1672
|
return result.data;
|
|
1583
1673
|
}
|
|
1584
1674
|
|
|
@@ -1818,11 +1908,11 @@ function computeStalenessScore(stalenessSeconds, depth, emphasis, depthWeight) {
|
|
|
1818
1908
|
* @module orchestrator/finalizeCycle
|
|
1819
1909
|
*/
|
|
1820
1910
|
/** Finalize a cycle using lock staging: write to .lock → copy to meta.json + archive → delete .lock. */
|
|
1821
|
-
function finalizeCycle(opts) {
|
|
1911
|
+
async function finalizeCycle(opts) {
|
|
1822
1912
|
const lockPath = join(opts.metaPath, '.lock');
|
|
1823
1913
|
const metaJsonPath = join(opts.metaPath, 'meta.json');
|
|
1824
|
-
// Stage: write merged result to .lock
|
|
1825
|
-
const updated = mergeAndWrite({
|
|
1914
|
+
// Stage: write merged result to .lock (sequential — ordering matters)
|
|
1915
|
+
const updated = await mergeAndWrite({
|
|
1826
1916
|
metaPath: opts.metaPath,
|
|
1827
1917
|
current: opts.current,
|
|
1828
1918
|
architect: opts.architect,
|
|
@@ -1841,10 +1931,10 @@ function finalizeCycle(opts) {
|
|
|
1841
1931
|
stateOnly: opts.stateOnly,
|
|
1842
1932
|
});
|
|
1843
1933
|
// Commit: copy .lock → meta.json
|
|
1844
|
-
|
|
1845
|
-
// Archive + prune from the committed meta.json
|
|
1846
|
-
createSnapshot(opts.metaPath, updated);
|
|
1847
|
-
pruneArchive(opts.metaPath, opts.config.maxArchive);
|
|
1934
|
+
await copyFile(lockPath, metaJsonPath);
|
|
1935
|
+
// Archive + prune from the committed meta.json (sequential)
|
|
1936
|
+
await createSnapshot(opts.metaPath, updated);
|
|
1937
|
+
await pruneArchive(opts.metaPath, opts.config.maxArchive);
|
|
1848
1938
|
// .lock is cleaned up by the finally block (releaseLock)
|
|
1849
1939
|
return updated;
|
|
1850
1940
|
}
|
|
@@ -1957,14 +2047,12 @@ function parseCriticOutput(output) {
|
|
|
1957
2047
|
* Returns an {@link OrchestrateResult} if state was salvaged, or `null`
|
|
1958
2048
|
* if the caller should fall through to a hard failure.
|
|
1959
2049
|
*/
|
|
1960
|
-
function attemptTimeoutRecovery(opts) {
|
|
2050
|
+
async function attemptTimeoutRecovery(opts) {
|
|
1961
2051
|
const { err, currentMeta, metaPath, config, builderBrief, structureHash, synthesisCount, } = opts;
|
|
1962
2052
|
let partialOutput = null;
|
|
1963
2053
|
try {
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
partialOutput = parseBuilderOutput(raw);
|
|
1967
|
-
}
|
|
2054
|
+
const raw = await readFile(err.outputPath, 'utf8');
|
|
2055
|
+
partialOutput = parseBuilderOutput(raw);
|
|
1968
2056
|
}
|
|
1969
2057
|
catch {
|
|
1970
2058
|
// Could not read partial output — fall through to hard failure
|
|
@@ -1978,7 +2066,7 @@ function attemptTimeoutRecovery(opts) {
|
|
|
1978
2066
|
code: 'TIMEOUT',
|
|
1979
2067
|
message: err.message,
|
|
1980
2068
|
};
|
|
1981
|
-
finalizeCycle({
|
|
2069
|
+
await finalizeCycle({
|
|
1982
2070
|
metaPath,
|
|
1983
2071
|
current: currentMeta,
|
|
1984
2072
|
config,
|
|
@@ -2009,12 +2097,12 @@ function attemptTimeoutRecovery(opts) {
|
|
|
2009
2097
|
* @module orchestrator/synthesizeNode
|
|
2010
2098
|
*/
|
|
2011
2099
|
/** Run the architect/builder/critic pipeline on a single node. */
|
|
2012
|
-
async function synthesizeNode(node, currentMeta, config, executor, watcher, onProgress) {
|
|
2100
|
+
async function synthesizeNode(node, currentMeta, config, executor, watcher, onProgress, logger) {
|
|
2013
2101
|
// Step 5-6: Steer change detection
|
|
2014
|
-
const latestArchive = readLatestArchive(node.metaPath);
|
|
2102
|
+
const latestArchive = await readLatestArchive(node.metaPath);
|
|
2015
2103
|
const steerChanged = hasSteerChanged(currentMeta._steer, latestArchive?._steer, Boolean(latestArchive));
|
|
2016
2104
|
// Step 7: Compute context (includes scope files and delta files)
|
|
2017
|
-
const ctx = await buildContextPackage(node, currentMeta, watcher);
|
|
2105
|
+
const ctx = await buildContextPackage(node, currentMeta, watcher, logger);
|
|
2018
2106
|
// Step 5 (deferred): Structure hash from context scope files
|
|
2019
2107
|
const newStructureHash = computeStructureHash(ctx.scopeFiles);
|
|
2020
2108
|
const structureChanged = newStructureHash !== currentMeta._structureHash;
|
|
@@ -2026,6 +2114,16 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
|
|
|
2026
2114
|
let architectTokens;
|
|
2027
2115
|
let builderTokens;
|
|
2028
2116
|
let criticTokens;
|
|
2117
|
+
// Shared base options for all finalizeCycle calls.
|
|
2118
|
+
// Note: synthesisCount is excluded because it mutates during the pipeline.
|
|
2119
|
+
const baseFinalizeOptions = {
|
|
2120
|
+
metaPath: node.metaPath,
|
|
2121
|
+
current: currentMeta,
|
|
2122
|
+
config,
|
|
2123
|
+
architect: currentMeta._architect ?? '',
|
|
2124
|
+
critic: currentMeta._critic ?? '',
|
|
2125
|
+
structureHash: newStructureHash,
|
|
2126
|
+
};
|
|
2029
2127
|
if (architectTriggered) {
|
|
2030
2128
|
try {
|
|
2031
2129
|
await onProgress?.({
|
|
@@ -2054,16 +2152,11 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
|
|
|
2054
2152
|
stepError = toMetaError('architect', err);
|
|
2055
2153
|
if (!currentMeta._builder) {
|
|
2056
2154
|
// No cached builder — cycle fails
|
|
2057
|
-
finalizeCycle({
|
|
2058
|
-
|
|
2059
|
-
current: currentMeta,
|
|
2060
|
-
config,
|
|
2061
|
-
architect: currentMeta._architect ?? '',
|
|
2155
|
+
await finalizeCycle({
|
|
2156
|
+
...baseFinalizeOptions,
|
|
2062
2157
|
builder: '',
|
|
2063
|
-
critic: currentMeta._critic ?? '',
|
|
2064
2158
|
builderOutput: null,
|
|
2065
2159
|
feedback: null,
|
|
2066
|
-
structureHash: newStructureHash,
|
|
2067
2160
|
synthesisCount,
|
|
2068
2161
|
error: stepError,
|
|
2069
2162
|
architectTokens,
|
|
@@ -2105,7 +2198,7 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
|
|
|
2105
2198
|
}
|
|
2106
2199
|
catch (err) {
|
|
2107
2200
|
if (err instanceof SpawnTimeoutError) {
|
|
2108
|
-
const recovered = attemptTimeoutRecovery({
|
|
2201
|
+
const recovered = await attemptTimeoutRecovery({
|
|
2109
2202
|
err,
|
|
2110
2203
|
currentMeta,
|
|
2111
2204
|
metaPath: node.metaPath,
|
|
@@ -2118,16 +2211,11 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
|
|
|
2118
2211
|
return recovered;
|
|
2119
2212
|
}
|
|
2120
2213
|
stepError = toMetaError('builder', err);
|
|
2121
|
-
finalizeCycle({
|
|
2122
|
-
|
|
2123
|
-
current: currentMeta,
|
|
2124
|
-
config,
|
|
2125
|
-
architect: currentMeta._architect ?? '',
|
|
2214
|
+
await finalizeCycle({
|
|
2215
|
+
...baseFinalizeOptions,
|
|
2126
2216
|
builder: builderBrief,
|
|
2127
|
-
critic: currentMeta._critic ?? '',
|
|
2128
2217
|
builderOutput: null,
|
|
2129
2218
|
feedback: null,
|
|
2130
|
-
structureHash: newStructureHash,
|
|
2131
2219
|
synthesisCount,
|
|
2132
2220
|
error: stepError,
|
|
2133
2221
|
});
|
|
@@ -2166,16 +2254,11 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
|
|
|
2166
2254
|
stepError = stepError ?? toMetaError('critic', err);
|
|
2167
2255
|
}
|
|
2168
2256
|
// Steps 11-12: Merge, archive, prune
|
|
2169
|
-
finalizeCycle({
|
|
2170
|
-
|
|
2171
|
-
current: currentMeta,
|
|
2172
|
-
config,
|
|
2173
|
-
architect: currentMeta._architect ?? '',
|
|
2257
|
+
await finalizeCycle({
|
|
2258
|
+
...baseFinalizeOptions,
|
|
2174
2259
|
builder: builderBrief,
|
|
2175
|
-
critic: currentMeta._critic ?? '',
|
|
2176
2260
|
builderOutput,
|
|
2177
2261
|
feedback,
|
|
2178
|
-
structureHash: newStructureHash,
|
|
2179
2262
|
synthesisCount,
|
|
2180
2263
|
error: stepError,
|
|
2181
2264
|
architectTokens,
|
|
@@ -2207,21 +2290,25 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
|
|
|
2207
2290
|
if (!acquireLock(node.metaPath))
|
|
2208
2291
|
return { synthesized: false };
|
|
2209
2292
|
try {
|
|
2210
|
-
const currentMeta = readMetaJson(normalizedTarget);
|
|
2211
|
-
return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress);
|
|
2293
|
+
const currentMeta = await readMetaJson(normalizedTarget);
|
|
2294
|
+
return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress, logger);
|
|
2212
2295
|
}
|
|
2213
2296
|
finally {
|
|
2214
2297
|
releaseLock(node.metaPath);
|
|
2215
2298
|
}
|
|
2216
2299
|
}
|
|
2300
|
+
// Full discovery path (scheduler-driven, no specific target)
|
|
2301
|
+
// Step 1: Discover via watcher walk
|
|
2302
|
+
const discoveryStart = Date.now();
|
|
2217
2303
|
const metaPaths = await discoverMetas(watcher);
|
|
2304
|
+
logger?.debug({ paths: metaPaths.length, durationMs: Date.now() - discoveryStart }, 'discovery complete');
|
|
2218
2305
|
if (metaPaths.length === 0)
|
|
2219
2306
|
return { synthesized: false };
|
|
2220
2307
|
// Read meta.json for each discovered meta
|
|
2221
2308
|
const metas = new Map();
|
|
2222
2309
|
for (const mp of metaPaths) {
|
|
2223
2310
|
try {
|
|
2224
|
-
metas.set(normalizePath(mp), readMetaJson(mp));
|
|
2311
|
+
metas.set(normalizePath(mp), await readMetaJson(mp));
|
|
2225
2312
|
}
|
|
2226
2313
|
catch {
|
|
2227
2314
|
// Skip metas with unreadable meta.json
|
|
@@ -2257,9 +2344,9 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
|
|
|
2257
2344
|
const verifiedStale = await isStale(getScopePrefix(candidate.node), candidate.meta, watcher);
|
|
2258
2345
|
if (!verifiedStale && candidate.meta._generatedAt) {
|
|
2259
2346
|
// Bump _generatedAt so it doesn't win next cycle
|
|
2260
|
-
const freshMeta = readMetaJson(candidate.node.metaPath);
|
|
2347
|
+
const freshMeta = await readMetaJson(candidate.node.metaPath);
|
|
2261
2348
|
freshMeta._generatedAt = new Date().toISOString();
|
|
2262
|
-
|
|
2349
|
+
await writeFile(join(candidate.node.metaPath, 'meta.json'), JSON.stringify(freshMeta, null, 2));
|
|
2263
2350
|
releaseLock(candidate.node.metaPath);
|
|
2264
2351
|
if (config.skipUnchanged)
|
|
2265
2352
|
continue;
|
|
@@ -2272,8 +2359,8 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
|
|
|
2272
2359
|
return { synthesized: false };
|
|
2273
2360
|
const node = winner.node;
|
|
2274
2361
|
try {
|
|
2275
|
-
const currentMeta = readMetaJson(node.metaPath);
|
|
2276
|
-
return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress);
|
|
2362
|
+
const currentMeta = await readMetaJson(node.metaPath);
|
|
2363
|
+
return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress, logger);
|
|
2277
2364
|
}
|
|
2278
2365
|
finally {
|
|
2279
2366
|
// Step 13: Release lock
|
|
@@ -2293,7 +2380,7 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
|
|
|
2293
2380
|
* @returns Array with a single result.
|
|
2294
2381
|
*/
|
|
2295
2382
|
async function orchestrate(config, executor, watcher, targetPath, onProgress, logger) {
|
|
2296
|
-
const result = await orchestrateOnce(config, executor, watcher, targetPath, onProgress);
|
|
2383
|
+
const result = await orchestrateOnce(config, executor, watcher, targetPath, onProgress, logger);
|
|
2297
2384
|
return [result];
|
|
2298
2385
|
}
|
|
2299
2386
|
|
|
@@ -2312,14 +2399,15 @@ function formatSeconds(durationMs) {
|
|
|
2312
2399
|
function titleCasePhase(phase) {
|
|
2313
2400
|
return phase.charAt(0).toUpperCase() + phase.slice(1);
|
|
2314
2401
|
}
|
|
2315
|
-
/** Build a link
|
|
2402
|
+
/** Build a link to the entity's meta.json output file. */
|
|
2316
2403
|
function buildEntityLink(path, serverBaseUrl) {
|
|
2404
|
+
// Normalize backslashes, then convert drive letter to URL path segment
|
|
2405
|
+
const normalized = normalizePath(path).replace(/^([A-Za-z]):/, '/$1');
|
|
2406
|
+
const metaJsonPath = `${normalized}/.meta/meta.json`;
|
|
2317
2407
|
if (!serverBaseUrl)
|
|
2318
|
-
return
|
|
2408
|
+
return metaJsonPath;
|
|
2319
2409
|
const base = serverBaseUrl.replace(/\/+$/, '');
|
|
2320
|
-
|
|
2321
|
-
const normalized = path.replace(/^([A-Za-z]):/, '/$1').replace(/\\/g, '/');
|
|
2322
|
-
return `${base}/path${normalized}`;
|
|
2410
|
+
return `${base}/path${metaJsonPath}`;
|
|
2323
2411
|
}
|
|
2324
2412
|
function formatProgressEvent(event, serverBaseUrl) {
|
|
2325
2413
|
const pathDisplay = buildEntityLink(event.path, serverBaseUrl);
|
|
@@ -2398,6 +2486,123 @@ class ProgressReporter {
|
|
|
2398
2486
|
}
|
|
2399
2487
|
}
|
|
2400
2488
|
|
|
2489
|
+
/**
|
|
2490
|
+
* Core seed logic — create a .meta/ directory with initial meta.json.
|
|
2491
|
+
*
|
|
2492
|
+
* Shared between the POST /seed route handler and the auto-seed pass.
|
|
2493
|
+
*
|
|
2494
|
+
* @module seed/createMeta
|
|
2495
|
+
*/
|
|
2496
|
+
/**
|
|
2497
|
+
* Create a .meta/ directory with an initial meta.json.
|
|
2498
|
+
*
|
|
2499
|
+
* Does NOT check for existing .meta/ — caller is responsible for that guard.
|
|
2500
|
+
*
|
|
2501
|
+
* @param ownerPath - The owner directory path.
|
|
2502
|
+
* @param options - Optional cross-refs and steering prompt.
|
|
2503
|
+
* @returns The meta directory path and generated ID.
|
|
2504
|
+
*/
|
|
2505
|
+
async function createMeta(ownerPath, options) {
|
|
2506
|
+
const metaDir = resolveMetaDir(ownerPath);
|
|
2507
|
+
await mkdir(metaDir, { recursive: true });
|
|
2508
|
+
const _id = randomUUID();
|
|
2509
|
+
const metaJson = { _id };
|
|
2510
|
+
if (options?.crossRefs !== undefined)
|
|
2511
|
+
metaJson._crossRefs = options.crossRefs;
|
|
2512
|
+
if (options?.steer !== undefined)
|
|
2513
|
+
metaJson._steer = options.steer;
|
|
2514
|
+
const metaJsonPath = join(metaDir, 'meta.json');
|
|
2515
|
+
await writeFile(metaJsonPath, JSON.stringify(metaJson, null, 2) + '\n');
|
|
2516
|
+
return { metaDir, _id };
|
|
2517
|
+
}
|
|
2518
|
+
/**
|
|
2519
|
+
* Check if a .meta/ directory already exists for an owner path.
|
|
2520
|
+
*
|
|
2521
|
+
* @param ownerPath - The owner directory path.
|
|
2522
|
+
* @returns True if .meta/ already exists.
|
|
2523
|
+
*/
|
|
2524
|
+
function metaExists(ownerPath) {
|
|
2525
|
+
return existsSync(resolveMetaDir(ownerPath));
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
/**
|
|
2529
|
+
* Auto-seed pass — scan for directories matching policy rules and seed them.
|
|
2530
|
+
*
|
|
2531
|
+
* Runs before discovery in each scheduler tick. For each auto-seed rule,
|
|
2532
|
+
* walks matching directories via the watcher and creates .meta/ directories
|
|
2533
|
+
* for those that don't already have one.
|
|
2534
|
+
*
|
|
2535
|
+
* Rules are processed in array order; last match wins for steer/crossRefs.
|
|
2536
|
+
*
|
|
2537
|
+
* @module seed/autoSeed
|
|
2538
|
+
*/
|
|
2539
|
+
/**
|
|
2540
|
+
* Extract parent directory paths from watcher walk results.
|
|
2541
|
+
*
|
|
2542
|
+
* Walk returns file paths; we need the unique set of immediate parent
|
|
2543
|
+
* directories that could be owners.
|
|
2544
|
+
*/
|
|
2545
|
+
function extractDirectories(filePaths) {
|
|
2546
|
+
const dirs = new Set();
|
|
2547
|
+
for (const fp of filePaths) {
|
|
2548
|
+
const dir = posix.dirname(fp);
|
|
2549
|
+
if (dir !== '.' && dir !== '/') {
|
|
2550
|
+
dirs.add(dir);
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
return [...dirs];
|
|
2554
|
+
}
|
|
2555
|
+
/**
|
|
2556
|
+
* Run the auto-seed pass: apply policy rules and create missing metas.
|
|
2557
|
+
*
|
|
2558
|
+
* @param rules - Auto-seed policy rules from config.
|
|
2559
|
+
* @param watcher - Watcher client for filesystem enumeration.
|
|
2560
|
+
* @param logger - Logger for reporting seed actions.
|
|
2561
|
+
* @returns Summary of what was seeded.
|
|
2562
|
+
*/
|
|
2563
|
+
async function autoSeedPass(rules, watcher, logger) {
|
|
2564
|
+
if (rules.length === 0)
|
|
2565
|
+
return { seeded: 0, paths: [] };
|
|
2566
|
+
// Build a map of ownerPath → effective options (last match wins)
|
|
2567
|
+
const candidates = new Map();
|
|
2568
|
+
for (const rule of rules) {
|
|
2569
|
+
const files = await watcher.walk([rule.match]);
|
|
2570
|
+
const dirs = extractDirectories(files);
|
|
2571
|
+
for (const dir of dirs) {
|
|
2572
|
+
candidates.set(dir, {
|
|
2573
|
+
steer: rule.steer,
|
|
2574
|
+
crossRefs: rule.crossRefs,
|
|
2575
|
+
});
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
// Filter out paths that already have .meta/meta.json
|
|
2579
|
+
const toSeed = [];
|
|
2580
|
+
for (const [path, opts] of candidates) {
|
|
2581
|
+
if (!metaExists(path)) {
|
|
2582
|
+
toSeed.push({ path, ...opts });
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2585
|
+
// Seed remaining
|
|
2586
|
+
const seededPaths = [];
|
|
2587
|
+
for (const candidate of toSeed) {
|
|
2588
|
+
try {
|
|
2589
|
+
await createMeta(candidate.path, {
|
|
2590
|
+
steer: candidate.steer,
|
|
2591
|
+
crossRefs: candidate.crossRefs,
|
|
2592
|
+
});
|
|
2593
|
+
seededPaths.push(candidate.path);
|
|
2594
|
+
logger?.info({ path: candidate.path }, 'auto-seeded meta');
|
|
2595
|
+
}
|
|
2596
|
+
catch (err) {
|
|
2597
|
+
logger?.warn({
|
|
2598
|
+
path: candidate.path,
|
|
2599
|
+
err: err instanceof Error ? err.message : String(err),
|
|
2600
|
+
}, 'auto-seed failed for path');
|
|
2601
|
+
}
|
|
2602
|
+
}
|
|
2603
|
+
return { seeded: seededPaths.length, paths: seededPaths };
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2401
2606
|
/**
|
|
2402
2607
|
* Croner-based scheduler that discovers the stalest meta candidate each tick
|
|
2403
2608
|
* and enqueues it for synthesis.
|
|
@@ -2495,6 +2700,18 @@ class Scheduler {
|
|
|
2495
2700
|
}, 'Skipping tick (backoff)');
|
|
2496
2701
|
return;
|
|
2497
2702
|
}
|
|
2703
|
+
// Auto-seed pass: create .meta/ for matching directories
|
|
2704
|
+
if (this.config.autoSeed.length > 0) {
|
|
2705
|
+
try {
|
|
2706
|
+
const result = await autoSeedPass(this.config.autoSeed, this.watcher, this.logger);
|
|
2707
|
+
if (result.seeded > 0) {
|
|
2708
|
+
this.logger.info({ seeded: result.seeded }, 'Auto-seed pass completed');
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
catch (err) {
|
|
2712
|
+
this.logger.warn({ err }, 'Auto-seed pass failed');
|
|
2713
|
+
}
|
|
2714
|
+
}
|
|
2498
2715
|
const candidate = await this.discoverStalest();
|
|
2499
2716
|
if (!candidate) {
|
|
2500
2717
|
this.backoffMultiplier = Math.min(this.backoffMultiplier * 2, MAX_BACKOFF_MULTIPLIER);
|
|
@@ -9750,53 +9967,6 @@ const metaDetailQuerySchema = z.object({
|
|
|
9750
9967
|
])
|
|
9751
9968
|
.optional(),
|
|
9752
9969
|
});
|
|
9753
|
-
/** Compute summary stats from a filtered set of MetaEntries. */
|
|
9754
|
-
function computeFilteredSummary(entries) {
|
|
9755
|
-
let staleCount = 0;
|
|
9756
|
-
let errorCount = 0;
|
|
9757
|
-
let neverSynthCount = 0;
|
|
9758
|
-
let stalestPath = null;
|
|
9759
|
-
let stalestSeconds = -1;
|
|
9760
|
-
let lastSynthesizedPath = null;
|
|
9761
|
-
let lastSynthesizedAt = null;
|
|
9762
|
-
let totalArchitectTokens = 0;
|
|
9763
|
-
let totalBuilderTokens = 0;
|
|
9764
|
-
let totalCriticTokens = 0;
|
|
9765
|
-
for (const e of entries) {
|
|
9766
|
-
if (e.stalenessSeconds > 0)
|
|
9767
|
-
staleCount++;
|
|
9768
|
-
if (e.hasError)
|
|
9769
|
-
errorCount++;
|
|
9770
|
-
if (e.stalenessSeconds === Infinity)
|
|
9771
|
-
neverSynthCount++;
|
|
9772
|
-
if (e.stalenessSeconds > stalestSeconds) {
|
|
9773
|
-
stalestSeconds = e.stalenessSeconds;
|
|
9774
|
-
stalestPath = e.path;
|
|
9775
|
-
}
|
|
9776
|
-
if (e.lastSynthesized &&
|
|
9777
|
-
(!lastSynthesizedAt || e.lastSynthesized > lastSynthesizedAt)) {
|
|
9778
|
-
lastSynthesizedAt = e.lastSynthesized;
|
|
9779
|
-
lastSynthesizedPath = e.path;
|
|
9780
|
-
}
|
|
9781
|
-
totalArchitectTokens += e.architectTokens ?? 0;
|
|
9782
|
-
totalBuilderTokens += e.builderTokens ?? 0;
|
|
9783
|
-
totalCriticTokens += e.criticTokens ?? 0;
|
|
9784
|
-
}
|
|
9785
|
-
return {
|
|
9786
|
-
total: entries.length,
|
|
9787
|
-
stale: staleCount,
|
|
9788
|
-
errors: errorCount,
|
|
9789
|
-
neverSynthesized: neverSynthCount,
|
|
9790
|
-
stalestPath,
|
|
9791
|
-
lastSynthesizedPath,
|
|
9792
|
-
lastSynthesizedAt,
|
|
9793
|
-
tokens: {
|
|
9794
|
-
architect: totalArchitectTokens,
|
|
9795
|
-
builder: totalBuilderTokens,
|
|
9796
|
-
critic: totalCriticTokens,
|
|
9797
|
-
},
|
|
9798
|
-
};
|
|
9799
|
-
}
|
|
9800
9970
|
function registerMetasRoutes(app, deps) {
|
|
9801
9971
|
app.get('/metas', async (request) => {
|
|
9802
9972
|
const query = metasQuerySchema.parse(request.query);
|
|
@@ -9811,7 +9981,7 @@ function registerMetasRoutes(app, deps) {
|
|
|
9811
9981
|
entries = entries.filter((e) => e.hasError === query.hasError);
|
|
9812
9982
|
}
|
|
9813
9983
|
if (query.neverSynthesized !== undefined) {
|
|
9814
|
-
entries = entries.filter((e) => (e.
|
|
9984
|
+
entries = entries.filter((e) => (e.lastSynthesized === null) === query.neverSynthesized);
|
|
9815
9985
|
}
|
|
9816
9986
|
if (query.locked !== undefined) {
|
|
9817
9987
|
entries = entries.filter((e) => e.locked === query.locked);
|
|
@@ -9820,7 +9990,7 @@ function registerMetasRoutes(app, deps) {
|
|
|
9820
9990
|
entries = entries.filter((e) => e.stalenessSeconds >= query.staleHours * 3600);
|
|
9821
9991
|
}
|
|
9822
9992
|
// Summary (computed from filtered entries)
|
|
9823
|
-
const summary =
|
|
9993
|
+
const summary = computeSummary(entries, config.depthWeight);
|
|
9824
9994
|
// Field projection
|
|
9825
9995
|
const fieldList = query.fields?.split(',');
|
|
9826
9996
|
const defaultFields = [
|
|
@@ -9872,7 +10042,7 @@ function registerMetasRoutes(app, deps) {
|
|
|
9872
10042
|
message: 'Meta path not found: ' + targetPath,
|
|
9873
10043
|
});
|
|
9874
10044
|
}
|
|
9875
|
-
const meta = JSON.parse(
|
|
10045
|
+
const meta = JSON.parse(await readFile(join(targetNode.metaPath, 'meta.json'), 'utf8'));
|
|
9876
10046
|
// Field projection
|
|
9877
10047
|
const defaultExclude = new Set([
|
|
9878
10048
|
'_architect',
|
|
@@ -9917,6 +10087,25 @@ function registerMetasRoutes(app, deps) {
|
|
|
9917
10087
|
score: Math.round(score * 100) / 100,
|
|
9918
10088
|
},
|
|
9919
10089
|
};
|
|
10090
|
+
// Cross-refs status
|
|
10091
|
+
const crossRefsRaw = meta._crossRefs;
|
|
10092
|
+
if (Array.isArray(crossRefsRaw) && crossRefsRaw.length > 0) {
|
|
10093
|
+
response.crossRefs = await Promise.all(crossRefsRaw.map(async (refPath) => {
|
|
10094
|
+
const rp = String(refPath);
|
|
10095
|
+
const refMetaFile = join(rp, '.meta', 'meta.json');
|
|
10096
|
+
try {
|
|
10097
|
+
const refMeta = JSON.parse(await readFile(refMetaFile, 'utf8'));
|
|
10098
|
+
return {
|
|
10099
|
+
path: rp,
|
|
10100
|
+
status: 'resolved',
|
|
10101
|
+
hasContent: Boolean(refMeta._content),
|
|
10102
|
+
};
|
|
10103
|
+
}
|
|
10104
|
+
catch {
|
|
10105
|
+
return { path: rp, status: 'missing' };
|
|
10106
|
+
}
|
|
10107
|
+
}));
|
|
10108
|
+
}
|
|
9920
10109
|
// Archive
|
|
9921
10110
|
if (query.includeArchive) {
|
|
9922
10111
|
const archiveFiles = listArchiveFiles(targetNode.metaPath);
|
|
@@ -9924,10 +10113,10 @@ function registerMetasRoutes(app, deps) {
|
|
|
9924
10113
|
? query.includeArchive
|
|
9925
10114
|
: archiveFiles.length;
|
|
9926
10115
|
const selected = archiveFiles.slice(-limit).reverse();
|
|
9927
|
-
response.archive = selected.map((af) => {
|
|
9928
|
-
const raw =
|
|
10116
|
+
response.archive = await Promise.all(selected.map(async (af) => {
|
|
10117
|
+
const raw = await readFile(af, 'utf8');
|
|
9929
10118
|
return projectMeta(JSON.parse(raw));
|
|
9930
|
-
});
|
|
10119
|
+
}));
|
|
9931
10120
|
}
|
|
9932
10121
|
return response;
|
|
9933
10122
|
});
|
|
@@ -9978,12 +10167,12 @@ function registerPreviewRoute(app, deps) {
|
|
|
9978
10167
|
}
|
|
9979
10168
|
targetNode = findNode(result.tree, stalestPath);
|
|
9980
10169
|
}
|
|
9981
|
-
const meta = readMetaJson(targetNode.metaPath);
|
|
10170
|
+
const meta = await readMetaJson(targetNode.metaPath);
|
|
9982
10171
|
// Scope files
|
|
9983
10172
|
const { scopeFiles } = await getScopeFiles(targetNode, watcher);
|
|
9984
10173
|
const structureHash = computeStructureHash(scopeFiles);
|
|
9985
10174
|
const structureChanged = structureHash !== meta._structureHash;
|
|
9986
|
-
const latestArchive = readLatestArchive(targetNode.metaPath);
|
|
10175
|
+
const latestArchive = await readLatestArchive(targetNode.metaPath);
|
|
9987
10176
|
const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
|
|
9988
10177
|
const architectTriggered = isArchitectTriggered(meta, structureChanged, steerChanged, config.architectEvery);
|
|
9989
10178
|
// Delta files
|
|
@@ -10034,28 +10223,28 @@ function registerPreviewRoute(app, deps) {
|
|
|
10034
10223
|
*/
|
|
10035
10224
|
const seedBodySchema = z.object({
|
|
10036
10225
|
path: z.string().min(1),
|
|
10226
|
+
crossRefs: z.array(z.string()).optional(),
|
|
10227
|
+
steer: z.string().optional(),
|
|
10037
10228
|
});
|
|
10038
10229
|
function registerSeedRoute(app, deps) {
|
|
10039
|
-
app.post('/seed', (request, reply) => {
|
|
10230
|
+
app.post('/seed', async (request, reply) => {
|
|
10040
10231
|
const body = seedBodySchema.parse(request.body);
|
|
10041
|
-
|
|
10042
|
-
if (existsSync(metaDir)) {
|
|
10232
|
+
if (metaExists(body.path)) {
|
|
10043
10233
|
return reply.status(409).send({
|
|
10044
10234
|
error: 'CONFLICT',
|
|
10045
10235
|
message: `.meta directory already exists at ${body.path}`,
|
|
10046
10236
|
});
|
|
10047
10237
|
}
|
|
10048
|
-
deps.logger.info({
|
|
10049
|
-
|
|
10050
|
-
|
|
10051
|
-
|
|
10052
|
-
|
|
10053
|
-
writeFileSync(metaJsonPath, JSON.stringify(metaJson, null, 2) + '\n');
|
|
10238
|
+
deps.logger.info({ path: body.path }, 'seeding .meta directory');
|
|
10239
|
+
const result = await createMeta(body.path, {
|
|
10240
|
+
crossRefs: body.crossRefs,
|
|
10241
|
+
steer: body.steer,
|
|
10242
|
+
});
|
|
10054
10243
|
return reply.status(201).send({
|
|
10055
10244
|
status: 'created',
|
|
10056
10245
|
path: body.path,
|
|
10057
|
-
metaDir,
|
|
10058
|
-
_id:
|
|
10246
|
+
metaDir: result.metaDir,
|
|
10247
|
+
_id: result._id,
|
|
10059
10248
|
});
|
|
10060
10249
|
});
|
|
10061
10250
|
}
|
|
@@ -10504,6 +10693,44 @@ class RuleRegistrar {
|
|
|
10504
10693
|
}
|
|
10505
10694
|
}
|
|
10506
10695
|
|
|
10696
|
+
/**
|
|
10697
|
+
* Post-registration verification of virtual rule application.
|
|
10698
|
+
*
|
|
10699
|
+
* After rules are registered with the watcher, verifies that .meta/meta.json
|
|
10700
|
+
* files are discoverable via watcher walk (which depends on virtual rules
|
|
10701
|
+
* being applied). Logs a warning if expected metas are not found.
|
|
10702
|
+
*
|
|
10703
|
+
* @module rules/verify
|
|
10704
|
+
*/
|
|
10705
|
+
/**
|
|
10706
|
+
* Verify that virtual rules are applied to indexed .meta/meta.json files.
|
|
10707
|
+
*
|
|
10708
|
+
* Runs a discovery pass and logs the result. If no metas are found but
|
|
10709
|
+
* the filesystem likely has some, logs a warning suggesting reindex.
|
|
10710
|
+
*
|
|
10711
|
+
* @param watcher - WatcherClient for discovery.
|
|
10712
|
+
* @param logger - Logger for reporting results.
|
|
10713
|
+
* @returns Number of metas discovered.
|
|
10714
|
+
*/
|
|
10715
|
+
async function verifyRuleApplication(watcher, logger) {
|
|
10716
|
+
try {
|
|
10717
|
+
const metaPaths = await discoverMetas(watcher);
|
|
10718
|
+
if (metaPaths.length === 0) {
|
|
10719
|
+
logger.warn({ count: 0 }, 'Post-registration verification: no .meta/meta.json files found via watcher walk. ' +
|
|
10720
|
+
'Virtual rules may not be applied to indexed files. ' +
|
|
10721
|
+
'If metas exist, a path-scoped reindex may be needed.');
|
|
10722
|
+
}
|
|
10723
|
+
else {
|
|
10724
|
+
logger.info({ count: metaPaths.length }, 'Post-registration verification: metas discoverable');
|
|
10725
|
+
}
|
|
10726
|
+
return metaPaths.length;
|
|
10727
|
+
}
|
|
10728
|
+
catch (err) {
|
|
10729
|
+
logger.warn({ err: err instanceof Error ? err.message : String(err) }, 'Post-registration verification failed (watcher may be unavailable)');
|
|
10730
|
+
return 0;
|
|
10731
|
+
}
|
|
10732
|
+
}
|
|
10733
|
+
|
|
10507
10734
|
/**
|
|
10508
10735
|
* Minimal Fastify HTTP server for jeeves-meta service.
|
|
10509
10736
|
*
|
|
@@ -10799,7 +11026,7 @@ async function startService(config, configPath) {
|
|
|
10799
11026
|
cycleTokens += evt.tokens;
|
|
10800
11027
|
}
|
|
10801
11028
|
await progress.report(evt);
|
|
10802
|
-
});
|
|
11029
|
+
}, logger);
|
|
10803
11030
|
// orchestrate() always returns exactly one result
|
|
10804
11031
|
const result = results[0];
|
|
10805
11032
|
const durationMs = Date.now() - startMs;
|
|
@@ -10851,11 +11078,15 @@ async function startService(config, configPath) {
|
|
|
10851
11078
|
}
|
|
10852
11079
|
// Start scheduler
|
|
10853
11080
|
scheduler.start();
|
|
10854
|
-
// Rule registration (fire-and-forget with retries)
|
|
11081
|
+
// Rule registration (fire-and-forget with retries) + post-registration verification
|
|
10855
11082
|
const registrar = new RuleRegistrar(config, logger, watcher);
|
|
10856
11083
|
scheduler.setRegistrar(registrar);
|
|
10857
11084
|
routeDeps.registrar = registrar;
|
|
10858
|
-
void registrar.register()
|
|
11085
|
+
void registrar.register().then(() => {
|
|
11086
|
+
if (registrar.isRegistered) {
|
|
11087
|
+
void verifyRuleApplication(watcher, logger);
|
|
11088
|
+
}
|
|
11089
|
+
});
|
|
10859
11090
|
// Periodic watcher health check (independent of scheduler)
|
|
10860
11091
|
const healthCheck = new WatcherHealthCheck({
|
|
10861
11092
|
watcherUrl: config.watcherUrl,
|
|
@@ -10864,26 +11095,52 @@ async function startService(config, configPath) {
|
|
|
10864
11095
|
logger,
|
|
10865
11096
|
});
|
|
10866
11097
|
healthCheck.start();
|
|
10867
|
-
// Config hot-reload (gap #12)
|
|
11098
|
+
// Config hot-reload (gap #12, expanded #32)
|
|
11099
|
+
// Fields requiring a service restart to take effect
|
|
11100
|
+
const restartRequiredFields = [
|
|
11101
|
+
'port',
|
|
11102
|
+
'host',
|
|
11103
|
+
'watcherUrl',
|
|
11104
|
+
'gatewayUrl',
|
|
11105
|
+
'gatewayApiKey',
|
|
11106
|
+
'defaultArchitect',
|
|
11107
|
+
'defaultCritic',
|
|
11108
|
+
];
|
|
10868
11109
|
if (configPath) {
|
|
10869
11110
|
watchFile(configPath, { interval: 5000 }, () => {
|
|
10870
11111
|
try {
|
|
10871
11112
|
const newConfig = loadServiceConfig(configPath);
|
|
10872
|
-
//
|
|
11113
|
+
// Warn about restart-required field changes
|
|
11114
|
+
for (const field of restartRequiredFields) {
|
|
11115
|
+
const oldVal = config[field];
|
|
11116
|
+
const newVal = newConfig[field];
|
|
11117
|
+
if (oldVal !== newVal) {
|
|
11118
|
+
logger.warn({ field, oldValue: oldVal, newValue: newVal }, 'Config field changed but requires restart to take effect');
|
|
11119
|
+
}
|
|
11120
|
+
}
|
|
11121
|
+
// Hot-reload schedule
|
|
10873
11122
|
if (newConfig.schedule !== config.schedule) {
|
|
10874
11123
|
scheduler.updateSchedule(newConfig.schedule);
|
|
10875
11124
|
logger.info({ schedule: newConfig.schedule }, 'Schedule hot-reloaded');
|
|
10876
11125
|
}
|
|
10877
|
-
|
|
10878
|
-
// Mutate shared config reference for progress reporter
|
|
10879
|
-
config.reportChannel =
|
|
10880
|
-
newConfig.reportChannel;
|
|
10881
|
-
logger.info({ reportChannel: newConfig.reportChannel }, 'reportChannel hot-reloaded');
|
|
10882
|
-
}
|
|
11126
|
+
// Hot-reload logging level
|
|
10883
11127
|
if (newConfig.logging.level !== config.logging.level) {
|
|
10884
11128
|
logger.level = newConfig.logging.level;
|
|
10885
11129
|
logger.info({ level: newConfig.logging.level }, 'Log level hot-reloaded');
|
|
10886
11130
|
}
|
|
11131
|
+
// Merge all non-restart-required fields into shared config ref.
|
|
11132
|
+
// newConfig is Zod-parsed, so removed fields get defaults — no deletion needed.
|
|
11133
|
+
const restartSet = new Set(restartRequiredFields);
|
|
11134
|
+
for (const key of Object.keys(newConfig)) {
|
|
11135
|
+
if (restartSet.has(key) || key === 'logging')
|
|
11136
|
+
continue;
|
|
11137
|
+
const oldVal = config[key];
|
|
11138
|
+
const newVal = newConfig[key];
|
|
11139
|
+
if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
|
|
11140
|
+
config[key] = newVal;
|
|
11141
|
+
logger.info({ field: key }, 'Config field hot-reloaded');
|
|
11142
|
+
}
|
|
11143
|
+
}
|
|
10887
11144
|
}
|
|
10888
11145
|
catch (err) {
|
|
10889
11146
|
logger.warn({ err }, 'Config hot-reload failed');
|