@karmaniverous/jeeves-meta 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/dist/cli.js +628 -497
  2. package/dist/index.d.ts +306 -262
  3. package/dist/index.js +464 -341
  4. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync, readdirSync, unlinkSync, mkdirSync, writeFileSync, existsSync, statSync } from 'node:fs';
2
+ import { readFileSync, readdirSync, unlinkSync, mkdirSync, writeFileSync, existsSync } from 'node:fs';
3
3
  import { dirname, join, relative, sep, resolve } from 'node:path';
4
4
  import { z } from 'zod';
5
- import { randomUUID, createHash } from 'node:crypto';
5
+ import { createHash } from 'node:crypto';
6
6
 
7
7
  /**
8
8
  * Zod schema for jeeves-meta configuration.
@@ -15,7 +15,6 @@ import { randomUUID, createHash } from 'node:crypto';
15
15
  /** Zod schema for jeeves-meta configuration. */
16
16
  const synthConfigSchema = z.object({
17
17
  /** Filesystem paths to watch for .meta/ directories. */
18
- watchPaths: z.array(z.string()).min(1),
19
18
  /** Watcher service base URL. */
20
19
  watcherUrl: z.url(),
21
20
  /** OpenClaw gateway base URL for subprocess spawning. */
@@ -48,6 +47,21 @@ const synthConfigSchema = z.object({
48
47
  skipUnchanged: z.boolean().default(true),
49
48
  /** Number of metas to synthesize per invocation. */
50
49
  batchSize: z.number().int().min(1).default(1),
50
+ /**
51
+ * Watcher metadata properties for live .meta/meta.json files.
52
+ * Virtual rules use these to tag live metas; scan queries derive
53
+ * their filter from the first domain value.
54
+ */
55
+ metaProperty: z
56
+ .object({ domains: z.array(z.string()).min(1) })
57
+ .default({ domains: ['meta'] }),
58
+ /**
59
+ * Watcher metadata properties for .meta/archive/** snapshots.
60
+ * Virtual rules use these to tag archive files.
61
+ */
62
+ metaArchiveProperty: z
63
+ .object({ domains: z.array(z.string()).min(1) })
64
+ .default({ domains: ['meta-archive'] }),
51
65
  });
52
66
 
53
67
  /**
@@ -310,81 +324,174 @@ var index$1 = /*#__PURE__*/Object.freeze({
310
324
  });
311
325
 
312
326
  /**
313
- * Ensure meta.json exists in each .meta/ directory.
327
+ * Normalize file paths to forward slashes for consistency with watcher-indexed paths.
314
328
  *
315
- * If meta.json is missing, creates it with a generated UUID.
329
+ * Watcher indexes paths with forward slashes (`j:/domains/...`). This utility
330
+ * ensures all paths in the library use the same convention, regardless of
331
+ * the platform's native separator.
316
332
  *
317
- * @module discovery/ensureMetaJson
333
+ * @module normalizePath
318
334
  */
319
335
  /**
320
- * Ensure meta.json exists at the given .meta/ path.
336
+ * Normalize a file path to forward slashes.
321
337
  *
322
- * @param metaPath - Absolute path to a .meta/ directory.
323
- * @returns The meta.json content (existing or newly created).
338
+ * @param p - File path (may contain backslashes).
339
+ * @returns Path with all backslashes replaced by forward slashes.
324
340
  */
325
- function ensureMetaJson(metaPath) {
326
- const filePath = join(metaPath, 'meta.json');
327
- if (existsSync(filePath)) {
328
- const raw = readFileSync(filePath, 'utf8');
329
- return JSON.parse(raw);
330
- }
331
- // Create the archive subdirectory while we're at it
332
- const archivePath = join(metaPath, 'archive');
333
- if (!existsSync(archivePath)) {
334
- mkdirSync(archivePath, { recursive: true });
341
+ function normalizePath$1(p) {
342
+ return p.replaceAll('\\', '/');
343
+ }
344
+
345
+ /**
346
+ * Paginated scan helper for exhaustive scope enumeration.
347
+ *
348
+ * @module paginatedScan
349
+ */
350
+ /**
351
+ * Perform a paginated scan that follows cursor tokens until exhausted.
352
+ *
353
+ * @param watcher - WatcherClient instance.
354
+ * @param params - Base scan parameters (cursor is managed internally).
355
+ * @returns All matching files across all pages.
356
+ */
357
+ async function paginatedScan(watcher, params) {
358
+ const allFiles = [];
359
+ let cursor;
360
+ do {
361
+ const result = await watcher.scan({ ...params, cursor });
362
+ allFiles.push(...result.files);
363
+ cursor = result.next;
364
+ } while (cursor);
365
+ return allFiles;
366
+ }
367
+
368
+ /**
369
+ * Discover .meta/ directories via watcher scan.
370
+ *
371
+ * Replaces filesystem-based globMetas() with a watcher query
372
+ * that returns indexed .meta/meta.json points, filtered by domain.
373
+ *
374
+ * @module discovery/discoverMetas
375
+ */
376
+ /**
377
+ * Build a Qdrant filter from config metaProperty.
378
+ *
379
+ * @param config - Synth config with metaProperty.
380
+ * @returns Qdrant filter object for scanning live metas.
381
+ */
382
+ function buildMetaFilter(config) {
383
+ return {
384
+ must: [
385
+ {
386
+ key: 'domains',
387
+ match: { value: config.metaProperty.domains[0] },
388
+ },
389
+ ],
390
+ };
391
+ }
392
+ /**
393
+ * Discover all .meta/ directories via watcher scan.
394
+ *
395
+ * Queries the watcher for indexed .meta/meta.json points using the
396
+ * configured domain filter. Returns deduplicated meta directory paths.
397
+ *
398
+ * @param config - Synth config (for domain filter).
399
+ * @param watcher - WatcherClient for scan queries.
400
+ * @returns Array of normalized .meta/ directory paths.
401
+ */
402
+ async function discoverMetas(config, watcher) {
403
+ const filter = buildMetaFilter(config);
404
+ const scanFiles = await paginatedScan(watcher, {
405
+ filter,
406
+ fields: ['file_path'],
407
+ });
408
+ // Deduplicate by file_path (multi-chunk files)
409
+ const seen = new Set();
410
+ const metaPaths = [];
411
+ for (const sf of scanFiles) {
412
+ const fp = normalizePath$1(sf.file_path);
413
+ if (seen.has(fp))
414
+ continue;
415
+ seen.add(fp);
416
+ // Derive .meta/ directory from file_path (strip /meta.json)
417
+ const metaPath = fp.replace(/\/meta\.json$/, '');
418
+ metaPaths.push(metaPath);
335
419
  }
336
- const meta = { _id: randomUUID() };
337
- writeFileSync(filePath, JSON.stringify(meta, null, 2) + '\n');
338
- return meta;
420
+ return metaPaths;
339
421
  }
340
422
 
341
423
  /**
342
- * Glob watchPaths for .meta/ directories.
424
+ * File-system lock for preventing concurrent synthesis on the same meta.
343
425
  *
344
- * Walks each watchPath recursively, collecting directories named '.meta'
345
- * that contain (or will contain) a meta.json file.
426
+ * Lock file: .meta/.lock containing PID + timestamp.
427
+ * Stale timeout: 30 minutes.
346
428
  *
347
- * @module discovery/globMetas
429
+ * @module lock
348
430
  */
431
+ const LOCK_FILE = '.lock';
432
+ const STALE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
349
433
  /**
350
- * Recursively find all .meta/ directories under the given paths.
434
+ * Attempt to acquire a lock on a .meta directory.
351
435
  *
352
- * @param watchPaths - Root directories to search.
353
- * @returns Array of absolute paths to .meta/ directories.
436
+ * @param metaPath - Absolute path to the .meta directory.
437
+ * @returns True if lock was acquired, false if already locked (non-stale).
354
438
  */
355
- function globMetas(watchPaths) {
356
- const results = [];
357
- function walk(dir) {
358
- let entries;
439
+ function acquireLock(metaPath) {
440
+ const lockPath = join(metaPath, LOCK_FILE);
441
+ if (existsSync(lockPath)) {
359
442
  try {
360
- entries = readdirSync(dir);
443
+ const raw = readFileSync(lockPath, 'utf8');
444
+ const data = JSON.parse(raw);
445
+ const lockAge = Date.now() - new Date(data.startedAt).getTime();
446
+ if (lockAge < STALE_TIMEOUT_MS) {
447
+ return false; // Lock is active
448
+ }
449
+ // Stale lock — fall through to overwrite
361
450
  }
362
451
  catch {
363
- return; // Skip unreadable directories
364
- }
365
- for (const entry of entries) {
366
- const full = join(dir, entry);
367
- let stat;
368
- try {
369
- stat = statSync(full);
370
- }
371
- catch {
372
- continue;
373
- }
374
- if (!stat.isDirectory())
375
- continue;
376
- if (entry === '.meta') {
377
- results.push(full);
378
- }
379
- else {
380
- walk(full);
381
- }
452
+ // Corrupt lock file — overwrite
382
453
  }
383
454
  }
384
- for (const wp of watchPaths) {
385
- walk(wp);
455
+ const lock = {
456
+ pid: process.pid,
457
+ startedAt: new Date().toISOString(),
458
+ };
459
+ writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n');
460
+ return true;
461
+ }
462
+ /**
463
+ * Release a lock on a .meta directory.
464
+ *
465
+ * @param metaPath - Absolute path to the .meta directory.
466
+ */
467
+ function releaseLock(metaPath) {
468
+ const lockPath = join(metaPath, LOCK_FILE);
469
+ try {
470
+ unlinkSync(lockPath);
471
+ }
472
+ catch {
473
+ // Already removed or never existed
474
+ }
475
+ }
476
+ /**
477
+ * Check if a .meta directory is currently locked (non-stale).
478
+ *
479
+ * @param metaPath - Absolute path to the .meta directory.
480
+ * @returns True if locked and not stale.
481
+ */
482
+ function isLocked(metaPath) {
483
+ const lockPath = join(metaPath, LOCK_FILE);
484
+ if (!existsSync(lockPath))
485
+ return false;
486
+ try {
487
+ const raw = readFileSync(lockPath, 'utf8');
488
+ const data = JSON.parse(raw);
489
+ const lockAge = Date.now() - new Date(data.startedAt).getTime();
490
+ return lockAge < STALE_TIMEOUT_MS;
491
+ }
492
+ catch {
493
+ return false; // Corrupt lock = not locked
386
494
  }
387
- return results;
388
495
  }
389
496
 
390
497
  /**
@@ -397,7 +504,7 @@ function globMetas(watchPaths) {
397
504
  * @module discovery/ownershipTree
398
505
  */
399
506
  /** Normalize path separators to forward slashes for consistent comparison. */
400
- function normalizePath$1(p) {
507
+ function normalizePath(p) {
401
508
  return p.split(sep).join('/');
402
509
  }
403
510
  /**
@@ -411,8 +518,8 @@ function buildOwnershipTree(metaPaths) {
411
518
  // Create nodes, sorted by ownerPath length (shortest first = shallowest)
412
519
  const sorted = [...metaPaths]
413
520
  .map((mp) => ({
414
- metaPath: normalizePath$1(mp),
415
- ownerPath: normalizePath$1(dirname(mp)),
521
+ metaPath: normalizePath(mp),
522
+ ownerPath: normalizePath(dirname(mp)),
416
523
  }))
417
524
  .sort((a, b) => a.ownerPath.length - b.ownerPath.length);
418
525
  for (const { metaPath, ownerPath } of sorted) {
@@ -464,6 +571,138 @@ function findNode(tree, targetPath) {
464
571
  return Array.from(tree.nodes.values()).find((n) => n.metaPath === targetPath || n.ownerPath === targetPath);
465
572
  }
466
573
 
574
+ /**
575
+ * Unified meta listing: scan, dedup, enrich.
576
+ *
577
+ * Single source of truth for all consumers that need a list of metas
578
+ * with enriched metadata. Replaces duplicated scan+dedup logic in
579
+ * plugin tools, CLI, and prompt injection.
580
+ *
581
+ * @module discovery/listMetas
582
+ */
583
+ /**
584
+ * Discover, deduplicate, and enrich all metas.
585
+ *
586
+ * This is the single consolidated function that replaces all duplicated
587
+ * scan+dedup+enrich logic across the codebase. All enrichment comes from
588
+ * reading meta.json on disk (the canonical source).
589
+ *
590
+ * @param config - Validated synthesis config.
591
+ * @param watcher - Watcher HTTP client for discovery.
592
+ * @returns Enriched meta list with summary statistics and ownership tree.
593
+ */
594
+ async function listMetas(config, watcher) {
595
+ // Step 1: Discover deduplicated meta paths via watcher scan
596
+ const metaPaths = await discoverMetas(config, watcher);
597
+ // Step 2: Build ownership tree
598
+ const tree = buildOwnershipTree(metaPaths);
599
+ // Step 3: Read and enrich each meta from disk
600
+ const entries = [];
601
+ let staleCount = 0;
602
+ let errorCount = 0;
603
+ let lockedCount = 0;
604
+ let neverSynthesizedCount = 0;
605
+ let totalArchTokens = 0;
606
+ let totalBuilderTokens = 0;
607
+ let totalCriticTokens = 0;
608
+ let lastSynthPath = null;
609
+ let lastSynthAt = null;
610
+ let stalestPath = null;
611
+ let stalestEffective = -1;
612
+ for (const node of tree.nodes.values()) {
613
+ let meta;
614
+ try {
615
+ meta = JSON.parse(readFileSync(join(node.metaPath, 'meta.json'), 'utf8'));
616
+ }
617
+ catch {
618
+ // Skip unreadable metas
619
+ continue;
620
+ }
621
+ const depth = meta._depth ?? node.treeDepth;
622
+ const emphasis = meta._emphasis ?? 1;
623
+ const hasError = Boolean(meta._error);
624
+ const locked = isLocked(normalizePath$1(node.metaPath));
625
+ const neverSynth = !meta._generatedAt;
626
+ // Compute staleness
627
+ let stalenessSeconds;
628
+ if (neverSynth) {
629
+ stalenessSeconds = Infinity;
630
+ }
631
+ else {
632
+ const genAt = new Date(meta._generatedAt).getTime();
633
+ stalenessSeconds = Math.max(0, Math.floor((Date.now() - genAt) / 1000));
634
+ }
635
+ // Tokens
636
+ const archTokens = meta._architectTokens ?? 0;
637
+ const buildTokens = meta._builderTokens ?? 0;
638
+ const critTokens = meta._criticTokens ?? 0;
639
+ // Accumulate summary stats
640
+ if (stalenessSeconds > 0)
641
+ staleCount++;
642
+ if (hasError)
643
+ errorCount++;
644
+ if (locked)
645
+ lockedCount++;
646
+ if (neverSynth)
647
+ neverSynthesizedCount++;
648
+ totalArchTokens += archTokens;
649
+ totalBuilderTokens += buildTokens;
650
+ totalCriticTokens += critTokens;
651
+ // Track last synthesized
652
+ if (meta._generatedAt) {
653
+ if (!lastSynthAt || meta._generatedAt > lastSynthAt) {
654
+ lastSynthAt = meta._generatedAt;
655
+ lastSynthPath = node.metaPath;
656
+ }
657
+ }
658
+ // Track stalest (effective staleness for scheduling)
659
+ const depthFactor = Math.pow(1 + config.depthWeight, depth);
660
+ const effectiveStaleness = (stalenessSeconds === Infinity
661
+ ? Number.MAX_SAFE_INTEGER
662
+ : stalenessSeconds) *
663
+ depthFactor *
664
+ emphasis;
665
+ if (effectiveStaleness > stalestEffective) {
666
+ stalestEffective = effectiveStaleness;
667
+ stalestPath = node.metaPath;
668
+ }
669
+ entries.push({
670
+ path: node.metaPath,
671
+ depth,
672
+ emphasis,
673
+ stalenessSeconds,
674
+ lastSynthesized: meta._generatedAt ?? null,
675
+ hasError,
676
+ locked,
677
+ architectTokens: archTokens > 0 ? archTokens : null,
678
+ builderTokens: buildTokens > 0 ? buildTokens : null,
679
+ criticTokens: critTokens > 0 ? critTokens : null,
680
+ children: node.children.length,
681
+ node,
682
+ meta,
683
+ });
684
+ }
685
+ return {
686
+ summary: {
687
+ total: entries.length,
688
+ stale: staleCount,
689
+ errors: errorCount,
690
+ locked: lockedCount,
691
+ neverSynthesized: neverSynthesizedCount,
692
+ tokens: {
693
+ architect: totalArchTokens,
694
+ builder: totalBuilderTokens,
695
+ critic: totalCriticTokens,
696
+ },
697
+ stalestPath,
698
+ lastSynthesizedPath: lastSynthPath,
699
+ lastSynthesizedAt: lastSynthAt,
700
+ },
701
+ entries,
702
+ tree,
703
+ };
704
+ }
705
+
467
706
  /**
468
707
  * Compute the file scope owned by a meta node.
469
708
  *
@@ -535,26 +774,126 @@ function computeEma(current, previous, decay = DEFAULT_DECAY) {
535
774
  }
536
775
 
537
776
  /**
538
- * Paginated scan helper for exhaustive scope enumeration.
777
+ * Shared error utilities.
539
778
  *
540
- * @module paginatedScan
779
+ * @module errors
541
780
  */
542
781
  /**
543
- * Perform a paginated scan that follows cursor tokens until exhausted.
782
+ * Wrap an unknown caught value into a SynthError.
544
783
  *
545
- * @param watcher - WatcherClient instance.
546
- * @param params - Base scan parameters (cursor is managed internally).
547
- * @returns All matching files across all pages.
784
+ * @param step - Which synthesis step failed.
785
+ * @param err - The caught error value.
786
+ * @param code - Error classification code.
787
+ * @returns A structured SynthError.
548
788
  */
549
- async function paginatedScan(watcher, params) {
550
- const allFiles = [];
551
- let cursor;
552
- do {
553
- const result = await watcher.scan({ ...params, cursor });
554
- allFiles.push(...result.files);
555
- cursor = result.next;
556
- } while (cursor);
557
- return allFiles;
789
+ function toSynthError(step, err, code = 'FAILED') {
790
+ return {
791
+ step,
792
+ code,
793
+ message: err instanceof Error ? err.message : String(err),
794
+ };
795
+ }
796
+
797
+ /**
798
+ * SynthExecutor implementation using the OpenClaw gateway HTTP API.
799
+ *
800
+ * Lives in the library package so both plugin and runner can import it.
801
+ * Spawns sub-agent sessions via the gateway, polls for completion,
802
+ * and extracts output text.
803
+ *
804
+ * @module executor/GatewayExecutor
805
+ */
806
+ const DEFAULT_POLL_INTERVAL_MS = 5000;
807
+ const DEFAULT_TIMEOUT_MS = 600_000; // 10 minutes
808
+ /** Sleep helper. */
809
+ function sleep$1(ms) {
810
+ return new Promise((resolve) => setTimeout(resolve, ms));
811
+ }
812
+ /**
813
+ * SynthExecutor that spawns OpenClaw sessions via the gateway HTTP API.
814
+ *
815
+ * Used by both the OpenClaw plugin (in-process tool calls) and the
816
+ * runner/CLI (external invocation). Constructs from `gatewayUrl` and
817
+ * optional `apiKey` — typically sourced from `SynthConfig`.
818
+ */
819
+ class GatewayExecutor {
820
+ gatewayUrl;
821
+ apiKey;
822
+ pollIntervalMs;
823
+ constructor(options = {}) {
824
+ this.gatewayUrl = (options.gatewayUrl ?? 'http://127.0.0.1:3000').replace(/\/+$/, '');
825
+ this.apiKey = options.apiKey;
826
+ this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
827
+ }
828
+ async spawn(task, options) {
829
+ const timeoutMs = (options?.timeout ?? DEFAULT_TIMEOUT_MS / 1000) * 1000;
830
+ const deadline = Date.now() + timeoutMs;
831
+ const headers = {
832
+ 'Content-Type': 'application/json',
833
+ };
834
+ if (this.apiKey) {
835
+ headers['Authorization'] = 'Bearer ' + this.apiKey;
836
+ }
837
+ const spawnRes = await fetch(this.gatewayUrl + '/api/sessions/spawn', {
838
+ method: 'POST',
839
+ headers,
840
+ body: JSON.stringify({
841
+ task,
842
+ mode: 'run',
843
+ model: options?.model,
844
+ runTimeoutSeconds: options?.timeout,
845
+ }),
846
+ });
847
+ if (!spawnRes.ok) {
848
+ const text = await spawnRes.text();
849
+ throw new Error('Gateway spawn failed: HTTP ' +
850
+ spawnRes.status.toString() +
851
+ ' - ' +
852
+ text);
853
+ }
854
+ const spawnData = (await spawnRes.json());
855
+ if (!spawnData.sessionKey) {
856
+ throw new Error('Gateway spawn returned no sessionKey: ' + JSON.stringify(spawnData));
857
+ }
858
+ const { sessionKey } = spawnData;
859
+ // Poll for completion
860
+ while (Date.now() < deadline) {
861
+ await sleep$1(this.pollIntervalMs);
862
+ const historyRes = await fetch(this.gatewayUrl +
863
+ '/api/sessions/' +
864
+ encodeURIComponent(sessionKey) +
865
+ '/history?limit=50', { headers });
866
+ if (!historyRes.ok)
867
+ continue;
868
+ const history = (await historyRes.json());
869
+ if (history.status === 'completed' || history.status === 'done') {
870
+ // Extract token usage from session-level or message-level usage
871
+ let tokens;
872
+ if (history.usage?.totalTokens) {
873
+ tokens = history.usage.totalTokens;
874
+ }
875
+ else {
876
+ // Sum message-level usage as fallback
877
+ let sum = 0;
878
+ for (const msg of history.messages ?? []) {
879
+ if (msg.usage?.totalTokens)
880
+ sum += msg.usage.totalTokens;
881
+ }
882
+ if (sum > 0)
883
+ tokens = sum;
884
+ }
885
+ // Extract the last assistant message as output
886
+ const messages = history.messages ?? [];
887
+ for (let i = messages.length - 1; i >= 0; i--) {
888
+ if (messages[i].role === 'assistant' && messages[i].content) {
889
+ return { output: messages[i].content, tokens };
890
+ }
891
+ }
892
+ return { output: '', tokens };
893
+ }
894
+ }
895
+ throw new Error('Synthesis subprocess timed out after ' + timeoutMs.toString() + 'ms');
896
+ }
558
897
  }
559
898
 
560
899
  /**
@@ -737,227 +1076,113 @@ function buildBuilderTask(ctx, meta, config) {
737
1076
  * @param meta - Current meta.json (with _content already set by builder).
738
1077
  * @param config - Synthesis config.
739
1078
  * @returns The critic task prompt string.
740
- */
741
- function buildCriticTask(ctx, meta, config) {
742
- const sections = [
743
- meta._critic ?? config.defaultCritic,
744
- '',
745
- '## SYNTHESIS TO EVALUATE',
746
- meta._content ?? '(No content produced)',
747
- '',
748
- '## SCOPE',
749
- `Path: ${ctx.path}`,
750
- `Files in scope: ${ctx.scopeFiles.length.toString()}`,
751
- ];
752
- appendSharedSections(sections, ctx, {
753
- includePreviousContent: false,
754
- feedbackHeading: '## YOUR PREVIOUS FEEDBACK',
755
- includeChildMetas: false,
756
- });
757
- sections.push('', '## OUTPUT FORMAT', 'Return your evaluation as Markdown text. Be specific and actionable.');
758
- return sections.join('\n');
759
- }
760
-
761
- /**
762
- * Merge synthesis results into meta.json.
763
- *
764
- * Preserves human-set fields (_id, _steer, _depth).
765
- * Writes engine fields (_generatedAt, _structureHash, etc.).
766
- * Validates against schema before writing.
767
- *
768
- * @module orchestrator/merge
769
- */
770
- /**
771
- * Merge results into meta.json and write atomically.
772
- *
773
- * @param options - Merge options.
774
- * @returns The updated MetaJson.
775
- * @throws If validation fails (malformed output).
776
- */
777
- function mergeAndWrite(options) {
778
- const merged = {
779
- // Preserve human-set fields
780
- _id: options.current._id,
781
- _steer: options.current._steer,
782
- _depth: options.current._depth,
783
- _emphasis: options.current._emphasis,
784
- // Engine fields
785
- _architect: options.architect,
786
- _builder: options.builder,
787
- _critic: options.critic,
788
- _generatedAt: new Date().toISOString(),
789
- _structureHash: options.structureHash,
790
- _synthesisCount: options.synthesisCount,
791
- // Token tracking
792
- _architectTokens: options.architectTokens,
793
- _builderTokens: options.builderTokens,
794
- _criticTokens: options.criticTokens,
795
- _architectTokensAvg: options.architectTokens !== undefined
796
- ? computeEma(options.architectTokens, options.current._architectTokensAvg)
797
- : options.current._architectTokensAvg,
798
- _builderTokensAvg: options.builderTokens !== undefined
799
- ? computeEma(options.builderTokens, options.current._builderTokensAvg)
800
- : options.current._builderTokensAvg,
801
- _criticTokensAvg: options.criticTokens !== undefined
802
- ? computeEma(options.criticTokens, options.current._criticTokensAvg)
803
- : options.current._criticTokensAvg,
804
- // Content from builder
805
- _content: options.builderOutput?.content ?? options.current._content,
806
- // Feedback from critic
807
- _feedback: options.feedback ?? options.current._feedback,
808
- // Error handling
809
- _error: options.error ?? undefined,
810
- // Spread structured fields from builder
811
- ...options.builderOutput?.fields,
812
- };
813
- // Clean up undefined optional fields
814
- if (merged._steer === undefined)
815
- delete merged._steer;
816
- if (merged._depth === undefined)
817
- delete merged._depth;
818
- if (merged._emphasis === undefined)
819
- delete merged._emphasis;
820
- if (merged._architectTokens === undefined)
821
- delete merged._architectTokens;
822
- if (merged._builderTokens === undefined)
823
- delete merged._builderTokens;
824
- if (merged._criticTokens === undefined)
825
- delete merged._criticTokens;
826
- if (merged._architectTokensAvg === undefined)
827
- delete merged._architectTokensAvg;
828
- if (merged._builderTokensAvg === undefined)
829
- delete merged._builderTokensAvg;
830
- if (merged._criticTokensAvg === undefined)
831
- delete merged._criticTokensAvg;
832
- if (merged._error === undefined)
833
- delete merged._error;
834
- if (merged._content === undefined)
835
- delete merged._content;
836
- if (merged._feedback === undefined)
837
- delete merged._feedback;
838
- // Validate
839
- const result = metaJsonSchema.safeParse(merged);
840
- if (!result.success) {
841
- throw new Error(`Meta validation failed: ${result.error.message}`);
842
- }
843
- // Write atomically
844
- const filePath = join(options.metaPath, 'meta.json');
845
- writeFileSync(filePath, JSON.stringify(result.data, null, 2) + '\n');
846
- return result.data;
847
- }
848
-
849
- /**
850
- * Shared error utilities.
851
- *
852
- * @module errors
853
- */
854
- /**
855
- * Wrap an unknown caught value into a SynthError.
856
- *
857
- * @param step - Which synthesis step failed.
858
- * @param err - The caught error value.
859
- * @param code - Error classification code.
860
- * @returns A structured SynthError.
861
- */
862
- function toSynthError(step, err, code = 'FAILED') {
863
- return {
864
- step,
865
- code,
866
- message: err instanceof Error ? err.message : String(err),
867
- };
868
- }
869
-
870
- /**
871
- * File-system lock for preventing concurrent synthesis on the same meta.
872
- *
873
- * Lock file: .meta/.lock containing PID + timestamp.
874
- * Stale timeout: 30 minutes.
875
- *
876
- * @module lock
877
- */
878
- const LOCK_FILE = '.lock';
879
- const STALE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
880
- /**
881
- * Attempt to acquire a lock on a .meta directory.
882
- *
883
- * @param metaPath - Absolute path to the .meta directory.
884
- * @returns True if lock was acquired, false if already locked (non-stale).
885
- */
886
- function acquireLock(metaPath) {
887
- const lockPath = join(metaPath, LOCK_FILE);
888
- if (existsSync(lockPath)) {
889
- try {
890
- const raw = readFileSync(lockPath, 'utf8');
891
- const data = JSON.parse(raw);
892
- const lockAge = Date.now() - new Date(data.startedAt).getTime();
893
- if (lockAge < STALE_TIMEOUT_MS) {
894
- return false; // Lock is active
895
- }
896
- // Stale lock — fall through to overwrite
897
- }
898
- catch {
899
- // Corrupt lock file — overwrite
900
- }
901
- }
902
- const lock = {
903
- pid: process.pid,
904
- startedAt: new Date().toISOString(),
905
- };
906
- writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n');
907
- return true;
908
- }
909
- /**
910
- * Release a lock on a .meta directory.
911
- *
912
- * @param metaPath - Absolute path to the .meta directory.
913
- */
914
- function releaseLock(metaPath) {
915
- const lockPath = join(metaPath, LOCK_FILE);
916
- try {
917
- unlinkSync(lockPath);
918
- }
919
- catch {
920
- // Already removed or never existed
921
- }
922
- }
923
- /**
924
- * Check if a .meta directory is currently locked (non-stale).
925
- *
926
- * @param metaPath - Absolute path to the .meta directory.
927
- * @returns True if locked and not stale.
928
- */
929
- function isLocked(metaPath) {
930
- const lockPath = join(metaPath, LOCK_FILE);
931
- if (!existsSync(lockPath))
932
- return false;
933
- try {
934
- const raw = readFileSync(lockPath, 'utf8');
935
- const data = JSON.parse(raw);
936
- const lockAge = Date.now() - new Date(data.startedAt).getTime();
937
- return lockAge < STALE_TIMEOUT_MS;
938
- }
939
- catch {
940
- return false; // Corrupt lock = not locked
941
- }
1079
+ */
1080
+ function buildCriticTask(ctx, meta, config) {
1081
+ const sections = [
1082
+ meta._critic ?? config.defaultCritic,
1083
+ '',
1084
+ '## SYNTHESIS TO EVALUATE',
1085
+ meta._content ?? '(No content produced)',
1086
+ '',
1087
+ '## SCOPE',
1088
+ `Path: ${ctx.path}`,
1089
+ `Files in scope: ${ctx.scopeFiles.length.toString()}`,
1090
+ ];
1091
+ appendSharedSections(sections, ctx, {
1092
+ includePreviousContent: false,
1093
+ feedbackHeading: '## YOUR PREVIOUS FEEDBACK',
1094
+ includeChildMetas: false,
1095
+ });
1096
+ sections.push('', '## OUTPUT FORMAT', 'Return your evaluation as Markdown text. Be specific and actionable.');
1097
+ return sections.join('\n');
942
1098
  }
943
1099
 
944
1100
  /**
945
- * Normalize file paths to forward slashes for consistency with watcher-indexed paths.
1101
+ * Merge synthesis results into meta.json.
946
1102
  *
947
- * Watcher indexes paths with forward slashes (`j:/domains/...`). This utility
948
- * ensures all paths in the library use the same convention, regardless of
949
- * the platform's native separator.
1103
+ * Preserves human-set fields (_id, _steer, _depth).
1104
+ * Writes engine fields (_generatedAt, _structureHash, etc.).
1105
+ * Validates against schema before writing.
950
1106
  *
951
- * @module normalizePath
1107
+ * @module orchestrator/merge
952
1108
  */
953
1109
  /**
954
- * Normalize a file path to forward slashes.
1110
+ * Merge results into meta.json and write atomically.
955
1111
  *
956
- * @param p - File path (may contain backslashes).
957
- * @returns Path with all backslashes replaced by forward slashes.
1112
+ * @param options - Merge options.
1113
+ * @returns The updated MetaJson.
1114
+ * @throws If validation fails (malformed output).
958
1115
  */
959
- function normalizePath(p) {
960
- return p.replaceAll('\\', '/');
1116
+ function mergeAndWrite(options) {
1117
+ const merged = {
1118
+ // Preserve human-set fields
1119
+ _id: options.current._id,
1120
+ _steer: options.current._steer,
1121
+ _depth: options.current._depth,
1122
+ _emphasis: options.current._emphasis,
1123
+ // Engine fields
1124
+ _architect: options.architect,
1125
+ _builder: options.builder,
1126
+ _critic: options.critic,
1127
+ _generatedAt: new Date().toISOString(),
1128
+ _structureHash: options.structureHash,
1129
+ _synthesisCount: options.synthesisCount,
1130
+ // Token tracking
1131
+ _architectTokens: options.architectTokens,
1132
+ _builderTokens: options.builderTokens,
1133
+ _criticTokens: options.criticTokens,
1134
+ _architectTokensAvg: options.architectTokens !== undefined
1135
+ ? computeEma(options.architectTokens, options.current._architectTokensAvg)
1136
+ : options.current._architectTokensAvg,
1137
+ _builderTokensAvg: options.builderTokens !== undefined
1138
+ ? computeEma(options.builderTokens, options.current._builderTokensAvg)
1139
+ : options.current._builderTokensAvg,
1140
+ _criticTokensAvg: options.criticTokens !== undefined
1141
+ ? computeEma(options.criticTokens, options.current._criticTokensAvg)
1142
+ : options.current._criticTokensAvg,
1143
+ // Content from builder
1144
+ _content: options.builderOutput?.content ?? options.current._content,
1145
+ // Feedback from critic
1146
+ _feedback: options.feedback ?? options.current._feedback,
1147
+ // Error handling
1148
+ _error: options.error ?? undefined,
1149
+ // Spread structured fields from builder
1150
+ ...options.builderOutput?.fields,
1151
+ };
1152
+ // Clean up undefined optional fields
1153
+ if (merged._steer === undefined)
1154
+ delete merged._steer;
1155
+ if (merged._depth === undefined)
1156
+ delete merged._depth;
1157
+ if (merged._emphasis === undefined)
1158
+ delete merged._emphasis;
1159
+ if (merged._architectTokens === undefined)
1160
+ delete merged._architectTokens;
1161
+ if (merged._builderTokens === undefined)
1162
+ delete merged._builderTokens;
1163
+ if (merged._criticTokens === undefined)
1164
+ delete merged._criticTokens;
1165
+ if (merged._architectTokensAvg === undefined)
1166
+ delete merged._architectTokensAvg;
1167
+ if (merged._builderTokensAvg === undefined)
1168
+ delete merged._builderTokensAvg;
1169
+ if (merged._criticTokensAvg === undefined)
1170
+ delete merged._criticTokensAvg;
1171
+ if (merged._error === undefined)
1172
+ delete merged._error;
1173
+ if (merged._content === undefined)
1174
+ delete merged._content;
1175
+ if (merged._feedback === undefined)
1176
+ delete merged._feedback;
1177
+ // Validate
1178
+ const result = metaJsonSchema.safeParse(merged);
1179
+ if (!result.success) {
1180
+ throw new Error(`Meta validation failed: ${result.error.message}`);
1181
+ }
1182
+ // Write atomically
1183
+ const filePath = join(options.metaPath, 'meta.json');
1184
+ writeFileSync(filePath, JSON.stringify(result.data, null, 2) + '\n');
1185
+ return result.data;
961
1186
  }
962
1187
 
963
1188
  /**
@@ -1216,17 +1441,32 @@ function finalizeCycle(metaPath, current, config, architect, builder, critic, bu
1216
1441
  * @param watcher - Watcher HTTP client.
1217
1442
  * @returns Result indicating whether synthesis occurred.
1218
1443
  */
1219
- async function orchestrateOnce(config, executor, watcher) {
1220
- // Step 1: Discover
1221
- const metaPaths = globMetas(config.watchPaths);
1444
+ async function orchestrateOnce(config, executor, watcher, targetPath) {
1445
+ // Step 1: Discover via watcher scan
1446
+ const metaPaths = await discoverMetas(config, watcher);
1222
1447
  if (metaPaths.length === 0)
1223
1448
  return { synthesized: false };
1224
- // Ensure all meta.json files exist
1449
+ // Read meta.json for each discovered meta
1225
1450
  const metas = new Map();
1226
1451
  for (const mp of metaPaths) {
1227
- metas.set(normalizePath(mp), ensureMetaJson(mp));
1452
+ const metaFilePath = join(mp, 'meta.json');
1453
+ try {
1454
+ metas.set(normalizePath$1(mp), JSON.parse(readFileSync(metaFilePath, 'utf8')));
1455
+ }
1456
+ catch {
1457
+ // Skip metas with unreadable meta.json
1458
+ continue;
1459
+ }
1228
1460
  }
1229
1461
  const tree = buildOwnershipTree(metaPaths);
1462
+ // If targetPath specified, skip candidate selection — go directly to that meta
1463
+ let targetNode;
1464
+ if (targetPath) {
1465
+ const normalized = normalizePath$1(targetPath);
1466
+ targetNode = findNode(tree, normalized) ?? undefined;
1467
+ if (!targetNode)
1468
+ return { synthesized: false };
1469
+ }
1230
1470
  // Steps 3-4: Staleness check + candidate selection
1231
1471
  const candidates = [];
1232
1472
  for (const node of tree.nodes.values()) {
@@ -1261,9 +1501,13 @@ async function orchestrateOnce(config, executor, watcher) {
1261
1501
  winner = candidate;
1262
1502
  break;
1263
1503
  }
1264
- if (!winner)
1504
+ if (!winner && !targetNode)
1505
+ return { synthesized: false };
1506
+ const node = targetNode ?? winner.node;
1507
+ // For targeted path, acquire lock now (candidate selection already locked for stalest)
1508
+ if (targetNode && !acquireLock(node.metaPath)) {
1265
1509
  return { synthesized: false };
1266
- const { node } = winner;
1510
+ }
1267
1511
  try {
1268
1512
  // Re-read meta after lock (may have changed)
1269
1513
  const currentMeta = JSON.parse(readFileSync(join(node.metaPath, 'meta.json'), 'utf8'));
@@ -1373,12 +1617,13 @@ async function orchestrateOnce(config, executor, watcher) {
1373
1617
  * @param config - Validated synthesis config.
1374
1618
  * @param executor - Pluggable LLM executor.
1375
1619
  * @param watcher - Watcher HTTP client.
1620
+ * @param targetPath - Optional: specific meta/owner path to synthesize instead of stalest candidate.
1376
1621
  * @returns Array of results, one per cycle attempted.
1377
1622
  */
1378
- async function orchestrate(config, executor, watcher) {
1623
+ async function orchestrate(config, executor, watcher, targetPath) {
1379
1624
  const results = [];
1380
1625
  for (let i = 0; i < config.batchSize; i++) {
1381
- const result = await orchestrateOnce(config, executor, watcher);
1626
+ const result = await orchestrateOnce(config, executor, watcher, targetPath);
1382
1627
  results.push(result);
1383
1628
  if (!result.synthesized)
1384
1629
  break; // No more candidates
@@ -1386,108 +1631,6 @@ async function orchestrate(config, executor, watcher) {
1386
1631
  return results;
1387
1632
  }
1388
1633
 
1389
- /**
1390
- * SynthExecutor implementation using the OpenClaw gateway HTTP API.
1391
- *
1392
- * Lives in the library package so both plugin and runner can import it.
1393
- * Spawns sub-agent sessions via the gateway, polls for completion,
1394
- * and extracts output text.
1395
- *
1396
- * @module executor/GatewayExecutor
1397
- */
1398
- const DEFAULT_POLL_INTERVAL_MS = 5000;
1399
- const DEFAULT_TIMEOUT_MS = 600_000; // 10 minutes
1400
- /** Sleep helper. */
1401
- function sleep$1(ms) {
1402
- return new Promise((resolve) => setTimeout(resolve, ms));
1403
- }
1404
- /**
1405
- * SynthExecutor that spawns OpenClaw sessions via the gateway HTTP API.
1406
- *
1407
- * Used by both the OpenClaw plugin (in-process tool calls) and the
1408
- * runner/CLI (external invocation). Constructs from `gatewayUrl` and
1409
- * optional `apiKey` — typically sourced from `SynthConfig`.
1410
- */
1411
- class GatewayExecutor {
1412
- gatewayUrl;
1413
- apiKey;
1414
- pollIntervalMs;
1415
- constructor(options = {}) {
1416
- this.gatewayUrl = (options.gatewayUrl ?? 'http://127.0.0.1:3000').replace(/\/+$/, '');
1417
- this.apiKey = options.apiKey;
1418
- this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
1419
- }
1420
- async spawn(task, options) {
1421
- const timeoutMs = (options?.timeout ?? DEFAULT_TIMEOUT_MS / 1000) * 1000;
1422
- const deadline = Date.now() + timeoutMs;
1423
- const headers = {
1424
- 'Content-Type': 'application/json',
1425
- };
1426
- if (this.apiKey) {
1427
- headers['Authorization'] = 'Bearer ' + this.apiKey;
1428
- }
1429
- const spawnRes = await fetch(this.gatewayUrl + '/api/sessions/spawn', {
1430
- method: 'POST',
1431
- headers,
1432
- body: JSON.stringify({
1433
- task,
1434
- mode: 'run',
1435
- model: options?.model,
1436
- runTimeoutSeconds: options?.timeout,
1437
- }),
1438
- });
1439
- if (!spawnRes.ok) {
1440
- const text = await spawnRes.text();
1441
- throw new Error('Gateway spawn failed: HTTP ' +
1442
- spawnRes.status.toString() +
1443
- ' - ' +
1444
- text);
1445
- }
1446
- const spawnData = (await spawnRes.json());
1447
- if (!spawnData.sessionKey) {
1448
- throw new Error('Gateway spawn returned no sessionKey: ' + JSON.stringify(spawnData));
1449
- }
1450
- const { sessionKey } = spawnData;
1451
- // Poll for completion
1452
- while (Date.now() < deadline) {
1453
- await sleep$1(this.pollIntervalMs);
1454
- const historyRes = await fetch(this.gatewayUrl +
1455
- '/api/sessions/' +
1456
- encodeURIComponent(sessionKey) +
1457
- '/history?limit=50', { headers });
1458
- if (!historyRes.ok)
1459
- continue;
1460
- const history = (await historyRes.json());
1461
- if (history.status === 'completed' || history.status === 'done') {
1462
- // Extract token usage from session-level or message-level usage
1463
- let tokens;
1464
- if (history.usage?.totalTokens) {
1465
- tokens = history.usage.totalTokens;
1466
- }
1467
- else {
1468
- // Sum message-level usage as fallback
1469
- let sum = 0;
1470
- for (const msg of history.messages ?? []) {
1471
- if (msg.usage?.totalTokens)
1472
- sum += msg.usage.totalTokens;
1473
- }
1474
- if (sum > 0)
1475
- tokens = sum;
1476
- }
1477
- // Extract the last assistant message as output
1478
- const messages = history.messages ?? [];
1479
- for (let i = messages.length - 1; i >= 0; i--) {
1480
- if (messages[i].role === 'assistant' && messages[i].content) {
1481
- return { output: messages[i].content, tokens };
1482
- }
1483
- }
1484
- return { output: '', tokens };
1485
- }
1486
- }
1487
- throw new Error('Synthesis subprocess timed out after ' + timeoutMs.toString() + 'ms');
1488
- }
1489
- }
1490
-
1491
1634
  /**
1492
1635
  * HTTP implementation of the WatcherClient interface.
1493
1636
  *
@@ -1546,16 +1689,31 @@ class HttpWatcherClient {
1546
1689
  throw new Error('Retry exhausted');
1547
1690
  }
1548
1691
  async scan(params) {
1549
- const body = {};
1550
- if (params.pathPrefix !== undefined) {
1551
- body.pathPrefix = params.pathPrefix;
1692
+ // Build Qdrant filter: merge explicit filter with pathPrefix/modifiedAfter
1693
+ const mustClauses = [];
1694
+ // Carry over any existing 'must' clauses from the provided filter
1695
+ if (params.filter) {
1696
+ const existing = params.filter.must;
1697
+ if (Array.isArray(existing)) {
1698
+ mustClauses.push(...existing);
1699
+ }
1552
1700
  }
1553
- if (params.filter !== undefined) {
1554
- body.filter = params.filter;
1701
+ // Translate pathPrefix into a Qdrant text match on file_path
1702
+ if (params.pathPrefix !== undefined) {
1703
+ mustClauses.push({
1704
+ key: 'file_path',
1705
+ match: { text: params.pathPrefix },
1706
+ });
1555
1707
  }
1708
+ // Translate modifiedAfter into a Qdrant range filter on modified_at
1556
1709
  if (params.modifiedAfter !== undefined) {
1557
- body.modifiedAfter = params.modifiedAfter;
1710
+ mustClauses.push({
1711
+ key: 'modified_at',
1712
+ range: { gt: params.modifiedAfter },
1713
+ });
1558
1714
  }
1715
+ const filter = { must: mustClauses };
1716
+ const body = { filter };
1559
1717
  if (params.fields !== undefined) {
1560
1718
  body.fields = params.fields;
1561
1719
  }
@@ -1604,25 +1762,26 @@ var index = /*#__PURE__*/Object.freeze({
1604
1762
  buildBuilderTask: buildBuilderTask,
1605
1763
  buildContextPackage: buildContextPackage,
1606
1764
  buildCriticTask: buildCriticTask,
1765
+ buildMetaFilter: buildMetaFilter,
1607
1766
  buildOwnershipTree: buildOwnershipTree,
1608
1767
  computeEffectiveStaleness: computeEffectiveStaleness,
1609
1768
  computeEma: computeEma,
1610
1769
  computeStructureHash: computeStructureHash,
1611
1770
  createSnapshot: createSnapshot,
1612
- ensureMetaJson: ensureMetaJson,
1771
+ discoverMetas: discoverMetas,
1613
1772
  filterInScope: filterInScope,
1614
1773
  findNode: findNode,
1615
1774
  getScopePrefix: getScopePrefix,
1616
- globMetas: globMetas,
1617
1775
  hasSteerChanged: hasSteerChanged,
1618
1776
  isArchitectTriggered: isArchitectTriggered,
1619
1777
  isLocked: isLocked,
1620
1778
  isStale: isStale,
1621
1779
  listArchiveFiles: listArchiveFiles,
1780
+ listMetas: listMetas,
1622
1781
  loadSynthConfig: loadSynthConfig,
1623
1782
  mergeAndWrite: mergeAndWrite,
1624
1783
  metaJsonSchema: metaJsonSchema,
1625
- normalizePath: normalizePath,
1784
+ normalizePath: normalizePath$1,
1626
1785
  orchestrate: orchestrate,
1627
1786
  paginatedScan: paginatedScan,
1628
1787
  parseArchitectOutput: parseArchitectOutput,
@@ -1648,6 +1807,10 @@ var index = /*#__PURE__*/Object.freeze({
1648
1807
  *
1649
1808
  * @module cli
1650
1809
  */
1810
+ /** Read and parse a meta.json file with proper typing. */
1811
+ function readMeta(metaPath) {
1812
+ return JSON.parse(readFileSync(join(metaPath, 'meta.json'), 'utf8'));
1813
+ }
1651
1814
  const args = process.argv.slice(2);
1652
1815
  const command = args.find((a) => !a.startsWith('-'));
1653
1816
  const jsonOutput = args.includes('--json');
@@ -1683,73 +1846,38 @@ function output(data) {
1683
1846
  console.log(JSON.stringify(data, null, 2));
1684
1847
  }
1685
1848
  }
1686
- function runStatus(config) {
1687
- const metaPaths = globMetas(config.watchPaths);
1688
- const tree = buildOwnershipTree(metaPaths);
1689
- let stale = 0;
1690
- let errors = 0;
1691
- let locked = 0;
1692
- let neverSynth = 0;
1693
- let archTokens = 0;
1694
- let buildTokens = 0;
1695
- let critTokens = 0;
1696
- for (const node of tree.nodes.values()) {
1697
- const meta = ensureMetaJson(node.metaPath);
1698
- const s = actualStaleness(meta);
1699
- if (s > 0)
1700
- stale++;
1701
- if (meta._error)
1702
- errors++;
1703
- if (isLocked(normalizePath(node.metaPath)))
1704
- locked++;
1705
- if (!meta._generatedAt)
1706
- neverSynth++;
1707
- if (meta._architectTokens)
1708
- archTokens += meta._architectTokens;
1709
- if (meta._builderTokens)
1710
- buildTokens += meta._builderTokens;
1711
- if (meta._criticTokens)
1712
- critTokens += meta._criticTokens;
1713
- }
1714
- output({
1715
- total: tree.nodes.size,
1716
- stale,
1717
- errors,
1718
- locked,
1719
- neverSynthesized: neverSynth,
1720
- tokens: { architect: archTokens, builder: buildTokens, critic: critTokens },
1721
- });
1849
+ async function runStatus(config) {
1850
+ const watcher = new HttpWatcherClient({ baseUrl: config.watcherUrl });
1851
+ const result = await listMetas(config, watcher);
1852
+ output(result.summary);
1722
1853
  }
1723
- function runList(config) {
1854
+ async function runList(config) {
1724
1855
  const prefix = getArg('--prefix');
1725
1856
  const filter = getArg('--filter');
1726
- const metaPaths = globMetas(config.watchPaths);
1727
- const tree = buildOwnershipTree(metaPaths);
1728
- const rows = [];
1729
- for (const node of tree.nodes.values()) {
1730
- if (prefix && !node.metaPath.includes(prefix))
1731
- continue;
1732
- const meta = ensureMetaJson(node.metaPath);
1733
- const s = actualStaleness(meta);
1734
- const hasError = Boolean(meta._error);
1735
- const isLockedNow = isLocked(normalizePath(node.metaPath));
1736
- if (filter === 'hasError' && !hasError)
1737
- continue;
1738
- if (filter === 'stale' && s <= 0)
1739
- continue;
1740
- if (filter === 'locked' && !isLockedNow)
1741
- continue;
1742
- if (filter === 'never' && meta._generatedAt)
1743
- continue;
1744
- rows.push({
1745
- path: node.metaPath,
1746
- depth: meta._depth ?? node.treeDepth,
1747
- staleness: s === Infinity ? 'never' : String(Math.round(s)) + 's',
1748
- hasError,
1749
- locked: isLockedNow,
1750
- children: node.children.length,
1751
- });
1752
- }
1857
+ const watcher = new HttpWatcherClient({ baseUrl: config.watcherUrl });
1858
+ const result = await listMetas(config, watcher);
1859
+ let entries = result.entries;
1860
+ if (prefix) {
1861
+ entries = entries.filter((e) => e.path.includes(prefix));
1862
+ }
1863
+ if (filter === 'hasError')
1864
+ entries = entries.filter((e) => e.hasError);
1865
+ if (filter === 'stale')
1866
+ entries = entries.filter((e) => e.stalenessSeconds > 0);
1867
+ if (filter === 'locked')
1868
+ entries = entries.filter((e) => e.locked);
1869
+ if (filter === 'never')
1870
+ entries = entries.filter((e) => e.stalenessSeconds === Infinity);
1871
+ const rows = entries.map((e) => ({
1872
+ path: e.path,
1873
+ depth: e.depth,
1874
+ staleness: e.stalenessSeconds === Infinity
1875
+ ? 'never'
1876
+ : String(Math.round(e.stalenessSeconds)) + 's',
1877
+ hasError: e.hasError,
1878
+ locked: e.locked,
1879
+ children: e.children,
1880
+ }));
1753
1881
  output({ total: rows.length, items: rows });
1754
1882
  }
1755
1883
  async function runDetail(config) {
@@ -1759,15 +1887,15 @@ async function runDetail(config) {
1759
1887
  process.exit(1);
1760
1888
  }
1761
1889
  const archiveArg = getArg('--archive');
1762
- const metaPaths = globMetas(config.watchPaths);
1763
- const tree = buildOwnershipTree(metaPaths);
1764
- const normalized = normalizePath(targetPath);
1765
- const node = findNode(tree, normalized);
1890
+ const watcher = new HttpWatcherClient({ baseUrl: config.watcherUrl });
1891
+ const metaResult = await listMetas(config, watcher);
1892
+ const normalized = normalizePath$1(targetPath);
1893
+ const node = findNode(metaResult.tree, normalized);
1766
1894
  if (!node) {
1767
1895
  console.error('Meta not found: ' + targetPath);
1768
1896
  process.exit(1);
1769
1897
  }
1770
- const meta = ensureMetaJson(node.metaPath);
1898
+ const meta = readMeta(node.metaPath);
1771
1899
  const result = { meta };
1772
1900
  if (archiveArg) {
1773
1901
  const { listArchiveFiles } = await Promise.resolve().then(function () { return index$1; });
@@ -1783,27 +1911,26 @@ async function runDetail(config) {
1783
1911
  }
1784
1912
  async function runPreview(config) {
1785
1913
  const targetPath = getArg('--path');
1786
- const { filterInScope, paginatedScan, readLatestArchive, computeStructureHash, selectCandidate, } = await Promise.resolve().then(function () { return index; });
1787
- const metaPaths = globMetas(config.watchPaths);
1788
- const tree = buildOwnershipTree(metaPaths);
1914
+ const { filterInScope, paginatedScan, readLatestArchive, computeStructureHash, } = await Promise.resolve().then(function () { return index; });
1789
1915
  const watcher = new HttpWatcherClient({ baseUrl: config.watcherUrl });
1916
+ const metaResult = await listMetas(config, watcher);
1790
1917
  let targetNode;
1791
1918
  if (targetPath) {
1792
- const normalized = normalizePath(targetPath);
1793
- targetNode = findNode(tree, normalized);
1919
+ const normalized = normalizePath$1(targetPath);
1920
+ targetNode = findNode(metaResult.tree, normalized);
1794
1921
  if (!targetNode) {
1795
1922
  console.error('Meta not found: ' + targetPath);
1796
1923
  process.exit(1);
1797
1924
  }
1798
1925
  }
1799
1926
  else {
1800
- const candidates = [];
1801
- for (const node of tree.nodes.values()) {
1802
- const meta = ensureMetaJson(node.metaPath);
1803
- const s = actualStaleness(meta);
1804
- if (s > 0)
1805
- candidates.push({ node, meta, actualStaleness: s });
1806
- }
1927
+ const candidates = metaResult.entries
1928
+ .filter((e) => e.stalenessSeconds > 0)
1929
+ .map((e) => ({
1930
+ node: e.node,
1931
+ meta: e.meta,
1932
+ actualStaleness: e.stalenessSeconds,
1933
+ }));
1807
1934
  const weighted = computeEffectiveStaleness(candidates, config.depthWeight);
1808
1935
  const winner = selectCandidate(weighted);
1809
1936
  if (!winner) {
@@ -1812,7 +1939,7 @@ async function runPreview(config) {
1812
1939
  }
1813
1940
  targetNode = winner.node;
1814
1941
  }
1815
- const meta = ensureMetaJson(targetNode.metaPath);
1942
+ const meta = readMeta(targetNode.metaPath);
1816
1943
  const allFiles = await paginatedScan(watcher, {
1817
1944
  pathPrefix: targetNode.ownerPath,
1818
1945
  });
@@ -1840,9 +1967,6 @@ async function runSynthesize(config) {
1840
1967
  const batchArg = getArg('--batch');
1841
1968
  const effectiveConfig = {
1842
1969
  ...config,
1843
- ...(targetPath
1844
- ? { watchPaths: [targetPath.replace(/[/\\]\.meta[/\\]?$/, '')] }
1845
- : {}),
1846
1970
  ...(batchArg ? { batchSize: parseInt(batchArg, 10) } : {}),
1847
1971
  };
1848
1972
  const executor = new GatewayExecutor({
@@ -1850,7 +1974,7 @@ async function runSynthesize(config) {
1850
1974
  apiKey: config.gatewayApiKey,
1851
1975
  });
1852
1976
  const watcher = new HttpWatcherClient({ baseUrl: config.watcherUrl });
1853
- const results = await orchestrate(effectiveConfig, executor, watcher);
1977
+ const results = await orchestrate(effectiveConfig, executor, watcher, targetPath ?? undefined);
1854
1978
  const synthesized = results.filter((r) => r.synthesized);
1855
1979
  output({
1856
1980
  synthesizedCount: synthesized.length,
@@ -1921,9 +2045,16 @@ async function runValidate(config) {
1921
2045
  catch {
1922
2046
  checks.gateway = 'UNREACHABLE (' + config.gatewayUrl + ')';
1923
2047
  }
1924
- // Check watch paths
1925
- const metaPaths = globMetas(config.watchPaths);
1926
- checks.metas = String(metaPaths.length) + ' .meta/ directories found';
2048
+ // Check meta discovery via watcher
2049
+ try {
2050
+ const watcherClient = new HttpWatcherClient({ baseUrl: config.watcherUrl });
2051
+ const metaPaths = await discoverMetas(config, watcherClient);
2052
+ checks.metas =
2053
+ String(metaPaths.length) + ' .meta/ entities discovered via watcher';
2054
+ }
2055
+ catch {
2056
+ checks.metas = 'FAILED — could not discover metas (watcher may be down)';
2057
+ }
1927
2058
  output({ config: 'valid', checks });
1928
2059
  }
1929
2060
  function runConfigShow(config) {
@@ -1983,10 +2114,10 @@ async function main() {
1983
2114
  }
1984
2115
  switch (command) {
1985
2116
  case 'status':
1986
- runStatus(config);
2117
+ await runStatus(config);
1987
2118
  break;
1988
2119
  case 'list':
1989
- runList(config);
2120
+ await runList(config);
1990
2121
  break;
1991
2122
  case 'detail':
1992
2123
  await runDetail(config);