@karmaniverous/jeeves-meta 0.9.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.
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
- import fs, { readFileSync, readdirSync, unlinkSync, mkdirSync, writeFileSync, existsSync, statSync, copyFileSync, watchFile } from 'node:fs';
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
- for (let i = 0; i < toRemove; i++) {
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 = readFileSync(files[files.length - 1], 'utf8');
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
- mkdirSync(archiveDir, { recursive: true });
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
- writeFileSync(archiveFile, JSON.stringify(archived, null, 2) + '\n');
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
- return JSON.parse(readFileSync(join(metaPath, 'meta.json'), 'utf8'));
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
- return {
906
- scopeFiles: filterInScope(node, allFiles),
907
- allFiles,
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.
@@ -1236,9 +1272,9 @@ function condenseScopeFiles(files, maxIndividual = 30) {
1236
1272
  * @param metaJsonPath - Absolute path to a meta.json file.
1237
1273
  * @returns The `_content` string, or null if missing/unreadable.
1238
1274
  */
1239
- function readMetaContent(metaJsonPath) {
1275
+ async function readMetaContent(metaJsonPath) {
1240
1276
  try {
1241
- const raw = readFileSync(metaJsonPath, 'utf8');
1277
+ const raw = await readFile(metaJsonPath, 'utf8');
1242
1278
  const meta = JSON.parse(raw);
1243
1279
  return meta._content ?? null;
1244
1280
  }
@@ -1254,25 +1290,43 @@ function readMetaContent(metaJsonPath) {
1254
1290
  * @param watcher - WatcherClient for scope enumeration.
1255
1291
  * @returns The computed context package.
1256
1292
  */
1257
- async function buildContextPackage(node, meta, watcher) {
1293
+ async function buildContextPackage(node, meta, watcher, logger) {
1258
1294
  // Scope and delta files via watcher walk
1259
- const { scopeFiles } = await getScopeFiles(node, watcher);
1295
+ const scopeStart = Date.now();
1296
+ const { scopeFiles } = await getScopeFiles(node, watcher, logger);
1260
1297
  const deltaFiles = getDeltaFiles(meta._generatedAt, scopeFiles);
1261
- // Child meta outputs
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)
1262
1304
  const childMetas = {};
1263
- for (const child of node.children) {
1264
- childMetas[child.ownerPath] = readMetaContent(join(child.metaPath, 'meta.json'));
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;
1265
1311
  }
1266
- // Cross-referenced meta outputs
1312
+ // Cross-referenced meta outputs (parallel reads)
1267
1313
  const crossRefMetas = {};
1268
1314
  const seen = new Set();
1315
+ const crossRefPaths = [];
1269
1316
  for (const refPath of meta._crossRefs ?? []) {
1270
1317
  if (refPath === node.ownerPath || refPath === node.metaPath)
1271
1318
  continue;
1272
1319
  if (seen.has(refPath))
1273
1320
  continue;
1274
1321
  seen.add(refPath);
1275
- crossRefMetas[refPath] = readMetaContent(join(refPath, '.meta', 'meta.json'));
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;
1276
1330
  }
1277
1331
  // Archive paths
1278
1332
  const archives = listArchiveFiles(node.metaPath);
@@ -1444,8 +1498,8 @@ const metaErrorSchema = z.object({
1444
1498
  /** Zod schema for the reserved (underscore-prefixed) meta.json properties. */
1445
1499
  const metaJsonSchema = z
1446
1500
  .object({
1447
- /** Stable identity. Generated on first synthesis, never changes. */
1448
- _id: z.uuid(),
1501
+ /** Stable identity. Auto-generated on first synthesis if not provided. */
1502
+ _id: z.uuid().optional(),
1449
1503
  /** Human-provided steering prompt. Optional. */
1450
1504
  _steer: z.string().optional(),
1451
1505
  /**
@@ -1538,10 +1592,10 @@ const metaJsonSchema = z
1538
1592
  * @returns The updated MetaJson.
1539
1593
  * @throws If validation fails (malformed output).
1540
1594
  */
1541
- function mergeAndWrite(options) {
1595
+ async function mergeAndWrite(options) {
1542
1596
  const merged = {
1543
- // Preserve human-set fields
1544
- _id: options.current._id,
1597
+ // Preserve human-set fields (auto-generate _id on first synthesis)
1598
+ _id: options.current._id ?? randomUUID(),
1545
1599
  _steer: options.current._steer,
1546
1600
  _depth: options.current._depth,
1547
1601
  _emphasis: options.current._emphasis,
@@ -1614,7 +1668,7 @@ function mergeAndWrite(options) {
1614
1668
  }
1615
1669
  // Write to specified path (lock staging) or default meta.json
1616
1670
  const filePath = options.outputPath ?? join(options.metaPath, 'meta.json');
1617
- writeFileSync(filePath, JSON.stringify(result.data, null, 2) + '\n');
1671
+ await writeFile(filePath, JSON.stringify(result.data, null, 2) + '\n');
1618
1672
  return result.data;
1619
1673
  }
1620
1674
 
@@ -1854,11 +1908,11 @@ function computeStalenessScore(stalenessSeconds, depth, emphasis, depthWeight) {
1854
1908
  * @module orchestrator/finalizeCycle
1855
1909
  */
1856
1910
  /** Finalize a cycle using lock staging: write to .lock → copy to meta.json + archive → delete .lock. */
1857
- function finalizeCycle(opts) {
1911
+ async function finalizeCycle(opts) {
1858
1912
  const lockPath = join(opts.metaPath, '.lock');
1859
1913
  const metaJsonPath = join(opts.metaPath, 'meta.json');
1860
- // Stage: write merged result to .lock
1861
- const updated = mergeAndWrite({
1914
+ // Stage: write merged result to .lock (sequential — ordering matters)
1915
+ const updated = await mergeAndWrite({
1862
1916
  metaPath: opts.metaPath,
1863
1917
  current: opts.current,
1864
1918
  architect: opts.architect,
@@ -1877,10 +1931,10 @@ function finalizeCycle(opts) {
1877
1931
  stateOnly: opts.stateOnly,
1878
1932
  });
1879
1933
  // Commit: copy .lock → meta.json
1880
- copyFileSync(lockPath, metaJsonPath);
1881
- // Archive + prune from the committed meta.json
1882
- createSnapshot(opts.metaPath, updated);
1883
- 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);
1884
1938
  // .lock is cleaned up by the finally block (releaseLock)
1885
1939
  return updated;
1886
1940
  }
@@ -1993,14 +2047,12 @@ function parseCriticOutput(output) {
1993
2047
  * Returns an {@link OrchestrateResult} if state was salvaged, or `null`
1994
2048
  * if the caller should fall through to a hard failure.
1995
2049
  */
1996
- function attemptTimeoutRecovery(opts) {
2050
+ async function attemptTimeoutRecovery(opts) {
1997
2051
  const { err, currentMeta, metaPath, config, builderBrief, structureHash, synthesisCount, } = opts;
1998
2052
  let partialOutput = null;
1999
2053
  try {
2000
- if (existsSync(err.outputPath)) {
2001
- const raw = readFileSync(err.outputPath, 'utf8');
2002
- partialOutput = parseBuilderOutput(raw);
2003
- }
2054
+ const raw = await readFile(err.outputPath, 'utf8');
2055
+ partialOutput = parseBuilderOutput(raw);
2004
2056
  }
2005
2057
  catch {
2006
2058
  // Could not read partial output — fall through to hard failure
@@ -2014,7 +2066,7 @@ function attemptTimeoutRecovery(opts) {
2014
2066
  code: 'TIMEOUT',
2015
2067
  message: err.message,
2016
2068
  };
2017
- finalizeCycle({
2069
+ await finalizeCycle({
2018
2070
  metaPath,
2019
2071
  current: currentMeta,
2020
2072
  config,
@@ -2045,12 +2097,12 @@ function attemptTimeoutRecovery(opts) {
2045
2097
  * @module orchestrator/synthesizeNode
2046
2098
  */
2047
2099
  /** Run the architect/builder/critic pipeline on a single node. */
2048
- async function synthesizeNode(node, currentMeta, config, executor, watcher, onProgress) {
2100
+ async function synthesizeNode(node, currentMeta, config, executor, watcher, onProgress, logger) {
2049
2101
  // Step 5-6: Steer change detection
2050
- const latestArchive = readLatestArchive(node.metaPath);
2102
+ const latestArchive = await readLatestArchive(node.metaPath);
2051
2103
  const steerChanged = hasSteerChanged(currentMeta._steer, latestArchive?._steer, Boolean(latestArchive));
2052
2104
  // Step 7: Compute context (includes scope files and delta files)
2053
- const ctx = await buildContextPackage(node, currentMeta, watcher);
2105
+ const ctx = await buildContextPackage(node, currentMeta, watcher, logger);
2054
2106
  // Step 5 (deferred): Structure hash from context scope files
2055
2107
  const newStructureHash = computeStructureHash(ctx.scopeFiles);
2056
2108
  const structureChanged = newStructureHash !== currentMeta._structureHash;
@@ -2062,6 +2114,16 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
2062
2114
  let architectTokens;
2063
2115
  let builderTokens;
2064
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
+ };
2065
2127
  if (architectTriggered) {
2066
2128
  try {
2067
2129
  await onProgress?.({
@@ -2090,16 +2152,11 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
2090
2152
  stepError = toMetaError('architect', err);
2091
2153
  if (!currentMeta._builder) {
2092
2154
  // No cached builder — cycle fails
2093
- finalizeCycle({
2094
- metaPath: node.metaPath,
2095
- current: currentMeta,
2096
- config,
2097
- architect: currentMeta._architect ?? '',
2155
+ await finalizeCycle({
2156
+ ...baseFinalizeOptions,
2098
2157
  builder: '',
2099
- critic: currentMeta._critic ?? '',
2100
2158
  builderOutput: null,
2101
2159
  feedback: null,
2102
- structureHash: newStructureHash,
2103
2160
  synthesisCount,
2104
2161
  error: stepError,
2105
2162
  architectTokens,
@@ -2141,7 +2198,7 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
2141
2198
  }
2142
2199
  catch (err) {
2143
2200
  if (err instanceof SpawnTimeoutError) {
2144
- const recovered = attemptTimeoutRecovery({
2201
+ const recovered = await attemptTimeoutRecovery({
2145
2202
  err,
2146
2203
  currentMeta,
2147
2204
  metaPath: node.metaPath,
@@ -2154,16 +2211,11 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
2154
2211
  return recovered;
2155
2212
  }
2156
2213
  stepError = toMetaError('builder', err);
2157
- finalizeCycle({
2158
- metaPath: node.metaPath,
2159
- current: currentMeta,
2160
- config,
2161
- architect: currentMeta._architect ?? '',
2214
+ await finalizeCycle({
2215
+ ...baseFinalizeOptions,
2162
2216
  builder: builderBrief,
2163
- critic: currentMeta._critic ?? '',
2164
2217
  builderOutput: null,
2165
2218
  feedback: null,
2166
- structureHash: newStructureHash,
2167
2219
  synthesisCount,
2168
2220
  error: stepError,
2169
2221
  });
@@ -2202,16 +2254,11 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
2202
2254
  stepError = stepError ?? toMetaError('critic', err);
2203
2255
  }
2204
2256
  // Steps 11-12: Merge, archive, prune
2205
- finalizeCycle({
2206
- metaPath: node.metaPath,
2207
- current: currentMeta,
2208
- config,
2209
- architect: currentMeta._architect ?? '',
2257
+ await finalizeCycle({
2258
+ ...baseFinalizeOptions,
2210
2259
  builder: builderBrief,
2211
- critic: currentMeta._critic ?? '',
2212
2260
  builderOutput,
2213
2261
  feedback,
2214
- structureHash: newStructureHash,
2215
2262
  synthesisCount,
2216
2263
  error: stepError,
2217
2264
  architectTokens,
@@ -2243,21 +2290,25 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
2243
2290
  if (!acquireLock(node.metaPath))
2244
2291
  return { synthesized: false };
2245
2292
  try {
2246
- const currentMeta = readMetaJson(normalizedTarget);
2247
- 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);
2248
2295
  }
2249
2296
  finally {
2250
2297
  releaseLock(node.metaPath);
2251
2298
  }
2252
2299
  }
2300
+ // Full discovery path (scheduler-driven, no specific target)
2301
+ // Step 1: Discover via watcher walk
2302
+ const discoveryStart = Date.now();
2253
2303
  const metaPaths = await discoverMetas(watcher);
2304
+ logger?.debug({ paths: metaPaths.length, durationMs: Date.now() - discoveryStart }, 'discovery complete');
2254
2305
  if (metaPaths.length === 0)
2255
2306
  return { synthesized: false };
2256
2307
  // Read meta.json for each discovered meta
2257
2308
  const metas = new Map();
2258
2309
  for (const mp of metaPaths) {
2259
2310
  try {
2260
- metas.set(normalizePath(mp), readMetaJson(mp));
2311
+ metas.set(normalizePath(mp), await readMetaJson(mp));
2261
2312
  }
2262
2313
  catch {
2263
2314
  // Skip metas with unreadable meta.json
@@ -2293,9 +2344,9 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
2293
2344
  const verifiedStale = await isStale(getScopePrefix(candidate.node), candidate.meta, watcher);
2294
2345
  if (!verifiedStale && candidate.meta._generatedAt) {
2295
2346
  // Bump _generatedAt so it doesn't win next cycle
2296
- const freshMeta = readMetaJson(candidate.node.metaPath);
2347
+ const freshMeta = await readMetaJson(candidate.node.metaPath);
2297
2348
  freshMeta._generatedAt = new Date().toISOString();
2298
- writeFileSync(join(candidate.node.metaPath, 'meta.json'), JSON.stringify(freshMeta, null, 2));
2349
+ await writeFile(join(candidate.node.metaPath, 'meta.json'), JSON.stringify(freshMeta, null, 2));
2299
2350
  releaseLock(candidate.node.metaPath);
2300
2351
  if (config.skipUnchanged)
2301
2352
  continue;
@@ -2308,8 +2359,8 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
2308
2359
  return { synthesized: false };
2309
2360
  const node = winner.node;
2310
2361
  try {
2311
- const currentMeta = readMetaJson(node.metaPath);
2312
- 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);
2313
2364
  }
2314
2365
  finally {
2315
2366
  // Step 13: Release lock
@@ -2329,7 +2380,7 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
2329
2380
  * @returns Array with a single result.
2330
2381
  */
2331
2382
  async function orchestrate(config, executor, watcher, targetPath, onProgress, logger) {
2332
- const result = await orchestrateOnce(config, executor, watcher, targetPath, onProgress);
2383
+ const result = await orchestrateOnce(config, executor, watcher, targetPath, onProgress, logger);
2333
2384
  return [result];
2334
2385
  }
2335
2386
 
@@ -2348,14 +2399,15 @@ function formatSeconds(durationMs) {
2348
2399
  function titleCasePhase(phase) {
2349
2400
  return phase.charAt(0).toUpperCase() + phase.slice(1);
2350
2401
  }
2351
- /** Build a link for the entity path, if serverBaseUrl is available. */
2402
+ /** Build a link to the entity's meta.json output file. */
2352
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`;
2353
2407
  if (!serverBaseUrl)
2354
- return path;
2408
+ return metaJsonPath;
2355
2409
  const base = serverBaseUrl.replace(/\/+$/, '');
2356
- // Convert Windows-style path to /drive/rest format: D:\foo → /D/foo
2357
- const normalized = path.replace(/^([A-Za-z]):/, '/$1').replace(/\\/g, '/');
2358
- return `${base}/path${normalized}`;
2410
+ return `${base}/path${metaJsonPath}`;
2359
2411
  }
2360
2412
  function formatProgressEvent(event, serverBaseUrl) {
2361
2413
  const pathDisplay = buildEntityLink(event.path, serverBaseUrl);
@@ -2434,6 +2486,123 @@ class ProgressReporter {
2434
2486
  }
2435
2487
  }
2436
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
+
2437
2606
  /**
2438
2607
  * Croner-based scheduler that discovers the stalest meta candidate each tick
2439
2608
  * and enqueues it for synthesis.
@@ -2531,6 +2700,18 @@ class Scheduler {
2531
2700
  }, 'Skipping tick (backoff)');
2532
2701
  return;
2533
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
+ }
2534
2715
  const candidate = await this.discoverStalest();
2535
2716
  if (!candidate) {
2536
2717
  this.backoffMultiplier = Math.min(this.backoffMultiplier * 2, MAX_BACKOFF_MULTIPLIER);
@@ -9786,53 +9967,6 @@ const metaDetailQuerySchema = z.object({
9786
9967
  ])
9787
9968
  .optional(),
9788
9969
  });
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
9970
  function registerMetasRoutes(app, deps) {
9837
9971
  app.get('/metas', async (request) => {
9838
9972
  const query = metasQuerySchema.parse(request.query);
@@ -9847,7 +9981,7 @@ function registerMetasRoutes(app, deps) {
9847
9981
  entries = entries.filter((e) => e.hasError === query.hasError);
9848
9982
  }
9849
9983
  if (query.neverSynthesized !== undefined) {
9850
- entries = entries.filter((e) => (e.stalenessSeconds === Infinity) === query.neverSynthesized);
9984
+ entries = entries.filter((e) => (e.lastSynthesized === null) === query.neverSynthesized);
9851
9985
  }
9852
9986
  if (query.locked !== undefined) {
9853
9987
  entries = entries.filter((e) => e.locked === query.locked);
@@ -9856,7 +9990,7 @@ function registerMetasRoutes(app, deps) {
9856
9990
  entries = entries.filter((e) => e.stalenessSeconds >= query.staleHours * 3600);
9857
9991
  }
9858
9992
  // Summary (computed from filtered entries)
9859
- const summary = computeFilteredSummary(entries);
9993
+ const summary = computeSummary(entries, config.depthWeight);
9860
9994
  // Field projection
9861
9995
  const fieldList = query.fields?.split(',');
9862
9996
  const defaultFields = [
@@ -9908,7 +10042,7 @@ function registerMetasRoutes(app, deps) {
9908
10042
  message: 'Meta path not found: ' + targetPath,
9909
10043
  });
9910
10044
  }
9911
- const meta = JSON.parse(readFileSync(join(targetNode.metaPath, 'meta.json'), 'utf8'));
10045
+ const meta = JSON.parse(await readFile(join(targetNode.metaPath, 'meta.json'), 'utf8'));
9912
10046
  // Field projection
9913
10047
  const defaultExclude = new Set([
9914
10048
  '_architect',
@@ -9956,13 +10090,11 @@ function registerMetasRoutes(app, deps) {
9956
10090
  // Cross-refs status
9957
10091
  const crossRefsRaw = meta._crossRefs;
9958
10092
  if (Array.isArray(crossRefsRaw) && crossRefsRaw.length > 0) {
9959
- response.crossRefs = crossRefsRaw.map((refPath) => {
10093
+ response.crossRefs = await Promise.all(crossRefsRaw.map(async (refPath) => {
9960
10094
  const rp = String(refPath);
9961
10095
  const refMetaFile = join(rp, '.meta', 'meta.json');
9962
- if (!existsSync(refMetaFile))
9963
- return { path: rp, status: 'missing' };
9964
10096
  try {
9965
- const refMeta = JSON.parse(readFileSync(refMetaFile, 'utf8'));
10097
+ const refMeta = JSON.parse(await readFile(refMetaFile, 'utf8'));
9966
10098
  return {
9967
10099
  path: rp,
9968
10100
  status: 'resolved',
@@ -9972,7 +10104,7 @@ function registerMetasRoutes(app, deps) {
9972
10104
  catch {
9973
10105
  return { path: rp, status: 'missing' };
9974
10106
  }
9975
- });
10107
+ }));
9976
10108
  }
9977
10109
  // Archive
9978
10110
  if (query.includeArchive) {
@@ -9981,10 +10113,10 @@ function registerMetasRoutes(app, deps) {
9981
10113
  ? query.includeArchive
9982
10114
  : archiveFiles.length;
9983
10115
  const selected = archiveFiles.slice(-limit).reverse();
9984
- response.archive = selected.map((af) => {
9985
- const raw = readFileSync(af, 'utf8');
10116
+ response.archive = await Promise.all(selected.map(async (af) => {
10117
+ const raw = await readFile(af, 'utf8');
9986
10118
  return projectMeta(JSON.parse(raw));
9987
- });
10119
+ }));
9988
10120
  }
9989
10121
  return response;
9990
10122
  });
@@ -10035,12 +10167,12 @@ function registerPreviewRoute(app, deps) {
10035
10167
  }
10036
10168
  targetNode = findNode(result.tree, stalestPath);
10037
10169
  }
10038
- const meta = readMetaJson(targetNode.metaPath);
10170
+ const meta = await readMetaJson(targetNode.metaPath);
10039
10171
  // Scope files
10040
10172
  const { scopeFiles } = await getScopeFiles(targetNode, watcher);
10041
10173
  const structureHash = computeStructureHash(scopeFiles);
10042
10174
  const structureChanged = structureHash !== meta._structureHash;
10043
- const latestArchive = readLatestArchive(targetNode.metaPath);
10175
+ const latestArchive = await readLatestArchive(targetNode.metaPath);
10044
10176
  const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
10045
10177
  const architectTriggered = isArchitectTriggered(meta, structureChanged, steerChanged, config.architectEvery);
10046
10178
  // Delta files
@@ -10092,30 +10224,27 @@ function registerPreviewRoute(app, deps) {
10092
10224
  const seedBodySchema = z.object({
10093
10225
  path: z.string().min(1),
10094
10226
  crossRefs: z.array(z.string()).optional(),
10227
+ steer: z.string().optional(),
10095
10228
  });
10096
10229
  function registerSeedRoute(app, deps) {
10097
- app.post('/seed', (request, reply) => {
10230
+ app.post('/seed', async (request, reply) => {
10098
10231
  const body = seedBodySchema.parse(request.body);
10099
- const metaDir = resolveMetaDir(body.path);
10100
- if (existsSync(metaDir)) {
10232
+ if (metaExists(body.path)) {
10101
10233
  return reply.status(409).send({
10102
10234
  error: 'CONFLICT',
10103
10235
  message: `.meta directory already exists at ${body.path}`,
10104
10236
  });
10105
10237
  }
10106
- deps.logger.info({ metaDir }, 'creating .meta directory');
10107
- mkdirSync(metaDir, { recursive: true });
10108
- const metaJson = { _id: randomUUID() };
10109
- if (body.crossRefs !== undefined)
10110
- metaJson._crossRefs = body.crossRefs;
10111
- const metaJsonPath = join(metaDir, 'meta.json');
10112
- deps.logger.info({ metaJsonPath }, 'writing meta.json');
10113
- 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
+ });
10114
10243
  return reply.status(201).send({
10115
10244
  status: 'created',
10116
10245
  path: body.path,
10117
- metaDir,
10118
- _id: metaJson._id,
10246
+ metaDir: result.metaDir,
10247
+ _id: result._id,
10119
10248
  });
10120
10249
  });
10121
10250
  }
@@ -10564,6 +10693,44 @@ class RuleRegistrar {
10564
10693
  }
10565
10694
  }
10566
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
+
10567
10734
  /**
10568
10735
  * Minimal Fastify HTTP server for jeeves-meta service.
10569
10736
  *
@@ -10859,7 +11026,7 @@ async function startService(config, configPath) {
10859
11026
  cycleTokens += evt.tokens;
10860
11027
  }
10861
11028
  await progress.report(evt);
10862
- });
11029
+ }, logger);
10863
11030
  // orchestrate() always returns exactly one result
10864
11031
  const result = results[0];
10865
11032
  const durationMs = Date.now() - startMs;
@@ -10911,11 +11078,15 @@ async function startService(config, configPath) {
10911
11078
  }
10912
11079
  // Start scheduler
10913
11080
  scheduler.start();
10914
- // Rule registration (fire-and-forget with retries)
11081
+ // Rule registration (fire-and-forget with retries) + post-registration verification
10915
11082
  const registrar = new RuleRegistrar(config, logger, watcher);
10916
11083
  scheduler.setRegistrar(registrar);
10917
11084
  routeDeps.registrar = registrar;
10918
- void registrar.register();
11085
+ void registrar.register().then(() => {
11086
+ if (registrar.isRegistered) {
11087
+ void verifyRuleApplication(watcher, logger);
11088
+ }
11089
+ });
10919
11090
  // Periodic watcher health check (independent of scheduler)
10920
11091
  const healthCheck = new WatcherHealthCheck({
10921
11092
  watcherUrl: config.watcherUrl,
@@ -10924,26 +11095,52 @@ async function startService(config, configPath) {
10924
11095
  logger,
10925
11096
  });
10926
11097
  healthCheck.start();
10927
- // 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
+ ];
10928
11109
  if (configPath) {
10929
11110
  watchFile(configPath, { interval: 5000 }, () => {
10930
11111
  try {
10931
11112
  const newConfig = loadServiceConfig(configPath);
10932
- // Hot-reloadable fields: schedule, reportChannel, logging level
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
10933
11122
  if (newConfig.schedule !== config.schedule) {
10934
11123
  scheduler.updateSchedule(newConfig.schedule);
10935
11124
  logger.info({ schedule: newConfig.schedule }, 'Schedule hot-reloaded');
10936
11125
  }
10937
- if (newConfig.reportChannel !== config.reportChannel) {
10938
- // Mutate shared config reference for progress reporter
10939
- config.reportChannel =
10940
- newConfig.reportChannel;
10941
- logger.info({ reportChannel: newConfig.reportChannel }, 'reportChannel hot-reloaded');
10942
- }
11126
+ // Hot-reload logging level
10943
11127
  if (newConfig.logging.level !== config.logging.level) {
10944
11128
  logger.level = newConfig.logging.level;
10945
11129
  logger.info({ level: newConfig.logging.level }, 'Log level hot-reloaded');
10946
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
+ }
10947
11144
  }
10948
11145
  catch (err) {
10949
11146
  logger.warn({ err }, 'Config hot-reload failed');