@karmaniverous/jeeves-meta 0.9.0 → 0.10.1
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 +5 -2
- package/dist/cli/jeeves-meta/architect.md +159 -0
- package/dist/cli/jeeves-meta/critic.md +104 -0
- package/dist/cli/jeeves-meta/index.js +478 -233
- package/dist/index.d.ts +90 -59
- package/dist/index.js +474 -233
- package/dist/prompts/architect.md +159 -0
- package/dist/prompts/critic.md +104 -0
- package/package.json +3 -1
|
@@ -1,14 +1,15 @@
|
|
|
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';
|
|
11
11
|
import pino from 'pino';
|
|
12
|
+
import Handlebars from 'handlebars';
|
|
12
13
|
import { Cron } from 'croner';
|
|
13
14
|
import vm from 'vm';
|
|
14
15
|
import require$$0$3 from 'path';
|
|
@@ -52,10 +53,10 @@ const metaConfigSchema = z.object({
|
|
|
52
53
|
criticTimeout: z.number().int().min(30).default(300),
|
|
53
54
|
/** Thinking level for spawned synthesis sessions. */
|
|
54
55
|
thinking: z.string().default('low'),
|
|
55
|
-
/** Resolved architect system prompt text. */
|
|
56
|
-
defaultArchitect: z.string(),
|
|
57
|
-
/** Resolved critic system prompt text. */
|
|
58
|
-
defaultCritic: z.string(),
|
|
56
|
+
/** Resolved architect system prompt text. Falls back to built-in default. */
|
|
57
|
+
defaultArchitect: z.string().optional(),
|
|
58
|
+
/** Resolved critic system prompt text. Falls back to built-in default. */
|
|
59
|
+
defaultCritic: z.string().optional(),
|
|
59
60
|
/** Skip unchanged candidates, bump _generatedAt. */
|
|
60
61
|
skipUnchanged: z.boolean().default(true),
|
|
61
62
|
/** Watcher metadata properties applied to live .meta/meta.json files. */
|
|
@@ -72,6 +73,15 @@ const loggingSchema = z.object({
|
|
|
72
73
|
/** Optional file path for log output. */
|
|
73
74
|
file: z.string().optional(),
|
|
74
75
|
});
|
|
76
|
+
/** Zod schema for a single auto-seed policy rule. */
|
|
77
|
+
const autoSeedRuleSchema = z.object({
|
|
78
|
+
/** Glob pattern matched against watcher walk results. */
|
|
79
|
+
match: z.string(),
|
|
80
|
+
/** Optional steering prompt for seeded metas. */
|
|
81
|
+
steer: z.string().optional(),
|
|
82
|
+
/** Optional cross-references for seeded metas. */
|
|
83
|
+
crossRefs: z.array(z.string()).optional(),
|
|
84
|
+
});
|
|
75
85
|
/** Zod schema for jeeves-meta service configuration (superset of MetaConfig). */
|
|
76
86
|
const serviceConfigSchema = metaConfigSchema.extend({
|
|
77
87
|
/** HTTP port for the service (default: 1938). */
|
|
@@ -88,6 +98,11 @@ const serviceConfigSchema = metaConfigSchema.extend({
|
|
|
88
98
|
watcherHealthIntervalMs: z.number().int().min(0).default(60_000),
|
|
89
99
|
/** Logging configuration. */
|
|
90
100
|
logging: loggingSchema.default(() => loggingSchema.parse({})),
|
|
101
|
+
/**
|
|
102
|
+
* Auto-seed policy: declarative rules for auto-creating .meta/ directories.
|
|
103
|
+
* Rules are evaluated in order; last match wins for steer/crossRefs.
|
|
104
|
+
*/
|
|
105
|
+
autoSeed: z.array(autoSeedRuleSchema).optional().default([]),
|
|
91
106
|
});
|
|
92
107
|
|
|
93
108
|
/**
|
|
@@ -338,14 +353,12 @@ function listArchiveFiles(metaPath) {
|
|
|
338
353
|
* @param maxArchive - Maximum snapshots to retain.
|
|
339
354
|
* @returns Number of files pruned.
|
|
340
355
|
*/
|
|
341
|
-
function pruneArchive(metaPath, maxArchive) {
|
|
356
|
+
async function pruneArchive(metaPath, maxArchive) {
|
|
342
357
|
const files = listArchiveFiles(metaPath);
|
|
343
358
|
const toRemove = files.length - maxArchive;
|
|
344
359
|
if (toRemove <= 0)
|
|
345
360
|
return 0;
|
|
346
|
-
|
|
347
|
-
unlinkSync(files[i]);
|
|
348
|
-
}
|
|
361
|
+
await Promise.all(files.slice(0, toRemove).map(unlink));
|
|
349
362
|
return toRemove;
|
|
350
363
|
}
|
|
351
364
|
|
|
@@ -360,11 +373,11 @@ function pruneArchive(metaPath, maxArchive) {
|
|
|
360
373
|
* @param metaPath - Absolute path to the .meta directory.
|
|
361
374
|
* @returns The latest archived meta, or null if no archives exist.
|
|
362
375
|
*/
|
|
363
|
-
function readLatestArchive(metaPath) {
|
|
376
|
+
async function readLatestArchive(metaPath) {
|
|
364
377
|
const files = listArchiveFiles(metaPath);
|
|
365
378
|
if (files.length === 0)
|
|
366
379
|
return null;
|
|
367
|
-
const raw =
|
|
380
|
+
const raw = await readFile(files[files.length - 1], 'utf8');
|
|
368
381
|
return JSON.parse(raw);
|
|
369
382
|
}
|
|
370
383
|
|
|
@@ -383,9 +396,9 @@ function readLatestArchive(metaPath) {
|
|
|
383
396
|
* @param meta - Current meta.json content.
|
|
384
397
|
* @returns The archive file path.
|
|
385
398
|
*/
|
|
386
|
-
function createSnapshot(metaPath, meta) {
|
|
399
|
+
async function createSnapshot(metaPath, meta) {
|
|
387
400
|
const archiveDir = join(metaPath, 'archive');
|
|
388
|
-
|
|
401
|
+
await mkdir(archiveDir, { recursive: true });
|
|
389
402
|
const now = new Date().toISOString().replace(/[:.]/g, '-');
|
|
390
403
|
const archiveFile = join(archiveDir, now + '.json');
|
|
391
404
|
const archived = {
|
|
@@ -393,10 +406,79 @@ function createSnapshot(metaPath, meta) {
|
|
|
393
406
|
_archived: true,
|
|
394
407
|
_archivedAt: new Date().toISOString(),
|
|
395
408
|
};
|
|
396
|
-
|
|
409
|
+
await writeFile(archiveFile, JSON.stringify(archived, null, 2) + '\n');
|
|
397
410
|
return archiveFile;
|
|
398
411
|
}
|
|
399
412
|
|
|
413
|
+
/**
|
|
414
|
+
* Compute summary statistics from an array of MetaEntry objects.
|
|
415
|
+
*
|
|
416
|
+
* Shared between listMetas() (full list) and route handlers (filtered lists).
|
|
417
|
+
*
|
|
418
|
+
* @module discovery/computeSummary
|
|
419
|
+
*/
|
|
420
|
+
/**
|
|
421
|
+
* Compute summary statistics from a list of meta entries.
|
|
422
|
+
*
|
|
423
|
+
* @param entries - Enriched meta entries (full or filtered).
|
|
424
|
+
* @param depthWeight - Config depth weight for effective staleness calculation.
|
|
425
|
+
* @returns Aggregated summary statistics.
|
|
426
|
+
*/
|
|
427
|
+
function computeSummary(entries, depthWeight) {
|
|
428
|
+
let staleCount = 0;
|
|
429
|
+
let errorCount = 0;
|
|
430
|
+
let lockedCount = 0;
|
|
431
|
+
let neverSynthesizedCount = 0;
|
|
432
|
+
let totalArchitectTokens = 0;
|
|
433
|
+
let totalBuilderTokens = 0;
|
|
434
|
+
let totalCriticTokens = 0;
|
|
435
|
+
let stalestPath = null;
|
|
436
|
+
let stalestEffective = -1;
|
|
437
|
+
let lastSynthesizedPath = null;
|
|
438
|
+
let lastSynthesizedAt = null;
|
|
439
|
+
for (const e of entries) {
|
|
440
|
+
if (e.stalenessSeconds > 0)
|
|
441
|
+
staleCount++;
|
|
442
|
+
if (e.hasError)
|
|
443
|
+
errorCount++;
|
|
444
|
+
if (e.locked)
|
|
445
|
+
lockedCount++;
|
|
446
|
+
if (e.lastSynthesized === null)
|
|
447
|
+
neverSynthesizedCount++;
|
|
448
|
+
totalArchitectTokens += e.architectTokens ?? 0;
|
|
449
|
+
totalBuilderTokens += e.builderTokens ?? 0;
|
|
450
|
+
totalCriticTokens += e.criticTokens ?? 0;
|
|
451
|
+
// Track last synthesized
|
|
452
|
+
if (e.lastSynthesized &&
|
|
453
|
+
(!lastSynthesizedAt || e.lastSynthesized > lastSynthesizedAt)) {
|
|
454
|
+
lastSynthesizedAt = e.lastSynthesized;
|
|
455
|
+
lastSynthesizedPath = e.path;
|
|
456
|
+
}
|
|
457
|
+
// Track stalest (effective staleness for scheduling)
|
|
458
|
+
const depthFactor = Math.pow(1 + depthWeight, e.depth);
|
|
459
|
+
const effectiveStaleness = e.stalenessSeconds * depthFactor * e.emphasis;
|
|
460
|
+
if (effectiveStaleness > stalestEffective) {
|
|
461
|
+
stalestEffective = effectiveStaleness;
|
|
462
|
+
stalestPath = e.path;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return {
|
|
466
|
+
total: entries.length,
|
|
467
|
+
stale: staleCount,
|
|
468
|
+
errors: errorCount,
|
|
469
|
+
locked: lockedCount,
|
|
470
|
+
neverSynthesized: neverSynthesizedCount,
|
|
471
|
+
tokens: {
|
|
472
|
+
architect: totalArchitectTokens,
|
|
473
|
+
builder: totalBuilderTokens,
|
|
474
|
+
critic: totalCriticTokens,
|
|
475
|
+
},
|
|
476
|
+
stalestPath,
|
|
477
|
+
lastSynthesizedPath,
|
|
478
|
+
lastSynthesizedAt,
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
400
482
|
/**
|
|
401
483
|
* Normalize file paths to forward slashes for consistency with watcher-indexed paths.
|
|
402
484
|
*
|
|
@@ -589,14 +671,15 @@ function cleanupStaleLocks(metaPaths, logger) {
|
|
|
589
671
|
* @module readMetaJson
|
|
590
672
|
*/
|
|
591
673
|
/**
|
|
592
|
-
* Read and parse a meta.json file from a `.meta/` directory path.
|
|
674
|
+
* Read and parse a meta.json file from a `.meta/` directory path (async).
|
|
593
675
|
*
|
|
594
676
|
* @param metaPath - Path to the `.meta/` directory.
|
|
595
677
|
* @returns Parsed meta.json content.
|
|
596
678
|
* @throws If the file doesn't exist or contains invalid JSON.
|
|
597
679
|
*/
|
|
598
|
-
function readMetaJson(metaPath) {
|
|
599
|
-
|
|
680
|
+
async function readMetaJson(metaPath) {
|
|
681
|
+
const raw = await readFile(join(metaPath, 'meta.json'), 'utf8');
|
|
682
|
+
return JSON.parse(raw);
|
|
600
683
|
}
|
|
601
684
|
|
|
602
685
|
/**
|
|
@@ -701,21 +784,10 @@ async function listMetas(config, watcher) {
|
|
|
701
784
|
const tree = buildOwnershipTree(metaPaths);
|
|
702
785
|
// Step 3: Read and enrich each meta from disk
|
|
703
786
|
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
787
|
for (const node of tree.nodes.values()) {
|
|
716
788
|
let meta;
|
|
717
789
|
try {
|
|
718
|
-
meta = readMetaJson(node.metaPath);
|
|
790
|
+
meta = await readMetaJson(node.metaPath);
|
|
719
791
|
}
|
|
720
792
|
catch {
|
|
721
793
|
// Skip unreadable metas
|
|
@@ -739,32 +811,6 @@ async function listMetas(config, watcher) {
|
|
|
739
811
|
const archTokens = meta._architectTokens ?? 0;
|
|
740
812
|
const buildTokens = meta._builderTokens ?? 0;
|
|
741
813
|
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
814
|
entries.push({
|
|
769
815
|
path: node.metaPath,
|
|
770
816
|
depth,
|
|
@@ -782,21 +828,7 @@ async function listMetas(config, watcher) {
|
|
|
782
828
|
});
|
|
783
829
|
}
|
|
784
830
|
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
|
-
},
|
|
831
|
+
summary: computeSummary(entries, config.depthWeight),
|
|
800
832
|
entries,
|
|
801
833
|
tree,
|
|
802
834
|
};
|
|
@@ -900,12 +932,18 @@ function filterInScope(node, files) {
|
|
|
900
932
|
/**
|
|
901
933
|
* Get all files in scope for a meta node via watcher walk.
|
|
902
934
|
*/
|
|
903
|
-
async function getScopeFiles(node, watcher) {
|
|
904
|
-
const
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
935
|
+
async function getScopeFiles(node, watcher, logger) {
|
|
936
|
+
const walkStart = Date.now();
|
|
937
|
+
const rawFiles = await watcher.walk([`${node.ownerPath}/**`]);
|
|
938
|
+
const allFiles = rawFiles.map(normalizePath);
|
|
939
|
+
const scopeFiles = filterInScope(node, allFiles);
|
|
940
|
+
logger?.debug({
|
|
941
|
+
ownerPath: node.ownerPath,
|
|
942
|
+
allFiles: allFiles.length,
|
|
943
|
+
scopeFiles: scopeFiles.length,
|
|
944
|
+
durationMs: Date.now() - walkStart,
|
|
945
|
+
}, 'scope files enumerated');
|
|
946
|
+
return { scopeFiles, allFiles };
|
|
909
947
|
}
|
|
910
948
|
/**
|
|
911
949
|
* Get files modified since a given timestamp within a meta node's scope.
|
|
@@ -1197,6 +1235,23 @@ function createLogger(config) {
|
|
|
1197
1235
|
return pino({ level });
|
|
1198
1236
|
}
|
|
1199
1237
|
|
|
1238
|
+
/**
|
|
1239
|
+
* Built-in default prompts for the synthesis pipeline.
|
|
1240
|
+
*
|
|
1241
|
+
* Prompts ship as .md files bundled into dist/prompts/ via rollup-plugin-copy.
|
|
1242
|
+
* Loaded at runtime relative to the compiled module location.
|
|
1243
|
+
*
|
|
1244
|
+
* Users can override via `defaultArchitect` / `defaultCritic` in the service
|
|
1245
|
+
* config. Most installations should use the built-in defaults.
|
|
1246
|
+
*
|
|
1247
|
+
* @module prompts
|
|
1248
|
+
*/
|
|
1249
|
+
const promptDir = dirname(fileURLToPath(import.meta.url));
|
|
1250
|
+
/** Built-in default architect prompt. */
|
|
1251
|
+
const DEFAULT_ARCHITECT_PROMPT = readFileSync(join(promptDir, 'architect.md'), 'utf8');
|
|
1252
|
+
/** Built-in default critic prompt. */
|
|
1253
|
+
const DEFAULT_CRITIC_PROMPT = readFileSync(join(promptDir, 'critic.md'), 'utf8');
|
|
1254
|
+
|
|
1200
1255
|
/**
|
|
1201
1256
|
* Build the MetaContext for a synthesis cycle.
|
|
1202
1257
|
*
|
|
@@ -1236,9 +1291,9 @@ function condenseScopeFiles(files, maxIndividual = 30) {
|
|
|
1236
1291
|
* @param metaJsonPath - Absolute path to a meta.json file.
|
|
1237
1292
|
* @returns The `_content` string, or null if missing/unreadable.
|
|
1238
1293
|
*/
|
|
1239
|
-
function readMetaContent(metaJsonPath) {
|
|
1294
|
+
async function readMetaContent(metaJsonPath) {
|
|
1240
1295
|
try {
|
|
1241
|
-
const raw =
|
|
1296
|
+
const raw = await readFile(metaJsonPath, 'utf8');
|
|
1242
1297
|
const meta = JSON.parse(raw);
|
|
1243
1298
|
return meta._content ?? null;
|
|
1244
1299
|
}
|
|
@@ -1254,25 +1309,43 @@ function readMetaContent(metaJsonPath) {
|
|
|
1254
1309
|
* @param watcher - WatcherClient for scope enumeration.
|
|
1255
1310
|
* @returns The computed context package.
|
|
1256
1311
|
*/
|
|
1257
|
-
async function buildContextPackage(node, meta, watcher) {
|
|
1312
|
+
async function buildContextPackage(node, meta, watcher, logger) {
|
|
1258
1313
|
// Scope and delta files via watcher walk
|
|
1259
|
-
const
|
|
1314
|
+
const scopeStart = Date.now();
|
|
1315
|
+
const { scopeFiles } = await getScopeFiles(node, watcher, logger);
|
|
1260
1316
|
const deltaFiles = getDeltaFiles(meta._generatedAt, scopeFiles);
|
|
1261
|
-
|
|
1317
|
+
logger?.debug({
|
|
1318
|
+
scopeFiles: scopeFiles.length,
|
|
1319
|
+
deltaFiles: deltaFiles.length,
|
|
1320
|
+
durationMs: Date.now() - scopeStart,
|
|
1321
|
+
}, 'scope and delta files computed');
|
|
1322
|
+
// Child meta outputs (parallel reads)
|
|
1262
1323
|
const childMetas = {};
|
|
1263
|
-
|
|
1264
|
-
|
|
1324
|
+
const childEntries = await Promise.all(node.children.map(async (child) => {
|
|
1325
|
+
const content = await readMetaContent(join(child.metaPath, 'meta.json'));
|
|
1326
|
+
return [child.ownerPath, content];
|
|
1327
|
+
}));
|
|
1328
|
+
for (const [path, content] of childEntries) {
|
|
1329
|
+
childMetas[path] = content;
|
|
1265
1330
|
}
|
|
1266
|
-
// Cross-referenced meta outputs
|
|
1331
|
+
// Cross-referenced meta outputs (parallel reads)
|
|
1267
1332
|
const crossRefMetas = {};
|
|
1268
1333
|
const seen = new Set();
|
|
1334
|
+
const crossRefPaths = [];
|
|
1269
1335
|
for (const refPath of meta._crossRefs ?? []) {
|
|
1270
1336
|
if (refPath === node.ownerPath || refPath === node.metaPath)
|
|
1271
1337
|
continue;
|
|
1272
1338
|
if (seen.has(refPath))
|
|
1273
1339
|
continue;
|
|
1274
1340
|
seen.add(refPath);
|
|
1275
|
-
|
|
1341
|
+
crossRefPaths.push(refPath);
|
|
1342
|
+
}
|
|
1343
|
+
const crossRefEntries = await Promise.all(crossRefPaths.map(async (refPath) => {
|
|
1344
|
+
const content = await readMetaContent(join(refPath, '.meta', 'meta.json'));
|
|
1345
|
+
return [refPath, content];
|
|
1346
|
+
}));
|
|
1347
|
+
for (const [path, content] of crossRefEntries) {
|
|
1348
|
+
crossRefMetas[path] = content;
|
|
1276
1349
|
}
|
|
1277
1350
|
// Archive paths
|
|
1278
1351
|
const archives = listArchiveFiles(node.metaPath);
|
|
@@ -1293,8 +1366,37 @@ async function buildContextPackage(node, meta, watcher) {
|
|
|
1293
1366
|
/**
|
|
1294
1367
|
* Build task prompts for each synthesis step.
|
|
1295
1368
|
*
|
|
1369
|
+
* Prompts are compiled as Handlebars templates with access to config,
|
|
1370
|
+
* meta, and scope context. The architect can write template expressions
|
|
1371
|
+
* into its _builder output; these resolve when the builder task is compiled.
|
|
1372
|
+
*
|
|
1296
1373
|
* @module orchestrator/buildTask
|
|
1297
1374
|
*/
|
|
1375
|
+
/** Build the template context from synthesis inputs. */
|
|
1376
|
+
function buildTemplateContext(ctx, meta, config) {
|
|
1377
|
+
return {
|
|
1378
|
+
config,
|
|
1379
|
+
meta,
|
|
1380
|
+
scope: {
|
|
1381
|
+
fileCount: ctx.scopeFiles.length,
|
|
1382
|
+
deltaCount: ctx.deltaFiles.length,
|
|
1383
|
+
childCount: Object.keys(ctx.childMetas).length,
|
|
1384
|
+
crossRefCount: Object.keys(ctx.crossRefMetas).length,
|
|
1385
|
+
},
|
|
1386
|
+
};
|
|
1387
|
+
}
|
|
1388
|
+
/**
|
|
1389
|
+
* Compile a string as a Handlebars template with the given context.
|
|
1390
|
+
* Returns the original string unchanged if compilation fails.
|
|
1391
|
+
*/
|
|
1392
|
+
function compileTemplate(text, context) {
|
|
1393
|
+
try {
|
|
1394
|
+
return Handlebars.compile(text, { noEscape: true })(context);
|
|
1395
|
+
}
|
|
1396
|
+
catch {
|
|
1397
|
+
return text;
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1298
1400
|
/** Append a keyed record of meta outputs as subsections, if non-empty. */
|
|
1299
1401
|
function appendMetaSections(sections, heading, metas) {
|
|
1300
1402
|
if (Object.keys(metas).length === 0)
|
|
@@ -1341,7 +1443,7 @@ function appendSharedSections(sections, ctx, options) {
|
|
|
1341
1443
|
*/
|
|
1342
1444
|
function buildArchitectTask(ctx, meta, config) {
|
|
1343
1445
|
const sections = [
|
|
1344
|
-
meta._architect ?? config.defaultArchitect,
|
|
1446
|
+
meta._architect ?? config.defaultArchitect ?? DEFAULT_ARCHITECT_PROMPT,
|
|
1345
1447
|
'',
|
|
1346
1448
|
'## SCOPE',
|
|
1347
1449
|
`Path: ${ctx.path}`,
|
|
@@ -1359,7 +1461,7 @@ function buildArchitectTask(ctx, meta, config) {
|
|
|
1359
1461
|
if (ctx.archives.length > 0) {
|
|
1360
1462
|
sections.push('', '## ARCHIVE HISTORY', `${ctx.archives.length.toString()} previous synthesis snapshots available in .meta/archive/.`, 'Review these to understand how the synthesis has evolved over time.');
|
|
1361
1463
|
}
|
|
1362
|
-
return sections.join('\n');
|
|
1464
|
+
return compileTemplate(sections.join('\n'), buildTemplateContext(ctx, meta, config));
|
|
1363
1465
|
}
|
|
1364
1466
|
/**
|
|
1365
1467
|
* Build the builder task prompt.
|
|
@@ -1387,7 +1489,7 @@ function buildBuilderTask(ctx, meta, config) {
|
|
|
1387
1489
|
feedbackHeading: '## FEEDBACK FROM CRITIC',
|
|
1388
1490
|
});
|
|
1389
1491
|
sections.push('', '## OUTPUT FORMAT', '', 'Respond with ONLY a JSON object. No explanation, no markdown fences, no text before or after.', '', 'Required schema:', '{', ' "type": "object",', ' "required": ["_content"],', ' "properties": {', ' "_content": { "type": "string", "description": "Markdown narrative synthesis" },', ' "_state": { "description": "Opaque state object for progressive work across cycles" }', ' },', ' "additionalProperties": true', '}', '', 'Add any structured fields that capture important facts about this entity', '(e.g. status, risks, dependencies, metrics). Use descriptive key names without underscore prefix.', 'The _content field is the only required key — everything else is domain-driven.', '_state is optional: set it to carry state across synthesis cycles for progressive work.', '', 'DIAGRAMS: When diagrams would aid understanding, use PlantUML in fenced code blocks (```plantuml).', 'PlantUML is rendered natively by the serving infrastructure. NEVER use ASCII art diagrams.');
|
|
1390
|
-
return sections.join('\n');
|
|
1492
|
+
return compileTemplate(sections.join('\n'), buildTemplateContext(ctx, meta, config));
|
|
1391
1493
|
}
|
|
1392
1494
|
/**
|
|
1393
1495
|
* Build the critic task prompt.
|
|
@@ -1399,7 +1501,7 @@ function buildBuilderTask(ctx, meta, config) {
|
|
|
1399
1501
|
*/
|
|
1400
1502
|
function buildCriticTask(ctx, meta, config) {
|
|
1401
1503
|
const sections = [
|
|
1402
|
-
meta._critic ?? config.defaultCritic,
|
|
1504
|
+
meta._critic ?? config.defaultCritic ?? DEFAULT_CRITIC_PROMPT,
|
|
1403
1505
|
'',
|
|
1404
1506
|
'## SYNTHESIS TO EVALUATE',
|
|
1405
1507
|
meta._content ?? '(No content produced)',
|
|
@@ -1415,7 +1517,7 @@ function buildCriticTask(ctx, meta, config) {
|
|
|
1415
1517
|
includeCrossRefs: false,
|
|
1416
1518
|
});
|
|
1417
1519
|
sections.push('', '## OUTPUT FORMAT', 'Return your evaluation as Markdown text. Be specific and actionable.');
|
|
1418
|
-
return sections.join('\n');
|
|
1520
|
+
return compileTemplate(sections.join('\n'), buildTemplateContext(ctx, meta, config));
|
|
1419
1521
|
}
|
|
1420
1522
|
|
|
1421
1523
|
/**
|
|
@@ -1444,8 +1546,8 @@ const metaErrorSchema = z.object({
|
|
|
1444
1546
|
/** Zod schema for the reserved (underscore-prefixed) meta.json properties. */
|
|
1445
1547
|
const metaJsonSchema = z
|
|
1446
1548
|
.object({
|
|
1447
|
-
/** Stable identity.
|
|
1448
|
-
_id: z.uuid(),
|
|
1549
|
+
/** Stable identity. Auto-generated on first synthesis if not provided. */
|
|
1550
|
+
_id: z.uuid().optional(),
|
|
1449
1551
|
/** Human-provided steering prompt. Optional. */
|
|
1450
1552
|
_steer: z.string().optional(),
|
|
1451
1553
|
/**
|
|
@@ -1538,10 +1640,10 @@ const metaJsonSchema = z
|
|
|
1538
1640
|
* @returns The updated MetaJson.
|
|
1539
1641
|
* @throws If validation fails (malformed output).
|
|
1540
1642
|
*/
|
|
1541
|
-
function mergeAndWrite(options) {
|
|
1643
|
+
async function mergeAndWrite(options) {
|
|
1542
1644
|
const merged = {
|
|
1543
|
-
// Preserve human-set fields
|
|
1544
|
-
_id: options.current._id,
|
|
1645
|
+
// Preserve human-set fields (auto-generate _id on first synthesis)
|
|
1646
|
+
_id: options.current._id ?? randomUUID(),
|
|
1545
1647
|
_steer: options.current._steer,
|
|
1546
1648
|
_depth: options.current._depth,
|
|
1547
1649
|
_emphasis: options.current._emphasis,
|
|
@@ -1614,7 +1716,7 @@ function mergeAndWrite(options) {
|
|
|
1614
1716
|
}
|
|
1615
1717
|
// Write to specified path (lock staging) or default meta.json
|
|
1616
1718
|
const filePath = options.outputPath ?? join(options.metaPath, 'meta.json');
|
|
1617
|
-
|
|
1719
|
+
await writeFile(filePath, JSON.stringify(result.data, null, 2) + '\n');
|
|
1618
1720
|
return result.data;
|
|
1619
1721
|
}
|
|
1620
1722
|
|
|
@@ -1854,11 +1956,11 @@ function computeStalenessScore(stalenessSeconds, depth, emphasis, depthWeight) {
|
|
|
1854
1956
|
* @module orchestrator/finalizeCycle
|
|
1855
1957
|
*/
|
|
1856
1958
|
/** Finalize a cycle using lock staging: write to .lock → copy to meta.json + archive → delete .lock. */
|
|
1857
|
-
function finalizeCycle(opts) {
|
|
1959
|
+
async function finalizeCycle(opts) {
|
|
1858
1960
|
const lockPath = join(opts.metaPath, '.lock');
|
|
1859
1961
|
const metaJsonPath = join(opts.metaPath, 'meta.json');
|
|
1860
|
-
// Stage: write merged result to .lock
|
|
1861
|
-
const updated = mergeAndWrite({
|
|
1962
|
+
// Stage: write merged result to .lock (sequential — ordering matters)
|
|
1963
|
+
const updated = await mergeAndWrite({
|
|
1862
1964
|
metaPath: opts.metaPath,
|
|
1863
1965
|
current: opts.current,
|
|
1864
1966
|
architect: opts.architect,
|
|
@@ -1877,10 +1979,10 @@ function finalizeCycle(opts) {
|
|
|
1877
1979
|
stateOnly: opts.stateOnly,
|
|
1878
1980
|
});
|
|
1879
1981
|
// Commit: copy .lock → meta.json
|
|
1880
|
-
|
|
1881
|
-
// Archive + prune from the committed meta.json
|
|
1882
|
-
createSnapshot(opts.metaPath, updated);
|
|
1883
|
-
pruneArchive(opts.metaPath, opts.config.maxArchive);
|
|
1982
|
+
await copyFile(lockPath, metaJsonPath);
|
|
1983
|
+
// Archive + prune from the committed meta.json (sequential)
|
|
1984
|
+
await createSnapshot(opts.metaPath, updated);
|
|
1985
|
+
await pruneArchive(opts.metaPath, opts.config.maxArchive);
|
|
1884
1986
|
// .lock is cleaned up by the finally block (releaseLock)
|
|
1885
1987
|
return updated;
|
|
1886
1988
|
}
|
|
@@ -1993,14 +2095,12 @@ function parseCriticOutput(output) {
|
|
|
1993
2095
|
* Returns an {@link OrchestrateResult} if state was salvaged, or `null`
|
|
1994
2096
|
* if the caller should fall through to a hard failure.
|
|
1995
2097
|
*/
|
|
1996
|
-
function attemptTimeoutRecovery(opts) {
|
|
2098
|
+
async function attemptTimeoutRecovery(opts) {
|
|
1997
2099
|
const { err, currentMeta, metaPath, config, builderBrief, structureHash, synthesisCount, } = opts;
|
|
1998
2100
|
let partialOutput = null;
|
|
1999
2101
|
try {
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
partialOutput = parseBuilderOutput(raw);
|
|
2003
|
-
}
|
|
2102
|
+
const raw = await readFile(err.outputPath, 'utf8');
|
|
2103
|
+
partialOutput = parseBuilderOutput(raw);
|
|
2004
2104
|
}
|
|
2005
2105
|
catch {
|
|
2006
2106
|
// Could not read partial output — fall through to hard failure
|
|
@@ -2014,7 +2114,7 @@ function attemptTimeoutRecovery(opts) {
|
|
|
2014
2114
|
code: 'TIMEOUT',
|
|
2015
2115
|
message: err.message,
|
|
2016
2116
|
};
|
|
2017
|
-
finalizeCycle({
|
|
2117
|
+
await finalizeCycle({
|
|
2018
2118
|
metaPath,
|
|
2019
2119
|
current: currentMeta,
|
|
2020
2120
|
config,
|
|
@@ -2045,12 +2145,12 @@ function attemptTimeoutRecovery(opts) {
|
|
|
2045
2145
|
* @module orchestrator/synthesizeNode
|
|
2046
2146
|
*/
|
|
2047
2147
|
/** Run the architect/builder/critic pipeline on a single node. */
|
|
2048
|
-
async function synthesizeNode(node, currentMeta, config, executor, watcher, onProgress) {
|
|
2148
|
+
async function synthesizeNode(node, currentMeta, config, executor, watcher, onProgress, logger) {
|
|
2049
2149
|
// Step 5-6: Steer change detection
|
|
2050
|
-
const latestArchive = readLatestArchive(node.metaPath);
|
|
2150
|
+
const latestArchive = await readLatestArchive(node.metaPath);
|
|
2051
2151
|
const steerChanged = hasSteerChanged(currentMeta._steer, latestArchive?._steer, Boolean(latestArchive));
|
|
2052
2152
|
// Step 7: Compute context (includes scope files and delta files)
|
|
2053
|
-
const ctx = await buildContextPackage(node, currentMeta, watcher);
|
|
2153
|
+
const ctx = await buildContextPackage(node, currentMeta, watcher, logger);
|
|
2054
2154
|
// Step 5 (deferred): Structure hash from context scope files
|
|
2055
2155
|
const newStructureHash = computeStructureHash(ctx.scopeFiles);
|
|
2056
2156
|
const structureChanged = newStructureHash !== currentMeta._structureHash;
|
|
@@ -2062,6 +2162,16 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
|
|
|
2062
2162
|
let architectTokens;
|
|
2063
2163
|
let builderTokens;
|
|
2064
2164
|
let criticTokens;
|
|
2165
|
+
// Shared base options for all finalizeCycle calls.
|
|
2166
|
+
// Note: synthesisCount is excluded because it mutates during the pipeline.
|
|
2167
|
+
const baseFinalizeOptions = {
|
|
2168
|
+
metaPath: node.metaPath,
|
|
2169
|
+
current: currentMeta,
|
|
2170
|
+
config,
|
|
2171
|
+
architect: currentMeta._architect ?? '',
|
|
2172
|
+
critic: currentMeta._critic ?? '',
|
|
2173
|
+
structureHash: newStructureHash,
|
|
2174
|
+
};
|
|
2065
2175
|
if (architectTriggered) {
|
|
2066
2176
|
try {
|
|
2067
2177
|
await onProgress?.({
|
|
@@ -2090,16 +2200,11 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
|
|
|
2090
2200
|
stepError = toMetaError('architect', err);
|
|
2091
2201
|
if (!currentMeta._builder) {
|
|
2092
2202
|
// No cached builder — cycle fails
|
|
2093
|
-
finalizeCycle({
|
|
2094
|
-
|
|
2095
|
-
current: currentMeta,
|
|
2096
|
-
config,
|
|
2097
|
-
architect: currentMeta._architect ?? '',
|
|
2203
|
+
await finalizeCycle({
|
|
2204
|
+
...baseFinalizeOptions,
|
|
2098
2205
|
builder: '',
|
|
2099
|
-
critic: currentMeta._critic ?? '',
|
|
2100
2206
|
builderOutput: null,
|
|
2101
2207
|
feedback: null,
|
|
2102
|
-
structureHash: newStructureHash,
|
|
2103
2208
|
synthesisCount,
|
|
2104
2209
|
error: stepError,
|
|
2105
2210
|
architectTokens,
|
|
@@ -2141,7 +2246,7 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
|
|
|
2141
2246
|
}
|
|
2142
2247
|
catch (err) {
|
|
2143
2248
|
if (err instanceof SpawnTimeoutError) {
|
|
2144
|
-
const recovered = attemptTimeoutRecovery({
|
|
2249
|
+
const recovered = await attemptTimeoutRecovery({
|
|
2145
2250
|
err,
|
|
2146
2251
|
currentMeta,
|
|
2147
2252
|
metaPath: node.metaPath,
|
|
@@ -2154,16 +2259,11 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
|
|
|
2154
2259
|
return recovered;
|
|
2155
2260
|
}
|
|
2156
2261
|
stepError = toMetaError('builder', err);
|
|
2157
|
-
finalizeCycle({
|
|
2158
|
-
|
|
2159
|
-
current: currentMeta,
|
|
2160
|
-
config,
|
|
2161
|
-
architect: currentMeta._architect ?? '',
|
|
2262
|
+
await finalizeCycle({
|
|
2263
|
+
...baseFinalizeOptions,
|
|
2162
2264
|
builder: builderBrief,
|
|
2163
|
-
critic: currentMeta._critic ?? '',
|
|
2164
2265
|
builderOutput: null,
|
|
2165
2266
|
feedback: null,
|
|
2166
|
-
structureHash: newStructureHash,
|
|
2167
2267
|
synthesisCount,
|
|
2168
2268
|
error: stepError,
|
|
2169
2269
|
});
|
|
@@ -2202,16 +2302,11 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
|
|
|
2202
2302
|
stepError = stepError ?? toMetaError('critic', err);
|
|
2203
2303
|
}
|
|
2204
2304
|
// Steps 11-12: Merge, archive, prune
|
|
2205
|
-
finalizeCycle({
|
|
2206
|
-
|
|
2207
|
-
current: currentMeta,
|
|
2208
|
-
config,
|
|
2209
|
-
architect: currentMeta._architect ?? '',
|
|
2305
|
+
await finalizeCycle({
|
|
2306
|
+
...baseFinalizeOptions,
|
|
2210
2307
|
builder: builderBrief,
|
|
2211
|
-
critic: currentMeta._critic ?? '',
|
|
2212
2308
|
builderOutput,
|
|
2213
2309
|
feedback,
|
|
2214
|
-
structureHash: newStructureHash,
|
|
2215
2310
|
synthesisCount,
|
|
2216
2311
|
error: stepError,
|
|
2217
2312
|
architectTokens,
|
|
@@ -2243,21 +2338,25 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
|
|
|
2243
2338
|
if (!acquireLock(node.metaPath))
|
|
2244
2339
|
return { synthesized: false };
|
|
2245
2340
|
try {
|
|
2246
|
-
const currentMeta = readMetaJson(normalizedTarget);
|
|
2247
|
-
return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress);
|
|
2341
|
+
const currentMeta = await readMetaJson(normalizedTarget);
|
|
2342
|
+
return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress, logger);
|
|
2248
2343
|
}
|
|
2249
2344
|
finally {
|
|
2250
2345
|
releaseLock(node.metaPath);
|
|
2251
2346
|
}
|
|
2252
2347
|
}
|
|
2348
|
+
// Full discovery path (scheduler-driven, no specific target)
|
|
2349
|
+
// Step 1: Discover via watcher walk
|
|
2350
|
+
const discoveryStart = Date.now();
|
|
2253
2351
|
const metaPaths = await discoverMetas(watcher);
|
|
2352
|
+
logger?.debug({ paths: metaPaths.length, durationMs: Date.now() - discoveryStart }, 'discovery complete');
|
|
2254
2353
|
if (metaPaths.length === 0)
|
|
2255
2354
|
return { synthesized: false };
|
|
2256
2355
|
// Read meta.json for each discovered meta
|
|
2257
2356
|
const metas = new Map();
|
|
2258
2357
|
for (const mp of metaPaths) {
|
|
2259
2358
|
try {
|
|
2260
|
-
metas.set(normalizePath(mp), readMetaJson(mp));
|
|
2359
|
+
metas.set(normalizePath(mp), await readMetaJson(mp));
|
|
2261
2360
|
}
|
|
2262
2361
|
catch {
|
|
2263
2362
|
// Skip metas with unreadable meta.json
|
|
@@ -2293,9 +2392,9 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
|
|
|
2293
2392
|
const verifiedStale = await isStale(getScopePrefix(candidate.node), candidate.meta, watcher);
|
|
2294
2393
|
if (!verifiedStale && candidate.meta._generatedAt) {
|
|
2295
2394
|
// Bump _generatedAt so it doesn't win next cycle
|
|
2296
|
-
const freshMeta = readMetaJson(candidate.node.metaPath);
|
|
2395
|
+
const freshMeta = await readMetaJson(candidate.node.metaPath);
|
|
2297
2396
|
freshMeta._generatedAt = new Date().toISOString();
|
|
2298
|
-
|
|
2397
|
+
await writeFile(join(candidate.node.metaPath, 'meta.json'), JSON.stringify(freshMeta, null, 2));
|
|
2299
2398
|
releaseLock(candidate.node.metaPath);
|
|
2300
2399
|
if (config.skipUnchanged)
|
|
2301
2400
|
continue;
|
|
@@ -2308,8 +2407,8 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
|
|
|
2308
2407
|
return { synthesized: false };
|
|
2309
2408
|
const node = winner.node;
|
|
2310
2409
|
try {
|
|
2311
|
-
const currentMeta = readMetaJson(node.metaPath);
|
|
2312
|
-
return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress);
|
|
2410
|
+
const currentMeta = await readMetaJson(node.metaPath);
|
|
2411
|
+
return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress, logger);
|
|
2313
2412
|
}
|
|
2314
2413
|
finally {
|
|
2315
2414
|
// Step 13: Release lock
|
|
@@ -2329,7 +2428,7 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
|
|
|
2329
2428
|
* @returns Array with a single result.
|
|
2330
2429
|
*/
|
|
2331
2430
|
async function orchestrate(config, executor, watcher, targetPath, onProgress, logger) {
|
|
2332
|
-
const result = await orchestrateOnce(config, executor, watcher, targetPath, onProgress);
|
|
2431
|
+
const result = await orchestrateOnce(config, executor, watcher, targetPath, onProgress, logger);
|
|
2333
2432
|
return [result];
|
|
2334
2433
|
}
|
|
2335
2434
|
|
|
@@ -2348,14 +2447,15 @@ function formatSeconds(durationMs) {
|
|
|
2348
2447
|
function titleCasePhase(phase) {
|
|
2349
2448
|
return phase.charAt(0).toUpperCase() + phase.slice(1);
|
|
2350
2449
|
}
|
|
2351
|
-
/** Build a link
|
|
2450
|
+
/** Build a link to the entity's meta.json output file. */
|
|
2352
2451
|
function buildEntityLink(path, serverBaseUrl) {
|
|
2452
|
+
// Normalize backslashes, then convert drive letter to URL path segment
|
|
2453
|
+
const normalized = normalizePath(path).replace(/^([A-Za-z]):/, '/$1');
|
|
2454
|
+
const metaJsonPath = `${normalized}/.meta/meta.json`;
|
|
2353
2455
|
if (!serverBaseUrl)
|
|
2354
|
-
return
|
|
2456
|
+
return metaJsonPath;
|
|
2355
2457
|
const base = serverBaseUrl.replace(/\/+$/, '');
|
|
2356
|
-
|
|
2357
|
-
const normalized = path.replace(/^([A-Za-z]):/, '/$1').replace(/\\/g, '/');
|
|
2358
|
-
return `${base}/path${normalized}`;
|
|
2458
|
+
return `${base}/path${metaJsonPath}`;
|
|
2359
2459
|
}
|
|
2360
2460
|
function formatProgressEvent(event, serverBaseUrl) {
|
|
2361
2461
|
const pathDisplay = buildEntityLink(event.path, serverBaseUrl);
|
|
@@ -2434,6 +2534,123 @@ class ProgressReporter {
|
|
|
2434
2534
|
}
|
|
2435
2535
|
}
|
|
2436
2536
|
|
|
2537
|
+
/**
|
|
2538
|
+
* Core seed logic — create a .meta/ directory with initial meta.json.
|
|
2539
|
+
*
|
|
2540
|
+
* Shared between the POST /seed route handler and the auto-seed pass.
|
|
2541
|
+
*
|
|
2542
|
+
* @module seed/createMeta
|
|
2543
|
+
*/
|
|
2544
|
+
/**
|
|
2545
|
+
* Create a .meta/ directory with an initial meta.json.
|
|
2546
|
+
*
|
|
2547
|
+
* Does NOT check for existing .meta/ — caller is responsible for that guard.
|
|
2548
|
+
*
|
|
2549
|
+
* @param ownerPath - The owner directory path.
|
|
2550
|
+
* @param options - Optional cross-refs and steering prompt.
|
|
2551
|
+
* @returns The meta directory path and generated ID.
|
|
2552
|
+
*/
|
|
2553
|
+
async function createMeta(ownerPath, options) {
|
|
2554
|
+
const metaDir = resolveMetaDir(ownerPath);
|
|
2555
|
+
await mkdir(metaDir, { recursive: true });
|
|
2556
|
+
const _id = randomUUID();
|
|
2557
|
+
const metaJson = { _id };
|
|
2558
|
+
if (options?.crossRefs !== undefined)
|
|
2559
|
+
metaJson._crossRefs = options.crossRefs;
|
|
2560
|
+
if (options?.steer !== undefined)
|
|
2561
|
+
metaJson._steer = options.steer;
|
|
2562
|
+
const metaJsonPath = join(metaDir, 'meta.json');
|
|
2563
|
+
await writeFile(metaJsonPath, JSON.stringify(metaJson, null, 2) + '\n');
|
|
2564
|
+
return { metaDir, _id };
|
|
2565
|
+
}
|
|
2566
|
+
/**
|
|
2567
|
+
* Check if a .meta/ directory already exists for an owner path.
|
|
2568
|
+
*
|
|
2569
|
+
* @param ownerPath - The owner directory path.
|
|
2570
|
+
* @returns True if .meta/ already exists.
|
|
2571
|
+
*/
|
|
2572
|
+
function metaExists(ownerPath) {
|
|
2573
|
+
return existsSync(resolveMetaDir(ownerPath));
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
/**
|
|
2577
|
+
* Auto-seed pass — scan for directories matching policy rules and seed them.
|
|
2578
|
+
*
|
|
2579
|
+
* Runs before discovery in each scheduler tick. For each auto-seed rule,
|
|
2580
|
+
* walks matching directories via the watcher and creates .meta/ directories
|
|
2581
|
+
* for those that don't already have one.
|
|
2582
|
+
*
|
|
2583
|
+
* Rules are processed in array order; last match wins for steer/crossRefs.
|
|
2584
|
+
*
|
|
2585
|
+
* @module seed/autoSeed
|
|
2586
|
+
*/
|
|
2587
|
+
/**
|
|
2588
|
+
* Extract parent directory paths from watcher walk results.
|
|
2589
|
+
*
|
|
2590
|
+
* Walk returns file paths; we need the unique set of immediate parent
|
|
2591
|
+
* directories that could be owners.
|
|
2592
|
+
*/
|
|
2593
|
+
function extractDirectories(filePaths) {
|
|
2594
|
+
const dirs = new Set();
|
|
2595
|
+
for (const fp of filePaths) {
|
|
2596
|
+
const dir = posix.dirname(fp);
|
|
2597
|
+
if (dir !== '.' && dir !== '/') {
|
|
2598
|
+
dirs.add(dir);
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
return [...dirs];
|
|
2602
|
+
}
|
|
2603
|
+
/**
|
|
2604
|
+
* Run the auto-seed pass: apply policy rules and create missing metas.
|
|
2605
|
+
*
|
|
2606
|
+
* @param rules - Auto-seed policy rules from config.
|
|
2607
|
+
* @param watcher - Watcher client for filesystem enumeration.
|
|
2608
|
+
* @param logger - Logger for reporting seed actions.
|
|
2609
|
+
* @returns Summary of what was seeded.
|
|
2610
|
+
*/
|
|
2611
|
+
async function autoSeedPass(rules, watcher, logger) {
|
|
2612
|
+
if (rules.length === 0)
|
|
2613
|
+
return { seeded: 0, paths: [] };
|
|
2614
|
+
// Build a map of ownerPath → effective options (last match wins)
|
|
2615
|
+
const candidates = new Map();
|
|
2616
|
+
for (const rule of rules) {
|
|
2617
|
+
const files = await watcher.walk([rule.match]);
|
|
2618
|
+
const dirs = extractDirectories(files);
|
|
2619
|
+
for (const dir of dirs) {
|
|
2620
|
+
candidates.set(dir, {
|
|
2621
|
+
steer: rule.steer,
|
|
2622
|
+
crossRefs: rule.crossRefs,
|
|
2623
|
+
});
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
// Filter out paths that already have .meta/meta.json
|
|
2627
|
+
const toSeed = [];
|
|
2628
|
+
for (const [path, opts] of candidates) {
|
|
2629
|
+
if (!metaExists(path)) {
|
|
2630
|
+
toSeed.push({ path, ...opts });
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
// Seed remaining
|
|
2634
|
+
const seededPaths = [];
|
|
2635
|
+
for (const candidate of toSeed) {
|
|
2636
|
+
try {
|
|
2637
|
+
await createMeta(candidate.path, {
|
|
2638
|
+
steer: candidate.steer,
|
|
2639
|
+
crossRefs: candidate.crossRefs,
|
|
2640
|
+
});
|
|
2641
|
+
seededPaths.push(candidate.path);
|
|
2642
|
+
logger?.info({ path: candidate.path }, 'auto-seeded meta');
|
|
2643
|
+
}
|
|
2644
|
+
catch (err) {
|
|
2645
|
+
logger?.warn({
|
|
2646
|
+
path: candidate.path,
|
|
2647
|
+
err: err instanceof Error ? err.message : String(err),
|
|
2648
|
+
}, 'auto-seed failed for path');
|
|
2649
|
+
}
|
|
2650
|
+
}
|
|
2651
|
+
return { seeded: seededPaths.length, paths: seededPaths };
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2437
2654
|
/**
|
|
2438
2655
|
* Croner-based scheduler that discovers the stalest meta candidate each tick
|
|
2439
2656
|
* and enqueues it for synthesis.
|
|
@@ -2531,6 +2748,18 @@ class Scheduler {
|
|
|
2531
2748
|
}, 'Skipping tick (backoff)');
|
|
2532
2749
|
return;
|
|
2533
2750
|
}
|
|
2751
|
+
// Auto-seed pass: create .meta/ for matching directories
|
|
2752
|
+
if (this.config.autoSeed.length > 0) {
|
|
2753
|
+
try {
|
|
2754
|
+
const result = await autoSeedPass(this.config.autoSeed, this.watcher, this.logger);
|
|
2755
|
+
if (result.seeded > 0) {
|
|
2756
|
+
this.logger.info({ seeded: result.seeded }, 'Auto-seed pass completed');
|
|
2757
|
+
}
|
|
2758
|
+
}
|
|
2759
|
+
catch (err) {
|
|
2760
|
+
this.logger.warn({ err }, 'Auto-seed pass failed');
|
|
2761
|
+
}
|
|
2762
|
+
}
|
|
2534
2763
|
const candidate = await this.discoverStalest();
|
|
2535
2764
|
if (!candidate) {
|
|
2536
2765
|
this.backoffMultiplier = Math.min(this.backoffMultiplier * 2, MAX_BACKOFF_MULTIPLIER);
|
|
@@ -9786,53 +10015,6 @@ const metaDetailQuerySchema = z.object({
|
|
|
9786
10015
|
])
|
|
9787
10016
|
.optional(),
|
|
9788
10017
|
});
|
|
9789
|
-
/** Compute summary stats from a filtered set of MetaEntries. */
|
|
9790
|
-
function computeFilteredSummary(entries) {
|
|
9791
|
-
let staleCount = 0;
|
|
9792
|
-
let errorCount = 0;
|
|
9793
|
-
let neverSynthCount = 0;
|
|
9794
|
-
let stalestPath = null;
|
|
9795
|
-
let stalestSeconds = -1;
|
|
9796
|
-
let lastSynthesizedPath = null;
|
|
9797
|
-
let lastSynthesizedAt = null;
|
|
9798
|
-
let totalArchitectTokens = 0;
|
|
9799
|
-
let totalBuilderTokens = 0;
|
|
9800
|
-
let totalCriticTokens = 0;
|
|
9801
|
-
for (const e of entries) {
|
|
9802
|
-
if (e.stalenessSeconds > 0)
|
|
9803
|
-
staleCount++;
|
|
9804
|
-
if (e.hasError)
|
|
9805
|
-
errorCount++;
|
|
9806
|
-
if (e.stalenessSeconds === Infinity)
|
|
9807
|
-
neverSynthCount++;
|
|
9808
|
-
if (e.stalenessSeconds > stalestSeconds) {
|
|
9809
|
-
stalestSeconds = e.stalenessSeconds;
|
|
9810
|
-
stalestPath = e.path;
|
|
9811
|
-
}
|
|
9812
|
-
if (e.lastSynthesized &&
|
|
9813
|
-
(!lastSynthesizedAt || e.lastSynthesized > lastSynthesizedAt)) {
|
|
9814
|
-
lastSynthesizedAt = e.lastSynthesized;
|
|
9815
|
-
lastSynthesizedPath = e.path;
|
|
9816
|
-
}
|
|
9817
|
-
totalArchitectTokens += e.architectTokens ?? 0;
|
|
9818
|
-
totalBuilderTokens += e.builderTokens ?? 0;
|
|
9819
|
-
totalCriticTokens += e.criticTokens ?? 0;
|
|
9820
|
-
}
|
|
9821
|
-
return {
|
|
9822
|
-
total: entries.length,
|
|
9823
|
-
stale: staleCount,
|
|
9824
|
-
errors: errorCount,
|
|
9825
|
-
neverSynthesized: neverSynthCount,
|
|
9826
|
-
stalestPath,
|
|
9827
|
-
lastSynthesizedPath,
|
|
9828
|
-
lastSynthesizedAt,
|
|
9829
|
-
tokens: {
|
|
9830
|
-
architect: totalArchitectTokens,
|
|
9831
|
-
builder: totalBuilderTokens,
|
|
9832
|
-
critic: totalCriticTokens,
|
|
9833
|
-
},
|
|
9834
|
-
};
|
|
9835
|
-
}
|
|
9836
10018
|
function registerMetasRoutes(app, deps) {
|
|
9837
10019
|
app.get('/metas', async (request) => {
|
|
9838
10020
|
const query = metasQuerySchema.parse(request.query);
|
|
@@ -9847,7 +10029,7 @@ function registerMetasRoutes(app, deps) {
|
|
|
9847
10029
|
entries = entries.filter((e) => e.hasError === query.hasError);
|
|
9848
10030
|
}
|
|
9849
10031
|
if (query.neverSynthesized !== undefined) {
|
|
9850
|
-
entries = entries.filter((e) => (e.
|
|
10032
|
+
entries = entries.filter((e) => (e.lastSynthesized === null) === query.neverSynthesized);
|
|
9851
10033
|
}
|
|
9852
10034
|
if (query.locked !== undefined) {
|
|
9853
10035
|
entries = entries.filter((e) => e.locked === query.locked);
|
|
@@ -9856,7 +10038,7 @@ function registerMetasRoutes(app, deps) {
|
|
|
9856
10038
|
entries = entries.filter((e) => e.stalenessSeconds >= query.staleHours * 3600);
|
|
9857
10039
|
}
|
|
9858
10040
|
// Summary (computed from filtered entries)
|
|
9859
|
-
const summary =
|
|
10041
|
+
const summary = computeSummary(entries, config.depthWeight);
|
|
9860
10042
|
// Field projection
|
|
9861
10043
|
const fieldList = query.fields?.split(',');
|
|
9862
10044
|
const defaultFields = [
|
|
@@ -9908,7 +10090,7 @@ function registerMetasRoutes(app, deps) {
|
|
|
9908
10090
|
message: 'Meta path not found: ' + targetPath,
|
|
9909
10091
|
});
|
|
9910
10092
|
}
|
|
9911
|
-
const meta = JSON.parse(
|
|
10093
|
+
const meta = JSON.parse(await readFile(join(targetNode.metaPath, 'meta.json'), 'utf8'));
|
|
9912
10094
|
// Field projection
|
|
9913
10095
|
const defaultExclude = new Set([
|
|
9914
10096
|
'_architect',
|
|
@@ -9956,13 +10138,11 @@ function registerMetasRoutes(app, deps) {
|
|
|
9956
10138
|
// Cross-refs status
|
|
9957
10139
|
const crossRefsRaw = meta._crossRefs;
|
|
9958
10140
|
if (Array.isArray(crossRefsRaw) && crossRefsRaw.length > 0) {
|
|
9959
|
-
response.crossRefs = crossRefsRaw.map((refPath) => {
|
|
10141
|
+
response.crossRefs = await Promise.all(crossRefsRaw.map(async (refPath) => {
|
|
9960
10142
|
const rp = String(refPath);
|
|
9961
10143
|
const refMetaFile = join(rp, '.meta', 'meta.json');
|
|
9962
|
-
if (!existsSync(refMetaFile))
|
|
9963
|
-
return { path: rp, status: 'missing' };
|
|
9964
10144
|
try {
|
|
9965
|
-
const refMeta = JSON.parse(
|
|
10145
|
+
const refMeta = JSON.parse(await readFile(refMetaFile, 'utf8'));
|
|
9966
10146
|
return {
|
|
9967
10147
|
path: rp,
|
|
9968
10148
|
status: 'resolved',
|
|
@@ -9972,7 +10152,7 @@ function registerMetasRoutes(app, deps) {
|
|
|
9972
10152
|
catch {
|
|
9973
10153
|
return { path: rp, status: 'missing' };
|
|
9974
10154
|
}
|
|
9975
|
-
});
|
|
10155
|
+
}));
|
|
9976
10156
|
}
|
|
9977
10157
|
// Archive
|
|
9978
10158
|
if (query.includeArchive) {
|
|
@@ -9981,10 +10161,10 @@ function registerMetasRoutes(app, deps) {
|
|
|
9981
10161
|
? query.includeArchive
|
|
9982
10162
|
: archiveFiles.length;
|
|
9983
10163
|
const selected = archiveFiles.slice(-limit).reverse();
|
|
9984
|
-
response.archive = selected.map((af) => {
|
|
9985
|
-
const raw =
|
|
10164
|
+
response.archive = await Promise.all(selected.map(async (af) => {
|
|
10165
|
+
const raw = await readFile(af, 'utf8');
|
|
9986
10166
|
return projectMeta(JSON.parse(raw));
|
|
9987
|
-
});
|
|
10167
|
+
}));
|
|
9988
10168
|
}
|
|
9989
10169
|
return response;
|
|
9990
10170
|
});
|
|
@@ -10035,12 +10215,12 @@ function registerPreviewRoute(app, deps) {
|
|
|
10035
10215
|
}
|
|
10036
10216
|
targetNode = findNode(result.tree, stalestPath);
|
|
10037
10217
|
}
|
|
10038
|
-
const meta = readMetaJson(targetNode.metaPath);
|
|
10218
|
+
const meta = await readMetaJson(targetNode.metaPath);
|
|
10039
10219
|
// Scope files
|
|
10040
10220
|
const { scopeFiles } = await getScopeFiles(targetNode, watcher);
|
|
10041
10221
|
const structureHash = computeStructureHash(scopeFiles);
|
|
10042
10222
|
const structureChanged = structureHash !== meta._structureHash;
|
|
10043
|
-
const latestArchive = readLatestArchive(targetNode.metaPath);
|
|
10223
|
+
const latestArchive = await readLatestArchive(targetNode.metaPath);
|
|
10044
10224
|
const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
|
|
10045
10225
|
const architectTriggered = isArchitectTriggered(meta, structureChanged, steerChanged, config.architectEvery);
|
|
10046
10226
|
// Delta files
|
|
@@ -10092,30 +10272,27 @@ function registerPreviewRoute(app, deps) {
|
|
|
10092
10272
|
const seedBodySchema = z.object({
|
|
10093
10273
|
path: z.string().min(1),
|
|
10094
10274
|
crossRefs: z.array(z.string()).optional(),
|
|
10275
|
+
steer: z.string().optional(),
|
|
10095
10276
|
});
|
|
10096
10277
|
function registerSeedRoute(app, deps) {
|
|
10097
|
-
app.post('/seed', (request, reply) => {
|
|
10278
|
+
app.post('/seed', async (request, reply) => {
|
|
10098
10279
|
const body = seedBodySchema.parse(request.body);
|
|
10099
|
-
|
|
10100
|
-
if (existsSync(metaDir)) {
|
|
10280
|
+
if (metaExists(body.path)) {
|
|
10101
10281
|
return reply.status(409).send({
|
|
10102
10282
|
error: 'CONFLICT',
|
|
10103
10283
|
message: `.meta directory already exists at ${body.path}`,
|
|
10104
10284
|
});
|
|
10105
10285
|
}
|
|
10106
|
-
deps.logger.info({
|
|
10107
|
-
|
|
10108
|
-
|
|
10109
|
-
|
|
10110
|
-
|
|
10111
|
-
const metaJsonPath = join(metaDir, 'meta.json');
|
|
10112
|
-
deps.logger.info({ metaJsonPath }, 'writing meta.json');
|
|
10113
|
-
writeFileSync(metaJsonPath, JSON.stringify(metaJson, null, 2) + '\n');
|
|
10286
|
+
deps.logger.info({ path: body.path }, 'seeding .meta directory');
|
|
10287
|
+
const result = await createMeta(body.path, {
|
|
10288
|
+
crossRefs: body.crossRefs,
|
|
10289
|
+
steer: body.steer,
|
|
10290
|
+
});
|
|
10114
10291
|
return reply.status(201).send({
|
|
10115
10292
|
status: 'created',
|
|
10116
10293
|
path: body.path,
|
|
10117
|
-
metaDir,
|
|
10118
|
-
_id:
|
|
10294
|
+
metaDir: result.metaDir,
|
|
10295
|
+
_id: result._id,
|
|
10119
10296
|
});
|
|
10120
10297
|
});
|
|
10121
10298
|
}
|
|
@@ -10564,6 +10741,44 @@ class RuleRegistrar {
|
|
|
10564
10741
|
}
|
|
10565
10742
|
}
|
|
10566
10743
|
|
|
10744
|
+
/**
|
|
10745
|
+
* Post-registration verification of virtual rule application.
|
|
10746
|
+
*
|
|
10747
|
+
* After rules are registered with the watcher, verifies that .meta/meta.json
|
|
10748
|
+
* files are discoverable via watcher walk (which depends on virtual rules
|
|
10749
|
+
* being applied). Logs a warning if expected metas are not found.
|
|
10750
|
+
*
|
|
10751
|
+
* @module rules/verify
|
|
10752
|
+
*/
|
|
10753
|
+
/**
|
|
10754
|
+
* Verify that virtual rules are applied to indexed .meta/meta.json files.
|
|
10755
|
+
*
|
|
10756
|
+
* Runs a discovery pass and logs the result. If no metas are found but
|
|
10757
|
+
* the filesystem likely has some, logs a warning suggesting reindex.
|
|
10758
|
+
*
|
|
10759
|
+
* @param watcher - WatcherClient for discovery.
|
|
10760
|
+
* @param logger - Logger for reporting results.
|
|
10761
|
+
* @returns Number of metas discovered.
|
|
10762
|
+
*/
|
|
10763
|
+
async function verifyRuleApplication(watcher, logger) {
|
|
10764
|
+
try {
|
|
10765
|
+
const metaPaths = await discoverMetas(watcher);
|
|
10766
|
+
if (metaPaths.length === 0) {
|
|
10767
|
+
logger.warn({ count: 0 }, 'Post-registration verification: no .meta/meta.json files found via watcher walk. ' +
|
|
10768
|
+
'Virtual rules may not be applied to indexed files. ' +
|
|
10769
|
+
'If metas exist, a path-scoped reindex may be needed.');
|
|
10770
|
+
}
|
|
10771
|
+
else {
|
|
10772
|
+
logger.info({ count: metaPaths.length }, 'Post-registration verification: metas discoverable');
|
|
10773
|
+
}
|
|
10774
|
+
return metaPaths.length;
|
|
10775
|
+
}
|
|
10776
|
+
catch (err) {
|
|
10777
|
+
logger.warn({ err: err instanceof Error ? err.message : String(err) }, 'Post-registration verification failed (watcher may be unavailable)');
|
|
10778
|
+
return 0;
|
|
10779
|
+
}
|
|
10780
|
+
}
|
|
10781
|
+
|
|
10567
10782
|
/**
|
|
10568
10783
|
* Minimal Fastify HTTP server for jeeves-meta service.
|
|
10569
10784
|
*
|
|
@@ -10859,7 +11074,7 @@ async function startService(config, configPath) {
|
|
|
10859
11074
|
cycleTokens += evt.tokens;
|
|
10860
11075
|
}
|
|
10861
11076
|
await progress.report(evt);
|
|
10862
|
-
});
|
|
11077
|
+
}, logger);
|
|
10863
11078
|
// orchestrate() always returns exactly one result
|
|
10864
11079
|
const result = results[0];
|
|
10865
11080
|
const durationMs = Date.now() - startMs;
|
|
@@ -10911,11 +11126,15 @@ async function startService(config, configPath) {
|
|
|
10911
11126
|
}
|
|
10912
11127
|
// Start scheduler
|
|
10913
11128
|
scheduler.start();
|
|
10914
|
-
// Rule registration (fire-and-forget with retries)
|
|
11129
|
+
// Rule registration (fire-and-forget with retries) + post-registration verification
|
|
10915
11130
|
const registrar = new RuleRegistrar(config, logger, watcher);
|
|
10916
11131
|
scheduler.setRegistrar(registrar);
|
|
10917
11132
|
routeDeps.registrar = registrar;
|
|
10918
|
-
void registrar.register()
|
|
11133
|
+
void registrar.register().then(() => {
|
|
11134
|
+
if (registrar.isRegistered) {
|
|
11135
|
+
void verifyRuleApplication(watcher, logger);
|
|
11136
|
+
}
|
|
11137
|
+
});
|
|
10919
11138
|
// Periodic watcher health check (independent of scheduler)
|
|
10920
11139
|
const healthCheck = new WatcherHealthCheck({
|
|
10921
11140
|
watcherUrl: config.watcherUrl,
|
|
@@ -10924,26 +11143,52 @@ async function startService(config, configPath) {
|
|
|
10924
11143
|
logger,
|
|
10925
11144
|
});
|
|
10926
11145
|
healthCheck.start();
|
|
10927
|
-
// Config hot-reload (gap #12)
|
|
11146
|
+
// Config hot-reload (gap #12, expanded #32)
|
|
11147
|
+
// Fields requiring a service restart to take effect
|
|
11148
|
+
const restartRequiredFields = [
|
|
11149
|
+
'port',
|
|
11150
|
+
'host',
|
|
11151
|
+
'watcherUrl',
|
|
11152
|
+
'gatewayUrl',
|
|
11153
|
+
'gatewayApiKey',
|
|
11154
|
+
'defaultArchitect',
|
|
11155
|
+
'defaultCritic',
|
|
11156
|
+
];
|
|
10928
11157
|
if (configPath) {
|
|
10929
11158
|
watchFile(configPath, { interval: 5000 }, () => {
|
|
10930
11159
|
try {
|
|
10931
11160
|
const newConfig = loadServiceConfig(configPath);
|
|
10932
|
-
//
|
|
11161
|
+
// Warn about restart-required field changes
|
|
11162
|
+
for (const field of restartRequiredFields) {
|
|
11163
|
+
const oldVal = config[field];
|
|
11164
|
+
const newVal = newConfig[field];
|
|
11165
|
+
if (oldVal !== newVal) {
|
|
11166
|
+
logger.warn({ field, oldValue: oldVal, newValue: newVal }, 'Config field changed but requires restart to take effect');
|
|
11167
|
+
}
|
|
11168
|
+
}
|
|
11169
|
+
// Hot-reload schedule
|
|
10933
11170
|
if (newConfig.schedule !== config.schedule) {
|
|
10934
11171
|
scheduler.updateSchedule(newConfig.schedule);
|
|
10935
11172
|
logger.info({ schedule: newConfig.schedule }, 'Schedule hot-reloaded');
|
|
10936
11173
|
}
|
|
10937
|
-
|
|
10938
|
-
// Mutate shared config reference for progress reporter
|
|
10939
|
-
config.reportChannel =
|
|
10940
|
-
newConfig.reportChannel;
|
|
10941
|
-
logger.info({ reportChannel: newConfig.reportChannel }, 'reportChannel hot-reloaded');
|
|
10942
|
-
}
|
|
11174
|
+
// Hot-reload logging level
|
|
10943
11175
|
if (newConfig.logging.level !== config.logging.level) {
|
|
10944
11176
|
logger.level = newConfig.logging.level;
|
|
10945
11177
|
logger.info({ level: newConfig.logging.level }, 'Log level hot-reloaded');
|
|
10946
11178
|
}
|
|
11179
|
+
// Merge all non-restart-required fields into shared config ref.
|
|
11180
|
+
// newConfig is Zod-parsed, so removed fields get defaults — no deletion needed.
|
|
11181
|
+
const restartSet = new Set(restartRequiredFields);
|
|
11182
|
+
for (const key of Object.keys(newConfig)) {
|
|
11183
|
+
if (restartSet.has(key) || key === 'logging')
|
|
11184
|
+
continue;
|
|
11185
|
+
const oldVal = config[key];
|
|
11186
|
+
const newVal = newConfig[key];
|
|
11187
|
+
if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
|
|
11188
|
+
config[key] = newVal;
|
|
11189
|
+
logger.info({ field: key }, 'Config field hot-reloaded');
|
|
11190
|
+
}
|
|
11191
|
+
}
|
|
10947
11192
|
}
|
|
10948
11193
|
catch (err) {
|
|
10949
11194
|
logger.warn({ err }, 'Config hot-reload failed');
|