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