@karmaniverous/jeeves-meta 0.4.0 → 0.4.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.
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
- import { readFileSync, readdirSync, unlinkSync, mkdirSync, writeFileSync, existsSync, copyFileSync, watchFile } from 'node:fs';
3
+ import { readFileSync, readdirSync, unlinkSync, mkdirSync, writeFileSync, existsSync, statSync, copyFileSync, watchFile } from 'node:fs';
4
4
  import { dirname, join, relative } from 'node:path';
5
5
  import { z } from 'zod';
6
6
  import { createHash, randomUUID } from 'node:crypto';
@@ -293,14 +293,29 @@ function normalizePath(p) {
293
293
  * @param params - Base scan parameters (cursor is managed internally).
294
294
  * @returns All matching files across all pages.
295
295
  */
296
- async function paginatedScan(watcher, params) {
296
+ async function paginatedScan(watcher, params, logger) {
297
297
  const allFiles = [];
298
298
  let cursor;
299
+ let pageCount = 0;
300
+ const start = Date.now();
299
301
  do {
302
+ const pageStart = Date.now();
300
303
  const result = await watcher.scan({ ...params, cursor });
301
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');
302
312
  cursor = result.next;
303
313
  } while (cursor);
314
+ logger?.debug({
315
+ pages: pageCount,
316
+ totalFiles: allFiles.length,
317
+ totalMs: Date.now() - start,
318
+ }, 'paginatedScan complete');
304
319
  return allFiles;
305
320
  }
306
321
 
@@ -367,12 +382,9 @@ function buildMetaFilter(config) {
367
382
  * @param watcher - WatcherClient for scan queries.
368
383
  * @returns Array of normalized .meta/ directory paths.
369
384
  */
370
- async function discoverMetas(config, watcher) {
385
+ async function discoverMetas(config, watcher, logger) {
371
386
  const filter = buildMetaFilter(config);
372
- const scanFiles = await paginatedScan(watcher, {
373
- filter,
374
- fields: ['file_path'],
375
- });
387
+ const scanFiles = await paginatedScan(watcher, { filter, fields: ['file_path'] }, logger);
376
388
  // Deduplicate by .meta/ directory path (handles multi-chunk files)
377
389
  const seen = new Set();
378
390
  const metaPaths = [];
@@ -600,6 +612,8 @@ function findNode(tree, targetPath) {
600
612
  *
601
613
  * @module discovery/listMetas
602
614
  */
615
+ /** Maximum staleness for never-synthesized metas (1 year in seconds). */
616
+ const MAX_STALENESS_SECONDS$1 = 365 * 86_400;
603
617
  /**
604
618
  * Discover, deduplicate, and enrich all metas.
605
619
  *
@@ -611,9 +625,9 @@ function findNode(tree, targetPath) {
611
625
  * @param watcher - Watcher HTTP client for discovery.
612
626
  * @returns Enriched meta list with summary statistics and ownership tree.
613
627
  */
614
- async function listMetas(config, watcher) {
628
+ async function listMetas(config, watcher, logger) {
615
629
  // Step 1: Discover deduplicated meta paths via watcher scan
616
- const metaPaths = await discoverMetas(config, watcher);
630
+ const metaPaths = await discoverMetas(config, watcher, logger);
617
631
  // Step 2: Build ownership tree
618
632
  const tree = buildOwnershipTree(metaPaths);
619
633
  // Step 3: Read and enrich each meta from disk
@@ -646,7 +660,7 @@ async function listMetas(config, watcher) {
646
660
  // Compute staleness
647
661
  let stalenessSeconds;
648
662
  if (neverSynth) {
649
- stalenessSeconds = Infinity;
663
+ stalenessSeconds = MAX_STALENESS_SECONDS$1;
650
664
  }
651
665
  else {
652
666
  const genAt = new Date(meta._generatedAt).getTime();
@@ -677,11 +691,7 @@ async function listMetas(config, watcher) {
677
691
  }
678
692
  // Track stalest (effective staleness for scheduling)
679
693
  const depthFactor = Math.pow(1 + config.depthWeight, depth);
680
- const effectiveStaleness = (stalenessSeconds === Infinity
681
- ? Number.MAX_SAFE_INTEGER
682
- : stalenessSeconds) *
683
- depthFactor *
684
- emphasis;
694
+ const effectiveStaleness = stalenessSeconds * depthFactor * emphasis;
685
695
  if (effectiveStaleness > stalestEffective) {
686
696
  stalestEffective = effectiveStaleness;
687
697
  stalestPath = node.metaPath;
@@ -723,22 +733,83 @@ async function listMetas(config, watcher) {
723
733
  };
724
734
  }
725
735
 
736
+ /**
737
+ * Recursive filesystem walker for file enumeration.
738
+ *
739
+ * Replaces paginated watcher scans for scope/delta/staleness checks.
740
+ * Returns normalized forward-slash paths.
741
+ *
742
+ * @module walkFiles
743
+ */
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
+ /**
753
+ * Recursively walk a directory and return all file paths.
754
+ *
755
+ * @param root - Root directory to walk.
756
+ * @param options - Walk options.
757
+ * @returns Array of normalized file paths.
758
+ */
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;
768
+ try {
769
+ entries = readdirSync(dir, { withFileTypes: true });
770
+ }
771
+ 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
+ }
794
+ }
795
+ }
796
+ walk(root, 0);
797
+ return results;
798
+ }
799
+
726
800
  /**
727
801
  * Compute the file scope owned by a meta node.
728
802
  *
729
- * A meta owns: parent dir + all descendants, minus child .meta/ subtrees.
730
- * For child subtrees, it consumes the child's .meta/meta.json as a rollup input.
803
+ * A meta owns: parent dir + all descendants, minus:
804
+ * - Its own .meta/ subtree (outputs, not inputs)
805
+ * - Child meta ownerPath subtrees (except their .meta/meta.json for rollups)
806
+ *
807
+ * Uses filesystem walks instead of watcher scans for performance.
731
808
  *
732
809
  * @module discovery/scope
733
810
  */
734
811
  /**
735
812
  * Get the scope path prefix for a meta node.
736
- *
737
- * This is the ownerPath — all files under this path are in scope,
738
- * except subtrees owned by child metas.
739
- *
740
- * @param node - The meta node to compute scope for.
741
- * @returns The scope path prefix.
742
813
  */
743
814
  function getScopePrefix(node) {
744
815
  return node.ownerPath;
@@ -746,47 +817,39 @@ function getScopePrefix(node) {
746
817
  /**
747
818
  * Filter a list of file paths to only those in scope for a meta node.
748
819
  *
749
- * Includes files under ownerPath, excludes files under child meta ownerPaths,
750
- * but includes child .meta/meta.json files as rollup inputs.
820
+ * Excludes:
821
+ * - The node's own .meta/ subtree (synthesis outputs are not scope inputs)
822
+ * - Child meta ownerPath subtrees (except child .meta/meta.json for rollups)
751
823
  *
752
- * @param node - The meta node.
753
- * @param files - Array of file paths to filter.
754
- * @returns Filtered array of in-scope file paths.
824
+ * walkFiles already returns normalized forward-slash paths.
755
825
  */
756
826
  function filterInScope(node, files) {
757
827
  const prefix = node.ownerPath + '/';
828
+ const ownMetaPrefix = node.metaPath + '/';
758
829
  const exclusions = node.children.map((c) => c.ownerPath + '/');
759
830
  const childMetaJsons = new Set(node.children.map((c) => c.metaPath + '/meta.json'));
760
831
  return files.filter((f) => {
761
- const normalized = f.split('\\').join('/');
762
832
  // Must be under ownerPath
763
- if (!normalized.startsWith(prefix) && normalized !== node.ownerPath)
833
+ if (!f.startsWith(prefix) && f !== node.ownerPath)
834
+ return false;
835
+ // Exclude own .meta/ subtree (outputs are not inputs)
836
+ if (f.startsWith(ownMetaPrefix))
764
837
  return false;
765
838
  // Check if under a child meta's subtree
766
839
  for (const excl of exclusions) {
767
- if (normalized.startsWith(excl)) {
840
+ if (f.startsWith(excl)) {
768
841
  // Exception: child meta.json files are included as rollup inputs
769
- return childMetaJsons.has(normalized);
842
+ return childMetaJsons.has(f);
770
843
  }
771
844
  }
772
845
  return true;
773
846
  });
774
847
  }
775
848
  /**
776
- * Get all files in scope for a meta node via watcher scan.
777
- *
778
- * Scans the owner path prefix and filters out child meta subtrees,
779
- * keeping only files directly owned by this meta.
780
- *
781
- * @param node - The meta node.
782
- * @param watcher - WatcherClient for scan queries.
783
- * @returns Array of in-scope file paths.
849
+ * Get all files in scope for a meta node via filesystem walk.
784
850
  */
785
- async function getScopeFiles(node, watcher) {
786
- const allScanFiles = await paginatedScan(watcher, {
787
- pathPrefix: node.ownerPath,
788
- });
789
- const allFiles = allScanFiles.map((f) => f.file_path);
851
+ function getScopeFiles(node) {
852
+ const allFiles = walkFiles(node.ownerPath);
790
853
  return {
791
854
  scopeFiles: filterInScope(node, allFiles),
792
855
  allFiles,
@@ -796,22 +859,13 @@ async function getScopeFiles(node, watcher) {
796
859
  * Get files modified since a given timestamp within a meta node's scope.
797
860
  *
798
861
  * If no generatedAt is provided (first run), returns all scope files.
799
- *
800
- * @param node - The meta node.
801
- * @param watcher - WatcherClient for scan queries.
802
- * @param generatedAt - ISO timestamp of last synthesis, or null/undefined for first run.
803
- * @param scopeFiles - Pre-computed scope files (used as fallback for first run).
804
- * @returns Array of modified in-scope file paths.
805
862
  */
806
- async function getDeltaFiles(node, watcher, generatedAt, scopeFiles) {
863
+ function getDeltaFiles(node, generatedAt, scopeFiles) {
807
864
  if (!generatedAt)
808
865
  return scopeFiles;
809
866
  const modifiedAfter = Math.floor(new Date(generatedAt).getTime() / 1000);
810
- const deltaScanFiles = await paginatedScan(watcher, {
811
- pathPrefix: node.ownerPath,
812
- modifiedAfter,
813
- });
814
- return filterInScope(node, deltaScanFiles.map((f) => f.file_path));
867
+ const deltaFiles = walkFiles(node.ownerPath, { modifiedAfter });
868
+ return filterInScope(node, deltaFiles);
815
869
  }
816
870
 
817
871
  /**
@@ -890,7 +944,7 @@ function sleep(ms) {
890
944
  * @module executor/GatewayExecutor
891
945
  */
892
946
  const DEFAULT_POLL_INTERVAL_MS = 5000;
893
- const DEFAULT_TIMEOUT_MS = 600_000; // 10 minutes
947
+ const DEFAULT_TIMEOUT_MS$1 = 600_000; // 10 minutes
894
948
  /**
895
949
  * MetaExecutor that spawns OpenClaw sessions via the gateway's
896
950
  * `/tools/invoke` endpoint.
@@ -903,10 +957,12 @@ class GatewayExecutor {
903
957
  gatewayUrl;
904
958
  apiKey;
905
959
  pollIntervalMs;
960
+ workspaceDir;
906
961
  constructor(options = {}) {
907
962
  this.gatewayUrl = (options.gatewayUrl ?? 'http://127.0.0.1:18789').replace(/\/+$/, '');
908
963
  this.apiKey = options.apiKey;
909
964
  this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
965
+ this.workspaceDir = options.workspaceDir ?? 'J:\\jeeves\\jeeves-meta';
910
966
  }
911
967
  /** Invoke a gateway tool via the /tools/invoke HTTP endpoint. */
912
968
  async invoke(tool, args) {
@@ -931,13 +987,44 @@ class GatewayExecutor {
931
987
  }
932
988
  return data;
933
989
  }
990
+ /** Look up totalTokens for a session via sessions_list. */
991
+ async getSessionTokens(sessionKey) {
992
+ try {
993
+ const result = await this.invoke('sessions_list', {
994
+ limit: 20,
995
+ messageLimit: 0,
996
+ });
997
+ const sessions = (result.result?.details?.sessions ??
998
+ result.result?.sessions ??
999
+ []);
1000
+ const match = sessions.find((s) => s.key === sessionKey);
1001
+ return match?.totalTokens ?? undefined;
1002
+ }
1003
+ catch {
1004
+ return undefined;
1005
+ }
1006
+ }
934
1007
  async spawn(task, options) {
935
- const timeoutSeconds = options?.timeout ?? DEFAULT_TIMEOUT_MS / 1000;
1008
+ const timeoutSeconds = options?.timeout ?? DEFAULT_TIMEOUT_MS$1 / 1000;
936
1009
  const timeoutMs = timeoutSeconds * 1000;
937
1010
  const deadline = Date.now() + timeoutMs;
1011
+ // Ensure workspace dir exists
1012
+ if (!existsSync(this.workspaceDir)) {
1013
+ mkdirSync(this.workspaceDir, { recursive: true });
1014
+ }
1015
+ // Generate unique output path for file-based output
1016
+ const outputId = randomUUID();
1017
+ const outputPath = this.workspaceDir + '/output-' + outputId + '.json';
1018
+ // Append file output instruction to the task
1019
+ const taskWithOutput = task +
1020
+ '\n\n## OUTPUT DELIVERY\n\n' +
1021
+ 'Write your complete output to a file using the Write tool at:\n' +
1022
+ outputPath +
1023
+ '\n\n' +
1024
+ 'Reply with ONLY the file path you wrote to. No other text.';
938
1025
  // Step 1: Spawn the sub-agent session
939
1026
  const spawnResult = await this.invoke('sessions_spawn', {
940
- task,
1027
+ task: taskWithOutput,
941
1028
  label: options?.label ?? 'jeeves-meta-synthesis',
942
1029
  runTimeoutSeconds: timeoutSeconds,
943
1030
  ...(options?.thinking ? { thinking: options.thinking } : {}),
@@ -969,19 +1056,37 @@ class GatewayExecutor {
969
1056
  lastMsg.stopReason &&
970
1057
  lastMsg.stopReason !== 'toolUse' &&
971
1058
  lastMsg.stopReason !== 'error') {
972
- // Sum token usage from all messages
973
- let tokens;
974
- let sum = 0;
975
- for (const msg of msgArray) {
976
- if (msg.usage?.totalTokens)
977
- sum += msg.usage.totalTokens;
1059
+ // Fetch token usage from session metadata
1060
+ const tokens = await this.getSessionTokens(sessionKey);
1061
+ // Read output from file (sub-agent wrote it via Write tool)
1062
+ if (existsSync(outputPath)) {
1063
+ try {
1064
+ const output = readFileSync(outputPath, 'utf8');
1065
+ return { output, tokens };
1066
+ }
1067
+ finally {
1068
+ try {
1069
+ unlinkSync(outputPath);
1070
+ }
1071
+ catch {
1072
+ /* cleanup best-effort */
1073
+ }
1074
+ }
978
1075
  }
979
- if (sum > 0)
980
- tokens = sum;
981
- // Find the last assistant message with content
1076
+ // Fallback: extract from message content if file wasn't written
982
1077
  for (let i = msgArray.length - 1; i >= 0; i--) {
983
- if (msgArray[i].role === 'assistant' && msgArray[i].content) {
984
- return { output: msgArray[i].content, tokens };
1078
+ const msg = msgArray[i];
1079
+ if (msg.role === 'assistant' && msg.content) {
1080
+ const text = typeof msg.content === 'string'
1081
+ ? msg.content
1082
+ : Array.isArray(msg.content)
1083
+ ? msg.content
1084
+ .filter((b) => b.type === 'text' && b.text)
1085
+ .map((b) => b.text)
1086
+ .join('\n')
1087
+ : '';
1088
+ if (text)
1089
+ return { output: text, tokens };
985
1090
  }
986
1091
  }
987
1092
  return { output: '', tokens };
@@ -1061,10 +1166,10 @@ function condenseScopeFiles(files, maxIndividual = 30) {
1061
1166
  * @param watcher - WatcherClient for scope enumeration.
1062
1167
  * @returns The computed context package.
1063
1168
  */
1064
- async function buildContextPackage(node, meta, watcher) {
1169
+ function buildContextPackage(node, meta) {
1065
1170
  // Scope and delta files via watcher scan
1066
- const { scopeFiles } = await getScopeFiles(node, watcher);
1067
- const deltaFiles = await getDeltaFiles(node, watcher, meta._generatedAt, scopeFiles);
1171
+ const { scopeFiles } = getScopeFiles(node);
1172
+ const deltaFiles = getDeltaFiles(node, meta._generatedAt, scopeFiles);
1068
1173
  // Child meta outputs
1069
1174
  const childMetas = {};
1070
1175
  for (const child of node.children) {
@@ -1175,7 +1280,7 @@ function buildBuilderTask(ctx, meta, config) {
1175
1280
  includeSteer: false,
1176
1281
  feedbackHeading: '## FEEDBACK FROM CRITIC',
1177
1282
  });
1178
- sections.push('', '## OUTPUT FORMAT', 'Return a JSON object with:', '- "_content": Markdown narrative synthesis (required)', '- Any additional structured fields as non-underscore keys');
1283
+ sections.push('', '## OUTPUT FORMAT', '', 'Respond with ONLY a JSON object. No explanation, no markdown fences, no text before or after.', '', 'Required schema:', '{', ' "type": "object",', ' "required": ["_content"],', ' "properties": {', ' "_content": { "type": "string", "description": "Markdown narrative synthesis" }', ' },', ' "additionalProperties": true', '}', '', 'Add any structured fields that capture important facts about this entity', '(e.g. status, risks, dependencies, metrics). Use descriptive key names without underscore prefix.', 'The _content field is the only required key — everything else is domain-driven.', '', 'DIAGRAMS: When diagrams would aid understanding, use PlantUML in fenced code blocks (```plantuml).', 'PlantUML is rendered natively by the serving infrastructure. NEVER use ASCII art diagrams.');
1179
1284
  return sections.join('\n');
1180
1285
  }
1181
1286
  /**
@@ -1481,28 +1586,33 @@ function discoverStalestPath(candidates, depthWeight) {
1481
1586
  * @param watcher - WatcherClient instance.
1482
1587
  * @returns True if any file in scope was modified after _generatedAt.
1483
1588
  */
1484
- async function isStale(scopePrefix, meta, watcher) {
1589
+ function isStale(scopePrefix, meta) {
1485
1590
  if (!meta._generatedAt)
1486
1591
  return true; // Never synthesized = stale
1487
1592
  const generatedAtUnix = Math.floor(new Date(meta._generatedAt).getTime() / 1000);
1488
- const result = await watcher.scan({
1489
- pathPrefix: scopePrefix,
1593
+ const modified = walkFiles(scopePrefix, {
1490
1594
  modifiedAfter: generatedAtUnix,
1491
- limit: 1,
1595
+ maxDepth: 1,
1492
1596
  });
1493
- return result.files.length > 0;
1597
+ return modified.length > 0;
1494
1598
  }
1599
+ /** Maximum staleness for never-synthesized metas (1 year in seconds). */
1600
+ const MAX_STALENESS_SECONDS = 365 * 86_400;
1495
1601
  /**
1496
1602
  * Compute actual staleness in seconds (now minus _generatedAt).
1497
1603
  *
1604
+ * Never-synthesized metas are capped at {@link MAX_STALENESS_SECONDS}
1605
+ * (1 year) so that depth weighting can differentiate them. Without
1606
+ * bounding, `Infinity * depthFactor` = `Infinity` for all depths.
1607
+ *
1498
1608
  * @param meta - Current meta.json content.
1499
- * @returns Staleness in seconds, or Infinity if never synthesized.
1609
+ * @returns Staleness in seconds, capped at 1 year for never-synthesized metas.
1500
1610
  */
1501
1611
  function actualStaleness(meta) {
1502
1612
  if (!meta._generatedAt)
1503
- return Infinity;
1613
+ return MAX_STALENESS_SECONDS;
1504
1614
  const generatedMs = new Date(meta._generatedAt).getTime();
1505
- return (Date.now() - generatedMs) / 1000;
1615
+ return Math.min((Date.now() - generatedMs) / 1000, MAX_STALENESS_SECONDS);
1506
1616
  }
1507
1617
  /**
1508
1618
  * Check whether the architect step should be triggered.
@@ -1579,20 +1689,50 @@ function parseArchitectOutput(output) {
1579
1689
  */
1580
1690
  function parseBuilderOutput(output) {
1581
1691
  const trimmed = output.trim();
1582
- // Try to extract JSON from the output (may be wrapped in markdown code fences)
1583
- let jsonStr = trimmed;
1584
- const fenceMatch = /```(?:json)?\s*([\s\S]*?)```/.exec(trimmed);
1585
- if (fenceMatch) {
1586
- jsonStr = fenceMatch[1].trim();
1587
- }
1692
+ // Strategy 1: Try to parse the entire output as JSON directly
1693
+ const direct = tryParseJson(trimmed);
1694
+ if (direct)
1695
+ return direct;
1696
+ // Strategy 2: Try all fenced code blocks (last match first — models often narrate then output)
1697
+ const fencePattern = /```(?:json)?\s*([\s\S]*?)```/g;
1698
+ const fenceMatches = [];
1699
+ let match;
1700
+ while ((match = fencePattern.exec(trimmed)) !== null) {
1701
+ fenceMatches.push(match[1].trim());
1702
+ }
1703
+ // Try last fence first (most likely to be the actual output)
1704
+ for (let i = fenceMatches.length - 1; i >= 0; i--) {
1705
+ const result = tryParseJson(fenceMatches[i]);
1706
+ if (result)
1707
+ return result;
1708
+ }
1709
+ // Strategy 3: Find outermost { ... } braces
1710
+ const firstBrace = trimmed.indexOf('{');
1711
+ const lastBrace = trimmed.lastIndexOf('}');
1712
+ if (firstBrace !== -1 && lastBrace > firstBrace) {
1713
+ const result = tryParseJson(trimmed.substring(firstBrace, lastBrace + 1));
1714
+ if (result)
1715
+ return result;
1716
+ }
1717
+ // Fallback: treat entire output as content
1718
+ return { content: trimmed, fields: {} };
1719
+ }
1720
+ /** Try to parse a string as JSON and extract builder output fields. */
1721
+ function tryParseJson(str) {
1588
1722
  try {
1589
- const parsed = JSON.parse(jsonStr);
1723
+ const raw = JSON.parse(str);
1724
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
1725
+ return null;
1726
+ }
1727
+ const parsed = raw;
1590
1728
  // Extract _content
1591
- const content = typeof parsed._content === 'string'
1592
- ? parsed._content
1593
- : typeof parsed.content === 'string'
1594
- ? parsed.content
1595
- : trimmed;
1729
+ const content = typeof parsed['_content'] === 'string'
1730
+ ? parsed['_content']
1731
+ : typeof parsed['content'] === 'string'
1732
+ ? parsed['content']
1733
+ : null;
1734
+ if (content === null)
1735
+ return null;
1596
1736
  // Extract non-underscore fields
1597
1737
  const fields = {};
1598
1738
  for (const [key, value] of Object.entries(parsed)) {
@@ -1603,8 +1743,7 @@ function parseBuilderOutput(output) {
1603
1743
  return { content, fields };
1604
1744
  }
1605
1745
  catch {
1606
- // Not valid JSON — treat entire output as content
1607
- return { content: trimmed, fields: {} };
1746
+ return null;
1608
1747
  }
1609
1748
  }
1610
1749
  /**
@@ -1665,9 +1804,239 @@ function finalizeCycle(opts) {
1665
1804
  * @param watcher - Watcher HTTP client.
1666
1805
  * @returns Result indicating whether synthesis occurred.
1667
1806
  */
1668
- async function orchestrateOnce(config, executor, watcher, targetPath, onProgress) {
1669
- // Step 1: Discover via watcher scan
1670
- const metaPaths = await discoverMetas(config, watcher);
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
+ /** Run the architect/builder/critic pipeline on a single node. */
1865
+ async function synthesizeNode(node, currentMeta, config, executor, watcher, onProgress) {
1866
+ const architectPrompt = currentMeta._architect ?? config.defaultArchitect;
1867
+ const criticPrompt = currentMeta._critic ?? config.defaultCritic;
1868
+ // Step 5-6: Steer change detection
1869
+ const latestArchive = readLatestArchive(node.metaPath);
1870
+ const steerChanged = hasSteerChanged(currentMeta._steer, latestArchive?._steer, Boolean(latestArchive));
1871
+ // Step 7: Compute context (includes scope files and delta files)
1872
+ const ctx = buildContextPackage(node, currentMeta);
1873
+ // Step 5 (deferred): Structure hash from context scope files
1874
+ const newStructureHash = computeStructureHash(ctx.scopeFiles);
1875
+ const structureChanged = newStructureHash !== currentMeta._structureHash;
1876
+ // Step 8: Architect (conditional)
1877
+ const architectTriggered = isArchitectTriggered(currentMeta, structureChanged, steerChanged, config.architectEvery);
1878
+ let builderBrief = currentMeta._builder ?? '';
1879
+ let synthesisCount = currentMeta._synthesisCount ?? 0;
1880
+ let stepError = null;
1881
+ let architectTokens;
1882
+ let builderTokens;
1883
+ let criticTokens;
1884
+ if (architectTriggered) {
1885
+ try {
1886
+ await onProgress?.({
1887
+ type: 'phase_start',
1888
+ path: node.ownerPath,
1889
+ phase: 'architect',
1890
+ });
1891
+ const phaseStart = Date.now();
1892
+ const architectTask = buildArchitectTask(ctx, currentMeta, config);
1893
+ const architectResult = await executor.spawn(architectTask, {
1894
+ thinking: config.thinking,
1895
+ timeout: config.architectTimeout,
1896
+ });
1897
+ builderBrief = parseArchitectOutput(architectResult.output);
1898
+ architectTokens = architectResult.tokens;
1899
+ synthesisCount = 0;
1900
+ await onProgress?.({
1901
+ type: 'phase_complete',
1902
+ path: node.ownerPath,
1903
+ phase: 'architect',
1904
+ tokens: architectTokens,
1905
+ durationMs: Date.now() - phaseStart,
1906
+ });
1907
+ }
1908
+ catch (err) {
1909
+ stepError = toMetaError('architect', err);
1910
+ if (!currentMeta._builder) {
1911
+ // No cached builder — cycle fails
1912
+ finalizeCycle({
1913
+ metaPath: node.metaPath,
1914
+ current: currentMeta,
1915
+ config,
1916
+ architect: architectPrompt,
1917
+ builder: '',
1918
+ critic: criticPrompt,
1919
+ builderOutput: null,
1920
+ feedback: null,
1921
+ structureHash: newStructureHash,
1922
+ synthesisCount,
1923
+ error: stepError,
1924
+ architectTokens,
1925
+ });
1926
+ return {
1927
+ synthesized: true,
1928
+ metaPath: node.metaPath,
1929
+ error: stepError,
1930
+ };
1931
+ }
1932
+ // Has cached builder — continue with existing
1933
+ }
1934
+ }
1935
+ // Step 9: Builder
1936
+ const metaForBuilder = { ...currentMeta, _builder: builderBrief };
1937
+ let builderOutput = null;
1938
+ try {
1939
+ await onProgress?.({
1940
+ type: 'phase_start',
1941
+ path: node.ownerPath,
1942
+ phase: 'builder',
1943
+ });
1944
+ const builderStart = Date.now();
1945
+ const builderTask = buildBuilderTask(ctx, metaForBuilder, config);
1946
+ const builderResult = await executor.spawn(builderTask, {
1947
+ thinking: config.thinking,
1948
+ timeout: config.builderTimeout,
1949
+ });
1950
+ builderOutput = parseBuilderOutput(builderResult.output);
1951
+ builderTokens = builderResult.tokens;
1952
+ synthesisCount++;
1953
+ await onProgress?.({
1954
+ type: 'phase_complete',
1955
+ path: node.ownerPath,
1956
+ phase: 'builder',
1957
+ tokens: builderTokens,
1958
+ durationMs: Date.now() - builderStart,
1959
+ });
1960
+ }
1961
+ catch (err) {
1962
+ stepError = toMetaError('builder', err);
1963
+ return { synthesized: true, metaPath: node.metaPath, error: stepError };
1964
+ }
1965
+ // Step 10: Critic
1966
+ const metaForCritic = {
1967
+ ...currentMeta,
1968
+ _content: builderOutput.content,
1969
+ };
1970
+ let feedback = null;
1971
+ try {
1972
+ await onProgress?.({
1973
+ type: 'phase_start',
1974
+ path: node.ownerPath,
1975
+ phase: 'critic',
1976
+ });
1977
+ const criticStart = Date.now();
1978
+ const criticTask = buildCriticTask(ctx, metaForCritic, config);
1979
+ const criticResult = await executor.spawn(criticTask, {
1980
+ thinking: config.thinking,
1981
+ timeout: config.criticTimeout,
1982
+ });
1983
+ feedback = parseCriticOutput(criticResult.output);
1984
+ criticTokens = criticResult.tokens;
1985
+ stepError = null; // Clear any architect error on full success
1986
+ await onProgress?.({
1987
+ type: 'phase_complete',
1988
+ path: node.ownerPath,
1989
+ phase: 'critic',
1990
+ tokens: criticTokens,
1991
+ durationMs: Date.now() - criticStart,
1992
+ });
1993
+ }
1994
+ catch (err) {
1995
+ stepError = stepError ?? toMetaError('critic', err);
1996
+ }
1997
+ // Steps 11-12: Merge, archive, prune
1998
+ finalizeCycle({
1999
+ metaPath: node.metaPath,
2000
+ current: currentMeta,
2001
+ config,
2002
+ architect: architectPrompt,
2003
+ builder: builderBrief,
2004
+ critic: criticPrompt,
2005
+ builderOutput,
2006
+ feedback,
2007
+ structureHash: newStructureHash,
2008
+ synthesisCount,
2009
+ error: stepError,
2010
+ architectTokens,
2011
+ builderTokens,
2012
+ criticTokens,
2013
+ });
2014
+ return {
2015
+ synthesized: true,
2016
+ metaPath: node.metaPath,
2017
+ error: stepError ?? undefined,
2018
+ };
2019
+ }
2020
+ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress, logger) {
2021
+ // When targetPath is provided, skip the expensive full discovery scan.
2022
+ // Build a minimal node from the filesystem instead.
2023
+ if (targetPath) {
2024
+ const normalizedTarget = normalizePath(targetPath);
2025
+ const targetMetaJson = join(normalizedTarget, 'meta.json');
2026
+ if (!existsSync(targetMetaJson))
2027
+ return { synthesized: false };
2028
+ const node = buildMinimalNode(normalizedTarget);
2029
+ if (!acquireLock(node.metaPath))
2030
+ return { synthesized: false };
2031
+ try {
2032
+ const currentMeta = JSON.parse(readFileSync(targetMetaJson, 'utf8'));
2033
+ return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress);
2034
+ }
2035
+ finally {
2036
+ releaseLock(node.metaPath);
2037
+ }
2038
+ }
2039
+ const metaPaths = await discoverMetas(config, watcher, logger);
1671
2040
  if (metaPaths.length === 0)
1672
2041
  return { synthesized: false };
1673
2042
  // Read meta.json for each discovered meta
@@ -1687,23 +2056,15 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
1687
2056
  if (validPaths.length === 0)
1688
2057
  return { synthesized: false };
1689
2058
  const tree = buildOwnershipTree(validPaths);
1690
- // If targetPath specified, skip candidate selection — go directly to that meta
1691
- let targetNode;
1692
- if (targetPath) {
1693
- const normalized = normalizePath(targetPath);
1694
- targetNode = findNode(tree, normalized) ?? undefined;
1695
- if (!targetNode)
1696
- return { synthesized: false };
1697
- }
1698
2059
  // Steps 3-4: Staleness check + candidate selection
1699
2060
  const candidates = [];
1700
- for (const node of tree.nodes.values()) {
1701
- const meta = metas.get(node.metaPath);
2061
+ for (const treeNode of tree.nodes.values()) {
2062
+ const meta = metas.get(treeNode.metaPath);
1702
2063
  if (!meta)
1703
- continue; // Node not in metas map (e.g. unreadable meta.json)
2064
+ continue;
1704
2065
  const staleness = actualStaleness(meta);
1705
2066
  if (staleness > 0) {
1706
- candidates.push({ node, meta, actualStaleness: staleness });
2067
+ candidates.push({ node: treeNode, meta, actualStaleness: staleness });
1707
2068
  }
1708
2069
  }
1709
2070
  const weighted = computeEffectiveStaleness(candidates, config.depthWeight);
@@ -1716,7 +2077,7 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
1716
2077
  for (const candidate of ranked) {
1717
2078
  if (!acquireLock(candidate.node.metaPath))
1718
2079
  continue;
1719
- const verifiedStale = await isStale(getScopePrefix(candidate.node), candidate.meta, watcher);
2080
+ const verifiedStale = isStale(getScopePrefix(candidate.node), candidate.meta);
1720
2081
  if (!verifiedStale && candidate.meta._generatedAt) {
1721
2082
  // Bump _generatedAt so it doesn't win next cycle
1722
2083
  const metaFilePath = join(candidate.node.metaPath, 'meta.json');
@@ -1731,169 +2092,12 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
1731
2092
  winner = candidate;
1732
2093
  break;
1733
2094
  }
1734
- if (!winner && !targetNode)
1735
- return { synthesized: false };
1736
- const node = targetNode ?? winner.node;
1737
- // For targeted path, acquire lock now (candidate selection already locked for stalest)
1738
- if (targetNode && !acquireLock(node.metaPath)) {
2095
+ if (!winner)
1739
2096
  return { synthesized: false };
1740
- }
2097
+ const node = winner.node;
1741
2098
  try {
1742
- // Re-read meta after lock (may have changed)
1743
2099
  const currentMeta = JSON.parse(readFileSync(join(node.metaPath, 'meta.json'), 'utf8'));
1744
- const architectPrompt = currentMeta._architect ?? config.defaultArchitect;
1745
- const criticPrompt = currentMeta._critic ?? config.defaultCritic;
1746
- // Step 5-6: Steer change detection
1747
- const latestArchive = readLatestArchive(node.metaPath);
1748
- const steerChanged = hasSteerChanged(currentMeta._steer, latestArchive?._steer, Boolean(latestArchive));
1749
- // Step 7: Compute context (includes scope files and delta files)
1750
- const ctx = await buildContextPackage(node, currentMeta, watcher);
1751
- // Step 5 (deferred): Structure hash from context scope files
1752
- const newStructureHash = computeStructureHash(ctx.scopeFiles);
1753
- const structureChanged = newStructureHash !== currentMeta._structureHash;
1754
- // Step 8: Architect (conditional)
1755
- const architectTriggered = isArchitectTriggered(currentMeta, structureChanged, steerChanged, config.architectEvery);
1756
- let builderBrief = currentMeta._builder ?? '';
1757
- let synthesisCount = currentMeta._synthesisCount ?? 0;
1758
- let stepError = null;
1759
- let architectTokens;
1760
- let builderTokens;
1761
- let criticTokens;
1762
- if (architectTriggered) {
1763
- try {
1764
- await onProgress?.({
1765
- type: 'phase_start',
1766
- metaPath: node.metaPath,
1767
- phase: 'architect',
1768
- });
1769
- const phaseStart = Date.now();
1770
- const architectTask = buildArchitectTask(ctx, currentMeta, config);
1771
- const architectResult = await executor.spawn(architectTask, {
1772
- thinking: config.thinking,
1773
- timeout: config.architectTimeout,
1774
- });
1775
- builderBrief = parseArchitectOutput(architectResult.output);
1776
- architectTokens = architectResult.tokens;
1777
- synthesisCount = 0;
1778
- await onProgress?.({
1779
- type: 'phase_complete',
1780
- metaPath: node.metaPath,
1781
- phase: 'architect',
1782
- tokens: architectTokens,
1783
- durationMs: Date.now() - phaseStart,
1784
- });
1785
- }
1786
- catch (err) {
1787
- stepError = toMetaError('architect', err);
1788
- if (!currentMeta._builder) {
1789
- // No cached builder — cycle fails
1790
- finalizeCycle({
1791
- metaPath: node.metaPath,
1792
- current: currentMeta,
1793
- config,
1794
- architect: architectPrompt,
1795
- builder: '',
1796
- critic: criticPrompt,
1797
- builderOutput: null,
1798
- feedback: null,
1799
- structureHash: newStructureHash,
1800
- synthesisCount,
1801
- error: stepError,
1802
- architectTokens,
1803
- });
1804
- return {
1805
- synthesized: true,
1806
- metaPath: node.metaPath,
1807
- error: stepError,
1808
- };
1809
- }
1810
- // Has cached builder — continue with existing
1811
- }
1812
- }
1813
- // Step 9: Builder
1814
- const metaForBuilder = { ...currentMeta, _builder: builderBrief };
1815
- let builderOutput = null;
1816
- try {
1817
- await onProgress?.({
1818
- type: 'phase_start',
1819
- metaPath: node.metaPath,
1820
- phase: 'builder',
1821
- });
1822
- const builderStart = Date.now();
1823
- const builderTask = buildBuilderTask(ctx, metaForBuilder, config);
1824
- const builderResult = await executor.spawn(builderTask, {
1825
- thinking: config.thinking,
1826
- timeout: config.builderTimeout,
1827
- });
1828
- builderOutput = parseBuilderOutput(builderResult.output);
1829
- builderTokens = builderResult.tokens;
1830
- synthesisCount++;
1831
- await onProgress?.({
1832
- type: 'phase_complete',
1833
- metaPath: node.metaPath,
1834
- phase: 'builder',
1835
- tokens: builderTokens,
1836
- durationMs: Date.now() - builderStart,
1837
- });
1838
- }
1839
- catch (err) {
1840
- stepError = toMetaError('builder', err);
1841
- return { synthesized: true, metaPath: node.metaPath, error: stepError };
1842
- }
1843
- // Step 10: Critic
1844
- const metaForCritic = {
1845
- ...currentMeta,
1846
- _content: builderOutput.content,
1847
- };
1848
- let feedback = null;
1849
- try {
1850
- await onProgress?.({
1851
- type: 'phase_start',
1852
- metaPath: node.metaPath,
1853
- phase: 'critic',
1854
- });
1855
- const criticStart = Date.now();
1856
- const criticTask = buildCriticTask(ctx, metaForCritic, config);
1857
- const criticResult = await executor.spawn(criticTask, {
1858
- thinking: config.thinking,
1859
- timeout: config.criticTimeout,
1860
- });
1861
- feedback = parseCriticOutput(criticResult.output);
1862
- criticTokens = criticResult.tokens;
1863
- stepError = null; // Clear any architect error on full success
1864
- await onProgress?.({
1865
- type: 'phase_complete',
1866
- metaPath: node.metaPath,
1867
- phase: 'critic',
1868
- tokens: criticTokens,
1869
- durationMs: Date.now() - criticStart,
1870
- });
1871
- }
1872
- catch (err) {
1873
- stepError = stepError ?? toMetaError('critic', err);
1874
- }
1875
- // Steps 11-12: Merge, archive, prune
1876
- finalizeCycle({
1877
- metaPath: node.metaPath,
1878
- current: currentMeta,
1879
- config,
1880
- architect: architectPrompt,
1881
- builder: builderBrief,
1882
- critic: criticPrompt,
1883
- builderOutput,
1884
- feedback,
1885
- structureHash: newStructureHash,
1886
- synthesisCount,
1887
- error: stepError,
1888
- architectTokens,
1889
- builderTokens,
1890
- criticTokens,
1891
- });
1892
- return {
1893
- synthesized: true,
1894
- metaPath: node.metaPath,
1895
- error: stepError ?? undefined,
1896
- };
2100
+ return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress);
1897
2101
  }
1898
2102
  finally {
1899
2103
  // Step 13: Release lock
@@ -1912,8 +2116,8 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
1912
2116
  * @param targetPath - Optional: specific meta/owner path to synthesize instead of stalest candidate.
1913
2117
  * @returns Array with a single result.
1914
2118
  */
1915
- async function orchestrate(config, executor, watcher, targetPath, onProgress) {
1916
- const result = await orchestrateOnce(config, executor, watcher, targetPath, onProgress);
2119
+ async function orchestrate(config, executor, watcher, targetPath, onProgress, logger) {
2120
+ const result = await orchestrateOnce(config, executor, watcher, targetPath, onProgress, logger);
1917
2121
  return [result];
1918
2122
  }
1919
2123
 
@@ -1922,9 +2126,12 @@ async function orchestrate(config, executor, watcher, targetPath, onProgress) {
1922
2126
  *
1923
2127
  * @module progress
1924
2128
  */
2129
+ function formatNumber(n) {
2130
+ return n.toLocaleString('en-US');
2131
+ }
1925
2132
  function formatSeconds(durationMs) {
1926
2133
  const seconds = durationMs / 1000;
1927
- return seconds.toFixed(1) + 's';
2134
+ return Math.round(seconds).toString() + 's';
1928
2135
  }
1929
2136
  function titleCasePhase(phase) {
1930
2137
  return phase.charAt(0).toUpperCase() + phase.slice(1);
@@ -1932,32 +2139,30 @@ function titleCasePhase(phase) {
1932
2139
  function formatProgressEvent(event) {
1933
2140
  switch (event.type) {
1934
2141
  case 'synthesis_start':
1935
- return `🔬 Started meta synthesis: ${event.metaPath}`;
2142
+ return `🔬 Started meta synthesis: ${event.path}`;
1936
2143
  case 'phase_start': {
1937
2144
  if (!event.phase) {
1938
- return ` ⚙️ Phase started: ${event.metaPath}`;
2145
+ return ' ⚙️ Phase started';
1939
2146
  }
1940
2147
  return ` ⚙️ ${titleCasePhase(event.phase)} phase started`;
1941
2148
  }
1942
2149
  case 'phase_complete': {
1943
2150
  const phase = event.phase ? titleCasePhase(event.phase) : 'Phase';
1944
2151
  const tokens = event.tokens ?? 0;
1945
- const duration = event.durationMs !== undefined
1946
- ? formatSeconds(event.durationMs)
1947
- : '0.0s';
1948
- return ` ✅ ${phase} phase complete (${String(tokens)} tokens / ${duration})`;
2152
+ const duration = event.durationMs !== undefined ? formatSeconds(event.durationMs) : '0s';
2153
+ return ` ✅ ${phase} complete (${formatNumber(tokens)} tokens / ${duration})`;
1949
2154
  }
1950
2155
  case 'synthesis_complete': {
1951
2156
  const tokens = event.tokens ?? 0;
1952
2157
  const duration = event.durationMs !== undefined
1953
2158
  ? formatSeconds(event.durationMs)
1954
2159
  : '0.0s';
1955
- return `✅ Completed: ${event.metaPath} (${String(tokens)} tokens / ${duration})`;
2160
+ return `✅ Completed: ${event.path} (${formatNumber(tokens)} tokens / ${duration})`;
1956
2161
  }
1957
2162
  case 'error': {
1958
2163
  const phase = event.phase ? `${titleCasePhase(event.phase)} ` : '';
1959
2164
  const error = event.error ?? 'Unknown error';
1960
- return `❌ Synthesis failed at ${phase}phase: ${event.metaPath}\n Error: ${error}`;
2165
+ return `❌ Synthesis failed at ${phase}phase: ${event.path}\n Error: ${error}`;
1961
2166
  }
1962
2167
  default: {
1963
2168
  return 'Unknown progress event';
@@ -2135,7 +2340,7 @@ class Scheduler {
2135
2340
  */
2136
2341
  async discoverStalest() {
2137
2342
  try {
2138
- const result = await listMetas(this.config, this.watcher);
2343
+ const result = await listMetas(this.config, this.watcher, this.logger);
2139
2344
  const stale = result.entries
2140
2345
  .filter((e) => e.stalenessSeconds > 0)
2141
2346
  .map((e) => ({
@@ -2421,7 +2626,7 @@ function registerMetasRoutes(app, deps) {
2421
2626
  app.get('/metas', async (request) => {
2422
2627
  const query = metasQuerySchema.parse(request.query);
2423
2628
  const { config, watcher } = deps;
2424
- const result = await listMetas(config, watcher);
2629
+ const result = await listMetas(config, watcher, request.log);
2425
2630
  let entries = result.entries;
2426
2631
  // Apply filters
2427
2632
  if (query.pathPrefix) {
@@ -2484,7 +2689,7 @@ function registerMetasRoutes(app, deps) {
2484
2689
  const query = metaDetailQuerySchema.parse(request.query);
2485
2690
  const { config, watcher } = deps;
2486
2691
  const targetPath = normalizePath(decodeURIComponent(request.params.path));
2487
- const result = await listMetas(config, watcher);
2692
+ const result = await listMetas(config, watcher, request.log);
2488
2693
  const targetNode = findNode(result.tree, targetPath);
2489
2694
  if (!targetNode) {
2490
2695
  return reply.status(404).send({
@@ -2517,7 +2722,7 @@ function registerMetasRoutes(app, deps) {
2517
2722
  return r;
2518
2723
  };
2519
2724
  // Compute scope
2520
- const { scopeFiles, allFiles } = await getScopeFiles(targetNode, watcher);
2725
+ const { scopeFiles, allFiles } = getScopeFiles(targetNode);
2521
2726
  // Compute staleness
2522
2727
  const metaTyped = meta;
2523
2728
  const staleSeconds = metaTyped._generatedAt
@@ -2564,7 +2769,7 @@ function registerPreviewRoute(app, deps) {
2564
2769
  const query = request.query;
2565
2770
  let result;
2566
2771
  try {
2567
- result = await listMetas(config, watcher);
2772
+ result = await listMetas(config, watcher, request.log);
2568
2773
  }
2569
2774
  catch {
2570
2775
  return reply.status(503).send({
@@ -2600,14 +2805,14 @@ function registerPreviewRoute(app, deps) {
2600
2805
  }
2601
2806
  const meta = JSON.parse(readFileSync(join(targetNode.metaPath, 'meta.json'), 'utf8'));
2602
2807
  // Scope files
2603
- const { scopeFiles } = await getScopeFiles(targetNode, watcher);
2808
+ const { scopeFiles } = getScopeFiles(targetNode);
2604
2809
  const structureHash = computeStructureHash(scopeFiles);
2605
2810
  const structureChanged = structureHash !== meta._structureHash;
2606
2811
  const latestArchive = readLatestArchive(targetNode.metaPath);
2607
2812
  const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
2608
2813
  const architectTriggered = isArchitectTriggered(meta, structureChanged, steerChanged, config.architectEvery);
2609
2814
  // Delta files
2610
- const deltaFiles = await getDeltaFiles(targetNode, watcher, meta._generatedAt, scopeFiles);
2815
+ const deltaFiles = getDeltaFiles(targetNode, meta._generatedAt, scopeFiles);
2611
2816
  // EMA token estimates
2612
2817
  const estimatedTokens = {
2613
2818
  architect: meta._architectTokensAvg ?? meta._architectTokens ?? 0,
@@ -2701,7 +2906,7 @@ async function checkDependency(url, path) {
2701
2906
  }
2702
2907
  function registerStatusRoute(app, deps) {
2703
2908
  app.get('/status', async () => {
2704
- const { config, queue, scheduler, stats, watcher } = deps;
2909
+ const { config, queue, scheduler, stats } = deps;
2705
2910
  // On-demand dependency checks
2706
2911
  const [watcherHealth, gatewayHealth] = await Promise.all([
2707
2912
  checkDependency(config.watcherUrl, '/status'),
@@ -2722,20 +2927,8 @@ function registerStatusRoute(app, deps) {
2722
2927
  else {
2723
2928
  status = 'idle';
2724
2929
  }
2725
- // Metas summary from listMetas (already computed)
2726
- let metasSummary = { total: 0, stale: 0, errors: 0, neverSynthesized: 0 };
2727
- try {
2728
- const result = await listMetas(config, watcher);
2729
- metasSummary = {
2730
- total: result.summary.total,
2731
- stale: result.summary.stale,
2732
- errors: result.summary.errors,
2733
- neverSynthesized: result.summary.neverSynthesized,
2734
- };
2735
- }
2736
- catch {
2737
- // Watcher unreachable — leave zeros
2738
- }
2930
+ // Metas summary is expensive (paginated watcher scan + disk reads).
2931
+ // Use GET /metas for full inventory; status is a lightweight health check.
2739
2932
  return {
2740
2933
  service: 'jeeves-meta',
2741
2934
  version: '0.4.0',
@@ -2758,7 +2951,6 @@ function registerStatusRoute(app, deps) {
2758
2951
  watcher: watcherHealth,
2759
2952
  gateway: gatewayHealth,
2760
2953
  },
2761
- metas: metasSummary,
2762
2954
  };
2763
2955
  });
2764
2956
  }
@@ -2784,7 +2976,7 @@ function registerSynthesizeRoute(app, deps) {
2784
2976
  // Discover stalest candidate
2785
2977
  let result;
2786
2978
  try {
2787
- result = await listMetas(config, watcher);
2979
+ result = await listMetas(config, watcher, request.log);
2788
2980
  }
2789
2981
  catch {
2790
2982
  return reply.status(503).send({
@@ -2945,6 +3137,10 @@ function buildMetaRules(config) {
2945
3137
  type: 'string',
2946
3138
  set: '{{json._error.step}}',
2947
3139
  },
3140
+ generated_at: {
3141
+ type: 'string',
3142
+ set: '{{json._generatedAt}}',
3143
+ },
2948
3144
  generated_at_unix: {
2949
3145
  type: 'integer',
2950
3146
  set: '{{toUnix json._generatedAt}}',
@@ -2957,16 +3153,7 @@ function buildMetaRules(config) {
2957
3153
  },
2958
3154
  ],
2959
3155
  render: {
2960
- frontmatter: [
2961
- 'meta_id',
2962
- 'meta_steer',
2963
- 'generated_at_unix',
2964
- 'meta_depth',
2965
- 'meta_emphasis',
2966
- 'meta_architect_tokens',
2967
- 'meta_builder_tokens',
2968
- 'meta_critic_tokens',
2969
- ],
3156
+ frontmatter: ['meta_id', 'generated_at', '*', '!has_error'],
2970
3157
  body: [{ path: 'json._content', heading: 1, label: 'Synthesis' }],
2971
3158
  },
2972
3159
  renderAs: 'md',
@@ -3116,7 +3303,10 @@ class RuleRegistrar {
3116
3303
  * @returns Configured Fastify instance (not yet listening).
3117
3304
  */
3118
3305
  function createServer(options) {
3119
- const app = Fastify({ logger: options.logger });
3306
+ // Fastify 5 requires `loggerInstance` for external pino loggers
3307
+ const app = Fastify({
3308
+ loggerInstance: options.logger,
3309
+ });
3120
3310
  registerRoutes(app, {
3121
3311
  config: options.config,
3122
3312
  logger: options.logger,
@@ -3197,6 +3387,7 @@ function registerShutdownHandlers(deps) {
3197
3387
  const DEFAULT_MAX_RETRIES = 3;
3198
3388
  const DEFAULT_BACKOFF_BASE_MS = 1000;
3199
3389
  const DEFAULT_BACKOFF_FACTOR = 4;
3390
+ const DEFAULT_TIMEOUT_MS = 30_000;
3200
3391
  /** Check if an error is transient (worth retrying). */
3201
3392
  function isTransient(status) {
3202
3393
  return status >= 500 || status === 408 || status === 429;
@@ -3209,11 +3400,13 @@ class HttpWatcherClient {
3209
3400
  maxRetries;
3210
3401
  backoffBaseMs;
3211
3402
  backoffFactor;
3403
+ timeoutMs;
3212
3404
  constructor(options) {
3213
3405
  this.baseUrl = options.baseUrl.replace(/\/+$/, '');
3214
3406
  this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
3215
3407
  this.backoffBaseMs = options.backoffBaseMs ?? DEFAULT_BACKOFF_BASE_MS;
3216
3408
  this.backoffFactor = options.backoffFactor ?? DEFAULT_BACKOFF_FACTOR;
3409
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
3217
3410
  }
3218
3411
  /** POST JSON with retry. */
3219
3412
  async post(endpoint, body) {
@@ -3223,6 +3416,7 @@ class HttpWatcherClient {
3223
3416
  method: 'POST',
3224
3417
  headers: { 'Content-Type': 'application/json' },
3225
3418
  body: JSON.stringify(body),
3419
+ signal: AbortSignal.timeout(this.timeoutMs),
3226
3420
  });
3227
3421
  if (res.ok) {
3228
3422
  return res.json();
@@ -3363,9 +3557,11 @@ async function startService(config, configPath) {
3363
3557
  const synthesizeFn = async (path) => {
3364
3558
  const startMs = Date.now();
3365
3559
  let cycleTokens = 0;
3560
+ // Strip .meta suffix for human-readable progress reporting
3561
+ const ownerPath = path.replace(/\/?\.meta\/?$/, '');
3366
3562
  await progress.report({
3367
3563
  type: 'synthesis_start',
3368
- metaPath: path,
3564
+ path: ownerPath,
3369
3565
  });
3370
3566
  try {
3371
3567
  const results = await orchestrate(config, executor, watcher, path, async (evt) => {
@@ -3387,7 +3583,7 @@ async function startService(config, configPath) {
3387
3583
  stats.totalErrors++;
3388
3584
  await progress.report({
3389
3585
  type: 'error',
3390
- metaPath: path,
3586
+ path: ownerPath,
3391
3587
  error: result.error.message,
3392
3588
  });
3393
3589
  }
@@ -3395,7 +3591,7 @@ async function startService(config, configPath) {
3395
3591
  scheduler.resetBackoff();
3396
3592
  await progress.report({
3397
3593
  type: 'synthesis_complete',
3398
- metaPath: path,
3594
+ path: ownerPath,
3399
3595
  tokens: cycleTokens,
3400
3596
  durationMs,
3401
3597
  });
@@ -3406,7 +3602,7 @@ async function startService(config, configPath) {
3406
3602
  const message = err instanceof Error ? err.message : String(err);
3407
3603
  await progress.report({
3408
3604
  type: 'error',
3409
- metaPath: path,
3605
+ path: ownerPath,
3410
3606
  error: message,
3411
3607
  });
3412
3608
  throw err;