@karmaniverous/jeeves-meta 0.1.0 → 0.2.0

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.js ADDED
@@ -0,0 +1,2013 @@
1
+ #!/usr/bin/env node
2
+ #!/usr/bin/env node
3
+ import { readFileSync, readdirSync, unlinkSync, mkdirSync, writeFileSync, existsSync, statSync } from 'node:fs';
4
+ import { dirname, join, relative, sep, resolve } from 'node:path';
5
+ import { z } from 'zod';
6
+ import { randomUUID, createHash } from 'node:crypto';
7
+
8
+ /**
9
+ * Zod schema for jeeves-meta configuration.
10
+ *
11
+ * Consumers load config however they want (file, env, constructor).
12
+ * The library validates via this schema.
13
+ *
14
+ * @module schema/config
15
+ */
16
+ /** Zod schema for jeeves-meta configuration. */
17
+ const synthConfigSchema = z.object({
18
+ /** Filesystem paths to watch for .meta/ directories. */
19
+ watchPaths: z.array(z.string()).min(1),
20
+ /** Watcher service base URL. */
21
+ watcherUrl: z.url(),
22
+ /** OpenClaw gateway base URL for subprocess spawning. */
23
+ gatewayUrl: z.url().default('http://127.0.0.1:3000'),
24
+ /** Optional API key for gateway authentication. */
25
+ gatewayApiKey: z.string().optional(),
26
+ /** Run architect every N cycles (per meta). */
27
+ architectEvery: z.number().int().min(1).default(10),
28
+ /** Exponent for depth weighting in staleness formula. */
29
+ depthWeight: z.number().min(0).default(0.5),
30
+ /** Maximum archive snapshots to retain per meta. */
31
+ maxArchive: z.number().int().min(1).default(20),
32
+ /** Maximum lines of context to include in subprocess prompts. */
33
+ maxLines: z.number().int().min(50).default(500),
34
+ /** Architect subprocess timeout in seconds. */
35
+ architectTimeout: z.number().int().min(30).default(120),
36
+ /** Builder subprocess timeout in seconds. */
37
+ builderTimeout: z.number().int().min(60).default(600),
38
+ /** Critic subprocess timeout in seconds. */
39
+ criticTimeout: z.number().int().min(30).default(300),
40
+ /** Resolved architect system prompt text. */
41
+ defaultArchitect: z.string(),
42
+ /** Resolved critic system prompt text. */
43
+ defaultCritic: z.string(),
44
+ /**
45
+ * When true, skip unchanged candidates and iterate to the next-stalest
46
+ * until finding one with actual changes. Skipped candidates get their
47
+ * _generatedAt bumped to prevent re-selection next cycle.
48
+ */
49
+ skipUnchanged: z.boolean().default(true),
50
+ /** Number of metas to synthesize per invocation. */
51
+ batchSize: z.number().int().min(1).default(1),
52
+ });
53
+
54
+ /**
55
+ * Structured error from a synthesis step failure.
56
+ *
57
+ * @module schema/error
58
+ */
59
+ /** Zod schema for synthesis step errors. */
60
+ const synthErrorSchema = z.object({
61
+ /** Which step failed: 'architect', 'builder', or 'critic'. */
62
+ step: z.enum(['architect', 'builder', 'critic']),
63
+ /** Error classification code. */
64
+ code: z.string(),
65
+ /** Human-readable error message. */
66
+ message: z.string(),
67
+ });
68
+
69
+ /**
70
+ * Zod schema for .meta/meta.json files.
71
+ *
72
+ * Reserved properties are underscore-prefixed and engine-managed.
73
+ * All other keys are open schema (builder output).
74
+ *
75
+ * @module schema/meta
76
+ */
77
+ /** Zod schema for the reserved (underscore-prefixed) meta.json properties. */
78
+ const metaJsonSchema = z
79
+ .object({
80
+ /** Stable identity. Generated on first synthesis, never changes. */
81
+ _id: z.uuid(),
82
+ /** Human-provided steering prompt. Optional. */
83
+ _steer: z.string().optional(),
84
+ /** Architect system prompt used this turn. Defaults from config. */
85
+ _architect: z.string().optional(),
86
+ /**
87
+ * Task brief generated by the architect. Cached and reused across cycles;
88
+ * regenerated only when triggered.
89
+ */
90
+ _builder: z.string().optional(),
91
+ /** Critic system prompt used this turn. Defaults from config. */
92
+ _critic: z.string().optional(),
93
+ /** Timestamp of last synthesis. ISO 8601. */
94
+ _generatedAt: z.iso.datetime().optional(),
95
+ /** Narrative synthesis output. Rendered by watcher for embedding. */
96
+ _content: z.string().optional(),
97
+ /**
98
+ * Hash of sorted file listing in scope. Detects directory structure
99
+ * changes that trigger an architect re-run.
100
+ */
101
+ _structureHash: z.string().optional(),
102
+ /**
103
+ * Cycles since last architect run. Reset to 0 when architect runs.
104
+ * Used with architectEvery to trigger periodic re-prompting.
105
+ */
106
+ _synthesisCount: z.number().int().min(0).optional(),
107
+ /** Critic evaluation of the last synthesis. */
108
+ _feedback: z.string().optional(),
109
+ /**
110
+ * Present and true on archive snapshots. Distinguishes live vs. archived
111
+ * metas.
112
+ */
113
+ _archived: z.boolean().optional(),
114
+ /** Timestamp when this snapshot was archived. ISO 8601. */
115
+ _archivedAt: z.iso.datetime().optional(),
116
+ /**
117
+ * Scheduling priority. Higher = updates more often. Negative allowed;
118
+ * normalized to min 0 at scheduling time.
119
+ */
120
+ _depth: z.number().optional(),
121
+ /**
122
+ * Emphasis multiplier for depth weighting in scheduling.
123
+ * Default 1. Higher values increase this meta's scheduling priority
124
+ * relative to its depth. Set to 0.5 to halve the depth effect,
125
+ * 2 to double it, 0 to ignore depth entirely for this meta.
126
+ */
127
+ _emphasis: z.number().min(0).optional(),
128
+ /** Token count from last architect subprocess call. */
129
+ _architectTokens: z.number().int().optional(),
130
+ /** Token count from last builder subprocess call. */
131
+ _builderTokens: z.number().int().optional(),
132
+ /** Token count from last critic subprocess call. */
133
+ _criticTokens: z.number().int().optional(),
134
+ /** Exponential moving average of architect token usage (decay 0.3). */
135
+ _architectTokensAvg: z.number().optional(),
136
+ /** Exponential moving average of builder token usage (decay 0.3). */
137
+ _builderTokensAvg: z.number().optional(),
138
+ /** Exponential moving average of critic token usage (decay 0.3). */
139
+ _criticTokensAvg: z.number().optional(),
140
+ /**
141
+ * Structured error from last cycle. Present when a step failed.
142
+ * Cleared on successful cycle.
143
+ */
144
+ _error: synthErrorSchema.optional(),
145
+ })
146
+ .loose();
147
+
148
+ /**
149
+ * Load and resolve jeeves-meta config with \@file: indirection.
150
+ *
151
+ * @module configLoader
152
+ */
153
+ /**
154
+ * Resolve \@file: references in a config value.
155
+ *
156
+ * @param value - String value that may start with "\@file:".
157
+ * @param baseDir - Base directory for resolving relative paths.
158
+ * @returns The resolved string (file contents or original value).
159
+ */
160
+ function resolveFileRef(value, baseDir) {
161
+ if (!value.startsWith('@file:'))
162
+ return value;
163
+ const filePath = join(baseDir, value.slice(6));
164
+ return readFileSync(filePath, 'utf8');
165
+ }
166
+ /**
167
+ * Load synth config from a JSON file, resolving \@file: references.
168
+ *
169
+ * @param configPath - Path to jeeves-meta.config.json.
170
+ * @returns Validated SynthConfig with resolved prompt strings.
171
+ */
172
+ function loadSynthConfig(configPath) {
173
+ const raw = JSON.parse(readFileSync(configPath, 'utf8'));
174
+ const baseDir = dirname(configPath);
175
+ if (typeof raw.defaultArchitect === 'string') {
176
+ raw.defaultArchitect = resolveFileRef(raw.defaultArchitect, baseDir);
177
+ }
178
+ if (typeof raw.defaultCritic === 'string') {
179
+ raw.defaultCritic = resolveFileRef(raw.defaultCritic, baseDir);
180
+ }
181
+ return synthConfigSchema.parse(raw);
182
+ }
183
+ /**
184
+ * Resolve config path from --config flag or JEEVES_META_CONFIG env var.
185
+ *
186
+ * @param args - CLI arguments (process.argv.slice(2)).
187
+ * @returns Resolved config path.
188
+ * @throws If no config path found.
189
+ */
190
+ function resolveConfigPath(args) {
191
+ // Check --config flag
192
+ const configIdx = args.indexOf('--config');
193
+ if (configIdx !== -1 && args[configIdx + 1]) {
194
+ return args[configIdx + 1];
195
+ }
196
+ // Check env var
197
+ const envPath = process.env['JEEVES_META_CONFIG'];
198
+ if (envPath)
199
+ return envPath;
200
+ throw new Error('Config path required. Use --config <path> or set JEEVES_META_CONFIG env var.');
201
+ }
202
+
203
+ /**
204
+ * List archive snapshot files in chronological order.
205
+ *
206
+ * @module archive/listArchive
207
+ */
208
+ /**
209
+ * List archive .json files sorted chronologically (oldest first).
210
+ *
211
+ * @param metaPath - Absolute path to the .meta directory.
212
+ * @returns Array of absolute paths to archive files, or empty if none.
213
+ */
214
+ function listArchiveFiles(metaPath) {
215
+ const archiveDir = join(metaPath, 'archive');
216
+ try {
217
+ return readdirSync(archiveDir)
218
+ .filter((f) => f.endsWith('.json'))
219
+ .sort()
220
+ .map((f) => join(archiveDir, f));
221
+ }
222
+ catch {
223
+ return [];
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Prune old archive snapshots beyond maxArchive.
229
+ *
230
+ * @module archive/prune
231
+ */
232
+ /**
233
+ * Prune archive directory to keep at most maxArchive snapshots.
234
+ * Removes the oldest files.
235
+ *
236
+ * @param metaPath - Absolute path to the .meta directory.
237
+ * @param maxArchive - Maximum snapshots to retain.
238
+ * @returns Number of files pruned.
239
+ */
240
+ function pruneArchive(metaPath, maxArchive) {
241
+ const files = listArchiveFiles(metaPath);
242
+ const toRemove = files.length - maxArchive;
243
+ if (toRemove <= 0)
244
+ return 0;
245
+ for (let i = 0; i < toRemove; i++) {
246
+ unlinkSync(files[i]);
247
+ }
248
+ return toRemove;
249
+ }
250
+
251
+ /**
252
+ * Read the latest archive snapshot for steer change detection.
253
+ *
254
+ * @module archive/readLatest
255
+ */
256
+ /**
257
+ * Read the most recent archive snapshot.
258
+ *
259
+ * @param metaPath - Absolute path to the .meta directory.
260
+ * @returns The latest archived meta, or null if no archives exist.
261
+ */
262
+ function readLatestArchive(metaPath) {
263
+ const files = listArchiveFiles(metaPath);
264
+ if (files.length === 0)
265
+ return null;
266
+ const raw = readFileSync(files[files.length - 1], 'utf8');
267
+ return JSON.parse(raw);
268
+ }
269
+
270
+ /**
271
+ * Create archive snapshots of meta.json.
272
+ *
273
+ * Copies current meta.json to archive/\{ISO-timestamp\}.json with
274
+ * _archived: true and _archivedAt added.
275
+ *
276
+ * @module archive/snapshot
277
+ */
278
+ /**
279
+ * Create an archive snapshot of the current meta.json.
280
+ *
281
+ * @param metaPath - Absolute path to the .meta directory.
282
+ * @param meta - Current meta.json content.
283
+ * @returns The archive file path.
284
+ */
285
+ function createSnapshot(metaPath, meta) {
286
+ const archiveDir = join(metaPath, 'archive');
287
+ mkdirSync(archiveDir, { recursive: true });
288
+ const now = new Date().toISOString().replace(/[:.]/g, '-');
289
+ const archiveFile = join(archiveDir, now + '.json');
290
+ const archived = {
291
+ ...meta,
292
+ _archived: true,
293
+ _archivedAt: new Date().toISOString(),
294
+ };
295
+ writeFileSync(archiveFile, JSON.stringify(archived, null, 2) + '\n');
296
+ return archiveFile;
297
+ }
298
+
299
+ /**
300
+ * Archive module — snapshot creation, pruning, and reading.
301
+ *
302
+ * @module archive
303
+ */
304
+
305
+ var index$1 = /*#__PURE__*/Object.freeze({
306
+ __proto__: null,
307
+ createSnapshot: createSnapshot,
308
+ listArchiveFiles: listArchiveFiles,
309
+ pruneArchive: pruneArchive,
310
+ readLatestArchive: readLatestArchive
311
+ });
312
+
313
+ /**
314
+ * Ensure meta.json exists in each .meta/ directory.
315
+ *
316
+ * If meta.json is missing, creates it with a generated UUID.
317
+ *
318
+ * @module discovery/ensureMetaJson
319
+ */
320
+ /**
321
+ * Ensure meta.json exists at the given .meta/ path.
322
+ *
323
+ * @param metaPath - Absolute path to a .meta/ directory.
324
+ * @returns The meta.json content (existing or newly created).
325
+ */
326
+ function ensureMetaJson(metaPath) {
327
+ const filePath = join(metaPath, 'meta.json');
328
+ if (existsSync(filePath)) {
329
+ const raw = readFileSync(filePath, 'utf8');
330
+ return JSON.parse(raw);
331
+ }
332
+ // Create the archive subdirectory while we're at it
333
+ const archivePath = join(metaPath, 'archive');
334
+ if (!existsSync(archivePath)) {
335
+ mkdirSync(archivePath, { recursive: true });
336
+ }
337
+ const meta = { _id: randomUUID() };
338
+ writeFileSync(filePath, JSON.stringify(meta, null, 2) + '\n');
339
+ return meta;
340
+ }
341
+
342
+ /**
343
+ * Glob watchPaths for .meta/ directories.
344
+ *
345
+ * Walks each watchPath recursively, collecting directories named '.meta'
346
+ * that contain (or will contain) a meta.json file.
347
+ *
348
+ * @module discovery/globMetas
349
+ */
350
+ /**
351
+ * Recursively find all .meta/ directories under the given paths.
352
+ *
353
+ * @param watchPaths - Root directories to search.
354
+ * @returns Array of absolute paths to .meta/ directories.
355
+ */
356
+ function globMetas(watchPaths) {
357
+ const results = [];
358
+ function walk(dir) {
359
+ let entries;
360
+ try {
361
+ entries = readdirSync(dir);
362
+ }
363
+ catch {
364
+ return; // Skip unreadable directories
365
+ }
366
+ for (const entry of entries) {
367
+ const full = join(dir, entry);
368
+ let stat;
369
+ try {
370
+ stat = statSync(full);
371
+ }
372
+ catch {
373
+ continue;
374
+ }
375
+ if (!stat.isDirectory())
376
+ continue;
377
+ if (entry === '.meta') {
378
+ results.push(full);
379
+ }
380
+ else {
381
+ walk(full);
382
+ }
383
+ }
384
+ }
385
+ for (const wp of watchPaths) {
386
+ walk(wp);
387
+ }
388
+ return results;
389
+ }
390
+
391
+ /**
392
+ * Build the ownership tree from discovered .meta/ paths.
393
+ *
394
+ * Each .meta/ directory owns its parent directory and all descendants,
395
+ * except subtrees that contain their own .meta/. For those subtrees,
396
+ * the parent meta consumes the child meta's synthesis output.
397
+ *
398
+ * @module discovery/ownershipTree
399
+ */
400
+ /** Normalize path separators to forward slashes for consistent comparison. */
401
+ function normalizePath$1(p) {
402
+ return p.split(sep).join('/');
403
+ }
404
+ /**
405
+ * Build an ownership tree from an array of .meta/ directory paths.
406
+ *
407
+ * @param metaPaths - Absolute paths to .meta/ directories.
408
+ * @returns The ownership tree with parent/child relationships.
409
+ */
410
+ function buildOwnershipTree(metaPaths) {
411
+ const nodes = new Map();
412
+ // Create nodes, sorted by ownerPath length (shortest first = shallowest)
413
+ const sorted = [...metaPaths]
414
+ .map((mp) => ({
415
+ metaPath: normalizePath$1(mp),
416
+ ownerPath: normalizePath$1(dirname(mp)),
417
+ }))
418
+ .sort((a, b) => a.ownerPath.length - b.ownerPath.length);
419
+ for (const { metaPath, ownerPath } of sorted) {
420
+ nodes.set(metaPath, {
421
+ metaPath,
422
+ ownerPath,
423
+ treeDepth: 0,
424
+ children: [],
425
+ parent: null,
426
+ });
427
+ }
428
+ const roots = [];
429
+ // For each node, find its closest ancestor meta
430
+ for (const node of nodes.values()) {
431
+ let bestParent = null;
432
+ let bestParentLen = -1;
433
+ for (const candidate of nodes.values()) {
434
+ if (candidate === node)
435
+ continue;
436
+ // Check if node's ownerPath is under candidate's ownerPath
437
+ const rel = relative(candidate.ownerPath, node.ownerPath);
438
+ if (rel.startsWith('..') || rel === '')
439
+ continue;
440
+ // candidate.ownerPath is an ancestor of node.ownerPath
441
+ if (candidate.ownerPath.length > bestParentLen) {
442
+ bestParent = candidate;
443
+ bestParentLen = candidate.ownerPath.length;
444
+ }
445
+ }
446
+ if (bestParent) {
447
+ node.parent = bestParent;
448
+ node.treeDepth = bestParent.treeDepth + 1;
449
+ bestParent.children.push(node);
450
+ }
451
+ else {
452
+ roots.push(node);
453
+ }
454
+ }
455
+ return { nodes, roots };
456
+ }
457
+ /**
458
+ * Find a node in the ownership tree by meta path or owner path.
459
+ *
460
+ * @param tree - The ownership tree to search.
461
+ * @param targetPath - Path to search for (meta path or owner path).
462
+ * @returns The matching node, or undefined if not found.
463
+ */
464
+ function findNode(tree, targetPath) {
465
+ return Array.from(tree.nodes.values()).find((n) => n.metaPath === targetPath || n.ownerPath === targetPath);
466
+ }
467
+
468
+ /**
469
+ * Compute the file scope owned by a meta node.
470
+ *
471
+ * A meta owns: parent dir + all descendants, minus child .meta/ subtrees.
472
+ * For child subtrees, it consumes the child's .meta/meta.json as a rollup input.
473
+ *
474
+ * @module discovery/scope
475
+ */
476
+ /**
477
+ * Get the scope path prefix for a meta node.
478
+ *
479
+ * This is the ownerPath — all files under this path are in scope,
480
+ * except subtrees owned by child metas.
481
+ *
482
+ * @param node - The meta node to compute scope for.
483
+ * @returns The scope path prefix.
484
+ */
485
+ function getScopePrefix(node) {
486
+ return node.ownerPath;
487
+ }
488
+ /**
489
+ * Filter a list of file paths to only those in scope for a meta node.
490
+ *
491
+ * Includes files under ownerPath, excludes files under child meta ownerPaths,
492
+ * but includes child .meta/meta.json files as rollup inputs.
493
+ *
494
+ * @param node - The meta node.
495
+ * @param files - Array of file paths to filter.
496
+ * @returns Filtered array of in-scope file paths.
497
+ */
498
+ function filterInScope(node, files) {
499
+ const prefix = node.ownerPath + '/';
500
+ const exclusions = node.children.map((c) => c.ownerPath + '/');
501
+ const childMetaJsons = new Set(node.children.map((c) => c.metaPath + '/meta.json'));
502
+ return files.filter((f) => {
503
+ const normalized = f.split('\\').join('/');
504
+ // Must be under ownerPath
505
+ if (!normalized.startsWith(prefix) && normalized !== node.ownerPath)
506
+ return false;
507
+ // Check if under a child meta's subtree
508
+ for (const excl of exclusions) {
509
+ if (normalized.startsWith(excl)) {
510
+ // Exception: child meta.json files are included as rollup inputs
511
+ return childMetaJsons.has(normalized);
512
+ }
513
+ }
514
+ return true;
515
+ });
516
+ }
517
+
518
+ /**
519
+ * Exponential moving average helper for token tracking.
520
+ *
521
+ * @module ema
522
+ */
523
+ const DEFAULT_DECAY = 0.3;
524
+ /**
525
+ * Compute exponential moving average.
526
+ *
527
+ * @param current - New observation.
528
+ * @param previous - Previous EMA value, or undefined for first observation.
529
+ * @param decay - Decay factor (0-1). Higher = more weight on new value. Default 0.3.
530
+ * @returns Updated EMA.
531
+ */
532
+ function computeEma(current, previous, decay = DEFAULT_DECAY) {
533
+ if (previous === undefined)
534
+ return current;
535
+ return decay * current + (1 - decay) * previous;
536
+ }
537
+
538
+ /**
539
+ * Paginated scan helper for exhaustive scope enumeration.
540
+ *
541
+ * @module paginatedScan
542
+ */
543
+ /**
544
+ * Perform a paginated scan that follows cursor tokens until exhausted.
545
+ *
546
+ * @param watcher - WatcherClient instance.
547
+ * @param params - Base scan parameters (cursor is managed internally).
548
+ * @returns All matching files across all pages.
549
+ */
550
+ async function paginatedScan(watcher, params) {
551
+ const allFiles = [];
552
+ let cursor;
553
+ do {
554
+ const result = await watcher.scan({ ...params, cursor });
555
+ allFiles.push(...result.files);
556
+ cursor = result.next;
557
+ } while (cursor);
558
+ return allFiles;
559
+ }
560
+
561
+ /**
562
+ * Build the SynthContext for a synthesis cycle.
563
+ *
564
+ * Computes shared inputs once: scope files, delta files, child meta outputs,
565
+ * previous content/feedback, steer, and archive paths.
566
+ *
567
+ * @module orchestrator/contextPackage
568
+ */
569
+ /**
570
+ * Condense a file list into glob-like summaries.
571
+ * Groups by directory + extension pattern.
572
+ *
573
+ * @param files - Array of file paths.
574
+ * @param maxIndividual - Show individual files up to this count.
575
+ * @returns Condensed summary string.
576
+ */
577
+ function condenseScopeFiles(files, maxIndividual = 30) {
578
+ if (files.length <= maxIndividual)
579
+ return files.join('\n');
580
+ // Group by dir + extension
581
+ const groups = new Map();
582
+ for (const f of files) {
583
+ const dir = f.substring(0, f.lastIndexOf('/') + 1) || './';
584
+ const ext = f.includes('.') ? f.substring(f.lastIndexOf('.')) : '(no ext)';
585
+ const key = dir + '*' + ext;
586
+ groups.set(key, (groups.get(key) ?? 0) + 1);
587
+ }
588
+ // Sort by count descending
589
+ const sorted = [...groups.entries()].sort((a, b) => b[1] - a[1]);
590
+ return sorted
591
+ .map(([pattern, count]) => pattern + ' (' + count.toString() + ' files)')
592
+ .join('\n');
593
+ }
594
+ /**
595
+ * Build the context package for a synthesis cycle.
596
+ *
597
+ * @param node - The meta node being synthesized.
598
+ * @param meta - Current meta.json content.
599
+ * @param watcher - WatcherClient for scope enumeration.
600
+ * @returns The computed context package.
601
+ */
602
+ async function buildContextPackage(node, meta, watcher) {
603
+ // Scope files via watcher scan, excluding child subtrees
604
+ const allScanFiles = await paginatedScan(watcher, {
605
+ pathPrefix: node.ownerPath,
606
+ });
607
+ const scopeFiles = filterInScope(node, allScanFiles.map((f) => f.file_path));
608
+ // Delta files: modified since _generatedAt
609
+ let deltaFiles;
610
+ if (meta._generatedAt) {
611
+ const modifiedAfter = Math.floor(new Date(meta._generatedAt).getTime() / 1000);
612
+ const deltaScanFiles = await paginatedScan(watcher, {
613
+ pathPrefix: node.ownerPath,
614
+ modifiedAfter,
615
+ });
616
+ deltaFiles = filterInScope(node, deltaScanFiles.map((f) => f.file_path));
617
+ }
618
+ else {
619
+ deltaFiles = scopeFiles; // First run: all files are delta
620
+ }
621
+ // Child meta outputs
622
+ const childMetas = {};
623
+ for (const child of node.children) {
624
+ const childMetaFile = join(child.metaPath, 'meta.json');
625
+ try {
626
+ const raw = readFileSync(childMetaFile, 'utf8');
627
+ const childMeta = JSON.parse(raw);
628
+ childMetas[child.ownerPath] = childMeta._content ?? null;
629
+ }
630
+ catch {
631
+ childMetas[child.ownerPath] = null;
632
+ }
633
+ }
634
+ // Archive paths
635
+ const archives = listArchiveFiles(node.metaPath);
636
+ return {
637
+ path: node.metaPath,
638
+ scopeFiles,
639
+ deltaFiles,
640
+ childMetas,
641
+ previousContent: meta._content ?? null,
642
+ previousFeedback: meta._feedback ?? null,
643
+ steer: meta._steer ?? null,
644
+ archives,
645
+ };
646
+ }
647
+
648
+ /**
649
+ * Build task prompts for each synthesis step.
650
+ *
651
+ * @module orchestrator/buildTask
652
+ */
653
+ /** Append optional context sections shared across all step prompts. */
654
+ function appendSharedSections(sections, ctx, options) {
655
+ const opts = {
656
+ includeSteer: true,
657
+ includePreviousContent: true,
658
+ includePreviousFeedback: true,
659
+ feedbackHeading: '## PREVIOUS FEEDBACK',
660
+ includeChildMetas: true,
661
+ ...options,
662
+ };
663
+ if (opts.includeSteer && ctx.steer) {
664
+ sections.push('', '## STEERING PROMPT', ctx.steer);
665
+ }
666
+ if (opts.includePreviousContent && ctx.previousContent) {
667
+ sections.push('', '## PREVIOUS SYNTHESIS', ctx.previousContent);
668
+ }
669
+ if (opts.includePreviousFeedback && ctx.previousFeedback) {
670
+ sections.push('', opts.feedbackHeading, ctx.previousFeedback);
671
+ }
672
+ if (opts.includeChildMetas && Object.keys(ctx.childMetas).length > 0) {
673
+ sections.push('', '## CHILD META OUTPUTS');
674
+ for (const [childPath, content] of Object.entries(ctx.childMetas)) {
675
+ sections.push(`### ${childPath}`, typeof content === 'string' ? content : '(not yet synthesized)');
676
+ }
677
+ }
678
+ }
679
+ /**
680
+ * Build the architect task prompt.
681
+ *
682
+ * @param ctx - Synthesis context.
683
+ * @param meta - Current meta.json.
684
+ * @param config - Synthesis config.
685
+ * @returns The architect task prompt string.
686
+ */
687
+ function buildArchitectTask(ctx, meta, config) {
688
+ const sections = [
689
+ meta._architect ?? config.defaultArchitect,
690
+ '',
691
+ '## SCOPE',
692
+ `Path: ${ctx.path}`,
693
+ `Total files in scope: ${ctx.scopeFiles.length.toString()}`,
694
+ `Files changed since last synthesis: ${ctx.deltaFiles.length.toString()}`,
695
+ '',
696
+ '### File listing (scope)',
697
+ condenseScopeFiles(ctx.scopeFiles),
698
+ ];
699
+ // Inject previous _builder so architect can see its own prior output
700
+ if (meta._builder) {
701
+ sections.push('', '## PREVIOUS TASK BRIEF', meta._builder);
702
+ }
703
+ appendSharedSections(sections, ctx);
704
+ if (ctx.archives.length > 0) {
705
+ sections.push('', '## ARCHIVE HISTORY', `${ctx.archives.length.toString()} previous synthesis snapshots available in .meta/archive/.`, 'Review these to understand how the synthesis has evolved over time.');
706
+ }
707
+ return sections.join('\n');
708
+ }
709
+ /**
710
+ * Build the builder task prompt.
711
+ *
712
+ * @param ctx - Synthesis context.
713
+ * @param meta - Current meta.json.
714
+ * @param config - Synthesis config.
715
+ * @returns The builder task prompt string.
716
+ */
717
+ function buildBuilderTask(ctx, meta, config) {
718
+ const sections = [
719
+ '## TASK BRIEF (from Architect)',
720
+ meta._builder ?? '(No architect brief available)',
721
+ '',
722
+ '## SCOPE',
723
+ `Path: ${ctx.path}`,
724
+ `Delta files (${ctx.deltaFiles.length.toString()} changed):`,
725
+ ...ctx.deltaFiles.slice(0, config.maxLines).map((f) => `- ${f}`),
726
+ ];
727
+ appendSharedSections(sections, ctx, {
728
+ includeSteer: false,
729
+ feedbackHeading: '## FEEDBACK FROM CRITIC',
730
+ });
731
+ sections.push('', '## OUTPUT FORMAT', 'Return a JSON object with:', '- "_content": Markdown narrative synthesis (required)', '- Any additional structured fields as non-underscore keys');
732
+ return sections.join('\n');
733
+ }
734
+ /**
735
+ * Build the critic task prompt.
736
+ *
737
+ * @param ctx - Synthesis context.
738
+ * @param meta - Current meta.json (with _content already set by builder).
739
+ * @param config - Synthesis config.
740
+ * @returns The critic task prompt string.
741
+ */
742
+ function buildCriticTask(ctx, meta, config) {
743
+ const sections = [
744
+ meta._critic ?? config.defaultCritic,
745
+ '',
746
+ '## SYNTHESIS TO EVALUATE',
747
+ meta._content ?? '(No content produced)',
748
+ '',
749
+ '## SCOPE',
750
+ `Path: ${ctx.path}`,
751
+ `Files in scope: ${ctx.scopeFiles.length.toString()}`,
752
+ ];
753
+ appendSharedSections(sections, ctx, {
754
+ includePreviousContent: false,
755
+ feedbackHeading: '## YOUR PREVIOUS FEEDBACK',
756
+ includeChildMetas: false,
757
+ });
758
+ sections.push('', '## OUTPUT FORMAT', 'Return your evaluation as Markdown text. Be specific and actionable.');
759
+ return sections.join('\n');
760
+ }
761
+
762
+ /**
763
+ * Merge synthesis results into meta.json.
764
+ *
765
+ * Preserves human-set fields (_id, _steer, _depth).
766
+ * Writes engine fields (_generatedAt, _structureHash, etc.).
767
+ * Validates against schema before writing.
768
+ *
769
+ * @module orchestrator/merge
770
+ */
771
+ /**
772
+ * Merge results into meta.json and write atomically.
773
+ *
774
+ * @param options - Merge options.
775
+ * @returns The updated MetaJson.
776
+ * @throws If validation fails (malformed output).
777
+ */
778
+ function mergeAndWrite(options) {
779
+ const merged = {
780
+ // Preserve human-set fields
781
+ _id: options.current._id,
782
+ _steer: options.current._steer,
783
+ _depth: options.current._depth,
784
+ _emphasis: options.current._emphasis,
785
+ // Engine fields
786
+ _architect: options.architect,
787
+ _builder: options.builder,
788
+ _critic: options.critic,
789
+ _generatedAt: new Date().toISOString(),
790
+ _structureHash: options.structureHash,
791
+ _synthesisCount: options.synthesisCount,
792
+ // Token tracking
793
+ _architectTokens: options.architectTokens,
794
+ _builderTokens: options.builderTokens,
795
+ _criticTokens: options.criticTokens,
796
+ _architectTokensAvg: options.architectTokens !== undefined
797
+ ? computeEma(options.architectTokens, options.current._architectTokensAvg)
798
+ : options.current._architectTokensAvg,
799
+ _builderTokensAvg: options.builderTokens !== undefined
800
+ ? computeEma(options.builderTokens, options.current._builderTokensAvg)
801
+ : options.current._builderTokensAvg,
802
+ _criticTokensAvg: options.criticTokens !== undefined
803
+ ? computeEma(options.criticTokens, options.current._criticTokensAvg)
804
+ : options.current._criticTokensAvg,
805
+ // Content from builder
806
+ _content: options.builderOutput?.content ?? options.current._content,
807
+ // Feedback from critic
808
+ _feedback: options.feedback ?? options.current._feedback,
809
+ // Error handling
810
+ _error: options.error ?? undefined,
811
+ // Spread structured fields from builder
812
+ ...options.builderOutput?.fields,
813
+ };
814
+ // Clean up undefined optional fields
815
+ if (merged._steer === undefined)
816
+ delete merged._steer;
817
+ if (merged._depth === undefined)
818
+ delete merged._depth;
819
+ if (merged._emphasis === undefined)
820
+ delete merged._emphasis;
821
+ if (merged._architectTokens === undefined)
822
+ delete merged._architectTokens;
823
+ if (merged._builderTokens === undefined)
824
+ delete merged._builderTokens;
825
+ if (merged._criticTokens === undefined)
826
+ delete merged._criticTokens;
827
+ if (merged._architectTokensAvg === undefined)
828
+ delete merged._architectTokensAvg;
829
+ if (merged._builderTokensAvg === undefined)
830
+ delete merged._builderTokensAvg;
831
+ if (merged._criticTokensAvg === undefined)
832
+ delete merged._criticTokensAvg;
833
+ if (merged._error === undefined)
834
+ delete merged._error;
835
+ if (merged._content === undefined)
836
+ delete merged._content;
837
+ if (merged._feedback === undefined)
838
+ delete merged._feedback;
839
+ // Validate
840
+ const result = metaJsonSchema.safeParse(merged);
841
+ if (!result.success) {
842
+ throw new Error(`Meta validation failed: ${result.error.message}`);
843
+ }
844
+ // Write atomically
845
+ const filePath = join(options.metaPath, 'meta.json');
846
+ writeFileSync(filePath, JSON.stringify(result.data, null, 2) + '\n');
847
+ return result.data;
848
+ }
849
+
850
+ /**
851
+ * Shared error utilities.
852
+ *
853
+ * @module errors
854
+ */
855
+ /**
856
+ * Wrap an unknown caught value into a SynthError.
857
+ *
858
+ * @param step - Which synthesis step failed.
859
+ * @param err - The caught error value.
860
+ * @param code - Error classification code.
861
+ * @returns A structured SynthError.
862
+ */
863
+ function toSynthError(step, err, code = 'FAILED') {
864
+ return {
865
+ step,
866
+ code,
867
+ message: err instanceof Error ? err.message : String(err),
868
+ };
869
+ }
870
+
871
+ /**
872
+ * File-system lock for preventing concurrent synthesis on the same meta.
873
+ *
874
+ * Lock file: .meta/.lock containing PID + timestamp.
875
+ * Stale timeout: 30 minutes.
876
+ *
877
+ * @module lock
878
+ */
879
+ const LOCK_FILE = '.lock';
880
+ const STALE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
881
+ /**
882
+ * Attempt to acquire a lock on a .meta directory.
883
+ *
884
+ * @param metaPath - Absolute path to the .meta directory.
885
+ * @returns True if lock was acquired, false if already locked (non-stale).
886
+ */
887
+ function acquireLock(metaPath) {
888
+ const lockPath = join(metaPath, LOCK_FILE);
889
+ if (existsSync(lockPath)) {
890
+ try {
891
+ const raw = readFileSync(lockPath, 'utf8');
892
+ const data = JSON.parse(raw);
893
+ const lockAge = Date.now() - new Date(data.startedAt).getTime();
894
+ if (lockAge < STALE_TIMEOUT_MS) {
895
+ return false; // Lock is active
896
+ }
897
+ // Stale lock — fall through to overwrite
898
+ }
899
+ catch {
900
+ // Corrupt lock file — overwrite
901
+ }
902
+ }
903
+ const lock = {
904
+ pid: process.pid,
905
+ startedAt: new Date().toISOString(),
906
+ };
907
+ writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n');
908
+ return true;
909
+ }
910
+ /**
911
+ * Release a lock on a .meta directory.
912
+ *
913
+ * @param metaPath - Absolute path to the .meta directory.
914
+ */
915
+ function releaseLock(metaPath) {
916
+ const lockPath = join(metaPath, LOCK_FILE);
917
+ try {
918
+ unlinkSync(lockPath);
919
+ }
920
+ catch {
921
+ // Already removed or never existed
922
+ }
923
+ }
924
+ /**
925
+ * Check if a .meta directory is currently locked (non-stale).
926
+ *
927
+ * @param metaPath - Absolute path to the .meta directory.
928
+ * @returns True if locked and not stale.
929
+ */
930
+ function isLocked(metaPath) {
931
+ const lockPath = join(metaPath, LOCK_FILE);
932
+ if (!existsSync(lockPath))
933
+ return false;
934
+ try {
935
+ const raw = readFileSync(lockPath, 'utf8');
936
+ const data = JSON.parse(raw);
937
+ const lockAge = Date.now() - new Date(data.startedAt).getTime();
938
+ return lockAge < STALE_TIMEOUT_MS;
939
+ }
940
+ catch {
941
+ return false; // Corrupt lock = not locked
942
+ }
943
+ }
944
+
945
+ /**
946
+ * Normalize file paths to forward slashes for consistency with watcher-indexed paths.
947
+ *
948
+ * Watcher indexes paths with forward slashes (`j:/domains/...`). This utility
949
+ * ensures all paths in the library use the same convention, regardless of
950
+ * the platform's native separator.
951
+ *
952
+ * @module normalizePath
953
+ */
954
+ /**
955
+ * Normalize a file path to forward slashes.
956
+ *
957
+ * @param p - File path (may contain backslashes).
958
+ * @returns Path with all backslashes replaced by forward slashes.
959
+ */
960
+ function normalizePath(p) {
961
+ return p.replaceAll('\\', '/');
962
+ }
963
+
964
+ /**
965
+ * Select the best synthesis candidate from stale metas.
966
+ *
967
+ * Picks the meta with highest effective staleness.
968
+ *
969
+ * @module scheduling/selectCandidate
970
+ */
971
+ /**
972
+ * Select the candidate with the highest effective staleness.
973
+ *
974
+ * @param candidates - Array of candidates with computed effective staleness.
975
+ * @returns The winning candidate, or null if no candidates.
976
+ */
977
+ function selectCandidate(candidates) {
978
+ if (candidates.length === 0)
979
+ return null;
980
+ let best = candidates[0];
981
+ for (let i = 1; i < candidates.length; i++) {
982
+ if (candidates[i].effectiveStaleness > best.effectiveStaleness) {
983
+ best = candidates[i];
984
+ }
985
+ }
986
+ return best;
987
+ }
988
+
989
+ /**
990
+ * Staleness detection via watcher scan.
991
+ *
992
+ * A meta is stale when any file in its scope was modified after _generatedAt.
993
+ *
994
+ * @module scheduling/staleness
995
+ */
996
+ /**
997
+ * Check if a meta is stale by querying the watcher for modified files.
998
+ *
999
+ * @param scopePrefix - Path prefix for this meta's scope.
1000
+ * @param meta - Current meta.json content.
1001
+ * @param watcher - WatcherClient instance.
1002
+ * @returns True if any file in scope was modified after _generatedAt.
1003
+ */
1004
+ async function isStale(scopePrefix, meta, watcher) {
1005
+ if (!meta._generatedAt)
1006
+ return true; // Never synthesized = stale
1007
+ const generatedAtUnix = Math.floor(new Date(meta._generatedAt).getTime() / 1000);
1008
+ const result = await watcher.scan({
1009
+ pathPrefix: scopePrefix,
1010
+ modifiedAfter: generatedAtUnix,
1011
+ limit: 1,
1012
+ });
1013
+ return result.files.length > 0;
1014
+ }
1015
+ /**
1016
+ * Compute actual staleness in seconds (now minus _generatedAt).
1017
+ *
1018
+ * @param meta - Current meta.json content.
1019
+ * @returns Staleness in seconds, or Infinity if never synthesized.
1020
+ */
1021
+ function actualStaleness(meta) {
1022
+ if (!meta._generatedAt)
1023
+ return Infinity;
1024
+ const generatedMs = new Date(meta._generatedAt).getTime();
1025
+ return (Date.now() - generatedMs) / 1000;
1026
+ }
1027
+ /**
1028
+ * Check whether the architect step should be triggered.
1029
+ *
1030
+ * @param meta - Current meta.json.
1031
+ * @param structureChanged - Whether the structure hash changed.
1032
+ * @param steerChanged - Whether the steer directive changed.
1033
+ * @param architectEvery - Config: run architect every N cycles.
1034
+ * @returns True if the architect step should run.
1035
+ */
1036
+ function isArchitectTriggered(meta, structureChanged, steerChanged, architectEvery) {
1037
+ return (!meta._builder ||
1038
+ structureChanged ||
1039
+ steerChanged ||
1040
+ (meta._synthesisCount ?? 0) >= architectEvery);
1041
+ }
1042
+ /**
1043
+ * Detect whether the steer directive changed since the last archive.
1044
+ *
1045
+ * @param currentSteer - Current _steer value (or undefined).
1046
+ * @param archiveSteer - Archive _steer value (or undefined).
1047
+ * @param hasArchive - Whether an archive snapshot exists.
1048
+ * @returns True if steer changed.
1049
+ */
1050
+ function hasSteerChanged(currentSteer, archiveSteer, hasArchive) {
1051
+ if (!hasArchive)
1052
+ return Boolean(currentSteer);
1053
+ return currentSteer !== archiveSteer;
1054
+ }
1055
+
1056
+ /**
1057
+ * Weighted staleness formula for candidate selection.
1058
+ *
1059
+ * effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
1060
+ *
1061
+ * @module scheduling/weightedFormula
1062
+ */
1063
+ /**
1064
+ * Compute effective staleness for a set of candidates.
1065
+ *
1066
+ * Normalizes depths so the minimum becomes 0, then applies the formula:
1067
+ * effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
1068
+ *
1069
+ * Per-meta _emphasis (default 1) multiplies depthWeight, allowing individual
1070
+ * metas to tune how much their tree position affects scheduling.
1071
+ *
1072
+ * @param candidates - Array of \{ node, meta, actualStaleness \}.
1073
+ * @param depthWeight - Exponent for depth weighting (0 = pure staleness).
1074
+ * @returns Same array with effectiveStaleness computed.
1075
+ */
1076
+ function computeEffectiveStaleness(candidates, depthWeight) {
1077
+ if (candidates.length === 0)
1078
+ return [];
1079
+ // Get depth for each candidate: use _depth override or tree depth
1080
+ const depths = candidates.map((c) => c.meta._depth ?? c.node.treeDepth);
1081
+ // Normalize: shift so minimum becomes 0
1082
+ const minDepth = Math.min(...depths);
1083
+ const normalizedDepths = depths.map((d) => Math.max(0, d - minDepth));
1084
+ return candidates.map((c, i) => {
1085
+ const emphasis = c.meta._emphasis ?? 1;
1086
+ return {
1087
+ ...c,
1088
+ effectiveStaleness: c.actualStaleness *
1089
+ Math.pow(normalizedDepths[i] + 1, depthWeight * emphasis),
1090
+ };
1091
+ });
1092
+ }
1093
+
1094
+ /**
1095
+ * Compute a structure hash from a sorted file listing.
1096
+ *
1097
+ * Used to detect when directory structure changes, triggering
1098
+ * an architect re-run.
1099
+ *
1100
+ * @module structureHash
1101
+ */
1102
+ /**
1103
+ * Compute a SHA-256 hash of a sorted file listing.
1104
+ *
1105
+ * @param filePaths - Array of file paths in scope.
1106
+ * @returns Hex-encoded SHA-256 hash of the sorted, newline-joined paths.
1107
+ */
1108
+ function computeStructureHash(filePaths) {
1109
+ const sorted = [...filePaths].sort();
1110
+ const content = sorted.join('\n');
1111
+ return createHash('sha256').update(content).digest('hex');
1112
+ }
1113
+
1114
+ /**
1115
+ * Parse subprocess outputs for each synthesis step.
1116
+ *
1117
+ * - Architect: returns text \> _builder
1118
+ * - Builder: returns JSON \> _content + structured fields
1119
+ * - Critic: returns text \> _feedback
1120
+ *
1121
+ * @module orchestrator/parseOutput
1122
+ */
1123
+ /**
1124
+ * Parse architect output. The architect returns a task brief as text.
1125
+ *
1126
+ * @param output - Raw subprocess output.
1127
+ * @returns The task brief string.
1128
+ */
1129
+ function parseArchitectOutput(output) {
1130
+ return output.trim();
1131
+ }
1132
+ /**
1133
+ * Parse builder output. The builder returns JSON with _content and optional fields.
1134
+ *
1135
+ * Attempts JSON parse first. If that fails, treats the entire output as _content.
1136
+ *
1137
+ * @param output - Raw subprocess output.
1138
+ * @returns Parsed builder output with content and structured fields.
1139
+ */
1140
+ function parseBuilderOutput(output) {
1141
+ const trimmed = output.trim();
1142
+ // Try to extract JSON from the output (may be wrapped in markdown code fences)
1143
+ let jsonStr = trimmed;
1144
+ const fenceMatch = /```(?:json)?\s*([\s\S]*?)```/.exec(trimmed);
1145
+ if (fenceMatch) {
1146
+ jsonStr = fenceMatch[1].trim();
1147
+ }
1148
+ try {
1149
+ const parsed = JSON.parse(jsonStr);
1150
+ // Extract _content
1151
+ const content = typeof parsed._content === 'string'
1152
+ ? parsed._content
1153
+ : typeof parsed.content === 'string'
1154
+ ? parsed.content
1155
+ : trimmed;
1156
+ // Extract non-underscore fields
1157
+ const fields = {};
1158
+ for (const [key, value] of Object.entries(parsed)) {
1159
+ if (!key.startsWith('_') && key !== 'content') {
1160
+ fields[key] = value;
1161
+ }
1162
+ }
1163
+ return { content, fields };
1164
+ }
1165
+ catch {
1166
+ // Not valid JSON — treat entire output as content
1167
+ return { content: trimmed, fields: {} };
1168
+ }
1169
+ }
1170
+ /**
1171
+ * Parse critic output. The critic returns evaluation text.
1172
+ *
1173
+ * @param output - Raw subprocess output.
1174
+ * @returns The feedback string.
1175
+ */
1176
+ function parseCriticOutput(output) {
1177
+ return output.trim();
1178
+ }
1179
+
1180
+ /**
1181
+ * Main orchestration function — the 13-step synthesis cycle.
1182
+ *
1183
+ * Wires together discovery, scheduling, archiving, executor calls,
1184
+ * and merge/write-back.
1185
+ *
1186
+ * @module orchestrator/orchestrate
1187
+ */
1188
+ /** Finalize a cycle: merge, snapshot, prune. */
1189
+ function finalizeCycle(metaPath, current, config, architect, builder, critic, builderOutput, feedback, structureHash, synthesisCount, error, architectTokens, builderTokens, criticTokens) {
1190
+ const updated = mergeAndWrite({
1191
+ metaPath,
1192
+ current,
1193
+ architect,
1194
+ builder,
1195
+ critic,
1196
+ builderOutput,
1197
+ feedback,
1198
+ structureHash,
1199
+ synthesisCount,
1200
+ error,
1201
+ architectTokens,
1202
+ builderTokens,
1203
+ criticTokens,
1204
+ });
1205
+ createSnapshot(metaPath, updated);
1206
+ pruneArchive(metaPath, config.maxArchive);
1207
+ return updated;
1208
+ }
1209
+ /**
1210
+ * Run a single synthesis cycle.
1211
+ *
1212
+ * Discovers all metas, selects the stalest candidate, and runs the
1213
+ * three-step synthesis (architect, builder, critic).
1214
+ *
1215
+ * @param config - Validated synthesis config.
1216
+ * @param executor - Pluggable LLM executor.
1217
+ * @param watcher - Watcher HTTP client.
1218
+ * @returns Result indicating whether synthesis occurred.
1219
+ */
1220
+ async function orchestrateOnce(config, executor, watcher) {
1221
+ // Step 1: Discover
1222
+ const metaPaths = globMetas(config.watchPaths);
1223
+ if (metaPaths.length === 0)
1224
+ return { synthesized: false };
1225
+ // Ensure all meta.json files exist
1226
+ const metas = new Map();
1227
+ for (const mp of metaPaths) {
1228
+ metas.set(normalizePath(mp), ensureMetaJson(mp));
1229
+ }
1230
+ const tree = buildOwnershipTree(metaPaths);
1231
+ // Steps 3-4: Staleness check + candidate selection
1232
+ const candidates = [];
1233
+ for (const node of tree.nodes.values()) {
1234
+ const meta = metas.get(node.metaPath);
1235
+ const staleness = actualStaleness(meta);
1236
+ if (staleness > 0) {
1237
+ candidates.push({ node, meta, actualStaleness: staleness });
1238
+ }
1239
+ }
1240
+ const weighted = computeEffectiveStaleness(candidates, config.depthWeight);
1241
+ // Sort by effective staleness descending
1242
+ const ranked = [...weighted].sort((a, b) => b.effectiveStaleness - a.effectiveStaleness);
1243
+ if (ranked.length === 0)
1244
+ return { synthesized: false };
1245
+ // Find the first candidate with actual changes (if skipUnchanged)
1246
+ let winner = null;
1247
+ for (const candidate of ranked) {
1248
+ if (!acquireLock(candidate.node.metaPath))
1249
+ continue;
1250
+ const verifiedStale = await isStale(getScopePrefix(candidate.node), candidate.meta, watcher);
1251
+ if (!verifiedStale && candidate.meta._generatedAt) {
1252
+ // Bump _generatedAt so it doesn't win next cycle
1253
+ const metaFilePath = join(candidate.node.metaPath, 'meta.json');
1254
+ const freshMeta = JSON.parse(readFileSync(metaFilePath, 'utf8'));
1255
+ freshMeta._generatedAt = new Date().toISOString();
1256
+ writeFileSync(metaFilePath, JSON.stringify(freshMeta, null, 2));
1257
+ releaseLock(candidate.node.metaPath);
1258
+ if (config.skipUnchanged)
1259
+ continue;
1260
+ return { synthesized: false };
1261
+ }
1262
+ winner = candidate;
1263
+ break;
1264
+ }
1265
+ if (!winner)
1266
+ return { synthesized: false };
1267
+ const { node } = winner;
1268
+ try {
1269
+ // Re-read meta after lock (may have changed)
1270
+ const currentMeta = JSON.parse(readFileSync(join(node.metaPath, 'meta.json'), 'utf8'));
1271
+ const architectPrompt = currentMeta._architect ?? config.defaultArchitect;
1272
+ const criticPrompt = currentMeta._critic ?? config.defaultCritic;
1273
+ // Step 5: Structure hash
1274
+ const scopePrefix = getScopePrefix(node);
1275
+ const allScanFiles = await paginatedScan(watcher, {
1276
+ pathPrefix: scopePrefix,
1277
+ });
1278
+ const allFilePaths = allScanFiles.map((f) => f.file_path);
1279
+ // Structure hash uses scope-filtered files (excluding child subtrees)
1280
+ // so changes in child scopes don't trigger parent architect re-runs
1281
+ const scopeFiles = filterInScope(node, allFilePaths);
1282
+ const newStructureHash = computeStructureHash(scopeFiles);
1283
+ const structureChanged = newStructureHash !== currentMeta._structureHash;
1284
+ // Step 6: Steer change detection
1285
+ const latestArchive = readLatestArchive(node.metaPath);
1286
+ const steerChanged = hasSteerChanged(currentMeta._steer, latestArchive?._steer, Boolean(latestArchive));
1287
+ // Step 7: Compute context
1288
+ const ctx = await buildContextPackage(node, currentMeta, watcher);
1289
+ // Step 8: Architect (conditional)
1290
+ const architectTriggered = isArchitectTriggered(currentMeta, structureChanged, steerChanged, config.architectEvery);
1291
+ let builderBrief = currentMeta._builder ?? '';
1292
+ let synthesisCount = currentMeta._synthesisCount ?? 0;
1293
+ let stepError = null;
1294
+ let architectTokens;
1295
+ let builderTokens;
1296
+ let criticTokens;
1297
+ if (architectTriggered) {
1298
+ try {
1299
+ const architectTask = buildArchitectTask(ctx, currentMeta, config);
1300
+ const architectResult = await executor.spawn(architectTask, {
1301
+ timeout: config.architectTimeout,
1302
+ });
1303
+ builderBrief = parseArchitectOutput(architectResult.output);
1304
+ architectTokens = architectResult.tokens;
1305
+ synthesisCount = 0;
1306
+ }
1307
+ catch (err) {
1308
+ stepError = toSynthError('architect', err);
1309
+ if (!currentMeta._builder) {
1310
+ // No cached builder — cycle fails
1311
+ finalizeCycle(node.metaPath, currentMeta, config, architectPrompt, '', criticPrompt, null, null, newStructureHash, synthesisCount, stepError, architectTokens);
1312
+ return {
1313
+ synthesized: true,
1314
+ metaPath: node.metaPath,
1315
+ error: stepError,
1316
+ };
1317
+ }
1318
+ // Has cached builder — continue with existing
1319
+ }
1320
+ }
1321
+ // Step 9: Builder
1322
+ const metaForBuilder = { ...currentMeta, _builder: builderBrief };
1323
+ let builderOutput = null;
1324
+ try {
1325
+ const builderTask = buildBuilderTask(ctx, metaForBuilder, config);
1326
+ const builderResult = await executor.spawn(builderTask, {
1327
+ timeout: config.builderTimeout,
1328
+ });
1329
+ builderOutput = parseBuilderOutput(builderResult.output);
1330
+ builderTokens = builderResult.tokens;
1331
+ synthesisCount++;
1332
+ }
1333
+ catch (err) {
1334
+ stepError = toSynthError('builder', err);
1335
+ return { synthesized: true, metaPath: node.metaPath, error: stepError };
1336
+ }
1337
+ // Step 10: Critic
1338
+ const metaForCritic = {
1339
+ ...currentMeta,
1340
+ _content: builderOutput.content,
1341
+ };
1342
+ let feedback = null;
1343
+ try {
1344
+ const criticTask = buildCriticTask(ctx, metaForCritic, config);
1345
+ const criticResult = await executor.spawn(criticTask, {
1346
+ timeout: config.criticTimeout,
1347
+ });
1348
+ feedback = parseCriticOutput(criticResult.output);
1349
+ criticTokens = criticResult.tokens;
1350
+ stepError = null; // Clear any architect error on full success
1351
+ }
1352
+ catch (err) {
1353
+ stepError = stepError ?? toSynthError('critic', err);
1354
+ }
1355
+ // Steps 11-12: Merge, archive, prune
1356
+ finalizeCycle(node.metaPath, currentMeta, config, architectPrompt, builderBrief, criticPrompt, builderOutput, feedback, newStructureHash, synthesisCount, stepError, architectTokens, builderTokens, criticTokens);
1357
+ return {
1358
+ synthesized: true,
1359
+ metaPath: node.metaPath,
1360
+ error: stepError ?? undefined,
1361
+ };
1362
+ }
1363
+ finally {
1364
+ // Step 13: Release lock
1365
+ releaseLock(node.metaPath);
1366
+ }
1367
+ }
1368
+ /**
1369
+ * Run synthesis cycles up to batchSize.
1370
+ *
1371
+ * Calls orchestrateOnce() in a loop, stopping when batchSize is reached
1372
+ * or no more candidates are available.
1373
+ *
1374
+ * @param config - Validated synthesis config.
1375
+ * @param executor - Pluggable LLM executor.
1376
+ * @param watcher - Watcher HTTP client.
1377
+ * @returns Array of results, one per cycle attempted.
1378
+ */
1379
+ async function orchestrate(config, executor, watcher) {
1380
+ const results = [];
1381
+ for (let i = 0; i < config.batchSize; i++) {
1382
+ const result = await orchestrateOnce(config, executor, watcher);
1383
+ results.push(result);
1384
+ if (!result.synthesized)
1385
+ break; // No more candidates
1386
+ }
1387
+ return results;
1388
+ }
1389
+
1390
+ /**
1391
+ * SynthExecutor implementation using the OpenClaw gateway HTTP API.
1392
+ *
1393
+ * Lives in the library package so both plugin and runner can import it.
1394
+ * Spawns sub-agent sessions via the gateway, polls for completion,
1395
+ * and extracts output text.
1396
+ *
1397
+ * @module executor/GatewayExecutor
1398
+ */
1399
+ const DEFAULT_POLL_INTERVAL_MS = 5000;
1400
+ const DEFAULT_TIMEOUT_MS = 600_000; // 10 minutes
1401
+ /** Sleep helper. */
1402
+ function sleep$1(ms) {
1403
+ return new Promise((resolve) => setTimeout(resolve, ms));
1404
+ }
1405
+ /**
1406
+ * SynthExecutor that spawns OpenClaw sessions via the gateway HTTP API.
1407
+ *
1408
+ * Used by both the OpenClaw plugin (in-process tool calls) and the
1409
+ * runner/CLI (external invocation). Constructs from `gatewayUrl` and
1410
+ * optional `apiKey` — typically sourced from `SynthConfig`.
1411
+ */
1412
+ class GatewayExecutor {
1413
+ gatewayUrl;
1414
+ apiKey;
1415
+ pollIntervalMs;
1416
+ constructor(options = {}) {
1417
+ this.gatewayUrl = (options.gatewayUrl ?? 'http://127.0.0.1:3000').replace(/\/+$/, '');
1418
+ this.apiKey = options.apiKey;
1419
+ this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
1420
+ }
1421
+ async spawn(task, options) {
1422
+ const timeoutMs = (options?.timeout ?? DEFAULT_TIMEOUT_MS / 1000) * 1000;
1423
+ const deadline = Date.now() + timeoutMs;
1424
+ const headers = {
1425
+ 'Content-Type': 'application/json',
1426
+ };
1427
+ if (this.apiKey) {
1428
+ headers['Authorization'] = 'Bearer ' + this.apiKey;
1429
+ }
1430
+ const spawnRes = await fetch(this.gatewayUrl + '/api/sessions/spawn', {
1431
+ method: 'POST',
1432
+ headers,
1433
+ body: JSON.stringify({
1434
+ task,
1435
+ mode: 'run',
1436
+ model: options?.model,
1437
+ runTimeoutSeconds: options?.timeout,
1438
+ }),
1439
+ });
1440
+ if (!spawnRes.ok) {
1441
+ const text = await spawnRes.text();
1442
+ throw new Error('Gateway spawn failed: HTTP ' +
1443
+ spawnRes.status.toString() +
1444
+ ' - ' +
1445
+ text);
1446
+ }
1447
+ const spawnData = (await spawnRes.json());
1448
+ if (!spawnData.sessionKey) {
1449
+ throw new Error('Gateway spawn returned no sessionKey: ' + JSON.stringify(spawnData));
1450
+ }
1451
+ const { sessionKey } = spawnData;
1452
+ // Poll for completion
1453
+ while (Date.now() < deadline) {
1454
+ await sleep$1(this.pollIntervalMs);
1455
+ const historyRes = await fetch(this.gatewayUrl +
1456
+ '/api/sessions/' +
1457
+ encodeURIComponent(sessionKey) +
1458
+ '/history?limit=50', { headers });
1459
+ if (!historyRes.ok)
1460
+ continue;
1461
+ const history = (await historyRes.json());
1462
+ if (history.status === 'completed' || history.status === 'done') {
1463
+ // Extract token usage from session-level or message-level usage
1464
+ let tokens;
1465
+ if (history.usage?.totalTokens) {
1466
+ tokens = history.usage.totalTokens;
1467
+ }
1468
+ else {
1469
+ // Sum message-level usage as fallback
1470
+ let sum = 0;
1471
+ for (const msg of history.messages ?? []) {
1472
+ if (msg.usage?.totalTokens)
1473
+ sum += msg.usage.totalTokens;
1474
+ }
1475
+ if (sum > 0)
1476
+ tokens = sum;
1477
+ }
1478
+ // Extract the last assistant message as output
1479
+ const messages = history.messages ?? [];
1480
+ for (let i = messages.length - 1; i >= 0; i--) {
1481
+ if (messages[i].role === 'assistant' && messages[i].content) {
1482
+ return { output: messages[i].content, tokens };
1483
+ }
1484
+ }
1485
+ return { output: '', tokens };
1486
+ }
1487
+ }
1488
+ throw new Error('Synthesis subprocess timed out after ' + timeoutMs.toString() + 'ms');
1489
+ }
1490
+ }
1491
+
1492
+ /**
1493
+ * HTTP implementation of the WatcherClient interface.
1494
+ *
1495
+ * Talks to jeeves-watcher's POST /scan and POST /rules endpoints
1496
+ * with retry and exponential backoff.
1497
+ *
1498
+ * @module watcher-client/HttpWatcherClient
1499
+ */
1500
+ /** Default retry configuration. */
1501
+ const DEFAULT_MAX_RETRIES = 3;
1502
+ const DEFAULT_BACKOFF_BASE_MS = 1000;
1503
+ const DEFAULT_BACKOFF_FACTOR = 4;
1504
+ /** Sleep for a given number of milliseconds. */
1505
+ function sleep(ms) {
1506
+ return new Promise((resolve) => setTimeout(resolve, ms));
1507
+ }
1508
+ /** Check if an error is transient (worth retrying). */
1509
+ function isTransient(status) {
1510
+ return status >= 500 || status === 408 || status === 429;
1511
+ }
1512
+ /**
1513
+ * HTTP-based WatcherClient implementation with retry.
1514
+ */
1515
+ class HttpWatcherClient {
1516
+ baseUrl;
1517
+ maxRetries;
1518
+ backoffBaseMs;
1519
+ backoffFactor;
1520
+ constructor(options) {
1521
+ this.baseUrl = options.baseUrl.replace(/\/+$/, '');
1522
+ this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
1523
+ this.backoffBaseMs = options.backoffBaseMs ?? DEFAULT_BACKOFF_BASE_MS;
1524
+ this.backoffFactor = options.backoffFactor ?? DEFAULT_BACKOFF_FACTOR;
1525
+ }
1526
+ /** POST JSON with retry. */
1527
+ async post(endpoint, body) {
1528
+ const url = this.baseUrl + endpoint;
1529
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
1530
+ const res = await fetch(url, {
1531
+ method: 'POST',
1532
+ headers: { 'Content-Type': 'application/json' },
1533
+ body: JSON.stringify(body),
1534
+ });
1535
+ if (res.ok) {
1536
+ return res.json();
1537
+ }
1538
+ if (!isTransient(res.status) || attempt === this.maxRetries) {
1539
+ const text = await res.text();
1540
+ throw new Error(`Watcher ${endpoint} failed: HTTP ${res.status.toString()} - ${text}`);
1541
+ }
1542
+ // Exponential backoff
1543
+ const delayMs = this.backoffBaseMs * Math.pow(this.backoffFactor, attempt);
1544
+ await sleep(delayMs);
1545
+ }
1546
+ // Unreachable, but TypeScript needs it
1547
+ throw new Error('Retry exhausted');
1548
+ }
1549
+ async scan(params) {
1550
+ const body = {};
1551
+ if (params.pathPrefix !== undefined) {
1552
+ body.pathPrefix = params.pathPrefix;
1553
+ }
1554
+ if (params.filter !== undefined) {
1555
+ body.filter = params.filter;
1556
+ }
1557
+ if (params.modifiedAfter !== undefined) {
1558
+ body.modifiedAfter = params.modifiedAfter;
1559
+ }
1560
+ if (params.fields !== undefined) {
1561
+ body.fields = params.fields;
1562
+ }
1563
+ if (params.limit !== undefined) {
1564
+ body.limit = params.limit;
1565
+ }
1566
+ if (params.cursor !== undefined) {
1567
+ body.cursor = params.cursor;
1568
+ }
1569
+ const result = await this.post('/scan', body);
1570
+ return result;
1571
+ }
1572
+ async registerRules(source, rules) {
1573
+ await this.post('/rules/register', { source, rules });
1574
+ }
1575
+ async unregisterRules(source) {
1576
+ await this.post('/rules/unregister', { source });
1577
+ }
1578
+ }
1579
+
1580
+ /**
1581
+ * Knowledge synthesis engine for the Jeeves platform.
1582
+ *
1583
+ * @packageDocumentation
1584
+ */
1585
+
1586
+ var index = /*#__PURE__*/Object.freeze({
1587
+ __proto__: null,
1588
+ GatewayExecutor: GatewayExecutor,
1589
+ HttpWatcherClient: HttpWatcherClient,
1590
+ acquireLock: acquireLock,
1591
+ actualStaleness: actualStaleness,
1592
+ buildArchitectTask: buildArchitectTask,
1593
+ buildBuilderTask: buildBuilderTask,
1594
+ buildContextPackage: buildContextPackage,
1595
+ buildCriticTask: buildCriticTask,
1596
+ buildOwnershipTree: buildOwnershipTree,
1597
+ computeEffectiveStaleness: computeEffectiveStaleness,
1598
+ computeEma: computeEma,
1599
+ computeStructureHash: computeStructureHash,
1600
+ createSnapshot: createSnapshot,
1601
+ ensureMetaJson: ensureMetaJson,
1602
+ filterInScope: filterInScope,
1603
+ findNode: findNode,
1604
+ getScopePrefix: getScopePrefix,
1605
+ globMetas: globMetas,
1606
+ hasSteerChanged: hasSteerChanged,
1607
+ isArchitectTriggered: isArchitectTriggered,
1608
+ isLocked: isLocked,
1609
+ isStale: isStale,
1610
+ listArchiveFiles: listArchiveFiles,
1611
+ loadSynthConfig: loadSynthConfig,
1612
+ mergeAndWrite: mergeAndWrite,
1613
+ metaJsonSchema: metaJsonSchema,
1614
+ normalizePath: normalizePath,
1615
+ orchestrate: orchestrate,
1616
+ paginatedScan: paginatedScan,
1617
+ parseArchitectOutput: parseArchitectOutput,
1618
+ parseBuilderOutput: parseBuilderOutput,
1619
+ parseCriticOutput: parseCriticOutput,
1620
+ pruneArchive: pruneArchive,
1621
+ readLatestArchive: readLatestArchive,
1622
+ releaseLock: releaseLock,
1623
+ resolveConfigPath: resolveConfigPath,
1624
+ selectCandidate: selectCandidate,
1625
+ synthConfigSchema: synthConfigSchema,
1626
+ synthErrorSchema: synthErrorSchema,
1627
+ toSynthError: toSynthError
1628
+ });
1629
+
1630
+ /**
1631
+ * jeeves-meta CLI — ad hoc invocation, debugging, and maintenance.
1632
+ *
1633
+ * Usage:
1634
+ * npx \@karmaniverous/jeeves-meta <command> [options]
1635
+ *
1636
+ * Config resolution: --config <path> or JEEVES_META_CONFIG env var.
1637
+ *
1638
+ * @module cli
1639
+ */
1640
+ const args = process.argv.slice(2);
1641
+ const command = args.find((a) => !a.startsWith('-'));
1642
+ const jsonOutput = args.includes('--json');
1643
+ function usage() {
1644
+ console.log(`jeeves-meta — Knowledge synthesis engine CLI
1645
+
1646
+ Usage: npx @karmaniverous/jeeves-meta <command> [options]
1647
+
1648
+ Commands:
1649
+ status Summary: total, stale, errors, tokens
1650
+ list [--prefix <p>] [--filter <f>] List metas with summary
1651
+ detail <path> [--archive <n>] Full detail for a single meta
1652
+ preview [--path <p>] Dry-run: what would the next cycle do
1653
+ synthesize [--path <p>] [--batch <n>] Run synthesis cycle(s)
1654
+ seed <path> Create .meta/ directory with fresh meta.json
1655
+ unlock <path> Force-remove stale .lock file
1656
+ validate Validate config + check service reachability
1657
+ config show Dump resolved config
1658
+
1659
+ Root options:
1660
+ --config <path> Path to jeeves-meta.config.json (or JEEVES_META_CONFIG env)
1661
+ --json Output as JSON
1662
+ `);
1663
+ }
1664
+ function output(data) {
1665
+ if (jsonOutput) {
1666
+ console.log(JSON.stringify(data, null, 2));
1667
+ }
1668
+ else if (typeof data === 'string') {
1669
+ console.log(data);
1670
+ }
1671
+ else {
1672
+ console.log(JSON.stringify(data, null, 2));
1673
+ }
1674
+ }
1675
+ function runStatus(config) {
1676
+ const metaPaths = globMetas(config.watchPaths);
1677
+ const tree = buildOwnershipTree(metaPaths);
1678
+ let stale = 0;
1679
+ let errors = 0;
1680
+ let locked = 0;
1681
+ let neverSynth = 0;
1682
+ let archTokens = 0;
1683
+ let buildTokens = 0;
1684
+ let critTokens = 0;
1685
+ for (const node of tree.nodes.values()) {
1686
+ const meta = ensureMetaJson(node.metaPath);
1687
+ const s = actualStaleness(meta);
1688
+ if (s > 0)
1689
+ stale++;
1690
+ if (meta._error)
1691
+ errors++;
1692
+ if (isLocked(normalizePath(node.metaPath)))
1693
+ locked++;
1694
+ if (!meta._generatedAt)
1695
+ neverSynth++;
1696
+ if (meta._architectTokens)
1697
+ archTokens += meta._architectTokens;
1698
+ if (meta._builderTokens)
1699
+ buildTokens += meta._builderTokens;
1700
+ if (meta._criticTokens)
1701
+ critTokens += meta._criticTokens;
1702
+ }
1703
+ output({
1704
+ total: tree.nodes.size,
1705
+ stale,
1706
+ errors,
1707
+ locked,
1708
+ neverSynthesized: neverSynth,
1709
+ tokens: { architect: archTokens, builder: buildTokens, critic: critTokens },
1710
+ });
1711
+ }
1712
+ function runList(config) {
1713
+ const prefix = getArg('--prefix');
1714
+ const filter = getArg('--filter');
1715
+ const metaPaths = globMetas(config.watchPaths);
1716
+ const tree = buildOwnershipTree(metaPaths);
1717
+ const rows = [];
1718
+ for (const node of tree.nodes.values()) {
1719
+ if (prefix && !node.metaPath.includes(prefix))
1720
+ continue;
1721
+ const meta = ensureMetaJson(node.metaPath);
1722
+ const s = actualStaleness(meta);
1723
+ const hasError = Boolean(meta._error);
1724
+ const isLockedNow = isLocked(normalizePath(node.metaPath));
1725
+ if (filter === 'hasError' && !hasError)
1726
+ continue;
1727
+ if (filter === 'stale' && s <= 0)
1728
+ continue;
1729
+ if (filter === 'locked' && !isLockedNow)
1730
+ continue;
1731
+ if (filter === 'never' && meta._generatedAt)
1732
+ continue;
1733
+ rows.push({
1734
+ path: node.metaPath,
1735
+ depth: meta._depth ?? node.treeDepth,
1736
+ staleness: s === Infinity ? 'never' : String(Math.round(s)) + 's',
1737
+ hasError,
1738
+ locked: isLockedNow,
1739
+ children: node.children.length,
1740
+ });
1741
+ }
1742
+ output({ total: rows.length, items: rows });
1743
+ }
1744
+ async function runDetail(config) {
1745
+ const targetPath = args.find((a) => !a.startsWith('-') && a !== 'detail');
1746
+ if (!targetPath) {
1747
+ console.error('Usage: jeeves-meta detail <path> [--archive <n>]');
1748
+ process.exit(1);
1749
+ }
1750
+ const archiveArg = getArg('--archive');
1751
+ const metaPaths = globMetas(config.watchPaths);
1752
+ const tree = buildOwnershipTree(metaPaths);
1753
+ const normalized = normalizePath(targetPath);
1754
+ const node = findNode(tree, normalized);
1755
+ if (!node) {
1756
+ console.error('Meta not found: ' + targetPath);
1757
+ process.exit(1);
1758
+ }
1759
+ const meta = ensureMetaJson(node.metaPath);
1760
+ const result = { meta };
1761
+ if (archiveArg) {
1762
+ const { listArchiveFiles } = await Promise.resolve().then(function () { return index$1; });
1763
+ const archiveFiles = listArchiveFiles(node.metaPath);
1764
+ const limit = parseInt(archiveArg, 10) || archiveFiles.length;
1765
+ const selected = archiveFiles.slice(-limit).reverse();
1766
+ result.archive = selected.map((af) => {
1767
+ const raw = readFileSync(join(node.metaPath, 'archive', af), 'utf8');
1768
+ return JSON.parse(raw);
1769
+ });
1770
+ }
1771
+ output(result);
1772
+ }
1773
+ async function runPreview(config) {
1774
+ const targetPath = getArg('--path');
1775
+ const { filterInScope, paginatedScan, readLatestArchive, computeStructureHash, selectCandidate, } = await Promise.resolve().then(function () { return index; });
1776
+ const metaPaths = globMetas(config.watchPaths);
1777
+ const tree = buildOwnershipTree(metaPaths);
1778
+ const watcher = new HttpWatcherClient({ baseUrl: config.watcherUrl });
1779
+ let targetNode;
1780
+ if (targetPath) {
1781
+ const normalized = normalizePath(targetPath);
1782
+ targetNode = findNode(tree, normalized);
1783
+ if (!targetNode) {
1784
+ console.error('Meta not found: ' + targetPath);
1785
+ process.exit(1);
1786
+ }
1787
+ }
1788
+ else {
1789
+ const candidates = [];
1790
+ for (const node of tree.nodes.values()) {
1791
+ const meta = ensureMetaJson(node.metaPath);
1792
+ const s = actualStaleness(meta);
1793
+ if (s > 0)
1794
+ candidates.push({ node, meta, actualStaleness: s });
1795
+ }
1796
+ const weighted = computeEffectiveStaleness(candidates, config.depthWeight);
1797
+ const winner = selectCandidate(weighted);
1798
+ if (!winner) {
1799
+ output({ message: 'No stale metas found.' });
1800
+ return;
1801
+ }
1802
+ targetNode = winner.node;
1803
+ }
1804
+ const meta = ensureMetaJson(targetNode.metaPath);
1805
+ const allFiles = await paginatedScan(watcher, {
1806
+ pathPrefix: targetNode.ownerPath,
1807
+ });
1808
+ const scopeFiles = filterInScope(targetNode, allFiles.map((f) => f.file_path));
1809
+ const structureHash = computeStructureHash(scopeFiles);
1810
+ const structureChanged = structureHash !== meta._structureHash;
1811
+ const latestArchive = readLatestArchive(targetNode.metaPath);
1812
+ const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
1813
+ const architectTriggered = isArchitectTriggered(meta, structureChanged, steerChanged, config.architectEvery);
1814
+ output({
1815
+ target: targetNode.metaPath,
1816
+ ownerPath: targetNode.ownerPath,
1817
+ depth: meta._depth ?? targetNode.treeDepth,
1818
+ staleness: actualStaleness(meta) === Infinity
1819
+ ? 'never'
1820
+ : String(Math.round(actualStaleness(meta))) + 's',
1821
+ scopeFiles: scopeFiles.length,
1822
+ structureChanged,
1823
+ steerChanged,
1824
+ architectTriggered,
1825
+ });
1826
+ }
1827
+ async function runSynthesize(config) {
1828
+ const targetPath = getArg('--path');
1829
+ const batchArg = getArg('--batch');
1830
+ const effectiveConfig = {
1831
+ ...config,
1832
+ ...(targetPath
1833
+ ? { watchPaths: [targetPath.replace(/[/\\]\.meta[/\\]?$/, '')] }
1834
+ : {}),
1835
+ ...(batchArg ? { batchSize: parseInt(batchArg, 10) } : {}),
1836
+ };
1837
+ const executor = new GatewayExecutor({
1838
+ gatewayUrl: config.gatewayUrl,
1839
+ apiKey: config.gatewayApiKey,
1840
+ });
1841
+ const watcher = new HttpWatcherClient({ baseUrl: config.watcherUrl });
1842
+ const results = await orchestrate(effectiveConfig, executor, watcher);
1843
+ const synthesized = results.filter((r) => r.synthesized);
1844
+ output({
1845
+ synthesizedCount: synthesized.length,
1846
+ results: synthesized.map((r) => ({
1847
+ metaPath: r.metaPath,
1848
+ error: r.error ?? null,
1849
+ })),
1850
+ });
1851
+ }
1852
+ async function runSeed() {
1853
+ const { mkdirSync, writeFileSync: writeFs } = await import('node:fs');
1854
+ const { randomUUID } = await import('node:crypto');
1855
+ const targetPath = args.find((a) => !a.startsWith('-') && a !== 'seed');
1856
+ if (!targetPath) {
1857
+ console.error('Usage: jeeves-meta seed <path>');
1858
+ process.exit(1);
1859
+ }
1860
+ const metaDir = targetPath.endsWith('.meta')
1861
+ ? targetPath
1862
+ : join(targetPath, '.meta');
1863
+ mkdirSync(metaDir, { recursive: true });
1864
+ const metaFile = join(metaDir, 'meta.json');
1865
+ writeFs(metaFile, JSON.stringify({ _id: randomUUID() }, null, 2) + '\n');
1866
+ output({ created: metaFile });
1867
+ }
1868
+ async function runUnlock() {
1869
+ const { unlinkSync } = await import('node:fs');
1870
+ const targetPath = args.find((a) => !a.startsWith('-') && a !== 'unlock');
1871
+ if (!targetPath) {
1872
+ console.error('Usage: jeeves-meta unlock <path>');
1873
+ process.exit(1);
1874
+ }
1875
+ const metaDir = targetPath.endsWith('.meta')
1876
+ ? targetPath
1877
+ : join(targetPath, '.meta');
1878
+ const lockFile = join(metaDir, '.lock');
1879
+ try {
1880
+ unlinkSync(lockFile);
1881
+ output({ unlocked: metaDir });
1882
+ }
1883
+ catch {
1884
+ output({ message: 'No lock file found at ' + lockFile });
1885
+ }
1886
+ }
1887
+ async function runValidate(config) {
1888
+ const checks = {};
1889
+ // Check watcher
1890
+ try {
1891
+ const res = await fetch(config.watcherUrl + '/status', {
1892
+ signal: AbortSignal.timeout(5000),
1893
+ });
1894
+ checks.watcher = res.ok
1895
+ ? 'OK (' + config.watcherUrl + ')'
1896
+ : 'HTTP ' + res.status.toString();
1897
+ }
1898
+ catch {
1899
+ checks.watcher = 'UNREACHABLE (' + config.watcherUrl + ')';
1900
+ }
1901
+ // Check gateway
1902
+ try {
1903
+ const res = await fetch(config.gatewayUrl + '/api/status', {
1904
+ signal: AbortSignal.timeout(5000),
1905
+ });
1906
+ checks.gateway = res.ok
1907
+ ? 'OK (' + config.gatewayUrl + ')'
1908
+ : 'HTTP ' + res.status.toString();
1909
+ }
1910
+ catch {
1911
+ checks.gateway = 'UNREACHABLE (' + config.gatewayUrl + ')';
1912
+ }
1913
+ // Check watch paths
1914
+ const metaPaths = globMetas(config.watchPaths);
1915
+ checks.metas = String(metaPaths.length) + ' .meta/ directories found';
1916
+ output({ config: 'valid', checks });
1917
+ }
1918
+ function runConfigShow(config) {
1919
+ // Show config with prompts truncated for readability
1920
+ const display = {
1921
+ ...config,
1922
+ defaultArchitect: config.defaultArchitect.slice(0, 100) +
1923
+ '... (' +
1924
+ String(config.defaultArchitect.length) +
1925
+ ' chars)',
1926
+ defaultCritic: config.defaultCritic.slice(0, 100) +
1927
+ '... (' +
1928
+ String(config.defaultCritic.length) +
1929
+ ' chars)',
1930
+ };
1931
+ output(display);
1932
+ }
1933
+ function getArg(flag) {
1934
+ const idx = args.indexOf(flag);
1935
+ if (idx !== -1 && args[idx + 1])
1936
+ return args[idx + 1];
1937
+ return undefined;
1938
+ }
1939
+ async function main() {
1940
+ if (!command ||
1941
+ command === 'help' ||
1942
+ args.includes('--help') ||
1943
+ args.includes('-h')) {
1944
+ usage();
1945
+ return;
1946
+ }
1947
+ // Commands that don't need config
1948
+ if (command === 'seed') {
1949
+ await runSeed();
1950
+ return;
1951
+ }
1952
+ if (command === 'unlock') {
1953
+ await runUnlock();
1954
+ return;
1955
+ }
1956
+ // All other commands need config
1957
+ let configPath;
1958
+ try {
1959
+ configPath = resolveConfigPath(args);
1960
+ }
1961
+ catch (err) {
1962
+ console.error(err instanceof Error ? err.message : String(err));
1963
+ process.exit(1);
1964
+ }
1965
+ let config;
1966
+ try {
1967
+ config = loadSynthConfig(resolve(configPath));
1968
+ }
1969
+ catch (err) {
1970
+ console.error('Failed to load config:', err instanceof Error ? err.message : String(err));
1971
+ process.exit(1);
1972
+ }
1973
+ switch (command) {
1974
+ case 'status':
1975
+ runStatus(config);
1976
+ break;
1977
+ case 'list':
1978
+ runList(config);
1979
+ break;
1980
+ case 'detail':
1981
+ await runDetail(config);
1982
+ break;
1983
+ case 'preview':
1984
+ await runPreview(config);
1985
+ break;
1986
+ case 'synthesize':
1987
+ await runSynthesize(config);
1988
+ break;
1989
+ case 'validate':
1990
+ await runValidate(config);
1991
+ break;
1992
+ case 'config':
1993
+ if (args.includes('show')) {
1994
+ runConfigShow(config);
1995
+ }
1996
+ else if (args.includes('check')) {
1997
+ await runValidate(config);
1998
+ }
1999
+ else {
2000
+ console.error('Unknown config subcommand. Use: config show, config check');
2001
+ process.exit(1);
2002
+ }
2003
+ break;
2004
+ default:
2005
+ console.error('Unknown command: ' + command);
2006
+ usage();
2007
+ process.exit(1);
2008
+ }
2009
+ }
2010
+ main().catch((err) => {
2011
+ console.error(err instanceof Error ? err.message : String(err));
2012
+ process.exit(1);
2013
+ });