@karmaniverous/jeeves-meta 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,7 +1,9 @@
1
- import { readdirSync, unlinkSync, readFileSync, mkdirSync, writeFileSync, existsSync, copyFileSync, watchFile } from 'node:fs';
2
- import { join, dirname, relative } from 'node:path';
1
+ import { readdirSync, unlinkSync, readFileSync, mkdirSync, writeFileSync, existsSync, statSync, copyFileSync, watchFile } from 'node:fs';
2
+ import { join, dirname, resolve, relative } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
3
4
  import { z } from 'zod';
4
5
  import { createHash, randomUUID } from 'node:crypto';
6
+ import { tmpdir } from 'node:os';
5
7
  import pino from 'pino';
6
8
  import { Cron } from 'croner';
7
9
  import Fastify from 'fastify';
@@ -102,6 +104,42 @@ function createSnapshot(metaPath, meta) {
102
104
  return archiveFile;
103
105
  }
104
106
 
107
+ /**
108
+ * Shared constants for the jeeves-meta service package.
109
+ *
110
+ * @module constants
111
+ */
112
+ /** Default HTTP port for the jeeves-meta service. */
113
+ const DEFAULT_PORT = 1938;
114
+ /** Default port as a string (for Commander CLI defaults). */
115
+ const DEFAULT_PORT_STR = String(DEFAULT_PORT);
116
+ /** Service name identifier. */
117
+ const SERVICE_NAME = 'jeeves-meta';
118
+ /** Service version, read from package.json at startup. */
119
+ const SERVICE_VERSION = (() => {
120
+ try {
121
+ const dir = dirname(fileURLToPath(import.meta.url));
122
+ // Walk up to find package.json (works from src/ or dist/)
123
+ for (const candidate of [
124
+ resolve(dir, '..', 'package.json'),
125
+ resolve(dir, '..', '..', 'package.json'),
126
+ ]) {
127
+ try {
128
+ const pkg = JSON.parse(readFileSync(candidate, 'utf8'));
129
+ if (pkg.version)
130
+ return pkg.version;
131
+ }
132
+ catch {
133
+ // try next candidate
134
+ }
135
+ }
136
+ return 'unknown';
137
+ }
138
+ catch {
139
+ return 'unknown';
140
+ }
141
+ })();
142
+
105
143
  /**
106
144
  * Zod schema for jeeves-meta service configuration.
107
145
  *
@@ -285,14 +323,29 @@ function normalizePath(p) {
285
323
  * @param params - Base scan parameters (cursor is managed internally).
286
324
  * @returns All matching files across all pages.
287
325
  */
288
- async function paginatedScan(watcher, params) {
326
+ async function paginatedScan(watcher, params, logger) {
289
327
  const allFiles = [];
290
328
  let cursor;
329
+ let pageCount = 0;
330
+ const start = Date.now();
291
331
  do {
332
+ const pageStart = Date.now();
292
333
  const result = await watcher.scan({ ...params, cursor });
293
334
  allFiles.push(...result.files);
335
+ pageCount++;
336
+ logger?.debug({
337
+ page: pageCount,
338
+ files: result.files.length,
339
+ pageMs: Date.now() - pageStart,
340
+ hasNext: Boolean(result.next),
341
+ }, 'paginatedScan page');
294
342
  cursor = result.next;
295
343
  } while (cursor);
344
+ logger?.debug({
345
+ pages: pageCount,
346
+ totalFiles: allFiles.length,
347
+ totalMs: Date.now() - start,
348
+ }, 'paginatedScan complete');
296
349
  return allFiles;
297
350
  }
298
351
 
@@ -359,12 +412,9 @@ function buildMetaFilter(config) {
359
412
  * @param watcher - WatcherClient for scan queries.
360
413
  * @returns Array of normalized .meta/ directory paths.
361
414
  */
362
- async function discoverMetas(config, watcher) {
415
+ async function discoverMetas(config, watcher, logger) {
363
416
  const filter = buildMetaFilter(config);
364
- const scanFiles = await paginatedScan(watcher, {
365
- filter,
366
- fields: ['file_path'],
367
- });
417
+ const scanFiles = await paginatedScan(watcher, { filter, fields: ['file_path'] }, logger);
368
418
  // Deduplicate by .meta/ directory path (handles multi-chunk files)
369
419
  const seen = new Set();
370
420
  const metaPaths = [];
@@ -592,6 +642,8 @@ function findNode(tree, targetPath) {
592
642
  *
593
643
  * @module discovery/listMetas
594
644
  */
645
+ /** Maximum staleness for never-synthesized metas (1 year in seconds). */
646
+ const MAX_STALENESS_SECONDS$1 = 365 * 86_400;
595
647
  /**
596
648
  * Discover, deduplicate, and enrich all metas.
597
649
  *
@@ -603,9 +655,9 @@ function findNode(tree, targetPath) {
603
655
  * @param watcher - Watcher HTTP client for discovery.
604
656
  * @returns Enriched meta list with summary statistics and ownership tree.
605
657
  */
606
- async function listMetas(config, watcher) {
658
+ async function listMetas(config, watcher, logger) {
607
659
  // Step 1: Discover deduplicated meta paths via watcher scan
608
- const metaPaths = await discoverMetas(config, watcher);
660
+ const metaPaths = await discoverMetas(config, watcher, logger);
609
661
  // Step 2: Build ownership tree
610
662
  const tree = buildOwnershipTree(metaPaths);
611
663
  // Step 3: Read and enrich each meta from disk
@@ -638,7 +690,7 @@ async function listMetas(config, watcher) {
638
690
  // Compute staleness
639
691
  let stalenessSeconds;
640
692
  if (neverSynth) {
641
- stalenessSeconds = Infinity;
693
+ stalenessSeconds = MAX_STALENESS_SECONDS$1;
642
694
  }
643
695
  else {
644
696
  const genAt = new Date(meta._generatedAt).getTime();
@@ -669,11 +721,7 @@ async function listMetas(config, watcher) {
669
721
  }
670
722
  // Track stalest (effective staleness for scheduling)
671
723
  const depthFactor = Math.pow(1 + config.depthWeight, depth);
672
- const effectiveStaleness = (stalenessSeconds === Infinity
673
- ? Number.MAX_SAFE_INTEGER
674
- : stalenessSeconds) *
675
- depthFactor *
676
- emphasis;
724
+ const effectiveStaleness = stalenessSeconds * depthFactor * emphasis;
677
725
  if (effectiveStaleness > stalestEffective) {
678
726
  stalestEffective = effectiveStaleness;
679
727
  stalestPath = node.metaPath;
@@ -715,22 +763,83 @@ async function listMetas(config, watcher) {
715
763
  };
716
764
  }
717
765
 
766
+ /**
767
+ * Recursive filesystem walker for file enumeration.
768
+ *
769
+ * Replaces paginated watcher scans for scope/delta/staleness checks.
770
+ * Returns normalized forward-slash paths.
771
+ *
772
+ * @module walkFiles
773
+ */
774
+ /** Default directory names to always skip. */
775
+ const DEFAULT_SKIP = new Set([
776
+ 'node_modules',
777
+ '.git',
778
+ '.rollup.cache',
779
+ 'dist',
780
+ 'Thumbs.db',
781
+ ]);
782
+ /**
783
+ * Recursively walk a directory and return all file paths.
784
+ *
785
+ * @param root - Root directory to walk.
786
+ * @param options - Walk options.
787
+ * @returns Array of normalized file paths.
788
+ */
789
+ function walkFiles(root, options) {
790
+ const exclude = new Set([...DEFAULT_SKIP, ...(options?.exclude ?? [])]);
791
+ const modifiedAfter = options?.modifiedAfter;
792
+ const maxDepth = options?.maxDepth ?? 50;
793
+ const results = [];
794
+ function walk(dir, depth) {
795
+ if (depth > maxDepth)
796
+ return;
797
+ let entries;
798
+ try {
799
+ entries = readdirSync(dir, { withFileTypes: true });
800
+ }
801
+ catch {
802
+ return; // Permission errors, missing dirs — skip
803
+ }
804
+ for (const entry of entries) {
805
+ if (exclude.has(entry.name))
806
+ continue;
807
+ const fullPath = join(dir, entry.name);
808
+ if (entry.isDirectory()) {
809
+ walk(fullPath, depth + 1);
810
+ }
811
+ else if (entry.isFile()) {
812
+ if (modifiedAfter !== undefined) {
813
+ try {
814
+ const stat = statSync(fullPath);
815
+ if (Math.floor(stat.mtimeMs / 1000) <= modifiedAfter)
816
+ continue;
817
+ }
818
+ catch {
819
+ continue;
820
+ }
821
+ }
822
+ results.push(normalizePath(fullPath));
823
+ }
824
+ }
825
+ }
826
+ walk(root, 0);
827
+ return results;
828
+ }
829
+
718
830
  /**
719
831
  * Compute the file scope owned by a meta node.
720
832
  *
721
- * A meta owns: parent dir + all descendants, minus child .meta/ subtrees.
722
- * For child subtrees, it consumes the child's .meta/meta.json as a rollup input.
833
+ * A meta owns: parent dir + all descendants, minus:
834
+ * - Its own .meta/ subtree (outputs, not inputs)
835
+ * - Child meta ownerPath subtrees (except their .meta/meta.json for rollups)
836
+ *
837
+ * Uses filesystem walks instead of watcher scans for performance.
723
838
  *
724
839
  * @module discovery/scope
725
840
  */
726
841
  /**
727
842
  * Get the scope path prefix for a meta node.
728
- *
729
- * This is the ownerPath — all files under this path are in scope,
730
- * except subtrees owned by child metas.
731
- *
732
- * @param node - The meta node to compute scope for.
733
- * @returns The scope path prefix.
734
843
  */
735
844
  function getScopePrefix(node) {
736
845
  return node.ownerPath;
@@ -738,47 +847,39 @@ function getScopePrefix(node) {
738
847
  /**
739
848
  * Filter a list of file paths to only those in scope for a meta node.
740
849
  *
741
- * Includes files under ownerPath, excludes files under child meta ownerPaths,
742
- * but includes child .meta/meta.json files as rollup inputs.
850
+ * Excludes:
851
+ * - The node's own .meta/ subtree (synthesis outputs are not scope inputs)
852
+ * - Child meta ownerPath subtrees (except child .meta/meta.json for rollups)
743
853
  *
744
- * @param node - The meta node.
745
- * @param files - Array of file paths to filter.
746
- * @returns Filtered array of in-scope file paths.
854
+ * walkFiles already returns normalized forward-slash paths.
747
855
  */
748
856
  function filterInScope(node, files) {
749
857
  const prefix = node.ownerPath + '/';
858
+ const ownMetaPrefix = node.metaPath + '/';
750
859
  const exclusions = node.children.map((c) => c.ownerPath + '/');
751
860
  const childMetaJsons = new Set(node.children.map((c) => c.metaPath + '/meta.json'));
752
861
  return files.filter((f) => {
753
- const normalized = f.split('\\').join('/');
754
862
  // Must be under ownerPath
755
- if (!normalized.startsWith(prefix) && normalized !== node.ownerPath)
863
+ if (!f.startsWith(prefix) && f !== node.ownerPath)
864
+ return false;
865
+ // Exclude own .meta/ subtree (outputs are not inputs)
866
+ if (f.startsWith(ownMetaPrefix))
756
867
  return false;
757
868
  // Check if under a child meta's subtree
758
869
  for (const excl of exclusions) {
759
- if (normalized.startsWith(excl)) {
870
+ if (f.startsWith(excl)) {
760
871
  // Exception: child meta.json files are included as rollup inputs
761
- return childMetaJsons.has(normalized);
872
+ return childMetaJsons.has(f);
762
873
  }
763
874
  }
764
875
  return true;
765
876
  });
766
877
  }
767
878
  /**
768
- * Get all files in scope for a meta node via watcher scan.
769
- *
770
- * Scans the owner path prefix and filters out child meta subtrees,
771
- * keeping only files directly owned by this meta.
772
- *
773
- * @param node - The meta node.
774
- * @param watcher - WatcherClient for scan queries.
775
- * @returns Array of in-scope file paths.
879
+ * Get all files in scope for a meta node via filesystem walk.
776
880
  */
777
- async function getScopeFiles(node, watcher) {
778
- const allScanFiles = await paginatedScan(watcher, {
779
- pathPrefix: node.ownerPath,
780
- });
781
- const allFiles = allScanFiles.map((f) => f.file_path);
881
+ function getScopeFiles(node) {
882
+ const allFiles = walkFiles(node.ownerPath);
782
883
  return {
783
884
  scopeFiles: filterInScope(node, allFiles),
784
885
  allFiles,
@@ -788,22 +889,13 @@ async function getScopeFiles(node, watcher) {
788
889
  * Get files modified since a given timestamp within a meta node's scope.
789
890
  *
790
891
  * If no generatedAt is provided (first run), returns all scope files.
791
- *
792
- * @param node - The meta node.
793
- * @param watcher - WatcherClient for scan queries.
794
- * @param generatedAt - ISO timestamp of last synthesis, or null/undefined for first run.
795
- * @param scopeFiles - Pre-computed scope files (used as fallback for first run).
796
- * @returns Array of modified in-scope file paths.
797
892
  */
798
- async function getDeltaFiles(node, watcher, generatedAt, scopeFiles) {
893
+ function getDeltaFiles(node, generatedAt, scopeFiles) {
799
894
  if (!generatedAt)
800
895
  return scopeFiles;
801
896
  const modifiedAfter = Math.floor(new Date(generatedAt).getTime() / 1000);
802
- const deltaScanFiles = await paginatedScan(watcher, {
803
- pathPrefix: node.ownerPath,
804
- modifiedAfter,
805
- });
806
- return filterInScope(node, deltaScanFiles.map((f) => f.file_path));
897
+ const deltaFiles = walkFiles(node.ownerPath, { modifiedAfter });
898
+ return filterInScope(node, deltaFiles);
807
899
  }
808
900
 
809
901
  /**
@@ -882,7 +974,7 @@ function sleep(ms) {
882
974
  * @module executor/GatewayExecutor
883
975
  */
884
976
  const DEFAULT_POLL_INTERVAL_MS = 5000;
885
- const DEFAULT_TIMEOUT_MS = 600_000; // 10 minutes
977
+ const DEFAULT_TIMEOUT_MS$1 = 600_000; // 10 minutes
886
978
  /**
887
979
  * MetaExecutor that spawns OpenClaw sessions via the gateway's
888
980
  * `/tools/invoke` endpoint.
@@ -895,10 +987,12 @@ class GatewayExecutor {
895
987
  gatewayUrl;
896
988
  apiKey;
897
989
  pollIntervalMs;
990
+ workspaceDir;
898
991
  constructor(options = {}) {
899
992
  this.gatewayUrl = (options.gatewayUrl ?? 'http://127.0.0.1:18789').replace(/\/+$/, '');
900
993
  this.apiKey = options.apiKey;
901
994
  this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
995
+ this.workspaceDir = options.workspaceDir ?? join(tmpdir(), 'jeeves-meta');
902
996
  }
903
997
  /** Invoke a gateway tool via the /tools/invoke HTTP endpoint. */
904
998
  async invoke(tool, args) {
@@ -923,13 +1017,44 @@ class GatewayExecutor {
923
1017
  }
924
1018
  return data;
925
1019
  }
1020
+ /** Look up totalTokens for a session via sessions_list. */
1021
+ async getSessionTokens(sessionKey) {
1022
+ try {
1023
+ const result = await this.invoke('sessions_list', {
1024
+ limit: 20,
1025
+ messageLimit: 0,
1026
+ });
1027
+ const sessions = (result.result?.details?.sessions ??
1028
+ result.result?.sessions ??
1029
+ []);
1030
+ const match = sessions.find((s) => s.key === sessionKey);
1031
+ return match?.totalTokens ?? undefined;
1032
+ }
1033
+ catch {
1034
+ return undefined;
1035
+ }
1036
+ }
926
1037
  async spawn(task, options) {
927
- const timeoutSeconds = options?.timeout ?? DEFAULT_TIMEOUT_MS / 1000;
1038
+ const timeoutSeconds = options?.timeout ?? DEFAULT_TIMEOUT_MS$1 / 1000;
928
1039
  const timeoutMs = timeoutSeconds * 1000;
929
1040
  const deadline = Date.now() + timeoutMs;
1041
+ // Ensure workspace dir exists
1042
+ if (!existsSync(this.workspaceDir)) {
1043
+ mkdirSync(this.workspaceDir, { recursive: true });
1044
+ }
1045
+ // Generate unique output path for file-based output
1046
+ const outputId = randomUUID();
1047
+ const outputPath = this.workspaceDir + '/output-' + outputId + '.json';
1048
+ // Append file output instruction to the task
1049
+ const taskWithOutput = task +
1050
+ '\n\n## OUTPUT DELIVERY\n\n' +
1051
+ 'Write your complete output to a file using the Write tool at:\n' +
1052
+ outputPath +
1053
+ '\n\n' +
1054
+ 'Reply with ONLY the file path you wrote to. No other text.';
930
1055
  // Step 1: Spawn the sub-agent session
931
1056
  const spawnResult = await this.invoke('sessions_spawn', {
932
- task,
1057
+ task: taskWithOutput,
933
1058
  label: options?.label ?? 'jeeves-meta-synthesis',
934
1059
  runTimeoutSeconds: timeoutSeconds,
935
1060
  ...(options?.thinking ? { thinking: options.thinking } : {}),
@@ -961,19 +1086,37 @@ class GatewayExecutor {
961
1086
  lastMsg.stopReason &&
962
1087
  lastMsg.stopReason !== 'toolUse' &&
963
1088
  lastMsg.stopReason !== 'error') {
964
- // Sum token usage from all messages
965
- let tokens;
966
- let sum = 0;
967
- for (const msg of msgArray) {
968
- if (msg.usage?.totalTokens)
969
- sum += msg.usage.totalTokens;
1089
+ // Fetch token usage from session metadata
1090
+ const tokens = await this.getSessionTokens(sessionKey);
1091
+ // Read output from file (sub-agent wrote it via Write tool)
1092
+ if (existsSync(outputPath)) {
1093
+ try {
1094
+ const output = readFileSync(outputPath, 'utf8');
1095
+ return { output, tokens };
1096
+ }
1097
+ finally {
1098
+ try {
1099
+ unlinkSync(outputPath);
1100
+ }
1101
+ catch {
1102
+ /* cleanup best-effort */
1103
+ }
1104
+ }
970
1105
  }
971
- if (sum > 0)
972
- tokens = sum;
973
- // Find the last assistant message with content
1106
+ // Fallback: extract from message content if file wasn't written
974
1107
  for (let i = msgArray.length - 1; i >= 0; i--) {
975
- if (msgArray[i].role === 'assistant' && msgArray[i].content) {
976
- return { output: msgArray[i].content, tokens };
1108
+ const msg = msgArray[i];
1109
+ if (msg.role === 'assistant' && msg.content) {
1110
+ const text = typeof msg.content === 'string'
1111
+ ? msg.content
1112
+ : Array.isArray(msg.content)
1113
+ ? msg.content
1114
+ .filter((b) => b.type === 'text' && b.text)
1115
+ .map((b) => b.text)
1116
+ .join('\n')
1117
+ : '';
1118
+ if (text)
1119
+ return { output: text, tokens };
977
1120
  }
978
1121
  }
979
1122
  return { output: '', tokens };
@@ -1053,10 +1196,10 @@ function condenseScopeFiles(files, maxIndividual = 30) {
1053
1196
  * @param watcher - WatcherClient for scope enumeration.
1054
1197
  * @returns The computed context package.
1055
1198
  */
1056
- async function buildContextPackage(node, meta, watcher) {
1199
+ function buildContextPackage(node, meta) {
1057
1200
  // Scope and delta files via watcher scan
1058
- const { scopeFiles } = await getScopeFiles(node, watcher);
1059
- const deltaFiles = await getDeltaFiles(node, watcher, meta._generatedAt, scopeFiles);
1201
+ const { scopeFiles } = getScopeFiles(node);
1202
+ const deltaFiles = getDeltaFiles(node, meta._generatedAt, scopeFiles);
1060
1203
  // Child meta outputs
1061
1204
  const childMetas = {};
1062
1205
  for (const child of node.children) {
@@ -1167,7 +1310,7 @@ function buildBuilderTask(ctx, meta, config) {
1167
1310
  includeSteer: false,
1168
1311
  feedbackHeading: '## FEEDBACK FROM CRITIC',
1169
1312
  });
1170
- sections.push('', '## OUTPUT FORMAT', 'Return a JSON object with:', '- "_content": Markdown narrative synthesis (required)', '- Any additional structured fields as non-underscore keys');
1313
+ 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.');
1171
1314
  return sections.join('\n');
1172
1315
  }
1173
1316
  /**
@@ -1473,28 +1616,33 @@ function discoverStalestPath(candidates, depthWeight) {
1473
1616
  * @param watcher - WatcherClient instance.
1474
1617
  * @returns True if any file in scope was modified after _generatedAt.
1475
1618
  */
1476
- async function isStale(scopePrefix, meta, watcher) {
1619
+ function isStale(scopePrefix, meta) {
1477
1620
  if (!meta._generatedAt)
1478
1621
  return true; // Never synthesized = stale
1479
1622
  const generatedAtUnix = Math.floor(new Date(meta._generatedAt).getTime() / 1000);
1480
- const result = await watcher.scan({
1481
- pathPrefix: scopePrefix,
1623
+ const modified = walkFiles(scopePrefix, {
1482
1624
  modifiedAfter: generatedAtUnix,
1483
- limit: 1,
1625
+ maxDepth: 1,
1484
1626
  });
1485
- return result.files.length > 0;
1627
+ return modified.length > 0;
1486
1628
  }
1629
+ /** Maximum staleness for never-synthesized metas (1 year in seconds). */
1630
+ const MAX_STALENESS_SECONDS = 365 * 86_400;
1487
1631
  /**
1488
1632
  * Compute actual staleness in seconds (now minus _generatedAt).
1489
1633
  *
1634
+ * Never-synthesized metas are capped at {@link MAX_STALENESS_SECONDS}
1635
+ * (1 year) so that depth weighting can differentiate them. Without
1636
+ * bounding, `Infinity * depthFactor` = `Infinity` for all depths.
1637
+ *
1490
1638
  * @param meta - Current meta.json content.
1491
- * @returns Staleness in seconds, or Infinity if never synthesized.
1639
+ * @returns Staleness in seconds, capped at 1 year for never-synthesized metas.
1492
1640
  */
1493
1641
  function actualStaleness(meta) {
1494
1642
  if (!meta._generatedAt)
1495
- return Infinity;
1643
+ return MAX_STALENESS_SECONDS;
1496
1644
  const generatedMs = new Date(meta._generatedAt).getTime();
1497
- return (Date.now() - generatedMs) / 1000;
1645
+ return Math.min((Date.now() - generatedMs) / 1000, MAX_STALENESS_SECONDS);
1498
1646
  }
1499
1647
  /**
1500
1648
  * Check whether the architect step should be triggered.
@@ -1571,20 +1719,50 @@ function parseArchitectOutput(output) {
1571
1719
  */
1572
1720
  function parseBuilderOutput(output) {
1573
1721
  const trimmed = output.trim();
1574
- // Try to extract JSON from the output (may be wrapped in markdown code fences)
1575
- let jsonStr = trimmed;
1576
- const fenceMatch = /```(?:json)?\s*([\s\S]*?)```/.exec(trimmed);
1577
- if (fenceMatch) {
1578
- jsonStr = fenceMatch[1].trim();
1579
- }
1722
+ // Strategy 1: Try to parse the entire output as JSON directly
1723
+ const direct = tryParseJson(trimmed);
1724
+ if (direct)
1725
+ return direct;
1726
+ // Strategy 2: Try all fenced code blocks (last match first — models often narrate then output)
1727
+ const fencePattern = /```(?:json)?\s*([\s\S]*?)```/g;
1728
+ const fenceMatches = [];
1729
+ let match;
1730
+ while ((match = fencePattern.exec(trimmed)) !== null) {
1731
+ fenceMatches.push(match[1].trim());
1732
+ }
1733
+ // Try last fence first (most likely to be the actual output)
1734
+ for (let i = fenceMatches.length - 1; i >= 0; i--) {
1735
+ const result = tryParseJson(fenceMatches[i]);
1736
+ if (result)
1737
+ return result;
1738
+ }
1739
+ // Strategy 3: Find outermost { ... } braces
1740
+ const firstBrace = trimmed.indexOf('{');
1741
+ const lastBrace = trimmed.lastIndexOf('}');
1742
+ if (firstBrace !== -1 && lastBrace > firstBrace) {
1743
+ const result = tryParseJson(trimmed.substring(firstBrace, lastBrace + 1));
1744
+ if (result)
1745
+ return result;
1746
+ }
1747
+ // Fallback: treat entire output as content
1748
+ return { content: trimmed, fields: {} };
1749
+ }
1750
+ /** Try to parse a string as JSON and extract builder output fields. */
1751
+ function tryParseJson(str) {
1580
1752
  try {
1581
- const parsed = JSON.parse(jsonStr);
1753
+ const raw = JSON.parse(str);
1754
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
1755
+ return null;
1756
+ }
1757
+ const parsed = raw;
1582
1758
  // Extract _content
1583
- const content = typeof parsed._content === 'string'
1584
- ? parsed._content
1585
- : typeof parsed.content === 'string'
1586
- ? parsed.content
1587
- : trimmed;
1759
+ const content = typeof parsed['_content'] === 'string'
1760
+ ? parsed['_content']
1761
+ : typeof parsed['content'] === 'string'
1762
+ ? parsed['content']
1763
+ : null;
1764
+ if (content === null)
1765
+ return null;
1588
1766
  // Extract non-underscore fields
1589
1767
  const fields = {};
1590
1768
  for (const [key, value] of Object.entries(parsed)) {
@@ -1595,8 +1773,7 @@ function parseBuilderOutput(output) {
1595
1773
  return { content, fields };
1596
1774
  }
1597
1775
  catch {
1598
- // Not valid JSON — treat entire output as content
1599
- return { content: trimmed, fields: {} };
1776
+ return null;
1600
1777
  }
1601
1778
  }
1602
1779
  /**
@@ -1657,9 +1834,243 @@ function finalizeCycle(opts) {
1657
1834
  * @param watcher - Watcher HTTP client.
1658
1835
  * @returns Result indicating whether synthesis occurred.
1659
1836
  */
1660
- async function orchestrateOnce(config, executor, watcher, targetPath, onProgress) {
1837
+ /**
1838
+ * Build a minimal MetaNode from the filesystem for a known meta path.
1839
+ * Discovers immediate child .meta/ dirs without a full watcher scan.
1840
+ */
1841
+ function buildMinimalNode(metaPath) {
1842
+ const normalized = normalizePath(metaPath);
1843
+ const ownerPath = normalizePath(dirname(metaPath));
1844
+ // Find child .meta/ directories by scanning the owner directory
1845
+ const children = [];
1846
+ function findChildMetas(dir, depth) {
1847
+ if (depth > 10)
1848
+ return; // Safety limit
1849
+ try {
1850
+ const entries = readdirSync(dir, { withFileTypes: true });
1851
+ for (const entry of entries) {
1852
+ if (!entry.isDirectory())
1853
+ continue;
1854
+ const fullPath = normalizePath(join(dir, entry.name));
1855
+ if (entry.name === '.meta' && fullPath !== normalized) {
1856
+ // Found a child .meta — check it has meta.json
1857
+ if (existsSync(join(fullPath, 'meta.json'))) {
1858
+ children.push({
1859
+ metaPath: fullPath,
1860
+ ownerPath: normalizePath(dirname(fullPath)),
1861
+ treeDepth: 1, // Relative to target
1862
+ children: [],
1863
+ parent: null, // Set below
1864
+ });
1865
+ }
1866
+ // Don't recurse into .meta dirs
1867
+ return;
1868
+ }
1869
+ if (entry.name === 'node_modules' ||
1870
+ entry.name === '.git' ||
1871
+ entry.name === 'archive')
1872
+ continue;
1873
+ findChildMetas(fullPath, depth + 1);
1874
+ }
1875
+ }
1876
+ catch {
1877
+ // Permission errors, etc — skip
1878
+ }
1879
+ }
1880
+ findChildMetas(ownerPath, 0);
1881
+ const node = {
1882
+ metaPath: normalized,
1883
+ ownerPath,
1884
+ treeDepth: 0,
1885
+ children,
1886
+ parent: null,
1887
+ };
1888
+ // Wire parent references
1889
+ for (const child of children) {
1890
+ child.parent = node;
1891
+ }
1892
+ return node;
1893
+ }
1894
+ /** Run the architect/builder/critic pipeline on a single node. */
1895
+ async function synthesizeNode(node, currentMeta, config, executor, watcher, onProgress) {
1896
+ const architectPrompt = currentMeta._architect ?? config.defaultArchitect;
1897
+ const criticPrompt = currentMeta._critic ?? config.defaultCritic;
1898
+ // Step 5-6: Steer change detection
1899
+ const latestArchive = readLatestArchive(node.metaPath);
1900
+ const steerChanged = hasSteerChanged(currentMeta._steer, latestArchive?._steer, Boolean(latestArchive));
1901
+ // Step 7: Compute context (includes scope files and delta files)
1902
+ const ctx = buildContextPackage(node, currentMeta);
1903
+ // Step 5 (deferred): Structure hash from context scope files
1904
+ const newStructureHash = computeStructureHash(ctx.scopeFiles);
1905
+ const structureChanged = newStructureHash !== currentMeta._structureHash;
1906
+ // Step 8: Architect (conditional)
1907
+ const architectTriggered = isArchitectTriggered(currentMeta, structureChanged, steerChanged, config.architectEvery);
1908
+ let builderBrief = currentMeta._builder ?? '';
1909
+ let synthesisCount = currentMeta._synthesisCount ?? 0;
1910
+ let stepError = null;
1911
+ let architectTokens;
1912
+ let builderTokens;
1913
+ let criticTokens;
1914
+ if (architectTriggered) {
1915
+ try {
1916
+ await onProgress?.({
1917
+ type: 'phase_start',
1918
+ path: node.ownerPath,
1919
+ phase: 'architect',
1920
+ });
1921
+ const phaseStart = Date.now();
1922
+ const architectTask = buildArchitectTask(ctx, currentMeta, config);
1923
+ const architectResult = await executor.spawn(architectTask, {
1924
+ thinking: config.thinking,
1925
+ timeout: config.architectTimeout,
1926
+ });
1927
+ builderBrief = parseArchitectOutput(architectResult.output);
1928
+ architectTokens = architectResult.tokens;
1929
+ synthesisCount = 0;
1930
+ await onProgress?.({
1931
+ type: 'phase_complete',
1932
+ path: node.ownerPath,
1933
+ phase: 'architect',
1934
+ tokens: architectTokens,
1935
+ durationMs: Date.now() - phaseStart,
1936
+ });
1937
+ }
1938
+ catch (err) {
1939
+ stepError = toMetaError('architect', err);
1940
+ if (!currentMeta._builder) {
1941
+ // No cached builder — cycle fails
1942
+ finalizeCycle({
1943
+ metaPath: node.metaPath,
1944
+ current: currentMeta,
1945
+ config,
1946
+ architect: architectPrompt,
1947
+ builder: '',
1948
+ critic: criticPrompt,
1949
+ builderOutput: null,
1950
+ feedback: null,
1951
+ structureHash: newStructureHash,
1952
+ synthesisCount,
1953
+ error: stepError,
1954
+ architectTokens,
1955
+ });
1956
+ return {
1957
+ synthesized: true,
1958
+ metaPath: node.metaPath,
1959
+ error: stepError,
1960
+ };
1961
+ }
1962
+ // Has cached builder — continue with existing
1963
+ }
1964
+ }
1965
+ // Step 9: Builder
1966
+ const metaForBuilder = { ...currentMeta, _builder: builderBrief };
1967
+ let builderOutput = null;
1968
+ try {
1969
+ await onProgress?.({
1970
+ type: 'phase_start',
1971
+ path: node.ownerPath,
1972
+ phase: 'builder',
1973
+ });
1974
+ const builderStart = Date.now();
1975
+ const builderTask = buildBuilderTask(ctx, metaForBuilder, config);
1976
+ const builderResult = await executor.spawn(builderTask, {
1977
+ thinking: config.thinking,
1978
+ timeout: config.builderTimeout,
1979
+ });
1980
+ builderOutput = parseBuilderOutput(builderResult.output);
1981
+ builderTokens = builderResult.tokens;
1982
+ synthesisCount++;
1983
+ await onProgress?.({
1984
+ type: 'phase_complete',
1985
+ path: node.ownerPath,
1986
+ phase: 'builder',
1987
+ tokens: builderTokens,
1988
+ durationMs: Date.now() - builderStart,
1989
+ });
1990
+ }
1991
+ catch (err) {
1992
+ stepError = toMetaError('builder', err);
1993
+ return { synthesized: true, metaPath: node.metaPath, error: stepError };
1994
+ }
1995
+ // Step 10: Critic
1996
+ const metaForCritic = {
1997
+ ...currentMeta,
1998
+ _content: builderOutput.content,
1999
+ };
2000
+ let feedback = null;
2001
+ try {
2002
+ await onProgress?.({
2003
+ type: 'phase_start',
2004
+ path: node.ownerPath,
2005
+ phase: 'critic',
2006
+ });
2007
+ const criticStart = Date.now();
2008
+ const criticTask = buildCriticTask(ctx, metaForCritic, config);
2009
+ const criticResult = await executor.spawn(criticTask, {
2010
+ thinking: config.thinking,
2011
+ timeout: config.criticTimeout,
2012
+ });
2013
+ feedback = parseCriticOutput(criticResult.output);
2014
+ criticTokens = criticResult.tokens;
2015
+ stepError = null; // Clear any architect error on full success
2016
+ await onProgress?.({
2017
+ type: 'phase_complete',
2018
+ path: node.ownerPath,
2019
+ phase: 'critic',
2020
+ tokens: criticTokens,
2021
+ durationMs: Date.now() - criticStart,
2022
+ });
2023
+ }
2024
+ catch (err) {
2025
+ stepError = stepError ?? toMetaError('critic', err);
2026
+ }
2027
+ // Steps 11-12: Merge, archive, prune
2028
+ finalizeCycle({
2029
+ metaPath: node.metaPath,
2030
+ current: currentMeta,
2031
+ config,
2032
+ architect: architectPrompt,
2033
+ builder: builderBrief,
2034
+ critic: criticPrompt,
2035
+ builderOutput,
2036
+ feedback,
2037
+ structureHash: newStructureHash,
2038
+ synthesisCount,
2039
+ error: stepError,
2040
+ architectTokens,
2041
+ builderTokens,
2042
+ criticTokens,
2043
+ });
2044
+ return {
2045
+ synthesized: true,
2046
+ metaPath: node.metaPath,
2047
+ error: stepError ?? undefined,
2048
+ };
2049
+ }
2050
+ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress, logger) {
2051
+ // When targetPath is provided, skip the expensive full discovery scan.
2052
+ // Build a minimal node from the filesystem instead.
2053
+ if (targetPath) {
2054
+ const normalizedTarget = normalizePath(targetPath);
2055
+ const targetMetaJson = join(normalizedTarget, 'meta.json');
2056
+ if (!existsSync(targetMetaJson))
2057
+ return { synthesized: false };
2058
+ const node = buildMinimalNode(normalizedTarget);
2059
+ if (!acquireLock(node.metaPath))
2060
+ return { synthesized: false };
2061
+ try {
2062
+ const currentMeta = JSON.parse(readFileSync(targetMetaJson, 'utf8'));
2063
+ return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress);
2064
+ }
2065
+ finally {
2066
+ releaseLock(node.metaPath);
2067
+ }
2068
+ }
2069
+ // Full discovery path (scheduler-driven, no specific target)
1661
2070
  // Step 1: Discover via watcher scan
1662
- const metaPaths = await discoverMetas(config, watcher);
2071
+ const discoveryStart = Date.now();
2072
+ const metaPaths = await discoverMetas(config, watcher, logger);
2073
+ logger?.debug({ paths: metaPaths.length, durationMs: Date.now() - discoveryStart }, 'discovery complete');
1663
2074
  if (metaPaths.length === 0)
1664
2075
  return { synthesized: false };
1665
2076
  // Read meta.json for each discovered meta
@@ -1679,23 +2090,15 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
1679
2090
  if (validPaths.length === 0)
1680
2091
  return { synthesized: false };
1681
2092
  const tree = buildOwnershipTree(validPaths);
1682
- // If targetPath specified, skip candidate selection — go directly to that meta
1683
- let targetNode;
1684
- if (targetPath) {
1685
- const normalized = normalizePath(targetPath);
1686
- targetNode = findNode(tree, normalized) ?? undefined;
1687
- if (!targetNode)
1688
- return { synthesized: false };
1689
- }
1690
2093
  // Steps 3-4: Staleness check + candidate selection
1691
2094
  const candidates = [];
1692
- for (const node of tree.nodes.values()) {
1693
- const meta = metas.get(node.metaPath);
2095
+ for (const treeNode of tree.nodes.values()) {
2096
+ const meta = metas.get(treeNode.metaPath);
1694
2097
  if (!meta)
1695
- continue; // Node not in metas map (e.g. unreadable meta.json)
2098
+ continue;
1696
2099
  const staleness = actualStaleness(meta);
1697
2100
  if (staleness > 0) {
1698
- candidates.push({ node, meta, actualStaleness: staleness });
2101
+ candidates.push({ node: treeNode, meta, actualStaleness: staleness });
1699
2102
  }
1700
2103
  }
1701
2104
  const weighted = computeEffectiveStaleness(candidates, config.depthWeight);
@@ -1708,7 +2111,7 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
1708
2111
  for (const candidate of ranked) {
1709
2112
  if (!acquireLock(candidate.node.metaPath))
1710
2113
  continue;
1711
- const verifiedStale = await isStale(getScopePrefix(candidate.node), candidate.meta, watcher);
2114
+ const verifiedStale = isStale(getScopePrefix(candidate.node), candidate.meta);
1712
2115
  if (!verifiedStale && candidate.meta._generatedAt) {
1713
2116
  // Bump _generatedAt so it doesn't win next cycle
1714
2117
  const metaFilePath = join(candidate.node.metaPath, 'meta.json');
@@ -1723,169 +2126,12 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
1723
2126
  winner = candidate;
1724
2127
  break;
1725
2128
  }
1726
- if (!winner && !targetNode)
1727
- return { synthesized: false };
1728
- const node = targetNode ?? winner.node;
1729
- // For targeted path, acquire lock now (candidate selection already locked for stalest)
1730
- if (targetNode && !acquireLock(node.metaPath)) {
2129
+ if (!winner)
1731
2130
  return { synthesized: false };
1732
- }
2131
+ const node = winner.node;
1733
2132
  try {
1734
- // Re-read meta after lock (may have changed)
1735
2133
  const currentMeta = JSON.parse(readFileSync(join(node.metaPath, 'meta.json'), 'utf8'));
1736
- const architectPrompt = currentMeta._architect ?? config.defaultArchitect;
1737
- const criticPrompt = currentMeta._critic ?? config.defaultCritic;
1738
- // Step 5-6: Steer change detection
1739
- const latestArchive = readLatestArchive(node.metaPath);
1740
- const steerChanged = hasSteerChanged(currentMeta._steer, latestArchive?._steer, Boolean(latestArchive));
1741
- // Step 7: Compute context (includes scope files and delta files)
1742
- const ctx = await buildContextPackage(node, currentMeta, watcher);
1743
- // Step 5 (deferred): Structure hash from context scope files
1744
- const newStructureHash = computeStructureHash(ctx.scopeFiles);
1745
- const structureChanged = newStructureHash !== currentMeta._structureHash;
1746
- // Step 8: Architect (conditional)
1747
- const architectTriggered = isArchitectTriggered(currentMeta, structureChanged, steerChanged, config.architectEvery);
1748
- let builderBrief = currentMeta._builder ?? '';
1749
- let synthesisCount = currentMeta._synthesisCount ?? 0;
1750
- let stepError = null;
1751
- let architectTokens;
1752
- let builderTokens;
1753
- let criticTokens;
1754
- if (architectTriggered) {
1755
- try {
1756
- await onProgress?.({
1757
- type: 'phase_start',
1758
- metaPath: node.metaPath,
1759
- phase: 'architect',
1760
- });
1761
- const phaseStart = Date.now();
1762
- const architectTask = buildArchitectTask(ctx, currentMeta, config);
1763
- const architectResult = await executor.spawn(architectTask, {
1764
- thinking: config.thinking,
1765
- timeout: config.architectTimeout,
1766
- });
1767
- builderBrief = parseArchitectOutput(architectResult.output);
1768
- architectTokens = architectResult.tokens;
1769
- synthesisCount = 0;
1770
- await onProgress?.({
1771
- type: 'phase_complete',
1772
- metaPath: node.metaPath,
1773
- phase: 'architect',
1774
- tokens: architectTokens,
1775
- durationMs: Date.now() - phaseStart,
1776
- });
1777
- }
1778
- catch (err) {
1779
- stepError = toMetaError('architect', err);
1780
- if (!currentMeta._builder) {
1781
- // No cached builder — cycle fails
1782
- finalizeCycle({
1783
- metaPath: node.metaPath,
1784
- current: currentMeta,
1785
- config,
1786
- architect: architectPrompt,
1787
- builder: '',
1788
- critic: criticPrompt,
1789
- builderOutput: null,
1790
- feedback: null,
1791
- structureHash: newStructureHash,
1792
- synthesisCount,
1793
- error: stepError,
1794
- architectTokens,
1795
- });
1796
- return {
1797
- synthesized: true,
1798
- metaPath: node.metaPath,
1799
- error: stepError,
1800
- };
1801
- }
1802
- // Has cached builder — continue with existing
1803
- }
1804
- }
1805
- // Step 9: Builder
1806
- const metaForBuilder = { ...currentMeta, _builder: builderBrief };
1807
- let builderOutput = null;
1808
- try {
1809
- await onProgress?.({
1810
- type: 'phase_start',
1811
- metaPath: node.metaPath,
1812
- phase: 'builder',
1813
- });
1814
- const builderStart = Date.now();
1815
- const builderTask = buildBuilderTask(ctx, metaForBuilder, config);
1816
- const builderResult = await executor.spawn(builderTask, {
1817
- thinking: config.thinking,
1818
- timeout: config.builderTimeout,
1819
- });
1820
- builderOutput = parseBuilderOutput(builderResult.output);
1821
- builderTokens = builderResult.tokens;
1822
- synthesisCount++;
1823
- await onProgress?.({
1824
- type: 'phase_complete',
1825
- metaPath: node.metaPath,
1826
- phase: 'builder',
1827
- tokens: builderTokens,
1828
- durationMs: Date.now() - builderStart,
1829
- });
1830
- }
1831
- catch (err) {
1832
- stepError = toMetaError('builder', err);
1833
- return { synthesized: true, metaPath: node.metaPath, error: stepError };
1834
- }
1835
- // Step 10: Critic
1836
- const metaForCritic = {
1837
- ...currentMeta,
1838
- _content: builderOutput.content,
1839
- };
1840
- let feedback = null;
1841
- try {
1842
- await onProgress?.({
1843
- type: 'phase_start',
1844
- metaPath: node.metaPath,
1845
- phase: 'critic',
1846
- });
1847
- const criticStart = Date.now();
1848
- const criticTask = buildCriticTask(ctx, metaForCritic, config);
1849
- const criticResult = await executor.spawn(criticTask, {
1850
- thinking: config.thinking,
1851
- timeout: config.criticTimeout,
1852
- });
1853
- feedback = parseCriticOutput(criticResult.output);
1854
- criticTokens = criticResult.tokens;
1855
- stepError = null; // Clear any architect error on full success
1856
- await onProgress?.({
1857
- type: 'phase_complete',
1858
- metaPath: node.metaPath,
1859
- phase: 'critic',
1860
- tokens: criticTokens,
1861
- durationMs: Date.now() - criticStart,
1862
- });
1863
- }
1864
- catch (err) {
1865
- stepError = stepError ?? toMetaError('critic', err);
1866
- }
1867
- // Steps 11-12: Merge, archive, prune
1868
- finalizeCycle({
1869
- metaPath: node.metaPath,
1870
- current: currentMeta,
1871
- config,
1872
- architect: architectPrompt,
1873
- builder: builderBrief,
1874
- critic: criticPrompt,
1875
- builderOutput,
1876
- feedback,
1877
- structureHash: newStructureHash,
1878
- synthesisCount,
1879
- error: stepError,
1880
- architectTokens,
1881
- builderTokens,
1882
- criticTokens,
1883
- });
1884
- return {
1885
- synthesized: true,
1886
- metaPath: node.metaPath,
1887
- error: stepError ?? undefined,
1888
- };
2134
+ return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress);
1889
2135
  }
1890
2136
  finally {
1891
2137
  // Step 13: Release lock
@@ -1904,8 +2150,8 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
1904
2150
  * @param targetPath - Optional: specific meta/owner path to synthesize instead of stalest candidate.
1905
2151
  * @returns Array with a single result.
1906
2152
  */
1907
- async function orchestrate(config, executor, watcher, targetPath, onProgress) {
1908
- const result = await orchestrateOnce(config, executor, watcher, targetPath, onProgress);
2153
+ async function orchestrate(config, executor, watcher, targetPath, onProgress, logger) {
2154
+ const result = await orchestrateOnce(config, executor, watcher, targetPath, onProgress, logger);
1909
2155
  return [result];
1910
2156
  }
1911
2157
 
@@ -1914,9 +2160,12 @@ async function orchestrate(config, executor, watcher, targetPath, onProgress) {
1914
2160
  *
1915
2161
  * @module progress
1916
2162
  */
2163
+ function formatNumber(n) {
2164
+ return n.toLocaleString('en-US');
2165
+ }
1917
2166
  function formatSeconds(durationMs) {
1918
2167
  const seconds = durationMs / 1000;
1919
- return seconds.toFixed(1) + 's';
2168
+ return Math.round(seconds).toString() + 's';
1920
2169
  }
1921
2170
  function titleCasePhase(phase) {
1922
2171
  return phase.charAt(0).toUpperCase() + phase.slice(1);
@@ -1924,32 +2173,30 @@ function titleCasePhase(phase) {
1924
2173
  function formatProgressEvent(event) {
1925
2174
  switch (event.type) {
1926
2175
  case 'synthesis_start':
1927
- return `🔬 Started meta synthesis: ${event.metaPath}`;
2176
+ return `🔬 Started meta synthesis: ${event.path}`;
1928
2177
  case 'phase_start': {
1929
2178
  if (!event.phase) {
1930
- return ` ⚙️ Phase started: ${event.metaPath}`;
2179
+ return ' ⚙️ Phase started';
1931
2180
  }
1932
2181
  return ` ⚙️ ${titleCasePhase(event.phase)} phase started`;
1933
2182
  }
1934
2183
  case 'phase_complete': {
1935
2184
  const phase = event.phase ? titleCasePhase(event.phase) : 'Phase';
1936
2185
  const tokens = event.tokens ?? 0;
1937
- const duration = event.durationMs !== undefined
1938
- ? formatSeconds(event.durationMs)
1939
- : '0.0s';
1940
- return ` ✅ ${phase} phase complete (${String(tokens)} tokens / ${duration})`;
2186
+ const duration = event.durationMs !== undefined ? formatSeconds(event.durationMs) : '0s';
2187
+ return ` ✅ ${phase} complete (${formatNumber(tokens)} tokens / ${duration})`;
1941
2188
  }
1942
2189
  case 'synthesis_complete': {
1943
2190
  const tokens = event.tokens ?? 0;
1944
2191
  const duration = event.durationMs !== undefined
1945
2192
  ? formatSeconds(event.durationMs)
1946
2193
  : '0.0s';
1947
- return `✅ Completed: ${event.metaPath} (${String(tokens)} tokens / ${duration})`;
2194
+ return `✅ Completed: ${event.path} (${formatNumber(tokens)} tokens / ${duration})`;
1948
2195
  }
1949
2196
  case 'error': {
1950
2197
  const phase = event.phase ? `${titleCasePhase(event.phase)} ` : '';
1951
2198
  const error = event.error ?? 'Unknown error';
1952
- return `❌ Synthesis failed at ${phase}phase: ${event.metaPath}\n Error: ${error}`;
2199
+ return `❌ Synthesis failed at ${phase}phase: ${event.path}\n Error: ${error}`;
1953
2200
  }
1954
2201
  default: {
1955
2202
  return 'Unknown progress event';
@@ -2127,7 +2374,7 @@ class Scheduler {
2127
2374
  */
2128
2375
  async discoverStalest() {
2129
2376
  try {
2130
- const result = await listMetas(this.config, this.watcher);
2377
+ const result = await listMetas(this.config, this.watcher, this.logger);
2131
2378
  const stale = result.entries
2132
2379
  .filter((e) => e.stalenessSeconds > 0)
2133
2380
  .map((e) => ({
@@ -2413,7 +2660,7 @@ function registerMetasRoutes(app, deps) {
2413
2660
  app.get('/metas', async (request) => {
2414
2661
  const query = metasQuerySchema.parse(request.query);
2415
2662
  const { config, watcher } = deps;
2416
- const result = await listMetas(config, watcher);
2663
+ const result = await listMetas(config, watcher, request.log);
2417
2664
  let entries = result.entries;
2418
2665
  // Apply filters
2419
2666
  if (query.pathPrefix) {
@@ -2476,7 +2723,7 @@ function registerMetasRoutes(app, deps) {
2476
2723
  const query = metaDetailQuerySchema.parse(request.query);
2477
2724
  const { config, watcher } = deps;
2478
2725
  const targetPath = normalizePath(decodeURIComponent(request.params.path));
2479
- const result = await listMetas(config, watcher);
2726
+ const result = await listMetas(config, watcher, request.log);
2480
2727
  const targetNode = findNode(result.tree, targetPath);
2481
2728
  if (!targetNode) {
2482
2729
  return reply.status(404).send({
@@ -2509,7 +2756,7 @@ function registerMetasRoutes(app, deps) {
2509
2756
  return r;
2510
2757
  };
2511
2758
  // Compute scope
2512
- const { scopeFiles, allFiles } = await getScopeFiles(targetNode, watcher);
2759
+ const { scopeFiles, allFiles } = getScopeFiles(targetNode);
2513
2760
  // Compute staleness
2514
2761
  const metaTyped = meta;
2515
2762
  const staleSeconds = metaTyped._generatedAt
@@ -2556,7 +2803,7 @@ function registerPreviewRoute(app, deps) {
2556
2803
  const query = request.query;
2557
2804
  let result;
2558
2805
  try {
2559
- result = await listMetas(config, watcher);
2806
+ result = await listMetas(config, watcher, request.log);
2560
2807
  }
2561
2808
  catch {
2562
2809
  return reply.status(503).send({
@@ -2592,14 +2839,14 @@ function registerPreviewRoute(app, deps) {
2592
2839
  }
2593
2840
  const meta = JSON.parse(readFileSync(join(targetNode.metaPath, 'meta.json'), 'utf8'));
2594
2841
  // Scope files
2595
- const { scopeFiles } = await getScopeFiles(targetNode, watcher);
2842
+ const { scopeFiles } = getScopeFiles(targetNode);
2596
2843
  const structureHash = computeStructureHash(scopeFiles);
2597
2844
  const structureChanged = structureHash !== meta._structureHash;
2598
2845
  const latestArchive = readLatestArchive(targetNode.metaPath);
2599
2846
  const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
2600
2847
  const architectTriggered = isArchitectTriggered(meta, structureChanged, steerChanged, config.architectEvery);
2601
2848
  // Delta files
2602
- const deltaFiles = await getDeltaFiles(targetNode, watcher, meta._generatedAt, scopeFiles);
2849
+ const deltaFiles = getDeltaFiles(targetNode, meta._generatedAt, scopeFiles);
2603
2850
  // EMA token estimates
2604
2851
  const estimatedTokens = {
2605
2852
  architect: meta._architectTokensAvg ?? meta._architectTokens ?? 0,
@@ -2693,7 +2940,7 @@ async function checkDependency(url, path) {
2693
2940
  }
2694
2941
  function registerStatusRoute(app, deps) {
2695
2942
  app.get('/status', async () => {
2696
- const { config, queue, scheduler, stats, watcher } = deps;
2943
+ const { config, queue, scheduler, stats } = deps;
2697
2944
  // On-demand dependency checks
2698
2945
  const [watcherHealth, gatewayHealth] = await Promise.all([
2699
2946
  checkDependency(config.watcherUrl, '/status'),
@@ -2714,23 +2961,11 @@ function registerStatusRoute(app, deps) {
2714
2961
  else {
2715
2962
  status = 'idle';
2716
2963
  }
2717
- // Metas summary from listMetas (already computed)
2718
- let metasSummary = { total: 0, stale: 0, errors: 0, neverSynthesized: 0 };
2719
- try {
2720
- const result = await listMetas(config, watcher);
2721
- metasSummary = {
2722
- total: result.summary.total,
2723
- stale: result.summary.stale,
2724
- errors: result.summary.errors,
2725
- neverSynthesized: result.summary.neverSynthesized,
2726
- };
2727
- }
2728
- catch {
2729
- // Watcher unreachable — leave zeros
2730
- }
2964
+ // Metas summary is expensive (paginated watcher scan + disk reads).
2965
+ // Use GET /metas for full inventory; status is a lightweight health check.
2731
2966
  return {
2732
- service: 'jeeves-meta',
2733
- version: '0.4.0',
2967
+ service: SERVICE_NAME,
2968
+ version: SERVICE_VERSION,
2734
2969
  uptime: process.uptime(),
2735
2970
  status,
2736
2971
  currentTarget: queue.current?.path ?? null,
@@ -2750,7 +2985,6 @@ function registerStatusRoute(app, deps) {
2750
2985
  watcher: watcherHealth,
2751
2986
  gateway: gatewayHealth,
2752
2987
  },
2753
- metas: metasSummary,
2754
2988
  };
2755
2989
  });
2756
2990
  }
@@ -2776,7 +3010,7 @@ function registerSynthesizeRoute(app, deps) {
2776
3010
  // Discover stalest candidate
2777
3011
  let result;
2778
3012
  try {
2779
- result = await listMetas(config, watcher);
3013
+ result = await listMetas(config, watcher, request.log);
2780
3014
  }
2781
3015
  catch {
2782
3016
  return reply.status(503).send({
@@ -2937,6 +3171,10 @@ function buildMetaRules(config) {
2937
3171
  type: 'string',
2938
3172
  set: '{{json._error.step}}',
2939
3173
  },
3174
+ generated_at: {
3175
+ type: 'string',
3176
+ set: '{{json._generatedAt}}',
3177
+ },
2940
3178
  generated_at_unix: {
2941
3179
  type: 'integer',
2942
3180
  set: '{{toUnix json._generatedAt}}',
@@ -2949,16 +3187,7 @@ function buildMetaRules(config) {
2949
3187
  },
2950
3188
  ],
2951
3189
  render: {
2952
- frontmatter: [
2953
- 'meta_id',
2954
- 'meta_steer',
2955
- 'generated_at_unix',
2956
- 'meta_depth',
2957
- 'meta_emphasis',
2958
- 'meta_architect_tokens',
2959
- 'meta_builder_tokens',
2960
- 'meta_critic_tokens',
2961
- ],
3190
+ frontmatter: ['meta_id', 'generated_at', '*', '!_*', '!has_error'],
2962
3191
  body: [{ path: 'json._content', heading: 1, label: 'Synthesis' }],
2963
3192
  },
2964
3193
  renderAs: 'md',
@@ -3108,7 +3337,10 @@ class RuleRegistrar {
3108
3337
  * @returns Configured Fastify instance (not yet listening).
3109
3338
  */
3110
3339
  function createServer(options) {
3111
- const app = Fastify({ logger: options.logger });
3340
+ // Fastify 5 requires `loggerInstance` for external pino loggers
3341
+ const app = Fastify({
3342
+ loggerInstance: options.logger,
3343
+ });
3112
3344
  registerRoutes(app, {
3113
3345
  config: options.config,
3114
3346
  logger: options.logger,
@@ -3189,6 +3421,7 @@ function registerShutdownHandlers(deps) {
3189
3421
  const DEFAULT_MAX_RETRIES = 3;
3190
3422
  const DEFAULT_BACKOFF_BASE_MS = 1000;
3191
3423
  const DEFAULT_BACKOFF_FACTOR = 4;
3424
+ const DEFAULT_TIMEOUT_MS = 30_000;
3192
3425
  /** Check if an error is transient (worth retrying). */
3193
3426
  function isTransient(status) {
3194
3427
  return status >= 500 || status === 408 || status === 429;
@@ -3201,11 +3434,13 @@ class HttpWatcherClient {
3201
3434
  maxRetries;
3202
3435
  backoffBaseMs;
3203
3436
  backoffFactor;
3437
+ timeoutMs;
3204
3438
  constructor(options) {
3205
3439
  this.baseUrl = options.baseUrl.replace(/\/+$/, '');
3206
3440
  this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
3207
3441
  this.backoffBaseMs = options.backoffBaseMs ?? DEFAULT_BACKOFF_BASE_MS;
3208
3442
  this.backoffFactor = options.backoffFactor ?? DEFAULT_BACKOFF_FACTOR;
3443
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
3209
3444
  }
3210
3445
  /** POST JSON with retry. */
3211
3446
  async post(endpoint, body) {
@@ -3215,6 +3450,7 @@ class HttpWatcherClient {
3215
3450
  method: 'POST',
3216
3451
  headers: { 'Content-Type': 'application/json' },
3217
3452
  body: JSON.stringify(body),
3453
+ signal: AbortSignal.timeout(this.timeoutMs),
3218
3454
  });
3219
3455
  if (res.ok) {
3220
3456
  return res.json();
@@ -3355,9 +3591,11 @@ async function startService(config, configPath) {
3355
3591
  const synthesizeFn = async (path) => {
3356
3592
  const startMs = Date.now();
3357
3593
  let cycleTokens = 0;
3594
+ // Strip .meta suffix for human-readable progress reporting
3595
+ const ownerPath = path.replace(/\/?\.meta\/?$/, '');
3358
3596
  await progress.report({
3359
3597
  type: 'synthesis_start',
3360
- metaPath: path,
3598
+ path: ownerPath,
3361
3599
  });
3362
3600
  try {
3363
3601
  const results = await orchestrate(config, executor, watcher, path, async (evt) => {
@@ -3379,7 +3617,7 @@ async function startService(config, configPath) {
3379
3617
  stats.totalErrors++;
3380
3618
  await progress.report({
3381
3619
  type: 'error',
3382
- metaPath: path,
3620
+ path: ownerPath,
3383
3621
  error: result.error.message,
3384
3622
  });
3385
3623
  }
@@ -3387,7 +3625,7 @@ async function startService(config, configPath) {
3387
3625
  scheduler.resetBackoff();
3388
3626
  await progress.report({
3389
3627
  type: 'synthesis_complete',
3390
- metaPath: path,
3628
+ path: ownerPath,
3391
3629
  tokens: cycleTokens,
3392
3630
  durationMs,
3393
3631
  });
@@ -3398,7 +3636,7 @@ async function startService(config, configPath) {
3398
3636
  const message = err instanceof Error ? err.message : String(err);
3399
3637
  await progress.report({
3400
3638
  type: 'error',
3401
- metaPath: path,
3639
+ path: ownerPath,
3402
3640
  error: message,
3403
3641
  });
3404
3642
  throw err;
@@ -3460,4 +3698,4 @@ async function startService(config, configPath) {
3460
3698
  logger.info('Service fully initialized');
3461
3699
  }
3462
3700
 
3463
- export { GatewayExecutor, HttpWatcherClient, ProgressReporter, RuleRegistrar, Scheduler, SynthesisQueue, acquireLock, actualStaleness, buildArchitectTask, buildBuilderTask, buildContextPackage, buildCriticTask, buildMetaFilter, buildOwnershipTree, cleanupStaleLocks, computeEffectiveStaleness, computeEma, computeStructureHash, createLogger, createServer, createSnapshot, discoverMetas, filterInScope, findNode, formatProgressEvent, getScopePrefix, hasSteerChanged, isArchitectTriggered, isLocked, isStale, listArchiveFiles, listMetas, loadServiceConfig, mergeAndWrite, metaConfigSchema, metaErrorSchema, metaJsonSchema, normalizePath, orchestrate, paginatedScan, parseArchitectOutput, parseBuilderOutput, parseCriticOutput, pruneArchive, readLatestArchive, readLockState, registerRoutes, registerShutdownHandlers, releaseLock, resolveConfigPath, resolveMetaDir, selectCandidate, serviceConfigSchema, sleep, startService, toMetaError };
3701
+ export { DEFAULT_PORT, DEFAULT_PORT_STR, GatewayExecutor, HttpWatcherClient, ProgressReporter, RuleRegistrar, SERVICE_NAME, SERVICE_VERSION, Scheduler, SynthesisQueue, acquireLock, actualStaleness, buildArchitectTask, buildBuilderTask, buildContextPackage, buildCriticTask, buildMetaFilter, buildOwnershipTree, cleanupStaleLocks, computeEffectiveStaleness, computeEma, computeStructureHash, createLogger, createServer, createSnapshot, discoverMetas, filterInScope, findNode, formatProgressEvent, getScopePrefix, hasSteerChanged, isArchitectTriggered, isLocked, isStale, listArchiveFiles, listMetas, loadServiceConfig, mergeAndWrite, metaConfigSchema, metaErrorSchema, metaJsonSchema, normalizePath, orchestrate, paginatedScan, parseArchitectOutput, parseBuilderOutput, parseCriticOutput, pruneArchive, readLatestArchive, readLockState, registerRoutes, registerShutdownHandlers, releaseLock, resolveConfigPath, resolveMetaDir, selectCandidate, serviceConfigSchema, sleep, startService, toMetaError, walkFiles };