@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.
@@ -1,9 +1,11 @@
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';
4
- import { dirname, join, relative } from 'node:path';
3
+ import { readFileSync, readdirSync, unlinkSync, mkdirSync, writeFileSync, existsSync, statSync, copyFileSync, watchFile } from 'node:fs';
4
+ import { dirname, join, resolve, relative } from 'node:path';
5
5
  import { z } from 'zod';
6
+ import { fileURLToPath } from 'node:url';
6
7
  import { createHash, randomUUID } from 'node:crypto';
8
+ import { tmpdir } from 'node:os';
7
9
  import pino from 'pino';
8
10
  import { Cron } from 'croner';
9
11
  import Fastify from 'fastify';
@@ -166,6 +168,42 @@ var configLoader = /*#__PURE__*/Object.freeze({
166
168
  resolveConfigPath: resolveConfigPath
167
169
  });
168
170
 
171
+ /**
172
+ * Shared constants for the jeeves-meta service package.
173
+ *
174
+ * @module constants
175
+ */
176
+ /** Default HTTP port for the jeeves-meta service. */
177
+ const DEFAULT_PORT = 1938;
178
+ /** Default port as a string (for Commander CLI defaults). */
179
+ const DEFAULT_PORT_STR = String(DEFAULT_PORT);
180
+ /** Service name identifier. */
181
+ const SERVICE_NAME = 'jeeves-meta';
182
+ /** Service version, read from package.json at startup. */
183
+ const SERVICE_VERSION = (() => {
184
+ try {
185
+ const dir = dirname(fileURLToPath(import.meta.url));
186
+ // Walk up to find package.json (works from src/ or dist/)
187
+ for (const candidate of [
188
+ resolve(dir, '..', 'package.json'),
189
+ resolve(dir, '..', '..', 'package.json'),
190
+ ]) {
191
+ try {
192
+ const pkg = JSON.parse(readFileSync(candidate, 'utf8'));
193
+ if (pkg.version)
194
+ return pkg.version;
195
+ }
196
+ catch {
197
+ // try next candidate
198
+ }
199
+ }
200
+ return 'unknown';
201
+ }
202
+ catch {
203
+ return 'unknown';
204
+ }
205
+ })();
206
+
169
207
  /**
170
208
  * List archive snapshot files in chronological order.
171
209
  *
@@ -293,14 +331,29 @@ function normalizePath(p) {
293
331
  * @param params - Base scan parameters (cursor is managed internally).
294
332
  * @returns All matching files across all pages.
295
333
  */
296
- async function paginatedScan(watcher, params) {
334
+ async function paginatedScan(watcher, params, logger) {
297
335
  const allFiles = [];
298
336
  let cursor;
337
+ let pageCount = 0;
338
+ const start = Date.now();
299
339
  do {
340
+ const pageStart = Date.now();
300
341
  const result = await watcher.scan({ ...params, cursor });
301
342
  allFiles.push(...result.files);
343
+ pageCount++;
344
+ logger?.debug({
345
+ page: pageCount,
346
+ files: result.files.length,
347
+ pageMs: Date.now() - pageStart,
348
+ hasNext: Boolean(result.next),
349
+ }, 'paginatedScan page');
302
350
  cursor = result.next;
303
351
  } while (cursor);
352
+ logger?.debug({
353
+ pages: pageCount,
354
+ totalFiles: allFiles.length,
355
+ totalMs: Date.now() - start,
356
+ }, 'paginatedScan complete');
304
357
  return allFiles;
305
358
  }
306
359
 
@@ -367,12 +420,9 @@ function buildMetaFilter(config) {
367
420
  * @param watcher - WatcherClient for scan queries.
368
421
  * @returns Array of normalized .meta/ directory paths.
369
422
  */
370
- async function discoverMetas(config, watcher) {
423
+ async function discoverMetas(config, watcher, logger) {
371
424
  const filter = buildMetaFilter(config);
372
- const scanFiles = await paginatedScan(watcher, {
373
- filter,
374
- fields: ['file_path'],
375
- });
425
+ const scanFiles = await paginatedScan(watcher, { filter, fields: ['file_path'] }, logger);
376
426
  // Deduplicate by .meta/ directory path (handles multi-chunk files)
377
427
  const seen = new Set();
378
428
  const metaPaths = [];
@@ -600,6 +650,8 @@ function findNode(tree, targetPath) {
600
650
  *
601
651
  * @module discovery/listMetas
602
652
  */
653
+ /** Maximum staleness for never-synthesized metas (1 year in seconds). */
654
+ const MAX_STALENESS_SECONDS$1 = 365 * 86_400;
603
655
  /**
604
656
  * Discover, deduplicate, and enrich all metas.
605
657
  *
@@ -611,9 +663,9 @@ function findNode(tree, targetPath) {
611
663
  * @param watcher - Watcher HTTP client for discovery.
612
664
  * @returns Enriched meta list with summary statistics and ownership tree.
613
665
  */
614
- async function listMetas(config, watcher) {
666
+ async function listMetas(config, watcher, logger) {
615
667
  // Step 1: Discover deduplicated meta paths via watcher scan
616
- const metaPaths = await discoverMetas(config, watcher);
668
+ const metaPaths = await discoverMetas(config, watcher, logger);
617
669
  // Step 2: Build ownership tree
618
670
  const tree = buildOwnershipTree(metaPaths);
619
671
  // Step 3: Read and enrich each meta from disk
@@ -646,7 +698,7 @@ async function listMetas(config, watcher) {
646
698
  // Compute staleness
647
699
  let stalenessSeconds;
648
700
  if (neverSynth) {
649
- stalenessSeconds = Infinity;
701
+ stalenessSeconds = MAX_STALENESS_SECONDS$1;
650
702
  }
651
703
  else {
652
704
  const genAt = new Date(meta._generatedAt).getTime();
@@ -677,11 +729,7 @@ async function listMetas(config, watcher) {
677
729
  }
678
730
  // Track stalest (effective staleness for scheduling)
679
731
  const depthFactor = Math.pow(1 + config.depthWeight, depth);
680
- const effectiveStaleness = (stalenessSeconds === Infinity
681
- ? Number.MAX_SAFE_INTEGER
682
- : stalenessSeconds) *
683
- depthFactor *
684
- emphasis;
732
+ const effectiveStaleness = stalenessSeconds * depthFactor * emphasis;
685
733
  if (effectiveStaleness > stalestEffective) {
686
734
  stalestEffective = effectiveStaleness;
687
735
  stalestPath = node.metaPath;
@@ -723,22 +771,83 @@ async function listMetas(config, watcher) {
723
771
  };
724
772
  }
725
773
 
774
+ /**
775
+ * Recursive filesystem walker for file enumeration.
776
+ *
777
+ * Replaces paginated watcher scans for scope/delta/staleness checks.
778
+ * Returns normalized forward-slash paths.
779
+ *
780
+ * @module walkFiles
781
+ */
782
+ /** Default directory names to always skip. */
783
+ const DEFAULT_SKIP = new Set([
784
+ 'node_modules',
785
+ '.git',
786
+ '.rollup.cache',
787
+ 'dist',
788
+ 'Thumbs.db',
789
+ ]);
790
+ /**
791
+ * Recursively walk a directory and return all file paths.
792
+ *
793
+ * @param root - Root directory to walk.
794
+ * @param options - Walk options.
795
+ * @returns Array of normalized file paths.
796
+ */
797
+ function walkFiles(root, options) {
798
+ const exclude = new Set([...DEFAULT_SKIP, ...(options?.exclude ?? [])]);
799
+ const modifiedAfter = options?.modifiedAfter;
800
+ const maxDepth = options?.maxDepth ?? 50;
801
+ const results = [];
802
+ function walk(dir, depth) {
803
+ if (depth > maxDepth)
804
+ return;
805
+ let entries;
806
+ try {
807
+ entries = readdirSync(dir, { withFileTypes: true });
808
+ }
809
+ catch {
810
+ return; // Permission errors, missing dirs — skip
811
+ }
812
+ for (const entry of entries) {
813
+ if (exclude.has(entry.name))
814
+ continue;
815
+ const fullPath = join(dir, entry.name);
816
+ if (entry.isDirectory()) {
817
+ walk(fullPath, depth + 1);
818
+ }
819
+ else if (entry.isFile()) {
820
+ if (modifiedAfter !== undefined) {
821
+ try {
822
+ const stat = statSync(fullPath);
823
+ if (Math.floor(stat.mtimeMs / 1000) <= modifiedAfter)
824
+ continue;
825
+ }
826
+ catch {
827
+ continue;
828
+ }
829
+ }
830
+ results.push(normalizePath(fullPath));
831
+ }
832
+ }
833
+ }
834
+ walk(root, 0);
835
+ return results;
836
+ }
837
+
726
838
  /**
727
839
  * Compute the file scope owned by a meta node.
728
840
  *
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.
841
+ * A meta owns: parent dir + all descendants, minus:
842
+ * - Its own .meta/ subtree (outputs, not inputs)
843
+ * - Child meta ownerPath subtrees (except their .meta/meta.json for rollups)
844
+ *
845
+ * Uses filesystem walks instead of watcher scans for performance.
731
846
  *
732
847
  * @module discovery/scope
733
848
  */
734
849
  /**
735
850
  * 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
851
  */
743
852
  function getScopePrefix(node) {
744
853
  return node.ownerPath;
@@ -746,47 +855,39 @@ function getScopePrefix(node) {
746
855
  /**
747
856
  * Filter a list of file paths to only those in scope for a meta node.
748
857
  *
749
- * Includes files under ownerPath, excludes files under child meta ownerPaths,
750
- * but includes child .meta/meta.json files as rollup inputs.
858
+ * Excludes:
859
+ * - The node's own .meta/ subtree (synthesis outputs are not scope inputs)
860
+ * - Child meta ownerPath subtrees (except child .meta/meta.json for rollups)
751
861
  *
752
- * @param node - The meta node.
753
- * @param files - Array of file paths to filter.
754
- * @returns Filtered array of in-scope file paths.
862
+ * walkFiles already returns normalized forward-slash paths.
755
863
  */
756
864
  function filterInScope(node, files) {
757
865
  const prefix = node.ownerPath + '/';
866
+ const ownMetaPrefix = node.metaPath + '/';
758
867
  const exclusions = node.children.map((c) => c.ownerPath + '/');
759
868
  const childMetaJsons = new Set(node.children.map((c) => c.metaPath + '/meta.json'));
760
869
  return files.filter((f) => {
761
- const normalized = f.split('\\').join('/');
762
870
  // Must be under ownerPath
763
- if (!normalized.startsWith(prefix) && normalized !== node.ownerPath)
871
+ if (!f.startsWith(prefix) && f !== node.ownerPath)
872
+ return false;
873
+ // Exclude own .meta/ subtree (outputs are not inputs)
874
+ if (f.startsWith(ownMetaPrefix))
764
875
  return false;
765
876
  // Check if under a child meta's subtree
766
877
  for (const excl of exclusions) {
767
- if (normalized.startsWith(excl)) {
878
+ if (f.startsWith(excl)) {
768
879
  // Exception: child meta.json files are included as rollup inputs
769
- return childMetaJsons.has(normalized);
880
+ return childMetaJsons.has(f);
770
881
  }
771
882
  }
772
883
  return true;
773
884
  });
774
885
  }
775
886
  /**
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.
887
+ * Get all files in scope for a meta node via filesystem walk.
784
888
  */
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);
889
+ function getScopeFiles(node) {
890
+ const allFiles = walkFiles(node.ownerPath);
790
891
  return {
791
892
  scopeFiles: filterInScope(node, allFiles),
792
893
  allFiles,
@@ -796,22 +897,13 @@ async function getScopeFiles(node, watcher) {
796
897
  * Get files modified since a given timestamp within a meta node's scope.
797
898
  *
798
899
  * 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
900
  */
806
- async function getDeltaFiles(node, watcher, generatedAt, scopeFiles) {
901
+ function getDeltaFiles(node, generatedAt, scopeFiles) {
807
902
  if (!generatedAt)
808
903
  return scopeFiles;
809
904
  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));
905
+ const deltaFiles = walkFiles(node.ownerPath, { modifiedAfter });
906
+ return filterInScope(node, deltaFiles);
815
907
  }
816
908
 
817
909
  /**
@@ -890,7 +982,7 @@ function sleep(ms) {
890
982
  * @module executor/GatewayExecutor
891
983
  */
892
984
  const DEFAULT_POLL_INTERVAL_MS = 5000;
893
- const DEFAULT_TIMEOUT_MS = 600_000; // 10 minutes
985
+ const DEFAULT_TIMEOUT_MS$1 = 600_000; // 10 minutes
894
986
  /**
895
987
  * MetaExecutor that spawns OpenClaw sessions via the gateway's
896
988
  * `/tools/invoke` endpoint.
@@ -903,10 +995,12 @@ class GatewayExecutor {
903
995
  gatewayUrl;
904
996
  apiKey;
905
997
  pollIntervalMs;
998
+ workspaceDir;
906
999
  constructor(options = {}) {
907
1000
  this.gatewayUrl = (options.gatewayUrl ?? 'http://127.0.0.1:18789').replace(/\/+$/, '');
908
1001
  this.apiKey = options.apiKey;
909
1002
  this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
1003
+ this.workspaceDir = options.workspaceDir ?? join(tmpdir(), 'jeeves-meta');
910
1004
  }
911
1005
  /** Invoke a gateway tool via the /tools/invoke HTTP endpoint. */
912
1006
  async invoke(tool, args) {
@@ -931,13 +1025,44 @@ class GatewayExecutor {
931
1025
  }
932
1026
  return data;
933
1027
  }
1028
+ /** Look up totalTokens for a session via sessions_list. */
1029
+ async getSessionTokens(sessionKey) {
1030
+ try {
1031
+ const result = await this.invoke('sessions_list', {
1032
+ limit: 20,
1033
+ messageLimit: 0,
1034
+ });
1035
+ const sessions = (result.result?.details?.sessions ??
1036
+ result.result?.sessions ??
1037
+ []);
1038
+ const match = sessions.find((s) => s.key === sessionKey);
1039
+ return match?.totalTokens ?? undefined;
1040
+ }
1041
+ catch {
1042
+ return undefined;
1043
+ }
1044
+ }
934
1045
  async spawn(task, options) {
935
- const timeoutSeconds = options?.timeout ?? DEFAULT_TIMEOUT_MS / 1000;
1046
+ const timeoutSeconds = options?.timeout ?? DEFAULT_TIMEOUT_MS$1 / 1000;
936
1047
  const timeoutMs = timeoutSeconds * 1000;
937
1048
  const deadline = Date.now() + timeoutMs;
1049
+ // Ensure workspace dir exists
1050
+ if (!existsSync(this.workspaceDir)) {
1051
+ mkdirSync(this.workspaceDir, { recursive: true });
1052
+ }
1053
+ // Generate unique output path for file-based output
1054
+ const outputId = randomUUID();
1055
+ const outputPath = this.workspaceDir + '/output-' + outputId + '.json';
1056
+ // Append file output instruction to the task
1057
+ const taskWithOutput = task +
1058
+ '\n\n## OUTPUT DELIVERY\n\n' +
1059
+ 'Write your complete output to a file using the Write tool at:\n' +
1060
+ outputPath +
1061
+ '\n\n' +
1062
+ 'Reply with ONLY the file path you wrote to. No other text.';
938
1063
  // Step 1: Spawn the sub-agent session
939
1064
  const spawnResult = await this.invoke('sessions_spawn', {
940
- task,
1065
+ task: taskWithOutput,
941
1066
  label: options?.label ?? 'jeeves-meta-synthesis',
942
1067
  runTimeoutSeconds: timeoutSeconds,
943
1068
  ...(options?.thinking ? { thinking: options.thinking } : {}),
@@ -969,19 +1094,37 @@ class GatewayExecutor {
969
1094
  lastMsg.stopReason &&
970
1095
  lastMsg.stopReason !== 'toolUse' &&
971
1096
  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;
1097
+ // Fetch token usage from session metadata
1098
+ const tokens = await this.getSessionTokens(sessionKey);
1099
+ // Read output from file (sub-agent wrote it via Write tool)
1100
+ if (existsSync(outputPath)) {
1101
+ try {
1102
+ const output = readFileSync(outputPath, 'utf8');
1103
+ return { output, tokens };
1104
+ }
1105
+ finally {
1106
+ try {
1107
+ unlinkSync(outputPath);
1108
+ }
1109
+ catch {
1110
+ /* cleanup best-effort */
1111
+ }
1112
+ }
978
1113
  }
979
- if (sum > 0)
980
- tokens = sum;
981
- // Find the last assistant message with content
1114
+ // Fallback: extract from message content if file wasn't written
982
1115
  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 };
1116
+ const msg = msgArray[i];
1117
+ if (msg.role === 'assistant' && msg.content) {
1118
+ const text = typeof msg.content === 'string'
1119
+ ? msg.content
1120
+ : Array.isArray(msg.content)
1121
+ ? msg.content
1122
+ .filter((b) => b.type === 'text' && b.text)
1123
+ .map((b) => b.text)
1124
+ .join('\n')
1125
+ : '';
1126
+ if (text)
1127
+ return { output: text, tokens };
985
1128
  }
986
1129
  }
987
1130
  return { output: '', tokens };
@@ -1061,10 +1204,10 @@ function condenseScopeFiles(files, maxIndividual = 30) {
1061
1204
  * @param watcher - WatcherClient for scope enumeration.
1062
1205
  * @returns The computed context package.
1063
1206
  */
1064
- async function buildContextPackage(node, meta, watcher) {
1207
+ function buildContextPackage(node, meta) {
1065
1208
  // Scope and delta files via watcher scan
1066
- const { scopeFiles } = await getScopeFiles(node, watcher);
1067
- const deltaFiles = await getDeltaFiles(node, watcher, meta._generatedAt, scopeFiles);
1209
+ const { scopeFiles } = getScopeFiles(node);
1210
+ const deltaFiles = getDeltaFiles(node, meta._generatedAt, scopeFiles);
1068
1211
  // Child meta outputs
1069
1212
  const childMetas = {};
1070
1213
  for (const child of node.children) {
@@ -1175,7 +1318,7 @@ function buildBuilderTask(ctx, meta, config) {
1175
1318
  includeSteer: false,
1176
1319
  feedbackHeading: '## FEEDBACK FROM CRITIC',
1177
1320
  });
1178
- sections.push('', '## OUTPUT FORMAT', 'Return a JSON object with:', '- "_content": Markdown narrative synthesis (required)', '- Any additional structured fields as non-underscore keys');
1321
+ 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
1322
  return sections.join('\n');
1180
1323
  }
1181
1324
  /**
@@ -1481,28 +1624,33 @@ function discoverStalestPath(candidates, depthWeight) {
1481
1624
  * @param watcher - WatcherClient instance.
1482
1625
  * @returns True if any file in scope was modified after _generatedAt.
1483
1626
  */
1484
- async function isStale(scopePrefix, meta, watcher) {
1627
+ function isStale(scopePrefix, meta) {
1485
1628
  if (!meta._generatedAt)
1486
1629
  return true; // Never synthesized = stale
1487
1630
  const generatedAtUnix = Math.floor(new Date(meta._generatedAt).getTime() / 1000);
1488
- const result = await watcher.scan({
1489
- pathPrefix: scopePrefix,
1631
+ const modified = walkFiles(scopePrefix, {
1490
1632
  modifiedAfter: generatedAtUnix,
1491
- limit: 1,
1633
+ maxDepth: 1,
1492
1634
  });
1493
- return result.files.length > 0;
1635
+ return modified.length > 0;
1494
1636
  }
1637
+ /** Maximum staleness for never-synthesized metas (1 year in seconds). */
1638
+ const MAX_STALENESS_SECONDS = 365 * 86_400;
1495
1639
  /**
1496
1640
  * Compute actual staleness in seconds (now minus _generatedAt).
1497
1641
  *
1642
+ * Never-synthesized metas are capped at {@link MAX_STALENESS_SECONDS}
1643
+ * (1 year) so that depth weighting can differentiate them. Without
1644
+ * bounding, `Infinity * depthFactor` = `Infinity` for all depths.
1645
+ *
1498
1646
  * @param meta - Current meta.json content.
1499
- * @returns Staleness in seconds, or Infinity if never synthesized.
1647
+ * @returns Staleness in seconds, capped at 1 year for never-synthesized metas.
1500
1648
  */
1501
1649
  function actualStaleness(meta) {
1502
1650
  if (!meta._generatedAt)
1503
- return Infinity;
1651
+ return MAX_STALENESS_SECONDS;
1504
1652
  const generatedMs = new Date(meta._generatedAt).getTime();
1505
- return (Date.now() - generatedMs) / 1000;
1653
+ return Math.min((Date.now() - generatedMs) / 1000, MAX_STALENESS_SECONDS);
1506
1654
  }
1507
1655
  /**
1508
1656
  * Check whether the architect step should be triggered.
@@ -1579,20 +1727,50 @@ function parseArchitectOutput(output) {
1579
1727
  */
1580
1728
  function parseBuilderOutput(output) {
1581
1729
  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
- }
1730
+ // Strategy 1: Try to parse the entire output as JSON directly
1731
+ const direct = tryParseJson(trimmed);
1732
+ if (direct)
1733
+ return direct;
1734
+ // Strategy 2: Try all fenced code blocks (last match first — models often narrate then output)
1735
+ const fencePattern = /```(?:json)?\s*([\s\S]*?)```/g;
1736
+ const fenceMatches = [];
1737
+ let match;
1738
+ while ((match = fencePattern.exec(trimmed)) !== null) {
1739
+ fenceMatches.push(match[1].trim());
1740
+ }
1741
+ // Try last fence first (most likely to be the actual output)
1742
+ for (let i = fenceMatches.length - 1; i >= 0; i--) {
1743
+ const result = tryParseJson(fenceMatches[i]);
1744
+ if (result)
1745
+ return result;
1746
+ }
1747
+ // Strategy 3: Find outermost { ... } braces
1748
+ const firstBrace = trimmed.indexOf('{');
1749
+ const lastBrace = trimmed.lastIndexOf('}');
1750
+ if (firstBrace !== -1 && lastBrace > firstBrace) {
1751
+ const result = tryParseJson(trimmed.substring(firstBrace, lastBrace + 1));
1752
+ if (result)
1753
+ return result;
1754
+ }
1755
+ // Fallback: treat entire output as content
1756
+ return { content: trimmed, fields: {} };
1757
+ }
1758
+ /** Try to parse a string as JSON and extract builder output fields. */
1759
+ function tryParseJson(str) {
1588
1760
  try {
1589
- const parsed = JSON.parse(jsonStr);
1761
+ const raw = JSON.parse(str);
1762
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
1763
+ return null;
1764
+ }
1765
+ const parsed = raw;
1590
1766
  // Extract _content
1591
- const content = typeof parsed._content === 'string'
1592
- ? parsed._content
1593
- : typeof parsed.content === 'string'
1594
- ? parsed.content
1595
- : trimmed;
1767
+ const content = typeof parsed['_content'] === 'string'
1768
+ ? parsed['_content']
1769
+ : typeof parsed['content'] === 'string'
1770
+ ? parsed['content']
1771
+ : null;
1772
+ if (content === null)
1773
+ return null;
1596
1774
  // Extract non-underscore fields
1597
1775
  const fields = {};
1598
1776
  for (const [key, value] of Object.entries(parsed)) {
@@ -1603,8 +1781,7 @@ function parseBuilderOutput(output) {
1603
1781
  return { content, fields };
1604
1782
  }
1605
1783
  catch {
1606
- // Not valid JSON — treat entire output as content
1607
- return { content: trimmed, fields: {} };
1784
+ return null;
1608
1785
  }
1609
1786
  }
1610
1787
  /**
@@ -1665,9 +1842,239 @@ function finalizeCycle(opts) {
1665
1842
  * @param watcher - Watcher HTTP client.
1666
1843
  * @returns Result indicating whether synthesis occurred.
1667
1844
  */
1668
- async function orchestrateOnce(config, executor, watcher, targetPath, onProgress) {
1669
- // Step 1: Discover via watcher scan
1670
- const metaPaths = await discoverMetas(config, watcher);
1845
+ /**
1846
+ * Build a minimal MetaNode from the filesystem for a known meta path.
1847
+ * Discovers immediate child .meta/ dirs without a full watcher scan.
1848
+ */
1849
+ function buildMinimalNode(metaPath) {
1850
+ const normalized = normalizePath(metaPath);
1851
+ const ownerPath = normalizePath(dirname(metaPath));
1852
+ // Find child .meta/ directories by scanning the owner directory
1853
+ const children = [];
1854
+ function findChildMetas(dir, depth) {
1855
+ if (depth > 10)
1856
+ return; // Safety limit
1857
+ try {
1858
+ const entries = readdirSync(dir, { withFileTypes: true });
1859
+ for (const entry of entries) {
1860
+ if (!entry.isDirectory())
1861
+ continue;
1862
+ const fullPath = normalizePath(join(dir, entry.name));
1863
+ if (entry.name === '.meta' && fullPath !== normalized) {
1864
+ // Found a child .meta — check it has meta.json
1865
+ if (existsSync(join(fullPath, 'meta.json'))) {
1866
+ children.push({
1867
+ metaPath: fullPath,
1868
+ ownerPath: normalizePath(dirname(fullPath)),
1869
+ treeDepth: 1, // Relative to target
1870
+ children: [],
1871
+ parent: null, // Set below
1872
+ });
1873
+ }
1874
+ // Don't recurse into .meta dirs
1875
+ return;
1876
+ }
1877
+ if (entry.name === 'node_modules' ||
1878
+ entry.name === '.git' ||
1879
+ entry.name === 'archive')
1880
+ continue;
1881
+ findChildMetas(fullPath, depth + 1);
1882
+ }
1883
+ }
1884
+ catch {
1885
+ // Permission errors, etc — skip
1886
+ }
1887
+ }
1888
+ findChildMetas(ownerPath, 0);
1889
+ const node = {
1890
+ metaPath: normalized,
1891
+ ownerPath,
1892
+ treeDepth: 0,
1893
+ children,
1894
+ parent: null,
1895
+ };
1896
+ // Wire parent references
1897
+ for (const child of children) {
1898
+ child.parent = node;
1899
+ }
1900
+ return node;
1901
+ }
1902
+ /** Run the architect/builder/critic pipeline on a single node. */
1903
+ async function synthesizeNode(node, currentMeta, config, executor, watcher, onProgress) {
1904
+ const architectPrompt = currentMeta._architect ?? config.defaultArchitect;
1905
+ const criticPrompt = currentMeta._critic ?? config.defaultCritic;
1906
+ // Step 5-6: Steer change detection
1907
+ const latestArchive = readLatestArchive(node.metaPath);
1908
+ const steerChanged = hasSteerChanged(currentMeta._steer, latestArchive?._steer, Boolean(latestArchive));
1909
+ // Step 7: Compute context (includes scope files and delta files)
1910
+ const ctx = buildContextPackage(node, currentMeta);
1911
+ // Step 5 (deferred): Structure hash from context scope files
1912
+ const newStructureHash = computeStructureHash(ctx.scopeFiles);
1913
+ const structureChanged = newStructureHash !== currentMeta._structureHash;
1914
+ // Step 8: Architect (conditional)
1915
+ const architectTriggered = isArchitectTriggered(currentMeta, structureChanged, steerChanged, config.architectEvery);
1916
+ let builderBrief = currentMeta._builder ?? '';
1917
+ let synthesisCount = currentMeta._synthesisCount ?? 0;
1918
+ let stepError = null;
1919
+ let architectTokens;
1920
+ let builderTokens;
1921
+ let criticTokens;
1922
+ if (architectTriggered) {
1923
+ try {
1924
+ await onProgress?.({
1925
+ type: 'phase_start',
1926
+ path: node.ownerPath,
1927
+ phase: 'architect',
1928
+ });
1929
+ const phaseStart = Date.now();
1930
+ const architectTask = buildArchitectTask(ctx, currentMeta, config);
1931
+ const architectResult = await executor.spawn(architectTask, {
1932
+ thinking: config.thinking,
1933
+ timeout: config.architectTimeout,
1934
+ });
1935
+ builderBrief = parseArchitectOutput(architectResult.output);
1936
+ architectTokens = architectResult.tokens;
1937
+ synthesisCount = 0;
1938
+ await onProgress?.({
1939
+ type: 'phase_complete',
1940
+ path: node.ownerPath,
1941
+ phase: 'architect',
1942
+ tokens: architectTokens,
1943
+ durationMs: Date.now() - phaseStart,
1944
+ });
1945
+ }
1946
+ catch (err) {
1947
+ stepError = toMetaError('architect', err);
1948
+ if (!currentMeta._builder) {
1949
+ // No cached builder — cycle fails
1950
+ finalizeCycle({
1951
+ metaPath: node.metaPath,
1952
+ current: currentMeta,
1953
+ config,
1954
+ architect: architectPrompt,
1955
+ builder: '',
1956
+ critic: criticPrompt,
1957
+ builderOutput: null,
1958
+ feedback: null,
1959
+ structureHash: newStructureHash,
1960
+ synthesisCount,
1961
+ error: stepError,
1962
+ architectTokens,
1963
+ });
1964
+ return {
1965
+ synthesized: true,
1966
+ metaPath: node.metaPath,
1967
+ error: stepError,
1968
+ };
1969
+ }
1970
+ // Has cached builder — continue with existing
1971
+ }
1972
+ }
1973
+ // Step 9: Builder
1974
+ const metaForBuilder = { ...currentMeta, _builder: builderBrief };
1975
+ let builderOutput = null;
1976
+ try {
1977
+ await onProgress?.({
1978
+ type: 'phase_start',
1979
+ path: node.ownerPath,
1980
+ phase: 'builder',
1981
+ });
1982
+ const builderStart = Date.now();
1983
+ const builderTask = buildBuilderTask(ctx, metaForBuilder, config);
1984
+ const builderResult = await executor.spawn(builderTask, {
1985
+ thinking: config.thinking,
1986
+ timeout: config.builderTimeout,
1987
+ });
1988
+ builderOutput = parseBuilderOutput(builderResult.output);
1989
+ builderTokens = builderResult.tokens;
1990
+ synthesisCount++;
1991
+ await onProgress?.({
1992
+ type: 'phase_complete',
1993
+ path: node.ownerPath,
1994
+ phase: 'builder',
1995
+ tokens: builderTokens,
1996
+ durationMs: Date.now() - builderStart,
1997
+ });
1998
+ }
1999
+ catch (err) {
2000
+ stepError = toMetaError('builder', err);
2001
+ return { synthesized: true, metaPath: node.metaPath, error: stepError };
2002
+ }
2003
+ // Step 10: Critic
2004
+ const metaForCritic = {
2005
+ ...currentMeta,
2006
+ _content: builderOutput.content,
2007
+ };
2008
+ let feedback = null;
2009
+ try {
2010
+ await onProgress?.({
2011
+ type: 'phase_start',
2012
+ path: node.ownerPath,
2013
+ phase: 'critic',
2014
+ });
2015
+ const criticStart = Date.now();
2016
+ const criticTask = buildCriticTask(ctx, metaForCritic, config);
2017
+ const criticResult = await executor.spawn(criticTask, {
2018
+ thinking: config.thinking,
2019
+ timeout: config.criticTimeout,
2020
+ });
2021
+ feedback = parseCriticOutput(criticResult.output);
2022
+ criticTokens = criticResult.tokens;
2023
+ stepError = null; // Clear any architect error on full success
2024
+ await onProgress?.({
2025
+ type: 'phase_complete',
2026
+ path: node.ownerPath,
2027
+ phase: 'critic',
2028
+ tokens: criticTokens,
2029
+ durationMs: Date.now() - criticStart,
2030
+ });
2031
+ }
2032
+ catch (err) {
2033
+ stepError = stepError ?? toMetaError('critic', err);
2034
+ }
2035
+ // Steps 11-12: Merge, archive, prune
2036
+ finalizeCycle({
2037
+ metaPath: node.metaPath,
2038
+ current: currentMeta,
2039
+ config,
2040
+ architect: architectPrompt,
2041
+ builder: builderBrief,
2042
+ critic: criticPrompt,
2043
+ builderOutput,
2044
+ feedback,
2045
+ structureHash: newStructureHash,
2046
+ synthesisCount,
2047
+ error: stepError,
2048
+ architectTokens,
2049
+ builderTokens,
2050
+ criticTokens,
2051
+ });
2052
+ return {
2053
+ synthesized: true,
2054
+ metaPath: node.metaPath,
2055
+ error: stepError ?? undefined,
2056
+ };
2057
+ }
2058
+ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress, logger) {
2059
+ // When targetPath is provided, skip the expensive full discovery scan.
2060
+ // Build a minimal node from the filesystem instead.
2061
+ if (targetPath) {
2062
+ const normalizedTarget = normalizePath(targetPath);
2063
+ const targetMetaJson = join(normalizedTarget, 'meta.json');
2064
+ if (!existsSync(targetMetaJson))
2065
+ return { synthesized: false };
2066
+ const node = buildMinimalNode(normalizedTarget);
2067
+ if (!acquireLock(node.metaPath))
2068
+ return { synthesized: false };
2069
+ try {
2070
+ const currentMeta = JSON.parse(readFileSync(targetMetaJson, 'utf8'));
2071
+ return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress);
2072
+ }
2073
+ finally {
2074
+ releaseLock(node.metaPath);
2075
+ }
2076
+ }
2077
+ const metaPaths = await discoverMetas(config, watcher, logger);
1671
2078
  if (metaPaths.length === 0)
1672
2079
  return { synthesized: false };
1673
2080
  // Read meta.json for each discovered meta
@@ -1687,23 +2094,15 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
1687
2094
  if (validPaths.length === 0)
1688
2095
  return { synthesized: false };
1689
2096
  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
2097
  // Steps 3-4: Staleness check + candidate selection
1699
2098
  const candidates = [];
1700
- for (const node of tree.nodes.values()) {
1701
- const meta = metas.get(node.metaPath);
2099
+ for (const treeNode of tree.nodes.values()) {
2100
+ const meta = metas.get(treeNode.metaPath);
1702
2101
  if (!meta)
1703
- continue; // Node not in metas map (e.g. unreadable meta.json)
2102
+ continue;
1704
2103
  const staleness = actualStaleness(meta);
1705
2104
  if (staleness > 0) {
1706
- candidates.push({ node, meta, actualStaleness: staleness });
2105
+ candidates.push({ node: treeNode, meta, actualStaleness: staleness });
1707
2106
  }
1708
2107
  }
1709
2108
  const weighted = computeEffectiveStaleness(candidates, config.depthWeight);
@@ -1716,7 +2115,7 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
1716
2115
  for (const candidate of ranked) {
1717
2116
  if (!acquireLock(candidate.node.metaPath))
1718
2117
  continue;
1719
- const verifiedStale = await isStale(getScopePrefix(candidate.node), candidate.meta, watcher);
2118
+ const verifiedStale = isStale(getScopePrefix(candidate.node), candidate.meta);
1720
2119
  if (!verifiedStale && candidate.meta._generatedAt) {
1721
2120
  // Bump _generatedAt so it doesn't win next cycle
1722
2121
  const metaFilePath = join(candidate.node.metaPath, 'meta.json');
@@ -1731,169 +2130,12 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
1731
2130
  winner = candidate;
1732
2131
  break;
1733
2132
  }
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)) {
2133
+ if (!winner)
1739
2134
  return { synthesized: false };
1740
- }
2135
+ const node = winner.node;
1741
2136
  try {
1742
- // Re-read meta after lock (may have changed)
1743
2137
  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
- };
2138
+ return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress);
1897
2139
  }
1898
2140
  finally {
1899
2141
  // Step 13: Release lock
@@ -1912,8 +2154,8 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
1912
2154
  * @param targetPath - Optional: specific meta/owner path to synthesize instead of stalest candidate.
1913
2155
  * @returns Array with a single result.
1914
2156
  */
1915
- async function orchestrate(config, executor, watcher, targetPath, onProgress) {
1916
- const result = await orchestrateOnce(config, executor, watcher, targetPath, onProgress);
2157
+ async function orchestrate(config, executor, watcher, targetPath, onProgress, logger) {
2158
+ const result = await orchestrateOnce(config, executor, watcher, targetPath, onProgress, logger);
1917
2159
  return [result];
1918
2160
  }
1919
2161
 
@@ -1922,9 +2164,12 @@ async function orchestrate(config, executor, watcher, targetPath, onProgress) {
1922
2164
  *
1923
2165
  * @module progress
1924
2166
  */
2167
+ function formatNumber(n) {
2168
+ return n.toLocaleString('en-US');
2169
+ }
1925
2170
  function formatSeconds(durationMs) {
1926
2171
  const seconds = durationMs / 1000;
1927
- return seconds.toFixed(1) + 's';
2172
+ return Math.round(seconds).toString() + 's';
1928
2173
  }
1929
2174
  function titleCasePhase(phase) {
1930
2175
  return phase.charAt(0).toUpperCase() + phase.slice(1);
@@ -1932,32 +2177,30 @@ function titleCasePhase(phase) {
1932
2177
  function formatProgressEvent(event) {
1933
2178
  switch (event.type) {
1934
2179
  case 'synthesis_start':
1935
- return `🔬 Started meta synthesis: ${event.metaPath}`;
2180
+ return `🔬 Started meta synthesis: ${event.path}`;
1936
2181
  case 'phase_start': {
1937
2182
  if (!event.phase) {
1938
- return ` ⚙️ Phase started: ${event.metaPath}`;
2183
+ return ' ⚙️ Phase started';
1939
2184
  }
1940
2185
  return ` ⚙️ ${titleCasePhase(event.phase)} phase started`;
1941
2186
  }
1942
2187
  case 'phase_complete': {
1943
2188
  const phase = event.phase ? titleCasePhase(event.phase) : 'Phase';
1944
2189
  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})`;
2190
+ const duration = event.durationMs !== undefined ? formatSeconds(event.durationMs) : '0s';
2191
+ return ` ✅ ${phase} complete (${formatNumber(tokens)} tokens / ${duration})`;
1949
2192
  }
1950
2193
  case 'synthesis_complete': {
1951
2194
  const tokens = event.tokens ?? 0;
1952
2195
  const duration = event.durationMs !== undefined
1953
2196
  ? formatSeconds(event.durationMs)
1954
2197
  : '0.0s';
1955
- return `✅ Completed: ${event.metaPath} (${String(tokens)} tokens / ${duration})`;
2198
+ return `✅ Completed: ${event.path} (${formatNumber(tokens)} tokens / ${duration})`;
1956
2199
  }
1957
2200
  case 'error': {
1958
2201
  const phase = event.phase ? `${titleCasePhase(event.phase)} ` : '';
1959
2202
  const error = event.error ?? 'Unknown error';
1960
- return `❌ Synthesis failed at ${phase}phase: ${event.metaPath}\n Error: ${error}`;
2203
+ return `❌ Synthesis failed at ${phase}phase: ${event.path}\n Error: ${error}`;
1961
2204
  }
1962
2205
  default: {
1963
2206
  return 'Unknown progress event';
@@ -2135,7 +2378,7 @@ class Scheduler {
2135
2378
  */
2136
2379
  async discoverStalest() {
2137
2380
  try {
2138
- const result = await listMetas(this.config, this.watcher);
2381
+ const result = await listMetas(this.config, this.watcher, this.logger);
2139
2382
  const stale = result.entries
2140
2383
  .filter((e) => e.stalenessSeconds > 0)
2141
2384
  .map((e) => ({
@@ -2421,7 +2664,7 @@ function registerMetasRoutes(app, deps) {
2421
2664
  app.get('/metas', async (request) => {
2422
2665
  const query = metasQuerySchema.parse(request.query);
2423
2666
  const { config, watcher } = deps;
2424
- const result = await listMetas(config, watcher);
2667
+ const result = await listMetas(config, watcher, request.log);
2425
2668
  let entries = result.entries;
2426
2669
  // Apply filters
2427
2670
  if (query.pathPrefix) {
@@ -2484,7 +2727,7 @@ function registerMetasRoutes(app, deps) {
2484
2727
  const query = metaDetailQuerySchema.parse(request.query);
2485
2728
  const { config, watcher } = deps;
2486
2729
  const targetPath = normalizePath(decodeURIComponent(request.params.path));
2487
- const result = await listMetas(config, watcher);
2730
+ const result = await listMetas(config, watcher, request.log);
2488
2731
  const targetNode = findNode(result.tree, targetPath);
2489
2732
  if (!targetNode) {
2490
2733
  return reply.status(404).send({
@@ -2517,7 +2760,7 @@ function registerMetasRoutes(app, deps) {
2517
2760
  return r;
2518
2761
  };
2519
2762
  // Compute scope
2520
- const { scopeFiles, allFiles } = await getScopeFiles(targetNode, watcher);
2763
+ const { scopeFiles, allFiles } = getScopeFiles(targetNode);
2521
2764
  // Compute staleness
2522
2765
  const metaTyped = meta;
2523
2766
  const staleSeconds = metaTyped._generatedAt
@@ -2564,7 +2807,7 @@ function registerPreviewRoute(app, deps) {
2564
2807
  const query = request.query;
2565
2808
  let result;
2566
2809
  try {
2567
- result = await listMetas(config, watcher);
2810
+ result = await listMetas(config, watcher, request.log);
2568
2811
  }
2569
2812
  catch {
2570
2813
  return reply.status(503).send({
@@ -2600,14 +2843,14 @@ function registerPreviewRoute(app, deps) {
2600
2843
  }
2601
2844
  const meta = JSON.parse(readFileSync(join(targetNode.metaPath, 'meta.json'), 'utf8'));
2602
2845
  // Scope files
2603
- const { scopeFiles } = await getScopeFiles(targetNode, watcher);
2846
+ const { scopeFiles } = getScopeFiles(targetNode);
2604
2847
  const structureHash = computeStructureHash(scopeFiles);
2605
2848
  const structureChanged = structureHash !== meta._structureHash;
2606
2849
  const latestArchive = readLatestArchive(targetNode.metaPath);
2607
2850
  const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
2608
2851
  const architectTriggered = isArchitectTriggered(meta, structureChanged, steerChanged, config.architectEvery);
2609
2852
  // Delta files
2610
- const deltaFiles = await getDeltaFiles(targetNode, watcher, meta._generatedAt, scopeFiles);
2853
+ const deltaFiles = getDeltaFiles(targetNode, meta._generatedAt, scopeFiles);
2611
2854
  // EMA token estimates
2612
2855
  const estimatedTokens = {
2613
2856
  architect: meta._architectTokensAvg ?? meta._architectTokens ?? 0,
@@ -2701,7 +2944,7 @@ async function checkDependency(url, path) {
2701
2944
  }
2702
2945
  function registerStatusRoute(app, deps) {
2703
2946
  app.get('/status', async () => {
2704
- const { config, queue, scheduler, stats, watcher } = deps;
2947
+ const { config, queue, scheduler, stats } = deps;
2705
2948
  // On-demand dependency checks
2706
2949
  const [watcherHealth, gatewayHealth] = await Promise.all([
2707
2950
  checkDependency(config.watcherUrl, '/status'),
@@ -2722,23 +2965,11 @@ function registerStatusRoute(app, deps) {
2722
2965
  else {
2723
2966
  status = 'idle';
2724
2967
  }
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
- }
2968
+ // Metas summary is expensive (paginated watcher scan + disk reads).
2969
+ // Use GET /metas for full inventory; status is a lightweight health check.
2739
2970
  return {
2740
- service: 'jeeves-meta',
2741
- version: '0.4.0',
2971
+ service: SERVICE_NAME,
2972
+ version: SERVICE_VERSION,
2742
2973
  uptime: process.uptime(),
2743
2974
  status,
2744
2975
  currentTarget: queue.current?.path ?? null,
@@ -2758,7 +2989,6 @@ function registerStatusRoute(app, deps) {
2758
2989
  watcher: watcherHealth,
2759
2990
  gateway: gatewayHealth,
2760
2991
  },
2761
- metas: metasSummary,
2762
2992
  };
2763
2993
  });
2764
2994
  }
@@ -2784,7 +3014,7 @@ function registerSynthesizeRoute(app, deps) {
2784
3014
  // Discover stalest candidate
2785
3015
  let result;
2786
3016
  try {
2787
- result = await listMetas(config, watcher);
3017
+ result = await listMetas(config, watcher, request.log);
2788
3018
  }
2789
3019
  catch {
2790
3020
  return reply.status(503).send({
@@ -2945,6 +3175,10 @@ function buildMetaRules(config) {
2945
3175
  type: 'string',
2946
3176
  set: '{{json._error.step}}',
2947
3177
  },
3178
+ generated_at: {
3179
+ type: 'string',
3180
+ set: '{{json._generatedAt}}',
3181
+ },
2948
3182
  generated_at_unix: {
2949
3183
  type: 'integer',
2950
3184
  set: '{{toUnix json._generatedAt}}',
@@ -2957,16 +3191,7 @@ function buildMetaRules(config) {
2957
3191
  },
2958
3192
  ],
2959
3193
  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
- ],
3194
+ frontmatter: ['meta_id', 'generated_at', '*', '!_*', '!has_error'],
2970
3195
  body: [{ path: 'json._content', heading: 1, label: 'Synthesis' }],
2971
3196
  },
2972
3197
  renderAs: 'md',
@@ -3116,7 +3341,10 @@ class RuleRegistrar {
3116
3341
  * @returns Configured Fastify instance (not yet listening).
3117
3342
  */
3118
3343
  function createServer(options) {
3119
- const app = Fastify({ logger: options.logger });
3344
+ // Fastify 5 requires `loggerInstance` for external pino loggers
3345
+ const app = Fastify({
3346
+ loggerInstance: options.logger,
3347
+ });
3120
3348
  registerRoutes(app, {
3121
3349
  config: options.config,
3122
3350
  logger: options.logger,
@@ -3197,6 +3425,7 @@ function registerShutdownHandlers(deps) {
3197
3425
  const DEFAULT_MAX_RETRIES = 3;
3198
3426
  const DEFAULT_BACKOFF_BASE_MS = 1000;
3199
3427
  const DEFAULT_BACKOFF_FACTOR = 4;
3428
+ const DEFAULT_TIMEOUT_MS = 30_000;
3200
3429
  /** Check if an error is transient (worth retrying). */
3201
3430
  function isTransient(status) {
3202
3431
  return status >= 500 || status === 408 || status === 429;
@@ -3209,11 +3438,13 @@ class HttpWatcherClient {
3209
3438
  maxRetries;
3210
3439
  backoffBaseMs;
3211
3440
  backoffFactor;
3441
+ timeoutMs;
3212
3442
  constructor(options) {
3213
3443
  this.baseUrl = options.baseUrl.replace(/\/+$/, '');
3214
3444
  this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
3215
3445
  this.backoffBaseMs = options.backoffBaseMs ?? DEFAULT_BACKOFF_BASE_MS;
3216
3446
  this.backoffFactor = options.backoffFactor ?? DEFAULT_BACKOFF_FACTOR;
3447
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
3217
3448
  }
3218
3449
  /** POST JSON with retry. */
3219
3450
  async post(endpoint, body) {
@@ -3223,6 +3454,7 @@ class HttpWatcherClient {
3223
3454
  method: 'POST',
3224
3455
  headers: { 'Content-Type': 'application/json' },
3225
3456
  body: JSON.stringify(body),
3457
+ signal: AbortSignal.timeout(this.timeoutMs),
3226
3458
  });
3227
3459
  if (res.ok) {
3228
3460
  return res.json();
@@ -3363,9 +3595,11 @@ async function startService(config, configPath) {
3363
3595
  const synthesizeFn = async (path) => {
3364
3596
  const startMs = Date.now();
3365
3597
  let cycleTokens = 0;
3598
+ // Strip .meta suffix for human-readable progress reporting
3599
+ const ownerPath = path.replace(/\/?\.meta\/?$/, '');
3366
3600
  await progress.report({
3367
3601
  type: 'synthesis_start',
3368
- metaPath: path,
3602
+ path: ownerPath,
3369
3603
  });
3370
3604
  try {
3371
3605
  const results = await orchestrate(config, executor, watcher, path, async (evt) => {
@@ -3387,7 +3621,7 @@ async function startService(config, configPath) {
3387
3621
  stats.totalErrors++;
3388
3622
  await progress.report({
3389
3623
  type: 'error',
3390
- metaPath: path,
3624
+ path: ownerPath,
3391
3625
  error: result.error.message,
3392
3626
  });
3393
3627
  }
@@ -3395,7 +3629,7 @@ async function startService(config, configPath) {
3395
3629
  scheduler.resetBackoff();
3396
3630
  await progress.report({
3397
3631
  type: 'synthesis_complete',
3398
- metaPath: path,
3632
+ path: ownerPath,
3399
3633
  tokens: cycleTokens,
3400
3634
  durationMs,
3401
3635
  });
@@ -3406,7 +3640,7 @@ async function startService(config, configPath) {
3406
3640
  const message = err instanceof Error ? err.message : String(err);
3407
3641
  await progress.report({
3408
3642
  type: 'error',
3409
- metaPath: path,
3643
+ path: ownerPath,
3410
3644
  error: message,
3411
3645
  });
3412
3646
  throw err;
@@ -3474,7 +3708,7 @@ async function startService(config, configPath) {
3474
3708
  * @module cli
3475
3709
  */
3476
3710
  const program = new Command();
3477
- program.name('jeeves-meta').description('Jeeves Meta synthesis service');
3711
+ program.name(SERVICE_NAME).description('Jeeves Meta synthesis service');
3478
3712
  // ─── start ──────────────────────────────────────────────────────────
3479
3713
  program
3480
3714
  .command('start')
@@ -3513,7 +3747,7 @@ async function apiPost(port, path, body) {
3513
3747
  program
3514
3748
  .command('status')
3515
3749
  .description('Show service status')
3516
- .option('-p, --port <port>', 'Service port', '1938')
3750
+ .option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
3517
3751
  .action(async (opts) => {
3518
3752
  try {
3519
3753
  const data = await apiGet(parseInt(opts.port, 10), '/status');
@@ -3528,7 +3762,7 @@ program
3528
3762
  program
3529
3763
  .command('list')
3530
3764
  .description('List all discovered meta entities')
3531
- .option('-p, --port <port>', 'Service port', '1938')
3765
+ .option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
3532
3766
  .action(async (opts) => {
3533
3767
  try {
3534
3768
  const data = await apiGet(parseInt(opts.port, 10), '/metas');
@@ -3543,7 +3777,7 @@ program
3543
3777
  program
3544
3778
  .command('detail <path>')
3545
3779
  .description('Show full detail for a single meta entity')
3546
- .option('-p, --port <port>', 'Service port', '1938')
3780
+ .option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
3547
3781
  .action(async (metaPath, opts) => {
3548
3782
  try {
3549
3783
  const encoded = encodeURIComponent(metaPath);
@@ -3559,7 +3793,7 @@ program
3559
3793
  program
3560
3794
  .command('preview')
3561
3795
  .description('Dry-run: preview inputs for next synthesis cycle')
3562
- .option('-p, --port <port>', 'Service port', '1938')
3796
+ .option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
3563
3797
  .option('--path <path>', 'Specific meta path to preview')
3564
3798
  .action(async (opts) => {
3565
3799
  try {
@@ -3576,7 +3810,7 @@ program
3576
3810
  program
3577
3811
  .command('synthesize')
3578
3812
  .description('Trigger synthesis (enqueues work)')
3579
- .option('-p, --port <port>', 'Service port', '1938')
3813
+ .option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
3580
3814
  .option('--path <path>', 'Specific meta path to synthesize')
3581
3815
  .action(async (opts) => {
3582
3816
  try {
@@ -3593,7 +3827,7 @@ program
3593
3827
  program
3594
3828
  .command('seed <path>')
3595
3829
  .description('Create .meta/ directory + meta.json for a path')
3596
- .option('-p, --port <port>', 'Service port', '1938')
3830
+ .option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
3597
3831
  .action(async (metaPath, opts) => {
3598
3832
  try {
3599
3833
  const data = await apiPost(parseInt(opts.port, 10), '/seed', {
@@ -3610,7 +3844,7 @@ program
3610
3844
  program
3611
3845
  .command('unlock <path>')
3612
3846
  .description('Remove .lock file from a meta entity')
3613
- .option('-p, --port <port>', 'Service port', '1938')
3847
+ .option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
3614
3848
  .action(async (metaPath, opts) => {
3615
3849
  try {
3616
3850
  const data = await apiPost(parseInt(opts.port, 10), '/unlock', {
@@ -3627,7 +3861,7 @@ program
3627
3861
  program
3628
3862
  .command('validate')
3629
3863
  .description('Validate current or candidate config')
3630
- .option('-p, --port <port>', 'Service port', '1938')
3864
+ .option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
3631
3865
  .option('-c, --config <path>', 'Validate a candidate config file locally')
3632
3866
  .action(async (opts) => {
3633
3867
  try {
@@ -3763,7 +3997,7 @@ service.addCommand(new Command('stop')
3763
3997
  // status command (service subcommand — queries HTTP API)
3764
3998
  service.addCommand(new Command('status')
3765
3999
  .description('Show service status via HTTP API')
3766
- .option('-p, --port <port>', 'Service port', '1938')
4000
+ .option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
3767
4001
  .action(async (opts) => {
3768
4002
  try {
3769
4003
  const data = await apiGet(parseInt(opts.port, 10), '/status');