@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.
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
- import fs, { readdirSync, unlinkSync, readFileSync, mkdirSync, writeFileSync, existsSync, statSync, copyFileSync, watchFile } from 'node:fs';
2
- import path, { join, dirname, resolve, relative } from 'node:path';
1
+ import fs, { readdirSync, readFileSync, writeFileSync, unlinkSync, existsSync, statSync, mkdirSync, watchFile } from 'node:fs';
2
+ import path, { join, dirname, resolve, relative, posix } from 'node:path';
3
+ import { unlink, readFile, mkdir, writeFile, copyFile } from 'node:fs/promises';
3
4
  import { fileURLToPath } from 'node:url';
4
- import 'node:fs/promises';
5
5
  import process$1 from 'node:process';
6
6
  import { z } from 'zod';
7
7
  import { createHash, randomUUID } from 'node:crypto';
@@ -56,14 +56,12 @@ function listArchiveFiles(metaPath) {
56
56
  * @param maxArchive - Maximum snapshots to retain.
57
57
  * @returns Number of files pruned.
58
58
  */
59
- function pruneArchive(metaPath, maxArchive) {
59
+ async function pruneArchive(metaPath, maxArchive) {
60
60
  const files = listArchiveFiles(metaPath);
61
61
  const toRemove = files.length - maxArchive;
62
62
  if (toRemove <= 0)
63
63
  return 0;
64
- for (let i = 0; i < toRemove; i++) {
65
- unlinkSync(files[i]);
66
- }
64
+ await Promise.all(files.slice(0, toRemove).map(unlink));
67
65
  return toRemove;
68
66
  }
69
67
 
@@ -78,11 +76,11 @@ function pruneArchive(metaPath, maxArchive) {
78
76
  * @param metaPath - Absolute path to the .meta directory.
79
77
  * @returns The latest archived meta, or null if no archives exist.
80
78
  */
81
- function readLatestArchive(metaPath) {
79
+ async function readLatestArchive(metaPath) {
82
80
  const files = listArchiveFiles(metaPath);
83
81
  if (files.length === 0)
84
82
  return null;
85
- const raw = readFileSync(files[files.length - 1], 'utf8');
83
+ const raw = await readFile(files[files.length - 1], 'utf8');
86
84
  return JSON.parse(raw);
87
85
  }
88
86
 
@@ -101,9 +99,9 @@ function readLatestArchive(metaPath) {
101
99
  * @param meta - Current meta.json content.
102
100
  * @returns The archive file path.
103
101
  */
104
- function createSnapshot(metaPath, meta) {
102
+ async function createSnapshot(metaPath, meta) {
105
103
  const archiveDir = join(metaPath, 'archive');
106
- mkdirSync(archiveDir, { recursive: true });
104
+ await mkdir(archiveDir, { recursive: true });
107
105
  const now = new Date().toISOString().replace(/[:.]/g, '-');
108
106
  const archiveFile = join(archiveDir, now + '.json');
109
107
  const archived = {
@@ -111,7 +109,7 @@ function createSnapshot(metaPath, meta) {
111
109
  _archived: true,
112
110
  _archivedAt: new Date().toISOString(),
113
111
  };
114
- writeFileSync(archiveFile, JSON.stringify(archived, null, 2) + '\n');
112
+ await writeFile(archiveFile, JSON.stringify(archived, null, 2) + '\n');
115
113
  return archiveFile;
116
114
  }
117
115
 
@@ -282,6 +280,15 @@ const loggingSchema = z.object({
282
280
  /** Optional file path for log output. */
283
281
  file: z.string().optional(),
284
282
  });
283
+ /** Zod schema for a single auto-seed policy rule. */
284
+ const autoSeedRuleSchema = z.object({
285
+ /** Glob pattern matched against watcher walk results. */
286
+ match: z.string(),
287
+ /** Optional steering prompt for seeded metas. */
288
+ steer: z.string().optional(),
289
+ /** Optional cross-references for seeded metas. */
290
+ crossRefs: z.array(z.string()).optional(),
291
+ });
285
292
  /** Zod schema for jeeves-meta service configuration (superset of MetaConfig). */
286
293
  const serviceConfigSchema = metaConfigSchema.extend({
287
294
  /** HTTP port for the service (default: 1938). */
@@ -298,6 +305,11 @@ const serviceConfigSchema = metaConfigSchema.extend({
298
305
  watcherHealthIntervalMs: z.number().int().min(0).default(60_000),
299
306
  /** Logging configuration. */
300
307
  logging: loggingSchema.default(() => loggingSchema.parse({})),
308
+ /**
309
+ * Auto-seed policy: declarative rules for auto-creating .meta/ directories.
310
+ * Rules are evaluated in order; last match wins for steer/crossRefs.
311
+ */
312
+ autoSeed: z.array(autoSeedRuleSchema).optional().default([]),
301
313
  });
302
314
 
303
315
  /**
@@ -389,6 +401,75 @@ function loadServiceConfig(configPath) {
389
401
  return serviceConfigSchema.parse(raw);
390
402
  }
391
403
 
404
+ /**
405
+ * Compute summary statistics from an array of MetaEntry objects.
406
+ *
407
+ * Shared between listMetas() (full list) and route handlers (filtered lists).
408
+ *
409
+ * @module discovery/computeSummary
410
+ */
411
+ /**
412
+ * Compute summary statistics from a list of meta entries.
413
+ *
414
+ * @param entries - Enriched meta entries (full or filtered).
415
+ * @param depthWeight - Config depth weight for effective staleness calculation.
416
+ * @returns Aggregated summary statistics.
417
+ */
418
+ function computeSummary(entries, depthWeight) {
419
+ let staleCount = 0;
420
+ let errorCount = 0;
421
+ let lockedCount = 0;
422
+ let neverSynthesizedCount = 0;
423
+ let totalArchitectTokens = 0;
424
+ let totalBuilderTokens = 0;
425
+ let totalCriticTokens = 0;
426
+ let stalestPath = null;
427
+ let stalestEffective = -1;
428
+ let lastSynthesizedPath = null;
429
+ let lastSynthesizedAt = null;
430
+ for (const e of entries) {
431
+ if (e.stalenessSeconds > 0)
432
+ staleCount++;
433
+ if (e.hasError)
434
+ errorCount++;
435
+ if (e.locked)
436
+ lockedCount++;
437
+ if (e.lastSynthesized === null)
438
+ neverSynthesizedCount++;
439
+ totalArchitectTokens += e.architectTokens ?? 0;
440
+ totalBuilderTokens += e.builderTokens ?? 0;
441
+ totalCriticTokens += e.criticTokens ?? 0;
442
+ // Track last synthesized
443
+ if (e.lastSynthesized &&
444
+ (!lastSynthesizedAt || e.lastSynthesized > lastSynthesizedAt)) {
445
+ lastSynthesizedAt = e.lastSynthesized;
446
+ lastSynthesizedPath = e.path;
447
+ }
448
+ // Track stalest (effective staleness for scheduling)
449
+ const depthFactor = Math.pow(1 + depthWeight, e.depth);
450
+ const effectiveStaleness = e.stalenessSeconds * depthFactor * e.emphasis;
451
+ if (effectiveStaleness > stalestEffective) {
452
+ stalestEffective = effectiveStaleness;
453
+ stalestPath = e.path;
454
+ }
455
+ }
456
+ return {
457
+ total: entries.length,
458
+ stale: staleCount,
459
+ errors: errorCount,
460
+ locked: lockedCount,
461
+ neverSynthesized: neverSynthesizedCount,
462
+ tokens: {
463
+ architect: totalArchitectTokens,
464
+ builder: totalBuilderTokens,
465
+ critic: totalCriticTokens,
466
+ },
467
+ stalestPath,
468
+ lastSynthesizedPath,
469
+ lastSynthesizedAt,
470
+ };
471
+ }
472
+
392
473
  /**
393
474
  * Normalize file paths to forward slashes for consistency with watcher-indexed paths.
394
475
  *
@@ -581,14 +662,15 @@ function cleanupStaleLocks(metaPaths, logger) {
581
662
  * @module readMetaJson
582
663
  */
583
664
  /**
584
- * Read and parse a meta.json file from a `.meta/` directory path.
665
+ * Read and parse a meta.json file from a `.meta/` directory path (async).
585
666
  *
586
667
  * @param metaPath - Path to the `.meta/` directory.
587
668
  * @returns Parsed meta.json content.
588
669
  * @throws If the file doesn't exist or contains invalid JSON.
589
670
  */
590
- function readMetaJson(metaPath) {
591
- return JSON.parse(readFileSync(join(metaPath, 'meta.json'), 'utf8'));
671
+ async function readMetaJson(metaPath) {
672
+ const raw = await readFile(join(metaPath, 'meta.json'), 'utf8');
673
+ return JSON.parse(raw);
592
674
  }
593
675
 
594
676
  /**
@@ -693,21 +775,10 @@ async function listMetas(config, watcher) {
693
775
  const tree = buildOwnershipTree(metaPaths);
694
776
  // Step 3: Read and enrich each meta from disk
695
777
  const entries = [];
696
- let staleCount = 0;
697
- let errorCount = 0;
698
- let lockedCount = 0;
699
- let neverSynthesizedCount = 0;
700
- let totalArchTokens = 0;
701
- let totalBuilderTokens = 0;
702
- let totalCriticTokens = 0;
703
- let lastSynthPath = null;
704
- let lastSynthAt = null;
705
- let stalestPath = null;
706
- let stalestEffective = -1;
707
778
  for (const node of tree.nodes.values()) {
708
779
  let meta;
709
780
  try {
710
- meta = readMetaJson(node.metaPath);
781
+ meta = await readMetaJson(node.metaPath);
711
782
  }
712
783
  catch {
713
784
  // Skip unreadable metas
@@ -731,32 +802,6 @@ async function listMetas(config, watcher) {
731
802
  const archTokens = meta._architectTokens ?? 0;
732
803
  const buildTokens = meta._builderTokens ?? 0;
733
804
  const critTokens = meta._criticTokens ?? 0;
734
- // Accumulate summary stats
735
- if (stalenessSeconds > 0)
736
- staleCount++;
737
- if (hasError)
738
- errorCount++;
739
- if (locked)
740
- lockedCount++;
741
- if (neverSynth)
742
- neverSynthesizedCount++;
743
- totalArchTokens += archTokens;
744
- totalBuilderTokens += buildTokens;
745
- totalCriticTokens += critTokens;
746
- // Track last synthesized
747
- if (meta._generatedAt) {
748
- if (!lastSynthAt || meta._generatedAt > lastSynthAt) {
749
- lastSynthAt = meta._generatedAt;
750
- lastSynthPath = node.metaPath;
751
- }
752
- }
753
- // Track stalest (effective staleness for scheduling)
754
- const depthFactor = Math.pow(1 + config.depthWeight, depth);
755
- const effectiveStaleness = stalenessSeconds * depthFactor * emphasis;
756
- if (effectiveStaleness > stalestEffective) {
757
- stalestEffective = effectiveStaleness;
758
- stalestPath = node.metaPath;
759
- }
760
805
  entries.push({
761
806
  path: node.metaPath,
762
807
  depth,
@@ -774,21 +819,7 @@ async function listMetas(config, watcher) {
774
819
  });
775
820
  }
776
821
  return {
777
- summary: {
778
- total: entries.length,
779
- stale: staleCount,
780
- errors: errorCount,
781
- locked: lockedCount,
782
- neverSynthesized: neverSynthesizedCount,
783
- tokens: {
784
- architect: totalArchTokens,
785
- builder: totalBuilderTokens,
786
- critic: totalCriticTokens,
787
- },
788
- stalestPath,
789
- lastSynthesizedPath: lastSynthPath,
790
- lastSynthesizedAt: lastSynthAt,
791
- },
822
+ summary: computeSummary(entries, config.depthWeight),
792
823
  entries,
793
824
  tree,
794
825
  };
@@ -892,12 +923,17 @@ function filterInScope(node, files) {
892
923
  /**
893
924
  * Get all files in scope for a meta node via watcher walk.
894
925
  */
895
- async function getScopeFiles(node, watcher) {
926
+ async function getScopeFiles(node, watcher, logger) {
927
+ const walkStart = Date.now();
896
928
  const allFiles = await watcher.walk([`${node.ownerPath}/**`]);
897
- return {
898
- scopeFiles: filterInScope(node, allFiles),
899
- allFiles,
900
- };
929
+ const scopeFiles = filterInScope(node, allFiles);
930
+ logger?.debug({
931
+ ownerPath: node.ownerPath,
932
+ allFiles: allFiles.length,
933
+ scopeFiles: scopeFiles.length,
934
+ durationMs: Date.now() - walkStart,
935
+ }, 'scope files enumerated');
936
+ return { scopeFiles, allFiles };
901
937
  }
902
938
  /**
903
939
  * Get files modified since a given timestamp within a meta node's scope.
@@ -1228,9 +1264,9 @@ function condenseScopeFiles(files, maxIndividual = 30) {
1228
1264
  * @param metaJsonPath - Absolute path to a meta.json file.
1229
1265
  * @returns The `_content` string, or null if missing/unreadable.
1230
1266
  */
1231
- function readMetaContent(metaJsonPath) {
1267
+ async function readMetaContent(metaJsonPath) {
1232
1268
  try {
1233
- const raw = readFileSync(metaJsonPath, 'utf8');
1269
+ const raw = await readFile(metaJsonPath, 'utf8');
1234
1270
  const meta = JSON.parse(raw);
1235
1271
  return meta._content ?? null;
1236
1272
  }
@@ -1246,25 +1282,43 @@ function readMetaContent(metaJsonPath) {
1246
1282
  * @param watcher - WatcherClient for scope enumeration.
1247
1283
  * @returns The computed context package.
1248
1284
  */
1249
- async function buildContextPackage(node, meta, watcher) {
1285
+ async function buildContextPackage(node, meta, watcher, logger) {
1250
1286
  // Scope and delta files via watcher walk
1251
- const { scopeFiles } = await getScopeFiles(node, watcher);
1287
+ const scopeStart = Date.now();
1288
+ const { scopeFiles } = await getScopeFiles(node, watcher, logger);
1252
1289
  const deltaFiles = getDeltaFiles(meta._generatedAt, scopeFiles);
1253
- // Child meta outputs
1290
+ logger?.debug({
1291
+ scopeFiles: scopeFiles.length,
1292
+ deltaFiles: deltaFiles.length,
1293
+ durationMs: Date.now() - scopeStart,
1294
+ }, 'scope and delta files computed');
1295
+ // Child meta outputs (parallel reads)
1254
1296
  const childMetas = {};
1255
- for (const child of node.children) {
1256
- childMetas[child.ownerPath] = readMetaContent(join(child.metaPath, 'meta.json'));
1297
+ const childEntries = await Promise.all(node.children.map(async (child) => {
1298
+ const content = await readMetaContent(join(child.metaPath, 'meta.json'));
1299
+ return [child.ownerPath, content];
1300
+ }));
1301
+ for (const [path, content] of childEntries) {
1302
+ childMetas[path] = content;
1257
1303
  }
1258
- // Cross-referenced meta outputs
1304
+ // Cross-referenced meta outputs (parallel reads)
1259
1305
  const crossRefMetas = {};
1260
1306
  const seen = new Set();
1307
+ const crossRefPaths = [];
1261
1308
  for (const refPath of meta._crossRefs ?? []) {
1262
1309
  if (refPath === node.ownerPath || refPath === node.metaPath)
1263
1310
  continue;
1264
1311
  if (seen.has(refPath))
1265
1312
  continue;
1266
1313
  seen.add(refPath);
1267
- crossRefMetas[refPath] = readMetaContent(join(refPath, '.meta', 'meta.json'));
1314
+ crossRefPaths.push(refPath);
1315
+ }
1316
+ const crossRefEntries = await Promise.all(crossRefPaths.map(async (refPath) => {
1317
+ const content = await readMetaContent(join(refPath, '.meta', 'meta.json'));
1318
+ return [refPath, content];
1319
+ }));
1320
+ for (const [path, content] of crossRefEntries) {
1321
+ crossRefMetas[path] = content;
1268
1322
  }
1269
1323
  // Archive paths
1270
1324
  const archives = listArchiveFiles(node.metaPath);
@@ -1436,8 +1490,8 @@ const metaErrorSchema = z.object({
1436
1490
  /** Zod schema for the reserved (underscore-prefixed) meta.json properties. */
1437
1491
  const metaJsonSchema = z
1438
1492
  .object({
1439
- /** Stable identity. Generated on first synthesis, never changes. */
1440
- _id: z.uuid(),
1493
+ /** Stable identity. Auto-generated on first synthesis if not provided. */
1494
+ _id: z.uuid().optional(),
1441
1495
  /** Human-provided steering prompt. Optional. */
1442
1496
  _steer: z.string().optional(),
1443
1497
  /**
@@ -1530,10 +1584,10 @@ const metaJsonSchema = z
1530
1584
  * @returns The updated MetaJson.
1531
1585
  * @throws If validation fails (malformed output).
1532
1586
  */
1533
- function mergeAndWrite(options) {
1587
+ async function mergeAndWrite(options) {
1534
1588
  const merged = {
1535
- // Preserve human-set fields
1536
- _id: options.current._id,
1589
+ // Preserve human-set fields (auto-generate _id on first synthesis)
1590
+ _id: options.current._id ?? randomUUID(),
1537
1591
  _steer: options.current._steer,
1538
1592
  _depth: options.current._depth,
1539
1593
  _emphasis: options.current._emphasis,
@@ -1606,7 +1660,7 @@ function mergeAndWrite(options) {
1606
1660
  }
1607
1661
  // Write to specified path (lock staging) or default meta.json
1608
1662
  const filePath = options.outputPath ?? join(options.metaPath, 'meta.json');
1609
- writeFileSync(filePath, JSON.stringify(result.data, null, 2) + '\n');
1663
+ await writeFile(filePath, JSON.stringify(result.data, null, 2) + '\n');
1610
1664
  return result.data;
1611
1665
  }
1612
1666
 
@@ -1846,11 +1900,11 @@ function computeStalenessScore(stalenessSeconds, depth, emphasis, depthWeight) {
1846
1900
  * @module orchestrator/finalizeCycle
1847
1901
  */
1848
1902
  /** Finalize a cycle using lock staging: write to .lock → copy to meta.json + archive → delete .lock. */
1849
- function finalizeCycle(opts) {
1903
+ async function finalizeCycle(opts) {
1850
1904
  const lockPath = join(opts.metaPath, '.lock');
1851
1905
  const metaJsonPath = join(opts.metaPath, 'meta.json');
1852
- // Stage: write merged result to .lock
1853
- const updated = mergeAndWrite({
1906
+ // Stage: write merged result to .lock (sequential — ordering matters)
1907
+ const updated = await mergeAndWrite({
1854
1908
  metaPath: opts.metaPath,
1855
1909
  current: opts.current,
1856
1910
  architect: opts.architect,
@@ -1869,10 +1923,10 @@ function finalizeCycle(opts) {
1869
1923
  stateOnly: opts.stateOnly,
1870
1924
  });
1871
1925
  // Commit: copy .lock → meta.json
1872
- copyFileSync(lockPath, metaJsonPath);
1873
- // Archive + prune from the committed meta.json
1874
- createSnapshot(opts.metaPath, updated);
1875
- pruneArchive(opts.metaPath, opts.config.maxArchive);
1926
+ await copyFile(lockPath, metaJsonPath);
1927
+ // Archive + prune from the committed meta.json (sequential)
1928
+ await createSnapshot(opts.metaPath, updated);
1929
+ await pruneArchive(opts.metaPath, opts.config.maxArchive);
1876
1930
  // .lock is cleaned up by the finally block (releaseLock)
1877
1931
  return updated;
1878
1932
  }
@@ -1985,14 +2039,12 @@ function parseCriticOutput(output) {
1985
2039
  * Returns an {@link OrchestrateResult} if state was salvaged, or `null`
1986
2040
  * if the caller should fall through to a hard failure.
1987
2041
  */
1988
- function attemptTimeoutRecovery(opts) {
2042
+ async function attemptTimeoutRecovery(opts) {
1989
2043
  const { err, currentMeta, metaPath, config, builderBrief, structureHash, synthesisCount, } = opts;
1990
2044
  let partialOutput = null;
1991
2045
  try {
1992
- if (existsSync(err.outputPath)) {
1993
- const raw = readFileSync(err.outputPath, 'utf8');
1994
- partialOutput = parseBuilderOutput(raw);
1995
- }
2046
+ const raw = await readFile(err.outputPath, 'utf8');
2047
+ partialOutput = parseBuilderOutput(raw);
1996
2048
  }
1997
2049
  catch {
1998
2050
  // Could not read partial output — fall through to hard failure
@@ -2006,7 +2058,7 @@ function attemptTimeoutRecovery(opts) {
2006
2058
  code: 'TIMEOUT',
2007
2059
  message: err.message,
2008
2060
  };
2009
- finalizeCycle({
2061
+ await finalizeCycle({
2010
2062
  metaPath,
2011
2063
  current: currentMeta,
2012
2064
  config,
@@ -2037,12 +2089,12 @@ function attemptTimeoutRecovery(opts) {
2037
2089
  * @module orchestrator/synthesizeNode
2038
2090
  */
2039
2091
  /** Run the architect/builder/critic pipeline on a single node. */
2040
- async function synthesizeNode(node, currentMeta, config, executor, watcher, onProgress) {
2092
+ async function synthesizeNode(node, currentMeta, config, executor, watcher, onProgress, logger) {
2041
2093
  // Step 5-6: Steer change detection
2042
- const latestArchive = readLatestArchive(node.metaPath);
2094
+ const latestArchive = await readLatestArchive(node.metaPath);
2043
2095
  const steerChanged = hasSteerChanged(currentMeta._steer, latestArchive?._steer, Boolean(latestArchive));
2044
2096
  // Step 7: Compute context (includes scope files and delta files)
2045
- const ctx = await buildContextPackage(node, currentMeta, watcher);
2097
+ const ctx = await buildContextPackage(node, currentMeta, watcher, logger);
2046
2098
  // Step 5 (deferred): Structure hash from context scope files
2047
2099
  const newStructureHash = computeStructureHash(ctx.scopeFiles);
2048
2100
  const structureChanged = newStructureHash !== currentMeta._structureHash;
@@ -2054,6 +2106,16 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
2054
2106
  let architectTokens;
2055
2107
  let builderTokens;
2056
2108
  let criticTokens;
2109
+ // Shared base options for all finalizeCycle calls.
2110
+ // Note: synthesisCount is excluded because it mutates during the pipeline.
2111
+ const baseFinalizeOptions = {
2112
+ metaPath: node.metaPath,
2113
+ current: currentMeta,
2114
+ config,
2115
+ architect: currentMeta._architect ?? '',
2116
+ critic: currentMeta._critic ?? '',
2117
+ structureHash: newStructureHash,
2118
+ };
2057
2119
  if (architectTriggered) {
2058
2120
  try {
2059
2121
  await onProgress?.({
@@ -2082,16 +2144,11 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
2082
2144
  stepError = toMetaError('architect', err);
2083
2145
  if (!currentMeta._builder) {
2084
2146
  // No cached builder — cycle fails
2085
- finalizeCycle({
2086
- metaPath: node.metaPath,
2087
- current: currentMeta,
2088
- config,
2089
- architect: currentMeta._architect ?? '',
2147
+ await finalizeCycle({
2148
+ ...baseFinalizeOptions,
2090
2149
  builder: '',
2091
- critic: currentMeta._critic ?? '',
2092
2150
  builderOutput: null,
2093
2151
  feedback: null,
2094
- structureHash: newStructureHash,
2095
2152
  synthesisCount,
2096
2153
  error: stepError,
2097
2154
  architectTokens,
@@ -2133,7 +2190,7 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
2133
2190
  }
2134
2191
  catch (err) {
2135
2192
  if (err instanceof SpawnTimeoutError) {
2136
- const recovered = attemptTimeoutRecovery({
2193
+ const recovered = await attemptTimeoutRecovery({
2137
2194
  err,
2138
2195
  currentMeta,
2139
2196
  metaPath: node.metaPath,
@@ -2146,16 +2203,11 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
2146
2203
  return recovered;
2147
2204
  }
2148
2205
  stepError = toMetaError('builder', err);
2149
- finalizeCycle({
2150
- metaPath: node.metaPath,
2151
- current: currentMeta,
2152
- config,
2153
- architect: currentMeta._architect ?? '',
2206
+ await finalizeCycle({
2207
+ ...baseFinalizeOptions,
2154
2208
  builder: builderBrief,
2155
- critic: currentMeta._critic ?? '',
2156
2209
  builderOutput: null,
2157
2210
  feedback: null,
2158
- structureHash: newStructureHash,
2159
2211
  synthesisCount,
2160
2212
  error: stepError,
2161
2213
  });
@@ -2194,16 +2246,11 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
2194
2246
  stepError = stepError ?? toMetaError('critic', err);
2195
2247
  }
2196
2248
  // Steps 11-12: Merge, archive, prune
2197
- finalizeCycle({
2198
- metaPath: node.metaPath,
2199
- current: currentMeta,
2200
- config,
2201
- architect: currentMeta._architect ?? '',
2249
+ await finalizeCycle({
2250
+ ...baseFinalizeOptions,
2202
2251
  builder: builderBrief,
2203
- critic: currentMeta._critic ?? '',
2204
2252
  builderOutput,
2205
2253
  feedback,
2206
- structureHash: newStructureHash,
2207
2254
  synthesisCount,
2208
2255
  error: stepError,
2209
2256
  architectTokens,
@@ -2235,8 +2282,8 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
2235
2282
  if (!acquireLock(node.metaPath))
2236
2283
  return { synthesized: false };
2237
2284
  try {
2238
- const currentMeta = readMetaJson(normalizedTarget);
2239
- return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress);
2285
+ const currentMeta = await readMetaJson(normalizedTarget);
2286
+ return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress, logger);
2240
2287
  }
2241
2288
  finally {
2242
2289
  releaseLock(node.metaPath);
@@ -2253,7 +2300,7 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
2253
2300
  const metas = new Map();
2254
2301
  for (const mp of metaPaths) {
2255
2302
  try {
2256
- metas.set(normalizePath(mp), readMetaJson(mp));
2303
+ metas.set(normalizePath(mp), await readMetaJson(mp));
2257
2304
  }
2258
2305
  catch {
2259
2306
  // Skip metas with unreadable meta.json
@@ -2289,9 +2336,9 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
2289
2336
  const verifiedStale = await isStale(getScopePrefix(candidate.node), candidate.meta, watcher);
2290
2337
  if (!verifiedStale && candidate.meta._generatedAt) {
2291
2338
  // Bump _generatedAt so it doesn't win next cycle
2292
- const freshMeta = readMetaJson(candidate.node.metaPath);
2339
+ const freshMeta = await readMetaJson(candidate.node.metaPath);
2293
2340
  freshMeta._generatedAt = new Date().toISOString();
2294
- writeFileSync(join(candidate.node.metaPath, 'meta.json'), JSON.stringify(freshMeta, null, 2));
2341
+ await writeFile(join(candidate.node.metaPath, 'meta.json'), JSON.stringify(freshMeta, null, 2));
2295
2342
  releaseLock(candidate.node.metaPath);
2296
2343
  if (config.skipUnchanged)
2297
2344
  continue;
@@ -2304,8 +2351,8 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
2304
2351
  return { synthesized: false };
2305
2352
  const node = winner.node;
2306
2353
  try {
2307
- const currentMeta = readMetaJson(node.metaPath);
2308
- return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress);
2354
+ const currentMeta = await readMetaJson(node.metaPath);
2355
+ return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress, logger);
2309
2356
  }
2310
2357
  finally {
2311
2358
  // Step 13: Release lock
@@ -2344,14 +2391,15 @@ function formatSeconds(durationMs) {
2344
2391
  function titleCasePhase(phase) {
2345
2392
  return phase.charAt(0).toUpperCase() + phase.slice(1);
2346
2393
  }
2347
- /** Build a link for the entity path, if serverBaseUrl is available. */
2394
+ /** Build a link to the entity's meta.json output file. */
2348
2395
  function buildEntityLink(path, serverBaseUrl) {
2396
+ // Normalize backslashes, then convert drive letter to URL path segment
2397
+ const normalized = normalizePath(path).replace(/^([A-Za-z]):/, '/$1');
2398
+ const metaJsonPath = `${normalized}/.meta/meta.json`;
2349
2399
  if (!serverBaseUrl)
2350
- return path;
2400
+ return metaJsonPath;
2351
2401
  const base = serverBaseUrl.replace(/\/+$/, '');
2352
- // Convert Windows-style path to /drive/rest format: D:\foo → /D/foo
2353
- const normalized = path.replace(/^([A-Za-z]):/, '/$1').replace(/\\/g, '/');
2354
- return `${base}/path${normalized}`;
2402
+ return `${base}/path${metaJsonPath}`;
2355
2403
  }
2356
2404
  function formatProgressEvent(event, serverBaseUrl) {
2357
2405
  const pathDisplay = buildEntityLink(event.path, serverBaseUrl);
@@ -2430,6 +2478,123 @@ class ProgressReporter {
2430
2478
  }
2431
2479
  }
2432
2480
 
2481
+ /**
2482
+ * Core seed logic — create a .meta/ directory with initial meta.json.
2483
+ *
2484
+ * Shared between the POST /seed route handler and the auto-seed pass.
2485
+ *
2486
+ * @module seed/createMeta
2487
+ */
2488
+ /**
2489
+ * Create a .meta/ directory with an initial meta.json.
2490
+ *
2491
+ * Does NOT check for existing .meta/ — caller is responsible for that guard.
2492
+ *
2493
+ * @param ownerPath - The owner directory path.
2494
+ * @param options - Optional cross-refs and steering prompt.
2495
+ * @returns The meta directory path and generated ID.
2496
+ */
2497
+ async function createMeta(ownerPath, options) {
2498
+ const metaDir = resolveMetaDir(ownerPath);
2499
+ await mkdir(metaDir, { recursive: true });
2500
+ const _id = randomUUID();
2501
+ const metaJson = { _id };
2502
+ if (options?.crossRefs !== undefined)
2503
+ metaJson._crossRefs = options.crossRefs;
2504
+ if (options?.steer !== undefined)
2505
+ metaJson._steer = options.steer;
2506
+ const metaJsonPath = join(metaDir, 'meta.json');
2507
+ await writeFile(metaJsonPath, JSON.stringify(metaJson, null, 2) + '\n');
2508
+ return { metaDir, _id };
2509
+ }
2510
+ /**
2511
+ * Check if a .meta/ directory already exists for an owner path.
2512
+ *
2513
+ * @param ownerPath - The owner directory path.
2514
+ * @returns True if .meta/ already exists.
2515
+ */
2516
+ function metaExists(ownerPath) {
2517
+ return existsSync(resolveMetaDir(ownerPath));
2518
+ }
2519
+
2520
+ /**
2521
+ * Auto-seed pass — scan for directories matching policy rules and seed them.
2522
+ *
2523
+ * Runs before discovery in each scheduler tick. For each auto-seed rule,
2524
+ * walks matching directories via the watcher and creates .meta/ directories
2525
+ * for those that don't already have one.
2526
+ *
2527
+ * Rules are processed in array order; last match wins for steer/crossRefs.
2528
+ *
2529
+ * @module seed/autoSeed
2530
+ */
2531
+ /**
2532
+ * Extract parent directory paths from watcher walk results.
2533
+ *
2534
+ * Walk returns file paths; we need the unique set of immediate parent
2535
+ * directories that could be owners.
2536
+ */
2537
+ function extractDirectories(filePaths) {
2538
+ const dirs = new Set();
2539
+ for (const fp of filePaths) {
2540
+ const dir = posix.dirname(fp);
2541
+ if (dir !== '.' && dir !== '/') {
2542
+ dirs.add(dir);
2543
+ }
2544
+ }
2545
+ return [...dirs];
2546
+ }
2547
+ /**
2548
+ * Run the auto-seed pass: apply policy rules and create missing metas.
2549
+ *
2550
+ * @param rules - Auto-seed policy rules from config.
2551
+ * @param watcher - Watcher client for filesystem enumeration.
2552
+ * @param logger - Logger for reporting seed actions.
2553
+ * @returns Summary of what was seeded.
2554
+ */
2555
+ async function autoSeedPass(rules, watcher, logger) {
2556
+ if (rules.length === 0)
2557
+ return { seeded: 0, paths: [] };
2558
+ // Build a map of ownerPath → effective options (last match wins)
2559
+ const candidates = new Map();
2560
+ for (const rule of rules) {
2561
+ const files = await watcher.walk([rule.match]);
2562
+ const dirs = extractDirectories(files);
2563
+ for (const dir of dirs) {
2564
+ candidates.set(dir, {
2565
+ steer: rule.steer,
2566
+ crossRefs: rule.crossRefs,
2567
+ });
2568
+ }
2569
+ }
2570
+ // Filter out paths that already have .meta/meta.json
2571
+ const toSeed = [];
2572
+ for (const [path, opts] of candidates) {
2573
+ if (!metaExists(path)) {
2574
+ toSeed.push({ path, ...opts });
2575
+ }
2576
+ }
2577
+ // Seed remaining
2578
+ const seededPaths = [];
2579
+ for (const candidate of toSeed) {
2580
+ try {
2581
+ await createMeta(candidate.path, {
2582
+ steer: candidate.steer,
2583
+ crossRefs: candidate.crossRefs,
2584
+ });
2585
+ seededPaths.push(candidate.path);
2586
+ logger?.info({ path: candidate.path }, 'auto-seeded meta');
2587
+ }
2588
+ catch (err) {
2589
+ logger?.warn({
2590
+ path: candidate.path,
2591
+ err: err instanceof Error ? err.message : String(err),
2592
+ }, 'auto-seed failed for path');
2593
+ }
2594
+ }
2595
+ return { seeded: seededPaths.length, paths: seededPaths };
2596
+ }
2597
+
2433
2598
  /**
2434
2599
  * Croner-based scheduler that discovers the stalest meta candidate each tick
2435
2600
  * and enqueues it for synthesis.
@@ -2527,6 +2692,18 @@ class Scheduler {
2527
2692
  }, 'Skipping tick (backoff)');
2528
2693
  return;
2529
2694
  }
2695
+ // Auto-seed pass: create .meta/ for matching directories
2696
+ if (this.config.autoSeed.length > 0) {
2697
+ try {
2698
+ const result = await autoSeedPass(this.config.autoSeed, this.watcher, this.logger);
2699
+ if (result.seeded > 0) {
2700
+ this.logger.info({ seeded: result.seeded }, 'Auto-seed pass completed');
2701
+ }
2702
+ }
2703
+ catch (err) {
2704
+ this.logger.warn({ err }, 'Auto-seed pass failed');
2705
+ }
2706
+ }
2530
2707
  const candidate = await this.discoverStalest();
2531
2708
  if (!candidate) {
2532
2709
  this.backoffMultiplier = Math.min(this.backoffMultiplier * 2, MAX_BACKOFF_MULTIPLIER);
@@ -9782,53 +9959,6 @@ const metaDetailQuerySchema = z.object({
9782
9959
  ])
9783
9960
  .optional(),
9784
9961
  });
9785
- /** Compute summary stats from a filtered set of MetaEntries. */
9786
- function computeFilteredSummary(entries) {
9787
- let staleCount = 0;
9788
- let errorCount = 0;
9789
- let neverSynthCount = 0;
9790
- let stalestPath = null;
9791
- let stalestSeconds = -1;
9792
- let lastSynthesizedPath = null;
9793
- let lastSynthesizedAt = null;
9794
- let totalArchitectTokens = 0;
9795
- let totalBuilderTokens = 0;
9796
- let totalCriticTokens = 0;
9797
- for (const e of entries) {
9798
- if (e.stalenessSeconds > 0)
9799
- staleCount++;
9800
- if (e.hasError)
9801
- errorCount++;
9802
- if (e.stalenessSeconds === Infinity)
9803
- neverSynthCount++;
9804
- if (e.stalenessSeconds > stalestSeconds) {
9805
- stalestSeconds = e.stalenessSeconds;
9806
- stalestPath = e.path;
9807
- }
9808
- if (e.lastSynthesized &&
9809
- (!lastSynthesizedAt || e.lastSynthesized > lastSynthesizedAt)) {
9810
- lastSynthesizedAt = e.lastSynthesized;
9811
- lastSynthesizedPath = e.path;
9812
- }
9813
- totalArchitectTokens += e.architectTokens ?? 0;
9814
- totalBuilderTokens += e.builderTokens ?? 0;
9815
- totalCriticTokens += e.criticTokens ?? 0;
9816
- }
9817
- return {
9818
- total: entries.length,
9819
- stale: staleCount,
9820
- errors: errorCount,
9821
- neverSynthesized: neverSynthCount,
9822
- stalestPath,
9823
- lastSynthesizedPath,
9824
- lastSynthesizedAt,
9825
- tokens: {
9826
- architect: totalArchitectTokens,
9827
- builder: totalBuilderTokens,
9828
- critic: totalCriticTokens,
9829
- },
9830
- };
9831
- }
9832
9962
  function registerMetasRoutes(app, deps) {
9833
9963
  app.get('/metas', async (request) => {
9834
9964
  const query = metasQuerySchema.parse(request.query);
@@ -9843,7 +9973,7 @@ function registerMetasRoutes(app, deps) {
9843
9973
  entries = entries.filter((e) => e.hasError === query.hasError);
9844
9974
  }
9845
9975
  if (query.neverSynthesized !== undefined) {
9846
- entries = entries.filter((e) => (e.stalenessSeconds === Infinity) === query.neverSynthesized);
9976
+ entries = entries.filter((e) => (e.lastSynthesized === null) === query.neverSynthesized);
9847
9977
  }
9848
9978
  if (query.locked !== undefined) {
9849
9979
  entries = entries.filter((e) => e.locked === query.locked);
@@ -9852,7 +9982,7 @@ function registerMetasRoutes(app, deps) {
9852
9982
  entries = entries.filter((e) => e.stalenessSeconds >= query.staleHours * 3600);
9853
9983
  }
9854
9984
  // Summary (computed from filtered entries)
9855
- const summary = computeFilteredSummary(entries);
9985
+ const summary = computeSummary(entries, config.depthWeight);
9856
9986
  // Field projection
9857
9987
  const fieldList = query.fields?.split(',');
9858
9988
  const defaultFields = [
@@ -9904,7 +10034,7 @@ function registerMetasRoutes(app, deps) {
9904
10034
  message: 'Meta path not found: ' + targetPath,
9905
10035
  });
9906
10036
  }
9907
- const meta = JSON.parse(readFileSync(join(targetNode.metaPath, 'meta.json'), 'utf8'));
10037
+ const meta = JSON.parse(await readFile(join(targetNode.metaPath, 'meta.json'), 'utf8'));
9908
10038
  // Field projection
9909
10039
  const defaultExclude = new Set([
9910
10040
  '_architect',
@@ -9952,13 +10082,11 @@ function registerMetasRoutes(app, deps) {
9952
10082
  // Cross-refs status
9953
10083
  const crossRefsRaw = meta._crossRefs;
9954
10084
  if (Array.isArray(crossRefsRaw) && crossRefsRaw.length > 0) {
9955
- response.crossRefs = crossRefsRaw.map((refPath) => {
10085
+ response.crossRefs = await Promise.all(crossRefsRaw.map(async (refPath) => {
9956
10086
  const rp = String(refPath);
9957
10087
  const refMetaFile = join(rp, '.meta', 'meta.json');
9958
- if (!existsSync(refMetaFile))
9959
- return { path: rp, status: 'missing' };
9960
10088
  try {
9961
- const refMeta = JSON.parse(readFileSync(refMetaFile, 'utf8'));
10089
+ const refMeta = JSON.parse(await readFile(refMetaFile, 'utf8'));
9962
10090
  return {
9963
10091
  path: rp,
9964
10092
  status: 'resolved',
@@ -9968,7 +10096,7 @@ function registerMetasRoutes(app, deps) {
9968
10096
  catch {
9969
10097
  return { path: rp, status: 'missing' };
9970
10098
  }
9971
- });
10099
+ }));
9972
10100
  }
9973
10101
  // Archive
9974
10102
  if (query.includeArchive) {
@@ -9977,10 +10105,10 @@ function registerMetasRoutes(app, deps) {
9977
10105
  ? query.includeArchive
9978
10106
  : archiveFiles.length;
9979
10107
  const selected = archiveFiles.slice(-limit).reverse();
9980
- response.archive = selected.map((af) => {
9981
- const raw = readFileSync(af, 'utf8');
10108
+ response.archive = await Promise.all(selected.map(async (af) => {
10109
+ const raw = await readFile(af, 'utf8');
9982
10110
  return projectMeta(JSON.parse(raw));
9983
- });
10111
+ }));
9984
10112
  }
9985
10113
  return response;
9986
10114
  });
@@ -10031,12 +10159,12 @@ function registerPreviewRoute(app, deps) {
10031
10159
  }
10032
10160
  targetNode = findNode(result.tree, stalestPath);
10033
10161
  }
10034
- const meta = readMetaJson(targetNode.metaPath);
10162
+ const meta = await readMetaJson(targetNode.metaPath);
10035
10163
  // Scope files
10036
10164
  const { scopeFiles } = await getScopeFiles(targetNode, watcher);
10037
10165
  const structureHash = computeStructureHash(scopeFiles);
10038
10166
  const structureChanged = structureHash !== meta._structureHash;
10039
- const latestArchive = readLatestArchive(targetNode.metaPath);
10167
+ const latestArchive = await readLatestArchive(targetNode.metaPath);
10040
10168
  const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
10041
10169
  const architectTriggered = isArchitectTriggered(meta, structureChanged, steerChanged, config.architectEvery);
10042
10170
  // Delta files
@@ -10088,30 +10216,27 @@ function registerPreviewRoute(app, deps) {
10088
10216
  const seedBodySchema = z.object({
10089
10217
  path: z.string().min(1),
10090
10218
  crossRefs: z.array(z.string()).optional(),
10219
+ steer: z.string().optional(),
10091
10220
  });
10092
10221
  function registerSeedRoute(app, deps) {
10093
- app.post('/seed', (request, reply) => {
10222
+ app.post('/seed', async (request, reply) => {
10094
10223
  const body = seedBodySchema.parse(request.body);
10095
- const metaDir = resolveMetaDir(body.path);
10096
- if (existsSync(metaDir)) {
10224
+ if (metaExists(body.path)) {
10097
10225
  return reply.status(409).send({
10098
10226
  error: 'CONFLICT',
10099
10227
  message: `.meta directory already exists at ${body.path}`,
10100
10228
  });
10101
10229
  }
10102
- deps.logger.info({ metaDir }, 'creating .meta directory');
10103
- mkdirSync(metaDir, { recursive: true });
10104
- const metaJson = { _id: randomUUID() };
10105
- if (body.crossRefs !== undefined)
10106
- metaJson._crossRefs = body.crossRefs;
10107
- const metaJsonPath = join(metaDir, 'meta.json');
10108
- deps.logger.info({ metaJsonPath }, 'writing meta.json');
10109
- writeFileSync(metaJsonPath, JSON.stringify(metaJson, null, 2) + '\n');
10230
+ deps.logger.info({ path: body.path }, 'seeding .meta directory');
10231
+ const result = await createMeta(body.path, {
10232
+ crossRefs: body.crossRefs,
10233
+ steer: body.steer,
10234
+ });
10110
10235
  return reply.status(201).send({
10111
10236
  status: 'created',
10112
10237
  path: body.path,
10113
- metaDir,
10114
- _id: metaJson._id,
10238
+ metaDir: result.metaDir,
10239
+ _id: result._id,
10115
10240
  });
10116
10241
  });
10117
10242
  }
@@ -10560,6 +10685,44 @@ class RuleRegistrar {
10560
10685
  }
10561
10686
  }
10562
10687
 
10688
+ /**
10689
+ * Post-registration verification of virtual rule application.
10690
+ *
10691
+ * After rules are registered with the watcher, verifies that .meta/meta.json
10692
+ * files are discoverable via watcher walk (which depends on virtual rules
10693
+ * being applied). Logs a warning if expected metas are not found.
10694
+ *
10695
+ * @module rules/verify
10696
+ */
10697
+ /**
10698
+ * Verify that virtual rules are applied to indexed .meta/meta.json files.
10699
+ *
10700
+ * Runs a discovery pass and logs the result. If no metas are found but
10701
+ * the filesystem likely has some, logs a warning suggesting reindex.
10702
+ *
10703
+ * @param watcher - WatcherClient for discovery.
10704
+ * @param logger - Logger for reporting results.
10705
+ * @returns Number of metas discovered.
10706
+ */
10707
+ async function verifyRuleApplication(watcher, logger) {
10708
+ try {
10709
+ const metaPaths = await discoverMetas(watcher);
10710
+ if (metaPaths.length === 0) {
10711
+ logger.warn({ count: 0 }, 'Post-registration verification: no .meta/meta.json files found via watcher walk. ' +
10712
+ 'Virtual rules may not be applied to indexed files. ' +
10713
+ 'If metas exist, a path-scoped reindex may be needed.');
10714
+ }
10715
+ else {
10716
+ logger.info({ count: metaPaths.length }, 'Post-registration verification: metas discoverable');
10717
+ }
10718
+ return metaPaths.length;
10719
+ }
10720
+ catch (err) {
10721
+ logger.warn({ err: err instanceof Error ? err.message : String(err) }, 'Post-registration verification failed (watcher may be unavailable)');
10722
+ return 0;
10723
+ }
10724
+ }
10725
+
10563
10726
  /**
10564
10727
  * Minimal Fastify HTTP server for jeeves-meta service.
10565
10728
  *
@@ -10855,7 +11018,7 @@ async function startService(config, configPath) {
10855
11018
  cycleTokens += evt.tokens;
10856
11019
  }
10857
11020
  await progress.report(evt);
10858
- });
11021
+ }, logger);
10859
11022
  // orchestrate() always returns exactly one result
10860
11023
  const result = results[0];
10861
11024
  const durationMs = Date.now() - startMs;
@@ -10907,11 +11070,15 @@ async function startService(config, configPath) {
10907
11070
  }
10908
11071
  // Start scheduler
10909
11072
  scheduler.start();
10910
- // Rule registration (fire-and-forget with retries)
11073
+ // Rule registration (fire-and-forget with retries) + post-registration verification
10911
11074
  const registrar = new RuleRegistrar(config, logger, watcher);
10912
11075
  scheduler.setRegistrar(registrar);
10913
11076
  routeDeps.registrar = registrar;
10914
- void registrar.register();
11077
+ void registrar.register().then(() => {
11078
+ if (registrar.isRegistered) {
11079
+ void verifyRuleApplication(watcher, logger);
11080
+ }
11081
+ });
10915
11082
  // Periodic watcher health check (independent of scheduler)
10916
11083
  const healthCheck = new WatcherHealthCheck({
10917
11084
  watcherUrl: config.watcherUrl,
@@ -10920,26 +11087,52 @@ async function startService(config, configPath) {
10920
11087
  logger,
10921
11088
  });
10922
11089
  healthCheck.start();
10923
- // Config hot-reload (gap #12)
11090
+ // Config hot-reload (gap #12, expanded #32)
11091
+ // Fields requiring a service restart to take effect
11092
+ const restartRequiredFields = [
11093
+ 'port',
11094
+ 'host',
11095
+ 'watcherUrl',
11096
+ 'gatewayUrl',
11097
+ 'gatewayApiKey',
11098
+ 'defaultArchitect',
11099
+ 'defaultCritic',
11100
+ ];
10924
11101
  if (configPath) {
10925
11102
  watchFile(configPath, { interval: 5000 }, () => {
10926
11103
  try {
10927
11104
  const newConfig = loadServiceConfig(configPath);
10928
- // Hot-reloadable fields: schedule, reportChannel, logging level
11105
+ // Warn about restart-required field changes
11106
+ for (const field of restartRequiredFields) {
11107
+ const oldVal = config[field];
11108
+ const newVal = newConfig[field];
11109
+ if (oldVal !== newVal) {
11110
+ logger.warn({ field, oldValue: oldVal, newValue: newVal }, 'Config field changed but requires restart to take effect');
11111
+ }
11112
+ }
11113
+ // Hot-reload schedule
10929
11114
  if (newConfig.schedule !== config.schedule) {
10930
11115
  scheduler.updateSchedule(newConfig.schedule);
10931
11116
  logger.info({ schedule: newConfig.schedule }, 'Schedule hot-reloaded');
10932
11117
  }
10933
- if (newConfig.reportChannel !== config.reportChannel) {
10934
- // Mutate shared config reference for progress reporter
10935
- config.reportChannel =
10936
- newConfig.reportChannel;
10937
- logger.info({ reportChannel: newConfig.reportChannel }, 'reportChannel hot-reloaded');
10938
- }
11118
+ // Hot-reload logging level
10939
11119
  if (newConfig.logging.level !== config.logging.level) {
10940
11120
  logger.level = newConfig.logging.level;
10941
11121
  logger.info({ level: newConfig.logging.level }, 'Log level hot-reloaded');
10942
11122
  }
11123
+ // Merge all non-restart-required fields into shared config ref.
11124
+ // newConfig is Zod-parsed, so removed fields get defaults — no deletion needed.
11125
+ const restartSet = new Set(restartRequiredFields);
11126
+ for (const key of Object.keys(newConfig)) {
11127
+ if (restartSet.has(key) || key === 'logging')
11128
+ continue;
11129
+ const oldVal = config[key];
11130
+ const newVal = newConfig[key];
11131
+ if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
11132
+ config[key] = newVal;
11133
+ logger.info({ field: key }, 'Config field hot-reloaded');
11134
+ }
11135
+ }
10943
11136
  }
10944
11137
  catch (err) {
10945
11138
  logger.warn({ err }, 'Config hot-reload failed');
@@ -10960,4 +11153,4 @@ async function startService(config, configPath) {
10960
11153
  logger.info('Service fully initialized');
10961
11154
  }
10962
11155
 
10963
- export { DEFAULT_PORT, DEFAULT_PORT_STR, GatewayExecutor, HttpWatcherClient, ProgressReporter, RuleRegistrar, SERVICE_NAME, SERVICE_VERSION, Scheduler, SynthesisQueue, acquireLock, actualStaleness, buildArchitectTask, buildBuilderTask, buildContextPackage, buildCriticTask, buildOwnershipTree, cleanupStaleLocks, computeEffectiveStaleness, computeEma, computeStructureHash, createLogger, createServer, createSnapshot, discoverMetas, filterInScope, findNode, formatProgressEvent, getScopePrefix, hasSteerChanged, isArchitectTriggered, isLocked, isStale, listArchiveFiles, listMetas, loadServiceConfig, mergeAndWrite, metaConfigSchema, metaErrorSchema, metaJsonSchema, normalizePath, orchestrate, parseArchitectOutput, parseBuilderOutput, parseCriticOutput, pruneArchive, readLatestArchive, readLockState, registerRoutes, registerShutdownHandlers, releaseLock, resolveConfigPath, resolveMetaDir, selectCandidate, serviceConfigSchema, sleep, startService, toMetaError };
11156
+ export { DEFAULT_PORT, DEFAULT_PORT_STR, GatewayExecutor, HttpWatcherClient, ProgressReporter, RuleRegistrar, SERVICE_NAME, SERVICE_VERSION, Scheduler, SynthesisQueue, acquireLock, actualStaleness, buildArchitectTask, buildBuilderTask, buildContextPackage, buildCriticTask, buildOwnershipTree, cleanupStaleLocks, computeEffectiveStaleness, computeEma, computeStructureHash, createLogger, createServer, createSnapshot, discoverMetas, filterInScope, findNode, formatProgressEvent, getScopePrefix, hasSteerChanged, isArchitectTriggered, isLocked, isStale, listArchiveFiles, listMetas, loadServiceConfig, mergeAndWrite, metaConfigSchema, metaErrorSchema, metaJsonSchema, normalizePath, orchestrate, parseArchitectOutput, parseBuilderOutput, parseCriticOutput, pruneArchive, readLatestArchive, readLockState, registerRoutes, registerShutdownHandlers, releaseLock, resolveConfigPath, resolveMetaDir, selectCandidate, serviceConfigSchema, sleep, startService, toMetaError, verifyRuleApplication };