@karmaniverous/jeeves-meta 0.4.2 → 0.5.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,9 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
- import { readFileSync, readdirSync, unlinkSync, mkdirSync, writeFileSync, existsSync, statSync, copyFileSync, watchFile } from 'node:fs';
4
- import { dirname, join, resolve, relative } from 'node:path';
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';
5
5
  import { z } from 'zod';
6
6
  import { fileURLToPath } from 'node:url';
7
+ import 'node:fs/promises';
8
+ import process$1 from 'node:process';
7
9
  import { createHash, randomUUID } from 'node:crypto';
8
10
  import { tmpdir } from 'node:os';
9
11
  import pino from 'pino';
@@ -69,6 +71,8 @@ const serviceConfigSchema = metaConfigSchema.extend({
69
71
  schedule: z.string().default('*/30 * * * *'),
70
72
  /** Optional channel identifier for reporting. */
71
73
  reportChannel: z.string().optional(),
74
+ /** Interval in ms for periodic watcher health check. 0 = disabled. Default: 60000. */
75
+ watcherHealthIntervalMs: z.number().int().min(0).default(60_000),
72
76
  /** Logging configuration. */
73
77
  logging: loggingSchema.default(() => loggingSchema.parse({})),
74
78
  });
@@ -168,6 +172,94 @@ var configLoader = /*#__PURE__*/Object.freeze({
168
172
  resolveConfigPath: resolveConfigPath
169
173
  });
170
174
 
175
+ const toPath = urlOrPath => urlOrPath instanceof URL ? fileURLToPath(urlOrPath) : urlOrPath;
176
+
177
+ function findUpSync(name, {
178
+ cwd = process$1.cwd(),
179
+ type = 'file',
180
+ stopAt,
181
+ } = {}) {
182
+ let directory = path.resolve(toPath(cwd) ?? '');
183
+ const {root} = path.parse(directory);
184
+ stopAt = path.resolve(directory, toPath(stopAt) ?? root);
185
+ const isAbsoluteName = path.isAbsolute(name);
186
+
187
+ while (directory) {
188
+ const filePath = isAbsoluteName ? name : path.join(directory, name);
189
+
190
+ try {
191
+ const stats = fs.statSync(filePath, {throwIfNoEntry: false});
192
+ if ((type === 'file' && stats?.isFile()) || (type === 'directory' && stats?.isDirectory())) {
193
+ return filePath;
194
+ }
195
+ } catch {}
196
+
197
+ if (directory === stopAt || directory === root) {
198
+ break;
199
+ }
200
+
201
+ directory = path.dirname(directory);
202
+ }
203
+ }
204
+
205
+ const isTypeOnlyPackageJsonData = packageData => {
206
+ if (!packageData || typeof packageData !== 'object' || Array.isArray(packageData)) {
207
+ return false;
208
+ }
209
+
210
+ const keys = Object.keys(packageData);
211
+ return keys.length === 1 && keys[0] === 'type' && typeof packageData.type === 'string';
212
+ };
213
+
214
+ const isTypeOnlyPackageJsonSync = filePath => {
215
+ let fileContents;
216
+
217
+ try {
218
+ fileContents = fs.readFileSync(filePath, 'utf8');
219
+ } catch {
220
+ return false;
221
+ }
222
+
223
+ try {
224
+ return isTypeOnlyPackageJsonData(JSON.parse(fileContents));
225
+ } catch {
226
+ return false;
227
+ }
228
+ };
229
+
230
+ const getNextSearchDirectory = filePath => {
231
+ const packageDirectoryPath = path.dirname(filePath);
232
+ const parentDirectoryPath = path.dirname(packageDirectoryPath);
233
+ return parentDirectoryPath === packageDirectoryPath ? undefined : parentDirectoryPath;
234
+ };
235
+
236
+ const findPackageDirectorySync = (directory, ignoreTypeOnlyPackageJson) => {
237
+ const filePath = findUpSync('package.json', {cwd: directory});
238
+ if (!filePath) {
239
+ return undefined;
240
+ }
241
+
242
+ const packageDirectoryPath = path.dirname(filePath);
243
+ if (!ignoreTypeOnlyPackageJson) {
244
+ return packageDirectoryPath;
245
+ }
246
+
247
+ if (!isTypeOnlyPackageJsonSync(filePath)) {
248
+ return packageDirectoryPath;
249
+ }
250
+
251
+ const nextDirectory = getNextSearchDirectory(filePath);
252
+ if (!nextDirectory) {
253
+ return undefined;
254
+ }
255
+
256
+ return findPackageDirectorySync(nextDirectory, ignoreTypeOnlyPackageJson);
257
+ };
258
+
259
+ function packageDirectorySync({cwd, ignoreTypeOnlyPackageJson} = {}) {
260
+ return findPackageDirectorySync(cwd ?? process$1.cwd(), ignoreTypeOnlyPackageJson);
261
+ }
262
+
171
263
  /**
172
264
  * Shared constants for the jeeves-meta service package.
173
265
  *
@@ -183,19 +275,11 @@ const SERVICE_NAME = 'jeeves-meta';
183
275
  const SERVICE_VERSION = (() => {
184
276
  try {
185
277
  const dir = dirname(fileURLToPath(import.meta.url));
186
- // Walk up to find package.json (works from src/ or dist/)
187
- for (const candidate of [
188
- resolve(dir, '..', 'package.json'),
189
- resolve(dir, '..', '..', 'package.json'),
190
- ]) {
191
- try {
192
- const pkg = JSON.parse(readFileSync(candidate, 'utf8'));
193
- if (pkg.version)
194
- return pkg.version;
195
- }
196
- catch {
197
- // try next candidate
198
- }
278
+ const root = packageDirectorySync({ cwd: dir });
279
+ if (root) {
280
+ const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8'));
281
+ if (pkg.version)
282
+ return pkg.version;
199
283
  }
200
284
  return 'unknown';
201
285
  }
@@ -320,114 +404,29 @@ function normalizePath(p) {
320
404
  }
321
405
 
322
406
  /**
323
- * Paginated scan helper for exhaustive scope enumeration.
407
+ * Discover .meta/ directories via watcher `/walk` endpoint.
324
408
  *
325
- * @module paginatedScan
326
- */
327
- /**
328
- * Perform a paginated scan that follows cursor tokens until exhausted.
329
- *
330
- * @param watcher - WatcherClient instance.
331
- * @param params - Base scan parameters (cursor is managed internally).
332
- * @returns All matching files across all pages.
333
- */
334
- async function paginatedScan(watcher, params, logger) {
335
- const allFiles = [];
336
- let cursor;
337
- let pageCount = 0;
338
- const start = Date.now();
339
- do {
340
- const pageStart = Date.now();
341
- const result = await watcher.scan({ ...params, cursor });
342
- allFiles.push(...result.files);
343
- pageCount++;
344
- logger?.debug({
345
- page: pageCount,
346
- files: result.files.length,
347
- pageMs: Date.now() - pageStart,
348
- hasNext: Boolean(result.next),
349
- }, 'paginatedScan page');
350
- cursor = result.next;
351
- } while (cursor);
352
- logger?.debug({
353
- pages: pageCount,
354
- totalFiles: allFiles.length,
355
- totalMs: Date.now() - start,
356
- }, 'paginatedScan complete');
357
- return allFiles;
358
- }
359
-
360
- /**
361
- * Discover .meta/ directories via watcher scan.
362
- *
363
- * Replaces filesystem-based globMetas() with a watcher query
364
- * that returns indexed .meta/meta.json points, filtered by domain.
409
+ * Uses filesystem enumeration through the watcher (not Qdrant) to find
410
+ * all `.meta/meta.json` files and returns deduplicated meta directory paths.
365
411
  *
366
412
  * @module discovery/discoverMetas
367
413
  */
368
414
  /**
369
- * Build a single Qdrant filter clause from a key-value pair.
415
+ * Discover all .meta/ directories via watcher walk.
370
416
  *
371
- * Arrays use `match.value` on the first element (Qdrant array membership).
372
- * Scalars (string, number, boolean) use `match.value` directly.
373
- * Objects and other non-filterable types are skipped with a warning.
374
- */
375
- function buildMatchClause(key, value) {
376
- if (Array.isArray(value)) {
377
- if (value.length === 0)
378
- return null;
379
- return { key, match: { value: value[0] } };
380
- }
381
- if (typeof value === 'string' ||
382
- typeof value === 'number' ||
383
- typeof value === 'boolean') {
384
- return { key, match: { value } };
385
- }
386
- // Non-filterable value (object, null, etc.) — valid for tagging but
387
- // cannot be expressed as a Qdrant match clause.
388
- return null;
389
- }
390
- /**
391
- * Build a Qdrant filter from config metaProperty.
392
- *
393
- * Iterates all key-value pairs in `metaProperty` (a generic record)
394
- * to construct `must` clauses. Always appends `file_path: meta.json`
395
- * for deduplication.
396
- *
397
- * @param config - Meta config with metaProperty.
398
- * @returns Qdrant filter object for scanning live metas.
399
- */
400
- function buildMetaFilter(config) {
401
- const must = [];
402
- for (const [key, value] of Object.entries(config.metaProperty)) {
403
- const clause = buildMatchClause(key, value);
404
- if (clause)
405
- must.push(clause);
406
- }
407
- must.push({
408
- key: 'file_path',
409
- match: { text: '.meta/meta.json' },
410
- });
411
- return { must };
412
- }
413
- /**
414
- * Discover all .meta/ directories via watcher scan.
417
+ * Uses the watcher's `/walk` endpoint to find all `.meta/meta.json` files
418
+ * and returns deduplicated meta directory paths.
415
419
  *
416
- * Queries the watcher for indexed .meta/meta.json points using the
417
- * configured domain filter. Returns deduplicated meta directory paths.
418
- *
419
- * @param config - Meta config (for domain filter).
420
- * @param watcher - WatcherClient for scan queries.
420
+ * @param watcher - WatcherClient for walk queries.
421
421
  * @returns Array of normalized .meta/ directory paths.
422
422
  */
423
- async function discoverMetas(config, watcher, logger) {
424
- const filter = buildMetaFilter(config);
425
- const scanFiles = await paginatedScan(watcher, { filter, fields: ['file_path'] }, logger);
423
+ async function discoverMetas(watcher) {
424
+ const allPaths = await watcher.walk(['**/.meta/meta.json']);
426
425
  // Deduplicate by .meta/ directory path (handles multi-chunk files)
427
426
  const seen = new Set();
428
427
  const metaPaths = [];
429
- for (const sf of scanFiles) {
430
- const fp = normalizePath(sf.file_path);
428
+ for (const filePath of allPaths) {
429
+ const fp = normalizePath(filePath);
431
430
  // Derive .meta/ directory from file_path (strip /meta.json)
432
431
  const metaPath = fp.replace(/\/meta\.json$/, '');
433
432
  if (seen.has(metaPath))
@@ -568,6 +567,25 @@ function cleanupStaleLocks(metaPaths, logger) {
568
567
  }
569
568
  }
570
569
 
570
+ /**
571
+ * Read and parse a meta.json file from a `.meta/` directory.
572
+ *
573
+ * Shared utility to eliminate repeated `JSON.parse(readFileSync(...))` across
574
+ * discovery, orchestration, and route handlers.
575
+ *
576
+ * @module readMetaJson
577
+ */
578
+ /**
579
+ * Read and parse a meta.json file from a `.meta/` directory path.
580
+ *
581
+ * @param metaPath - Path to the `.meta/` directory.
582
+ * @returns Parsed meta.json content.
583
+ * @throws If the file doesn't exist or contains invalid JSON.
584
+ */
585
+ function readMetaJson(metaPath) {
586
+ return JSON.parse(readFileSync(join(metaPath, 'meta.json'), 'utf8'));
587
+ }
588
+
571
589
  /**
572
590
  * Build the ownership tree from discovered .meta/ paths.
573
591
  *
@@ -663,9 +681,9 @@ const MAX_STALENESS_SECONDS$1 = 365 * 86_400;
663
681
  * @param watcher - Watcher HTTP client for discovery.
664
682
  * @returns Enriched meta list with summary statistics and ownership tree.
665
683
  */
666
- async function listMetas(config, watcher, logger) {
667
- // Step 1: Discover deduplicated meta paths via watcher scan
668
- const metaPaths = await discoverMetas(config, watcher, logger);
684
+ async function listMetas(config, watcher) {
685
+ // Step 1: Discover deduplicated meta paths via watcher walk
686
+ const metaPaths = await discoverMetas(watcher);
669
687
  // Step 2: Build ownership tree
670
688
  const tree = buildOwnershipTree(metaPaths);
671
689
  // Step 3: Read and enrich each meta from disk
@@ -684,7 +702,7 @@ async function listMetas(config, watcher, logger) {
684
702
  for (const node of tree.nodes.values()) {
685
703
  let meta;
686
704
  try {
687
- meta = JSON.parse(readFileSync(join(node.metaPath, 'meta.json'), 'utf8'));
705
+ meta = readMetaJson(node.metaPath);
688
706
  }
689
707
  catch {
690
708
  // Skip unreadable metas
@@ -772,67 +790,50 @@ async function listMetas(config, watcher, logger) {
772
790
  }
773
791
 
774
792
  /**
775
- * Recursive filesystem walker for file enumeration.
793
+ * Filter file paths by modification time.
776
794
  *
777
- * Replaces paginated watcher scans for scope/delta/staleness checks.
778
- * Returns normalized forward-slash paths.
795
+ * Shared utility for staleness detection and delta file enumeration.
796
+ * Uses `fs.statSync` for fast local mtime checks on known paths.
779
797
  *
780
- * @module walkFiles
798
+ * @module mtimeFilter
781
799
  */
782
- /** Default directory names to always skip. */
783
- const DEFAULT_SKIP = new Set([
784
- 'node_modules',
785
- '.git',
786
- '.rollup.cache',
787
- 'dist',
788
- 'Thumbs.db',
789
- ]);
790
800
  /**
791
- * Recursively walk a directory and return all file paths.
801
+ * Check if any file in the list was modified after the given timestamp.
802
+ *
803
+ * Short-circuits on first match for efficiency (staleness checks).
792
804
  *
793
- * @param root - Root directory to walk.
794
- * @param options - Walk options.
795
- * @returns Array of normalized file paths.
805
+ * @param files - Array of file paths to check.
806
+ * @param afterMs - Timestamp in milliseconds. Files with `mtimeMs > afterMs` match.
807
+ * @returns True if any file was modified after the timestamp.
796
808
  */
797
- function walkFiles(root, options) {
798
- const exclude = new Set([...DEFAULT_SKIP, ...(options?.exclude ?? [])]);
799
- const modifiedAfter = options?.modifiedAfter;
800
- const maxDepth = options?.maxDepth ?? 50;
801
- const results = [];
802
- function walk(dir, depth) {
803
- if (depth > maxDepth)
804
- return;
805
- let entries;
809
+ function hasModifiedAfter(files, afterMs) {
810
+ for (const filePath of files) {
806
811
  try {
807
- entries = readdirSync(dir, { withFileTypes: true });
812
+ if (statSync(filePath).mtimeMs > afterMs)
813
+ return true;
808
814
  }
809
815
  catch {
810
- return; // Permission errors, missing dirs — skip
811
- }
812
- for (const entry of entries) {
813
- if (exclude.has(entry.name))
814
- continue;
815
- const fullPath = join(dir, entry.name);
816
- if (entry.isDirectory()) {
817
- walk(fullPath, depth + 1);
818
- }
819
- else if (entry.isFile()) {
820
- if (modifiedAfter !== undefined) {
821
- try {
822
- const stat = statSync(fullPath);
823
- if (Math.floor(stat.mtimeMs / 1000) <= modifiedAfter)
824
- continue;
825
- }
826
- catch {
827
- continue;
828
- }
829
- }
830
- results.push(normalizePath(fullPath));
831
- }
816
+ // Unreadable file — skip
832
817
  }
833
818
  }
834
- walk(root, 0);
835
- return results;
819
+ return false;
820
+ }
821
+ /**
822
+ * Filter files to only those modified after the given timestamp.
823
+ *
824
+ * @param files - Array of file paths to filter.
825
+ * @param afterMs - Timestamp in milliseconds. Files with `mtimeMs > afterMs` are included.
826
+ * @returns Filtered array of file paths.
827
+ */
828
+ function filterModifiedAfter(files, afterMs) {
829
+ return files.filter((filePath) => {
830
+ try {
831
+ return statSync(filePath).mtimeMs > afterMs;
832
+ }
833
+ catch {
834
+ return false;
835
+ }
836
+ });
836
837
  }
837
838
 
838
839
  /**
@@ -842,7 +843,7 @@ function walkFiles(root, options) {
842
843
  * - Its own .meta/ subtree (outputs, not inputs)
843
844
  * - Child meta ownerPath subtrees (except their .meta/meta.json for rollups)
844
845
  *
845
- * Uses filesystem walks instead of watcher scans for performance.
846
+ * All filesystem enumeration delegated to the watcher's `/walk` endpoint.
846
847
  *
847
848
  * @module discovery/scope
848
849
  */
@@ -859,7 +860,7 @@ function getScopePrefix(node) {
859
860
  * - The node's own .meta/ subtree (synthesis outputs are not scope inputs)
860
861
  * - Child meta ownerPath subtrees (except child .meta/meta.json for rollups)
861
862
  *
862
- * walkFiles already returns normalized forward-slash paths.
863
+ * Watcher walk returns normalized forward-slash paths.
863
864
  */
864
865
  function filterInScope(node, files) {
865
866
  const prefix = node.ownerPath + '/';
@@ -884,10 +885,10 @@ function filterInScope(node, files) {
884
885
  });
885
886
  }
886
887
  /**
887
- * Get all files in scope for a meta node via filesystem walk.
888
+ * Get all files in scope for a meta node via watcher walk.
888
889
  */
889
- function getScopeFiles(node) {
890
- const allFiles = walkFiles(node.ownerPath);
890
+ async function getScopeFiles(node, watcher) {
891
+ const allFiles = await watcher.walk([`${node.ownerPath}/**`]);
891
892
  return {
892
893
  scopeFiles: filterInScope(node, allFiles),
893
894
  allFiles,
@@ -897,13 +898,12 @@ function getScopeFiles(node) {
897
898
  * Get files modified since a given timestamp within a meta node's scope.
898
899
  *
899
900
  * If no generatedAt is provided (first run), returns all scope files.
901
+ * Reuses scope files from getScopeFiles() and filters locally by mtime.
900
902
  */
901
- function getDeltaFiles(node, generatedAt, scopeFiles) {
903
+ function getDeltaFiles(generatedAt, scopeFiles) {
902
904
  if (!generatedAt)
903
905
  return scopeFiles;
904
- const modifiedAfter = Math.floor(new Date(generatedAt).getTime() / 1000);
905
- const deltaFiles = walkFiles(node.ownerPath, { modifiedAfter });
906
- return filterInScope(node, deltaFiles);
906
+ return filterModifiedAfter(scopeFiles, new Date(generatedAt).getTime());
907
907
  }
908
908
 
909
909
  /**
@@ -1204,10 +1204,10 @@ function condenseScopeFiles(files, maxIndividual = 30) {
1204
1204
  * @param watcher - WatcherClient for scope enumeration.
1205
1205
  * @returns The computed context package.
1206
1206
  */
1207
- function buildContextPackage(node, meta) {
1208
- // Scope and delta files via watcher scan
1209
- const { scopeFiles } = getScopeFiles(node);
1210
- const deltaFiles = getDeltaFiles(node, meta._generatedAt, scopeFiles);
1207
+ async function buildContextPackage(node, meta, watcher) {
1208
+ // Scope and delta files via watcher walk
1209
+ const { scopeFiles } = await getScopeFiles(node, watcher);
1210
+ const deltaFiles = getDeltaFiles(meta._generatedAt, scopeFiles);
1211
1211
  // Child meta outputs
1212
1212
  const childMetas = {};
1213
1213
  for (const child of node.children) {
@@ -1531,6 +1531,67 @@ function mergeAndWrite(options) {
1531
1531
  return result.data;
1532
1532
  }
1533
1533
 
1534
+ /**
1535
+ * Build a minimal MetaNode from a known meta path using watcher walk.
1536
+ *
1537
+ * Used for targeted synthesis (when a specific path is requested) to avoid
1538
+ * the full discovery + ownership tree build. Discovers only immediate child
1539
+ * `.meta/` directories.
1540
+ *
1541
+ * @module discovery/buildMinimalNode
1542
+ */
1543
+ /**
1544
+ * Build a minimal MetaNode for a known meta path.
1545
+ *
1546
+ * Walks the owner directory for child `.meta/meta.json` files and constructs
1547
+ * a shallow ownership tree (self + direct children only).
1548
+ *
1549
+ * @param metaPath - Absolute path to the `.meta/` directory.
1550
+ * @param watcher - WatcherClient for filesystem enumeration.
1551
+ * @returns MetaNode with direct children wired.
1552
+ */
1553
+ async function buildMinimalNode(metaPath, watcher) {
1554
+ const normalized = normalizePath(metaPath);
1555
+ const ownerPath = normalizePath(dirname(metaPath));
1556
+ // Find child metas using watcher walk.
1557
+ // We include only *direct* children (nearest descendants in the ownership tree)
1558
+ // to match the ownership semantics used elsewhere.
1559
+ const rawMetaJsonPaths = await watcher.walk([
1560
+ `${ownerPath}/**/.meta/meta.json`,
1561
+ ]);
1562
+ const candidateMetaPaths = [
1563
+ ...new Set(rawMetaJsonPaths.map((p) => normalizePath(dirname(p)))),
1564
+ ].filter((p) => p !== normalized);
1565
+ const candidates = candidateMetaPaths
1566
+ .map((mp) => ({ metaPath: mp, ownerPath: normalizePath(dirname(mp)) }))
1567
+ .sort((a, b) => a.ownerPath.length - b.ownerPath.length);
1568
+ const directChildren = [];
1569
+ for (const c of candidates) {
1570
+ const nestedUnderExisting = directChildren.some((d) => c.ownerPath === d.ownerPath ||
1571
+ c.ownerPath.startsWith(d.ownerPath + '/'));
1572
+ if (!nestedUnderExisting)
1573
+ directChildren.push(c);
1574
+ }
1575
+ const children = directChildren.map((c) => ({
1576
+ metaPath: c.metaPath,
1577
+ ownerPath: c.ownerPath,
1578
+ treeDepth: 1,
1579
+ children: [],
1580
+ parent: null,
1581
+ }));
1582
+ const node = {
1583
+ metaPath: normalized,
1584
+ ownerPath,
1585
+ treeDepth: 0,
1586
+ children,
1587
+ parent: null,
1588
+ };
1589
+ for (const child of children) {
1590
+ child.parent = node;
1591
+ }
1592
+ return node;
1593
+ }
1594
+
1534
1595
  /**
1535
1596
  * Weighted staleness formula for candidate selection.
1536
1597
  *
@@ -1610,29 +1671,30 @@ function discoverStalestPath(candidates, depthWeight) {
1610
1671
  }
1611
1672
 
1612
1673
  /**
1613
- * Staleness detection via watcher scan.
1674
+ * Staleness detection via watcher walk.
1614
1675
  *
1615
- * A meta is stale when any file in its scope was modified after _generatedAt.
1676
+ * A meta is stale when any watched file in its scope was modified after
1677
+ * `_generatedAt`.
1616
1678
  *
1617
1679
  * @module scheduling/staleness
1618
1680
  */
1619
1681
  /**
1620
- * Check if a meta is stale by querying the watcher for modified files.
1682
+ * Check if a meta is stale.
1683
+ *
1684
+ * Uses watcher `/walk` to enumerate watched files under the scope prefix,
1685
+ * then applies a local mtime check (fast) to detect any modifications since
1686
+ * `_generatedAt`. Short-circuits on first match.
1621
1687
  *
1622
1688
  * @param scopePrefix - Path prefix for this meta's scope.
1623
1689
  * @param meta - Current meta.json content.
1624
1690
  * @param watcher - WatcherClient instance.
1625
- * @returns True if any file in scope was modified after _generatedAt.
1691
+ * @returns True if any file in scope was modified after `_generatedAt`.
1626
1692
  */
1627
- function isStale(scopePrefix, meta) {
1693
+ async function isStale(scopePrefix, meta, watcher) {
1628
1694
  if (!meta._generatedAt)
1629
1695
  return true; // Never synthesized = stale
1630
- const generatedAtUnix = Math.floor(new Date(meta._generatedAt).getTime() / 1000);
1631
- const modified = walkFiles(scopePrefix, {
1632
- modifiedAfter: generatedAtUnix,
1633
- maxDepth: 1,
1634
- });
1635
- return modified.length > 0;
1696
+ const files = await watcher.walk([`${scopePrefix}/**`]);
1697
+ return hasModifiedAfter(files, new Date(meta._generatedAt).getTime());
1636
1698
  }
1637
1699
  /** Maximum staleness for never-synthesized metas (1 year in seconds). */
1638
1700
  const MAX_STALENESS_SECONDS = 365 * 86_400;
@@ -1842,72 +1904,13 @@ function finalizeCycle(opts) {
1842
1904
  * @param watcher - Watcher HTTP client.
1843
1905
  * @returns Result indicating whether synthesis occurred.
1844
1906
  */
1845
- /**
1846
- * Build a minimal MetaNode from the filesystem for a known meta path.
1847
- * Discovers immediate child .meta/ dirs without a full watcher scan.
1848
- */
1849
- function buildMinimalNode(metaPath) {
1850
- const normalized = normalizePath(metaPath);
1851
- const ownerPath = normalizePath(dirname(metaPath));
1852
- // Find child .meta/ directories by scanning the owner directory
1853
- const children = [];
1854
- function findChildMetas(dir, depth) {
1855
- if (depth > 10)
1856
- return; // Safety limit
1857
- try {
1858
- const entries = readdirSync(dir, { withFileTypes: true });
1859
- for (const entry of entries) {
1860
- if (!entry.isDirectory())
1861
- continue;
1862
- const fullPath = normalizePath(join(dir, entry.name));
1863
- if (entry.name === '.meta' && fullPath !== normalized) {
1864
- // Found a child .meta — check it has meta.json
1865
- if (existsSync(join(fullPath, 'meta.json'))) {
1866
- children.push({
1867
- metaPath: fullPath,
1868
- ownerPath: normalizePath(dirname(fullPath)),
1869
- treeDepth: 1, // Relative to target
1870
- children: [],
1871
- parent: null, // Set below
1872
- });
1873
- }
1874
- // Don't recurse into .meta dirs
1875
- return;
1876
- }
1877
- if (entry.name === 'node_modules' ||
1878
- entry.name === '.git' ||
1879
- entry.name === 'archive')
1880
- continue;
1881
- findChildMetas(fullPath, depth + 1);
1882
- }
1883
- }
1884
- catch {
1885
- // Permission errors, etc — skip
1886
- }
1887
- }
1888
- findChildMetas(ownerPath, 0);
1889
- const node = {
1890
- metaPath: normalized,
1891
- ownerPath,
1892
- treeDepth: 0,
1893
- children,
1894
- parent: null,
1895
- };
1896
- // Wire parent references
1897
- for (const child of children) {
1898
- child.parent = node;
1899
- }
1900
- return node;
1901
- }
1902
1907
  /** Run the architect/builder/critic pipeline on a single node. */
1903
1908
  async function synthesizeNode(node, currentMeta, config, executor, watcher, onProgress) {
1904
- const architectPrompt = currentMeta._architect ?? config.defaultArchitect;
1905
- const criticPrompt = currentMeta._critic ?? config.defaultCritic;
1906
1909
  // Step 5-6: Steer change detection
1907
1910
  const latestArchive = readLatestArchive(node.metaPath);
1908
1911
  const steerChanged = hasSteerChanged(currentMeta._steer, latestArchive?._steer, Boolean(latestArchive));
1909
1912
  // Step 7: Compute context (includes scope files and delta files)
1910
- const ctx = buildContextPackage(node, currentMeta);
1913
+ const ctx = await buildContextPackage(node, currentMeta, watcher);
1911
1914
  // Step 5 (deferred): Structure hash from context scope files
1912
1915
  const newStructureHash = computeStructureHash(ctx.scopeFiles);
1913
1916
  const structureChanged = newStructureHash !== currentMeta._structureHash;
@@ -1951,9 +1954,9 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
1951
1954
  metaPath: node.metaPath,
1952
1955
  current: currentMeta,
1953
1956
  config,
1954
- architect: architectPrompt,
1957
+ architect: currentMeta._architect ?? '',
1955
1958
  builder: '',
1956
- critic: criticPrompt,
1959
+ critic: currentMeta._critic ?? '',
1957
1960
  builderOutput: null,
1958
1961
  feedback: null,
1959
1962
  structureHash: newStructureHash,
@@ -2037,9 +2040,9 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
2037
2040
  metaPath: node.metaPath,
2038
2041
  current: currentMeta,
2039
2042
  config,
2040
- architect: architectPrompt,
2043
+ architect: currentMeta._architect ?? '',
2041
2044
  builder: builderBrief,
2042
- critic: criticPrompt,
2045
+ critic: currentMeta._critic ?? '',
2043
2046
  builderOutput,
2044
2047
  feedback,
2045
2048
  structureHash: newStructureHash,
@@ -2063,26 +2066,25 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
2063
2066
  const targetMetaJson = join(normalizedTarget, 'meta.json');
2064
2067
  if (!existsSync(targetMetaJson))
2065
2068
  return { synthesized: false };
2066
- const node = buildMinimalNode(normalizedTarget);
2069
+ const node = await buildMinimalNode(normalizedTarget, watcher);
2067
2070
  if (!acquireLock(node.metaPath))
2068
2071
  return { synthesized: false };
2069
2072
  try {
2070
- const currentMeta = JSON.parse(readFileSync(targetMetaJson, 'utf8'));
2073
+ const currentMeta = readMetaJson(normalizedTarget);
2071
2074
  return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress);
2072
2075
  }
2073
2076
  finally {
2074
2077
  releaseLock(node.metaPath);
2075
2078
  }
2076
2079
  }
2077
- const metaPaths = await discoverMetas(config, watcher, logger);
2080
+ const metaPaths = await discoverMetas(watcher);
2078
2081
  if (metaPaths.length === 0)
2079
2082
  return { synthesized: false };
2080
2083
  // Read meta.json for each discovered meta
2081
2084
  const metas = new Map();
2082
2085
  for (const mp of metaPaths) {
2083
- const metaFilePath = join(mp, 'meta.json');
2084
2086
  try {
2085
- metas.set(normalizePath(mp), JSON.parse(readFileSync(metaFilePath, 'utf8')));
2087
+ metas.set(normalizePath(mp), readMetaJson(mp));
2086
2088
  }
2087
2089
  catch {
2088
2090
  // Skip metas with unreadable meta.json
@@ -2115,13 +2117,12 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
2115
2117
  for (const candidate of ranked) {
2116
2118
  if (!acquireLock(candidate.node.metaPath))
2117
2119
  continue;
2118
- const verifiedStale = isStale(getScopePrefix(candidate.node), candidate.meta);
2120
+ const verifiedStale = await isStale(getScopePrefix(candidate.node), candidate.meta, watcher);
2119
2121
  if (!verifiedStale && candidate.meta._generatedAt) {
2120
2122
  // Bump _generatedAt so it doesn't win next cycle
2121
- const metaFilePath = join(candidate.node.metaPath, 'meta.json');
2122
- const freshMeta = JSON.parse(readFileSync(metaFilePath, 'utf8'));
2123
+ const freshMeta = readMetaJson(candidate.node.metaPath);
2123
2124
  freshMeta._generatedAt = new Date().toISOString();
2124
- writeFileSync(metaFilePath, JSON.stringify(freshMeta, null, 2));
2125
+ writeFileSync(join(candidate.node.metaPath, 'meta.json'), JSON.stringify(freshMeta, null, 2));
2125
2126
  releaseLock(candidate.node.metaPath);
2126
2127
  if (config.skipUnchanged)
2127
2128
  continue;
@@ -2134,7 +2135,7 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
2134
2135
  return { synthesized: false };
2135
2136
  const node = winner.node;
2136
2137
  try {
2137
- const currentMeta = JSON.parse(readFileSync(join(node.metaPath, 'meta.json'), 'utf8'));
2138
+ const currentMeta = readMetaJson(node.metaPath);
2138
2139
  return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress);
2139
2140
  }
2140
2141
  finally {
@@ -2155,7 +2156,7 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
2155
2156
  * @returns Array with a single result.
2156
2157
  */
2157
2158
  async function orchestrate(config, executor, watcher, targetPath, onProgress, logger) {
2158
- const result = await orchestrateOnce(config, executor, watcher, targetPath, onProgress, logger);
2159
+ const result = await orchestrateOnce(config, executor, watcher, targetPath, onProgress);
2159
2160
  return [result];
2160
2161
  }
2161
2162
 
@@ -2378,7 +2379,7 @@ class Scheduler {
2378
2379
  */
2379
2380
  async discoverStalest() {
2380
2381
  try {
2381
- const result = await listMetas(this.config, this.watcher, this.logger);
2382
+ const result = await listMetas(this.config, this.watcher);
2382
2383
  const stale = result.entries
2383
2384
  .filter((e) => e.stalenessSeconds > 0)
2384
2385
  .map((e) => ({
@@ -2664,7 +2665,7 @@ function registerMetasRoutes(app, deps) {
2664
2665
  app.get('/metas', async (request) => {
2665
2666
  const query = metasQuerySchema.parse(request.query);
2666
2667
  const { config, watcher } = deps;
2667
- const result = await listMetas(config, watcher, request.log);
2668
+ const result = await listMetas(config, watcher);
2668
2669
  let entries = result.entries;
2669
2670
  // Apply filters
2670
2671
  if (query.pathPrefix) {
@@ -2727,7 +2728,7 @@ function registerMetasRoutes(app, deps) {
2727
2728
  const query = metaDetailQuerySchema.parse(request.query);
2728
2729
  const { config, watcher } = deps;
2729
2730
  const targetPath = normalizePath(decodeURIComponent(request.params.path));
2730
- const result = await listMetas(config, watcher, request.log);
2731
+ const result = await listMetas(config, watcher);
2731
2732
  const targetNode = findNode(result.tree, targetPath);
2732
2733
  if (!targetNode) {
2733
2734
  return reply.status(404).send({
@@ -2760,7 +2761,7 @@ function registerMetasRoutes(app, deps) {
2760
2761
  return r;
2761
2762
  };
2762
2763
  // Compute scope
2763
- const { scopeFiles, allFiles } = getScopeFiles(targetNode);
2764
+ const { scopeFiles, allFiles } = await getScopeFiles(targetNode, watcher);
2764
2765
  // Compute staleness
2765
2766
  const metaTyped = meta;
2766
2767
  const staleSeconds = metaTyped._generatedAt
@@ -2807,7 +2808,7 @@ function registerPreviewRoute(app, deps) {
2807
2808
  const query = request.query;
2808
2809
  let result;
2809
2810
  try {
2810
- result = await listMetas(config, watcher, request.log);
2811
+ result = await listMetas(config, watcher);
2811
2812
  }
2812
2813
  catch {
2813
2814
  return reply.status(503).send({
@@ -2841,16 +2842,16 @@ function registerPreviewRoute(app, deps) {
2841
2842
  }
2842
2843
  targetNode = findNode(result.tree, stalestPath);
2843
2844
  }
2844
- const meta = JSON.parse(readFileSync(join(targetNode.metaPath, 'meta.json'), 'utf8'));
2845
+ const meta = readMetaJson(targetNode.metaPath);
2845
2846
  // Scope files
2846
- const { scopeFiles } = getScopeFiles(targetNode);
2847
+ const { scopeFiles } = await getScopeFiles(targetNode, watcher);
2847
2848
  const structureHash = computeStructureHash(scopeFiles);
2848
2849
  const structureChanged = structureHash !== meta._structureHash;
2849
2850
  const latestArchive = readLatestArchive(targetNode.metaPath);
2850
2851
  const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
2851
2852
  const architectTriggered = isArchitectTriggered(meta, structureChanged, steerChanged, config.architectEvery);
2852
2853
  // Delta files
2853
- const deltaFiles = getDeltaFiles(targetNode, meta._generatedAt, scopeFiles);
2854
+ const deltaFiles = getDeltaFiles(meta._generatedAt, scopeFiles);
2854
2855
  // EMA token estimates
2855
2856
  const estimatedTokens = {
2856
2857
  architect: meta._architectTokensAvg ?? meta._architectTokens ?? 0,
@@ -2965,7 +2966,7 @@ function registerStatusRoute(app, deps) {
2965
2966
  else {
2966
2967
  status = 'idle';
2967
2968
  }
2968
- // Metas summary is expensive (paginated watcher scan + disk reads).
2969
+ // Metas summary is expensive (watcher walk + disk reads).
2969
2970
  // Use GET /metas for full inventory; status is a lightweight health check.
2970
2971
  return {
2971
2972
  service: SERVICE_NAME,
@@ -2986,7 +2987,10 @@ function registerStatusRoute(app, deps) {
2986
2987
  nextAt: scheduler?.nextRunAt?.toISOString() ?? null,
2987
2988
  },
2988
2989
  dependencies: {
2989
- watcher: watcherHealth,
2990
+ watcher: {
2991
+ ...watcherHealth,
2992
+ rulesRegistered: deps.registrar?.isRegistered ?? false,
2993
+ },
2990
2994
  gateway: gatewayHealth,
2991
2995
  },
2992
2996
  };
@@ -3014,7 +3018,7 @@ function registerSynthesizeRoute(app, deps) {
3014
3018
  // Discover stalest candidate
3015
3019
  let result;
3016
3020
  try {
3017
- result = await listMetas(config, watcher, request.log);
3021
+ result = await listMetas(config, watcher);
3018
3022
  }
3019
3023
  catch {
3020
3024
  return reply.status(503).send({
@@ -3191,7 +3195,15 @@ function buildMetaRules(config) {
3191
3195
  },
3192
3196
  ],
3193
3197
  render: {
3194
- frontmatter: ['meta_id', 'generated_at', '*', '!_*', '!has_error'],
3198
+ frontmatter: [
3199
+ 'meta_id',
3200
+ 'generated_at',
3201
+ '*',
3202
+ '!_*',
3203
+ '!json',
3204
+ '!file',
3205
+ '!has_error',
3206
+ ],
3195
3207
  body: [{ path: 'json._content', heading: 1, label: 'Synthesis' }],
3196
3208
  },
3197
3209
  renderAs: 'md',
@@ -3383,6 +3395,8 @@ function registerShutdownHandlers(deps) {
3383
3395
  if (deps.routeDeps) {
3384
3396
  deps.routeDeps.shuttingDown = true;
3385
3397
  }
3398
+ // 0. Run optional cleanup
3399
+ deps.onShutdown?.();
3386
3400
  // 1. Stop scheduler
3387
3401
  if (deps.scheduler) {
3388
3402
  deps.scheduler.stop();
@@ -3416,7 +3430,7 @@ function registerShutdownHandlers(deps) {
3416
3430
  /**
3417
3431
  * HTTP implementation of the WatcherClient interface.
3418
3432
  *
3419
- * Talks to jeeves-watcher's POST /scan and POST /rules endpoints
3433
+ * Talks to jeeves-watcher's POST /walk and POST /rules/register endpoints
3420
3434
  * with retry and exponential backoff.
3421
3435
  *
3422
3436
  * @module watcher-client/HttpWatcherClient
@@ -3470,61 +3484,80 @@ class HttpWatcherClient {
3470
3484
  // Unreachable, but TypeScript needs it
3471
3485
  throw new Error('Retry exhausted');
3472
3486
  }
3473
- async scan(params) {
3474
- // Build Qdrant filter: merge explicit filter with pathPrefix/modifiedAfter
3475
- const mustClauses = [];
3476
- // Carry over any existing 'must' clauses from the provided filter
3477
- if (params.filter) {
3478
- const existing = params.filter.must;
3479
- if (Array.isArray(existing)) {
3480
- mustClauses.push(...existing);
3481
- }
3487
+ async registerRules(source, rules) {
3488
+ await this.post('/rules/register', { source, rules });
3489
+ }
3490
+ async walk(globs) {
3491
+ const raw = (await this.post('/walk', { globs }));
3492
+ return (raw.paths ?? []);
3493
+ }
3494
+ }
3495
+
3496
+ /**
3497
+ * Periodic watcher health check for rule registration resilience.
3498
+ *
3499
+ * Pings watcher `/status` on a configurable interval, detects restarts
3500
+ * (uptime decrease), and re-registers virtual rules automatically.
3501
+ * Independent of the synthesis scheduler.
3502
+ *
3503
+ * @module rules/healthCheck
3504
+ */
3505
+ /**
3506
+ * Manages the periodic watcher health check loop.
3507
+ *
3508
+ * Starts a `setInterval` that pings the watcher and delegates
3509
+ * restart detection to `RuleRegistrar.checkAndReregister()`.
3510
+ */
3511
+ class WatcherHealthCheck {
3512
+ watcherUrl;
3513
+ intervalMs;
3514
+ registrar;
3515
+ logger;
3516
+ handle = null;
3517
+ constructor(opts) {
3518
+ this.watcherUrl = opts.watcherUrl.replace(/\/+$/, '');
3519
+ this.intervalMs = opts.intervalMs;
3520
+ this.registrar = opts.registrar;
3521
+ this.logger = opts.logger;
3522
+ }
3523
+ /** Start the periodic health check. No-op if intervalMs is 0. */
3524
+ start() {
3525
+ if (this.intervalMs <= 0) {
3526
+ this.logger.info('Watcher health check disabled (interval = 0)');
3527
+ return;
3482
3528
  }
3483
- // Translate pathPrefix into a Qdrant text match on file_path
3484
- if (params.pathPrefix !== undefined) {
3485
- mustClauses.push({
3486
- key: 'file_path',
3487
- match: { text: params.pathPrefix },
3488
- });
3529
+ this.handle = setInterval(() => {
3530
+ void this.check();
3531
+ }, this.intervalMs);
3532
+ // Don't prevent process exit
3533
+ if (typeof this.handle === 'object' && 'unref' in this.handle) {
3534
+ this.handle.unref();
3489
3535
  }
3490
- // Translate modifiedAfter into a Qdrant range filter on modified_at
3491
- if (params.modifiedAfter !== undefined) {
3492
- mustClauses.push({
3493
- key: 'modified_at',
3494
- range: { gt: params.modifiedAfter },
3495
- });
3496
- }
3497
- const filter = { must: mustClauses };
3498
- const body = { filter };
3499
- if (params.fields !== undefined) {
3500
- body.fields = params.fields;
3536
+ this.logger.info({ intervalMs: this.intervalMs }, 'Watcher health check started');
3537
+ }
3538
+ /** Stop the periodic health check. */
3539
+ stop() {
3540
+ if (this.handle) {
3541
+ clearInterval(this.handle);
3542
+ this.handle = null;
3501
3543
  }
3502
- if (params.limit !== undefined) {
3503
- body.limit = params.limit;
3544
+ }
3545
+ /** Single health check iteration. */
3546
+ async check() {
3547
+ try {
3548
+ const res = await fetch(this.watcherUrl + '/status', {
3549
+ signal: AbortSignal.timeout(5000),
3550
+ });
3551
+ if (!res.ok) {
3552
+ this.logger.warn({ status: res.status }, 'Watcher health check: non-OK response');
3553
+ return;
3554
+ }
3555
+ const data = (await res.json());
3556
+ await this.registrar.checkAndReregister(data.uptime);
3504
3557
  }
3505
- if (params.cursor !== undefined) {
3506
- body.cursor = params.cursor;
3558
+ catch (err) {
3559
+ this.logger.debug({ err }, 'Watcher health check: unreachable (expected during startup)');
3507
3560
  }
3508
- const raw = (await this.post('/scan', body));
3509
- // jeeves-watcher returns { points, cursor }; map to ScanResponse.
3510
- const points = (raw.points ?? raw.files ?? []);
3511
- const next = (raw.cursor ?? raw.next);
3512
- const files = points.map((p) => {
3513
- const payload = (p.payload ?? p);
3514
- return {
3515
- file_path: (payload.file_path ?? payload.path ?? ''),
3516
- modified_at: (payload.modified_at ?? payload.mtime ?? 0),
3517
- content_hash: (payload.content_hash ?? ''),
3518
- ...payload,
3519
- };
3520
- });
3521
- return { files, next: next ?? undefined };
3522
- }
3523
- async registerRules(source, rules) {
3524
- await this.post('/rules/register', { source, rules });
3525
- }
3526
- async unregisterRules(source) {
3527
- await this.post('/rules/unregister', { source });
3528
3561
  }
3529
3562
  }
3530
3563
 
@@ -3664,7 +3697,16 @@ async function startService(config, configPath) {
3664
3697
  // Rule registration (fire-and-forget with retries)
3665
3698
  const registrar = new RuleRegistrar(config, logger, watcher);
3666
3699
  scheduler.setRegistrar(registrar);
3700
+ routeDeps.registrar = registrar;
3667
3701
  void registrar.register();
3702
+ // Periodic watcher health check (independent of scheduler)
3703
+ const healthCheck = new WatcherHealthCheck({
3704
+ watcherUrl: config.watcherUrl,
3705
+ intervalMs: config.watcherHealthIntervalMs,
3706
+ registrar,
3707
+ logger,
3708
+ });
3709
+ healthCheck.start();
3668
3710
  // Config hot-reload (gap #12)
3669
3711
  if (configPath) {
3670
3712
  watchFile(configPath, { interval: 5000 }, () => {
@@ -3698,6 +3740,9 @@ async function startService(config, configPath) {
3698
3740
  queue,
3699
3741
  logger,
3700
3742
  routeDeps,
3743
+ onShutdown: () => {
3744
+ healthCheck.stop();
3745
+ },
3701
3746
  });
3702
3747
  logger.info('Service fully initialized');
3703
3748
  }
@@ -3894,7 +3939,7 @@ const service = program
3894
3939
  service.addCommand(new Command('install')
3895
3940
  .description('Print install instructions for a system service')
3896
3941
  .option('-c, --config <path>', 'Path to configuration file')
3897
- .option('-n, --name <name>', 'Service name', 'JeevesMeta')
3942
+ .option('-n, --name <name>', 'Service name', 'jeeves-meta')
3898
3943
  .action((options) => {
3899
3944
  const { name } = options;
3900
3945
  const configFlag = options.config ? ` -c "${options.config}"` : '';
@@ -3959,7 +4004,7 @@ service.addCommand(new Command('install')
3959
4004
  // start command (prints OS-specific start instructions)
3960
4005
  service.addCommand(new Command('start')
3961
4006
  .description('Print start instructions for the installed service')
3962
- .option('-n, --name <name>', 'Service name', 'JeevesMeta')
4007
+ .option('-n, --name <name>', 'Service name', 'jeeves-meta')
3963
4008
  .action((options) => {
3964
4009
  const { name } = options;
3965
4010
  if (process.platform === 'win32') {
@@ -3978,7 +4023,7 @@ service.addCommand(new Command('start')
3978
4023
  // stop command
3979
4024
  service.addCommand(new Command('stop')
3980
4025
  .description('Stop the running service')
3981
- .option('-n, --name <name>', 'Service name', 'JeevesMeta')
4026
+ .option('-n, --name <name>', 'Service name', 'jeeves-meta')
3982
4027
  .action((options) => {
3983
4028
  const { name } = options;
3984
4029
  if (process.platform === 'win32') {
@@ -4010,7 +4055,7 @@ service.addCommand(new Command('status')
4010
4055
  }));
4011
4056
  service.addCommand(new Command('remove')
4012
4057
  .description('Print remove instructions for a system service')
4013
- .option('-n, --name <name>', 'Service name', 'JeevesMeta')
4058
+ .option('-n, --name <name>', 'Service name', 'jeeves-meta')
4014
4059
  .action((options) => {
4015
4060
  const { name } = options;
4016
4061
  if (process.platform === 'win32') {