@karmaniverous/jeeves-meta 0.1.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/index.js ADDED
@@ -0,0 +1,1394 @@
1
+ import { readdirSync, unlinkSync, readFileSync, mkdirSync, writeFileSync, existsSync, statSync } from 'node:fs';
2
+ import { join, dirname, relative, sep } from 'node:path';
3
+ import { randomUUID, createHash } from 'node:crypto';
4
+ import { z } from 'zod';
5
+
6
+ /**
7
+ * List archive snapshot files in chronological order.
8
+ *
9
+ * @module archive/listArchive
10
+ */
11
+ /**
12
+ * List archive .json files sorted chronologically (oldest first).
13
+ *
14
+ * @param metaPath - Absolute path to the .meta directory.
15
+ * @returns Array of absolute paths to archive files, or empty if none.
16
+ */
17
+ function listArchiveFiles(metaPath) {
18
+ const archiveDir = join(metaPath, 'archive');
19
+ try {
20
+ return readdirSync(archiveDir)
21
+ .filter((f) => f.endsWith('.json'))
22
+ .sort()
23
+ .map((f) => join(archiveDir, f));
24
+ }
25
+ catch {
26
+ return [];
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Prune old archive snapshots beyond maxArchive.
32
+ *
33
+ * @module archive/prune
34
+ */
35
+ /**
36
+ * Prune archive directory to keep at most maxArchive snapshots.
37
+ * Removes the oldest files.
38
+ *
39
+ * @param metaPath - Absolute path to the .meta directory.
40
+ * @param maxArchive - Maximum snapshots to retain.
41
+ * @returns Number of files pruned.
42
+ */
43
+ function pruneArchive(metaPath, maxArchive) {
44
+ const files = listArchiveFiles(metaPath);
45
+ const toRemove = files.length - maxArchive;
46
+ if (toRemove <= 0)
47
+ return 0;
48
+ for (let i = 0; i < toRemove; i++) {
49
+ unlinkSync(files[i]);
50
+ }
51
+ return toRemove;
52
+ }
53
+
54
+ /**
55
+ * Read the latest archive snapshot for steer change detection.
56
+ *
57
+ * @module archive/readLatest
58
+ */
59
+ /**
60
+ * Read the most recent archive snapshot.
61
+ *
62
+ * @param metaPath - Absolute path to the .meta directory.
63
+ * @returns The latest archived meta, or null if no archives exist.
64
+ */
65
+ function readLatestArchive(metaPath) {
66
+ const files = listArchiveFiles(metaPath);
67
+ if (files.length === 0)
68
+ return null;
69
+ const raw = readFileSync(files[files.length - 1], 'utf8');
70
+ return JSON.parse(raw);
71
+ }
72
+
73
+ /**
74
+ * Create archive snapshots of meta.json.
75
+ *
76
+ * Copies current meta.json to archive/\{ISO-timestamp\}.json with
77
+ * _archived: true and _archivedAt added.
78
+ *
79
+ * @module archive/snapshot
80
+ */
81
+ /**
82
+ * Create an archive snapshot of the current meta.json.
83
+ *
84
+ * @param metaPath - Absolute path to the .meta directory.
85
+ * @param meta - Current meta.json content.
86
+ * @returns The archive file path.
87
+ */
88
+ function createSnapshot(metaPath, meta) {
89
+ const archiveDir = join(metaPath, 'archive');
90
+ mkdirSync(archiveDir, { recursive: true });
91
+ const now = new Date().toISOString().replace(/[:.]/g, '-');
92
+ const archiveFile = join(archiveDir, now + '.json');
93
+ const archived = {
94
+ ...meta,
95
+ _archived: true,
96
+ _archivedAt: new Date().toISOString(),
97
+ };
98
+ writeFileSync(archiveFile, JSON.stringify(archived, null, 2) + '\n');
99
+ return archiveFile;
100
+ }
101
+
102
+ /**
103
+ * Ensure meta.json exists in each .meta/ directory.
104
+ *
105
+ * If meta.json is missing, creates it with a generated UUID.
106
+ *
107
+ * @module discovery/ensureMetaJson
108
+ */
109
+ /**
110
+ * Ensure meta.json exists at the given .meta/ path.
111
+ *
112
+ * @param metaPath - Absolute path to a .meta/ directory.
113
+ * @returns The meta.json content (existing or newly created).
114
+ */
115
+ function ensureMetaJson(metaPath) {
116
+ const filePath = join(metaPath, 'meta.json');
117
+ if (existsSync(filePath)) {
118
+ const raw = readFileSync(filePath, 'utf8');
119
+ return JSON.parse(raw);
120
+ }
121
+ // Create the archive subdirectory while we're at it
122
+ const archivePath = join(metaPath, 'archive');
123
+ if (!existsSync(archivePath)) {
124
+ mkdirSync(archivePath, { recursive: true });
125
+ }
126
+ const meta = { _id: randomUUID() };
127
+ writeFileSync(filePath, JSON.stringify(meta, null, 2) + '\n');
128
+ return meta;
129
+ }
130
+
131
+ /**
132
+ * Glob watchPaths for .meta/ directories.
133
+ *
134
+ * Walks each watchPath recursively, collecting directories named '.meta'
135
+ * that contain (or will contain) a meta.json file.
136
+ *
137
+ * @module discovery/globMetas
138
+ */
139
+ /**
140
+ * Recursively find all .meta/ directories under the given paths.
141
+ *
142
+ * @param watchPaths - Root directories to search.
143
+ * @returns Array of absolute paths to .meta/ directories.
144
+ */
145
+ function globMetas(watchPaths) {
146
+ const results = [];
147
+ function walk(dir) {
148
+ let entries;
149
+ try {
150
+ entries = readdirSync(dir);
151
+ }
152
+ catch {
153
+ return; // Skip unreadable directories
154
+ }
155
+ for (const entry of entries) {
156
+ const full = join(dir, entry);
157
+ let stat;
158
+ try {
159
+ stat = statSync(full);
160
+ }
161
+ catch {
162
+ continue;
163
+ }
164
+ if (!stat.isDirectory())
165
+ continue;
166
+ if (entry === '.meta') {
167
+ results.push(full);
168
+ }
169
+ else {
170
+ walk(full);
171
+ }
172
+ }
173
+ }
174
+ for (const wp of watchPaths) {
175
+ walk(wp);
176
+ }
177
+ return results;
178
+ }
179
+
180
+ /**
181
+ * Build the ownership tree from discovered .meta/ paths.
182
+ *
183
+ * Each .meta/ directory owns its parent directory and all descendants,
184
+ * except subtrees that contain their own .meta/. For those subtrees,
185
+ * the parent meta consumes the child meta's synthesis output.
186
+ *
187
+ * @module discovery/ownershipTree
188
+ */
189
+ /** Normalize path separators to forward slashes for consistent comparison. */
190
+ function normalizePath$1(p) {
191
+ return p.split(sep).join('/');
192
+ }
193
+ /**
194
+ * Build an ownership tree from an array of .meta/ directory paths.
195
+ *
196
+ * @param metaPaths - Absolute paths to .meta/ directories.
197
+ * @returns The ownership tree with parent/child relationships.
198
+ */
199
+ function buildOwnershipTree(metaPaths) {
200
+ const nodes = new Map();
201
+ // Create nodes, sorted by ownerPath length (shortest first = shallowest)
202
+ const sorted = [...metaPaths]
203
+ .map((mp) => ({
204
+ metaPath: normalizePath$1(mp),
205
+ ownerPath: normalizePath$1(dirname(mp)),
206
+ }))
207
+ .sort((a, b) => a.ownerPath.length - b.ownerPath.length);
208
+ for (const { metaPath, ownerPath } of sorted) {
209
+ nodes.set(metaPath, {
210
+ metaPath,
211
+ ownerPath,
212
+ treeDepth: 0,
213
+ children: [],
214
+ parent: null,
215
+ });
216
+ }
217
+ const roots = [];
218
+ // For each node, find its closest ancestor meta
219
+ for (const node of nodes.values()) {
220
+ let bestParent = null;
221
+ let bestParentLen = -1;
222
+ for (const candidate of nodes.values()) {
223
+ if (candidate === node)
224
+ continue;
225
+ // Check if node's ownerPath is under candidate's ownerPath
226
+ const rel = relative(candidate.ownerPath, node.ownerPath);
227
+ if (rel.startsWith('..') || rel === '')
228
+ continue;
229
+ // candidate.ownerPath is an ancestor of node.ownerPath
230
+ if (candidate.ownerPath.length > bestParentLen) {
231
+ bestParent = candidate;
232
+ bestParentLen = candidate.ownerPath.length;
233
+ }
234
+ }
235
+ if (bestParent) {
236
+ node.parent = bestParent;
237
+ node.treeDepth = bestParent.treeDepth + 1;
238
+ bestParent.children.push(node);
239
+ }
240
+ else {
241
+ roots.push(node);
242
+ }
243
+ }
244
+ return { nodes, roots };
245
+ }
246
+
247
+ /**
248
+ * Compute the file scope owned by a meta node.
249
+ *
250
+ * A meta owns: parent dir + all descendants, minus child .meta/ subtrees.
251
+ * For child subtrees, it consumes the child's .meta/meta.json as a rollup input.
252
+ *
253
+ * @module discovery/scope
254
+ */
255
+ /**
256
+ * Get the scope path prefix for a meta node.
257
+ *
258
+ * This is the ownerPath — all files under this path are in scope,
259
+ * except subtrees owned by child metas.
260
+ *
261
+ * @param node - The meta node to compute scope for.
262
+ * @returns The scope path prefix.
263
+ */
264
+ function getScopePrefix(node) {
265
+ return node.ownerPath;
266
+ }
267
+ /**
268
+ * Get paths that should be excluded from the scope (child meta subtrees).
269
+ *
270
+ * @param node - The meta node to compute exclusions for.
271
+ * @returns Array of path prefixes to exclude from scope queries.
272
+ */
273
+ function getScopeExclusions(node) {
274
+ return node.children.map((child) => child.ownerPath);
275
+ }
276
+ /**
277
+ * Filter a list of file paths to only those in scope for a meta node.
278
+ *
279
+ * Includes files under ownerPath, excludes files under child meta ownerPaths,
280
+ * but includes child .meta/meta.json files as rollup inputs.
281
+ *
282
+ * @param node - The meta node.
283
+ * @param files - Array of file paths to filter.
284
+ * @returns Filtered array of in-scope file paths.
285
+ */
286
+ function filterInScope(node, files) {
287
+ const prefix = node.ownerPath + '/';
288
+ const exclusions = node.children.map((c) => c.ownerPath + '/');
289
+ const childMetaJsons = new Set(node.children.map((c) => c.metaPath + '/meta.json'));
290
+ return files.filter((f) => {
291
+ const normalized = f.split('\\').join('/');
292
+ // Must be under ownerPath
293
+ if (!normalized.startsWith(prefix) && normalized !== node.ownerPath)
294
+ return false;
295
+ // Check if under a child meta's subtree
296
+ for (const excl of exclusions) {
297
+ if (normalized.startsWith(excl)) {
298
+ // Exception: child meta.json files are included as rollup inputs
299
+ return childMetaJsons.has(normalized);
300
+ }
301
+ }
302
+ return true;
303
+ });
304
+ }
305
+
306
+ /**
307
+ * Exponential moving average helper for token tracking.
308
+ *
309
+ * @module ema
310
+ */
311
+ const DEFAULT_DECAY = 0.3;
312
+ /**
313
+ * Compute exponential moving average.
314
+ *
315
+ * @param current - New observation.
316
+ * @param previous - Previous EMA value, or undefined for first observation.
317
+ * @param decay - Decay factor (0-1). Higher = more weight on new value. Default 0.3.
318
+ * @returns Updated EMA.
319
+ */
320
+ function computeEma(current, previous, decay = DEFAULT_DECAY) {
321
+ if (previous === undefined)
322
+ return current;
323
+ return decay * current + (1 - decay) * previous;
324
+ }
325
+
326
+ /**
327
+ * Paginated scan helper for exhaustive scope enumeration.
328
+ *
329
+ * @module paginatedScan
330
+ */
331
+ /**
332
+ * Perform a paginated scan that follows cursor tokens until exhausted.
333
+ *
334
+ * @param watcher - WatcherClient instance.
335
+ * @param params - Base scan parameters (cursor is managed internally).
336
+ * @returns All matching files across all pages.
337
+ */
338
+ async function paginatedScan(watcher, params) {
339
+ const allFiles = [];
340
+ let cursor;
341
+ do {
342
+ const result = await watcher.scan({ ...params, cursor });
343
+ allFiles.push(...result.files);
344
+ cursor = result.next;
345
+ } while (cursor);
346
+ return allFiles;
347
+ }
348
+
349
+ /**
350
+ * Build the SynthContext for a synthesis cycle.
351
+ *
352
+ * Computes shared inputs once: scope files, delta files, child meta outputs,
353
+ * previous content/feedback, steer, and archive paths.
354
+ *
355
+ * @module orchestrator/contextPackage
356
+ */
357
+ /**
358
+ * Condense a file list into glob-like summaries.
359
+ * Groups by directory + extension pattern.
360
+ *
361
+ * @param files - Array of file paths.
362
+ * @param maxIndividual - Show individual files up to this count.
363
+ * @returns Condensed summary string.
364
+ */
365
+ function condenseScopeFiles(files, maxIndividual = 30) {
366
+ if (files.length <= maxIndividual)
367
+ return files.join('\n');
368
+ // Group by dir + extension
369
+ const groups = new Map();
370
+ for (const f of files) {
371
+ const dir = f.substring(0, f.lastIndexOf('/') + 1) || './';
372
+ const ext = f.includes('.') ? f.substring(f.lastIndexOf('.')) : '(no ext)';
373
+ const key = dir + '*' + ext;
374
+ groups.set(key, (groups.get(key) ?? 0) + 1);
375
+ }
376
+ // Sort by count descending
377
+ const sorted = [...groups.entries()].sort((a, b) => b[1] - a[1]);
378
+ return sorted
379
+ .map(([pattern, count]) => pattern + ' (' + count.toString() + ' files)')
380
+ .join('\n');
381
+ }
382
+ /** Filter files to exclude child meta subtrees. */
383
+ function excludeChildSubtrees(files, childPrefixes) {
384
+ if (childPrefixes.length === 0)
385
+ return files;
386
+ return files.filter((f) => childPrefixes.every((cp) => !f.startsWith(cp)));
387
+ }
388
+ /**
389
+ * Build the context package for a synthesis cycle.
390
+ *
391
+ * @param node - The meta node being synthesized.
392
+ * @param meta - Current meta.json content.
393
+ * @param watcher - WatcherClient for scope enumeration.
394
+ * @returns The computed context package.
395
+ */
396
+ async function buildContextPackage(node, meta, watcher) {
397
+ const childPrefixes = node.children.map((c) => c.ownerPath + '/');
398
+ // Scope files via watcher scan, excluding child subtrees
399
+ const allScanFiles = await paginatedScan(watcher, {
400
+ pathPrefix: node.ownerPath,
401
+ });
402
+ const allFiles = allScanFiles.map((f) => f.file_path);
403
+ const scopeFiles = excludeChildSubtrees(allFiles, childPrefixes);
404
+ // Delta files: modified since _generatedAt
405
+ let deltaFiles;
406
+ if (meta._generatedAt) {
407
+ const modifiedAfter = Math.floor(new Date(meta._generatedAt).getTime() / 1000);
408
+ const deltaScanFiles = await paginatedScan(watcher, {
409
+ pathPrefix: node.ownerPath,
410
+ modifiedAfter,
411
+ });
412
+ deltaFiles = excludeChildSubtrees(deltaScanFiles.map((f) => f.file_path), childPrefixes);
413
+ }
414
+ else {
415
+ deltaFiles = scopeFiles; // First run: all files are delta
416
+ }
417
+ // Child meta outputs
418
+ const childMetas = {};
419
+ for (const child of node.children) {
420
+ const childMetaFile = join(child.metaPath, 'meta.json');
421
+ try {
422
+ const raw = readFileSync(childMetaFile, 'utf8');
423
+ const childMeta = JSON.parse(raw);
424
+ childMetas[child.ownerPath] = childMeta._content ?? null;
425
+ }
426
+ catch {
427
+ childMetas[child.ownerPath] = null;
428
+ }
429
+ }
430
+ // Archive paths
431
+ const archives = listArchiveFiles(node.metaPath);
432
+ return {
433
+ path: node.metaPath,
434
+ scopeFiles,
435
+ deltaFiles,
436
+ childMetas,
437
+ previousContent: meta._content ?? null,
438
+ previousFeedback: meta._feedback ?? null,
439
+ steer: meta._steer ?? null,
440
+ archives,
441
+ };
442
+ }
443
+
444
+ /**
445
+ * Build task prompts for each synthesis step.
446
+ *
447
+ * @module orchestrator/buildTask
448
+ */
449
+ /** Append optional context sections shared across all step prompts. */
450
+ function appendSharedSections(sections, ctx, options) {
451
+ const opts = {
452
+ includeSteer: true,
453
+ includePreviousContent: true,
454
+ includePreviousFeedback: true,
455
+ feedbackHeading: '## PREVIOUS FEEDBACK',
456
+ includeChildMetas: true,
457
+ ...options,
458
+ };
459
+ if (opts.includeSteer && ctx.steer) {
460
+ sections.push('', '## STEERING PROMPT', ctx.steer);
461
+ }
462
+ if (opts.includePreviousContent && ctx.previousContent) {
463
+ sections.push('', '## PREVIOUS SYNTHESIS', ctx.previousContent);
464
+ }
465
+ if (opts.includePreviousFeedback && ctx.previousFeedback) {
466
+ sections.push('', opts.feedbackHeading, ctx.previousFeedback);
467
+ }
468
+ if (opts.includeChildMetas && Object.keys(ctx.childMetas).length > 0) {
469
+ sections.push('', '## CHILD META OUTPUTS');
470
+ for (const [childPath, content] of Object.entries(ctx.childMetas)) {
471
+ sections.push(`### ${childPath}`, typeof content === 'string' ? content : '(not yet synthesized)');
472
+ }
473
+ }
474
+ }
475
+ /**
476
+ * Build the architect task prompt.
477
+ *
478
+ * @param ctx - Synthesis context.
479
+ * @param meta - Current meta.json.
480
+ * @param config - Synthesis config.
481
+ * @returns The architect task prompt string.
482
+ */
483
+ function buildArchitectTask(ctx, meta, config) {
484
+ const sections = [
485
+ meta._architect ?? config.defaultArchitect,
486
+ '',
487
+ '## SCOPE',
488
+ `Path: ${ctx.path}`,
489
+ `Total files in scope: ${ctx.scopeFiles.length.toString()}`,
490
+ `Files changed since last synthesis: ${ctx.deltaFiles.length.toString()}`,
491
+ '',
492
+ '### File listing (scope)',
493
+ condenseScopeFiles(ctx.scopeFiles),
494
+ ];
495
+ // Inject previous _builder so architect can see its own prior output
496
+ if (meta._builder) {
497
+ sections.push('', '## PREVIOUS TASK BRIEF', meta._builder);
498
+ }
499
+ appendSharedSections(sections, ctx);
500
+ if (ctx.archives.length > 0) {
501
+ 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.');
502
+ }
503
+ return sections.join('\n');
504
+ }
505
+ /**
506
+ * Build the builder task prompt.
507
+ *
508
+ * @param ctx - Synthesis context.
509
+ * @param meta - Current meta.json.
510
+ * @param config - Synthesis config.
511
+ * @returns The builder task prompt string.
512
+ */
513
+ function buildBuilderTask(ctx, meta, config) {
514
+ const sections = [
515
+ '## TASK BRIEF (from Architect)',
516
+ meta._builder ?? '(No architect brief available)',
517
+ '',
518
+ '## SCOPE',
519
+ `Path: ${ctx.path}`,
520
+ `Delta files (${ctx.deltaFiles.length.toString()} changed):`,
521
+ ...ctx.deltaFiles.slice(0, config.maxLines).map((f) => `- ${f}`),
522
+ ];
523
+ appendSharedSections(sections, ctx, {
524
+ includeSteer: false,
525
+ feedbackHeading: '## FEEDBACK FROM CRITIC',
526
+ });
527
+ sections.push('', '## OUTPUT FORMAT', 'Return a JSON object with:', '- "_content": Markdown narrative synthesis (required)', '- Any additional structured fields as non-underscore keys');
528
+ return sections.join('\n');
529
+ }
530
+ /**
531
+ * Build the critic task prompt.
532
+ *
533
+ * @param ctx - Synthesis context.
534
+ * @param meta - Current meta.json (with _content already set by builder).
535
+ * @param config - Synthesis config.
536
+ * @returns The critic task prompt string.
537
+ */
538
+ function buildCriticTask(ctx, meta, config) {
539
+ const sections = [
540
+ meta._critic ?? config.defaultCritic,
541
+ '',
542
+ '## SYNTHESIS TO EVALUATE',
543
+ meta._content ?? '(No content produced)',
544
+ '',
545
+ '## SCOPE',
546
+ `Path: ${ctx.path}`,
547
+ `Files in scope: ${ctx.scopeFiles.length.toString()}`,
548
+ ];
549
+ appendSharedSections(sections, ctx, {
550
+ includePreviousContent: false,
551
+ feedbackHeading: '## YOUR PREVIOUS FEEDBACK',
552
+ includeChildMetas: false,
553
+ });
554
+ sections.push('', '## OUTPUT FORMAT', 'Return your evaluation as Markdown text. Be specific and actionable.');
555
+ return sections.join('\n');
556
+ }
557
+
558
+ /**
559
+ * Zod schema for jeeves-meta configuration.
560
+ *
561
+ * Consumers load config however they want (file, env, constructor).
562
+ * The library validates via this schema.
563
+ *
564
+ * @module schema/config
565
+ */
566
+ /** Zod schema for jeeves-meta configuration. */
567
+ const synthConfigSchema = z.object({
568
+ /** Filesystem paths to watch for .meta/ directories. */
569
+ watchPaths: z.array(z.string()).min(1),
570
+ /** Watcher service base URL. */
571
+ watcherUrl: z.url(),
572
+ /** Run architect every N cycles (per meta). */
573
+ architectEvery: z.number().int().min(1).default(10),
574
+ /** Exponent for depth weighting in staleness formula. */
575
+ depthWeight: z.number().min(0).default(0.5),
576
+ /** Maximum archive snapshots to retain per meta. */
577
+ maxArchive: z.number().int().min(1).default(20),
578
+ /** Maximum lines of context to include in subprocess prompts. */
579
+ maxLines: z.number().int().min(50).default(500),
580
+ /** Architect subprocess timeout in seconds. */
581
+ architectTimeout: z.number().int().min(30).default(120),
582
+ /** Builder subprocess timeout in seconds. */
583
+ builderTimeout: z.number().int().min(60).default(600),
584
+ /** Critic subprocess timeout in seconds. */
585
+ criticTimeout: z.number().int().min(30).default(300),
586
+ /** Resolved architect system prompt text. */
587
+ defaultArchitect: z.string(),
588
+ /** Resolved critic system prompt text. */
589
+ defaultCritic: z.string(),
590
+ /**
591
+ * When true, skip unchanged candidates and iterate to the next-stalest
592
+ * until finding one with actual changes. Skipped candidates get their
593
+ * _generatedAt bumped to prevent re-selection next cycle.
594
+ */
595
+ skipUnchanged: z.boolean().default(true),
596
+ /** Number of metas to synthesize per invocation. */
597
+ batchSize: z.number().int().min(1).default(1),
598
+ });
599
+
600
+ /**
601
+ * Structured error from a synthesis step failure.
602
+ *
603
+ * @module schema/error
604
+ */
605
+ /** Zod schema for synthesis step errors. */
606
+ const synthErrorSchema = z.object({
607
+ /** Which step failed: 'architect', 'builder', or 'critic'. */
608
+ step: z.enum(['architect', 'builder', 'critic']),
609
+ /** Error classification code. */
610
+ code: z.string(),
611
+ /** Human-readable error message. */
612
+ message: z.string(),
613
+ });
614
+
615
+ /**
616
+ * Zod schema for .meta/meta.json files.
617
+ *
618
+ * Reserved properties are underscore-prefixed and engine-managed.
619
+ * All other keys are open schema (builder output).
620
+ *
621
+ * @module schema/meta
622
+ */
623
+ /** Zod schema for the reserved (underscore-prefixed) meta.json properties. */
624
+ const metaJsonSchema = z
625
+ .object({
626
+ /** Stable identity. Generated on first synthesis, never changes. */
627
+ _id: z.uuid(),
628
+ /** Human-provided steering prompt. Optional. */
629
+ _steer: z.string().optional(),
630
+ /** Architect system prompt used this turn. Defaults from config. */
631
+ _architect: z.string().optional(),
632
+ /**
633
+ * Task brief generated by the architect. Cached and reused across cycles;
634
+ * regenerated only when triggered.
635
+ */
636
+ _builder: z.string().optional(),
637
+ /** Critic system prompt used this turn. Defaults from config. */
638
+ _critic: z.string().optional(),
639
+ /** Timestamp of last synthesis. ISO 8601. */
640
+ _generatedAt: z.iso.datetime().optional(),
641
+ /** Narrative synthesis output. Rendered by watcher for embedding. */
642
+ _content: z.string().optional(),
643
+ /**
644
+ * Hash of sorted file listing in scope. Detects directory structure
645
+ * changes that trigger an architect re-run.
646
+ */
647
+ _structureHash: z.string().optional(),
648
+ /**
649
+ * Cycles since last architect run. Reset to 0 when architect runs.
650
+ * Used with architectEvery to trigger periodic re-prompting.
651
+ */
652
+ _synthesisCount: z.number().int().min(0).optional(),
653
+ /** Critic evaluation of the last synthesis. */
654
+ _feedback: z.string().optional(),
655
+ /**
656
+ * Present and true on archive snapshots. Distinguishes live vs. archived
657
+ * metas.
658
+ */
659
+ _archived: z.boolean().optional(),
660
+ /** Timestamp when this snapshot was archived. ISO 8601. */
661
+ _archivedAt: z.iso.datetime().optional(),
662
+ /**
663
+ * Scheduling priority. Higher = updates more often. Negative allowed;
664
+ * normalized to min 0 at scheduling time.
665
+ */
666
+ _depth: z.number().optional(),
667
+ /**
668
+ * Emphasis multiplier for depth weighting in scheduling.
669
+ * Default 1. Higher values increase this meta's scheduling priority
670
+ * relative to its depth. Set to 0.5 to halve the depth effect,
671
+ * 2 to double it, 0 to ignore depth entirely for this meta.
672
+ */
673
+ _emphasis: z.number().min(0).optional(),
674
+ /** Token count from last architect subprocess call. */
675
+ _architectTokens: z.number().int().optional(),
676
+ /** Token count from last builder subprocess call. */
677
+ _builderTokens: z.number().int().optional(),
678
+ /** Token count from last critic subprocess call. */
679
+ _criticTokens: z.number().int().optional(),
680
+ /** Exponential moving average of architect token usage (decay 0.3). */
681
+ _architectTokensAvg: z.number().optional(),
682
+ /** Exponential moving average of builder token usage (decay 0.3). */
683
+ _builderTokensAvg: z.number().optional(),
684
+ /** Exponential moving average of critic token usage (decay 0.3). */
685
+ _criticTokensAvg: z.number().optional(),
686
+ /**
687
+ * Structured error from last cycle. Present when a step failed.
688
+ * Cleared on successful cycle.
689
+ */
690
+ _error: synthErrorSchema.optional(),
691
+ })
692
+ .loose();
693
+
694
+ /**
695
+ * Merge synthesis results into meta.json.
696
+ *
697
+ * Preserves human-set fields (_id, _steer, _depth).
698
+ * Writes engine fields (_generatedAt, _structureHash, etc.).
699
+ * Validates against schema before writing.
700
+ *
701
+ * @module orchestrator/merge
702
+ */
703
+ /**
704
+ * Merge results into meta.json and write atomically.
705
+ *
706
+ * @param options - Merge options.
707
+ * @returns The updated MetaJson.
708
+ * @throws If validation fails (malformed output).
709
+ */
710
+ function mergeAndWrite(options) {
711
+ const merged = {
712
+ // Preserve human-set fields
713
+ _id: options.current._id,
714
+ _steer: options.current._steer,
715
+ _depth: options.current._depth,
716
+ _emphasis: options.current._emphasis,
717
+ // Engine fields
718
+ _architect: options.architect,
719
+ _builder: options.builder,
720
+ _critic: options.critic,
721
+ _generatedAt: new Date().toISOString(),
722
+ _structureHash: options.structureHash,
723
+ _synthesisCount: options.synthesisCount,
724
+ // Token tracking
725
+ _architectTokens: options.architectTokens,
726
+ _builderTokens: options.builderTokens,
727
+ _criticTokens: options.criticTokens,
728
+ _architectTokensAvg: options.architectTokens !== undefined
729
+ ? computeEma(options.architectTokens, options.current._architectTokensAvg)
730
+ : options.current._architectTokensAvg,
731
+ _builderTokensAvg: options.builderTokens !== undefined
732
+ ? computeEma(options.builderTokens, options.current._builderTokensAvg)
733
+ : options.current._builderTokensAvg,
734
+ _criticTokensAvg: options.criticTokens !== undefined
735
+ ? computeEma(options.criticTokens, options.current._criticTokensAvg)
736
+ : options.current._criticTokensAvg,
737
+ // Content from builder
738
+ _content: options.builderOutput?.content ?? options.current._content,
739
+ // Feedback from critic
740
+ _feedback: options.feedback ?? options.current._feedback,
741
+ // Error handling
742
+ _error: options.error ?? undefined,
743
+ // Spread structured fields from builder
744
+ ...options.builderOutput?.fields,
745
+ };
746
+ // Clean up undefined optional fields
747
+ if (merged._steer === undefined)
748
+ delete merged._steer;
749
+ if (merged._depth === undefined)
750
+ delete merged._depth;
751
+ if (merged._emphasis === undefined)
752
+ delete merged._emphasis;
753
+ if (merged._architectTokens === undefined)
754
+ delete merged._architectTokens;
755
+ if (merged._builderTokens === undefined)
756
+ delete merged._builderTokens;
757
+ if (merged._criticTokens === undefined)
758
+ delete merged._criticTokens;
759
+ if (merged._architectTokensAvg === undefined)
760
+ delete merged._architectTokensAvg;
761
+ if (merged._builderTokensAvg === undefined)
762
+ delete merged._builderTokensAvg;
763
+ if (merged._criticTokensAvg === undefined)
764
+ delete merged._criticTokensAvg;
765
+ if (merged._error === undefined)
766
+ delete merged._error;
767
+ if (merged._content === undefined)
768
+ delete merged._content;
769
+ if (merged._feedback === undefined)
770
+ delete merged._feedback;
771
+ // Validate
772
+ const result = metaJsonSchema.safeParse(merged);
773
+ if (!result.success) {
774
+ throw new Error(`Meta validation failed: ${result.error.message}`);
775
+ }
776
+ // Write atomically
777
+ const filePath = join(options.metaPath, 'meta.json');
778
+ writeFileSync(filePath, JSON.stringify(result.data, null, 2) + '\n');
779
+ return result.data;
780
+ }
781
+
782
+ /**
783
+ * Shared error utilities.
784
+ *
785
+ * @module errors
786
+ */
787
+ /**
788
+ * Wrap an unknown caught value into a SynthError.
789
+ *
790
+ * @param step - Which synthesis step failed.
791
+ * @param err - The caught error value.
792
+ * @param code - Error classification code.
793
+ * @returns A structured SynthError.
794
+ */
795
+ function toSynthError(step, err, code = 'FAILED') {
796
+ return {
797
+ step,
798
+ code,
799
+ message: err instanceof Error ? err.message : String(err),
800
+ };
801
+ }
802
+
803
+ /**
804
+ * File-system lock for preventing concurrent synthesis on the same meta.
805
+ *
806
+ * Lock file: .meta/.lock containing PID + timestamp.
807
+ * Stale timeout: 30 minutes.
808
+ *
809
+ * @module lock
810
+ */
811
+ const LOCK_FILE = '.lock';
812
+ const STALE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
813
+ /**
814
+ * Attempt to acquire a lock on a .meta directory.
815
+ *
816
+ * @param metaPath - Absolute path to the .meta directory.
817
+ * @returns True if lock was acquired, false if already locked (non-stale).
818
+ */
819
+ function acquireLock(metaPath) {
820
+ const lockPath = join(metaPath, LOCK_FILE);
821
+ if (existsSync(lockPath)) {
822
+ try {
823
+ const raw = readFileSync(lockPath, 'utf8');
824
+ const data = JSON.parse(raw);
825
+ const lockAge = Date.now() - new Date(data.startedAt).getTime();
826
+ if (lockAge < STALE_TIMEOUT_MS) {
827
+ return false; // Lock is active
828
+ }
829
+ // Stale lock — fall through to overwrite
830
+ }
831
+ catch {
832
+ // Corrupt lock file — overwrite
833
+ }
834
+ }
835
+ const lock = {
836
+ pid: process.pid,
837
+ startedAt: new Date().toISOString(),
838
+ };
839
+ writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n');
840
+ return true;
841
+ }
842
+ /**
843
+ * Release a lock on a .meta directory.
844
+ *
845
+ * @param metaPath - Absolute path to the .meta directory.
846
+ */
847
+ function releaseLock(metaPath) {
848
+ const lockPath = join(metaPath, LOCK_FILE);
849
+ try {
850
+ unlinkSync(lockPath);
851
+ }
852
+ catch {
853
+ // Already removed or never existed
854
+ }
855
+ }
856
+ /**
857
+ * Check if a .meta directory is currently locked (non-stale).
858
+ *
859
+ * @param metaPath - Absolute path to the .meta directory.
860
+ * @returns True if locked and not stale.
861
+ */
862
+ function isLocked(metaPath) {
863
+ const lockPath = join(metaPath, LOCK_FILE);
864
+ if (!existsSync(lockPath))
865
+ return false;
866
+ try {
867
+ const raw = readFileSync(lockPath, 'utf8');
868
+ const data = JSON.parse(raw);
869
+ const lockAge = Date.now() - new Date(data.startedAt).getTime();
870
+ return lockAge < STALE_TIMEOUT_MS;
871
+ }
872
+ catch {
873
+ return false; // Corrupt lock = not locked
874
+ }
875
+ }
876
+
877
+ /**
878
+ * Select the best synthesis candidate from stale metas.
879
+ *
880
+ * Picks the meta with highest effective staleness.
881
+ *
882
+ * @module scheduling/selectCandidate
883
+ */
884
+ /**
885
+ * Select the candidate with the highest effective staleness.
886
+ *
887
+ * @param candidates - Array of candidates with computed effective staleness.
888
+ * @returns The winning candidate, or null if no candidates.
889
+ */
890
+ function selectCandidate(candidates) {
891
+ if (candidates.length === 0)
892
+ return null;
893
+ let best = candidates[0];
894
+ for (let i = 1; i < candidates.length; i++) {
895
+ if (candidates[i].effectiveStaleness > best.effectiveStaleness) {
896
+ best = candidates[i];
897
+ }
898
+ }
899
+ return best;
900
+ }
901
+
902
+ /**
903
+ * Staleness detection via watcher scan.
904
+ *
905
+ * A meta is stale when any file in its scope was modified after _generatedAt.
906
+ *
907
+ * @module scheduling/staleness
908
+ */
909
+ /**
910
+ * Check if a meta is stale by querying the watcher for modified files.
911
+ *
912
+ * @param scopePrefix - Path prefix for this meta's scope.
913
+ * @param meta - Current meta.json content.
914
+ * @param watcher - WatcherClient instance.
915
+ * @returns True if any file in scope was modified after _generatedAt.
916
+ */
917
+ async function isStale(scopePrefix, meta, watcher) {
918
+ if (!meta._generatedAt)
919
+ return true; // Never synthesized = stale
920
+ const generatedAtUnix = Math.floor(new Date(meta._generatedAt).getTime() / 1000);
921
+ const result = await watcher.scan({
922
+ pathPrefix: scopePrefix,
923
+ modifiedAfter: generatedAtUnix,
924
+ limit: 1,
925
+ });
926
+ return result.files.length > 0;
927
+ }
928
+ /**
929
+ * Compute actual staleness in seconds (now minus _generatedAt).
930
+ *
931
+ * @param meta - Current meta.json content.
932
+ * @returns Staleness in seconds, or Infinity if never synthesized.
933
+ */
934
+ function actualStaleness(meta) {
935
+ if (!meta._generatedAt)
936
+ return Infinity;
937
+ const generatedMs = new Date(meta._generatedAt).getTime();
938
+ return (Date.now() - generatedMs) / 1000;
939
+ }
940
+
941
+ /**
942
+ * Weighted staleness formula for candidate selection.
943
+ *
944
+ * effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
945
+ *
946
+ * @module scheduling/weightedFormula
947
+ */
948
+ /**
949
+ * Compute effective staleness for a set of candidates.
950
+ *
951
+ * Normalizes depths so the minimum becomes 0, then applies the formula:
952
+ * effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
953
+ *
954
+ * Per-meta _emphasis (default 1) multiplies depthWeight, allowing individual
955
+ * metas to tune how much their tree position affects scheduling.
956
+ *
957
+ * @param candidates - Array of \{ node, meta, actualStaleness \}.
958
+ * @param depthWeight - Exponent for depth weighting (0 = pure staleness).
959
+ * @returns Same array with effectiveStaleness computed.
960
+ */
961
+ function computeEffectiveStaleness(candidates, depthWeight) {
962
+ if (candidates.length === 0)
963
+ return [];
964
+ // Get depth for each candidate: use _depth override or tree depth
965
+ const depths = candidates.map((c) => c.meta._depth ?? c.node.treeDepth);
966
+ // Normalize: shift so minimum becomes 0
967
+ const minDepth = Math.min(...depths);
968
+ const normalizedDepths = depths.map((d) => Math.max(0, d - minDepth));
969
+ return candidates.map((c, i) => {
970
+ const emphasis = c.meta._emphasis ?? 1;
971
+ return {
972
+ ...c,
973
+ effectiveStaleness: c.actualStaleness *
974
+ Math.pow(normalizedDepths[i] + 1, depthWeight * emphasis),
975
+ };
976
+ });
977
+ }
978
+
979
+ /**
980
+ * Compute a structure hash from a sorted file listing.
981
+ *
982
+ * Used to detect when directory structure changes, triggering
983
+ * an architect re-run.
984
+ *
985
+ * @module structureHash
986
+ */
987
+ /**
988
+ * Compute a SHA-256 hash of a sorted file listing.
989
+ *
990
+ * @param filePaths - Array of file paths in scope.
991
+ * @returns Hex-encoded SHA-256 hash of the sorted, newline-joined paths.
992
+ */
993
+ function computeStructureHash(filePaths) {
994
+ const sorted = [...filePaths].sort();
995
+ const content = sorted.join('\n');
996
+ return createHash('sha256').update(content).digest('hex');
997
+ }
998
+
999
+ /**
1000
+ * Parse subprocess outputs for each synthesis step.
1001
+ *
1002
+ * - Architect: returns text \> _builder
1003
+ * - Builder: returns JSON \> _content + structured fields
1004
+ * - Critic: returns text \> _feedback
1005
+ *
1006
+ * @module orchestrator/parseOutput
1007
+ */
1008
+ /**
1009
+ * Parse architect output. The architect returns a task brief as text.
1010
+ *
1011
+ * @param output - Raw subprocess output.
1012
+ * @returns The task brief string.
1013
+ */
1014
+ function parseArchitectOutput(output) {
1015
+ return output.trim();
1016
+ }
1017
+ /**
1018
+ * Parse builder output. The builder returns JSON with _content and optional fields.
1019
+ *
1020
+ * Attempts JSON parse first. If that fails, treats the entire output as _content.
1021
+ *
1022
+ * @param output - Raw subprocess output.
1023
+ * @returns Parsed builder output with content and structured fields.
1024
+ */
1025
+ function parseBuilderOutput(output) {
1026
+ const trimmed = output.trim();
1027
+ // Try to extract JSON from the output (may be wrapped in markdown code fences)
1028
+ let jsonStr = trimmed;
1029
+ const fenceMatch = /```(?:json)?\s*([\s\S]*?)```/.exec(trimmed);
1030
+ if (fenceMatch) {
1031
+ jsonStr = fenceMatch[1].trim();
1032
+ }
1033
+ try {
1034
+ const parsed = JSON.parse(jsonStr);
1035
+ // Extract _content
1036
+ const content = typeof parsed._content === 'string'
1037
+ ? parsed._content
1038
+ : typeof parsed.content === 'string'
1039
+ ? parsed.content
1040
+ : trimmed;
1041
+ // Extract non-underscore fields
1042
+ const fields = {};
1043
+ for (const [key, value] of Object.entries(parsed)) {
1044
+ if (!key.startsWith('_') && key !== 'content') {
1045
+ fields[key] = value;
1046
+ }
1047
+ }
1048
+ return { content, fields };
1049
+ }
1050
+ catch {
1051
+ // Not valid JSON — treat entire output as content
1052
+ return { content: trimmed, fields: {} };
1053
+ }
1054
+ }
1055
+ /**
1056
+ * Parse critic output. The critic returns evaluation text.
1057
+ *
1058
+ * @param output - Raw subprocess output.
1059
+ * @returns The feedback string.
1060
+ */
1061
+ function parseCriticOutput(output) {
1062
+ return output.trim();
1063
+ }
1064
+
1065
+ /**
1066
+ * Main orchestration function — the 13-step synthesis cycle.
1067
+ *
1068
+ * Wires together discovery, scheduling, archiving, executor calls,
1069
+ * and merge/write-back.
1070
+ *
1071
+ * @module orchestrator/orchestrate
1072
+ */
1073
+ /** Normalize path separators to forward slashes. */
1074
+ function normalizePath(p) {
1075
+ return p.replaceAll('\\', '/');
1076
+ }
1077
+ /** Finalize a cycle: merge, snapshot, prune. */
1078
+ function finalizeCycle(metaPath, current, config, architect, builder, critic, builderOutput, feedback, structureHash, synthesisCount, error, architectTokens, builderTokens, criticTokens) {
1079
+ const updated = mergeAndWrite({
1080
+ metaPath,
1081
+ current,
1082
+ architect,
1083
+ builder,
1084
+ critic,
1085
+ builderOutput,
1086
+ feedback,
1087
+ structureHash,
1088
+ synthesisCount,
1089
+ error,
1090
+ architectTokens,
1091
+ builderTokens,
1092
+ criticTokens,
1093
+ });
1094
+ createSnapshot(metaPath, updated);
1095
+ pruneArchive(metaPath, config.maxArchive);
1096
+ return updated;
1097
+ }
1098
+ /**
1099
+ * Run a single synthesis cycle.
1100
+ *
1101
+ * Discovers all metas, selects the stalest candidate, and runs the
1102
+ * three-step synthesis (architect, builder, critic).
1103
+ *
1104
+ * @param config - Validated synthesis config.
1105
+ * @param executor - Pluggable LLM executor.
1106
+ * @param watcher - Watcher HTTP client.
1107
+ * @returns Result indicating whether synthesis occurred.
1108
+ */
1109
+ async function orchestrateOnce(config, executor, watcher) {
1110
+ // Step 1: Discover
1111
+ const metaPaths = globMetas(config.watchPaths);
1112
+ if (metaPaths.length === 0)
1113
+ return { synthesized: false };
1114
+ // Ensure all meta.json files exist
1115
+ const metas = new Map();
1116
+ for (const mp of metaPaths) {
1117
+ metas.set(normalizePath(mp), ensureMetaJson(mp));
1118
+ }
1119
+ const tree = buildOwnershipTree(metaPaths);
1120
+ // Steps 3-4: Staleness check + candidate selection
1121
+ const candidates = [];
1122
+ for (const node of tree.nodes.values()) {
1123
+ const meta = metas.get(node.metaPath);
1124
+ const staleness = actualStaleness(meta);
1125
+ if (staleness > 0) {
1126
+ candidates.push({ node, meta, actualStaleness: staleness });
1127
+ }
1128
+ }
1129
+ const weighted = computeEffectiveStaleness(candidates, config.depthWeight);
1130
+ // Sort by effective staleness descending
1131
+ const ranked = [...weighted].sort((a, b) => b.effectiveStaleness - a.effectiveStaleness);
1132
+ if (ranked.length === 0)
1133
+ return { synthesized: false };
1134
+ // Find the first candidate with actual changes (if skipUnchanged)
1135
+ let winner = null;
1136
+ for (const candidate of ranked) {
1137
+ if (!acquireLock(candidate.node.metaPath))
1138
+ continue;
1139
+ const verifiedStale = await isStale(getScopePrefix(candidate.node), candidate.meta, watcher);
1140
+ if (!verifiedStale && candidate.meta._generatedAt) {
1141
+ // Bump _generatedAt so it doesn't win next cycle
1142
+ const metaFilePath = join(candidate.node.metaPath, 'meta.json');
1143
+ const freshMeta = JSON.parse(readFileSync(metaFilePath, 'utf8'));
1144
+ freshMeta._generatedAt = new Date().toISOString();
1145
+ writeFileSync(metaFilePath, JSON.stringify(freshMeta, null, 2));
1146
+ releaseLock(candidate.node.metaPath);
1147
+ if (config.skipUnchanged)
1148
+ continue;
1149
+ return { synthesized: false };
1150
+ }
1151
+ winner = candidate;
1152
+ break;
1153
+ }
1154
+ if (!winner)
1155
+ return { synthesized: false };
1156
+ const { node } = winner;
1157
+ try {
1158
+ // Re-read meta after lock (may have changed)
1159
+ const currentMeta = JSON.parse(readFileSync(join(node.metaPath, 'meta.json'), 'utf8'));
1160
+ const architectPrompt = currentMeta._architect ?? config.defaultArchitect;
1161
+ const criticPrompt = currentMeta._critic ?? config.defaultCritic;
1162
+ // Step 5: Structure hash
1163
+ const scopePrefix = getScopePrefix(node);
1164
+ const allScanFiles = await paginatedScan(watcher, {
1165
+ pathPrefix: scopePrefix,
1166
+ });
1167
+ const allFilePaths = allScanFiles.map((f) => f.file_path);
1168
+ // Structure hash uses scope-filtered files (excluding child subtrees)
1169
+ // so changes in child scopes don't trigger parent architect re-runs
1170
+ const scopeFiles = filterInScope(node, allFilePaths);
1171
+ const newStructureHash = computeStructureHash(scopeFiles);
1172
+ const structureChanged = newStructureHash !== currentMeta._structureHash;
1173
+ // Step 6: Steer change detection
1174
+ const latestArchive = readLatestArchive(node.metaPath);
1175
+ const steerChanged = latestArchive
1176
+ ? currentMeta._steer !== latestArchive._steer
1177
+ : Boolean(currentMeta._steer);
1178
+ // Step 7: Compute context
1179
+ const ctx = await buildContextPackage(node, currentMeta, watcher);
1180
+ // Step 8: Architect (conditional)
1181
+ const architectTriggered = !currentMeta._builder ||
1182
+ structureChanged ||
1183
+ steerChanged ||
1184
+ (currentMeta._synthesisCount ?? 0) >= config.architectEvery;
1185
+ let builderBrief = currentMeta._builder ?? '';
1186
+ let synthesisCount = currentMeta._synthesisCount ?? 0;
1187
+ let stepError = null;
1188
+ let architectTokens;
1189
+ let builderTokens;
1190
+ let criticTokens;
1191
+ if (architectTriggered) {
1192
+ try {
1193
+ const architectTask = buildArchitectTask(ctx, currentMeta, config);
1194
+ const architectResult = await executor.spawn(architectTask, {
1195
+ timeout: config.architectTimeout,
1196
+ });
1197
+ builderBrief = parseArchitectOutput(architectResult.output);
1198
+ architectTokens = architectResult.tokens;
1199
+ synthesisCount = 0;
1200
+ }
1201
+ catch (err) {
1202
+ stepError = toSynthError('architect', err);
1203
+ if (!currentMeta._builder) {
1204
+ // No cached builder — cycle fails
1205
+ finalizeCycle(node.metaPath, currentMeta, config, architectPrompt, '', criticPrompt, null, null, newStructureHash, synthesisCount, stepError, architectTokens);
1206
+ return {
1207
+ synthesized: true,
1208
+ metaPath: node.metaPath,
1209
+ error: stepError,
1210
+ };
1211
+ }
1212
+ // Has cached builder — continue with existing
1213
+ }
1214
+ }
1215
+ // Step 9: Builder
1216
+ const metaForBuilder = { ...currentMeta, _builder: builderBrief };
1217
+ let builderOutput = null;
1218
+ try {
1219
+ const builderTask = buildBuilderTask(ctx, metaForBuilder, config);
1220
+ const builderResult = await executor.spawn(builderTask, {
1221
+ timeout: config.builderTimeout,
1222
+ });
1223
+ builderOutput = parseBuilderOutput(builderResult.output);
1224
+ builderTokens = builderResult.tokens;
1225
+ synthesisCount++;
1226
+ }
1227
+ catch (err) {
1228
+ stepError = toSynthError('builder', err);
1229
+ return { synthesized: true, metaPath: node.metaPath, error: stepError };
1230
+ }
1231
+ // Step 10: Critic
1232
+ const metaForCritic = {
1233
+ ...currentMeta,
1234
+ _content: builderOutput.content,
1235
+ };
1236
+ let feedback = null;
1237
+ try {
1238
+ const criticTask = buildCriticTask(ctx, metaForCritic, config);
1239
+ const criticResult = await executor.spawn(criticTask, {
1240
+ timeout: config.criticTimeout,
1241
+ });
1242
+ feedback = parseCriticOutput(criticResult.output);
1243
+ criticTokens = criticResult.tokens;
1244
+ stepError = null; // Clear any architect error on full success
1245
+ }
1246
+ catch (err) {
1247
+ stepError = stepError ?? toSynthError('critic', err);
1248
+ }
1249
+ // Steps 11-12: Merge, archive, prune
1250
+ finalizeCycle(node.metaPath, currentMeta, config, architectPrompt, builderBrief, criticPrompt, builderOutput, feedback, newStructureHash, synthesisCount, stepError, architectTokens, builderTokens, criticTokens);
1251
+ return {
1252
+ synthesized: true,
1253
+ metaPath: node.metaPath,
1254
+ error: stepError ?? undefined,
1255
+ };
1256
+ }
1257
+ finally {
1258
+ // Step 13: Release lock
1259
+ releaseLock(node.metaPath);
1260
+ }
1261
+ }
1262
+ /**
1263
+ * Run synthesis cycles up to batchSize.
1264
+ *
1265
+ * Calls orchestrateOnce() in a loop, stopping when batchSize is reached
1266
+ * or no more candidates are available.
1267
+ *
1268
+ * @param config - Validated synthesis config.
1269
+ * @param executor - Pluggable LLM executor.
1270
+ * @param watcher - Watcher HTTP client.
1271
+ * @returns Array of results, one per cycle attempted.
1272
+ */
1273
+ async function orchestrate(config, executor, watcher) {
1274
+ const results = [];
1275
+ for (let i = 0; i < config.batchSize; i++) {
1276
+ const result = await orchestrateOnce(config, executor, watcher);
1277
+ results.push(result);
1278
+ if (!result.synthesized)
1279
+ break; // No more candidates
1280
+ }
1281
+ return results;
1282
+ }
1283
+
1284
+ /**
1285
+ * Factory for creating a bound synthesis engine.
1286
+ *
1287
+ * @module engine
1288
+ */
1289
+ /**
1290
+ * Create a synthesis engine with bound config, executor, and watcher client.
1291
+ *
1292
+ * @param config - Validated synthesis config.
1293
+ * @param executor - Pluggable LLM executor.
1294
+ * @param watcher - Watcher HTTP client.
1295
+ * @returns A bound engine instance.
1296
+ */
1297
+ function createSynthEngine(config, executor, watcher) {
1298
+ return {
1299
+ config,
1300
+ synthesize() {
1301
+ return orchestrate(config, executor, watcher);
1302
+ },
1303
+ synthesizePath(ownerPath) {
1304
+ const scopedConfig = { ...config, watchPaths: [ownerPath] };
1305
+ return orchestrate(scopedConfig, executor, watcher);
1306
+ },
1307
+ };
1308
+ }
1309
+
1310
+ /**
1311
+ * HTTP implementation of the WatcherClient interface.
1312
+ *
1313
+ * Talks to jeeves-watcher's POST /scan and POST /rules endpoints
1314
+ * with retry and exponential backoff.
1315
+ *
1316
+ * @module watcher-client/HttpWatcherClient
1317
+ */
1318
+ /** Default retry configuration. */
1319
+ const DEFAULT_MAX_RETRIES = 3;
1320
+ const DEFAULT_BACKOFF_BASE_MS = 1000;
1321
+ const DEFAULT_BACKOFF_FACTOR = 4;
1322
+ /** Sleep for a given number of milliseconds. */
1323
+ function sleep(ms) {
1324
+ return new Promise((resolve) => setTimeout(resolve, ms));
1325
+ }
1326
+ /** Check if an error is transient (worth retrying). */
1327
+ function isTransient(status) {
1328
+ return status >= 500 || status === 408 || status === 429;
1329
+ }
1330
+ /**
1331
+ * HTTP-based WatcherClient implementation with retry.
1332
+ */
1333
+ class HttpWatcherClient {
1334
+ baseUrl;
1335
+ maxRetries;
1336
+ backoffBaseMs;
1337
+ backoffFactor;
1338
+ constructor(options) {
1339
+ this.baseUrl = options.baseUrl.replace(/\/+$/, '');
1340
+ this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
1341
+ this.backoffBaseMs = options.backoffBaseMs ?? DEFAULT_BACKOFF_BASE_MS;
1342
+ this.backoffFactor = options.backoffFactor ?? DEFAULT_BACKOFF_FACTOR;
1343
+ }
1344
+ /** POST JSON with retry. */
1345
+ async post(endpoint, body) {
1346
+ const url = this.baseUrl + endpoint;
1347
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
1348
+ const res = await fetch(url, {
1349
+ method: 'POST',
1350
+ headers: { 'Content-Type': 'application/json' },
1351
+ body: JSON.stringify(body),
1352
+ });
1353
+ if (res.ok) {
1354
+ return res.json();
1355
+ }
1356
+ if (!isTransient(res.status) || attempt === this.maxRetries) {
1357
+ const text = await res.text();
1358
+ throw new Error(`Watcher ${endpoint} failed: HTTP ${res.status.toString()} - ${text}`);
1359
+ }
1360
+ // Exponential backoff
1361
+ const delayMs = this.backoffBaseMs * Math.pow(this.backoffFactor, attempt);
1362
+ await sleep(delayMs);
1363
+ }
1364
+ // Unreachable, but TypeScript needs it
1365
+ throw new Error('Retry exhausted');
1366
+ }
1367
+ async scan(params) {
1368
+ const body = {
1369
+ pathPrefix: params.pathPrefix,
1370
+ };
1371
+ if (params.modifiedAfter !== undefined) {
1372
+ body.modifiedAfter = params.modifiedAfter;
1373
+ }
1374
+ if (params.fields !== undefined) {
1375
+ body.fields = params.fields;
1376
+ }
1377
+ if (params.limit !== undefined) {
1378
+ body.limit = params.limit;
1379
+ }
1380
+ if (params.cursor !== undefined) {
1381
+ body.cursor = params.cursor;
1382
+ }
1383
+ const result = await this.post('/scan', body);
1384
+ return result;
1385
+ }
1386
+ async registerRules(source, rules) {
1387
+ await this.post('/rules/register', { source, rules });
1388
+ }
1389
+ async unregisterRules(source) {
1390
+ await this.post('/rules/unregister', { source });
1391
+ }
1392
+ }
1393
+
1394
+ export { HttpWatcherClient, acquireLock, actualStaleness, buildArchitectTask, buildBuilderTask, buildContextPackage, buildCriticTask, buildOwnershipTree, computeEffectiveStaleness, computeEma, computeStructureHash, createSnapshot, createSynthEngine, ensureMetaJson, filterInScope, getScopeExclusions, getScopePrefix, globMetas, isLocked, isStale, listArchiveFiles, mergeAndWrite, metaJsonSchema, orchestrate, paginatedScan, parseArchitectOutput, parseBuilderOutput, parseCriticOutput, pruneArchive, readLatestArchive, releaseLock, selectCandidate, synthConfigSchema, synthErrorSchema, toSynthError };