@karmaniverous/jeeves-meta 0.3.2 → 0.4.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 CHANGED
@@ -1,7 +1,10 @@
1
- import { readdirSync, unlinkSync, readFileSync, mkdirSync, writeFileSync, existsSync } from 'node:fs';
2
- import { join, dirname, relative, sep } from 'node:path';
1
+ import { readdirSync, unlinkSync, readFileSync, mkdirSync, writeFileSync, existsSync, copyFileSync, watchFile } from 'node:fs';
2
+ import { join, dirname, relative } from 'node:path';
3
3
  import { z } from 'zod';
4
- import { createHash } from 'node:crypto';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+ import pino from 'pino';
6
+ import { Cron } from 'croner';
7
+ import Fastify from 'fastify';
5
8
 
6
9
  /**
7
10
  * List archive snapshot files in chronological order.
@@ -100,20 +103,18 @@ function createSnapshot(metaPath, meta) {
100
103
  }
101
104
 
102
105
  /**
103
- * Zod schema for jeeves-meta configuration.
106
+ * Zod schema for jeeves-meta service configuration.
104
107
  *
105
- * Consumers load config however they want (file, env, constructor).
106
- * The library validates via this schema.
108
+ * The service config is a strict superset of the core (library-compatible) meta config.
107
109
  *
108
110
  * @module schema/config
109
111
  */
110
- /** Zod schema for jeeves-meta configuration. */
111
- const synthConfigSchema = z.object({
112
- /** Filesystem paths to watch for .meta/ directories. */
112
+ /** Zod schema for the core (library-compatible) meta configuration. */
113
+ const metaConfigSchema = z.object({
113
114
  /** Watcher service base URL. */
114
115
  watcherUrl: z.url(),
115
116
  /** OpenClaw gateway base URL for subprocess spawning. */
116
- gatewayUrl: z.url().default('http://127.0.0.1:3000'),
117
+ gatewayUrl: z.url().default('http://127.0.0.1:18789'),
117
118
  /** Optional API key for gateway authentication. */
118
119
  gatewayApiKey: z.string().optional(),
119
120
  /** Run architect every N cycles (per meta). */
@@ -130,134 +131,75 @@ const synthConfigSchema = z.object({
130
131
  builderTimeout: z.number().int().min(60).default(600),
131
132
  /** Critic subprocess timeout in seconds. */
132
133
  criticTimeout: z.number().int().min(30).default(300),
134
+ /** Thinking level for spawned synthesis sessions. */
135
+ thinking: z.string().default('low'),
133
136
  /** Resolved architect system prompt text. */
134
137
  defaultArchitect: z.string(),
135
138
  /** Resolved critic system prompt text. */
136
139
  defaultCritic: z.string(),
137
- /**
138
- * When true, skip unchanged candidates and iterate to the next-stalest
139
- * until finding one with actual changes. Skipped candidates get their
140
- * _generatedAt bumped to prevent re-selection next cycle.
141
- */
140
+ /** Skip unchanged candidates, bump _generatedAt. */
142
141
  skipUnchanged: z.boolean().default(true),
143
- /** Number of metas to synthesize per invocation. */
144
- batchSize: z.number().int().min(1).default(1),
145
- /**
146
- * Watcher metadata properties for live .meta/meta.json files.
147
- * Virtual rules use these to tag live metas; scan queries derive
148
- * their filter from the first domain value.
149
- */
150
- metaProperty: z
151
- .object({ domains: z.array(z.string()).min(1) })
152
- .default({ domains: ['meta'] }),
153
- /**
154
- * Watcher metadata properties for .meta/archive/** snapshots.
155
- * Virtual rules use these to tag archive files.
156
- */
142
+ /** Watcher metadata properties applied to live .meta/meta.json files. */
143
+ metaProperty: z.record(z.string(), z.unknown()).default({ _meta: 'current' }),
144
+ /** Watcher metadata properties applied to archive snapshots. */
157
145
  metaArchiveProperty: z
158
- .object({ domains: z.array(z.string()).min(1) })
159
- .default({ domains: ['meta-archive'] }),
146
+ .record(z.string(), z.unknown())
147
+ .default({ _meta: 'archive' }),
160
148
  });
161
-
162
- /**
163
- * Structured error from a synthesis step failure.
164
- *
165
- * @module schema/error
166
- */
167
- /** Zod schema for synthesis step errors. */
168
- const synthErrorSchema = z.object({
169
- /** Which step failed: 'architect', 'builder', or 'critic'. */
170
- step: z.enum(['architect', 'builder', 'critic']),
171
- /** Error classification code. */
172
- code: z.string(),
173
- /** Human-readable error message. */
174
- message: z.string(),
149
+ /** Zod schema for logging configuration. */
150
+ const loggingSchema = z.object({
151
+ /** Log level. */
152
+ level: z.string().default('info'),
153
+ /** Optional file path for log output. */
154
+ file: z.string().optional(),
155
+ });
156
+ /** Zod schema for jeeves-meta service configuration (superset of MetaConfig). */
157
+ const serviceConfigSchema = metaConfigSchema.extend({
158
+ /** HTTP port for the service (default: 1938). */
159
+ port: z.number().int().min(1).max(65535).default(1938),
160
+ /** Cron schedule for synthesis cycles (default: every 30 min). */
161
+ schedule: z.string().default('*/30 * * * *'),
162
+ /** Optional channel identifier for reporting. */
163
+ reportChannel: z.string().optional(),
164
+ /** Logging configuration. */
165
+ logging: loggingSchema.default(() => loggingSchema.parse({})),
175
166
  });
176
167
 
177
168
  /**
178
- * Zod schema for .meta/meta.json files.
169
+ * Load and resolve jeeves-meta service config.
179
170
  *
180
- * Reserved properties are underscore-prefixed and engine-managed.
181
- * All other keys are open schema (builder output).
171
+ * Supports \@file: indirection and environment-variable substitution (dollar-brace pattern).
182
172
  *
183
- * @module schema/meta
173
+ * @module configLoader
184
174
  */
185
- /** Zod schema for the reserved (underscore-prefixed) meta.json properties. */
186
- const metaJsonSchema = z
187
- .object({
188
- /** Stable identity. Generated on first synthesis, never changes. */
189
- _id: z.uuid(),
190
- /** Human-provided steering prompt. Optional. */
191
- _steer: z.string().optional(),
192
- /** Architect system prompt used this turn. Defaults from config. */
193
- _architect: z.string().optional(),
194
- /**
195
- * Task brief generated by the architect. Cached and reused across cycles;
196
- * regenerated only when triggered.
197
- */
198
- _builder: z.string().optional(),
199
- /** Critic system prompt used this turn. Defaults from config. */
200
- _critic: z.string().optional(),
201
- /** Timestamp of last synthesis. ISO 8601. */
202
- _generatedAt: z.iso.datetime().optional(),
203
- /** Narrative synthesis output. Rendered by watcher for embedding. */
204
- _content: z.string().optional(),
205
- /**
206
- * Hash of sorted file listing in scope. Detects directory structure
207
- * changes that trigger an architect re-run.
208
- */
209
- _structureHash: z.string().optional(),
210
- /**
211
- * Cycles since last architect run. Reset to 0 when architect runs.
212
- * Used with architectEvery to trigger periodic re-prompting.
213
- */
214
- _synthesisCount: z.number().int().min(0).optional(),
215
- /** Critic evaluation of the last synthesis. */
216
- _feedback: z.string().optional(),
217
- /**
218
- * Present and true on archive snapshots. Distinguishes live vs. archived
219
- * metas.
220
- */
221
- _archived: z.boolean().optional(),
222
- /** Timestamp when this snapshot was archived. ISO 8601. */
223
- _archivedAt: z.iso.datetime().optional(),
224
- /**
225
- * Scheduling priority. Higher = updates more often. Negative allowed;
226
- * normalized to min 0 at scheduling time.
227
- */
228
- _depth: z.number().optional(),
229
- /**
230
- * Emphasis multiplier for depth weighting in scheduling.
231
- * Default 1. Higher values increase this meta's scheduling priority
232
- * relative to its depth. Set to 0.5 to halve the depth effect,
233
- * 2 to double it, 0 to ignore depth entirely for this meta.
234
- */
235
- _emphasis: z.number().min(0).optional(),
236
- /** Token count from last architect subprocess call. */
237
- _architectTokens: z.number().int().optional(),
238
- /** Token count from last builder subprocess call. */
239
- _builderTokens: z.number().int().optional(),
240
- /** Token count from last critic subprocess call. */
241
- _criticTokens: z.number().int().optional(),
242
- /** Exponential moving average of architect token usage (decay 0.3). */
243
- _architectTokensAvg: z.number().optional(),
244
- /** Exponential moving average of builder token usage (decay 0.3). */
245
- _builderTokensAvg: z.number().optional(),
246
- /** Exponential moving average of critic token usage (decay 0.3). */
247
- _criticTokensAvg: z.number().optional(),
248
- /**
249
- * Structured error from last cycle. Present when a step failed.
250
- * Cleared on successful cycle.
251
- */
252
- _error: synthErrorSchema.optional(),
253
- })
254
- .loose();
255
-
256
175
  /**
257
- * Load and resolve jeeves-meta config with \@file: indirection.
176
+ * Deep-walk a value, replacing `\${VAR\}` patterns with process.env values.
258
177
  *
259
- * @module configLoader
178
+ * @param value - Arbitrary JSON-compatible value.
179
+ * @returns Value with env-var placeholders resolved.
260
180
  */
181
+ function substituteEnvVars(value) {
182
+ if (typeof value === 'string') {
183
+ return value.replace(/\$\{([^}]+)\}/g, (_match, name) => {
184
+ const envVal = process.env[name];
185
+ if (envVal === undefined) {
186
+ throw new Error(`Environment variable ${name} is not set`);
187
+ }
188
+ return envVal;
189
+ });
190
+ }
191
+ if (Array.isArray(value)) {
192
+ return value.map(substituteEnvVars);
193
+ }
194
+ if (value !== null && typeof value === 'object') {
195
+ const result = {};
196
+ for (const [key, val] of Object.entries(value)) {
197
+ result[key] = substituteEnvVars(val);
198
+ }
199
+ return result;
200
+ }
201
+ return value;
202
+ }
261
203
  /**
262
204
  * Resolve \@file: references in a config value.
263
205
  *
@@ -271,23 +213,6 @@ function resolveFileRef(value, baseDir) {
271
213
  const filePath = join(baseDir, value.slice(6));
272
214
  return readFileSync(filePath, 'utf8');
273
215
  }
274
- /**
275
- * Load synth config from a JSON file, resolving \@file: references.
276
- *
277
- * @param configPath - Path to jeeves-meta.config.json.
278
- * @returns Validated SynthConfig with resolved prompt strings.
279
- */
280
- function loadSynthConfig(configPath) {
281
- const raw = JSON.parse(readFileSync(configPath, 'utf8'));
282
- const baseDir = dirname(configPath);
283
- if (typeof raw.defaultArchitect === 'string') {
284
- raw.defaultArchitect = resolveFileRef(raw.defaultArchitect, baseDir);
285
- }
286
- if (typeof raw.defaultCritic === 'string') {
287
- raw.defaultCritic = resolveFileRef(raw.defaultCritic, baseDir);
288
- }
289
- return synthConfigSchema.parse(raw);
290
- }
291
216
  /**
292
217
  * Resolve config path from --config flag or JEEVES_META_CONFIG env var.
293
218
  *
@@ -296,17 +221,38 @@ function loadSynthConfig(configPath) {
296
221
  * @throws If no config path found.
297
222
  */
298
223
  function resolveConfigPath(args) {
299
- // Check --config flag
300
- const configIdx = args.indexOf('--config');
224
+ let configIdx = args.indexOf('--config');
225
+ if (configIdx === -1)
226
+ configIdx = args.indexOf('-c');
301
227
  if (configIdx !== -1 && args[configIdx + 1]) {
302
228
  return args[configIdx + 1];
303
229
  }
304
- // Check env var
305
230
  const envPath = process.env['JEEVES_META_CONFIG'];
306
231
  if (envPath)
307
232
  return envPath;
308
233
  throw new Error('Config path required. Use --config <path> or set JEEVES_META_CONFIG env var.');
309
234
  }
235
+ /**
236
+ * Load service config from a JSON file.
237
+ *
238
+ * Resolves \@file: references for defaultArchitect and defaultCritic,
239
+ * and substitutes environment-variable placeholders throughout.
240
+ *
241
+ * @param configPath - Path to config JSON file.
242
+ * @returns Validated ServiceConfig.
243
+ */
244
+ function loadServiceConfig(configPath) {
245
+ const rawText = readFileSync(configPath, 'utf8');
246
+ const raw = substituteEnvVars(JSON.parse(rawText));
247
+ const baseDir = dirname(configPath);
248
+ if (typeof raw['defaultArchitect'] === 'string') {
249
+ raw['defaultArchitect'] = resolveFileRef(raw['defaultArchitect'], baseDir);
250
+ }
251
+ if (typeof raw['defaultCritic'] === 'string') {
252
+ raw['defaultCritic'] = resolveFileRef(raw['defaultCritic'], baseDir);
253
+ }
254
+ return serviceConfigSchema.parse(raw);
255
+ }
310
256
 
311
257
  /**
312
258
  * Normalize file paths to forward slashes for consistency with watcher-indexed paths.
@@ -323,7 +269,7 @@ function resolveConfigPath(args) {
323
269
  * @param p - File path (may contain backslashes).
324
270
  * @returns Path with all backslashes replaced by forward slashes.
325
271
  */
326
- function normalizePath$1(p) {
272
+ function normalizePath(p) {
327
273
  return p.replaceAll('\\', '/');
328
274
  }
329
275
 
@@ -358,25 +304,50 @@ async function paginatedScan(watcher, params) {
358
304
  *
359
305
  * @module discovery/discoverMetas
360
306
  */
307
+ /**
308
+ * Build a single Qdrant filter clause from a key-value pair.
309
+ *
310
+ * Arrays use `match.value` on the first element (Qdrant array membership).
311
+ * Scalars (string, number, boolean) use `match.value` directly.
312
+ * Objects and other non-filterable types are skipped with a warning.
313
+ */
314
+ function buildMatchClause(key, value) {
315
+ if (Array.isArray(value)) {
316
+ if (value.length === 0)
317
+ return null;
318
+ return { key, match: { value: value[0] } };
319
+ }
320
+ if (typeof value === 'string' ||
321
+ typeof value === 'number' ||
322
+ typeof value === 'boolean') {
323
+ return { key, match: { value } };
324
+ }
325
+ // Non-filterable value (object, null, etc.) — valid for tagging but
326
+ // cannot be expressed as a Qdrant match clause.
327
+ return null;
328
+ }
361
329
  /**
362
330
  * Build a Qdrant filter from config metaProperty.
363
331
  *
364
- * @param config - Synth config with metaProperty.
332
+ * Iterates all key-value pairs in `metaProperty` (a generic record)
333
+ * to construct `must` clauses. Always appends `file_path: meta.json`
334
+ * for deduplication.
335
+ *
336
+ * @param config - Meta config with metaProperty.
365
337
  * @returns Qdrant filter object for scanning live metas.
366
338
  */
367
339
  function buildMetaFilter(config) {
368
- return {
369
- must: [
370
- {
371
- key: 'domains',
372
- match: { value: config.metaProperty.domains[0] },
373
- },
374
- {
375
- key: 'file_path',
376
- match: { text: 'meta.json' },
377
- },
378
- ],
379
- };
340
+ const must = [];
341
+ for (const [key, value] of Object.entries(config.metaProperty)) {
342
+ const clause = buildMatchClause(key, value);
343
+ if (clause)
344
+ must.push(clause);
345
+ }
346
+ must.push({
347
+ key: 'file_path',
348
+ match: { text: '.meta/meta.json' },
349
+ });
350
+ return { must };
380
351
  }
381
352
  /**
382
353
  * Discover all .meta/ directories via watcher scan.
@@ -384,7 +355,7 @@ function buildMetaFilter(config) {
384
355
  * Queries the watcher for indexed .meta/meta.json points using the
385
356
  * configured domain filter. Returns deduplicated meta directory paths.
386
357
  *
387
- * @param config - Synth config (for domain filter).
358
+ * @param config - Meta config (for domain filter).
388
359
  * @param watcher - WatcherClient for scan queries.
389
360
  * @returns Array of normalized .meta/ directory paths.
390
361
  */
@@ -398,7 +369,7 @@ async function discoverMetas(config, watcher) {
398
369
  const seen = new Set();
399
370
  const metaPaths = [];
400
371
  for (const sf of scanFiles) {
401
- const fp = normalizePath$1(sf.file_path);
372
+ const fp = normalizePath(sf.file_path);
402
373
  // Derive .meta/ directory from file_path (strip /meta.json)
403
374
  const metaPath = fp.replace(/\/meta\.json$/, '');
404
375
  if (seen.has(metaPath))
@@ -412,38 +383,75 @@ async function discoverMetas(config, watcher) {
412
383
  /**
413
384
  * File-system lock for preventing concurrent synthesis on the same meta.
414
385
  *
415
- * Lock file: .meta/.lock containing PID + timestamp.
386
+ * Lock file: .meta/.lock containing `_lockPid` + `_lockStartedAt` (underscore-prefixed
387
+ * reserved keys, consistent with meta.json conventions).
416
388
  * Stale timeout: 30 minutes.
417
389
  *
418
390
  * @module lock
419
391
  */
420
392
  const LOCK_FILE = '.lock';
393
+ /**
394
+ * Resolve a path to a .meta directory.
395
+ *
396
+ * If the path already ends with '.meta', returns it as-is.
397
+ * Otherwise, appends '.meta' as a subdirectory.
398
+ *
399
+ * @param inputPath - Path that may or may not end with '.meta'.
400
+ * @returns The resolved .meta directory path.
401
+ */
402
+ function resolveMetaDir(inputPath) {
403
+ return inputPath.endsWith('.meta') ? inputPath : join(inputPath, '.meta');
404
+ }
421
405
  const STALE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
422
406
  /**
423
- * Attempt to acquire a lock on a .meta directory.
407
+ * Read and classify the state of a .meta/.lock file.
424
408
  *
425
409
  * @param metaPath - Absolute path to the .meta directory.
426
- * @returns True if lock was acquired, false if already locked (non-stale).
410
+ * @returns Parsed lock state.
427
411
  */
428
- function acquireLock(metaPath) {
412
+ function readLockState(metaPath) {
429
413
  const lockPath = join(metaPath, LOCK_FILE);
430
- if (existsSync(lockPath)) {
431
- try {
432
- const raw = readFileSync(lockPath, 'utf8');
433
- const data = JSON.parse(raw);
434
- const lockAge = Date.now() - new Date(data.startedAt).getTime();
435
- if (lockAge < STALE_TIMEOUT_MS) {
436
- return false; // Lock is active
437
- }
438
- // Stale lock — fall through to overwrite
414
+ if (!existsSync(lockPath)) {
415
+ return { exists: false, staged: false, active: false, data: null };
416
+ }
417
+ try {
418
+ const raw = readFileSync(lockPath, 'utf8');
419
+ const data = JSON.parse(raw);
420
+ if ('_id' in data) {
421
+ return { exists: true, staged: true, active: false, data };
439
422
  }
440
- catch {
441
- // Corrupt lock file — overwrite
423
+ const startedAt = data._lockStartedAt;
424
+ if (startedAt) {
425
+ const lockAge = Date.now() - new Date(startedAt).getTime();
426
+ return {
427
+ exists: true,
428
+ staged: false,
429
+ active: lockAge < STALE_TIMEOUT_MS,
430
+ data,
431
+ };
442
432
  }
433
+ return { exists: true, staged: false, active: false, data };
434
+ }
435
+ catch {
436
+ return { exists: true, staged: false, active: false, data: null };
443
437
  }
438
+ }
439
+ /**
440
+ * Attempt to acquire a lock on a .meta directory.
441
+ *
442
+ * @param metaPath - Absolute path to the .meta directory.
443
+ * @returns True if lock was acquired, false if already locked (non-stale).
444
+ */
445
+ function acquireLock(metaPath) {
446
+ const state = readLockState(metaPath);
447
+ // Active non-stale lock — cannot acquire
448
+ if (state.active)
449
+ return false;
450
+ // Staged, stale, corrupt, or missing — safe to (over)write
451
+ const lockPath = join(metaPath, LOCK_FILE);
444
452
  const lock = {
445
- pid: process.pid,
446
- startedAt: new Date().toISOString(),
453
+ _lockPid: process.pid,
454
+ _lockStartedAt: new Date().toISOString(),
447
455
  };
448
456
  writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n');
449
457
  return true;
@@ -469,17 +477,36 @@ function releaseLock(metaPath) {
469
477
  * @returns True if locked and not stale.
470
478
  */
471
479
  function isLocked(metaPath) {
472
- const lockPath = join(metaPath, LOCK_FILE);
473
- if (!existsSync(lockPath))
474
- return false;
475
- try {
476
- const raw = readFileSync(lockPath, 'utf8');
477
- const data = JSON.parse(raw);
478
- const lockAge = Date.now() - new Date(data.startedAt).getTime();
479
- return lockAge < STALE_TIMEOUT_MS;
480
- }
481
- catch {
482
- return false; // Corrupt lock = not locked
480
+ return readLockState(metaPath).active;
481
+ }
482
+ /**
483
+ * Clean up stale lock files on startup.
484
+ *
485
+ * For each .meta directory found via the provided paths:
486
+ * - If lock contains PID-only data (synthesis incomplete), delete it.
487
+ * - If lock contains staged result (_id present), log warning and delete.
488
+ *
489
+ * @param metaPaths - Array of .meta directory paths to check.
490
+ * @param logger - Optional logger for warnings.
491
+ */
492
+ function cleanupStaleLocks(metaPaths, logger) {
493
+ for (const metaPath of metaPaths) {
494
+ const state = readLockState(metaPath);
495
+ if (!state.exists)
496
+ continue;
497
+ const lockPath = join(metaPath, LOCK_FILE);
498
+ if (state.staged) {
499
+ logger?.warn({ metaPath }, 'Found staged synthesis result in lock file from previous crash — deleting (conservative: not auto-finalizing)');
500
+ }
501
+ else {
502
+ logger?.warn({ metaPath }, 'Found stale lock file from previous crash — deleting');
503
+ }
504
+ try {
505
+ unlinkSync(lockPath);
506
+ }
507
+ catch {
508
+ // Already gone
509
+ }
483
510
  }
484
511
  }
485
512
 
@@ -492,10 +519,6 @@ function isLocked(metaPath) {
492
519
  *
493
520
  * @module discovery/ownershipTree
494
521
  */
495
- /** Normalize path separators to forward slashes for consistent comparison. */
496
- function normalizePath(p) {
497
- return p.split(sep).join('/');
498
- }
499
522
  /**
500
523
  * Build an ownership tree from an array of .meta/ directory paths.
501
524
  *
@@ -610,7 +633,7 @@ async function listMetas(config, watcher) {
610
633
  const depth = meta._depth ?? node.treeDepth;
611
634
  const emphasis = meta._emphasis ?? 1;
612
635
  const hasError = Boolean(meta._error);
613
- const locked = isLocked(normalizePath$1(node.metaPath));
636
+ const locked = isLocked(normalizePath(node.metaPath));
614
637
  const neverSynth = !meta._generatedAt;
615
638
  // Compute staleness
616
639
  let stalenessSeconds;
@@ -741,6 +764,47 @@ function filterInScope(node, files) {
741
764
  return true;
742
765
  });
743
766
  }
767
+ /**
768
+ * Get all files in scope for a meta node via watcher scan.
769
+ *
770
+ * Scans the owner path prefix and filters out child meta subtrees,
771
+ * keeping only files directly owned by this meta.
772
+ *
773
+ * @param node - The meta node.
774
+ * @param watcher - WatcherClient for scan queries.
775
+ * @returns Array of in-scope file paths.
776
+ */
777
+ async function getScopeFiles(node, watcher) {
778
+ const allScanFiles = await paginatedScan(watcher, {
779
+ pathPrefix: node.ownerPath,
780
+ });
781
+ const allFiles = allScanFiles.map((f) => f.file_path);
782
+ return {
783
+ scopeFiles: filterInScope(node, allFiles),
784
+ allFiles,
785
+ };
786
+ }
787
+ /**
788
+ * Get files modified since a given timestamp within a meta node's scope.
789
+ *
790
+ * If no generatedAt is provided (first run), returns all scope files.
791
+ *
792
+ * @param node - The meta node.
793
+ * @param watcher - WatcherClient for scan queries.
794
+ * @param generatedAt - ISO timestamp of last synthesis, or null/undefined for first run.
795
+ * @param scopeFiles - Pre-computed scope files (used as fallback for first run).
796
+ * @returns Array of modified in-scope file paths.
797
+ */
798
+ async function getDeltaFiles(node, watcher, generatedAt, scopeFiles) {
799
+ if (!generatedAt)
800
+ return scopeFiles;
801
+ const modifiedAfter = Math.floor(new Date(generatedAt).getTime() / 1000);
802
+ const deltaScanFiles = await paginatedScan(watcher, {
803
+ pathPrefix: node.ownerPath,
804
+ modifiedAfter,
805
+ });
806
+ return filterInScope(node, deltaScanFiles.map((f) => f.file_path));
807
+ }
744
808
 
745
809
  /**
746
810
  * Exponential moving average helper for token tracking.
@@ -768,14 +832,14 @@ function computeEma(current, previous, decay = DEFAULT_DECAY) {
768
832
  * @module errors
769
833
  */
770
834
  /**
771
- * Wrap an unknown caught value into a SynthError.
835
+ * Wrap an unknown caught value into a MetaError.
772
836
  *
773
837
  * @param step - Which synthesis step failed.
774
838
  * @param err - The caught error value.
775
839
  * @param code - Error classification code.
776
- * @returns A structured SynthError.
840
+ * @returns A structured MetaError.
777
841
  */
778
- function toSynthError(step, err, code = 'FAILED') {
842
+ function toMetaError(step, err, code = 'FAILED') {
779
843
  return {
780
844
  step,
781
845
  code,
@@ -784,122 +848,185 @@ function toSynthError(step, err, code = 'FAILED') {
784
848
  }
785
849
 
786
850
  /**
787
- * SynthExecutor implementation using the OpenClaw gateway HTTP API.
851
+ * Compute a structure hash from a sorted file listing.
852
+ *
853
+ * Used to detect when directory structure changes, triggering
854
+ * an architect re-run.
855
+ *
856
+ * @module structureHash
857
+ */
858
+ /**
859
+ * Compute a SHA-256 hash of a sorted file listing.
860
+ *
861
+ * @param filePaths - Array of file paths in scope.
862
+ * @returns Hex-encoded SHA-256 hash of the sorted, newline-joined paths.
863
+ */
864
+ function computeStructureHash(filePaths) {
865
+ const sorted = [...filePaths].sort();
866
+ const content = sorted.join('\n');
867
+ return createHash('sha256').update(content).digest('hex');
868
+ }
869
+
870
+ /** Sleep for a given number of milliseconds. */
871
+ function sleep(ms) {
872
+ return new Promise((resolve) => setTimeout(resolve, ms));
873
+ }
874
+
875
+ /**
876
+ * MetaExecutor implementation using the OpenClaw gateway HTTP API.
788
877
  *
789
878
  * Lives in the library package so both plugin and runner can import it.
790
- * Spawns sub-agent sessions via the gateway, polls for completion,
791
- * and extracts output text.
879
+ * Spawns sub-agent sessions via the gateway's `/tools/invoke` endpoint,
880
+ * polls for completion, and extracts output text.
792
881
  *
793
882
  * @module executor/GatewayExecutor
794
883
  */
795
884
  const DEFAULT_POLL_INTERVAL_MS = 5000;
796
885
  const DEFAULT_TIMEOUT_MS = 600_000; // 10 minutes
797
- /** Sleep helper. */
798
- function sleep$1(ms) {
799
- return new Promise((resolve) => setTimeout(resolve, ms));
800
- }
801
886
  /**
802
- * SynthExecutor that spawns OpenClaw sessions via the gateway HTTP API.
887
+ * MetaExecutor that spawns OpenClaw sessions via the gateway's
888
+ * `/tools/invoke` endpoint.
803
889
  *
804
890
  * Used by both the OpenClaw plugin (in-process tool calls) and the
805
891
  * runner/CLI (external invocation). Constructs from `gatewayUrl` and
806
- * optional `apiKey` — typically sourced from `SynthConfig`.
892
+ * optional `apiKey` — typically sourced from `MetaConfig`.
807
893
  */
808
894
  class GatewayExecutor {
809
895
  gatewayUrl;
810
896
  apiKey;
811
897
  pollIntervalMs;
812
898
  constructor(options = {}) {
813
- this.gatewayUrl = (options.gatewayUrl ?? 'http://127.0.0.1:3000').replace(/\/+$/, '');
899
+ this.gatewayUrl = (options.gatewayUrl ?? 'http://127.0.0.1:18789').replace(/\/+$/, '');
814
900
  this.apiKey = options.apiKey;
815
901
  this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
816
902
  }
817
- async spawn(task, options) {
818
- const timeoutMs = (options?.timeout ?? DEFAULT_TIMEOUT_MS / 1000) * 1000;
819
- const deadline = Date.now() + timeoutMs;
903
+ /** Invoke a gateway tool via the /tools/invoke HTTP endpoint. */
904
+ async invoke(tool, args) {
820
905
  const headers = {
821
906
  'Content-Type': 'application/json',
822
907
  };
823
908
  if (this.apiKey) {
824
909
  headers['Authorization'] = 'Bearer ' + this.apiKey;
825
910
  }
826
- const spawnRes = await fetch(this.gatewayUrl + '/api/sessions/spawn', {
911
+ const res = await fetch(this.gatewayUrl + '/tools/invoke', {
827
912
  method: 'POST',
828
913
  headers,
829
- body: JSON.stringify({
830
- task,
831
- mode: 'run',
832
- model: options?.model,
833
- runTimeoutSeconds: options?.timeout,
834
- }),
914
+ body: JSON.stringify({ tool, args }),
915
+ });
916
+ if (!res.ok) {
917
+ const text = await res.text();
918
+ throw new Error(`Gateway ${tool} failed: HTTP ${res.status.toString()} - ${text}`);
919
+ }
920
+ const data = (await res.json());
921
+ if (data.ok === false || data.error) {
922
+ throw new Error(`Gateway ${tool} error: ${data.error?.message ?? JSON.stringify(data)}`);
923
+ }
924
+ return data;
925
+ }
926
+ async spawn(task, options) {
927
+ const timeoutSeconds = options?.timeout ?? DEFAULT_TIMEOUT_MS / 1000;
928
+ const timeoutMs = timeoutSeconds * 1000;
929
+ const deadline = Date.now() + timeoutMs;
930
+ // Step 1: Spawn the sub-agent session
931
+ const spawnResult = await this.invoke('sessions_spawn', {
932
+ task,
933
+ label: options?.label ?? 'jeeves-meta-synthesis',
934
+ runTimeoutSeconds: timeoutSeconds,
935
+ ...(options?.thinking ? { thinking: options.thinking } : {}),
936
+ ...(options?.model ? { model: options.model } : {}),
835
937
  });
836
- if (!spawnRes.ok) {
837
- const text = await spawnRes.text();
838
- throw new Error('Gateway spawn failed: HTTP ' +
839
- spawnRes.status.toString() +
840
- ' - ' +
841
- text);
842
- }
843
- const spawnData = (await spawnRes.json());
844
- if (!spawnData.sessionKey) {
845
- throw new Error('Gateway spawn returned no sessionKey: ' + JSON.stringify(spawnData));
846
- }
847
- const { sessionKey } = spawnData;
848
- // Poll for completion
938
+ const details = (spawnResult.result?.details ?? spawnResult.result);
939
+ const sessionKey = details?.childSessionKey ?? details?.sessionKey;
940
+ if (typeof sessionKey !== 'string' || !sessionKey) {
941
+ throw new Error('Gateway sessions_spawn returned no sessionKey: ' +
942
+ JSON.stringify(spawnResult));
943
+ }
944
+ // Step 2: Poll for completion via sessions_history
945
+ await sleep(3000);
849
946
  while (Date.now() < deadline) {
850
- await sleep$1(this.pollIntervalMs);
851
- const historyRes = await fetch(this.gatewayUrl +
852
- '/api/sessions/' +
853
- encodeURIComponent(sessionKey) +
854
- '/history?limit=50', { headers });
855
- if (!historyRes.ok)
856
- continue;
857
- const history = (await historyRes.json());
858
- if (history.status === 'completed' || history.status === 'done') {
859
- // Extract token usage from session-level or message-level usage
860
- let tokens;
861
- if (history.usage?.totalTokens) {
862
- tokens = history.usage.totalTokens;
863
- }
864
- else {
865
- // Sum message-level usage as fallback
866
- let sum = 0;
867
- for (const msg of history.messages ?? []) {
868
- if (msg.usage?.totalTokens)
869
- sum += msg.usage.totalTokens;
870
- }
871
- if (sum > 0)
872
- tokens = sum;
873
- }
874
- // Extract the last assistant message as output
875
- const messages = history.messages ?? [];
876
- for (let i = messages.length - 1; i >= 0; i--) {
877
- if (messages[i].role === 'assistant' && messages[i].content) {
878
- return { output: messages[i].content, tokens };
947
+ try {
948
+ const historyResult = await this.invoke('sessions_history', {
949
+ sessionKey,
950
+ limit: 5,
951
+ includeTools: false,
952
+ });
953
+ const messages = historyResult.result?.details?.messages ??
954
+ historyResult.result?.messages ??
955
+ [];
956
+ const msgArray = messages;
957
+ if (msgArray.length > 0) {
958
+ const lastMsg = msgArray[msgArray.length - 1];
959
+ // Complete when last message is assistant with a terminal stop reason
960
+ if (lastMsg.role === 'assistant' &&
961
+ lastMsg.stopReason &&
962
+ lastMsg.stopReason !== 'toolUse' &&
963
+ lastMsg.stopReason !== 'error') {
964
+ // Sum token usage from all messages
965
+ let tokens;
966
+ let sum = 0;
967
+ for (const msg of msgArray) {
968
+ if (msg.usage?.totalTokens)
969
+ sum += msg.usage.totalTokens;
970
+ }
971
+ if (sum > 0)
972
+ tokens = sum;
973
+ // Find the last assistant message with content
974
+ for (let i = msgArray.length - 1; i >= 0; i--) {
975
+ if (msgArray[i].role === 'assistant' && msgArray[i].content) {
976
+ return { output: msgArray[i].content, tokens };
977
+ }
978
+ }
979
+ return { output: '', tokens };
879
980
  }
880
981
  }
881
- return { output: '', tokens };
882
982
  }
983
+ catch {
984
+ // Transient poll failure — keep trying
985
+ }
986
+ await sleep(this.pollIntervalMs);
883
987
  }
884
988
  throw new Error('Synthesis subprocess timed out after ' + timeoutMs.toString() + 'ms');
885
989
  }
886
990
  }
887
991
 
888
992
  /**
889
- * Build the SynthContext for a synthesis cycle.
890
- *
891
- * Computes shared inputs once: scope files, delta files, child meta outputs,
892
- * previous content/feedback, steer, and archive paths.
993
+ * Pino logger factory.
893
994
  *
894
- * @module orchestrator/contextPackage
995
+ * @module logger
895
996
  */
896
997
  /**
897
- * Condense a file list into glob-like summaries.
898
- * Groups by directory + extension pattern.
998
+ * Create a pino logger instance.
899
999
  *
900
- * @param files - Array of file paths.
901
- * @param maxIndividual - Show individual files up to this count.
902
- * @returns Condensed summary string.
1000
+ * @param config - Optional logger configuration.
1001
+ * @returns Configured pino logger.
1002
+ */
1003
+ function createLogger(config) {
1004
+ const level = config?.level ?? 'info';
1005
+ if (config?.file) {
1006
+ const transport = pino.transport({
1007
+ target: 'pino/file',
1008
+ options: { destination: config.file, mkdir: true },
1009
+ });
1010
+ return pino({ level }, transport);
1011
+ }
1012
+ return pino({ level });
1013
+ }
1014
+
1015
+ /**
1016
+ * Build the MetaContext for a synthesis cycle.
1017
+ *
1018
+ * Computes shared inputs once: scope files, delta files, child meta outputs,
1019
+ * previous content/feedback, steer, and archive paths.
1020
+ *
1021
+ * @module orchestrator/contextPackage
1022
+ */
1023
+ /**
1024
+ * Condense a file list into glob-like summaries.
1025
+ * Groups by directory + extension pattern.
1026
+ *
1027
+ * @param files - Array of file paths.
1028
+ * @param maxIndividual - Show individual files up to this count.
1029
+ * @returns Condensed summary string.
903
1030
  */
904
1031
  function condenseScopeFiles(files, maxIndividual = 30) {
905
1032
  if (files.length <= maxIndividual)
@@ -927,24 +1054,9 @@ function condenseScopeFiles(files, maxIndividual = 30) {
927
1054
  * @returns The computed context package.
928
1055
  */
929
1056
  async function buildContextPackage(node, meta, watcher) {
930
- // Scope files via watcher scan, excluding child subtrees
931
- const allScanFiles = await paginatedScan(watcher, {
932
- pathPrefix: node.ownerPath,
933
- });
934
- const scopeFiles = filterInScope(node, allScanFiles.map((f) => f.file_path));
935
- // Delta files: modified since _generatedAt
936
- let deltaFiles;
937
- if (meta._generatedAt) {
938
- const modifiedAfter = Math.floor(new Date(meta._generatedAt).getTime() / 1000);
939
- const deltaScanFiles = await paginatedScan(watcher, {
940
- pathPrefix: node.ownerPath,
941
- modifiedAfter,
942
- });
943
- deltaFiles = filterInScope(node, deltaScanFiles.map((f) => f.file_path));
944
- }
945
- else {
946
- deltaFiles = scopeFiles; // First run: all files are delta
947
- }
1057
+ // Scope and delta files via watcher scan
1058
+ const { scopeFiles } = await getScopeFiles(node, watcher);
1059
+ const deltaFiles = await getDeltaFiles(node, watcher, meta._generatedAt, scopeFiles);
948
1060
  // Child meta outputs
949
1061
  const childMetas = {};
950
1062
  for (const child of node.children) {
@@ -1086,6 +1198,100 @@ function buildCriticTask(ctx, meta, config) {
1086
1198
  return sections.join('\n');
1087
1199
  }
1088
1200
 
1201
+ /**
1202
+ * Structured error from a synthesis step failure.
1203
+ *
1204
+ * @module schema/error
1205
+ */
1206
+ /** Zod schema for synthesis step errors. */
1207
+ const metaErrorSchema = z.object({
1208
+ /** Which step failed: 'architect', 'builder', or 'critic'. */
1209
+ step: z.enum(['architect', 'builder', 'critic']),
1210
+ /** Error classification code. */
1211
+ code: z.string(),
1212
+ /** Human-readable error message. */
1213
+ message: z.string(),
1214
+ });
1215
+
1216
+ /**
1217
+ * Zod schema for .meta/meta.json files.
1218
+ *
1219
+ * Reserved properties are underscore-prefixed and engine-managed.
1220
+ * All other keys are open schema (builder output).
1221
+ *
1222
+ * @module schema/meta
1223
+ */
1224
+ /** Zod schema for the reserved (underscore-prefixed) meta.json properties. */
1225
+ const metaJsonSchema = z
1226
+ .object({
1227
+ /** Stable identity. Generated on first synthesis, never changes. */
1228
+ _id: z.uuid(),
1229
+ /** Human-provided steering prompt. Optional. */
1230
+ _steer: z.string().optional(),
1231
+ /** Architect system prompt used this turn. Defaults from config. */
1232
+ _architect: z.string().optional(),
1233
+ /**
1234
+ * Task brief generated by the architect. Cached and reused across cycles;
1235
+ * regenerated only when triggered.
1236
+ */
1237
+ _builder: z.string().optional(),
1238
+ /** Critic system prompt used this turn. Defaults from config. */
1239
+ _critic: z.string().optional(),
1240
+ /** Timestamp of last synthesis. ISO 8601. */
1241
+ _generatedAt: z.iso.datetime().optional(),
1242
+ /** Narrative synthesis output. Rendered by watcher for embedding. */
1243
+ _content: z.string().optional(),
1244
+ /**
1245
+ * Hash of sorted file listing in scope. Detects directory structure
1246
+ * changes that trigger an architect re-run.
1247
+ */
1248
+ _structureHash: z.string().optional(),
1249
+ /**
1250
+ * Cycles since last architect run. Reset to 0 when architect runs.
1251
+ * Used with architectEvery to trigger periodic re-prompting.
1252
+ */
1253
+ _synthesisCount: z.number().int().min(0).optional(),
1254
+ /** Critic evaluation of the last synthesis. */
1255
+ _feedback: z.string().optional(),
1256
+ /**
1257
+ * Present and true on archive snapshots. Distinguishes live vs. archived
1258
+ * metas.
1259
+ */
1260
+ _archived: z.boolean().optional(),
1261
+ /** Timestamp when this snapshot was archived. ISO 8601. */
1262
+ _archivedAt: z.iso.datetime().optional(),
1263
+ /**
1264
+ * Scheduling priority. Higher = updates more often. Negative allowed;
1265
+ * normalized to min 0 at scheduling time.
1266
+ */
1267
+ _depth: z.number().optional(),
1268
+ /**
1269
+ * Emphasis multiplier for depth weighting in scheduling.
1270
+ * Default 1. Higher values increase this meta's scheduling priority
1271
+ * relative to its depth. Set to 0.5 to halve the depth effect,
1272
+ * 2 to double it, 0 to ignore depth entirely for this meta.
1273
+ */
1274
+ _emphasis: z.number().min(0).optional(),
1275
+ /** Token count from last architect subprocess call. */
1276
+ _architectTokens: z.number().int().optional(),
1277
+ /** Token count from last builder subprocess call. */
1278
+ _builderTokens: z.number().int().optional(),
1279
+ /** Token count from last critic subprocess call. */
1280
+ _criticTokens: z.number().int().optional(),
1281
+ /** Exponential moving average of architect token usage (decay 0.3). */
1282
+ _architectTokensAvg: z.number().optional(),
1283
+ /** Exponential moving average of builder token usage (decay 0.3). */
1284
+ _builderTokensAvg: z.number().optional(),
1285
+ /** Exponential moving average of critic token usage (decay 0.3). */
1286
+ _criticTokensAvg: z.number().optional(),
1287
+ /**
1288
+ * Structured error from last cycle. Present when a step failed.
1289
+ * Cleared on successful cycle.
1290
+ */
1291
+ _error: metaErrorSchema.optional(),
1292
+ })
1293
+ .loose();
1294
+
1089
1295
  /**
1090
1296
  * Merge synthesis results into meta.json.
1091
1297
  *
@@ -1168,12 +1374,50 @@ function mergeAndWrite(options) {
1168
1374
  if (!result.success) {
1169
1375
  throw new Error(`Meta validation failed: ${result.error.message}`);
1170
1376
  }
1171
- // Write atomically
1172
- const filePath = join(options.metaPath, 'meta.json');
1377
+ // Write to specified path (lock staging) or default meta.json
1378
+ const filePath = options.outputPath ?? join(options.metaPath, 'meta.json');
1173
1379
  writeFileSync(filePath, JSON.stringify(result.data, null, 2) + '\n');
1174
1380
  return result.data;
1175
1381
  }
1176
1382
 
1383
+ /**
1384
+ * Weighted staleness formula for candidate selection.
1385
+ *
1386
+ * effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
1387
+ *
1388
+ * @module scheduling/weightedFormula
1389
+ */
1390
+ /**
1391
+ * Compute effective staleness for a set of candidates.
1392
+ *
1393
+ * Normalizes depths so the minimum becomes 0, then applies the formula:
1394
+ * effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
1395
+ *
1396
+ * Per-meta _emphasis (default 1) multiplies depthWeight, allowing individual
1397
+ * metas to tune how much their tree position affects scheduling.
1398
+ *
1399
+ * @param candidates - Array of \{ node, meta, actualStaleness \}.
1400
+ * @param depthWeight - Exponent for depth weighting (0 = pure staleness).
1401
+ * @returns Same array with effectiveStaleness computed.
1402
+ */
1403
+ function computeEffectiveStaleness(candidates, depthWeight) {
1404
+ if (candidates.length === 0)
1405
+ return [];
1406
+ // Get depth for each candidate: use _depth override or tree depth
1407
+ const depths = candidates.map((c) => c.meta._depth ?? c.node.treeDepth);
1408
+ // Normalize: shift so minimum becomes 0
1409
+ const minDepth = Math.min(...depths);
1410
+ const normalizedDepths = depths.map((d) => Math.max(0, d - minDepth));
1411
+ return candidates.map((c, i) => {
1412
+ const emphasis = c.meta._emphasis ?? 1;
1413
+ return {
1414
+ ...c,
1415
+ effectiveStaleness: c.actualStaleness *
1416
+ Math.pow(normalizedDepths[i] + 1, depthWeight * emphasis),
1417
+ };
1418
+ });
1419
+ }
1420
+
1177
1421
  /**
1178
1422
  * Select the best synthesis candidate from stale metas.
1179
1423
  *
@@ -1198,6 +1442,21 @@ function selectCandidate(candidates) {
1198
1442
  }
1199
1443
  return best;
1200
1444
  }
1445
+ /**
1446
+ * Extract stale candidates from a list and return the stalest path.
1447
+ *
1448
+ * Consolidates the repeated pattern of:
1449
+ * filter → computeEffectiveStaleness → selectCandidate → return path
1450
+ *
1451
+ * @param candidates - Array with node, meta, and stalenessSeconds.
1452
+ * @param depthWeight - Depth weighting exponent from config.
1453
+ * @returns The stalest candidate's metaPath, or null if none are stale.
1454
+ */
1455
+ function discoverStalestPath(candidates, depthWeight) {
1456
+ const weighted = computeEffectiveStaleness(candidates, depthWeight);
1457
+ const winner = selectCandidate(weighted);
1458
+ return winner?.node.metaPath ?? null;
1459
+ }
1201
1460
 
1202
1461
  /**
1203
1462
  * Staleness detection via watcher scan.
@@ -1265,63 +1524,23 @@ function hasSteerChanged(currentSteer, archiveSteer, hasArchive) {
1265
1524
  return Boolean(currentSteer);
1266
1525
  return currentSteer !== archiveSteer;
1267
1526
  }
1268
-
1269
- /**
1270
- * Weighted staleness formula for candidate selection.
1271
- *
1272
- * effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
1273
- *
1274
- * @module scheduling/weightedFormula
1275
- */
1276
- /**
1277
- * Compute effective staleness for a set of candidates.
1278
- *
1279
- * Normalizes depths so the minimum becomes 0, then applies the formula:
1280
- * effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
1281
- *
1282
- * Per-meta _emphasis (default 1) multiplies depthWeight, allowing individual
1283
- * metas to tune how much their tree position affects scheduling.
1284
- *
1285
- * @param candidates - Array of \{ node, meta, actualStaleness \}.
1286
- * @param depthWeight - Exponent for depth weighting (0 = pure staleness).
1287
- * @returns Same array with effectiveStaleness computed.
1288
- */
1289
- function computeEffectiveStaleness(candidates, depthWeight) {
1290
- if (candidates.length === 0)
1291
- return [];
1292
- // Get depth for each candidate: use _depth override or tree depth
1293
- const depths = candidates.map((c) => c.meta._depth ?? c.node.treeDepth);
1294
- // Normalize: shift so minimum becomes 0
1295
- const minDepth = Math.min(...depths);
1296
- const normalizedDepths = depths.map((d) => Math.max(0, d - minDepth));
1297
- return candidates.map((c, i) => {
1298
- const emphasis = c.meta._emphasis ?? 1;
1299
- return {
1300
- ...c,
1301
- effectiveStaleness: c.actualStaleness *
1302
- Math.pow(normalizedDepths[i] + 1, depthWeight * emphasis),
1303
- };
1304
- });
1305
- }
1306
-
1307
1527
  /**
1308
- * Compute a structure hash from a sorted file listing.
1528
+ * Compute a normalized staleness score (0–1) for display purposes.
1309
1529
  *
1310
- * Used to detect when directory structure changes, triggering
1311
- * an architect re-run.
1312
- *
1313
- * @module structureHash
1314
- */
1315
- /**
1316
- * Compute a SHA-256 hash of a sorted file listing.
1530
+ * Uses the same depth/emphasis weighting as candidate selection,
1531
+ * normalized to a 30-day window.
1317
1532
  *
1318
- * @param filePaths - Array of file paths in scope.
1319
- * @returns Hex-encoded SHA-256 hash of the sorted, newline-joined paths.
1533
+ * @param stalenessSeconds - Raw staleness in seconds (null = never synthesized).
1534
+ * @param depth - Meta tree depth.
1535
+ * @param emphasis - Scheduling emphasis multiplier.
1536
+ * @param depthWeight - Depth weighting exponent from config.
1537
+ * @returns Normalized score between 0 and 1.
1320
1538
  */
1321
- function computeStructureHash(filePaths) {
1322
- const sorted = [...filePaths].sort();
1323
- const content = sorted.join('\n');
1324
- return createHash('sha256').update(content).digest('hex');
1539
+ function computeStalenessScore(stalenessSeconds, depth, emphasis, depthWeight) {
1540
+ if (stalenessSeconds === null)
1541
+ return 1;
1542
+ const depthFactor = Math.pow(1 + depthWeight, depth);
1543
+ return Math.min(1, (stalenessSeconds * depthFactor * emphasis) / (30 * 86400));
1325
1544
  }
1326
1545
 
1327
1546
  /**
@@ -1398,25 +1617,33 @@ function parseCriticOutput(output) {
1398
1617
  *
1399
1618
  * @module orchestrator/orchestrate
1400
1619
  */
1401
- /** Finalize a cycle: merge, snapshot, prune. */
1402
- function finalizeCycle(metaPath, current, config, architect, builder, critic, builderOutput, feedback, structureHash, synthesisCount, error, architectTokens, builderTokens, criticTokens) {
1620
+ /** Finalize a cycle using lock staging: write to .lock → copy to meta.json + archive → delete .lock. */
1621
+ function finalizeCycle(opts) {
1622
+ const lockPath = join(opts.metaPath, '.lock');
1623
+ const metaJsonPath = join(opts.metaPath, 'meta.json');
1624
+ // Stage: write merged result to .lock
1403
1625
  const updated = mergeAndWrite({
1404
- metaPath,
1405
- current,
1406
- architect,
1407
- builder,
1408
- critic,
1409
- builderOutput,
1410
- feedback,
1411
- structureHash,
1412
- synthesisCount,
1413
- error,
1414
- architectTokens,
1415
- builderTokens,
1416
- criticTokens,
1626
+ metaPath: opts.metaPath,
1627
+ current: opts.current,
1628
+ architect: opts.architect,
1629
+ builder: opts.builder,
1630
+ critic: opts.critic,
1631
+ builderOutput: opts.builderOutput,
1632
+ feedback: opts.feedback,
1633
+ structureHash: opts.structureHash,
1634
+ synthesisCount: opts.synthesisCount,
1635
+ error: opts.error,
1636
+ architectTokens: opts.architectTokens,
1637
+ builderTokens: opts.builderTokens,
1638
+ criticTokens: opts.criticTokens,
1639
+ outputPath: lockPath,
1417
1640
  });
1418
- createSnapshot(metaPath, updated);
1419
- pruneArchive(metaPath, config.maxArchive);
1641
+ // Commit: copy .lock → meta.json
1642
+ copyFileSync(lockPath, metaJsonPath);
1643
+ // Archive + prune from the committed meta.json
1644
+ createSnapshot(opts.metaPath, updated);
1645
+ pruneArchive(opts.metaPath, opts.config.maxArchive);
1646
+ // .lock is cleaned up by the finally block (releaseLock)
1420
1647
  return updated;
1421
1648
  }
1422
1649
  /**
@@ -1430,7 +1657,7 @@ function finalizeCycle(metaPath, current, config, architect, builder, critic, bu
1430
1657
  * @param watcher - Watcher HTTP client.
1431
1658
  * @returns Result indicating whether synthesis occurred.
1432
1659
  */
1433
- async function orchestrateOnce(config, executor, watcher, targetPath) {
1660
+ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress) {
1434
1661
  // Step 1: Discover via watcher scan
1435
1662
  const metaPaths = await discoverMetas(config, watcher);
1436
1663
  if (metaPaths.length === 0)
@@ -1440,18 +1667,22 @@ async function orchestrateOnce(config, executor, watcher, targetPath) {
1440
1667
  for (const mp of metaPaths) {
1441
1668
  const metaFilePath = join(mp, 'meta.json');
1442
1669
  try {
1443
- metas.set(normalizePath$1(mp), JSON.parse(readFileSync(metaFilePath, 'utf8')));
1670
+ metas.set(normalizePath(mp), JSON.parse(readFileSync(metaFilePath, 'utf8')));
1444
1671
  }
1445
1672
  catch {
1446
1673
  // Skip metas with unreadable meta.json
1447
1674
  continue;
1448
1675
  }
1449
1676
  }
1450
- const tree = buildOwnershipTree(metaPaths);
1677
+ // Only build tree from paths with readable meta.json (excludes orphaned/deleted entries)
1678
+ const validPaths = metaPaths.filter((mp) => metas.has(normalizePath(mp)));
1679
+ if (validPaths.length === 0)
1680
+ return { synthesized: false };
1681
+ const tree = buildOwnershipTree(validPaths);
1451
1682
  // If targetPath specified, skip candidate selection — go directly to that meta
1452
1683
  let targetNode;
1453
1684
  if (targetPath) {
1454
- const normalized = normalizePath$1(targetPath);
1685
+ const normalized = normalizePath(targetPath);
1455
1686
  targetNode = findNode(tree, normalized) ?? undefined;
1456
1687
  if (!targetNode)
1457
1688
  return { synthesized: false };
@@ -1460,6 +1691,8 @@ async function orchestrateOnce(config, executor, watcher, targetPath) {
1460
1691
  const candidates = [];
1461
1692
  for (const node of tree.nodes.values()) {
1462
1693
  const meta = metas.get(node.metaPath);
1694
+ if (!meta)
1695
+ continue; // Node not in metas map (e.g. unreadable meta.json)
1463
1696
  const staleness = actualStaleness(meta);
1464
1697
  if (staleness > 0) {
1465
1698
  candidates.push({ node, meta, actualStaleness: staleness });
@@ -1502,22 +1735,14 @@ async function orchestrateOnce(config, executor, watcher, targetPath) {
1502
1735
  const currentMeta = JSON.parse(readFileSync(join(node.metaPath, 'meta.json'), 'utf8'));
1503
1736
  const architectPrompt = currentMeta._architect ?? config.defaultArchitect;
1504
1737
  const criticPrompt = currentMeta._critic ?? config.defaultCritic;
1505
- // Step 5: Structure hash
1506
- const scopePrefix = getScopePrefix(node);
1507
- const allScanFiles = await paginatedScan(watcher, {
1508
- pathPrefix: scopePrefix,
1509
- });
1510
- const allFilePaths = allScanFiles.map((f) => f.file_path);
1511
- // Structure hash uses scope-filtered files (excluding child subtrees)
1512
- // so changes in child scopes don't trigger parent architect re-runs
1513
- const scopeFiles = filterInScope(node, allFilePaths);
1514
- const newStructureHash = computeStructureHash(scopeFiles);
1515
- const structureChanged = newStructureHash !== currentMeta._structureHash;
1516
- // Step 6: Steer change detection
1738
+ // Step 5-6: Steer change detection
1517
1739
  const latestArchive = readLatestArchive(node.metaPath);
1518
1740
  const steerChanged = hasSteerChanged(currentMeta._steer, latestArchive?._steer, Boolean(latestArchive));
1519
- // Step 7: Compute context
1741
+ // Step 7: Compute context (includes scope files and delta files)
1520
1742
  const ctx = await buildContextPackage(node, currentMeta, watcher);
1743
+ // Step 5 (deferred): Structure hash from context scope files
1744
+ const newStructureHash = computeStructureHash(ctx.scopeFiles);
1745
+ const structureChanged = newStructureHash !== currentMeta._structureHash;
1521
1746
  // Step 8: Architect (conditional)
1522
1747
  const architectTriggered = isArchitectTriggered(currentMeta, structureChanged, steerChanged, config.architectEvery);
1523
1748
  let builderBrief = currentMeta._builder ?? '';
@@ -1528,19 +1753,46 @@ async function orchestrateOnce(config, executor, watcher, targetPath) {
1528
1753
  let criticTokens;
1529
1754
  if (architectTriggered) {
1530
1755
  try {
1756
+ await onProgress?.({
1757
+ type: 'phase_start',
1758
+ metaPath: node.metaPath,
1759
+ phase: 'architect',
1760
+ });
1761
+ const phaseStart = Date.now();
1531
1762
  const architectTask = buildArchitectTask(ctx, currentMeta, config);
1532
1763
  const architectResult = await executor.spawn(architectTask, {
1764
+ thinking: config.thinking,
1533
1765
  timeout: config.architectTimeout,
1534
1766
  });
1535
1767
  builderBrief = parseArchitectOutput(architectResult.output);
1536
1768
  architectTokens = architectResult.tokens;
1537
1769
  synthesisCount = 0;
1770
+ await onProgress?.({
1771
+ type: 'phase_complete',
1772
+ metaPath: node.metaPath,
1773
+ phase: 'architect',
1774
+ tokens: architectTokens,
1775
+ durationMs: Date.now() - phaseStart,
1776
+ });
1538
1777
  }
1539
1778
  catch (err) {
1540
- stepError = toSynthError('architect', err);
1779
+ stepError = toMetaError('architect', err);
1541
1780
  if (!currentMeta._builder) {
1542
1781
  // No cached builder — cycle fails
1543
- finalizeCycle(node.metaPath, currentMeta, config, architectPrompt, '', criticPrompt, null, null, newStructureHash, synthesisCount, stepError, architectTokens);
1782
+ finalizeCycle({
1783
+ metaPath: node.metaPath,
1784
+ current: currentMeta,
1785
+ config,
1786
+ architect: architectPrompt,
1787
+ builder: '',
1788
+ critic: criticPrompt,
1789
+ builderOutput: null,
1790
+ feedback: null,
1791
+ structureHash: newStructureHash,
1792
+ synthesisCount,
1793
+ error: stepError,
1794
+ architectTokens,
1795
+ });
1544
1796
  return {
1545
1797
  synthesized: true,
1546
1798
  metaPath: node.metaPath,
@@ -1554,16 +1806,30 @@ async function orchestrateOnce(config, executor, watcher, targetPath) {
1554
1806
  const metaForBuilder = { ...currentMeta, _builder: builderBrief };
1555
1807
  let builderOutput = null;
1556
1808
  try {
1809
+ await onProgress?.({
1810
+ type: 'phase_start',
1811
+ metaPath: node.metaPath,
1812
+ phase: 'builder',
1813
+ });
1814
+ const builderStart = Date.now();
1557
1815
  const builderTask = buildBuilderTask(ctx, metaForBuilder, config);
1558
1816
  const builderResult = await executor.spawn(builderTask, {
1817
+ thinking: config.thinking,
1559
1818
  timeout: config.builderTimeout,
1560
1819
  });
1561
1820
  builderOutput = parseBuilderOutput(builderResult.output);
1562
1821
  builderTokens = builderResult.tokens;
1563
1822
  synthesisCount++;
1823
+ await onProgress?.({
1824
+ type: 'phase_complete',
1825
+ metaPath: node.metaPath,
1826
+ phase: 'builder',
1827
+ tokens: builderTokens,
1828
+ durationMs: Date.now() - builderStart,
1829
+ });
1564
1830
  }
1565
1831
  catch (err) {
1566
- stepError = toSynthError('builder', err);
1832
+ stepError = toMetaError('builder', err);
1567
1833
  return { synthesized: true, metaPath: node.metaPath, error: stepError };
1568
1834
  }
1569
1835
  // Step 10: Critic
@@ -1573,19 +1839,48 @@ async function orchestrateOnce(config, executor, watcher, targetPath) {
1573
1839
  };
1574
1840
  let feedback = null;
1575
1841
  try {
1842
+ await onProgress?.({
1843
+ type: 'phase_start',
1844
+ metaPath: node.metaPath,
1845
+ phase: 'critic',
1846
+ });
1847
+ const criticStart = Date.now();
1576
1848
  const criticTask = buildCriticTask(ctx, metaForCritic, config);
1577
1849
  const criticResult = await executor.spawn(criticTask, {
1850
+ thinking: config.thinking,
1578
1851
  timeout: config.criticTimeout,
1579
1852
  });
1580
1853
  feedback = parseCriticOutput(criticResult.output);
1581
1854
  criticTokens = criticResult.tokens;
1582
1855
  stepError = null; // Clear any architect error on full success
1856
+ await onProgress?.({
1857
+ type: 'phase_complete',
1858
+ metaPath: node.metaPath,
1859
+ phase: 'critic',
1860
+ tokens: criticTokens,
1861
+ durationMs: Date.now() - criticStart,
1862
+ });
1583
1863
  }
1584
1864
  catch (err) {
1585
- stepError = stepError ?? toSynthError('critic', err);
1865
+ stepError = stepError ?? toMetaError('critic', err);
1586
1866
  }
1587
1867
  // Steps 11-12: Merge, archive, prune
1588
- finalizeCycle(node.metaPath, currentMeta, config, architectPrompt, builderBrief, criticPrompt, builderOutput, feedback, newStructureHash, synthesisCount, stepError, architectTokens, builderTokens, criticTokens);
1868
+ finalizeCycle({
1869
+ metaPath: node.metaPath,
1870
+ current: currentMeta,
1871
+ config,
1872
+ architect: architectPrompt,
1873
+ builder: builderBrief,
1874
+ critic: criticPrompt,
1875
+ builderOutput,
1876
+ feedback,
1877
+ structureHash: newStructureHash,
1878
+ synthesisCount,
1879
+ error: stepError,
1880
+ architectTokens,
1881
+ builderTokens,
1882
+ criticTokens,
1883
+ });
1589
1884
  return {
1590
1885
  synthesized: true,
1591
1886
  metaPath: node.metaPath,
@@ -1598,59 +1893,1317 @@ async function orchestrateOnce(config, executor, watcher, targetPath) {
1598
1893
  }
1599
1894
  }
1600
1895
  /**
1601
- * Run synthesis cycles up to batchSize.
1896
+ * Run a single synthesis cycle.
1602
1897
  *
1603
- * Calls orchestrateOnce() in a loop, stopping when batchSize is reached
1604
- * or no more candidates are available.
1898
+ * Selects the stalest candidate (or a specific target) and runs the
1899
+ * full architect/builder/critic pipeline.
1605
1900
  *
1606
1901
  * @param config - Validated synthesis config.
1607
1902
  * @param executor - Pluggable LLM executor.
1608
1903
  * @param watcher - Watcher HTTP client.
1609
1904
  * @param targetPath - Optional: specific meta/owner path to synthesize instead of stalest candidate.
1610
- * @returns Array of results, one per cycle attempted.
1905
+ * @returns Array with a single result.
1611
1906
  */
1612
- async function orchestrate(config, executor, watcher, targetPath) {
1613
- const results = [];
1614
- for (let i = 0; i < config.batchSize; i++) {
1615
- const result = await orchestrateOnce(config, executor, watcher, targetPath);
1616
- results.push(result);
1617
- if (!result.synthesized)
1618
- break; // No more candidates
1619
- }
1620
- return results;
1907
+ async function orchestrate(config, executor, watcher, targetPath, onProgress) {
1908
+ const result = await orchestrateOnce(config, executor, watcher, targetPath, onProgress);
1909
+ return [result];
1621
1910
  }
1622
1911
 
1623
1912
  /**
1624
- * HTTP implementation of the WatcherClient interface.
1625
- *
1626
- * Talks to jeeves-watcher's POST /scan and POST /rules endpoints
1627
- * with retry and exponential backoff.
1913
+ * Progress reporting via OpenClaw gateway `/tools/invoke` → `message` tool.
1628
1914
  *
1629
- * @module watcher-client/HttpWatcherClient
1915
+ * @module progress
1630
1916
  */
1631
- /** Default retry configuration. */
1632
- const DEFAULT_MAX_RETRIES = 3;
1633
- const DEFAULT_BACKOFF_BASE_MS = 1000;
1634
- const DEFAULT_BACKOFF_FACTOR = 4;
1635
- /** Sleep for a given number of milliseconds. */
1636
- function sleep(ms) {
1637
- return new Promise((resolve) => setTimeout(resolve, ms));
1917
+ function formatSeconds(durationMs) {
1918
+ const seconds = durationMs / 1000;
1919
+ return seconds.toFixed(1) + 's';
1638
1920
  }
1639
- /** Check if an error is transient (worth retrying). */
1640
- function isTransient(status) {
1641
- return status >= 500 || status === 408 || status === 429;
1921
+ function titleCasePhase(phase) {
1922
+ return phase.charAt(0).toUpperCase() + phase.slice(1);
1642
1923
  }
1643
- /**
1644
- * HTTP-based WatcherClient implementation with retry.
1645
- */
1646
- class HttpWatcherClient {
1647
- baseUrl;
1648
- maxRetries;
1649
- backoffBaseMs;
1650
- backoffFactor;
1651
- constructor(options) {
1652
- this.baseUrl = options.baseUrl.replace(/\/+$/, '');
1653
- this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
1924
+ function formatProgressEvent(event) {
1925
+ switch (event.type) {
1926
+ case 'synthesis_start':
1927
+ return `🔬 Started meta synthesis: ${event.metaPath}`;
1928
+ case 'phase_start': {
1929
+ if (!event.phase) {
1930
+ return ` ⚙️ Phase started: ${event.metaPath}`;
1931
+ }
1932
+ return ` ⚙️ ${titleCasePhase(event.phase)} phase started`;
1933
+ }
1934
+ case 'phase_complete': {
1935
+ const phase = event.phase ? titleCasePhase(event.phase) : 'Phase';
1936
+ const tokens = event.tokens ?? 0;
1937
+ const duration = event.durationMs !== undefined
1938
+ ? formatSeconds(event.durationMs)
1939
+ : '0.0s';
1940
+ return ` ✅ ${phase} phase complete (${String(tokens)} tokens / ${duration})`;
1941
+ }
1942
+ case 'synthesis_complete': {
1943
+ const tokens = event.tokens ?? 0;
1944
+ const duration = event.durationMs !== undefined
1945
+ ? formatSeconds(event.durationMs)
1946
+ : '0.0s';
1947
+ return `✅ Completed: ${event.metaPath} (${String(tokens)} tokens / ${duration})`;
1948
+ }
1949
+ case 'error': {
1950
+ const phase = event.phase ? `${titleCasePhase(event.phase)} ` : '';
1951
+ const error = event.error ?? 'Unknown error';
1952
+ return `❌ Synthesis failed at ${phase}phase: ${event.metaPath}\n Error: ${error}`;
1953
+ }
1954
+ default: {
1955
+ return 'Unknown progress event';
1956
+ }
1957
+ }
1958
+ }
1959
+ class ProgressReporter {
1960
+ config;
1961
+ logger;
1962
+ constructor(config, logger) {
1963
+ this.config = config;
1964
+ this.logger = logger;
1965
+ }
1966
+ async report(event) {
1967
+ const target = this.config.reportChannel;
1968
+ if (!target)
1969
+ return;
1970
+ const message = formatProgressEvent(event);
1971
+ const url = new URL('/tools/invoke', this.config.gatewayUrl);
1972
+ const payload = {
1973
+ tool: 'message',
1974
+ args: {
1975
+ action: 'send',
1976
+ target,
1977
+ message,
1978
+ },
1979
+ };
1980
+ try {
1981
+ const res = await fetch(url, {
1982
+ method: 'POST',
1983
+ headers: {
1984
+ 'content-type': 'application/json',
1985
+ ...(this.config.gatewayApiKey
1986
+ ? { authorization: `Bearer ${this.config.gatewayApiKey}` }
1987
+ : {}),
1988
+ },
1989
+ body: JSON.stringify(payload),
1990
+ });
1991
+ if (!res.ok) {
1992
+ const text = await res.text().catch(() => '');
1993
+ this.logger.warn({ status: res.status, statusText: res.statusText, body: text }, 'Progress reporting failed');
1994
+ }
1995
+ }
1996
+ catch (err) {
1997
+ this.logger.warn({ err }, 'Progress reporting threw');
1998
+ }
1999
+ }
2000
+ }
2001
+
2002
+ /**
2003
+ * Croner-based scheduler that discovers the stalest meta candidate each tick
2004
+ * and enqueues it for synthesis.
2005
+ *
2006
+ * @module scheduler
2007
+ */
2008
+ const MAX_BACKOFF_MULTIPLIER = 4;
2009
+ /**
2010
+ * Periodic scheduler that discovers stale meta candidates and enqueues them.
2011
+ *
2012
+ * Supports adaptive backoff when no candidates are found and hot-reloadable
2013
+ * cron expressions via {@link Scheduler.updateSchedule}.
2014
+ */
2015
+ class Scheduler {
2016
+ job = null;
2017
+ backoffMultiplier = 1;
2018
+ tickCount = 0;
2019
+ config;
2020
+ queue;
2021
+ logger;
2022
+ watcher;
2023
+ registrar = null;
2024
+ currentExpression;
2025
+ constructor(config, queue, logger, watcher) {
2026
+ this.config = config;
2027
+ this.queue = queue;
2028
+ this.logger = logger;
2029
+ this.watcher = watcher;
2030
+ this.currentExpression = config.schedule;
2031
+ }
2032
+ /** Set the rule registrar for watcher restart detection. */
2033
+ setRegistrar(registrar) {
2034
+ this.registrar = registrar;
2035
+ }
2036
+ /** Start the cron job. */
2037
+ start() {
2038
+ if (this.job)
2039
+ return;
2040
+ this.job = new Cron(this.currentExpression, () => {
2041
+ void this.tick();
2042
+ });
2043
+ this.logger.info({ schedule: this.currentExpression }, 'Scheduler started');
2044
+ }
2045
+ /** Stop the cron job. */
2046
+ stop() {
2047
+ if (!this.job)
2048
+ return;
2049
+ this.job.stop();
2050
+ this.job = null;
2051
+ this.backoffMultiplier = 1;
2052
+ this.logger.info('Scheduler stopped');
2053
+ }
2054
+ /** Hot-reload the cron schedule expression. */
2055
+ updateSchedule(expression) {
2056
+ this.currentExpression = expression;
2057
+ if (this.job) {
2058
+ this.job.stop();
2059
+ this.job = new Cron(expression, () => {
2060
+ void this.tick();
2061
+ });
2062
+ this.logger.info({ schedule: expression }, 'Schedule updated');
2063
+ }
2064
+ }
2065
+ /** Reset backoff multiplier (call after successful synthesis). */
2066
+ resetBackoff() {
2067
+ if (this.backoffMultiplier > 1) {
2068
+ this.logger.debug('Backoff reset after successful synthesis');
2069
+ }
2070
+ this.backoffMultiplier = 1;
2071
+ }
2072
+ /** Whether the scheduler is currently running. */
2073
+ get isRunning() {
2074
+ return this.job !== null;
2075
+ }
2076
+ /** Next scheduled tick time, or null if not running. */
2077
+ get nextRunAt() {
2078
+ if (!this.job)
2079
+ return null;
2080
+ return this.job.nextRun() ?? null;
2081
+ }
2082
+ /**
2083
+ * Single tick: discover stalest candidate and enqueue it.
2084
+ *
2085
+ * Skips if the queue is currently processing. Applies adaptive backoff
2086
+ * when no candidates are found.
2087
+ */
2088
+ async tick() {
2089
+ this.tickCount++;
2090
+ // Apply backoff: skip ticks when backing off
2091
+ if (this.backoffMultiplier > 1 &&
2092
+ this.tickCount % this.backoffMultiplier !== 0) {
2093
+ this.logger.trace({
2094
+ backoffMultiplier: this.backoffMultiplier,
2095
+ tickCount: this.tickCount,
2096
+ }, 'Skipping tick (backoff)');
2097
+ return;
2098
+ }
2099
+ const candidate = await this.discoverStalest();
2100
+ if (!candidate) {
2101
+ this.backoffMultiplier = Math.min(this.backoffMultiplier * 2, MAX_BACKOFF_MULTIPLIER);
2102
+ this.logger.debug({ backoffMultiplier: this.backoffMultiplier }, 'No stale candidates found, increasing backoff');
2103
+ return;
2104
+ }
2105
+ this.queue.enqueue(candidate);
2106
+ this.logger.info({ path: candidate }, 'Enqueued stale candidate');
2107
+ // Opportunistic watcher restart detection
2108
+ if (this.registrar) {
2109
+ try {
2110
+ const statusRes = await fetch(new URL('/status', this.config.watcherUrl), {
2111
+ signal: AbortSignal.timeout(3000),
2112
+ });
2113
+ if (statusRes.ok) {
2114
+ const status = (await statusRes.json());
2115
+ if (typeof status.uptime === 'number') {
2116
+ await this.registrar.checkAndReregister(status.uptime);
2117
+ }
2118
+ }
2119
+ }
2120
+ catch {
2121
+ // Watcher unreachable — skip uptime check
2122
+ }
2123
+ }
2124
+ }
2125
+ /**
2126
+ * Discover the stalest meta candidate via watcher.
2127
+ */
2128
+ async discoverStalest() {
2129
+ try {
2130
+ const result = await listMetas(this.config, this.watcher);
2131
+ const stale = result.entries
2132
+ .filter((e) => e.stalenessSeconds > 0)
2133
+ .map((e) => ({
2134
+ node: e.node,
2135
+ meta: e.meta,
2136
+ actualStaleness: e.stalenessSeconds,
2137
+ }));
2138
+ return discoverStalestPath(stale, this.config.depthWeight);
2139
+ }
2140
+ catch (err) {
2141
+ this.logger.warn({ err }, 'Failed to discover stalest candidate');
2142
+ return null;
2143
+ }
2144
+ }
2145
+ }
2146
+
2147
+ /**
2148
+ * Single-threaded synthesis queue with priority support and deduplication.
2149
+ *
2150
+ * The scheduler enqueues the stalest candidate each tick. HTTP-triggered
2151
+ * synthesis requests get priority (inserted at front). A path appears at
2152
+ * most once in the queue; re-triggering returns the current position.
2153
+ *
2154
+ * @module queue
2155
+ */
2156
+ const DEPTH_WARNING_THRESHOLD = 3;
2157
+ /**
2158
+ * Single-threaded synthesis queue.
2159
+ *
2160
+ * Only one synthesis runs at a time. Priority items are inserted at the
2161
+ * front of the queue. Duplicate paths are rejected with their current
2162
+ * position returned.
2163
+ */
2164
+ class SynthesisQueue {
2165
+ queue = [];
2166
+ currentItem = null;
2167
+ processing = false;
2168
+ logger;
2169
+ onEnqueueCallback = null;
2170
+ /**
2171
+ * Create a new SynthesisQueue.
2172
+ *
2173
+ * @param logger - Pino logger instance.
2174
+ */
2175
+ constructor(logger) {
2176
+ this.logger = logger;
2177
+ }
2178
+ /**
2179
+ * Set a callback to invoke when a new (non-duplicate) item is enqueued.
2180
+ */
2181
+ onEnqueue(callback) {
2182
+ this.onEnqueueCallback = callback;
2183
+ }
2184
+ /**
2185
+ * Add a path to the synthesis queue.
2186
+ *
2187
+ * @param path - Meta path to synthesize.
2188
+ * @param priority - If true, insert at front of queue.
2189
+ * @returns Position and whether the path was already queued.
2190
+ */
2191
+ enqueue(path, priority = false) {
2192
+ // Check if currently being synthesized.
2193
+ if (this.currentItem?.path === path) {
2194
+ return { position: 0, alreadyQueued: true };
2195
+ }
2196
+ // Check if already in queue.
2197
+ const existingIndex = this.queue.findIndex((item) => item.path === path);
2198
+ if (existingIndex !== -1) {
2199
+ return { position: existingIndex, alreadyQueued: true };
2200
+ }
2201
+ const item = {
2202
+ path,
2203
+ priority,
2204
+ enqueuedAt: new Date().toISOString(),
2205
+ };
2206
+ if (priority) {
2207
+ this.queue.unshift(item);
2208
+ }
2209
+ else {
2210
+ this.queue.push(item);
2211
+ }
2212
+ if (this.queue.length > DEPTH_WARNING_THRESHOLD) {
2213
+ this.logger.warn({ depth: this.queue.length }, 'Queue depth exceeds threshold');
2214
+ }
2215
+ const position = this.queue.findIndex((i) => i.path === path);
2216
+ this.onEnqueueCallback?.();
2217
+ return { position, alreadyQueued: false };
2218
+ }
2219
+ /**
2220
+ * Remove and return the next item from the queue.
2221
+ *
2222
+ * @returns The next QueueItem, or undefined if the queue is empty.
2223
+ */
2224
+ dequeue() {
2225
+ const item = this.queue.shift();
2226
+ if (item) {
2227
+ this.currentItem = item;
2228
+ }
2229
+ return item;
2230
+ }
2231
+ /** Mark the currently-running synthesis as complete. */
2232
+ complete() {
2233
+ this.currentItem = null;
2234
+ }
2235
+ /** Number of items waiting in the queue (excludes current). */
2236
+ get depth() {
2237
+ return this.queue.length;
2238
+ }
2239
+ /** The item currently being synthesized, or null. */
2240
+ get current() {
2241
+ return this.currentItem;
2242
+ }
2243
+ /** A shallow copy of the queued items. */
2244
+ get items() {
2245
+ return [...this.queue];
2246
+ }
2247
+ /**
2248
+ * Check whether a path is in the queue or currently being synthesized.
2249
+ *
2250
+ * @param path - Meta path to look up.
2251
+ * @returns True if the path is queued or currently running.
2252
+ */
2253
+ has(path) {
2254
+ if (this.currentItem?.path === path)
2255
+ return true;
2256
+ return this.queue.some((item) => item.path === path);
2257
+ }
2258
+ /**
2259
+ * Get the 0-indexed position of a path in the queue.
2260
+ *
2261
+ * @param path - Meta path to look up.
2262
+ * @returns Position index, or null if not found in the queue.
2263
+ */
2264
+ getPosition(path) {
2265
+ const index = this.queue.findIndex((item) => item.path === path);
2266
+ return index === -1 ? null : index;
2267
+ }
2268
+ /**
2269
+ * Return a snapshot of queue state for the /status endpoint.
2270
+ *
2271
+ * @returns Queue depth and item list.
2272
+ */
2273
+ getState() {
2274
+ return {
2275
+ depth: this.queue.length,
2276
+ items: this.queue.map((item) => ({
2277
+ path: item.path,
2278
+ priority: item.priority,
2279
+ enqueuedAt: item.enqueuedAt,
2280
+ })),
2281
+ };
2282
+ }
2283
+ /**
2284
+ * Process queued items one at a time until the queue is empty.
2285
+ *
2286
+ * Re-entry is prevented: if already processing, the call returns
2287
+ * immediately. Errors are logged and do not block subsequent items.
2288
+ *
2289
+ * @param synthesizeFn - Async function that performs synthesis for a path.
2290
+ */
2291
+ async processQueue(synthesizeFn) {
2292
+ if (this.processing)
2293
+ return;
2294
+ this.processing = true;
2295
+ try {
2296
+ let item = this.dequeue();
2297
+ while (item) {
2298
+ try {
2299
+ await synthesizeFn(item.path);
2300
+ }
2301
+ catch (err) {
2302
+ this.logger.error({ path: item.path, err }, 'Synthesis failed');
2303
+ }
2304
+ this.complete();
2305
+ item = this.dequeue();
2306
+ }
2307
+ }
2308
+ finally {
2309
+ this.processing = false;
2310
+ }
2311
+ }
2312
+ }
2313
+
2314
+ /**
2315
+ * GET /config/validate — return sanitized service configuration.
2316
+ *
2317
+ * @module routes/configValidate
2318
+ */
2319
+ function registerConfigValidateRoute(app, deps) {
2320
+ app.get('/config/validate', () => {
2321
+ const sanitized = {
2322
+ ...deps.config,
2323
+ gatewayApiKey: deps.config.gatewayApiKey ? '[REDACTED]' : undefined,
2324
+ };
2325
+ return sanitized;
2326
+ });
2327
+ }
2328
+
2329
+ /**
2330
+ * GET /metas — list metas with optional filters.
2331
+ * GET /metas/:path — single meta detail.
2332
+ *
2333
+ * @module routes/metas
2334
+ */
2335
+ const metasQuerySchema = z.object({
2336
+ pathPrefix: z.string().optional(),
2337
+ hasError: z
2338
+ .enum(['true', 'false'])
2339
+ .transform((v) => v === 'true')
2340
+ .optional(),
2341
+ staleHours: z
2342
+ .string()
2343
+ .transform(Number)
2344
+ .pipe(z.number().positive())
2345
+ .optional(),
2346
+ neverSynthesized: z
2347
+ .enum(['true', 'false'])
2348
+ .transform((v) => v === 'true')
2349
+ .optional(),
2350
+ locked: z
2351
+ .enum(['true', 'false'])
2352
+ .transform((v) => v === 'true')
2353
+ .optional(),
2354
+ fields: z.string().optional(),
2355
+ });
2356
+ const metaDetailQuerySchema = z.object({
2357
+ fields: z.string().optional(),
2358
+ includeArchive: z
2359
+ .union([
2360
+ z.enum(['true', 'false']).transform((v) => v === 'true'),
2361
+ z.string().transform(Number).pipe(z.number().int().nonnegative()),
2362
+ ])
2363
+ .optional(),
2364
+ });
2365
+ /** Compute summary stats from a filtered set of MetaEntries. */
2366
+ function computeFilteredSummary(entries) {
2367
+ let staleCount = 0;
2368
+ let errorCount = 0;
2369
+ let neverSynthCount = 0;
2370
+ let stalestPath = null;
2371
+ let stalestSeconds = -1;
2372
+ let lastSynthesizedPath = null;
2373
+ let lastSynthesizedAt = null;
2374
+ let totalArchitectTokens = 0;
2375
+ let totalBuilderTokens = 0;
2376
+ let totalCriticTokens = 0;
2377
+ for (const e of entries) {
2378
+ if (e.stalenessSeconds > 0)
2379
+ staleCount++;
2380
+ if (e.hasError)
2381
+ errorCount++;
2382
+ if (e.stalenessSeconds === Infinity)
2383
+ neverSynthCount++;
2384
+ if (e.stalenessSeconds > stalestSeconds) {
2385
+ stalestSeconds = e.stalenessSeconds;
2386
+ stalestPath = e.path;
2387
+ }
2388
+ if (e.lastSynthesized &&
2389
+ (!lastSynthesizedAt || e.lastSynthesized > lastSynthesizedAt)) {
2390
+ lastSynthesizedAt = e.lastSynthesized;
2391
+ lastSynthesizedPath = e.path;
2392
+ }
2393
+ totalArchitectTokens += e.architectTokens ?? 0;
2394
+ totalBuilderTokens += e.builderTokens ?? 0;
2395
+ totalCriticTokens += e.criticTokens ?? 0;
2396
+ }
2397
+ return {
2398
+ total: entries.length,
2399
+ stale: staleCount,
2400
+ errors: errorCount,
2401
+ neverSynthesized: neverSynthCount,
2402
+ stalestPath,
2403
+ lastSynthesizedPath,
2404
+ lastSynthesizedAt,
2405
+ tokens: {
2406
+ architect: totalArchitectTokens,
2407
+ builder: totalBuilderTokens,
2408
+ critic: totalCriticTokens,
2409
+ },
2410
+ };
2411
+ }
2412
+ function registerMetasRoutes(app, deps) {
2413
+ app.get('/metas', async (request) => {
2414
+ const query = metasQuerySchema.parse(request.query);
2415
+ const { config, watcher } = deps;
2416
+ const result = await listMetas(config, watcher);
2417
+ let entries = result.entries;
2418
+ // Apply filters
2419
+ if (query.pathPrefix) {
2420
+ entries = entries.filter((e) => e.path.includes(query.pathPrefix));
2421
+ }
2422
+ if (query.hasError !== undefined) {
2423
+ entries = entries.filter((e) => e.hasError === query.hasError);
2424
+ }
2425
+ if (query.neverSynthesized !== undefined) {
2426
+ entries = entries.filter((e) => (e.stalenessSeconds === Infinity) === query.neverSynthesized);
2427
+ }
2428
+ if (query.locked !== undefined) {
2429
+ entries = entries.filter((e) => e.locked === query.locked);
2430
+ }
2431
+ if (typeof query.staleHours === 'number') {
2432
+ entries = entries.filter((e) => e.stalenessSeconds >= query.staleHours * 3600);
2433
+ }
2434
+ // Summary (computed from filtered entries)
2435
+ const summary = computeFilteredSummary(entries);
2436
+ // Field projection
2437
+ const fieldList = query.fields?.split(',');
2438
+ const defaultFields = [
2439
+ 'path',
2440
+ 'depth',
2441
+ 'emphasis',
2442
+ 'stalenessSeconds',
2443
+ 'lastSynthesized',
2444
+ 'hasError',
2445
+ 'locked',
2446
+ 'architectTokens',
2447
+ 'builderTokens',
2448
+ 'criticTokens',
2449
+ ];
2450
+ const projectedFields = fieldList ?? defaultFields;
2451
+ const metas = entries.map((e) => {
2452
+ const full = {
2453
+ path: e.path,
2454
+ depth: e.depth,
2455
+ emphasis: e.emphasis,
2456
+ stalenessSeconds: e.stalenessSeconds === Infinity
2457
+ ? null
2458
+ : Math.round(e.stalenessSeconds),
2459
+ lastSynthesized: e.lastSynthesized,
2460
+ hasError: e.hasError,
2461
+ locked: e.locked,
2462
+ architectTokens: e.architectTokens,
2463
+ builderTokens: e.builderTokens,
2464
+ criticTokens: e.criticTokens,
2465
+ };
2466
+ const projected = {};
2467
+ for (const f of projectedFields) {
2468
+ if (f in full)
2469
+ projected[f] = full[f];
2470
+ }
2471
+ return projected;
2472
+ });
2473
+ return { summary, metas };
2474
+ });
2475
+ app.get('/metas/:path', async (request, reply) => {
2476
+ const query = metaDetailQuerySchema.parse(request.query);
2477
+ const { config, watcher } = deps;
2478
+ const targetPath = normalizePath(decodeURIComponent(request.params.path));
2479
+ const result = await listMetas(config, watcher);
2480
+ const targetNode = findNode(result.tree, targetPath);
2481
+ if (!targetNode) {
2482
+ return reply.status(404).send({
2483
+ error: 'NOT_FOUND',
2484
+ message: 'Meta path not found: ' + targetPath,
2485
+ });
2486
+ }
2487
+ const meta = JSON.parse(readFileSync(join(targetNode.metaPath, 'meta.json'), 'utf8'));
2488
+ // Field projection
2489
+ const defaultExclude = new Set([
2490
+ '_architect',
2491
+ '_builder',
2492
+ '_critic',
2493
+ '_content',
2494
+ '_feedback',
2495
+ ]);
2496
+ const fieldList = query.fields?.split(',');
2497
+ const projectMeta = (m) => {
2498
+ if (fieldList) {
2499
+ const r = {};
2500
+ for (const f of fieldList)
2501
+ r[f] = m[f];
2502
+ return r;
2503
+ }
2504
+ const r = {};
2505
+ for (const [k, v] of Object.entries(m)) {
2506
+ if (!defaultExclude.has(k))
2507
+ r[k] = v;
2508
+ }
2509
+ return r;
2510
+ };
2511
+ // Compute scope
2512
+ const { scopeFiles, allFiles } = await getScopeFiles(targetNode, watcher);
2513
+ // Compute staleness
2514
+ const metaTyped = meta;
2515
+ const staleSeconds = metaTyped._generatedAt
2516
+ ? Math.round((Date.now() - new Date(metaTyped._generatedAt).getTime()) / 1000)
2517
+ : null;
2518
+ const score = computeStalenessScore(staleSeconds, metaTyped._depth ?? 0, metaTyped._emphasis ?? 1, config.depthWeight);
2519
+ const response = {
2520
+ path: targetNode.metaPath,
2521
+ meta: projectMeta(meta),
2522
+ scope: {
2523
+ ownedFiles: scopeFiles.length,
2524
+ childMetas: targetNode.children.length,
2525
+ totalFiles: allFiles.length,
2526
+ },
2527
+ staleness: {
2528
+ seconds: staleSeconds,
2529
+ score: Math.round(score * 100) / 100,
2530
+ },
2531
+ };
2532
+ // Archive
2533
+ if (query.includeArchive) {
2534
+ const archiveFiles = listArchiveFiles(targetNode.metaPath);
2535
+ const limit = typeof query.includeArchive === 'number'
2536
+ ? query.includeArchive
2537
+ : archiveFiles.length;
2538
+ const selected = archiveFiles.slice(-limit).reverse();
2539
+ response.archive = selected.map((af) => {
2540
+ const raw = readFileSync(af, 'utf8');
2541
+ return projectMeta(JSON.parse(raw));
2542
+ });
2543
+ }
2544
+ return response;
2545
+ });
2546
+ }
2547
+
2548
+ /**
2549
+ * GET /preview — dry-run synthesis preview.
2550
+ *
2551
+ * @module routes/preview
2552
+ */
2553
+ function registerPreviewRoute(app, deps) {
2554
+ app.get('/preview', async (request, reply) => {
2555
+ const { config, watcher } = deps;
2556
+ const query = request.query;
2557
+ let result;
2558
+ try {
2559
+ result = await listMetas(config, watcher);
2560
+ }
2561
+ catch {
2562
+ return reply.status(503).send({
2563
+ error: 'SERVICE_UNAVAILABLE',
2564
+ message: 'Watcher unreachable — cannot compute preview',
2565
+ });
2566
+ }
2567
+ let targetNode;
2568
+ if (query.path) {
2569
+ const normalized = normalizePath(query.path);
2570
+ targetNode = findNode(result.tree, normalized);
2571
+ if (!targetNode) {
2572
+ return {
2573
+ error: 'NOT_FOUND',
2574
+ message: 'Meta path not found: ' + query.path,
2575
+ };
2576
+ }
2577
+ }
2578
+ else {
2579
+ // Select stalest candidate
2580
+ const stale = result.entries
2581
+ .filter((e) => e.stalenessSeconds > 0)
2582
+ .map((e) => ({
2583
+ node: e.node,
2584
+ meta: e.meta,
2585
+ actualStaleness: e.stalenessSeconds,
2586
+ }));
2587
+ const stalestPath = discoverStalestPath(stale, config.depthWeight);
2588
+ if (!stalestPath) {
2589
+ return { message: 'No stale metas found. Nothing to synthesize.' };
2590
+ }
2591
+ targetNode = findNode(result.tree, stalestPath);
2592
+ }
2593
+ const meta = JSON.parse(readFileSync(join(targetNode.metaPath, 'meta.json'), 'utf8'));
2594
+ // Scope files
2595
+ const { scopeFiles } = await getScopeFiles(targetNode, watcher);
2596
+ const structureHash = computeStructureHash(scopeFiles);
2597
+ const structureChanged = structureHash !== meta._structureHash;
2598
+ const latestArchive = readLatestArchive(targetNode.metaPath);
2599
+ const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
2600
+ const architectTriggered = isArchitectTriggered(meta, structureChanged, steerChanged, config.architectEvery);
2601
+ // Delta files
2602
+ const deltaFiles = await getDeltaFiles(targetNode, watcher, meta._generatedAt, scopeFiles);
2603
+ // EMA token estimates
2604
+ const estimatedTokens = {
2605
+ architect: meta._architectTokensAvg ?? meta._architectTokens ?? 0,
2606
+ builder: meta._builderTokensAvg ?? meta._builderTokens ?? 0,
2607
+ critic: meta._criticTokensAvg ?? meta._criticTokens ?? 0,
2608
+ };
2609
+ // Compute staleness
2610
+ const stalenessSeconds = meta._generatedAt
2611
+ ? Math.round((Date.now() - new Date(meta._generatedAt).getTime()) / 1000)
2612
+ : null;
2613
+ const stalenessScore = computeStalenessScore(stalenessSeconds, meta._depth ?? 0, meta._emphasis ?? 1, config.depthWeight);
2614
+ return {
2615
+ path: targetNode.metaPath,
2616
+ staleness: {
2617
+ seconds: stalenessSeconds,
2618
+ score: Math.round(stalenessScore * 100) / 100,
2619
+ },
2620
+ architectWillRun: architectTriggered,
2621
+ architectReason: [
2622
+ ...(!meta._builder ? ['no cached builder (first run)'] : []),
2623
+ ...(structureChanged ? ['structure changed'] : []),
2624
+ ...(steerChanged ? ['steer changed'] : []),
2625
+ ...((meta._synthesisCount ?? 0) >= config.architectEvery
2626
+ ? ['periodic refresh']
2627
+ : []),
2628
+ ].join(', ') || 'not triggered',
2629
+ scope: {
2630
+ ownedFiles: scopeFiles.length,
2631
+ childMetas: targetNode.children.length,
2632
+ deltaFiles: deltaFiles
2633
+ .slice(0, 50)
2634
+ .map((f) => ({ path: f, action: 'modified' })),
2635
+ deltaCount: deltaFiles.length,
2636
+ },
2637
+ estimatedTokens,
2638
+ };
2639
+ });
2640
+ }
2641
+
2642
+ /**
2643
+ * POST /seed — create a .meta/ directory with an empty meta.json.
2644
+ *
2645
+ * @module routes/seed
2646
+ */
2647
+ const seedBodySchema = z.object({
2648
+ path: z.string().min(1),
2649
+ });
2650
+ function registerSeedRoute(app, deps) {
2651
+ app.post('/seed', (request, reply) => {
2652
+ const body = seedBodySchema.parse(request.body);
2653
+ const metaDir = resolveMetaDir(body.path);
2654
+ if (existsSync(metaDir)) {
2655
+ return reply.status(409).send({
2656
+ error: 'CONFLICT',
2657
+ message: `.meta directory already exists at ${body.path}`,
2658
+ });
2659
+ }
2660
+ deps.logger.info({ metaDir }, 'creating .meta directory');
2661
+ mkdirSync(metaDir, { recursive: true });
2662
+ const metaJson = { _id: randomUUID() };
2663
+ const metaJsonPath = join(metaDir, 'meta.json');
2664
+ deps.logger.info({ metaJsonPath }, 'writing meta.json');
2665
+ writeFileSync(metaJsonPath, JSON.stringify(metaJson, null, 2) + '\n');
2666
+ return reply.status(201).send({
2667
+ status: 'created',
2668
+ path: body.path,
2669
+ metaDir,
2670
+ _id: metaJson._id,
2671
+ });
2672
+ });
2673
+ }
2674
+
2675
+ /**
2676
+ * GET /status — service health and status overview.
2677
+ *
2678
+ * On-demand dependency health checks (lightweight ping).
2679
+ *
2680
+ * @module routes/status
2681
+ */
2682
+ async function checkDependency(url, path) {
2683
+ const checkedAt = new Date().toISOString();
2684
+ try {
2685
+ const res = await fetch(new URL(path, url), {
2686
+ signal: AbortSignal.timeout(3000),
2687
+ });
2688
+ return { url, status: res.ok ? 'ok' : 'error', checkedAt };
2689
+ }
2690
+ catch {
2691
+ return { url, status: 'unreachable', checkedAt };
2692
+ }
2693
+ }
2694
+ function registerStatusRoute(app, deps) {
2695
+ app.get('/status', async () => {
2696
+ const { config, queue, scheduler, stats, watcher } = deps;
2697
+ // On-demand dependency checks
2698
+ const [watcherHealth, gatewayHealth] = await Promise.all([
2699
+ checkDependency(config.watcherUrl, '/status'),
2700
+ checkDependency(config.gatewayUrl, '/api/status'),
2701
+ ]);
2702
+ const degraded = watcherHealth.status !== 'ok' || gatewayHealth.status !== 'ok';
2703
+ // Determine status
2704
+ let status;
2705
+ if (deps.shuttingDown) {
2706
+ status = 'stopping';
2707
+ }
2708
+ else if (queue.current) {
2709
+ status = 'synthesizing';
2710
+ }
2711
+ else if (degraded) {
2712
+ status = 'degraded';
2713
+ }
2714
+ else {
2715
+ status = 'idle';
2716
+ }
2717
+ // Metas summary from listMetas (already computed)
2718
+ let metasSummary = { total: 0, stale: 0, errors: 0, neverSynthesized: 0 };
2719
+ try {
2720
+ const result = await listMetas(config, watcher);
2721
+ metasSummary = {
2722
+ total: result.summary.total,
2723
+ stale: result.summary.stale,
2724
+ errors: result.summary.errors,
2725
+ neverSynthesized: result.summary.neverSynthesized,
2726
+ };
2727
+ }
2728
+ catch {
2729
+ // Watcher unreachable — leave zeros
2730
+ }
2731
+ return {
2732
+ service: 'jeeves-meta',
2733
+ version: '0.4.0',
2734
+ uptime: process.uptime(),
2735
+ status,
2736
+ currentTarget: queue.current?.path ?? null,
2737
+ queue: queue.getState(),
2738
+ stats: {
2739
+ totalSyntheses: stats.totalSyntheses,
2740
+ totalTokens: stats.totalTokens,
2741
+ totalErrors: stats.totalErrors,
2742
+ lastCycleDurationMs: stats.lastCycleDurationMs,
2743
+ lastCycleAt: stats.lastCycleAt,
2744
+ },
2745
+ schedule: {
2746
+ expression: config.schedule,
2747
+ nextAt: scheduler?.nextRunAt?.toISOString() ?? null,
2748
+ },
2749
+ dependencies: {
2750
+ watcher: watcherHealth,
2751
+ gateway: gatewayHealth,
2752
+ },
2753
+ metas: metasSummary,
2754
+ };
2755
+ });
2756
+ }
2757
+
2758
+ /**
2759
+ * POST /synthesize route handler.
2760
+ *
2761
+ * @module routes/synthesize
2762
+ */
2763
+ const synthesizeBodySchema = z.object({
2764
+ path: z.string().optional(),
2765
+ });
2766
+ /** Register the POST /synthesize route. */
2767
+ function registerSynthesizeRoute(app, deps) {
2768
+ app.post('/synthesize', async (request, reply) => {
2769
+ const body = synthesizeBodySchema.parse(request.body);
2770
+ const { config, watcher, queue } = deps;
2771
+ let targetPath;
2772
+ if (body.path) {
2773
+ targetPath = body.path;
2774
+ }
2775
+ else {
2776
+ // Discover stalest candidate
2777
+ let result;
2778
+ try {
2779
+ result = await listMetas(config, watcher);
2780
+ }
2781
+ catch {
2782
+ return reply.status(503).send({
2783
+ error: 'SERVICE_UNAVAILABLE',
2784
+ message: 'Watcher unreachable — cannot discover candidates',
2785
+ });
2786
+ }
2787
+ const stale = result.entries
2788
+ .filter((e) => e.stalenessSeconds > 0)
2789
+ .map((e) => ({
2790
+ node: e.node,
2791
+ meta: e.meta,
2792
+ actualStaleness: e.stalenessSeconds,
2793
+ }));
2794
+ const stalest = discoverStalestPath(stale, config.depthWeight);
2795
+ if (!stalest) {
2796
+ return reply.code(200).send({
2797
+ status: 'skipped',
2798
+ message: 'No stale metas found. Nothing to synthesize.',
2799
+ });
2800
+ }
2801
+ targetPath = stalest;
2802
+ }
2803
+ const result = queue.enqueue(targetPath, body.path !== undefined);
2804
+ return reply.code(202).send({
2805
+ status: 'accepted',
2806
+ path: targetPath,
2807
+ queuePosition: result.position,
2808
+ alreadyQueued: result.alreadyQueued,
2809
+ });
2810
+ });
2811
+ }
2812
+
2813
+ /**
2814
+ * POST /unlock — remove .lock from a .meta/ directory.
2815
+ *
2816
+ * @module routes/unlock
2817
+ */
2818
+ const unlockBodySchema = z.object({
2819
+ path: z.string().min(1),
2820
+ });
2821
+ function registerUnlockRoute(app, deps) {
2822
+ app.post('/unlock', (request, reply) => {
2823
+ const body = unlockBodySchema.parse(request.body);
2824
+ const metaDir = resolveMetaDir(body.path);
2825
+ const lockPath = join(metaDir, '.lock');
2826
+ if (!existsSync(lockPath)) {
2827
+ return reply.status(409).send({
2828
+ error: 'ALREADY_UNLOCKED',
2829
+ message: `No lock file at ${body.path} (already unlocked)`,
2830
+ });
2831
+ }
2832
+ deps.logger.info({ lockPath }, 'removing lock file');
2833
+ unlinkSync(lockPath);
2834
+ return reply.status(200).send({
2835
+ status: 'unlocked',
2836
+ path: body.path,
2837
+ });
2838
+ });
2839
+ }
2840
+
2841
+ /**
2842
+ * Route registration for jeeves-meta service.
2843
+ *
2844
+ * @module routes
2845
+ */
2846
+ /** Register all HTTP routes on the Fastify instance. */
2847
+ function registerRoutes(app, deps) {
2848
+ // Global error handler for validation + watcher errors
2849
+ app.setErrorHandler((error, _request, reply) => {
2850
+ if (error.validation) {
2851
+ return reply
2852
+ .status(400)
2853
+ .send({ error: 'BAD_REQUEST', message: error.message });
2854
+ }
2855
+ if (error.statusCode === 404) {
2856
+ return reply
2857
+ .status(404)
2858
+ .send({ error: 'NOT_FOUND', message: error.message });
2859
+ }
2860
+ deps.logger.error(error, 'Unhandled route error');
2861
+ return reply
2862
+ .status(500)
2863
+ .send({ error: 'INTERNAL_ERROR', message: error.message });
2864
+ });
2865
+ registerStatusRoute(app, deps);
2866
+ registerMetasRoutes(app, deps);
2867
+ registerSynthesizeRoute(app, deps);
2868
+ registerPreviewRoute(app, deps);
2869
+ registerSeedRoute(app, deps);
2870
+ registerUnlockRoute(app, deps);
2871
+ registerConfigValidateRoute(app, deps);
2872
+ }
2873
+
2874
+ /**
2875
+ * Virtual rule registration with jeeves-watcher.
2876
+ *
2877
+ * Service registers inference rules at startup (with retry) and
2878
+ * re-registers opportunistically when watcher restart is detected.
2879
+ *
2880
+ * @module rules
2881
+ */
2882
+ const SOURCE = 'jeeves-meta';
2883
+ const MAX_RETRIES = 10;
2884
+ const RETRY_BASE_MS = 2000;
2885
+ /**
2886
+ * Convert a `Record<string, unknown>` config property into watcher
2887
+ * schema `set` directives: `{ key: { set: value } }` per entry.
2888
+ */
2889
+ function toSchemaSetDirectives(props) {
2890
+ return Object.fromEntries(Object.entries(props).map(([k, v]) => [k, { set: v }]));
2891
+ }
2892
+ /** Build the three virtual rule definitions. */
2893
+ function buildMetaRules(config) {
2894
+ return [
2895
+ {
2896
+ name: 'meta-current',
2897
+ description: 'Live jeeves-meta .meta/meta.json files',
2898
+ match: {
2899
+ properties: {
2900
+ file: {
2901
+ properties: {
2902
+ path: { type: 'string', glob: '**/.meta/meta.json' },
2903
+ },
2904
+ },
2905
+ },
2906
+ },
2907
+ schema: [
2908
+ 'base',
2909
+ {
2910
+ properties: {
2911
+ ...toSchemaSetDirectives(config.metaProperty),
2912
+ meta_id: { type: 'string', set: '{{json._id}}' },
2913
+ meta_steer: { type: 'string', set: '{{json._steer}}' },
2914
+ meta_depth: { type: 'number', set: '{{json._depth}}' },
2915
+ meta_emphasis: { type: 'number', set: '{{json._emphasis}}' },
2916
+ meta_synthesis_count: {
2917
+ type: 'integer',
2918
+ set: '{{json._synthesisCount}}',
2919
+ },
2920
+ meta_structure_hash: {
2921
+ type: 'string',
2922
+ set: '{{json._structureHash}}',
2923
+ },
2924
+ meta_architect_tokens: {
2925
+ type: 'integer',
2926
+ set: '{{json._architectTokens}}',
2927
+ },
2928
+ meta_builder_tokens: {
2929
+ type: 'integer',
2930
+ set: '{{json._builderTokens}}',
2931
+ },
2932
+ meta_critic_tokens: {
2933
+ type: 'integer',
2934
+ set: '{{json._criticTokens}}',
2935
+ },
2936
+ meta_error_step: {
2937
+ type: 'string',
2938
+ set: '{{json._error.step}}',
2939
+ },
2940
+ generated_at_unix: {
2941
+ type: 'integer',
2942
+ set: '{{toUnix json._generatedAt}}',
2943
+ },
2944
+ has_error: {
2945
+ type: 'boolean',
2946
+ set: '{{#if json._error}}true{{else}}false{{/if}}',
2947
+ },
2948
+ },
2949
+ },
2950
+ ],
2951
+ render: {
2952
+ frontmatter: [
2953
+ 'meta_id',
2954
+ 'meta_steer',
2955
+ 'generated_at_unix',
2956
+ 'meta_depth',
2957
+ 'meta_emphasis',
2958
+ 'meta_architect_tokens',
2959
+ 'meta_builder_tokens',
2960
+ 'meta_critic_tokens',
2961
+ ],
2962
+ body: [{ path: 'json._content', heading: 1, label: 'Synthesis' }],
2963
+ },
2964
+ renderAs: 'md',
2965
+ },
2966
+ {
2967
+ name: 'meta-archive',
2968
+ description: 'Archived jeeves-meta .meta/archive snapshots',
2969
+ match: {
2970
+ properties: {
2971
+ file: {
2972
+ properties: {
2973
+ path: { type: 'string', glob: '**/.meta/archive/*.json' },
2974
+ },
2975
+ },
2976
+ },
2977
+ },
2978
+ schema: [
2979
+ 'base',
2980
+ {
2981
+ properties: {
2982
+ ...toSchemaSetDirectives(config.metaArchiveProperty),
2983
+ meta_id: { type: 'string', set: '{{json._id}}' },
2984
+ archived: { type: 'boolean', set: 'true' },
2985
+ archived_at: { type: 'string', set: '{{json._archivedAt}}' },
2986
+ },
2987
+ },
2988
+ ],
2989
+ render: {
2990
+ frontmatter: ['meta_id', 'archived', 'archived_at'],
2991
+ body: [
2992
+ {
2993
+ path: 'json._content',
2994
+ heading: 1,
2995
+ label: 'Synthesis (archived)',
2996
+ },
2997
+ ],
2998
+ },
2999
+ renderAs: 'md',
3000
+ },
3001
+ {
3002
+ name: 'meta-config',
3003
+ description: 'jeeves-meta configuration file',
3004
+ match: {
3005
+ properties: {
3006
+ file: {
3007
+ properties: {
3008
+ path: { type: 'string', glob: '**/jeeves-meta.config.json' },
3009
+ },
3010
+ },
3011
+ },
3012
+ },
3013
+ schema: ['base', { properties: { domains: { set: ['meta-config'] } } }],
3014
+ render: {
3015
+ frontmatter: [
3016
+ 'watcherUrl',
3017
+ 'gatewayUrl',
3018
+ 'architectEvery',
3019
+ 'depthWeight',
3020
+ 'maxArchive',
3021
+ 'maxLines',
3022
+ ],
3023
+ body: [
3024
+ {
3025
+ path: 'json.defaultArchitect',
3026
+ heading: 2,
3027
+ label: 'Default Architect Prompt',
3028
+ },
3029
+ {
3030
+ path: 'json.defaultCritic',
3031
+ heading: 2,
3032
+ label: 'Default Critic Prompt',
3033
+ },
3034
+ ],
3035
+ },
3036
+ renderAs: 'md',
3037
+ },
3038
+ ];
3039
+ }
3040
+ /**
3041
+ * Manages virtual rule registration with watcher.
3042
+ *
3043
+ * - Registers at startup with exponential retry
3044
+ * - Tracks watcher uptime for restart detection
3045
+ * - Re-registers opportunistically when uptime decreases
3046
+ */
3047
+ class RuleRegistrar {
3048
+ config;
3049
+ logger;
3050
+ watcherClient;
3051
+ lastWatcherUptime = null;
3052
+ registered = false;
3053
+ constructor(config, logger, watcher) {
3054
+ this.config = config;
3055
+ this.logger = logger;
3056
+ this.watcherClient = watcher;
3057
+ }
3058
+ /** Whether rules have been successfully registered. */
3059
+ get isRegistered() {
3060
+ return this.registered;
3061
+ }
3062
+ /**
3063
+ * Register rules with watcher. Retries with exponential backoff.
3064
+ * Non-blocking — logs errors but never throws.
3065
+ */
3066
+ async register() {
3067
+ const rules = buildMetaRules(this.config);
3068
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
3069
+ try {
3070
+ await this.watcherClient.registerRules(SOURCE, rules);
3071
+ this.registered = true;
3072
+ this.logger.info('Virtual rules registered with watcher');
3073
+ return;
3074
+ }
3075
+ catch (err) {
3076
+ const delayMs = RETRY_BASE_MS * Math.pow(2, attempt);
3077
+ this.logger.warn({ attempt: attempt + 1, delayMs, err }, 'Rule registration failed, retrying');
3078
+ await new Promise((r) => setTimeout(r, delayMs));
3079
+ }
3080
+ }
3081
+ this.logger.error('Rule registration failed after max retries — service degraded');
3082
+ }
3083
+ /**
3084
+ * Check watcher uptime and re-register if it decreased (restart detected).
3085
+ *
3086
+ * @param currentUptime - Current watcher uptime in seconds.
3087
+ */
3088
+ async checkAndReregister(currentUptime) {
3089
+ if (this.lastWatcherUptime !== null &&
3090
+ currentUptime < this.lastWatcherUptime) {
3091
+ this.logger.info({ previous: this.lastWatcherUptime, current: currentUptime }, 'Watcher restart detected — re-registering rules');
3092
+ this.registered = false;
3093
+ await this.register();
3094
+ }
3095
+ this.lastWatcherUptime = currentUptime;
3096
+ }
3097
+ }
3098
+
3099
+ /**
3100
+ * Minimal Fastify HTTP server for jeeves-meta service.
3101
+ *
3102
+ * @module server
3103
+ */
3104
+ /**
3105
+ * Create and configure the Fastify server.
3106
+ *
3107
+ * @param options - Server creation options.
3108
+ * @returns Configured Fastify instance (not yet listening).
3109
+ */
3110
+ function createServer(options) {
3111
+ const app = Fastify({ logger: options.logger });
3112
+ registerRoutes(app, {
3113
+ config: options.config,
3114
+ logger: options.logger,
3115
+ queue: options.queue,
3116
+ watcher: options.watcher,
3117
+ scheduler: options.scheduler,
3118
+ stats: options.stats,
3119
+ });
3120
+ return app;
3121
+ }
3122
+
3123
+ /**
3124
+ * Graceful shutdown handler.
3125
+ *
3126
+ * On SIGTERM/SIGINT: stops scheduler, drains queue, cleans up locks.
3127
+ *
3128
+ * @module shutdown
3129
+ */
3130
+ /**
3131
+ * Register shutdown handlers for SIGTERM and SIGINT.
3132
+ *
3133
+ * Flow:
3134
+ * 1. Stop scheduler (no new ticks)
3135
+ * 2. If synthesis in progress, release its lock
3136
+ * 3. Close Fastify server
3137
+ * 4. Exit
3138
+ */
3139
+ function registerShutdownHandlers(deps) {
3140
+ let shuttingDown = false;
3141
+ const shutdown = async (signal) => {
3142
+ if (shuttingDown)
3143
+ return;
3144
+ shuttingDown = true;
3145
+ deps.logger.info({ signal }, 'Shutdown signal received');
3146
+ // Signal stopping state to /status
3147
+ if (deps.routeDeps) {
3148
+ deps.routeDeps.shuttingDown = true;
3149
+ }
3150
+ // 1. Stop scheduler
3151
+ if (deps.scheduler) {
3152
+ deps.scheduler.stop();
3153
+ deps.logger.info('Scheduler stopped');
3154
+ }
3155
+ // 2. Release lock for in-progress synthesis
3156
+ const current = deps.queue.current;
3157
+ if (current) {
3158
+ try {
3159
+ releaseLock(current.path);
3160
+ deps.logger.info({ path: current.path }, 'Released lock for in-progress synthesis');
3161
+ }
3162
+ catch {
3163
+ deps.logger.warn({ path: current.path }, 'Failed to release lock during shutdown');
3164
+ }
3165
+ }
3166
+ // 3. Close server
3167
+ try {
3168
+ await deps.server.close();
3169
+ deps.logger.info('HTTP server closed');
3170
+ }
3171
+ catch (err) {
3172
+ deps.logger.error(err, 'Error closing HTTP server');
3173
+ }
3174
+ process.exit(0);
3175
+ };
3176
+ process.on('SIGTERM', () => void shutdown('SIGTERM'));
3177
+ process.on('SIGINT', () => void shutdown('SIGINT'));
3178
+ }
3179
+
3180
+ /**
3181
+ * HTTP implementation of the WatcherClient interface.
3182
+ *
3183
+ * Talks to jeeves-watcher's POST /scan and POST /rules endpoints
3184
+ * with retry and exponential backoff.
3185
+ *
3186
+ * @module watcher-client/HttpWatcherClient
3187
+ */
3188
+ /** Default retry configuration. */
3189
+ const DEFAULT_MAX_RETRIES = 3;
3190
+ const DEFAULT_BACKOFF_BASE_MS = 1000;
3191
+ const DEFAULT_BACKOFF_FACTOR = 4;
3192
+ /** Check if an error is transient (worth retrying). */
3193
+ function isTransient(status) {
3194
+ return status >= 500 || status === 408 || status === 429;
3195
+ }
3196
+ /**
3197
+ * HTTP-based WatcherClient implementation with retry.
3198
+ */
3199
+ class HttpWatcherClient {
3200
+ baseUrl;
3201
+ maxRetries;
3202
+ backoffBaseMs;
3203
+ backoffFactor;
3204
+ constructor(options) {
3205
+ this.baseUrl = options.baseUrl.replace(/\/+$/, '');
3206
+ this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
1654
3207
  this.backoffBaseMs = options.backoffBaseMs ?? DEFAULT_BACKOFF_BASE_MS;
1655
3208
  this.backoffFactor = options.backoffFactor ?? DEFAULT_BACKOFF_FACTOR;
1656
3209
  }
@@ -1735,4 +3288,176 @@ class HttpWatcherClient {
1735
3288
  }
1736
3289
  }
1737
3290
 
1738
- export { GatewayExecutor, HttpWatcherClient, acquireLock, actualStaleness, buildArchitectTask, buildBuilderTask, buildContextPackage, buildCriticTask, buildMetaFilter, buildOwnershipTree, computeEffectiveStaleness, computeEma, computeStructureHash, createSnapshot, discoverMetas, filterInScope, findNode, getScopePrefix, hasSteerChanged, isArchitectTriggered, isLocked, isStale, listArchiveFiles, listMetas, loadSynthConfig, mergeAndWrite, metaJsonSchema, normalizePath$1 as normalizePath, orchestrate, paginatedScan, parseArchitectOutput, parseBuilderOutput, parseCriticOutput, pruneArchive, readLatestArchive, releaseLock, resolveConfigPath, selectCandidate, synthConfigSchema, synthErrorSchema, toSynthError };
3291
+ /**
3292
+ * Jeeves Meta Service — knowledge synthesis HTTP service for the Jeeves platform.
3293
+ *
3294
+ * @packageDocumentation
3295
+ */
3296
+ // ── Archive ──
3297
+ /**
3298
+ * Bootstrap the service: create logger, build server, start listening,
3299
+ * wire scheduler, queue processing, rule registration, config hot-reload,
3300
+ * startup lock cleanup, and shutdown.
3301
+ *
3302
+ * @param config - Validated service configuration.
3303
+ * @param configPath - Optional path to config file for hot-reload.
3304
+ */
3305
+ async function startService(config, configPath) {
3306
+ const logger = createLogger({
3307
+ level: config.logging.level,
3308
+ file: config.logging.file,
3309
+ });
3310
+ // Wire synthesis executor + watcher
3311
+ const watcher = new HttpWatcherClient({ baseUrl: config.watcherUrl });
3312
+ const executor = new GatewayExecutor({
3313
+ gatewayUrl: config.gatewayUrl,
3314
+ apiKey: config.gatewayApiKey,
3315
+ });
3316
+ // Runtime stats (mutable, shared with routes)
3317
+ const stats = {
3318
+ totalSyntheses: 0,
3319
+ totalTokens: 0,
3320
+ totalErrors: 0,
3321
+ lastCycleDurationMs: null,
3322
+ lastCycleAt: null,
3323
+ };
3324
+ const queue = new SynthesisQueue(logger);
3325
+ // Scheduler (needs watcher for discovery)
3326
+ const scheduler = new Scheduler(config, queue, logger, watcher);
3327
+ const routeDeps = {
3328
+ config,
3329
+ logger,
3330
+ queue,
3331
+ watcher,
3332
+ scheduler,
3333
+ stats,
3334
+ };
3335
+ const server = createServer({
3336
+ logger,
3337
+ config,
3338
+ queue,
3339
+ watcher,
3340
+ scheduler,
3341
+ stats,
3342
+ });
3343
+ // Start HTTP server
3344
+ try {
3345
+ await server.listen({ port: config.port, host: '0.0.0.0' });
3346
+ logger.info({ port: config.port }, 'Service listening');
3347
+ }
3348
+ catch (err) {
3349
+ logger.error(err, 'Failed to start service');
3350
+ process.exit(1);
3351
+ }
3352
+ // Progress reporter — uses shared config reference so hot-reload propagates
3353
+ const progress = new ProgressReporter(config, logger);
3354
+ // Wire queue processing — synthesize one meta per dequeue
3355
+ const synthesizeFn = async (path) => {
3356
+ const startMs = Date.now();
3357
+ let cycleTokens = 0;
3358
+ await progress.report({
3359
+ type: 'synthesis_start',
3360
+ metaPath: path,
3361
+ });
3362
+ try {
3363
+ const results = await orchestrate(config, executor, watcher, path, async (evt) => {
3364
+ // Track token stats from phase completions
3365
+ if (evt.type === 'phase_complete' && evt.tokens) {
3366
+ stats.totalTokens += evt.tokens;
3367
+ cycleTokens += evt.tokens;
3368
+ }
3369
+ await progress.report(evt);
3370
+ });
3371
+ // orchestrate() always returns exactly one result
3372
+ const result = results[0];
3373
+ const durationMs = Date.now() - startMs;
3374
+ // Update stats
3375
+ stats.totalSyntheses++;
3376
+ stats.lastCycleDurationMs = durationMs;
3377
+ stats.lastCycleAt = new Date().toISOString();
3378
+ if (result.error) {
3379
+ stats.totalErrors++;
3380
+ await progress.report({
3381
+ type: 'error',
3382
+ metaPath: path,
3383
+ error: result.error.message,
3384
+ });
3385
+ }
3386
+ else {
3387
+ scheduler.resetBackoff();
3388
+ await progress.report({
3389
+ type: 'synthesis_complete',
3390
+ metaPath: path,
3391
+ tokens: cycleTokens,
3392
+ durationMs,
3393
+ });
3394
+ }
3395
+ }
3396
+ catch (err) {
3397
+ stats.totalErrors++;
3398
+ const message = err instanceof Error ? err.message : String(err);
3399
+ await progress.report({
3400
+ type: 'error',
3401
+ metaPath: path,
3402
+ error: message,
3403
+ });
3404
+ throw err;
3405
+ }
3406
+ };
3407
+ // Auto-process queue when new items arrive
3408
+ queue.onEnqueue(() => {
3409
+ void queue.processQueue(synthesizeFn);
3410
+ });
3411
+ // Startup: clean stale locks (gap #16)
3412
+ try {
3413
+ const metaResult = await listMetas(config, watcher);
3414
+ const metaPaths = metaResult.entries.map((e) => e.node.metaPath);
3415
+ cleanupStaleLocks(metaPaths, logger);
3416
+ }
3417
+ catch (err) {
3418
+ logger.warn({ err }, 'Could not clean stale locks (watcher may be down)');
3419
+ }
3420
+ // Start scheduler
3421
+ scheduler.start();
3422
+ // Rule registration (fire-and-forget with retries)
3423
+ const registrar = new RuleRegistrar(config, logger, watcher);
3424
+ scheduler.setRegistrar(registrar);
3425
+ void registrar.register();
3426
+ // Config hot-reload (gap #12)
3427
+ if (configPath) {
3428
+ watchFile(configPath, { interval: 5000 }, () => {
3429
+ try {
3430
+ const newConfig = loadServiceConfig(configPath);
3431
+ // Hot-reloadable fields: schedule, reportChannel, logging level
3432
+ if (newConfig.schedule !== config.schedule) {
3433
+ scheduler.updateSchedule(newConfig.schedule);
3434
+ logger.info({ schedule: newConfig.schedule }, 'Schedule hot-reloaded');
3435
+ }
3436
+ if (newConfig.reportChannel !== config.reportChannel) {
3437
+ // Mutate shared config reference for progress reporter
3438
+ config.reportChannel =
3439
+ newConfig.reportChannel;
3440
+ logger.info({ reportChannel: newConfig.reportChannel }, 'reportChannel hot-reloaded');
3441
+ }
3442
+ if (newConfig.logging.level !== config.logging.level) {
3443
+ logger.level = newConfig.logging.level;
3444
+ logger.info({ level: newConfig.logging.level }, 'Log level hot-reloaded');
3445
+ }
3446
+ }
3447
+ catch (err) {
3448
+ logger.warn({ err }, 'Config hot-reload failed');
3449
+ }
3450
+ });
3451
+ }
3452
+ // Shutdown handlers
3453
+ registerShutdownHandlers({
3454
+ server,
3455
+ scheduler,
3456
+ queue,
3457
+ logger,
3458
+ routeDeps,
3459
+ });
3460
+ logger.info('Service fully initialized');
3461
+ }
3462
+
3463
+ export { GatewayExecutor, HttpWatcherClient, ProgressReporter, RuleRegistrar, Scheduler, SynthesisQueue, acquireLock, actualStaleness, buildArchitectTask, buildBuilderTask, buildContextPackage, buildCriticTask, buildMetaFilter, buildOwnershipTree, cleanupStaleLocks, computeEffectiveStaleness, computeEma, computeStructureHash, createLogger, createServer, createSnapshot, discoverMetas, filterInScope, findNode, formatProgressEvent, getScopePrefix, hasSteerChanged, isArchitectTriggered, isLocked, isStale, listArchiveFiles, listMetas, loadServiceConfig, mergeAndWrite, metaConfigSchema, metaErrorSchema, metaJsonSchema, normalizePath, orchestrate, paginatedScan, parseArchitectOutput, parseBuilderOutput, parseCriticOutput, pruneArchive, readLatestArchive, readLockState, registerRoutes, registerShutdownHandlers, releaseLock, resolveConfigPath, resolveMetaDir, selectCandidate, serviceConfigSchema, sleep, startService, toMetaError };