@karmaniverous/jeeves-meta 0.4.1 → 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,13 @@
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, 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
+ import { fileURLToPath } from 'node:url';
7
+ import 'node:fs/promises';
8
+ import process$1 from 'node:process';
6
9
  import { createHash, randomUUID } from 'node:crypto';
10
+ import { tmpdir } from 'node:os';
7
11
  import pino from 'pino';
8
12
  import { Cron } from 'croner';
9
13
  import Fastify from 'fastify';
@@ -67,6 +71,8 @@ const serviceConfigSchema = metaConfigSchema.extend({
67
71
  schedule: z.string().default('*/30 * * * *'),
68
72
  /** Optional channel identifier for reporting. */
69
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),
70
76
  /** Logging configuration. */
71
77
  logging: loggingSchema.default(() => loggingSchema.parse({})),
72
78
  });
@@ -166,6 +172,122 @@ var configLoader = /*#__PURE__*/Object.freeze({
166
172
  resolveConfigPath: resolveConfigPath
167
173
  });
168
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
+
263
+ /**
264
+ * Shared constants for the jeeves-meta service package.
265
+ *
266
+ * @module constants
267
+ */
268
+ /** Default HTTP port for the jeeves-meta service. */
269
+ const DEFAULT_PORT = 1938;
270
+ /** Default port as a string (for Commander CLI defaults). */
271
+ const DEFAULT_PORT_STR = String(DEFAULT_PORT);
272
+ /** Service name identifier. */
273
+ const SERVICE_NAME = 'jeeves-meta';
274
+ /** Service version, read from package.json at startup. */
275
+ const SERVICE_VERSION = (() => {
276
+ try {
277
+ const dir = dirname(fileURLToPath(import.meta.url));
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;
283
+ }
284
+ return 'unknown';
285
+ }
286
+ catch {
287
+ return 'unknown';
288
+ }
289
+ })();
290
+
169
291
  /**
170
292
  * List archive snapshot files in chronological order.
171
293
  *
@@ -282,114 +404,29 @@ function normalizePath(p) {
282
404
  }
283
405
 
284
406
  /**
285
- * Paginated scan helper for exhaustive scope enumeration.
407
+ * Discover .meta/ directories via watcher `/walk` endpoint.
286
408
  *
287
- * @module paginatedScan
288
- */
289
- /**
290
- * Perform a paginated scan that follows cursor tokens until exhausted.
291
- *
292
- * @param watcher - WatcherClient instance.
293
- * @param params - Base scan parameters (cursor is managed internally).
294
- * @returns All matching files across all pages.
295
- */
296
- async function paginatedScan(watcher, params, logger) {
297
- const allFiles = [];
298
- let cursor;
299
- let pageCount = 0;
300
- const start = Date.now();
301
- do {
302
- const pageStart = Date.now();
303
- const result = await watcher.scan({ ...params, cursor });
304
- allFiles.push(...result.files);
305
- pageCount++;
306
- logger?.debug({
307
- page: pageCount,
308
- files: result.files.length,
309
- pageMs: Date.now() - pageStart,
310
- hasNext: Boolean(result.next),
311
- }, 'paginatedScan page');
312
- cursor = result.next;
313
- } while (cursor);
314
- logger?.debug({
315
- pages: pageCount,
316
- totalFiles: allFiles.length,
317
- totalMs: Date.now() - start,
318
- }, 'paginatedScan complete');
319
- return allFiles;
320
- }
321
-
322
- /**
323
- * Discover .meta/ directories via watcher scan.
324
- *
325
- * Replaces filesystem-based globMetas() with a watcher query
326
- * 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.
327
411
  *
328
412
  * @module discovery/discoverMetas
329
413
  */
330
414
  /**
331
- * Build a single Qdrant filter clause from a key-value pair.
415
+ * Discover all .meta/ directories via watcher walk.
332
416
  *
333
- * Arrays use `match.value` on the first element (Qdrant array membership).
334
- * Scalars (string, number, boolean) use `match.value` directly.
335
- * Objects and other non-filterable types are skipped with a warning.
336
- */
337
- function buildMatchClause(key, value) {
338
- if (Array.isArray(value)) {
339
- if (value.length === 0)
340
- return null;
341
- return { key, match: { value: value[0] } };
342
- }
343
- if (typeof value === 'string' ||
344
- typeof value === 'number' ||
345
- typeof value === 'boolean') {
346
- return { key, match: { value } };
347
- }
348
- // Non-filterable value (object, null, etc.) — valid for tagging but
349
- // cannot be expressed as a Qdrant match clause.
350
- return null;
351
- }
352
- /**
353
- * Build a Qdrant filter from config metaProperty.
354
- *
355
- * Iterates all key-value pairs in `metaProperty` (a generic record)
356
- * to construct `must` clauses. Always appends `file_path: meta.json`
357
- * for deduplication.
417
+ * Uses the watcher's `/walk` endpoint to find all `.meta/meta.json` files
418
+ * and returns deduplicated meta directory paths.
358
419
  *
359
- * @param config - Meta config with metaProperty.
360
- * @returns Qdrant filter object for scanning live metas.
361
- */
362
- function buildMetaFilter(config) {
363
- const must = [];
364
- for (const [key, value] of Object.entries(config.metaProperty)) {
365
- const clause = buildMatchClause(key, value);
366
- if (clause)
367
- must.push(clause);
368
- }
369
- must.push({
370
- key: 'file_path',
371
- match: { text: '.meta/meta.json' },
372
- });
373
- return { must };
374
- }
375
- /**
376
- * Discover all .meta/ directories via watcher scan.
377
- *
378
- * Queries the watcher for indexed .meta/meta.json points using the
379
- * configured domain filter. Returns deduplicated meta directory paths.
380
- *
381
- * @param config - Meta config (for domain filter).
382
- * @param watcher - WatcherClient for scan queries.
420
+ * @param watcher - WatcherClient for walk queries.
383
421
  * @returns Array of normalized .meta/ directory paths.
384
422
  */
385
- async function discoverMetas(config, watcher, logger) {
386
- const filter = buildMetaFilter(config);
387
- const scanFiles = await paginatedScan(watcher, { filter, fields: ['file_path'] }, logger);
423
+ async function discoverMetas(watcher) {
424
+ const allPaths = await watcher.walk(['**/.meta/meta.json']);
388
425
  // Deduplicate by .meta/ directory path (handles multi-chunk files)
389
426
  const seen = new Set();
390
427
  const metaPaths = [];
391
- for (const sf of scanFiles) {
392
- const fp = normalizePath(sf.file_path);
428
+ for (const filePath of allPaths) {
429
+ const fp = normalizePath(filePath);
393
430
  // Derive .meta/ directory from file_path (strip /meta.json)
394
431
  const metaPath = fp.replace(/\/meta\.json$/, '');
395
432
  if (seen.has(metaPath))
@@ -530,6 +567,25 @@ function cleanupStaleLocks(metaPaths, logger) {
530
567
  }
531
568
  }
532
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
+
533
589
  /**
534
590
  * Build the ownership tree from discovered .meta/ paths.
535
591
  *
@@ -625,9 +681,9 @@ const MAX_STALENESS_SECONDS$1 = 365 * 86_400;
625
681
  * @param watcher - Watcher HTTP client for discovery.
626
682
  * @returns Enriched meta list with summary statistics and ownership tree.
627
683
  */
628
- async function listMetas(config, watcher, logger) {
629
- // Step 1: Discover deduplicated meta paths via watcher scan
630
- 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);
631
687
  // Step 2: Build ownership tree
632
688
  const tree = buildOwnershipTree(metaPaths);
633
689
  // Step 3: Read and enrich each meta from disk
@@ -646,7 +702,7 @@ async function listMetas(config, watcher, logger) {
646
702
  for (const node of tree.nodes.values()) {
647
703
  let meta;
648
704
  try {
649
- meta = JSON.parse(readFileSync(join(node.metaPath, 'meta.json'), 'utf8'));
705
+ meta = readMetaJson(node.metaPath);
650
706
  }
651
707
  catch {
652
708
  // Skip unreadable metas
@@ -734,67 +790,50 @@ async function listMetas(config, watcher, logger) {
734
790
  }
735
791
 
736
792
  /**
737
- * Recursive filesystem walker for file enumeration.
793
+ * Filter file paths by modification time.
738
794
  *
739
- * Replaces paginated watcher scans for scope/delta/staleness checks.
740
- * 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.
741
797
  *
742
- * @module walkFiles
798
+ * @module mtimeFilter
743
799
  */
744
- /** Default directory names to always skip. */
745
- const DEFAULT_SKIP = new Set([
746
- 'node_modules',
747
- '.git',
748
- '.rollup.cache',
749
- 'dist',
750
- 'Thumbs.db',
751
- ]);
752
800
  /**
753
- * Recursively walk a directory and return all file paths.
801
+ * Check if any file in the list was modified after the given timestamp.
754
802
  *
755
- * @param root - Root directory to walk.
756
- * @param options - Walk options.
757
- * @returns Array of normalized file paths.
803
+ * Short-circuits on first match for efficiency (staleness checks).
804
+ *
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.
758
808
  */
759
- function walkFiles(root, options) {
760
- const exclude = new Set([...DEFAULT_SKIP, ...(options?.exclude ?? [])]);
761
- const modifiedAfter = options?.modifiedAfter;
762
- const maxDepth = options?.maxDepth ?? 50;
763
- const results = [];
764
- function walk(dir, depth) {
765
- if (depth > maxDepth)
766
- return;
767
- let entries;
809
+ function hasModifiedAfter(files, afterMs) {
810
+ for (const filePath of files) {
768
811
  try {
769
- entries = readdirSync(dir, { withFileTypes: true });
812
+ if (statSync(filePath).mtimeMs > afterMs)
813
+ return true;
770
814
  }
771
815
  catch {
772
- return; // Permission errors, missing dirs — skip
773
- }
774
- for (const entry of entries) {
775
- if (exclude.has(entry.name))
776
- continue;
777
- const fullPath = join(dir, entry.name);
778
- if (entry.isDirectory()) {
779
- walk(fullPath, depth + 1);
780
- }
781
- else if (entry.isFile()) {
782
- if (modifiedAfter !== undefined) {
783
- try {
784
- const stat = statSync(fullPath);
785
- if (Math.floor(stat.mtimeMs / 1000) <= modifiedAfter)
786
- continue;
787
- }
788
- catch {
789
- continue;
790
- }
791
- }
792
- results.push(normalizePath(fullPath));
793
- }
816
+ // Unreadable file — skip
794
817
  }
795
818
  }
796
- walk(root, 0);
797
- 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
+ });
798
837
  }
799
838
 
800
839
  /**
@@ -804,7 +843,7 @@ function walkFiles(root, options) {
804
843
  * - Its own .meta/ subtree (outputs, not inputs)
805
844
  * - Child meta ownerPath subtrees (except their .meta/meta.json for rollups)
806
845
  *
807
- * Uses filesystem walks instead of watcher scans for performance.
846
+ * All filesystem enumeration delegated to the watcher's `/walk` endpoint.
808
847
  *
809
848
  * @module discovery/scope
810
849
  */
@@ -821,7 +860,7 @@ function getScopePrefix(node) {
821
860
  * - The node's own .meta/ subtree (synthesis outputs are not scope inputs)
822
861
  * - Child meta ownerPath subtrees (except child .meta/meta.json for rollups)
823
862
  *
824
- * walkFiles already returns normalized forward-slash paths.
863
+ * Watcher walk returns normalized forward-slash paths.
825
864
  */
826
865
  function filterInScope(node, files) {
827
866
  const prefix = node.ownerPath + '/';
@@ -846,10 +885,10 @@ function filterInScope(node, files) {
846
885
  });
847
886
  }
848
887
  /**
849
- * 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.
850
889
  */
851
- function getScopeFiles(node) {
852
- const allFiles = walkFiles(node.ownerPath);
890
+ async function getScopeFiles(node, watcher) {
891
+ const allFiles = await watcher.walk([`${node.ownerPath}/**`]);
853
892
  return {
854
893
  scopeFiles: filterInScope(node, allFiles),
855
894
  allFiles,
@@ -859,13 +898,12 @@ function getScopeFiles(node) {
859
898
  * Get files modified since a given timestamp within a meta node's scope.
860
899
  *
861
900
  * If no generatedAt is provided (first run), returns all scope files.
901
+ * Reuses scope files from getScopeFiles() and filters locally by mtime.
862
902
  */
863
- function getDeltaFiles(node, generatedAt, scopeFiles) {
903
+ function getDeltaFiles(generatedAt, scopeFiles) {
864
904
  if (!generatedAt)
865
905
  return scopeFiles;
866
- const modifiedAfter = Math.floor(new Date(generatedAt).getTime() / 1000);
867
- const deltaFiles = walkFiles(node.ownerPath, { modifiedAfter });
868
- return filterInScope(node, deltaFiles);
906
+ return filterModifiedAfter(scopeFiles, new Date(generatedAt).getTime());
869
907
  }
870
908
 
871
909
  /**
@@ -962,7 +1000,7 @@ class GatewayExecutor {
962
1000
  this.gatewayUrl = (options.gatewayUrl ?? 'http://127.0.0.1:18789').replace(/\/+$/, '');
963
1001
  this.apiKey = options.apiKey;
964
1002
  this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
965
- this.workspaceDir = options.workspaceDir ?? 'J:\\jeeves\\jeeves-meta';
1003
+ this.workspaceDir = options.workspaceDir ?? join(tmpdir(), 'jeeves-meta');
966
1004
  }
967
1005
  /** Invoke a gateway tool via the /tools/invoke HTTP endpoint. */
968
1006
  async invoke(tool, args) {
@@ -1166,10 +1204,10 @@ function condenseScopeFiles(files, maxIndividual = 30) {
1166
1204
  * @param watcher - WatcherClient for scope enumeration.
1167
1205
  * @returns The computed context package.
1168
1206
  */
1169
- function buildContextPackage(node, meta) {
1170
- // Scope and delta files via watcher scan
1171
- const { scopeFiles } = getScopeFiles(node);
1172
- 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);
1173
1211
  // Child meta outputs
1174
1212
  const childMetas = {};
1175
1213
  for (const child of node.children) {
@@ -1493,6 +1531,67 @@ function mergeAndWrite(options) {
1493
1531
  return result.data;
1494
1532
  }
1495
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
+
1496
1595
  /**
1497
1596
  * Weighted staleness formula for candidate selection.
1498
1597
  *
@@ -1572,29 +1671,30 @@ function discoverStalestPath(candidates, depthWeight) {
1572
1671
  }
1573
1672
 
1574
1673
  /**
1575
- * Staleness detection via watcher scan.
1674
+ * Staleness detection via watcher walk.
1576
1675
  *
1577
- * 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`.
1578
1678
  *
1579
1679
  * @module scheduling/staleness
1580
1680
  */
1581
1681
  /**
1582
- * 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.
1583
1687
  *
1584
1688
  * @param scopePrefix - Path prefix for this meta's scope.
1585
1689
  * @param meta - Current meta.json content.
1586
1690
  * @param watcher - WatcherClient instance.
1587
- * @returns True if any file in scope was modified after _generatedAt.
1691
+ * @returns True if any file in scope was modified after `_generatedAt`.
1588
1692
  */
1589
- function isStale(scopePrefix, meta) {
1693
+ async function isStale(scopePrefix, meta, watcher) {
1590
1694
  if (!meta._generatedAt)
1591
1695
  return true; // Never synthesized = stale
1592
- const generatedAtUnix = Math.floor(new Date(meta._generatedAt).getTime() / 1000);
1593
- const modified = walkFiles(scopePrefix, {
1594
- modifiedAfter: generatedAtUnix,
1595
- maxDepth: 1,
1596
- });
1597
- return modified.length > 0;
1696
+ const files = await watcher.walk([`${scopePrefix}/**`]);
1697
+ return hasModifiedAfter(files, new Date(meta._generatedAt).getTime());
1598
1698
  }
1599
1699
  /** Maximum staleness for never-synthesized metas (1 year in seconds). */
1600
1700
  const MAX_STALENESS_SECONDS = 365 * 86_400;
@@ -1804,72 +1904,13 @@ function finalizeCycle(opts) {
1804
1904
  * @param watcher - Watcher HTTP client.
1805
1905
  * @returns Result indicating whether synthesis occurred.
1806
1906
  */
1807
- /**
1808
- * Build a minimal MetaNode from the filesystem for a known meta path.
1809
- * Discovers immediate child .meta/ dirs without a full watcher scan.
1810
- */
1811
- function buildMinimalNode(metaPath) {
1812
- const normalized = normalizePath(metaPath);
1813
- const ownerPath = normalizePath(dirname(metaPath));
1814
- // Find child .meta/ directories by scanning the owner directory
1815
- const children = [];
1816
- function findChildMetas(dir, depth) {
1817
- if (depth > 10)
1818
- return; // Safety limit
1819
- try {
1820
- const entries = readdirSync(dir, { withFileTypes: true });
1821
- for (const entry of entries) {
1822
- if (!entry.isDirectory())
1823
- continue;
1824
- const fullPath = normalizePath(join(dir, entry.name));
1825
- if (entry.name === '.meta' && fullPath !== normalized) {
1826
- // Found a child .meta — check it has meta.json
1827
- if (existsSync(join(fullPath, 'meta.json'))) {
1828
- children.push({
1829
- metaPath: fullPath,
1830
- ownerPath: normalizePath(dirname(fullPath)),
1831
- treeDepth: 1, // Relative to target
1832
- children: [],
1833
- parent: null, // Set below
1834
- });
1835
- }
1836
- // Don't recurse into .meta dirs
1837
- return;
1838
- }
1839
- if (entry.name === 'node_modules' ||
1840
- entry.name === '.git' ||
1841
- entry.name === 'archive')
1842
- continue;
1843
- findChildMetas(fullPath, depth + 1);
1844
- }
1845
- }
1846
- catch {
1847
- // Permission errors, etc — skip
1848
- }
1849
- }
1850
- findChildMetas(ownerPath, 0);
1851
- const node = {
1852
- metaPath: normalized,
1853
- ownerPath,
1854
- treeDepth: 0,
1855
- children,
1856
- parent: null,
1857
- };
1858
- // Wire parent references
1859
- for (const child of children) {
1860
- child.parent = node;
1861
- }
1862
- return node;
1863
- }
1864
1907
  /** Run the architect/builder/critic pipeline on a single node. */
1865
1908
  async function synthesizeNode(node, currentMeta, config, executor, watcher, onProgress) {
1866
- const architectPrompt = currentMeta._architect ?? config.defaultArchitect;
1867
- const criticPrompt = currentMeta._critic ?? config.defaultCritic;
1868
1909
  // Step 5-6: Steer change detection
1869
1910
  const latestArchive = readLatestArchive(node.metaPath);
1870
1911
  const steerChanged = hasSteerChanged(currentMeta._steer, latestArchive?._steer, Boolean(latestArchive));
1871
1912
  // Step 7: Compute context (includes scope files and delta files)
1872
- const ctx = buildContextPackage(node, currentMeta);
1913
+ const ctx = await buildContextPackage(node, currentMeta, watcher);
1873
1914
  // Step 5 (deferred): Structure hash from context scope files
1874
1915
  const newStructureHash = computeStructureHash(ctx.scopeFiles);
1875
1916
  const structureChanged = newStructureHash !== currentMeta._structureHash;
@@ -1913,9 +1954,9 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
1913
1954
  metaPath: node.metaPath,
1914
1955
  current: currentMeta,
1915
1956
  config,
1916
- architect: architectPrompt,
1957
+ architect: currentMeta._architect ?? '',
1917
1958
  builder: '',
1918
- critic: criticPrompt,
1959
+ critic: currentMeta._critic ?? '',
1919
1960
  builderOutput: null,
1920
1961
  feedback: null,
1921
1962
  structureHash: newStructureHash,
@@ -1999,9 +2040,9 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
1999
2040
  metaPath: node.metaPath,
2000
2041
  current: currentMeta,
2001
2042
  config,
2002
- architect: architectPrompt,
2043
+ architect: currentMeta._architect ?? '',
2003
2044
  builder: builderBrief,
2004
- critic: criticPrompt,
2045
+ critic: currentMeta._critic ?? '',
2005
2046
  builderOutput,
2006
2047
  feedback,
2007
2048
  structureHash: newStructureHash,
@@ -2025,26 +2066,25 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
2025
2066
  const targetMetaJson = join(normalizedTarget, 'meta.json');
2026
2067
  if (!existsSync(targetMetaJson))
2027
2068
  return { synthesized: false };
2028
- const node = buildMinimalNode(normalizedTarget);
2069
+ const node = await buildMinimalNode(normalizedTarget, watcher);
2029
2070
  if (!acquireLock(node.metaPath))
2030
2071
  return { synthesized: false };
2031
2072
  try {
2032
- const currentMeta = JSON.parse(readFileSync(targetMetaJson, 'utf8'));
2073
+ const currentMeta = readMetaJson(normalizedTarget);
2033
2074
  return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress);
2034
2075
  }
2035
2076
  finally {
2036
2077
  releaseLock(node.metaPath);
2037
2078
  }
2038
2079
  }
2039
- const metaPaths = await discoverMetas(config, watcher, logger);
2080
+ const metaPaths = await discoverMetas(watcher);
2040
2081
  if (metaPaths.length === 0)
2041
2082
  return { synthesized: false };
2042
2083
  // Read meta.json for each discovered meta
2043
2084
  const metas = new Map();
2044
2085
  for (const mp of metaPaths) {
2045
- const metaFilePath = join(mp, 'meta.json');
2046
2086
  try {
2047
- metas.set(normalizePath(mp), JSON.parse(readFileSync(metaFilePath, 'utf8')));
2087
+ metas.set(normalizePath(mp), readMetaJson(mp));
2048
2088
  }
2049
2089
  catch {
2050
2090
  // Skip metas with unreadable meta.json
@@ -2077,13 +2117,12 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
2077
2117
  for (const candidate of ranked) {
2078
2118
  if (!acquireLock(candidate.node.metaPath))
2079
2119
  continue;
2080
- const verifiedStale = isStale(getScopePrefix(candidate.node), candidate.meta);
2120
+ const verifiedStale = await isStale(getScopePrefix(candidate.node), candidate.meta, watcher);
2081
2121
  if (!verifiedStale && candidate.meta._generatedAt) {
2082
2122
  // Bump _generatedAt so it doesn't win next cycle
2083
- const metaFilePath = join(candidate.node.metaPath, 'meta.json');
2084
- const freshMeta = JSON.parse(readFileSync(metaFilePath, 'utf8'));
2123
+ const freshMeta = readMetaJson(candidate.node.metaPath);
2085
2124
  freshMeta._generatedAt = new Date().toISOString();
2086
- writeFileSync(metaFilePath, JSON.stringify(freshMeta, null, 2));
2125
+ writeFileSync(join(candidate.node.metaPath, 'meta.json'), JSON.stringify(freshMeta, null, 2));
2087
2126
  releaseLock(candidate.node.metaPath);
2088
2127
  if (config.skipUnchanged)
2089
2128
  continue;
@@ -2096,7 +2135,7 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
2096
2135
  return { synthesized: false };
2097
2136
  const node = winner.node;
2098
2137
  try {
2099
- const currentMeta = JSON.parse(readFileSync(join(node.metaPath, 'meta.json'), 'utf8'));
2138
+ const currentMeta = readMetaJson(node.metaPath);
2100
2139
  return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress);
2101
2140
  }
2102
2141
  finally {
@@ -2117,7 +2156,7 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
2117
2156
  * @returns Array with a single result.
2118
2157
  */
2119
2158
  async function orchestrate(config, executor, watcher, targetPath, onProgress, logger) {
2120
- const result = await orchestrateOnce(config, executor, watcher, targetPath, onProgress, logger);
2159
+ const result = await orchestrateOnce(config, executor, watcher, targetPath, onProgress);
2121
2160
  return [result];
2122
2161
  }
2123
2162
 
@@ -2340,7 +2379,7 @@ class Scheduler {
2340
2379
  */
2341
2380
  async discoverStalest() {
2342
2381
  try {
2343
- const result = await listMetas(this.config, this.watcher, this.logger);
2382
+ const result = await listMetas(this.config, this.watcher);
2344
2383
  const stale = result.entries
2345
2384
  .filter((e) => e.stalenessSeconds > 0)
2346
2385
  .map((e) => ({
@@ -2626,7 +2665,7 @@ function registerMetasRoutes(app, deps) {
2626
2665
  app.get('/metas', async (request) => {
2627
2666
  const query = metasQuerySchema.parse(request.query);
2628
2667
  const { config, watcher } = deps;
2629
- const result = await listMetas(config, watcher, request.log);
2668
+ const result = await listMetas(config, watcher);
2630
2669
  let entries = result.entries;
2631
2670
  // Apply filters
2632
2671
  if (query.pathPrefix) {
@@ -2689,7 +2728,7 @@ function registerMetasRoutes(app, deps) {
2689
2728
  const query = metaDetailQuerySchema.parse(request.query);
2690
2729
  const { config, watcher } = deps;
2691
2730
  const targetPath = normalizePath(decodeURIComponent(request.params.path));
2692
- const result = await listMetas(config, watcher, request.log);
2731
+ const result = await listMetas(config, watcher);
2693
2732
  const targetNode = findNode(result.tree, targetPath);
2694
2733
  if (!targetNode) {
2695
2734
  return reply.status(404).send({
@@ -2722,7 +2761,7 @@ function registerMetasRoutes(app, deps) {
2722
2761
  return r;
2723
2762
  };
2724
2763
  // Compute scope
2725
- const { scopeFiles, allFiles } = getScopeFiles(targetNode);
2764
+ const { scopeFiles, allFiles } = await getScopeFiles(targetNode, watcher);
2726
2765
  // Compute staleness
2727
2766
  const metaTyped = meta;
2728
2767
  const staleSeconds = metaTyped._generatedAt
@@ -2769,7 +2808,7 @@ function registerPreviewRoute(app, deps) {
2769
2808
  const query = request.query;
2770
2809
  let result;
2771
2810
  try {
2772
- result = await listMetas(config, watcher, request.log);
2811
+ result = await listMetas(config, watcher);
2773
2812
  }
2774
2813
  catch {
2775
2814
  return reply.status(503).send({
@@ -2803,16 +2842,16 @@ function registerPreviewRoute(app, deps) {
2803
2842
  }
2804
2843
  targetNode = findNode(result.tree, stalestPath);
2805
2844
  }
2806
- const meta = JSON.parse(readFileSync(join(targetNode.metaPath, 'meta.json'), 'utf8'));
2845
+ const meta = readMetaJson(targetNode.metaPath);
2807
2846
  // Scope files
2808
- const { scopeFiles } = getScopeFiles(targetNode);
2847
+ const { scopeFiles } = await getScopeFiles(targetNode, watcher);
2809
2848
  const structureHash = computeStructureHash(scopeFiles);
2810
2849
  const structureChanged = structureHash !== meta._structureHash;
2811
2850
  const latestArchive = readLatestArchive(targetNode.metaPath);
2812
2851
  const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
2813
2852
  const architectTriggered = isArchitectTriggered(meta, structureChanged, steerChanged, config.architectEvery);
2814
2853
  // Delta files
2815
- const deltaFiles = getDeltaFiles(targetNode, meta._generatedAt, scopeFiles);
2854
+ const deltaFiles = getDeltaFiles(meta._generatedAt, scopeFiles);
2816
2855
  // EMA token estimates
2817
2856
  const estimatedTokens = {
2818
2857
  architect: meta._architectTokensAvg ?? meta._architectTokens ?? 0,
@@ -2927,11 +2966,11 @@ function registerStatusRoute(app, deps) {
2927
2966
  else {
2928
2967
  status = 'idle';
2929
2968
  }
2930
- // Metas summary is expensive (paginated watcher scan + disk reads).
2969
+ // Metas summary is expensive (watcher walk + disk reads).
2931
2970
  // Use GET /metas for full inventory; status is a lightweight health check.
2932
2971
  return {
2933
- service: 'jeeves-meta',
2934
- version: '0.4.0',
2972
+ service: SERVICE_NAME,
2973
+ version: SERVICE_VERSION,
2935
2974
  uptime: process.uptime(),
2936
2975
  status,
2937
2976
  currentTarget: queue.current?.path ?? null,
@@ -2948,7 +2987,10 @@ function registerStatusRoute(app, deps) {
2948
2987
  nextAt: scheduler?.nextRunAt?.toISOString() ?? null,
2949
2988
  },
2950
2989
  dependencies: {
2951
- watcher: watcherHealth,
2990
+ watcher: {
2991
+ ...watcherHealth,
2992
+ rulesRegistered: deps.registrar?.isRegistered ?? false,
2993
+ },
2952
2994
  gateway: gatewayHealth,
2953
2995
  },
2954
2996
  };
@@ -2976,7 +3018,7 @@ function registerSynthesizeRoute(app, deps) {
2976
3018
  // Discover stalest candidate
2977
3019
  let result;
2978
3020
  try {
2979
- result = await listMetas(config, watcher, request.log);
3021
+ result = await listMetas(config, watcher);
2980
3022
  }
2981
3023
  catch {
2982
3024
  return reply.status(503).send({
@@ -3153,7 +3195,15 @@ function buildMetaRules(config) {
3153
3195
  },
3154
3196
  ],
3155
3197
  render: {
3156
- 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
+ ],
3157
3207
  body: [{ path: 'json._content', heading: 1, label: 'Synthesis' }],
3158
3208
  },
3159
3209
  renderAs: 'md',
@@ -3345,6 +3395,8 @@ function registerShutdownHandlers(deps) {
3345
3395
  if (deps.routeDeps) {
3346
3396
  deps.routeDeps.shuttingDown = true;
3347
3397
  }
3398
+ // 0. Run optional cleanup
3399
+ deps.onShutdown?.();
3348
3400
  // 1. Stop scheduler
3349
3401
  if (deps.scheduler) {
3350
3402
  deps.scheduler.stop();
@@ -3378,7 +3430,7 @@ function registerShutdownHandlers(deps) {
3378
3430
  /**
3379
3431
  * HTTP implementation of the WatcherClient interface.
3380
3432
  *
3381
- * Talks to jeeves-watcher's POST /scan and POST /rules endpoints
3433
+ * Talks to jeeves-watcher's POST /walk and POST /rules/register endpoints
3382
3434
  * with retry and exponential backoff.
3383
3435
  *
3384
3436
  * @module watcher-client/HttpWatcherClient
@@ -3432,61 +3484,80 @@ class HttpWatcherClient {
3432
3484
  // Unreachable, but TypeScript needs it
3433
3485
  throw new Error('Retry exhausted');
3434
3486
  }
3435
- async scan(params) {
3436
- // Build Qdrant filter: merge explicit filter with pathPrefix/modifiedAfter
3437
- const mustClauses = [];
3438
- // Carry over any existing 'must' clauses from the provided filter
3439
- if (params.filter) {
3440
- const existing = params.filter.must;
3441
- if (Array.isArray(existing)) {
3442
- mustClauses.push(...existing);
3443
- }
3444
- }
3445
- // Translate pathPrefix into a Qdrant text match on file_path
3446
- if (params.pathPrefix !== undefined) {
3447
- mustClauses.push({
3448
- key: 'file_path',
3449
- match: { text: params.pathPrefix },
3450
- });
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;
3451
3528
  }
3452
- // Translate modifiedAfter into a Qdrant range filter on modified_at
3453
- if (params.modifiedAfter !== undefined) {
3454
- mustClauses.push({
3455
- key: 'modified_at',
3456
- range: { gt: params.modifiedAfter },
3457
- });
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();
3458
3535
  }
3459
- const filter = { must: mustClauses };
3460
- const body = { filter };
3461
- if (params.fields !== undefined) {
3462
- 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;
3463
3543
  }
3464
- if (params.limit !== undefined) {
3465
- 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);
3466
3557
  }
3467
- if (params.cursor !== undefined) {
3468
- body.cursor = params.cursor;
3558
+ catch (err) {
3559
+ this.logger.debug({ err }, 'Watcher health check: unreachable (expected during startup)');
3469
3560
  }
3470
- const raw = (await this.post('/scan', body));
3471
- // jeeves-watcher returns { points, cursor }; map to ScanResponse.
3472
- const points = (raw.points ?? raw.files ?? []);
3473
- const next = (raw.cursor ?? raw.next);
3474
- const files = points.map((p) => {
3475
- const payload = (p.payload ?? p);
3476
- return {
3477
- file_path: (payload.file_path ?? payload.path ?? ''),
3478
- modified_at: (payload.modified_at ?? payload.mtime ?? 0),
3479
- content_hash: (payload.content_hash ?? ''),
3480
- ...payload,
3481
- };
3482
- });
3483
- return { files, next: next ?? undefined };
3484
- }
3485
- async registerRules(source, rules) {
3486
- await this.post('/rules/register', { source, rules });
3487
- }
3488
- async unregisterRules(source) {
3489
- await this.post('/rules/unregister', { source });
3490
3561
  }
3491
3562
  }
3492
3563
 
@@ -3626,7 +3697,16 @@ async function startService(config, configPath) {
3626
3697
  // Rule registration (fire-and-forget with retries)
3627
3698
  const registrar = new RuleRegistrar(config, logger, watcher);
3628
3699
  scheduler.setRegistrar(registrar);
3700
+ routeDeps.registrar = registrar;
3629
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();
3630
3710
  // Config hot-reload (gap #12)
3631
3711
  if (configPath) {
3632
3712
  watchFile(configPath, { interval: 5000 }, () => {
@@ -3660,6 +3740,9 @@ async function startService(config, configPath) {
3660
3740
  queue,
3661
3741
  logger,
3662
3742
  routeDeps,
3743
+ onShutdown: () => {
3744
+ healthCheck.stop();
3745
+ },
3663
3746
  });
3664
3747
  logger.info('Service fully initialized');
3665
3748
  }
@@ -3670,7 +3753,7 @@ async function startService(config, configPath) {
3670
3753
  * @module cli
3671
3754
  */
3672
3755
  const program = new Command();
3673
- program.name('jeeves-meta').description('Jeeves Meta synthesis service');
3756
+ program.name(SERVICE_NAME).description('Jeeves Meta synthesis service');
3674
3757
  // ─── start ──────────────────────────────────────────────────────────
3675
3758
  program
3676
3759
  .command('start')
@@ -3709,7 +3792,7 @@ async function apiPost(port, path, body) {
3709
3792
  program
3710
3793
  .command('status')
3711
3794
  .description('Show service status')
3712
- .option('-p, --port <port>', 'Service port', '1938')
3795
+ .option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
3713
3796
  .action(async (opts) => {
3714
3797
  try {
3715
3798
  const data = await apiGet(parseInt(opts.port, 10), '/status');
@@ -3724,7 +3807,7 @@ program
3724
3807
  program
3725
3808
  .command('list')
3726
3809
  .description('List all discovered meta entities')
3727
- .option('-p, --port <port>', 'Service port', '1938')
3810
+ .option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
3728
3811
  .action(async (opts) => {
3729
3812
  try {
3730
3813
  const data = await apiGet(parseInt(opts.port, 10), '/metas');
@@ -3739,7 +3822,7 @@ program
3739
3822
  program
3740
3823
  .command('detail <path>')
3741
3824
  .description('Show full detail for a single meta entity')
3742
- .option('-p, --port <port>', 'Service port', '1938')
3825
+ .option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
3743
3826
  .action(async (metaPath, opts) => {
3744
3827
  try {
3745
3828
  const encoded = encodeURIComponent(metaPath);
@@ -3755,7 +3838,7 @@ program
3755
3838
  program
3756
3839
  .command('preview')
3757
3840
  .description('Dry-run: preview inputs for next synthesis cycle')
3758
- .option('-p, --port <port>', 'Service port', '1938')
3841
+ .option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
3759
3842
  .option('--path <path>', 'Specific meta path to preview')
3760
3843
  .action(async (opts) => {
3761
3844
  try {
@@ -3772,7 +3855,7 @@ program
3772
3855
  program
3773
3856
  .command('synthesize')
3774
3857
  .description('Trigger synthesis (enqueues work)')
3775
- .option('-p, --port <port>', 'Service port', '1938')
3858
+ .option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
3776
3859
  .option('--path <path>', 'Specific meta path to synthesize')
3777
3860
  .action(async (opts) => {
3778
3861
  try {
@@ -3789,7 +3872,7 @@ program
3789
3872
  program
3790
3873
  .command('seed <path>')
3791
3874
  .description('Create .meta/ directory + meta.json for a path')
3792
- .option('-p, --port <port>', 'Service port', '1938')
3875
+ .option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
3793
3876
  .action(async (metaPath, opts) => {
3794
3877
  try {
3795
3878
  const data = await apiPost(parseInt(opts.port, 10), '/seed', {
@@ -3806,7 +3889,7 @@ program
3806
3889
  program
3807
3890
  .command('unlock <path>')
3808
3891
  .description('Remove .lock file from a meta entity')
3809
- .option('-p, --port <port>', 'Service port', '1938')
3892
+ .option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
3810
3893
  .action(async (metaPath, opts) => {
3811
3894
  try {
3812
3895
  const data = await apiPost(parseInt(opts.port, 10), '/unlock', {
@@ -3823,7 +3906,7 @@ program
3823
3906
  program
3824
3907
  .command('validate')
3825
3908
  .description('Validate current or candidate config')
3826
- .option('-p, --port <port>', 'Service port', '1938')
3909
+ .option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
3827
3910
  .option('-c, --config <path>', 'Validate a candidate config file locally')
3828
3911
  .action(async (opts) => {
3829
3912
  try {
@@ -3856,7 +3939,7 @@ const service = program
3856
3939
  service.addCommand(new Command('install')
3857
3940
  .description('Print install instructions for a system service')
3858
3941
  .option('-c, --config <path>', 'Path to configuration file')
3859
- .option('-n, --name <name>', 'Service name', 'JeevesMeta')
3942
+ .option('-n, --name <name>', 'Service name', 'jeeves-meta')
3860
3943
  .action((options) => {
3861
3944
  const { name } = options;
3862
3945
  const configFlag = options.config ? ` -c "${options.config}"` : '';
@@ -3921,7 +4004,7 @@ service.addCommand(new Command('install')
3921
4004
  // start command (prints OS-specific start instructions)
3922
4005
  service.addCommand(new Command('start')
3923
4006
  .description('Print start instructions for the installed service')
3924
- .option('-n, --name <name>', 'Service name', 'JeevesMeta')
4007
+ .option('-n, --name <name>', 'Service name', 'jeeves-meta')
3925
4008
  .action((options) => {
3926
4009
  const { name } = options;
3927
4010
  if (process.platform === 'win32') {
@@ -3940,7 +4023,7 @@ service.addCommand(new Command('start')
3940
4023
  // stop command
3941
4024
  service.addCommand(new Command('stop')
3942
4025
  .description('Stop the running service')
3943
- .option('-n, --name <name>', 'Service name', 'JeevesMeta')
4026
+ .option('-n, --name <name>', 'Service name', 'jeeves-meta')
3944
4027
  .action((options) => {
3945
4028
  const { name } = options;
3946
4029
  if (process.platform === 'win32') {
@@ -3959,7 +4042,7 @@ service.addCommand(new Command('stop')
3959
4042
  // status command (service subcommand — queries HTTP API)
3960
4043
  service.addCommand(new Command('status')
3961
4044
  .description('Show service status via HTTP API')
3962
- .option('-p, --port <port>', 'Service port', '1938')
4045
+ .option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
3963
4046
  .action(async (opts) => {
3964
4047
  try {
3965
4048
  const data = await apiGet(parseInt(opts.port, 10), '/status');
@@ -3972,7 +4055,7 @@ service.addCommand(new Command('status')
3972
4055
  }));
3973
4056
  service.addCommand(new Command('remove')
3974
4057
  .description('Print remove instructions for a system service')
3975
- .option('-n, --name <name>', 'Service name', 'JeevesMeta')
4058
+ .option('-n, --name <name>', 'Service name', 'jeeves-meta')
3976
4059
  .action((options) => {
3977
4060
  const { name } = options;
3978
4061
  if (process.platform === 'win32') {