@karmaniverous/jeeves-meta 0.15.3 → 0.15.4

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,4 +1,4 @@
1
- import fs, { readdirSync, readFileSync, existsSync, writeFileSync, renameSync, unlinkSync, mkdirSync, copyFileSync, statSync, watchFile } from 'node:fs';
1
+ import fs, { readdirSync, readFileSync, existsSync, writeFileSync, renameSync, unlinkSync, statSync, mkdirSync, copyFileSync, watchFile } from 'node:fs';
2
2
  import path, { join, dirname, resolve, basename, relative, posix } from 'node:path';
3
3
  import { unlink, readFile, mkdir, writeFile, copyFile } from 'node:fs/promises';
4
4
  import { fileURLToPath } from 'node:url';
@@ -8116,381 +8116,120 @@ function registerCustomCliCommands(program) {
8116
8116
  }
8117
8117
 
8118
8118
  /**
8119
- * Shared live config hot-reload support.
8119
+ * Compute summary statistics from an array of MetaEntry objects.
8120
8120
  *
8121
- * Used by both file-watch reloads in bootstrap and POST /config/apply
8122
- * via the component descriptor's onConfigApply callback.
8121
+ * Shared between listMetas() (full list) and route handlers (filtered lists).
8123
8122
  *
8124
- * @module configHotReload
8123
+ * @module discovery/computeSummary
8125
8124
  */
8126
8125
  /**
8127
- * Fields that require a service restart to take effect.
8126
+ * Compute summary statistics from a list of meta entries.
8128
8127
  *
8129
- * Shared between the descriptor's `onConfigApply` and the file-watcher
8130
- * hot-reload in `bootstrap.ts`.
8128
+ * @param entries - Enriched meta entries (full or filtered).
8129
+ * @param depthWeight - Config depth weight for effective staleness calculation.
8130
+ * @returns Aggregated summary statistics.
8131
8131
  */
8132
- const RESTART_REQUIRED_FIELDS = [
8133
- 'port',
8134
- 'watcherUrl',
8135
- 'gatewayUrl',
8136
- 'gatewayApiKey',
8137
- 'defaultArchitect',
8138
- 'defaultCritic',
8139
- ];
8140
- let runtime = null;
8141
- /** Register the active service runtime for config-apply hot reload. */
8142
- function registerConfigHotReloadRuntime(nextRuntime) {
8143
- runtime = nextRuntime;
8144
- }
8145
- /** Apply hot-reloadable config changes to the live shared config object. */
8146
- function applyHotReloadedConfig(newConfig) {
8147
- if (!runtime)
8148
- return;
8149
- const { config, logger, scheduler } = runtime;
8150
- for (const field of RESTART_REQUIRED_FIELDS) {
8151
- const oldVal = config[field];
8152
- const nextVal = newConfig[field];
8153
- if (oldVal !== nextVal) {
8154
- logger.warn({ field, oldValue: oldVal, newValue: nextVal }, 'Config field changed but requires restart to take effect');
8155
- }
8156
- }
8157
- if (newConfig.schedule !== config.schedule) {
8158
- scheduler?.updateSchedule(newConfig.schedule);
8159
- config.schedule = newConfig.schedule;
8160
- logger.info({ schedule: newConfig.schedule }, 'Schedule hot-reloaded');
8161
- }
8162
- if (newConfig.logging.level !== config.logging.level) {
8163
- logger.level = newConfig.logging.level;
8164
- config.logging.level = newConfig.logging.level;
8165
- logger.info({ level: newConfig.logging.level }, 'Log level hot-reloaded');
8166
- }
8167
- const restartSet = new Set(RESTART_REQUIRED_FIELDS);
8168
- for (const key of Object.keys(newConfig)) {
8169
- if (restartSet.has(key) || key === 'logging' || key === 'schedule') {
8170
- continue;
8132
+ function computeSummary(entries, depthWeight) {
8133
+ let staleCount = 0;
8134
+ let errorCount = 0;
8135
+ let lockedCount = 0;
8136
+ let disabledCount = 0;
8137
+ let neverSynthesizedCount = 0;
8138
+ let totalArchitectTokens = 0;
8139
+ let totalBuilderTokens = 0;
8140
+ let totalCriticTokens = 0;
8141
+ let stalestPath = null;
8142
+ let stalestEffective = -1;
8143
+ let lastSynthesizedPath = null;
8144
+ let lastSynthesizedAt = null;
8145
+ for (const e of entries) {
8146
+ if (e.stalenessSeconds > 0)
8147
+ staleCount++;
8148
+ if (e.hasError)
8149
+ errorCount++;
8150
+ if (e.locked)
8151
+ lockedCount++;
8152
+ if (e.disabled)
8153
+ disabledCount++;
8154
+ if (e.lastSynthesized === null)
8155
+ neverSynthesizedCount++;
8156
+ totalArchitectTokens += e.architectTokens ?? 0;
8157
+ totalBuilderTokens += e.builderTokens ?? 0;
8158
+ totalCriticTokens += e.criticTokens ?? 0;
8159
+ // Track last synthesized
8160
+ if (e.lastSynthesized &&
8161
+ (!lastSynthesizedAt || e.lastSynthesized > lastSynthesizedAt)) {
8162
+ lastSynthesizedAt = e.lastSynthesized;
8163
+ lastSynthesizedPath = e.path;
8171
8164
  }
8172
- const oldVal = config[key];
8173
- const nextVal = newConfig[key];
8174
- if (JSON.stringify(oldVal) !== JSON.stringify(nextVal)) {
8175
- config[key] = nextVal;
8176
- logger.info({ field: key }, 'Config field hot-reloaded');
8165
+ // Track stalest (effective staleness for scheduling)
8166
+ const depthFactor = Math.pow(1 + depthWeight, e.depth);
8167
+ const effectiveStaleness = e.stalenessSeconds * depthFactor * e.emphasis;
8168
+ if (effectiveStaleness > stalestEffective) {
8169
+ stalestEffective = effectiveStaleness;
8170
+ stalestPath = e.path;
8177
8171
  }
8178
8172
  }
8173
+ return {
8174
+ total: entries.length,
8175
+ stale: staleCount,
8176
+ errors: errorCount,
8177
+ locked: lockedCount,
8178
+ disabled: disabledCount,
8179
+ neverSynthesized: neverSynthesizedCount,
8180
+ tokens: {
8181
+ architect: totalArchitectTokens,
8182
+ builder: totalBuilderTokens,
8183
+ critic: totalCriticTokens,
8184
+ },
8185
+ stalestPath,
8186
+ lastSynthesizedPath,
8187
+ lastSynthesizedAt,
8188
+ };
8179
8189
  }
8180
8190
 
8181
8191
  /**
8182
- * Zod schema for jeeves-meta service configuration.
8192
+ * Discover .meta/ directories via watcher `/walk` endpoint.
8183
8193
  *
8184
- * The service config is a strict superset of the core (library-compatible) meta config.
8194
+ * Uses filesystem enumeration through the watcher (not Qdrant) to find
8195
+ * all `.meta/meta.json` files and returns deduplicated meta directory paths.
8185
8196
  *
8186
- * @module schema/config
8197
+ * @module discovery/discoverMetas
8187
8198
  */
8188
- /** Zod schema for the core (library-compatible) meta configuration. */
8189
- const metaConfigSchema = z.object({
8190
- /** Watcher service base URL. */
8191
- watcherUrl: z.url(),
8192
- /** OpenClaw gateway base URL for subprocess spawning. */
8193
- gatewayUrl: z.url().default('http://127.0.0.1:18789'),
8194
- /** Optional API key for gateway authentication. */
8195
- gatewayApiKey: z.string().optional(),
8196
- /** Run architect every N cycles (per meta). */
8197
- architectEvery: z.number().int().min(1).default(10),
8198
- /** Exponent for depth weighting in staleness formula. */
8199
- depthWeight: z.number().min(0).default(0.5),
8200
- /** Maximum archive snapshots to retain per meta. */
8201
- maxArchive: z.number().int().min(1).default(20),
8202
- /** Maximum lines of context to include in subprocess prompts. */
8203
- maxLines: z.number().int().min(50).default(500),
8204
- /** Architect subprocess timeout in seconds. */
8205
- architectTimeout: z.number().int().min(30).default(180),
8206
- /** Builder subprocess timeout in seconds. */
8207
- builderTimeout: z.number().int().min(60).default(360),
8208
- /** Critic subprocess timeout in seconds. */
8209
- criticTimeout: z.number().int().min(30).default(240),
8210
- /** Thinking level for spawned synthesis sessions. */
8211
- thinking: z.string().default('low'),
8212
- /** Resolved architect system prompt text. Falls back to built-in default. */
8213
- defaultArchitect: z.string().optional(),
8214
- /** Resolved critic system prompt text. Falls back to built-in default. */
8215
- defaultCritic: z.string().optional(),
8216
- /** Skip unchanged candidates, bump _generatedAt. */
8217
- skipUnchanged: z.boolean().default(true),
8218
- /** Watcher metadata properties applied to live .meta/meta.json files. */
8219
- metaProperty: z.record(z.string(), z.unknown()).default({ _meta: 'current' }),
8220
- /** Watcher metadata properties applied to archive snapshots. */
8221
- metaArchiveProperty: z
8222
- .record(z.string(), z.unknown())
8223
- .default({ _meta: 'archive' }),
8224
- });
8225
- /** Zod schema for logging configuration. */
8226
- const loggingSchema = z.object({
8227
- /** Log level. */
8228
- level: z.string().default('info'),
8229
- /** Optional file path for log output. */
8230
- file: z.string().optional(),
8231
- });
8232
- /** Zod schema for a single auto-seed policy rule. */
8233
- const autoSeedRuleSchema = z.object({
8234
- /** Glob pattern matched against watcher walk results. */
8235
- match: z.string(),
8236
- /** Optional steering prompt for seeded metas. */
8237
- steer: z.string().optional(),
8238
- /** Optional cross-references for seeded metas. */
8239
- crossRefs: z.array(z.string()).optional(),
8240
- });
8241
- /** Zod schema for jeeves-meta service configuration (superset of MetaConfig). */
8242
- const serviceConfigSchema = metaConfigSchema.extend({
8243
- /** HTTP port for the service (default: 1938). */
8244
- port: z.number().int().min(1).max(65535).default(1938),
8245
- /** Cron schedule for synthesis cycles (default: every 30 min). */
8246
- schedule: z.string().default('*/30 * * * *'),
8247
- /** Messaging channel name (e.g. 'slack'). Legacy: also used as target if reportTarget is unset. */
8248
- reportChannel: z.string().optional(),
8249
- /** Channel/user ID to send progress messages to. */
8250
- reportTarget: z.string().optional(),
8251
- /** Optional base URL for the service, used to construct entity links in progress reports. */
8252
- serverBaseUrl: z.string().optional(),
8253
- /** Interval in ms for periodic watcher health check. 0 = disabled. Default: 60000. */
8254
- watcherHealthIntervalMs: z.number().int().min(0).default(60_000),
8255
- /** Logging configuration. */
8256
- logging: loggingSchema.default(() => loggingSchema.parse({})),
8257
- /**
8258
- * Auto-seed policy: declarative rules for auto-creating .meta/ directories.
8259
- * Rules are evaluated in order; last match wins for steer/crossRefs.
8260
- */
8261
- autoSeed: z.array(autoSeedRuleSchema).optional().default([]),
8262
- });
8263
-
8264
8199
  /**
8265
- * Load and resolve jeeves-meta service config.
8266
- *
8267
- * Supports \@file: indirection and environment-variable substitution (dollar-brace pattern).
8200
+ * Discover all .meta/ directories via watcher walk.
8268
8201
  *
8269
- * @module configLoader
8270
- */
8271
- /**
8272
- * Deep-walk a value, replacing `\${VAR\}` patterns with process.env values.
8202
+ * Uses the watcher's `/walk` endpoint to find all `.meta/meta.json` files
8203
+ * and returns deduplicated meta directory paths.
8273
8204
  *
8274
- * @param value - Arbitrary JSON-compatible value.
8275
- * @returns Value with env-var placeholders resolved.
8205
+ * @param watcher - WatcherClient for walk queries.
8206
+ * @returns Array of normalized .meta/ directory paths.
8276
8207
  */
8277
- function substituteEnvVars(value) {
8278
- if (typeof value === 'string') {
8279
- return value.replace(/\$\{([^}]+)\}/g, (_match, name) => {
8280
- const envVal = process.env[name];
8281
- if (envVal === undefined) {
8282
- throw new Error(`Environment variable ${name} is not set`);
8283
- }
8284
- return envVal;
8285
- });
8286
- }
8287
- if (Array.isArray(value)) {
8288
- return value.map(substituteEnvVars);
8289
- }
8290
- if (value !== null && typeof value === 'object') {
8291
- const result = {};
8292
- for (const [key, val] of Object.entries(value)) {
8293
- result[key] = substituteEnvVars(val);
8294
- }
8295
- return result;
8208
+ async function discoverMetas(watcher) {
8209
+ const allPaths = await watcher.walk(['**/.meta/meta.json']);
8210
+ // Deduplicate by .meta/ directory path (handles multi-chunk files)
8211
+ const seen = new Set();
8212
+ const metaPaths = [];
8213
+ for (const filePath of allPaths) {
8214
+ const fp = normalizePath(filePath);
8215
+ // Derive .meta/ directory from file_path (strip /meta.json)
8216
+ const metaPath = fp.replace(/\/meta\.json$/, '');
8217
+ if (seen.has(metaPath))
8218
+ continue;
8219
+ seen.add(metaPath);
8220
+ metaPaths.push(metaPath);
8296
8221
  }
8297
- return value;
8222
+ return metaPaths;
8298
8223
  }
8224
+
8299
8225
  /**
8300
- * Resolve \@file: references in a config value.
8226
+ * File-system lock for preventing concurrent synthesis on the same meta.
8301
8227
  *
8302
- * @param value - String value that may start with "\@file:".
8303
- * @param baseDir - Base directory for resolving relative paths.
8304
- * @returns The resolved string (file contents or original value).
8305
- */
8306
- function resolveFileRef(value, baseDir) {
8307
- if (!value.startsWith('@file:'))
8308
- return value;
8309
- const filePath = join(baseDir, value.slice(6));
8310
- return readFileSync(filePath, 'utf8');
8311
- }
8312
- /**
8313
- * Migrate legacy config path to the new canonical location.
8314
- *
8315
- * If the old path `{configRoot}/jeeves-meta.config.json` exists and the new
8316
- * path `{configRoot}/jeeves-meta/config.json` does NOT exist, copies the file
8317
- * to the new location and logs a warning.
8318
- *
8319
- * @param configRoot - Root directory for configuration files.
8320
- * @param warn - Optional callback for logging the migration warning.
8321
- */
8322
- function migrateConfigPath(configRoot, warn) {
8323
- const oldPath = join(configRoot, 'jeeves-meta.config.json');
8324
- const newDir = join(configRoot, 'jeeves-meta');
8325
- const newPath = join(newDir, 'config.json');
8326
- if (existsSync(oldPath) && !existsSync(newPath)) {
8327
- mkdirSync(newDir, { recursive: true });
8328
- copyFileSync(oldPath, newPath);
8329
- const message = `Migrated config from ${oldPath} to ${newPath}. The old file can be removed.`;
8330
- if (warn) {
8331
- warn(message);
8332
- }
8333
- else {
8334
- console.warn(`[jeeves-meta] ${message}`);
8335
- }
8336
- }
8337
- }
8338
- /**
8339
- * Resolve config path from --config flag or JEEVES_META_CONFIG env var.
8340
- *
8341
- * @param args - CLI arguments (process.argv.slice(2)).
8342
- * @returns Resolved config path.
8343
- * @throws If no config path found.
8344
- */
8345
- function resolveConfigPath(args) {
8346
- let configIdx = args.indexOf('--config');
8347
- if (configIdx === -1)
8348
- configIdx = args.indexOf('-c');
8349
- if (configIdx !== -1 && args[configIdx + 1]) {
8350
- return args[configIdx + 1];
8351
- }
8352
- const envPath = process.env['JEEVES_META_CONFIG'];
8353
- if (envPath)
8354
- return envPath;
8355
- throw new Error('Config path required. Use --config <path> or set JEEVES_META_CONFIG env var.');
8356
- }
8357
- /**
8358
- * Load service config from a JSON file.
8359
- *
8360
- * Resolves \@file: references for defaultArchitect and defaultCritic,
8361
- * and substitutes environment-variable placeholders throughout.
8362
- *
8363
- * @param configPath - Path to config JSON file.
8364
- * @returns Validated ServiceConfig.
8365
- */
8366
- function loadServiceConfig(configPath) {
8367
- const rawText = readFileSync(configPath, 'utf8');
8368
- const raw = substituteEnvVars(JSON.parse(rawText));
8369
- const baseDir = dirname(configPath);
8370
- if (typeof raw['defaultArchitect'] === 'string') {
8371
- raw['defaultArchitect'] = resolveFileRef(raw['defaultArchitect'], baseDir);
8372
- }
8373
- if (typeof raw['defaultCritic'] === 'string') {
8374
- raw['defaultCritic'] = resolveFileRef(raw['defaultCritic'], baseDir);
8375
- }
8376
- return serviceConfigSchema.parse(raw);
8377
- }
8378
-
8379
- /**
8380
- * Compute summary statistics from an array of MetaEntry objects.
8381
- *
8382
- * Shared between listMetas() (full list) and route handlers (filtered lists).
8383
- *
8384
- * @module discovery/computeSummary
8385
- */
8386
- /**
8387
- * Compute summary statistics from a list of meta entries.
8388
- *
8389
- * @param entries - Enriched meta entries (full or filtered).
8390
- * @param depthWeight - Config depth weight for effective staleness calculation.
8391
- * @returns Aggregated summary statistics.
8392
- */
8393
- function computeSummary(entries, depthWeight) {
8394
- let staleCount = 0;
8395
- let errorCount = 0;
8396
- let lockedCount = 0;
8397
- let disabledCount = 0;
8398
- let neverSynthesizedCount = 0;
8399
- let totalArchitectTokens = 0;
8400
- let totalBuilderTokens = 0;
8401
- let totalCriticTokens = 0;
8402
- let stalestPath = null;
8403
- let stalestEffective = -1;
8404
- let lastSynthesizedPath = null;
8405
- let lastSynthesizedAt = null;
8406
- for (const e of entries) {
8407
- if (e.stalenessSeconds > 0)
8408
- staleCount++;
8409
- if (e.hasError)
8410
- errorCount++;
8411
- if (e.locked)
8412
- lockedCount++;
8413
- if (e.disabled)
8414
- disabledCount++;
8415
- if (e.lastSynthesized === null)
8416
- neverSynthesizedCount++;
8417
- totalArchitectTokens += e.architectTokens ?? 0;
8418
- totalBuilderTokens += e.builderTokens ?? 0;
8419
- totalCriticTokens += e.criticTokens ?? 0;
8420
- // Track last synthesized
8421
- if (e.lastSynthesized &&
8422
- (!lastSynthesizedAt || e.lastSynthesized > lastSynthesizedAt)) {
8423
- lastSynthesizedAt = e.lastSynthesized;
8424
- lastSynthesizedPath = e.path;
8425
- }
8426
- // Track stalest (effective staleness for scheduling)
8427
- const depthFactor = Math.pow(1 + depthWeight, e.depth);
8428
- const effectiveStaleness = e.stalenessSeconds * depthFactor * e.emphasis;
8429
- if (effectiveStaleness > stalestEffective) {
8430
- stalestEffective = effectiveStaleness;
8431
- stalestPath = e.path;
8432
- }
8433
- }
8434
- return {
8435
- total: entries.length,
8436
- stale: staleCount,
8437
- errors: errorCount,
8438
- locked: lockedCount,
8439
- disabled: disabledCount,
8440
- neverSynthesized: neverSynthesizedCount,
8441
- tokens: {
8442
- architect: totalArchitectTokens,
8443
- builder: totalBuilderTokens,
8444
- critic: totalCriticTokens,
8445
- },
8446
- stalestPath,
8447
- lastSynthesizedPath,
8448
- lastSynthesizedAt,
8449
- };
8450
- }
8451
-
8452
- /**
8453
- * Discover .meta/ directories via watcher `/walk` endpoint.
8454
- *
8455
- * Uses filesystem enumeration through the watcher (not Qdrant) to find
8456
- * all `.meta/meta.json` files and returns deduplicated meta directory paths.
8457
- *
8458
- * @module discovery/discoverMetas
8459
- */
8460
- /**
8461
- * Discover all .meta/ directories via watcher walk.
8462
- *
8463
- * Uses the watcher's `/walk` endpoint to find all `.meta/meta.json` files
8464
- * and returns deduplicated meta directory paths.
8465
- *
8466
- * @param watcher - WatcherClient for walk queries.
8467
- * @returns Array of normalized .meta/ directory paths.
8468
- */
8469
- async function discoverMetas(watcher) {
8470
- const allPaths = await watcher.walk(['**/.meta/meta.json']);
8471
- // Deduplicate by .meta/ directory path (handles multi-chunk files)
8472
- const seen = new Set();
8473
- const metaPaths = [];
8474
- for (const filePath of allPaths) {
8475
- const fp = normalizePath(filePath);
8476
- // Derive .meta/ directory from file_path (strip /meta.json)
8477
- const metaPath = fp.replace(/\/meta\.json$/, '');
8478
- if (seen.has(metaPath))
8479
- continue;
8480
- seen.add(metaPath);
8481
- metaPaths.push(metaPath);
8482
- }
8483
- return metaPaths;
8484
- }
8485
-
8486
- /**
8487
- * File-system lock for preventing concurrent synthesis on the same meta.
8488
- *
8489
- * Lock file: .meta/.lock containing `_lockPid` + `_lockStartedAt` (underscore-prefixed
8490
- * reserved keys, consistent with meta.json conventions).
8491
- * Stale timeout: 30 minutes.
8492
- *
8493
- * @module lock
8228
+ * Lock file: .meta/.lock containing `_lockPid` + `_lockStartedAt` (underscore-prefixed
8229
+ * reserved keys, consistent with meta.json conventions).
8230
+ * Stale timeout: 30 minutes.
8231
+ *
8232
+ * @module lock
8494
8233
  */
8495
8234
  const LOCK_FILE = '.lock';
8496
8235
  /**
@@ -9030,1483 +8769,928 @@ function getDeltaFiles(generatedAt, scopeFiles) {
9030
8769
  }
9031
8770
 
9032
8771
  /**
9033
- * Error thrown when a spawned subprocess is aborted via AbortController.
8772
+ * In-memory cache for listMetas results with TTL and concurrent refresh guard.
9034
8773
  *
9035
- * @module executor/SpawnAbortedError
8774
+ * @module cache
9036
8775
  */
9037
- /** Error indicating a spawn was deliberately aborted. */
9038
- class SpawnAbortedError extends Error {
9039
- constructor(message = 'Synthesis was aborted') {
9040
- super(message);
9041
- this.name = 'SpawnAbortedError';
9042
- }
9043
- }
9044
-
8776
+ const TTL_MS = 60_000;
9045
8777
  /**
9046
- * Error thrown when a spawned subprocess times out.
9047
- *
9048
- * Carries the output file path so callers can attempt partial output recovery.
9049
- *
9050
- * @module executor/SpawnTimeoutError
8778
+ * Caches listMetas results to avoid expensive repeated filesystem walks.
8779
+ * Supports concurrent refresh coalescing and manual invalidation.
9051
8780
  */
9052
- /** Error indicating a spawn timeout with a recoverable output path. */
9053
- class SpawnTimeoutError extends Error {
9054
- /** Path to the (possibly partial) output file written before timeout. */
9055
- outputPath;
9056
- constructor(message, outputPath) {
9057
- super(message);
9058
- this.name = 'SpawnTimeoutError';
9059
- this.outputPath = outputPath;
8781
+ class MetaCache {
8782
+ result = null;
8783
+ updatedAt = 0;
8784
+ refreshPromise = null;
8785
+ /** Get cached result or refresh if stale. */
8786
+ async get(config, watcher) {
8787
+ if (this.result && Date.now() - this.updatedAt < TTL_MS) {
8788
+ return this.result;
8789
+ }
8790
+ return this.refresh(config, watcher);
8791
+ }
8792
+ /** Force-expire the cache so next get() triggers a refresh. */
8793
+ invalidate() {
8794
+ this.updatedAt = 0;
8795
+ }
8796
+ async refresh(config, watcher) {
8797
+ if (this.refreshPromise)
8798
+ return this.refreshPromise;
8799
+ this.refreshPromise = listMetas(config, watcher)
8800
+ .then((result) => {
8801
+ this.result = result;
8802
+ this.updatedAt = Date.now();
8803
+ return result;
8804
+ })
8805
+ .finally(() => {
8806
+ this.refreshPromise = null;
8807
+ });
8808
+ return this.refreshPromise;
9060
8809
  }
9061
8810
  }
9062
8811
 
9063
8812
  /**
9064
- * MetaExecutor implementation using the OpenClaw gateway HTTP API.
8813
+ * Shared live config hot-reload support.
9065
8814
  *
9066
- * Lives in the library package so both plugin and runner can import it.
9067
- * Spawns sub-agent sessions via the gateway's `/tools/invoke` endpoint,
9068
- * polls for completion, and extracts output text.
8815
+ * Used by both file-watch reloads in bootstrap and POST /config/apply
8816
+ * via the component descriptor's onConfigApply callback.
9069
8817
  *
9070
- * @module executor/GatewayExecutor
8818
+ * @module configHotReload
9071
8819
  */
9072
- const DEFAULT_POLL_INTERVAL_MS = 5000;
9073
- const DEFAULT_TIMEOUT_MS$1 = 600_000; // 10 minutes
9074
8820
  /**
9075
- * MetaExecutor that spawns OpenClaw sessions via the gateway's
9076
- * `/tools/invoke` endpoint.
8821
+ * Fields that require a service restart to take effect.
9077
8822
  *
9078
- * Used by both the OpenClaw plugin (in-process tool calls) and the
9079
- * runner/CLI (external invocation). Constructs from `gatewayUrl` and
9080
- * optional `apiKey` — typically sourced from `MetaConfig`.
8823
+ * Shared between the descriptor's `onConfigApply` and the file-watcher
8824
+ * hot-reload in `bootstrap.ts`.
9081
8825
  */
9082
- class GatewayExecutor {
9083
- gatewayUrl;
9084
- apiKey;
9085
- pollIntervalMs;
9086
- workspaceDir;
9087
- controller = new AbortController();
9088
- constructor(options = {}) {
9089
- this.gatewayUrl = (options.gatewayUrl ?? 'http://127.0.0.1:18789').replace(/\/+$/, '');
9090
- this.apiKey = options.apiKey;
9091
- this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
9092
- this.workspaceDir = options.workspaceDir ?? join(tmpdir(), 'jeeves-meta');
9093
- }
9094
- /** Remove a temp output file if it exists. */
9095
- cleanupOutputFile(outputPath) {
9096
- try {
9097
- if (existsSync(outputPath))
9098
- unlinkSync(outputPath);
9099
- }
9100
- catch {
9101
- /* best-effort cleanup */
9102
- }
9103
- }
9104
- /** Invoke a gateway tool via the /tools/invoke HTTP endpoint. */
9105
- async invoke(tool, args, sessionKey) {
9106
- const headers = {
9107
- 'Content-Type': 'application/json',
9108
- };
9109
- if (this.apiKey) {
9110
- headers['Authorization'] = 'Bearer ' + this.apiKey;
9111
- }
9112
- const body = { tool, args };
9113
- if (sessionKey)
9114
- body.sessionKey = sessionKey;
9115
- const res = await fetch(this.gatewayUrl + '/tools/invoke', {
9116
- method: 'POST',
9117
- headers,
9118
- body: JSON.stringify(body),
9119
- });
9120
- if (!res.ok) {
9121
- const text = await res.text();
9122
- throw new Error(`Gateway ${tool} failed: HTTP ${res.status.toString()} - ${text}`);
9123
- }
9124
- const data = (await res.json());
9125
- if (data.ok === false || data.error) {
9126
- throw new Error(`Gateway ${tool} error: ${data.error?.message ?? JSON.stringify(data)}`);
9127
- }
9128
- return data;
9129
- }
9130
- /** Look up totalTokens for a session via sessions_list. */
9131
- async getSessionTokens(sessionKey) {
9132
- try {
9133
- const result = await this.invoke('sessions_list', {
9134
- limit: 20,
9135
- messageLimit: 0,
9136
- });
9137
- const sessions = (result.result?.details?.sessions ??
9138
- result.result?.sessions ??
9139
- []);
9140
- const match = sessions.find((s) => s.key === sessionKey);
9141
- return match?.totalTokens ?? undefined;
9142
- }
9143
- catch {
9144
- return undefined;
8826
+ const RESTART_REQUIRED_FIELDS = [
8827
+ 'port',
8828
+ 'watcherUrl',
8829
+ 'gatewayUrl',
8830
+ 'gatewayApiKey',
8831
+ 'defaultArchitect',
8832
+ 'defaultCritic',
8833
+ ];
8834
+ let runtime = null;
8835
+ /** Register the active service runtime for config-apply hot reload. */
8836
+ function registerConfigHotReloadRuntime(nextRuntime) {
8837
+ runtime = nextRuntime;
8838
+ }
8839
+ /** Apply hot-reloadable config changes to the live shared config object. */
8840
+ function applyHotReloadedConfig(newConfig) {
8841
+ if (!runtime)
8842
+ return;
8843
+ const { config, logger, scheduler } = runtime;
8844
+ for (const field of RESTART_REQUIRED_FIELDS) {
8845
+ const oldVal = config[field];
8846
+ const nextVal = newConfig[field];
8847
+ if (oldVal !== nextVal) {
8848
+ logger.warn({ field, oldValue: oldVal, newValue: nextVal }, 'Config field changed but requires restart to take effect');
9145
8849
  }
9146
8850
  }
9147
- /** Whether this executor has been aborted by the operator. */
9148
- get aborted() {
9149
- return this.controller.signal.aborted;
8851
+ if (newConfig.schedule !== config.schedule) {
8852
+ scheduler?.updateSchedule(newConfig.schedule);
8853
+ config.schedule = newConfig.schedule;
8854
+ logger.info({ schedule: newConfig.schedule }, 'Schedule hot-reloaded');
9150
8855
  }
9151
- /** Abort the currently running spawn, if any. */
9152
- abort() {
9153
- this.controller.abort();
8856
+ if (newConfig.logging.level !== config.logging.level) {
8857
+ logger.level = newConfig.logging.level;
8858
+ config.logging.level = newConfig.logging.level;
8859
+ logger.info({ level: newConfig.logging.level }, 'Log level hot-reloaded');
9154
8860
  }
9155
- async spawn(task, options) {
9156
- // Fresh controller for each spawn call
9157
- this.controller = new AbortController();
9158
- const timeoutSeconds = options?.timeout ?? DEFAULT_TIMEOUT_MS$1 / 1000;
9159
- const timeoutMs = timeoutSeconds * 1000;
9160
- const deadline = Date.now() + timeoutMs;
9161
- // Ensure workspace dir exists
9162
- if (!existsSync(this.workspaceDir)) {
9163
- mkdirSync(this.workspaceDir, { recursive: true });
9164
- }
9165
- // Generate unique output path for file-based output
9166
- const outputId = randomUUID();
9167
- const outputPath = this.workspaceDir + '/output-' + outputId + '.json';
9168
- // Append file output instruction to the task
9169
- const taskWithOutput = task +
9170
- '\n\n## OUTPUT DELIVERY\n\n' +
9171
- 'Write your complete output to a file using the Write tool at:\n' +
9172
- outputPath +
9173
- '\n\n' +
9174
- 'After writing the file, reply with ONLY: NO_REPLY';
9175
- // Step 1: Spawn the sub-agent session (unique label per cycle to avoid
9176
- // "label already in use" errors — gateway labels persist after session completion)
9177
- const labelBase = options?.label ?? 'jeeves-meta-synthesis';
9178
- const label = labelBase + '-' + outputId.slice(0, 8);
9179
- const spawnResult = await this.invoke('sessions_spawn', {
9180
- task: taskWithOutput,
9181
- label,
9182
- runTimeoutSeconds: timeoutSeconds,
9183
- ...(options?.thinking ? { thinking: options.thinking } : {}),
9184
- ...(options?.model ? { model: options.model } : {}),
9185
- });
9186
- const details = (spawnResult.result?.details ?? spawnResult.result);
9187
- const sessionKey = details?.childSessionKey ?? details?.sessionKey;
9188
- if (typeof sessionKey !== 'string' || !sessionKey) {
9189
- throw new Error('Gateway sessions_spawn returned no sessionKey: ' +
9190
- JSON.stringify(spawnResult));
8861
+ const restartSet = new Set(RESTART_REQUIRED_FIELDS);
8862
+ for (const key of Object.keys(newConfig)) {
8863
+ if (restartSet.has(key) || key === 'logging' || key === 'schedule') {
8864
+ continue;
9191
8865
  }
9192
- // Step 2: Poll for completion via sessions_history
9193
- await sleepAsync(3000);
9194
- while (Date.now() < deadline) {
9195
- // Check for abort before each poll iteration
9196
- if (this.controller.signal.aborted) {
9197
- this.cleanupOutputFile(outputPath);
9198
- throw new SpawnAbortedError();
9199
- }
9200
- try {
9201
- const historyResult = await this.invoke('sessions_history', {
9202
- sessionKey,
9203
- limit: 5,
9204
- includeTools: false,
9205
- });
9206
- const messages = historyResult.result?.details?.messages ??
9207
- historyResult.result?.messages ??
9208
- [];
9209
- const msgArray = messages;
9210
- if (msgArray.length > 0) {
9211
- const lastMsg = msgArray[msgArray.length - 1];
9212
- // Complete when last message is assistant with a terminal stop reason
9213
- if (lastMsg.role === 'assistant' &&
9214
- lastMsg.stopReason &&
9215
- lastMsg.stopReason !== 'toolUse' &&
9216
- lastMsg.stopReason !== 'error') {
9217
- // Fetch token usage from session metadata
9218
- const tokens = await this.getSessionTokens(sessionKey);
9219
- // Read output from file (sub-agent wrote it via Write tool)
9220
- if (existsSync(outputPath)) {
9221
- try {
9222
- const output = readFileSync(outputPath, 'utf8');
9223
- return { output, tokens };
9224
- }
9225
- finally {
9226
- try {
9227
- unlinkSync(outputPath);
9228
- }
9229
- catch {
9230
- /* cleanup best-effort */
9231
- }
9232
- }
9233
- }
9234
- // Fallback: extract from message content if file wasn't written
9235
- for (let i = msgArray.length - 1; i >= 0; i--) {
9236
- const msg = msgArray[i];
9237
- if (msg.role === 'assistant' && msg.content) {
9238
- const text = typeof msg.content === 'string'
9239
- ? msg.content
9240
- : Array.isArray(msg.content)
9241
- ? msg.content
9242
- .filter((b) => b.type === 'text' && b.text)
9243
- .map((b) => b.text)
9244
- .join('\n')
9245
- : '';
9246
- if (text)
9247
- return { output: text, tokens };
9248
- }
9249
- }
9250
- return { output: '', tokens };
9251
- }
9252
- }
9253
- }
9254
- catch {
9255
- // Transient poll failure — keep trying
9256
- }
9257
- await sleepAsync(this.pollIntervalMs);
8866
+ const oldVal = config[key];
8867
+ const nextVal = newConfig[key];
8868
+ if (JSON.stringify(oldVal) !== JSON.stringify(nextVal)) {
8869
+ config[key] = nextVal;
8870
+ logger.info({ field: key }, 'Config field hot-reloaded');
9258
8871
  }
9259
- throw new SpawnTimeoutError('Synthesis subprocess timed out after ' + timeoutMs.toString() + 'ms', outputPath);
9260
8872
  }
9261
8873
  }
9262
8874
 
9263
8875
  /**
9264
- * Pino logger factory.
8876
+ * Zod schema for jeeves-meta service configuration.
9265
8877
  *
9266
- * @module logger
9267
- */
9268
- /**
9269
- * Create a pino logger instance.
8878
+ * The service config is a strict superset of the core (library-compatible) meta config.
9270
8879
  *
9271
- * @param config - Optional logger configuration.
9272
- * @returns Configured pino logger.
8880
+ * @module schema/config
9273
8881
  */
9274
- function createLogger(config) {
9275
- const level = config?.level ?? 'info';
9276
- if (config?.file) {
9277
- const transport = pino.transport({
9278
- target: 'pino/file',
9279
- options: { destination: config.file, mkdir: true },
9280
- });
9281
- return pino({ level }, transport);
9282
- }
9283
- return pino({ level });
9284
- }
8882
+ /** Zod schema for the core (library-compatible) meta configuration. */
8883
+ const metaConfigSchema = z.object({
8884
+ /** Watcher service base URL. */
8885
+ watcherUrl: z.url(),
8886
+ /** OpenClaw gateway base URL for subprocess spawning. */
8887
+ gatewayUrl: z.url().default('http://127.0.0.1:18789'),
8888
+ /** Optional API key for gateway authentication. */
8889
+ gatewayApiKey: z.string().optional(),
8890
+ /** Run architect every N cycles (per meta). */
8891
+ architectEvery: z.number().int().min(1).default(10),
8892
+ /** Exponent for depth weighting in staleness formula. */
8893
+ depthWeight: z.number().min(0).default(0.5),
8894
+ /** Maximum archive snapshots to retain per meta. */
8895
+ maxArchive: z.number().int().min(1).default(20),
8896
+ /** Maximum lines of context to include in subprocess prompts. */
8897
+ maxLines: z.number().int().min(50).default(500),
8898
+ /** Architect subprocess timeout in seconds. */
8899
+ architectTimeout: z.number().int().min(30).default(180),
8900
+ /** Builder subprocess timeout in seconds. */
8901
+ builderTimeout: z.number().int().min(60).default(360),
8902
+ /** Critic subprocess timeout in seconds. */
8903
+ criticTimeout: z.number().int().min(30).default(240),
8904
+ /** Thinking level for spawned synthesis sessions. */
8905
+ thinking: z.string().default('low'),
8906
+ /** Resolved architect system prompt text. Falls back to built-in default. */
8907
+ defaultArchitect: z.string().optional(),
8908
+ /** Resolved critic system prompt text. Falls back to built-in default. */
8909
+ defaultCritic: z.string().optional(),
8910
+ /** Skip unchanged candidates, bump _generatedAt. */
8911
+ skipUnchanged: z.boolean().default(true),
8912
+ /** Watcher metadata properties applied to live .meta/meta.json files. */
8913
+ metaProperty: z.record(z.string(), z.unknown()).default({ _meta: 'current' }),
8914
+ /** Watcher metadata properties applied to archive snapshots. */
8915
+ metaArchiveProperty: z
8916
+ .record(z.string(), z.unknown())
8917
+ .default({ _meta: 'archive' }),
8918
+ });
8919
+ /** Zod schema for logging configuration. */
8920
+ const loggingSchema = z.object({
8921
+ /** Log level. */
8922
+ level: z.string().default('info'),
8923
+ /** Optional file path for log output. */
8924
+ file: z.string().optional(),
8925
+ });
8926
+ /** Zod schema for a single auto-seed policy rule. */
8927
+ const autoSeedRuleSchema = z.object({
8928
+ /** Glob pattern matched against watcher walk results. */
8929
+ match: z.string(),
8930
+ /** Optional steering prompt for seeded metas. */
8931
+ steer: z.string().optional(),
8932
+ /** Optional cross-references for seeded metas. */
8933
+ crossRefs: z.array(z.string()).optional(),
8934
+ });
8935
+ /** Zod schema for jeeves-meta service configuration (superset of MetaConfig). */
8936
+ const serviceConfigSchema = metaConfigSchema.extend({
8937
+ /** HTTP port for the service (default: 1938). */
8938
+ port: z.number().int().min(1).max(65535).default(1938),
8939
+ /** Cron schedule for synthesis cycles (default: every 30 min). */
8940
+ schedule: z.string().default('*/30 * * * *'),
8941
+ /** Messaging channel name (e.g. 'slack'). Legacy: also used as target if reportTarget is unset. */
8942
+ reportChannel: z.string().optional(),
8943
+ /** Channel/user ID to send progress messages to. */
8944
+ reportTarget: z.string().optional(),
8945
+ /** Optional base URL for the service, used to construct entity links in progress reports. */
8946
+ serverBaseUrl: z.string().optional(),
8947
+ /** Interval in ms for periodic watcher health check. 0 = disabled. Default: 60000. */
8948
+ watcherHealthIntervalMs: z.number().int().min(0).default(60_000),
8949
+ /** Logging configuration. */
8950
+ logging: loggingSchema.default(() => loggingSchema.parse({})),
8951
+ /**
8952
+ * Auto-seed policy: declarative rules for auto-creating .meta/ directories.
8953
+ * Rules are evaluated in order; last match wins for steer/crossRefs.
8954
+ */
8955
+ autoSeed: z.array(autoSeedRuleSchema).optional().default([]),
8956
+ });
9285
8957
 
9286
8958
  /**
9287
- * Built-in default prompts for the synthesis pipeline.
8959
+ * Load and resolve jeeves-meta service config.
9288
8960
  *
9289
- * Prompts ship as .md files bundled into dist/prompts/ via rollup-plugin-copy.
9290
- * Loaded at runtime relative to the compiled module location.
9291
- *
9292
- * Users can override via `defaultArchitect` / `defaultCritic` in the service
9293
- * config. Most installations should use the built-in defaults.
9294
- *
9295
- * @module prompts
9296
- */
9297
- const packageRoot = packageDirectorySync({
9298
- cwd: fileURLToPath(import.meta.url),
9299
- });
9300
- const promptDir = join(packageRoot, 'dist', 'prompts');
9301
- /** Built-in default architect prompt. */
9302
- const DEFAULT_ARCHITECT_PROMPT = readFileSync(join(promptDir, 'architect.md'), 'utf8');
9303
- /** Built-in default critic prompt. */
9304
- const DEFAULT_CRITIC_PROMPT = readFileSync(join(promptDir, 'critic.md'), 'utf8');
9305
-
9306
- /**
9307
- * Build the MetaContext for a synthesis cycle.
9308
- *
9309
- * Computes shared inputs once: scope files, delta files, child meta outputs,
9310
- * previous content/feedback, steer, and archive paths.
9311
- *
9312
- * @module orchestrator/contextPackage
9313
- */
9314
- /**
9315
- * Condense a file list into glob-like summaries.
9316
- * Groups by directory + extension pattern.
9317
- *
9318
- * @param files - Array of file paths.
9319
- * @param maxIndividual - Show individual files up to this count.
9320
- * @returns Condensed summary string.
9321
- */
9322
- function condenseScopeFiles(files, maxIndividual = 30) {
9323
- if (files.length <= maxIndividual)
9324
- return files.join('\n');
9325
- // Group by dir + extension
9326
- const groups = new Map();
9327
- for (const f of files) {
9328
- const dir = f.substring(0, f.lastIndexOf('/') + 1) || './';
9329
- const ext = f.includes('.') ? f.substring(f.lastIndexOf('.')) : '(no ext)';
9330
- const key = dir + '*' + ext;
9331
- groups.set(key, (groups.get(key) ?? 0) + 1);
9332
- }
9333
- // Sort by count descending
9334
- const sorted = [...groups.entries()].sort((a, b) => b[1] - a[1]);
9335
- return sorted
9336
- .map(([pattern, count]) => pattern + ' (' + count.toString() + ' files)')
9337
- .join('\n');
9338
- }
9339
- /**
9340
- * Read a meta.json file and extract its `_content` field.
9341
- *
9342
- * @param metaJsonPath - Absolute path to a meta.json file.
9343
- * @returns The `_content` string, or null if missing/unreadable.
9344
- */
9345
- async function readMetaContent(metaJsonPath) {
9346
- try {
9347
- const raw = await readFile(metaJsonPath, 'utf8');
9348
- const meta = JSON.parse(raw);
9349
- return meta._content ?? null;
9350
- }
9351
- catch {
9352
- return null;
9353
- }
9354
- }
9355
- /**
9356
- * Build the context package for a synthesis cycle.
9357
- *
9358
- * @param node - The meta node being synthesized.
9359
- * @param meta - Current meta.json content.
9360
- * @param watcher - WatcherClient for scope enumeration.
9361
- * @returns The computed context package.
9362
- */
9363
- async function buildContextPackage(node, meta, watcher, logger) {
9364
- // Scope and delta files via watcher walk
9365
- const scopeStart = Date.now();
9366
- const { scopeFiles } = await getScopeFiles(node, watcher, logger);
9367
- const deltaFiles = getDeltaFiles(meta._generatedAt, scopeFiles);
9368
- logger?.debug({
9369
- scopeFiles: scopeFiles.length,
9370
- deltaFiles: deltaFiles.length,
9371
- durationMs: Date.now() - scopeStart,
9372
- }, 'scope and delta files computed');
9373
- // Child meta outputs (parallel reads)
9374
- const childMetas = {};
9375
- const childEntries = await Promise.all(node.children.map(async (child) => {
9376
- const content = await readMetaContent(join(child.metaPath, 'meta.json'));
9377
- return [child.ownerPath, content];
9378
- }));
9379
- for (const [path, content] of childEntries) {
9380
- childMetas[path] = content;
9381
- }
9382
- // Cross-referenced meta outputs (parallel reads)
9383
- const crossRefMetas = {};
9384
- const seen = new Set();
9385
- const crossRefPaths = [];
9386
- for (const refPath of meta._crossRefs ?? []) {
9387
- if (refPath === node.ownerPath || refPath === node.metaPath)
9388
- continue;
9389
- if (seen.has(refPath))
9390
- continue;
9391
- seen.add(refPath);
9392
- crossRefPaths.push(refPath);
9393
- }
9394
- const crossRefEntries = await Promise.all(crossRefPaths.map(async (refPath) => {
9395
- const content = await readMetaContent(join(refPath, '.meta', 'meta.json'));
9396
- return [refPath, content];
9397
- }));
9398
- for (const [path, content] of crossRefEntries) {
9399
- crossRefMetas[path] = content;
9400
- }
9401
- // Archive paths
9402
- const archives = listArchiveFiles(node.metaPath);
9403
- return {
9404
- path: node.metaPath,
9405
- scopeFiles,
9406
- deltaFiles,
9407
- childMetas,
9408
- crossRefMetas,
9409
- previousContent: meta._content ?? null,
9410
- previousFeedback: meta._feedback ?? null,
9411
- steer: meta._steer ?? null,
9412
- previousState: meta._state ?? null,
9413
- archives,
9414
- };
9415
- }
9416
-
9417
- /**
9418
- * Build task prompts for each synthesis step.
9419
- *
9420
- * Prompts are compiled as Handlebars templates with access to config,
9421
- * meta, and scope context. The architect can write template expressions
9422
- * into its _builder output; these resolve when the builder task is compiled.
8961
+ * Supports \@file: indirection and environment-variable substitution (dollar-brace pattern).
9423
8962
  *
9424
- * @module orchestrator/buildTask
8963
+ * @module configLoader
9425
8964
  */
9426
- /** Build the template context from synthesis inputs. */
9427
- function buildTemplateContext(ctx, meta, config) {
9428
- return {
9429
- config,
9430
- meta,
9431
- scope: {
9432
- fileCount: ctx.scopeFiles.length,
9433
- deltaCount: ctx.deltaFiles.length,
9434
- childCount: Object.keys(ctx.childMetas).length,
9435
- crossRefCount: Object.keys(ctx.crossRefMetas).length,
9436
- },
9437
- };
9438
- }
9439
8965
  /**
9440
- * Compile a string as a Handlebars template with the given context.
9441
- * Returns the original string unchanged if compilation fails.
9442
- */
9443
- function compileTemplate(text, context) {
9444
- try {
9445
- return Handlebars.compile(text, { noEscape: true })(context);
9446
- }
9447
- catch {
9448
- return text;
9449
- }
9450
- }
9451
- /** Append a keyed record of meta outputs as subsections, if non-empty. */
9452
- function appendMetaSections(sections, heading, metas) {
9453
- if (Object.keys(metas).length === 0)
9454
- return;
9455
- sections.push('', heading);
9456
- for (const [path, content] of Object.entries(metas)) {
9457
- sections.push(`### ${path}`, typeof content === 'string' ? content : '(not yet synthesized)');
9458
- }
9459
- }
9460
- /** Append optional context sections shared across all step prompts. */
9461
- function appendSharedSections(sections, ctx, options) {
9462
- const opts = {
9463
- includeSteer: true,
9464
- includePreviousContent: true,
9465
- includePreviousFeedback: true,
9466
- feedbackHeading: '## PREVIOUS FEEDBACK',
9467
- includeChildMetas: true,
9468
- includeCrossRefs: true,
9469
- ...options,
9470
- };
9471
- if (opts.includeSteer && ctx.steer) {
9472
- sections.push('', '## STEERING PROMPT', ctx.steer);
9473
- }
9474
- if (opts.includePreviousContent && ctx.previousContent) {
9475
- sections.push('', '## PREVIOUS SYNTHESIS', ctx.previousContent);
9476
- }
9477
- if (opts.includePreviousFeedback && ctx.previousFeedback) {
9478
- sections.push('', opts.feedbackHeading, ctx.previousFeedback);
9479
- }
9480
- if (opts.includeChildMetas) {
9481
- appendMetaSections(sections, '## CHILD META OUTPUTS', ctx.childMetas);
9482
- }
9483
- if (opts.includeCrossRefs) {
9484
- appendMetaSections(sections, '## CROSS-REFERENCED METAS', ctx.crossRefMetas);
9485
- }
9486
- }
9487
- /**
9488
- * Build the architect task prompt.
8966
+ * Deep-walk a value, replacing `\${VAR\}` patterns with process.env values.
9489
8967
  *
9490
- * @param ctx - Synthesis context.
9491
- * @param meta - Current meta.json.
9492
- * @param config - Synthesis config.
9493
- * @returns The architect task prompt string.
8968
+ * @param value - Arbitrary JSON-compatible value.
8969
+ * @returns Value with env-var placeholders resolved.
9494
8970
  */
9495
- function buildArchitectTask(ctx, meta, config) {
9496
- const sections = [
9497
- `# jeeves-meta · ARCHITECT · ${ctx.path}`,
9498
- '',
9499
- meta._architect ?? config.defaultArchitect ?? DEFAULT_ARCHITECT_PROMPT,
9500
- '',
9501
- '## SCOPE',
9502
- `Path: ${ctx.path}`,
9503
- `Total files in scope: ${ctx.scopeFiles.length.toString()}`,
9504
- `Files changed since last synthesis: ${ctx.deltaFiles.length.toString()}`,
9505
- '',
9506
- '### File listing (scope)',
9507
- condenseScopeFiles(ctx.scopeFiles),
9508
- ];
9509
- // Inject previous _builder so architect can see its own prior output
9510
- if (meta._builder) {
9511
- sections.push('', '## PREVIOUS TASK BRIEF', meta._builder);
8971
+ function substituteEnvVars(value) {
8972
+ if (typeof value === 'string') {
8973
+ return value.replace(/\$\{([^}]+)\}/g, (_match, name) => {
8974
+ const envVal = process.env[name];
8975
+ if (envVal === undefined) {
8976
+ throw new Error(`Environment variable ${name} is not set`);
8977
+ }
8978
+ return envVal;
8979
+ });
9512
8980
  }
9513
- appendSharedSections(sections, ctx);
9514
- if (ctx.archives.length > 0) {
9515
- sections.push('', '## ARCHIVE HISTORY', `${ctx.archives.length.toString()} previous synthesis snapshots available in .meta/archive/.`, 'Review these to understand how the synthesis has evolved over time.');
8981
+ if (Array.isArray(value)) {
8982
+ return value.map(substituteEnvVars);
9516
8983
  }
9517
- return compileTemplate(sections.join('\n'), buildTemplateContext(ctx, meta, config));
9518
- }
9519
- /**
9520
- * Build the builder task prompt.
9521
- *
9522
- * @param ctx - Synthesis context.
9523
- * @param meta - Current meta.json.
9524
- * @param config - Synthesis config.
9525
- * @returns The builder task prompt string.
9526
- */
9527
- function buildBuilderTask(ctx, meta, config) {
9528
- const sections = [
9529
- `# jeeves-meta · BUILDER · ${ctx.path}`,
9530
- '',
9531
- '## TASK BRIEF (from Architect)',
9532
- meta._builder ?? '(No architect brief available)',
9533
- '',
9534
- '## SCOPE',
9535
- `Path: ${ctx.path}`,
9536
- `Delta files (${ctx.deltaFiles.length.toString()} changed):`,
9537
- ...ctx.deltaFiles.slice(0, config.maxLines).map((f) => `- ${f}`),
9538
- ];
9539
- if (ctx.previousState != null) {
9540
- sections.push('', '## PREVIOUS STATE', 'The following opaque state was returned by the previous synthesis cycle.', 'Use it to continue progressive work. Update `_state` in your output to', 'reflect your progress.', '', '```json', JSON.stringify(ctx.previousState, null, 2), '```');
8984
+ if (value !== null && typeof value === 'object') {
8985
+ const result = {};
8986
+ for (const [key, val] of Object.entries(value)) {
8987
+ result[key] = substituteEnvVars(val);
8988
+ }
8989
+ return result;
9541
8990
  }
9542
- appendSharedSections(sections, ctx, {
9543
- includeSteer: false,
9544
- feedbackHeading: '## FEEDBACK FROM CRITIC',
9545
- });
9546
- sections.push('', '## OUTPUT FORMAT', '', 'Respond with ONLY a JSON object. No explanation, no markdown fences, no text before or after.', '', 'Required schema:', '{', ' "type": "object",', ' "required": ["_content"],', ' "properties": {', ' "_content": { "type": "string", "description": "Markdown narrative synthesis" },', ' "_state": { "description": "Opaque state object for progressive work across cycles" }', ' },', ' "additionalProperties": true', '}', '', 'Add any structured fields that capture important facts about this entity', '(e.g. status, risks, dependencies, metrics). Use descriptive key names without underscore prefix.', 'The _content field is the only required key — everything else is domain-driven.', '_state is optional: set it to carry state across synthesis cycles for progressive work.', '', 'DIAGRAMS: When diagrams would aid understanding, use PlantUML in fenced code blocks (```plantuml).', 'PlantUML is rendered natively by the serving infrastructure. NEVER use ASCII art diagrams.');
9547
- return compileTemplate(sections.join('\n'), buildTemplateContext(ctx, meta, config));
9548
- }
9549
- /**
9550
- * Build the critic task prompt.
9551
- *
9552
- * @param ctx - Synthesis context.
9553
- * @param meta - Current meta.json (with _content already set by builder).
9554
- * @param config - Synthesis config.
9555
- * @returns The critic task prompt string.
9556
- */
9557
- function buildCriticTask(ctx, meta, config) {
9558
- const sections = [
9559
- `# jeeves-meta · CRITIC · ${ctx.path}`,
9560
- '',
9561
- meta._critic ?? config.defaultCritic ?? DEFAULT_CRITIC_PROMPT,
9562
- '',
9563
- '## SYNTHESIS TO EVALUATE',
9564
- meta._content ?? '(No content produced)',
9565
- '',
9566
- '## SCOPE',
9567
- `Path: ${ctx.path}`,
9568
- `Files in scope: ${ctx.scopeFiles.length.toString()}`,
9569
- ];
9570
- appendSharedSections(sections, ctx, {
9571
- includePreviousContent: false,
9572
- feedbackHeading: '## YOUR PREVIOUS FEEDBACK',
9573
- includeChildMetas: false,
9574
- includeCrossRefs: false,
9575
- });
9576
- sections.push('', '## OUTPUT FORMAT', 'Return your evaluation as Markdown text. Be specific and actionable.');
9577
- return compileTemplate(sections.join('\n'), buildTemplateContext(ctx, meta, config));
8991
+ return value;
9578
8992
  }
9579
-
9580
- /**
9581
- * Exponential moving average helper for token tracking.
9582
- *
9583
- * @module ema
9584
- */
9585
- const DEFAULT_DECAY = 0.3;
9586
8993
  /**
9587
- * Compute exponential moving average.
8994
+ * Resolve \@file: references in a config value.
9588
8995
  *
9589
- * @param current - New observation.
9590
- * @param previous - Previous EMA value, or undefined for first observation.
9591
- * @param decay - Decay factor (0-1). Higher = more weight on new value. Default 0.3.
9592
- * @returns Updated EMA.
8996
+ * @param value - String value that may start with "\@file:".
8997
+ * @param baseDir - Base directory for resolving relative paths.
8998
+ * @returns The resolved string (file contents or original value).
9593
8999
  */
9594
- function computeEma(current, previous, decay = DEFAULT_DECAY) {
9595
- if (previous === undefined)
9596
- return current;
9597
- return decay * current + (1 - decay) * previous;
9000
+ function resolveFileRef(value, baseDir) {
9001
+ if (!value.startsWith('@file:'))
9002
+ return value;
9003
+ const filePath = join(baseDir, value.slice(6));
9004
+ return readFileSync(filePath, 'utf8');
9598
9005
  }
9599
-
9600
- /**
9601
- * Structured error from a synthesis step failure.
9602
- *
9603
- * @module schema/error
9604
- */
9605
- /** Zod schema for synthesis step errors. */
9606
- const metaErrorSchema = z.object({
9607
- /** Which step failed: 'architect', 'builder', or 'critic'. */
9608
- step: z.enum(['architect', 'builder', 'critic']),
9609
- /** Error classification code. */
9610
- code: z.string(),
9611
- /** Human-readable error message. */
9612
- message: z.string(),
9613
- });
9614
-
9615
9006
  /**
9616
- * Zod schema for .meta/meta.json files.
9007
+ * Migrate legacy config path to the new canonical location.
9617
9008
  *
9618
- * Reserved properties are underscore-prefixed and engine-managed.
9619
- * All other keys are open schema (builder output).
9009
+ * If the old path `{configRoot}/jeeves-meta.config.json` exists and the new
9010
+ * path `{configRoot}/jeeves-meta/config.json` does NOT exist, copies the file
9011
+ * to the new location and logs a warning.
9620
9012
  *
9621
- * @module schema/meta
9013
+ * @param configRoot - Root directory for configuration files.
9014
+ * @param warn - Optional callback for logging the migration warning.
9622
9015
  */
9623
- /** Valid states for a synthesis phase. */
9624
- const phaseStatuses = [
9625
- 'fresh',
9626
- 'stale',
9627
- 'pending',
9628
- 'running',
9629
- 'failed',
9630
- ];
9631
- /** Zod schema for a per-phase status value. */
9632
- const phaseStatusSchema = z.enum(phaseStatuses);
9633
- /** Zod schema for the per-meta phase state record. */
9634
- const phaseStateSchema = z.object({
9635
- architect: phaseStatusSchema,
9636
- builder: phaseStatusSchema,
9637
- critic: phaseStatusSchema,
9638
- });
9639
- /** Zod schema for the reserved (underscore-prefixed) meta.json properties. */
9640
- const metaJsonSchema = z
9641
- .object({
9642
- /** Stable identity. Auto-generated on first synthesis if not provided. */
9643
- _id: z.uuid().optional(),
9644
- /** Human-provided steering prompt. Optional. */
9645
- _steer: z.string().optional(),
9646
- /**
9647
- * Explicit cross-references to other meta owner paths.
9648
- * Referenced metas' _content is included as architect/builder context.
9649
- */
9650
- _crossRefs: z.array(z.string()).optional(),
9651
- /** Architect system prompt used this turn. Defaults from config. */
9652
- _architect: z.string().optional(),
9653
- /**
9654
- * Task brief generated by the architect. Cached and reused across cycles;
9655
- * regenerated only when triggered.
9656
- */
9657
- _builder: z.string().optional(),
9658
- /** Critic system prompt used this turn. Defaults from config. */
9659
- _critic: z.string().optional(),
9660
- /** Timestamp of last synthesis. ISO 8601. */
9661
- _generatedAt: z.iso.datetime().optional(),
9662
- /** Narrative synthesis output. Rendered by watcher for embedding. */
9663
- _content: z.string().optional(),
9664
- /**
9665
- * Hash of sorted file listing in scope. Detects directory structure
9666
- * changes that trigger an architect re-run.
9667
- */
9668
- _structureHash: z.string().optional(),
9669
- /**
9670
- * Cycles since last architect run. Reset to 0 when architect runs.
9671
- * Used with architectEvery to trigger periodic re-prompting.
9672
- */
9673
- _synthesisCount: z.number().int().min(0).optional(),
9674
- /** Critic evaluation of the last synthesis. */
9675
- _feedback: z.string().optional(),
9676
- /**
9677
- * Present and true on archive snapshots. Distinguishes live vs. archived
9678
- * metas.
9679
- */
9680
- _archived: z.boolean().optional(),
9681
- /** Timestamp when this snapshot was archived. ISO 8601. */
9682
- _archivedAt: z.iso.datetime().optional(),
9683
- /**
9684
- * Scheduling priority. Higher = updates more often. Negative allowed;
9685
- * normalized to min 0 at scheduling time.
9686
- */
9687
- _depth: z.number().optional(),
9688
- /**
9689
- * Emphasis multiplier for depth weighting in scheduling.
9690
- * Default 1. Higher values increase this meta's scheduling priority
9691
- * relative to its depth. Set to 0.5 to halve the depth effect,
9692
- * 2 to double it, 0 to ignore depth entirely for this meta.
9693
- */
9694
- _emphasis: z.number().min(0).optional(),
9695
- /** Token count from last architect subprocess call. */
9696
- _architectTokens: z.number().int().optional(),
9697
- /** Token count from last builder subprocess call. */
9698
- _builderTokens: z.number().int().optional(),
9699
- /** Token count from last critic subprocess call. */
9700
- _criticTokens: z.number().int().optional(),
9701
- /** Exponential moving average of architect token usage (decay 0.3). */
9702
- _architectTokensAvg: z.number().optional(),
9703
- /** Exponential moving average of builder token usage (decay 0.3). */
9704
- _builderTokensAvg: z.number().optional(),
9705
- /** Exponential moving average of critic token usage (decay 0.3). */
9706
- _criticTokensAvg: z.number().optional(),
9707
- /**
9708
- * Opaque state carried across synthesis cycles for progressive work.
9709
- * Set by the builder, passed back as context on next cycle.
9710
- */
9711
- _state: z.unknown().optional(),
9712
- /**
9713
- * Structured error from last cycle. Present when a step failed.
9714
- * Cleared on successful cycle.
9715
- */
9716
- _error: metaErrorSchema.optional(),
9717
- /** When true, this meta is skipped during staleness scheduling. Manual trigger still works. */
9718
- _disabled: z.boolean().optional(),
9719
- /**
9720
- * Per-phase state machine record. Engine-managed.
9721
- * Keyed by phase name (architect, builder, critic) with status values.
9722
- * Persisted to survive ticks; derived on first load for back-compat.
9723
- */
9724
- _phaseState: phaseStateSchema.optional(),
9725
- })
9726
- .loose();
9727
-
9016
+ function migrateConfigPath(configRoot, warn) {
9017
+ const oldPath = join(configRoot, 'jeeves-meta.config.json');
9018
+ const newDir = join(configRoot, 'jeeves-meta');
9019
+ const newPath = join(newDir, 'config.json');
9020
+ if (existsSync(oldPath) && !existsSync(newPath)) {
9021
+ mkdirSync(newDir, { recursive: true });
9022
+ copyFileSync(oldPath, newPath);
9023
+ const message = `Migrated config from ${oldPath} to ${newPath}. The old file can be removed.`;
9024
+ if (warn) {
9025
+ warn(message);
9026
+ }
9027
+ else {
9028
+ console.warn(`[jeeves-meta] ${message}`);
9029
+ }
9030
+ }
9031
+ }
9032
+ /**
9033
+ * Resolve config path from --config flag or JEEVES_META_CONFIG env var.
9034
+ *
9035
+ * @param args - CLI arguments (process.argv.slice(2)).
9036
+ * @returns Resolved config path.
9037
+ * @throws If no config path found.
9038
+ */
9039
+ function resolveConfigPath(args) {
9040
+ let configIdx = args.indexOf('--config');
9041
+ if (configIdx === -1)
9042
+ configIdx = args.indexOf('-c');
9043
+ if (configIdx !== -1 && args[configIdx + 1]) {
9044
+ return args[configIdx + 1];
9045
+ }
9046
+ const envPath = process.env['JEEVES_META_CONFIG'];
9047
+ if (envPath)
9048
+ return envPath;
9049
+ throw new Error('Config path required. Use --config <path> or set JEEVES_META_CONFIG env var.');
9050
+ }
9728
9051
  /**
9729
- * Merge synthesis results into meta.json.
9052
+ * Load service config from a JSON file.
9730
9053
  *
9731
- * Preserves human-set fields (_id, _steer, _depth).
9732
- * Writes engine fields (_generatedAt, _structureHash, etc.).
9733
- * Validates against schema before writing.
9054
+ * Resolves \@file: references for defaultArchitect and defaultCritic,
9055
+ * and substitutes environment-variable placeholders throughout.
9734
9056
  *
9735
- * @module orchestrator/merge
9057
+ * @param configPath - Path to config JSON file.
9058
+ * @returns Validated ServiceConfig.
9736
9059
  */
9060
+ function loadServiceConfig(configPath) {
9061
+ const rawText = readFileSync(configPath, 'utf8');
9062
+ const raw = substituteEnvVars(JSON.parse(rawText));
9063
+ const baseDir = dirname(configPath);
9064
+ if (typeof raw['defaultArchitect'] === 'string') {
9065
+ raw['defaultArchitect'] = resolveFileRef(raw['defaultArchitect'], baseDir);
9066
+ }
9067
+ if (typeof raw['defaultCritic'] === 'string') {
9068
+ raw['defaultCritic'] = resolveFileRef(raw['defaultCritic'], baseDir);
9069
+ }
9070
+ return serviceConfigSchema.parse(raw);
9071
+ }
9072
+
9737
9073
  /**
9738
- * Merge results into meta.json and write atomically.
9074
+ * Error thrown when a spawned subprocess is aborted via AbortController.
9739
9075
  *
9740
- * @param options - Merge options.
9741
- * @returns The updated MetaJson.
9742
- * @throws If validation fails (malformed output).
9076
+ * @module executor/SpawnAbortedError
9743
9077
  */
9744
- async function mergeAndWrite(options) {
9745
- const merged = {
9746
- // Preserve human-set fields (auto-generate _id on first synthesis)
9747
- _id: options.current._id ?? randomUUID(),
9748
- _steer: options.current._steer,
9749
- _depth: options.current._depth,
9750
- _emphasis: options.current._emphasis,
9751
- // Engine fields
9752
- _architect: options.architect,
9753
- _builder: options.builder,
9754
- _critic: options.critic,
9755
- _generatedAt: options.stateOnly
9756
- ? options.current._generatedAt
9757
- : new Date().toISOString(),
9758
- _structureHash: options.structureHash,
9759
- _synthesisCount: options.synthesisCount,
9760
- // Token tracking
9761
- _architectTokens: options.architectTokens,
9762
- _builderTokens: options.builderTokens,
9763
- _criticTokens: options.criticTokens,
9764
- _architectTokensAvg: options.architectTokens !== undefined
9765
- ? computeEma(options.architectTokens, options.current._architectTokensAvg)
9766
- : options.current._architectTokensAvg,
9767
- _builderTokensAvg: options.builderTokens !== undefined
9768
- ? computeEma(options.builderTokens, options.current._builderTokensAvg)
9769
- : options.current._builderTokensAvg,
9770
- _criticTokensAvg: options.criticTokens !== undefined
9771
- ? computeEma(options.criticTokens, options.current._criticTokensAvg)
9772
- : options.current._criticTokensAvg,
9773
- // Content from builder (stateOnly preserves previous content)
9774
- _content: options.stateOnly
9775
- ? options.current._content
9776
- : (options.builderOutput?.content ?? options.current._content),
9777
- // Feedback from critic
9778
- _feedback: options.feedback ?? options.current._feedback,
9779
- // Progressive state
9780
- _state: options.state,
9781
- // Error handling
9782
- _error: options.error ?? undefined,
9783
- // Phase state machine
9784
- _phaseState: options.phaseState,
9785
- // Spread structured fields from builder
9786
- ...options.builderOutput?.fields,
9787
- };
9788
- // Clean up undefined optional fields
9789
- if (merged._steer === undefined)
9790
- delete merged._steer;
9791
- if (merged._depth === undefined)
9792
- delete merged._depth;
9793
- if (merged._emphasis === undefined)
9794
- delete merged._emphasis;
9795
- if (merged._architectTokens === undefined)
9796
- delete merged._architectTokens;
9797
- if (merged._builderTokens === undefined)
9798
- delete merged._builderTokens;
9799
- if (merged._criticTokens === undefined)
9800
- delete merged._criticTokens;
9801
- if (merged._architectTokensAvg === undefined)
9802
- delete merged._architectTokensAvg;
9803
- if (merged._builderTokensAvg === undefined)
9804
- delete merged._builderTokensAvg;
9805
- if (merged._criticTokensAvg === undefined)
9806
- delete merged._criticTokensAvg;
9807
- if (merged._state === undefined)
9808
- delete merged._state;
9809
- if (merged._error === undefined)
9810
- delete merged._error;
9811
- if (merged._content === undefined)
9812
- delete merged._content;
9813
- if (merged._feedback === undefined)
9814
- delete merged._feedback;
9815
- if (merged._phaseState === undefined)
9816
- delete merged._phaseState;
9817
- // Validate
9818
- const result = metaJsonSchema.safeParse(merged);
9819
- if (!result.success) {
9820
- throw new Error(`Meta validation failed: ${result.error.message}`);
9078
+ /** Error indicating a spawn was deliberately aborted. */
9079
+ class SpawnAbortedError extends Error {
9080
+ constructor(message = 'Synthesis was aborted') {
9081
+ super(message);
9082
+ this.name = 'SpawnAbortedError';
9821
9083
  }
9822
- // Write to specified path (lock staging) or default meta.json
9823
- const filePath = options.outputPath ?? join(options.metaPath, 'meta.json');
9824
- await writeFile(filePath, JSON.stringify(result.data, null, 2) + '\n');
9825
- return result.data;
9826
9084
  }
9827
9085
 
9828
9086
  /**
9829
- * Build a minimal MetaNode from a known meta path using watcher walk.
9087
+ * Error thrown when a spawned subprocess times out.
9830
9088
  *
9831
- * Used for targeted synthesis (when a specific path is requested) to avoid
9832
- * the full discovery + ownership tree build. Discovers only immediate child
9833
- * `.meta/` directories.
9089
+ * Carries the output file path so callers can attempt partial output recovery.
9834
9090
  *
9835
- * @module discovery/buildMinimalNode
9091
+ * @module executor/SpawnTimeoutError
9836
9092
  */
9093
+ /** Error indicating a spawn timeout with a recoverable output path. */
9094
+ class SpawnTimeoutError extends Error {
9095
+ /** Path to the (possibly partial) output file written before timeout. */
9096
+ outputPath;
9097
+ constructor(message, outputPath) {
9098
+ super(message);
9099
+ this.name = 'SpawnTimeoutError';
9100
+ this.outputPath = outputPath;
9101
+ }
9102
+ }
9103
+
9837
9104
  /**
9838
- * Build a minimal MetaNode for a known meta path.
9105
+ * MetaExecutor implementation using the OpenClaw gateway HTTP API.
9839
9106
  *
9840
- * Walks the owner directory for child `.meta/meta.json` files and constructs
9841
- * a shallow ownership tree (self + direct children only).
9107
+ * Lives in the library package so both plugin and runner can import it.
9108
+ * Spawns sub-agent sessions via the gateway's `/tools/invoke` endpoint,
9109
+ * polls for completion, and extracts output text.
9842
9110
  *
9843
- * @param metaPath - Absolute path to the `.meta/` directory.
9844
- * @param watcher - WatcherClient for filesystem enumeration.
9845
- * @returns MetaNode with direct children wired.
9111
+ * @module executor/GatewayExecutor
9846
9112
  */
9847
- async function buildMinimalNode(metaPath, watcher) {
9848
- const normalized = normalizePath(metaPath);
9849
- const ownerPath = posix.dirname(normalized);
9850
- // Find child metas using watcher walk.
9851
- // We include only *direct* children (nearest descendants in the ownership tree)
9852
- // to match the ownership semantics used elsewhere.
9853
- const rawMetaJsonPaths = await watcher.walk([
9854
- `${escapeGlob(ownerPath)}/**/.meta/meta.json`,
9855
- ]);
9856
- const candidateMetaPaths = [
9857
- ...new Set(rawMetaJsonPaths.map((p) => posix.dirname(normalizePath(p)))),
9858
- ].filter((p) => p !== normalized);
9859
- const candidates = candidateMetaPaths
9860
- .map((mp) => ({ metaPath: mp, ownerPath: posix.dirname(mp) }))
9861
- .sort((a, b) => a.ownerPath.length - b.ownerPath.length);
9862
- const directChildren = [];
9863
- for (const c of candidates) {
9864
- const nestedUnderExisting = directChildren.some((d) => c.ownerPath === d.ownerPath ||
9865
- c.ownerPath.startsWith(d.ownerPath + '/'));
9866
- if (!nestedUnderExisting)
9867
- directChildren.push(c);
9113
+ const DEFAULT_POLL_INTERVAL_MS = 5000;
9114
+ const DEFAULT_TIMEOUT_MS$1 = 600_000; // 10 minutes
9115
+ /**
9116
+ * MetaExecutor that spawns OpenClaw sessions via the gateway's
9117
+ * `/tools/invoke` endpoint.
9118
+ *
9119
+ * Used by both the OpenClaw plugin (in-process tool calls) and the
9120
+ * runner/CLI (external invocation). Constructs from `gatewayUrl` and
9121
+ * optional `apiKey` — typically sourced from `MetaConfig`.
9122
+ */
9123
+ class GatewayExecutor {
9124
+ gatewayUrl;
9125
+ apiKey;
9126
+ pollIntervalMs;
9127
+ workspaceDir;
9128
+ controller = new AbortController();
9129
+ constructor(options = {}) {
9130
+ this.gatewayUrl = (options.gatewayUrl ?? 'http://127.0.0.1:18789').replace(/\/+$/, '');
9131
+ this.apiKey = options.apiKey;
9132
+ this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
9133
+ this.workspaceDir = options.workspaceDir ?? join(tmpdir(), 'jeeves-meta');
9868
9134
  }
9869
- const children = directChildren.map((c) => ({
9870
- metaPath: c.metaPath,
9871
- ownerPath: c.ownerPath,
9872
- treeDepth: 1,
9873
- children: [],
9874
- parent: null,
9875
- }));
9876
- const node = {
9877
- metaPath: normalized,
9878
- ownerPath,
9879
- treeDepth: 0,
9880
- children,
9881
- parent: null,
9882
- };
9883
- for (const child of children) {
9884
- child.parent = node;
9135
+ /** Remove a temp output file if it exists. */
9136
+ cleanupOutputFile(outputPath) {
9137
+ try {
9138
+ if (existsSync(outputPath))
9139
+ unlinkSync(outputPath);
9140
+ }
9141
+ catch {
9142
+ /* best-effort cleanup */
9143
+ }
9144
+ }
9145
+ /** Invoke a gateway tool via the /tools/invoke HTTP endpoint. */
9146
+ async invoke(tool, args, sessionKey) {
9147
+ const headers = {
9148
+ 'Content-Type': 'application/json',
9149
+ };
9150
+ if (this.apiKey) {
9151
+ headers['Authorization'] = 'Bearer ' + this.apiKey;
9152
+ }
9153
+ const body = { tool, args };
9154
+ if (sessionKey)
9155
+ body.sessionKey = sessionKey;
9156
+ const res = await fetch(this.gatewayUrl + '/tools/invoke', {
9157
+ method: 'POST',
9158
+ headers,
9159
+ body: JSON.stringify(body),
9160
+ });
9161
+ if (!res.ok) {
9162
+ const text = await res.text();
9163
+ throw new Error(`Gateway ${tool} failed: HTTP ${res.status.toString()} - ${text}`);
9164
+ }
9165
+ const data = (await res.json());
9166
+ if (data.ok === false || data.error) {
9167
+ throw new Error(`Gateway ${tool} error: ${data.error?.message ?? JSON.stringify(data)}`);
9168
+ }
9169
+ return data;
9170
+ }
9171
+ /** Look up session metadata (tokens, completion status) via sessions_list. */
9172
+ async getSessionInfo(sessionKey) {
9173
+ try {
9174
+ const result = await this.invoke('sessions_list', {
9175
+ limit: 200,
9176
+ messageLimit: 0,
9177
+ });
9178
+ const sessions = (result.result?.details?.sessions ??
9179
+ result.result?.sessions ??
9180
+ []);
9181
+ const match = sessions.find((s) => s.key === sessionKey);
9182
+ if (!match) {
9183
+ // Session absent from list — likely cleaned up after completion.
9184
+ // With limit=200 this is reliable; a false positive here only
9185
+ // means we read the output file slightly early (still correct
9186
+ // if the file exists).
9187
+ return { completed: true };
9188
+ }
9189
+ const done = match.status === 'completed' || match.status === 'done';
9190
+ return { tokens: match.totalTokens, completed: done };
9191
+ }
9192
+ catch {
9193
+ return { completed: false };
9194
+ }
9195
+ }
9196
+ /** Whether this executor has been aborted by the operator. */
9197
+ get aborted() {
9198
+ return this.controller.signal.aborted;
9199
+ }
9200
+ /** Abort the currently running spawn, if any. */
9201
+ abort() {
9202
+ this.controller.abort();
9203
+ }
9204
+ async spawn(task, options) {
9205
+ // Fresh controller for each spawn call
9206
+ this.controller = new AbortController();
9207
+ const timeoutSeconds = options?.timeout ?? DEFAULT_TIMEOUT_MS$1 / 1000;
9208
+ const timeoutMs = timeoutSeconds * 1000;
9209
+ const deadline = Date.now() + timeoutMs;
9210
+ // Ensure workspace dir exists
9211
+ if (!existsSync(this.workspaceDir)) {
9212
+ mkdirSync(this.workspaceDir, { recursive: true });
9213
+ }
9214
+ // Generate unique output path for file-based output
9215
+ const outputId = randomUUID();
9216
+ const outputPath = this.workspaceDir + '/output-' + outputId + '.json';
9217
+ // Append file output instruction to the task
9218
+ const taskWithOutput = task +
9219
+ '\n\n## OUTPUT DELIVERY\n\n' +
9220
+ 'Write your complete output to a file using the Write tool at:\n' +
9221
+ outputPath +
9222
+ '\n\n' +
9223
+ 'After writing the file, reply with ONLY: NO_REPLY';
9224
+ // Step 1: Spawn the sub-agent session (unique label per cycle to avoid
9225
+ // "label already in use" errors — gateway labels persist after session completion)
9226
+ const labelBase = options?.label ?? 'jeeves-meta-synthesis';
9227
+ const label = labelBase + '-' + outputId.slice(0, 8);
9228
+ const spawnResult = await this.invoke('sessions_spawn', {
9229
+ task: taskWithOutput,
9230
+ label,
9231
+ runTimeoutSeconds: timeoutSeconds,
9232
+ ...(options?.thinking ? { thinking: options.thinking } : {}),
9233
+ ...(options?.model ? { model: options.model } : {}),
9234
+ });
9235
+ const details = (spawnResult.result?.details ?? spawnResult.result);
9236
+ const sessionKey = details?.childSessionKey ?? details?.sessionKey;
9237
+ if (typeof sessionKey !== 'string' || !sessionKey) {
9238
+ throw new Error('Gateway sessions_spawn returned no sessionKey: ' +
9239
+ JSON.stringify(spawnResult));
9240
+ }
9241
+ // Step 2: Poll for completion via sessions_history
9242
+ await sleepAsync(3000);
9243
+ while (Date.now() < deadline) {
9244
+ // Check for abort before each poll iteration
9245
+ if (this.controller.signal.aborted) {
9246
+ this.cleanupOutputFile(outputPath);
9247
+ throw new SpawnAbortedError();
9248
+ }
9249
+ try {
9250
+ const historyResult = await this.invoke('sessions_history', {
9251
+ sessionKey,
9252
+ limit: 5,
9253
+ includeTools: false,
9254
+ });
9255
+ const messages = historyResult.result?.details?.messages ??
9256
+ historyResult.result?.messages ??
9257
+ [];
9258
+ const msgArray = messages;
9259
+ // Check 1: terminal stop reason in history
9260
+ let historyDone = false;
9261
+ if (msgArray.length > 0) {
9262
+ const lastMsg = msgArray[msgArray.length - 1];
9263
+ if (lastMsg.role === 'assistant' &&
9264
+ lastMsg.stopReason &&
9265
+ lastMsg.stopReason !== 'toolUse' &&
9266
+ lastMsg.stopReason !== 'error') {
9267
+ historyDone = true;
9268
+ }
9269
+ }
9270
+ // Check 2: session completion status via sessions_list
9271
+ const sessionInfo = await this.getSessionInfo(sessionKey);
9272
+ if (historyDone || sessionInfo.completed) {
9273
+ const tokens = sessionInfo.tokens;
9274
+ // Read output from file (sub-agent wrote it via Write tool)
9275
+ if (existsSync(outputPath)) {
9276
+ try {
9277
+ const output = readFileSync(outputPath, 'utf8');
9278
+ return { output, tokens };
9279
+ }
9280
+ finally {
9281
+ try {
9282
+ unlinkSync(outputPath);
9283
+ }
9284
+ catch {
9285
+ /* cleanup best-effort */
9286
+ }
9287
+ }
9288
+ }
9289
+ // Fallback: extract from message content if file wasn't written
9290
+ for (let i = msgArray.length - 1; i >= 0; i--) {
9291
+ const msg = msgArray[i];
9292
+ if (msg.role === 'assistant' && msg.content) {
9293
+ const text = typeof msg.content === 'string'
9294
+ ? msg.content
9295
+ : Array.isArray(msg.content)
9296
+ ? msg.content
9297
+ .filter((b) => b.type === 'text' && b.text)
9298
+ .map((b) => b.text)
9299
+ .join('\n')
9300
+ : '';
9301
+ if (text)
9302
+ return { output: text, tokens };
9303
+ }
9304
+ }
9305
+ return { output: '', tokens };
9306
+ }
9307
+ }
9308
+ catch {
9309
+ // Transient poll failure — keep trying
9310
+ }
9311
+ await sleepAsync(this.pollIntervalMs);
9312
+ }
9313
+ throw new SpawnTimeoutError('Synthesis subprocess timed out after ' + timeoutMs.toString() + 'ms', outputPath);
9885
9314
  }
9886
- return node;
9887
9315
  }
9888
9316
 
9889
9317
  /**
9890
- * Weighted staleness formula for candidate selection.
9318
+ * Pino logger factory.
9891
9319
  *
9892
- * effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
9320
+ * @module logger
9321
+ */
9322
+ /**
9323
+ * Create a pino logger instance.
9893
9324
  *
9894
- * @module scheduling/weightedFormula
9325
+ * @param config - Optional logger configuration.
9326
+ * @returns Configured pino logger.
9895
9327
  */
9328
+ function createLogger(config) {
9329
+ const level = config?.level ?? 'info';
9330
+ if (config?.file) {
9331
+ const transport = pino.transport({
9332
+ target: 'pino/file',
9333
+ options: { destination: config.file, mkdir: true },
9334
+ });
9335
+ return pino({ level }, transport);
9336
+ }
9337
+ return pino({ level });
9338
+ }
9339
+
9896
9340
  /**
9897
- * Compute effective staleness for a set of candidates.
9341
+ * Built-in default prompts for the synthesis pipeline.
9898
9342
  *
9899
- * Normalizes depths so the minimum becomes 0, then applies the formula:
9900
- * effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
9343
+ * Prompts ship as .md files bundled into dist/prompts/ via rollup-plugin-copy.
9344
+ * Loaded at runtime relative to the compiled module location.
9901
9345
  *
9902
- * Per-meta _emphasis (default 1) multiplies depthWeight, allowing individual
9903
- * metas to tune how much their tree position affects scheduling.
9346
+ * Users can override via `defaultArchitect` / `defaultCritic` in the service
9347
+ * config. Most installations should use the built-in defaults.
9904
9348
  *
9905
- * @param candidates - Array of \{ node, meta, actualStaleness \}.
9906
- * @param depthWeight - Exponent for depth weighting (0 = pure staleness).
9907
- * @returns Same array with effectiveStaleness computed.
9349
+ * @module prompts
9908
9350
  */
9909
- function computeEffectiveStaleness(candidates, depthWeight) {
9910
- if (candidates.length === 0)
9911
- return [];
9912
- // Get depth for each candidate: use _depth override or tree depth
9913
- const depths = candidates.map((c) => c.meta._depth ?? c.node.treeDepth);
9914
- // Normalize: shift so minimum becomes 0
9915
- const minDepth = Math.min(...depths);
9916
- const normalizedDepths = depths.map((d) => Math.max(0, d - minDepth));
9917
- return candidates.map((c, i) => {
9918
- const emphasis = c.meta._emphasis ?? 1;
9919
- return {
9920
- ...c,
9921
- effectiveStaleness: c.actualStaleness *
9922
- Math.pow(normalizedDepths[i] + 1, depthWeight * emphasis),
9923
- };
9924
- });
9925
- }
9351
+ const packageRoot = packageDirectorySync({
9352
+ cwd: fileURLToPath(import.meta.url),
9353
+ });
9354
+ const promptDir = join(packageRoot, 'dist', 'prompts');
9355
+ /** Built-in default architect prompt. */
9356
+ const DEFAULT_ARCHITECT_PROMPT = readFileSync(join(promptDir, 'architect.md'), 'utf8');
9357
+ /** Built-in default critic prompt. */
9358
+ const DEFAULT_CRITIC_PROMPT = readFileSync(join(promptDir, 'critic.md'), 'utf8');
9926
9359
 
9927
9360
  /**
9928
- * Select the best synthesis candidate from stale metas.
9361
+ * Build the MetaContext for a synthesis cycle.
9929
9362
  *
9930
- * Picks the meta with highest effective staleness.
9363
+ * Computes shared inputs once: scope files, delta files, child meta outputs,
9364
+ * previous content/feedback, steer, and archive paths.
9931
9365
  *
9932
- * @module scheduling/selectCandidate
9366
+ * @module orchestrator/contextPackage
9933
9367
  */
9934
9368
  /**
9935
- * Select the candidate with the highest effective staleness.
9369
+ * Condense a file list into glob-like summaries.
9370
+ * Groups by directory + extension pattern.
9936
9371
  *
9937
- * @param candidates - Array of candidates with computed effective staleness.
9938
- * @returns The winning candidate, or null if no candidates.
9372
+ * @param files - Array of file paths.
9373
+ * @param maxIndividual - Show individual files up to this count.
9374
+ * @returns Condensed summary string.
9939
9375
  */
9940
- function selectCandidate(candidates) {
9941
- if (candidates.length === 0)
9942
- return null;
9943
- let best = candidates[0];
9944
- for (let i = 1; i < candidates.length; i++) {
9945
- if (candidates[i].effectiveStaleness > best.effectiveStaleness) {
9946
- best = candidates[i];
9947
- }
9376
+ function condenseScopeFiles(files, maxIndividual = 30) {
9377
+ if (files.length <= maxIndividual)
9378
+ return files.join('\n');
9379
+ // Group by dir + extension
9380
+ const groups = new Map();
9381
+ for (const f of files) {
9382
+ const dir = f.substring(0, f.lastIndexOf('/') + 1) || './';
9383
+ const ext = f.includes('.') ? f.substring(f.lastIndexOf('.')) : '(no ext)';
9384
+ const key = dir + '*' + ext;
9385
+ groups.set(key, (groups.get(key) ?? 0) + 1);
9948
9386
  }
9949
- return best;
9387
+ // Sort by count descending
9388
+ const sorted = [...groups.entries()].sort((a, b) => b[1] - a[1]);
9389
+ return sorted
9390
+ .map(([pattern, count]) => pattern + ' (' + count.toString() + ' files)')
9391
+ .join('\n');
9950
9392
  }
9951
9393
  /**
9952
- * Extract stale candidates from a list and return the stalest path.
9953
- *
9954
- * Consolidates the repeated pattern of:
9955
- * filter → computeEffectiveStaleness → selectCandidate → return path
9394
+ * Read a meta.json file and extract its `_content` field.
9956
9395
  *
9957
- * @param candidates - Array with node, meta, and stalenessSeconds.
9958
- * @param depthWeight - Depth weighting exponent from config.
9959
- * @returns The stalest candidate's metaPath, or null if none are stale.
9396
+ * @param metaJsonPath - Absolute path to a meta.json file.
9397
+ * @returns The `_content` string, or null if missing/unreadable.
9960
9398
  */
9961
- function discoverStalestPath(candidates, depthWeight) {
9962
- const weighted = computeEffectiveStaleness(candidates, depthWeight);
9963
- const winner = selectCandidate(weighted);
9964
- return winner?.node.metaPath ?? null;
9399
+ async function readMetaContent(metaJsonPath) {
9400
+ try {
9401
+ const raw = await readFile(metaJsonPath, 'utf8');
9402
+ const meta = JSON.parse(raw);
9403
+ return meta._content ?? null;
9404
+ }
9405
+ catch {
9406
+ return null;
9407
+ }
9965
9408
  }
9966
-
9967
- /**
9968
- * Shared error utilities.
9969
- *
9970
- * @module errors
9971
- */
9972
9409
  /**
9973
- * Wrap an unknown caught value into a MetaError.
9410
+ * Build the context package for a synthesis cycle.
9974
9411
  *
9975
- * @param step - Which synthesis step failed.
9976
- * @param err - The caught error value.
9977
- * @param code - Error classification code.
9978
- * @returns A structured MetaError.
9412
+ * @param node - The meta node being synthesized.
9413
+ * @param meta - Current meta.json content.
9414
+ * @param watcher - WatcherClient for scope enumeration.
9415
+ * @returns The computed context package.
9979
9416
  */
9980
- function toMetaError(step, err, code = 'FAILED') {
9417
+ async function buildContextPackage(node, meta, watcher, logger) {
9418
+ // Scope and delta files via watcher walk
9419
+ const scopeStart = Date.now();
9420
+ const { scopeFiles } = await getScopeFiles(node, watcher, logger);
9421
+ const deltaFiles = getDeltaFiles(meta._generatedAt, scopeFiles);
9422
+ logger?.debug({
9423
+ scopeFiles: scopeFiles.length,
9424
+ deltaFiles: deltaFiles.length,
9425
+ durationMs: Date.now() - scopeStart,
9426
+ }, 'scope and delta files computed');
9427
+ // Child meta outputs (parallel reads)
9428
+ const childMetas = {};
9429
+ const childEntries = await Promise.all(node.children.map(async (child) => {
9430
+ const content = await readMetaContent(join(child.metaPath, 'meta.json'));
9431
+ return [child.ownerPath, content];
9432
+ }));
9433
+ for (const [path, content] of childEntries) {
9434
+ childMetas[path] = content;
9435
+ }
9436
+ // Cross-referenced meta outputs (parallel reads)
9437
+ const crossRefMetas = {};
9438
+ const seen = new Set();
9439
+ const crossRefPaths = [];
9440
+ for (const refPath of meta._crossRefs ?? []) {
9441
+ if (refPath === node.ownerPath || refPath === node.metaPath)
9442
+ continue;
9443
+ if (seen.has(refPath))
9444
+ continue;
9445
+ seen.add(refPath);
9446
+ crossRefPaths.push(refPath);
9447
+ }
9448
+ const crossRefEntries = await Promise.all(crossRefPaths.map(async (refPath) => {
9449
+ const content = await readMetaContent(join(refPath, '.meta', 'meta.json'));
9450
+ return [refPath, content];
9451
+ }));
9452
+ for (const [path, content] of crossRefEntries) {
9453
+ crossRefMetas[path] = content;
9454
+ }
9455
+ // Archive paths
9456
+ const archives = listArchiveFiles(node.metaPath);
9981
9457
  return {
9982
- step,
9983
- code,
9984
- message: err instanceof Error ? err.message : String(err),
9458
+ path: node.metaPath,
9459
+ scopeFiles,
9460
+ deltaFiles,
9461
+ childMetas,
9462
+ crossRefMetas,
9463
+ previousContent: meta._content ?? null,
9464
+ previousFeedback: meta._feedback ?? null,
9465
+ steer: meta._steer ?? null,
9466
+ previousState: meta._state ?? null,
9467
+ archives,
9985
9468
  };
9986
9469
  }
9987
9470
 
9988
9471
  /**
9989
- * Compute a structure hash from a sorted file listing.
9990
- *
9991
- * Used to detect when directory structure changes, triggering
9992
- * an architect re-run.
9993
- *
9994
- * @module structureHash
9995
- */
9996
- /**
9997
- * Compute a SHA-256 hash of a sorted file listing.
9998
- *
9999
- * @param filePaths - Array of file paths in scope.
10000
- * @returns Hex-encoded SHA-256 hash of the sorted, newline-joined paths.
10001
- */
10002
- function computeStructureHash(filePaths) {
10003
- const sorted = [...filePaths].sort();
10004
- const content = sorted.join('\n');
10005
- return createHash('sha256').update(content).digest('hex');
10006
- }
10007
-
10008
- /**
10009
- * Lock-staged cycle finalization: write to .lock, copy to meta.json, archive, prune.
10010
- *
10011
- * @module orchestrator/finalizeCycle
10012
- */
10013
- /** Finalize a cycle using lock staging: write to .lock → copy to meta.json + archive → delete .lock. */
10014
- async function finalizeCycle(opts) {
10015
- const lockPath = join(opts.metaPath, '.lock');
10016
- const metaJsonPath = join(opts.metaPath, 'meta.json');
10017
- // Stage: write merged result to .lock (sequential — ordering matters)
10018
- const updated = await mergeAndWrite({
10019
- metaPath: opts.metaPath,
10020
- current: opts.current,
10021
- architect: opts.architect,
10022
- builder: opts.builder,
10023
- critic: opts.critic,
10024
- builderOutput: opts.builderOutput,
10025
- feedback: opts.feedback,
10026
- structureHash: opts.structureHash,
10027
- synthesisCount: opts.synthesisCount,
10028
- error: opts.error,
10029
- architectTokens: opts.architectTokens,
10030
- builderTokens: opts.builderTokens,
10031
- criticTokens: opts.criticTokens,
10032
- outputPath: lockPath,
10033
- state: opts.state,
10034
- stateOnly: opts.stateOnly,
10035
- });
10036
- // Commit: copy .lock → meta.json
10037
- await copyFile(lockPath, metaJsonPath);
10038
- // Archive + prune from the committed meta.json (sequential)
10039
- await createSnapshot(opts.metaPath, updated);
10040
- await pruneArchive(opts.metaPath, opts.config.maxArchive);
10041
- // .lock is cleaned up by the finally block (releaseLock)
10042
- return updated;
10043
- }
10044
-
10045
- /**
10046
- * Parse subprocess outputs for each synthesis step.
10047
- *
10048
- * - Architect: returns text \> _builder
10049
- * - Builder: returns JSON \> _content + structured fields
10050
- * - Critic: returns text \> _feedback
9472
+ * Build task prompts for each synthesis step.
10051
9473
  *
10052
- * @module orchestrator/parseOutput
10053
- */
10054
- /**
10055
- * Parse architect output. The architect returns a task brief as text.
9474
+ * Prompts are compiled as Handlebars templates with access to config,
9475
+ * meta, and scope context. The architect can write template expressions
9476
+ * into its _builder output; these resolve when the builder task is compiled.
10056
9477
  *
10057
- * @param output - Raw subprocess output.
10058
- * @returns The task brief string.
9478
+ * @module orchestrator/buildTask
10059
9479
  */
10060
- function parseArchitectOutput(output) {
10061
- return output.trim();
9480
+ Handlebars.registerHelper('gt', (a, b) => a > b);
9481
+ /** Build the template context from synthesis inputs. */
9482
+ function buildTemplateContext(ctx, meta, config) {
9483
+ return {
9484
+ config,
9485
+ meta,
9486
+ scope: {
9487
+ fileCount: ctx.scopeFiles.length,
9488
+ deltaCount: ctx.deltaFiles.length,
9489
+ childCount: Object.keys(ctx.childMetas).length,
9490
+ crossRefCount: Object.keys(ctx.crossRefMetas).length,
9491
+ },
9492
+ };
10062
9493
  }
10063
9494
  /**
10064
- * Parse builder output. The builder returns JSON with _content and optional fields.
10065
- *
10066
- * Attempts JSON parse first. If that fails, treats the entire output as _content.
10067
- *
10068
- * @param output - Raw subprocess output.
10069
- * @returns Parsed builder output with content and structured fields.
9495
+ * Compile a string as a Handlebars template with the given context.
9496
+ * Returns the original string unchanged if compilation fails.
10070
9497
  */
10071
- function parseBuilderOutput(output) {
10072
- const trimmed = output.trim();
10073
- // Strategy 1: Try to parse the entire output as JSON directly
10074
- const direct = tryParseJson(trimmed);
10075
- if (direct)
10076
- return direct;
10077
- // Strategy 2: Try all fenced code blocks (last match first — models often narrate then output)
10078
- const fencePattern = /```(?:json)?\s*([\s\S]*?)```/g;
10079
- const fenceMatches = [];
10080
- let match;
10081
- while ((match = fencePattern.exec(trimmed)) !== null) {
10082
- fenceMatches.push(match[1].trim());
9498
+ function compileTemplate(text, context) {
9499
+ try {
9500
+ return Handlebars.compile(text, { noEscape: true })(context);
10083
9501
  }
10084
- // Try last fence first (most likely to be the actual output)
10085
- for (let i = fenceMatches.length - 1; i >= 0; i--) {
10086
- const result = tryParseJson(fenceMatches[i]);
10087
- if (result)
10088
- return result;
9502
+ catch {
9503
+ return text;
10089
9504
  }
10090
- // Strategy 3: Find outermost { ... } braces
10091
- const firstBrace = trimmed.indexOf('{');
10092
- const lastBrace = trimmed.lastIndexOf('}');
10093
- if (firstBrace !== -1 && lastBrace > firstBrace) {
10094
- const result = tryParseJson(trimmed.substring(firstBrace, lastBrace + 1));
10095
- if (result)
10096
- return result;
9505
+ }
9506
+ /** Append a keyed record of meta outputs as subsections, if non-empty. */
9507
+ function appendMetaSections(sections, heading, metas) {
9508
+ if (Object.keys(metas).length === 0)
9509
+ return;
9510
+ sections.push('', heading);
9511
+ for (const [path, content] of Object.entries(metas)) {
9512
+ sections.push(`### ${path}`, typeof content === 'string' ? content : '(not yet synthesized)');
10097
9513
  }
10098
- // Fallback: treat entire output as content
10099
- return { content: trimmed, fields: {} };
10100
9514
  }
10101
- /** Try to parse a string as JSON and extract builder output fields. */
10102
- function tryParseJson(str) {
10103
- try {
10104
- const raw = JSON.parse(str);
10105
- if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
10106
- return null;
10107
- }
10108
- const parsed = raw;
10109
- // Extract _content
10110
- const content = typeof parsed['_content'] === 'string'
10111
- ? parsed['_content']
10112
- : typeof parsed['content'] === 'string'
10113
- ? parsed['content']
10114
- : null;
10115
- if (content === null)
10116
- return null;
10117
- // Extract _state (the ONLY underscore key the builder is allowed to set)
10118
- const state = '_state' in parsed ? parsed['_state'] : undefined;
10119
- // Extract non-underscore fields
10120
- const fields = {};
10121
- for (const [key, value] of Object.entries(parsed)) {
10122
- if (!key.startsWith('_') && key !== 'content') {
10123
- fields[key] = value;
10124
- }
10125
- }
10126
- return { content, fields, ...(state !== undefined ? { state } : {}) };
9515
+ /** Append optional context sections shared across all step prompts. */
9516
+ function appendSharedSections(sections, ctx, options) {
9517
+ const opts = {
9518
+ includeSteer: true,
9519
+ includePreviousContent: true,
9520
+ includePreviousFeedback: true,
9521
+ feedbackHeading: '## PREVIOUS FEEDBACK',
9522
+ includeChildMetas: true,
9523
+ includeCrossRefs: true,
9524
+ ...options,
9525
+ };
9526
+ if (opts.includeSteer && ctx.steer) {
9527
+ sections.push('', '## STEERING PROMPT', ctx.steer);
10127
9528
  }
10128
- catch {
10129
- return null;
9529
+ if (opts.includePreviousContent && ctx.previousContent) {
9530
+ sections.push('', '## PREVIOUS SYNTHESIS', ctx.previousContent);
9531
+ }
9532
+ if (opts.includePreviousFeedback && ctx.previousFeedback) {
9533
+ sections.push('', opts.feedbackHeading, ctx.previousFeedback);
9534
+ }
9535
+ if (opts.includeChildMetas) {
9536
+ appendMetaSections(sections, '## CHILD META OUTPUTS', ctx.childMetas);
9537
+ }
9538
+ if (opts.includeCrossRefs) {
9539
+ appendMetaSections(sections, '## CROSS-REFERENCED METAS', ctx.crossRefMetas);
10130
9540
  }
10131
9541
  }
10132
9542
  /**
10133
- * Parse critic output. The critic returns evaluation text.
9543
+ * Build the architect task prompt.
10134
9544
  *
10135
- * @param output - Raw subprocess output.
10136
- * @returns The feedback string.
9545
+ * @param ctx - Synthesis context.
9546
+ * @param meta - Current meta.json.
9547
+ * @param config - Synthesis config.
9548
+ * @returns The architect task prompt string.
10137
9549
  */
10138
- function parseCriticOutput(output) {
10139
- return output.trim();
9550
+ function buildArchitectTask(ctx, meta, config) {
9551
+ const sections = [
9552
+ `# jeeves-meta · ARCHITECT · ${ctx.path}`,
9553
+ '',
9554
+ meta._architect ?? config.defaultArchitect ?? DEFAULT_ARCHITECT_PROMPT,
9555
+ '',
9556
+ '## SCOPE',
9557
+ `Path: ${ctx.path}`,
9558
+ `Total files in scope: ${ctx.scopeFiles.length.toString()}`,
9559
+ `Files changed since last synthesis: ${ctx.deltaFiles.length.toString()}`,
9560
+ '',
9561
+ '### File listing (scope)',
9562
+ condenseScopeFiles(ctx.scopeFiles),
9563
+ ];
9564
+ // Inject previous _builder so architect can see its own prior output
9565
+ if (meta._builder) {
9566
+ sections.push('', '## PREVIOUS TASK BRIEF', meta._builder);
9567
+ }
9568
+ appendSharedSections(sections, ctx);
9569
+ if (ctx.archives.length > 0) {
9570
+ sections.push('', '## ARCHIVE HISTORY', `${ctx.archives.length.toString()} previous synthesis snapshots available in .meta/archive/.`, 'Review these to understand how the synthesis has evolved over time.');
9571
+ }
9572
+ return compileTemplate(sections.join('\n'), buildTemplateContext(ctx, meta, config));
10140
9573
  }
10141
-
10142
- /**
10143
- * Timeout recovery — salvage partial builder state after a SpawnTimeoutError.
10144
- *
10145
- * @module orchestrator/timeoutRecovery
10146
- */
10147
9574
  /**
10148
- * Attempt to recover partial state from a timed-out builder spawn.
9575
+ * Build the builder task prompt.
10149
9576
  *
10150
- * Returns an {@link OrchestrateResult} if state was salvaged, or `null`
10151
- * if the caller should fall through to a hard failure.
9577
+ * @param ctx - Synthesis context.
9578
+ * @param meta - Current meta.json.
9579
+ * @param config - Synthesis config.
9580
+ * @returns The builder task prompt string.
10152
9581
  */
10153
- async function attemptTimeoutRecovery(opts) {
10154
- const { err, currentMeta, metaPath, config, builderBrief, structureHash, synthesisCount, } = opts;
10155
- let partialOutput = null;
10156
- try {
10157
- const raw = await readFile(err.outputPath, 'utf8');
10158
- partialOutput = parseBuilderOutput(raw);
10159
- }
10160
- catch {
10161
- // Could not read partial output — fall through to hard failure
10162
- }
10163
- if (partialOutput?.state !== undefined) {
10164
- const currentState = JSON.stringify(currentMeta._state);
10165
- const newState = JSON.stringify(partialOutput.state);
10166
- if (newState !== currentState) {
10167
- const timeoutError = {
10168
- step: 'builder',
10169
- code: 'TIMEOUT',
10170
- message: err.message,
10171
- };
10172
- await finalizeCycle({
10173
- metaPath,
10174
- current: currentMeta,
10175
- config,
10176
- architect: currentMeta._architect ?? '',
10177
- builder: builderBrief,
10178
- critic: currentMeta._critic ?? '',
10179
- builderOutput: null,
10180
- feedback: null,
10181
- structureHash,
10182
- synthesisCount,
10183
- error: timeoutError,
10184
- state: partialOutput.state,
10185
- stateOnly: true,
10186
- });
10187
- return {
10188
- synthesized: true,
10189
- metaPath,
10190
- error: timeoutError,
10191
- };
10192
- }
9582
+ function buildBuilderTask(ctx, meta, config) {
9583
+ const sections = [
9584
+ `# jeeves-meta · BUILDER · ${ctx.path}`,
9585
+ '',
9586
+ '## TASK BRIEF (from Architect)',
9587
+ meta._builder ?? '(No architect brief available)',
9588
+ '',
9589
+ '## SCOPE',
9590
+ `Path: ${ctx.path}`,
9591
+ `Delta files (${ctx.deltaFiles.length.toString()} changed):`,
9592
+ ...ctx.deltaFiles.slice(0, config.maxLines).map((f) => `- ${f}`),
9593
+ ];
9594
+ if (ctx.previousState != null) {
9595
+ sections.push('', '## PREVIOUS STATE', 'The following opaque state was returned by the previous synthesis cycle.', 'Use it to continue progressive work. Update `_state` in your output to', 'reflect your progress.', '', '```json', JSON.stringify(ctx.previousState, null, 2), '```');
10193
9596
  }
10194
- return null;
9597
+ appendSharedSections(sections, ctx, {
9598
+ includeSteer: false,
9599
+ feedbackHeading: '## FEEDBACK FROM CRITIC',
9600
+ });
9601
+ sections.push('', '## OUTPUT FORMAT', '', 'Respond with ONLY a JSON object. No explanation, no markdown fences, no text before or after.', '', 'Required schema:', '{', ' "type": "object",', ' "required": ["_content"],', ' "properties": {', ' "_content": { "type": "string", "description": "Markdown narrative synthesis" },', ' "_state": { "description": "Opaque state object for progressive work across cycles" }', ' },', ' "additionalProperties": true', '}', '', 'Add any structured fields that capture important facts about this entity', '(e.g. status, risks, dependencies, metrics). Use descriptive key names without underscore prefix.', 'The _content field is the only required key — everything else is domain-driven.', '_state is optional: set it to carry state across synthesis cycles for progressive work.', '', 'DIAGRAMS: When diagrams would aid understanding, use PlantUML in fenced code blocks (```plantuml).', 'PlantUML is rendered natively by the serving infrastructure. NEVER use ASCII art diagrams.');
9602
+ return compileTemplate(sections.join('\n'), buildTemplateContext(ctx, meta, config));
10195
9603
  }
10196
-
10197
9604
  /**
10198
- * Single-node synthesis pipeline architect, builder, critic.
9605
+ * Build the critic task prompt.
10199
9606
  *
10200
- * @module orchestrator/synthesizeNode
9607
+ * @param ctx - Synthesis context.
9608
+ * @param meta - Current meta.json (with _content already set by builder).
9609
+ * @param config - Synthesis config.
9610
+ * @returns The critic task prompt string.
10201
9611
  */
10202
- /** Run the architect/builder/critic pipeline on a single node. */
10203
- async function synthesizeNode(node, currentMeta, config, executor, watcher, onProgress, logger) {
10204
- // Step 5-6: Steer change detection
10205
- const latestArchive = await readLatestArchive(node.metaPath);
10206
- const steerChanged = hasSteerChanged(currentMeta._steer, latestArchive?._steer, Boolean(latestArchive));
10207
- // Step 7: Compute context (includes scope files and delta files)
10208
- const ctx = await buildContextPackage(node, currentMeta, watcher, logger);
10209
- // Skip empty-scope entities that have no prior content.
10210
- // Without scope files, child metas, or cross-refs there is nothing for
10211
- // the architect/builder to work with and the cycle will either time out
10212
- // or produce empty output.
10213
- const hasScope = ctx.scopeFiles.length > 0 ||
10214
- Object.keys(ctx.childMetas).length > 0 ||
10215
- Object.keys(ctx.crossRefMetas).length > 0;
10216
- if (!hasScope && !currentMeta._content) {
10217
- // Bump _generatedAt so this entity doesn't keep winning the staleness
10218
- // race every cycle. It will be re-evaluated when files appear.
10219
- // Uses lock-staging for atomic write consistency.
10220
- currentMeta._generatedAt = new Date().toISOString();
10221
- const lockPath = join(node.metaPath, '.lock');
10222
- const metaJsonPath = join(node.metaPath, 'meta.json');
10223
- await writeFile(lockPath, JSON.stringify(currentMeta, null, 2));
10224
- await copyFile(lockPath, metaJsonPath);
10225
- logger?.debug({ path: node.ownerPath }, 'Skipping empty-scope entity');
10226
- return { synthesized: false };
10227
- }
10228
- // Step 5 (deferred): Structure hash from context scope files
10229
- const newStructureHash = computeStructureHash(ctx.scopeFiles);
10230
- const structureChanged = newStructureHash !== currentMeta._structureHash;
10231
- // Step 8: Architect (conditional)
10232
- const architectTriggered = isArchitectTriggered(currentMeta, structureChanged, steerChanged, config.architectEvery);
10233
- let builderBrief = currentMeta._builder ?? '';
10234
- let synthesisCount = currentMeta._synthesisCount ?? 0;
10235
- let stepError = null;
10236
- let architectTokens;
10237
- let builderTokens;
10238
- let criticTokens;
10239
- // Shared base options for all finalizeCycle calls.
10240
- // Note: synthesisCount is excluded because it mutates during the pipeline.
10241
- const baseFinalizeOptions = {
10242
- metaPath: node.metaPath,
10243
- current: currentMeta,
10244
- config,
10245
- architect: currentMeta._architect ?? '',
10246
- critic: currentMeta._critic ?? '',
10247
- structureHash: newStructureHash,
10248
- };
10249
- if (architectTriggered) {
10250
- try {
10251
- await onProgress?.({
10252
- type: 'phase_start',
10253
- path: node.ownerPath,
10254
- phase: 'architect',
10255
- });
10256
- const phaseStart = Date.now();
10257
- const architectTask = buildArchitectTask(ctx, currentMeta, config);
10258
- const architectResult = await executor.spawn(architectTask, {
10259
- thinking: config.thinking,
10260
- timeout: config.architectTimeout,
10261
- label: 'meta-architect',
10262
- });
10263
- builderBrief = parseArchitectOutput(architectResult.output);
10264
- architectTokens = architectResult.tokens;
10265
- synthesisCount = 0;
10266
- await onProgress?.({
10267
- type: 'phase_complete',
10268
- path: node.ownerPath,
10269
- phase: 'architect',
10270
- tokens: architectTokens,
10271
- durationMs: Date.now() - phaseStart,
10272
- });
10273
- }
10274
- catch (err) {
10275
- stepError = toMetaError('architect', err);
10276
- if (!currentMeta._builder) {
10277
- // No cached builder — cycle fails
10278
- await finalizeCycle({
10279
- ...baseFinalizeOptions,
10280
- builder: '',
10281
- builderOutput: null,
10282
- feedback: null,
10283
- synthesisCount,
10284
- error: stepError,
10285
- architectTokens,
10286
- });
10287
- return {
10288
- synthesized: true,
10289
- metaPath: node.metaPath,
10290
- error: stepError,
10291
- };
10292
- }
10293
- // Has cached builder — continue with existing
10294
- }
10295
- }
10296
- // Step 9: Builder
10297
- const metaForBuilder = { ...currentMeta, _builder: builderBrief };
10298
- let builderOutput;
10299
- try {
10300
- await onProgress?.({
10301
- type: 'phase_start',
10302
- path: node.ownerPath,
10303
- phase: 'builder',
10304
- });
10305
- const builderStart = Date.now();
10306
- const builderTask = buildBuilderTask(ctx, metaForBuilder, config);
10307
- const builderResult = await executor.spawn(builderTask, {
10308
- thinking: config.thinking,
10309
- timeout: config.builderTimeout,
10310
- label: 'meta-builder',
10311
- });
10312
- builderOutput = parseBuilderOutput(builderResult.output);
10313
- builderTokens = builderResult.tokens;
10314
- synthesisCount++;
10315
- await onProgress?.({
10316
- type: 'phase_complete',
10317
- path: node.ownerPath,
10318
- phase: 'builder',
10319
- tokens: builderTokens,
10320
- durationMs: Date.now() - builderStart,
10321
- });
10322
- }
10323
- catch (err) {
10324
- if (err instanceof SpawnTimeoutError) {
10325
- const recovered = await attemptTimeoutRecovery({
10326
- err,
10327
- currentMeta,
10328
- metaPath: node.metaPath,
10329
- config,
10330
- builderBrief,
10331
- structureHash: newStructureHash,
10332
- synthesisCount,
10333
- });
10334
- if (recovered)
10335
- return recovered;
10336
- }
10337
- stepError = toMetaError('builder', err);
10338
- await finalizeCycle({
10339
- ...baseFinalizeOptions,
10340
- builder: builderBrief,
10341
- builderOutput: null,
10342
- feedback: null,
10343
- synthesisCount,
10344
- error: stepError,
10345
- });
10346
- return { synthesized: true, metaPath: node.metaPath, error: stepError };
10347
- }
10348
- // Step 10: Critic
10349
- const metaForCritic = {
10350
- ...currentMeta,
10351
- _content: builderOutput.content,
10352
- };
10353
- let feedback = null;
10354
- try {
10355
- await onProgress?.({
10356
- type: 'phase_start',
10357
- path: node.ownerPath,
10358
- phase: 'critic',
10359
- });
10360
- const criticStart = Date.now();
10361
- const criticTask = buildCriticTask(ctx, metaForCritic, config);
10362
- const criticResult = await executor.spawn(criticTask, {
10363
- thinking: config.thinking,
10364
- timeout: config.criticTimeout,
10365
- label: 'meta-critic',
10366
- });
10367
- feedback = parseCriticOutput(criticResult.output);
10368
- criticTokens = criticResult.tokens;
10369
- stepError = null; // Clear any architect error on full success
10370
- await onProgress?.({
10371
- type: 'phase_complete',
10372
- path: node.ownerPath,
10373
- phase: 'critic',
10374
- tokens: criticTokens,
10375
- durationMs: Date.now() - criticStart,
10376
- });
10377
- }
10378
- catch (err) {
10379
- stepError = stepError ?? toMetaError('critic', err);
10380
- }
10381
- // Steps 11-12: Merge, archive, prune
10382
- await finalizeCycle({
10383
- ...baseFinalizeOptions,
10384
- builder: builderBrief,
10385
- builderOutput,
10386
- feedback,
10387
- synthesisCount,
10388
- error: stepError,
10389
- architectTokens,
10390
- builderTokens,
10391
- criticTokens,
10392
- state: builderOutput.state,
9612
+ function buildCriticTask(ctx, meta, config) {
9613
+ const sections = [
9614
+ `# jeeves-meta · CRITIC · ${ctx.path}`,
9615
+ '',
9616
+ meta._critic ?? config.defaultCritic ?? DEFAULT_CRITIC_PROMPT,
9617
+ '',
9618
+ '## SYNTHESIS TO EVALUATE',
9619
+ meta._content ?? '(No content produced)',
9620
+ '',
9621
+ '## SCOPE',
9622
+ `Path: ${ctx.path}`,
9623
+ `Files in scope: ${ctx.scopeFiles.length.toString()}`,
9624
+ ];
9625
+ appendSharedSections(sections, ctx, {
9626
+ includePreviousContent: false,
9627
+ feedbackHeading: '## YOUR PREVIOUS FEEDBACK',
9628
+ includeChildMetas: false,
9629
+ includeCrossRefs: false,
10393
9630
  });
10394
- return {
10395
- synthesized: true,
10396
- metaPath: node.metaPath,
10397
- error: stepError ?? undefined,
10398
- };
9631
+ sections.push('', '## OUTPUT FORMAT', 'Return your evaluation as Markdown text. Be specific and actionable.');
9632
+ return compileTemplate(sections.join('\n'), buildTemplateContext(ctx, meta, config));
10399
9633
  }
10400
9634
 
10401
9635
  /**
10402
- * Main orchestration entry point discovery, scheduling, candidate selection.
9636
+ * Build a minimal MetaNode from a known meta path using watcher walk.
9637
+ *
9638
+ * Used for targeted synthesis (when a specific path is requested) to avoid
9639
+ * the full discovery + ownership tree build. Discovers only immediate child
9640
+ * `.meta/` directories.
10403
9641
  *
10404
- * @module orchestrator/orchestrate
9642
+ * @module discovery/buildMinimalNode
10405
9643
  */
10406
- async function orchestrateOnce(config, executor, watcher, targetPath, onProgress, logger) {
10407
- // When targetPath is provided, skip the expensive full discovery scan.
10408
- // Build a minimal node from the filesystem instead.
10409
- if (targetPath) {
10410
- const normalizedTarget = normalizePath(targetPath);
10411
- const targetMetaJson = join(normalizedTarget, 'meta.json');
10412
- if (!existsSync(targetMetaJson))
10413
- return { synthesized: false };
10414
- const node = await buildMinimalNode(normalizedTarget, watcher);
10415
- if (!acquireLock(node.metaPath))
10416
- return { synthesized: false };
10417
- try {
10418
- const currentMeta = await readMetaJson(normalizedTarget);
10419
- return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress, logger);
10420
- }
10421
- finally {
10422
- releaseLock(node.metaPath);
10423
- }
10424
- }
10425
- // Full discovery path (scheduler-driven, no specific target)
10426
- // Step 1: Discover via watcher walk
10427
- const discoveryStart = Date.now();
10428
- const metaPaths = await discoverMetas(watcher);
10429
- logger?.debug({ paths: metaPaths.length, durationMs: Date.now() - discoveryStart }, 'discovery complete');
10430
- if (metaPaths.length === 0)
10431
- return { synthesized: false };
10432
- // Read meta.json for each discovered meta
10433
- const metas = new Map();
10434
- for (const mp of metaPaths) {
10435
- try {
10436
- metas.set(normalizePath(mp), await readMetaJson(mp));
10437
- }
10438
- catch {
10439
- // Skip metas with unreadable meta.json
10440
- continue;
10441
- }
10442
- }
10443
- // Only build tree from paths with readable meta.json (excludes orphaned/deleted entries)
10444
- const validPaths = metaPaths.filter((mp) => metas.has(normalizePath(mp)));
10445
- if (validPaths.length === 0)
10446
- return { synthesized: false };
10447
- const tree = buildOwnershipTree(validPaths);
10448
- // Steps 3-4: Staleness check + candidate selection
10449
- const candidates = [];
10450
- for (const treeNode of tree.nodes.values()) {
10451
- const meta = metas.get(treeNode.metaPath);
10452
- if (!meta)
10453
- continue;
10454
- const staleness = actualStaleness(meta);
10455
- if (staleness > 0) {
10456
- candidates.push({ node: treeNode, meta, actualStaleness: staleness });
10457
- }
10458
- }
10459
- const weighted = computeEffectiveStaleness(candidates, config.depthWeight);
10460
- // Sort by effective staleness descending
10461
- const ranked = [...weighted].sort((a, b) => b.effectiveStaleness - a.effectiveStaleness);
10462
- if (ranked.length === 0)
10463
- return { synthesized: false };
10464
- // Find the first candidate with actual changes (if skipUnchanged)
10465
- let winner = null;
10466
- for (const candidate of ranked) {
10467
- if (!acquireLock(candidate.node.metaPath))
10468
- continue;
10469
- const verifiedStale = await isStale(getScopePrefix(candidate.node), candidate.meta, watcher);
10470
- if (!verifiedStale && candidate.meta._generatedAt) {
10471
- // Bump _generatedAt so it doesn't win next cycle
10472
- const freshMeta = await readMetaJson(candidate.node.metaPath);
10473
- freshMeta._generatedAt = new Date().toISOString();
10474
- await writeFile(join(candidate.node.metaPath, 'meta.json'), JSON.stringify(freshMeta, null, 2));
10475
- releaseLock(candidate.node.metaPath);
10476
- if (config.skipUnchanged)
10477
- continue;
10478
- return { synthesized: false };
10479
- }
10480
- winner = candidate;
10481
- break;
10482
- }
10483
- if (!winner)
10484
- return { synthesized: false };
10485
- const node = winner.node;
10486
- try {
10487
- const currentMeta = await readMetaJson(node.metaPath);
10488
- return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress, logger);
10489
- }
10490
- finally {
10491
- // Step 13: Release lock
10492
- releaseLock(node.metaPath);
10493
- }
10494
- }
10495
9644
  /**
10496
- * Run a single synthesis cycle.
9645
+ * Build a minimal MetaNode for a known meta path.
10497
9646
  *
10498
- * Selects the stalest candidate (or a specific target) and runs the
10499
- * full architect/builder/critic pipeline.
9647
+ * Walks the owner directory for child `.meta/meta.json` files and constructs
9648
+ * a shallow ownership tree (self + direct children only).
10500
9649
  *
10501
- * @param config - Validated synthesis config.
10502
- * @param executor - Pluggable LLM executor.
10503
- * @param watcher - Watcher HTTP client.
10504
- * @param targetPath - Optional: specific meta/owner path to synthesize instead of stalest candidate.
10505
- * @returns Array with a single result.
9650
+ * @param metaPath - Absolute path to the `.meta/` directory.
9651
+ * @param watcher - WatcherClient for filesystem enumeration.
9652
+ * @returns MetaNode with direct children wired.
10506
9653
  */
10507
- async function orchestrate(config, executor, watcher, targetPath, onProgress, logger) {
10508
- const result = await orchestrateOnce(config, executor, watcher, targetPath, onProgress, logger);
10509
- return [result];
9654
+ async function buildMinimalNode(metaPath, watcher) {
9655
+ const normalized = normalizePath(metaPath);
9656
+ const ownerPath = posix.dirname(normalized);
9657
+ // Find child metas using watcher walk.
9658
+ // We include only *direct* children (nearest descendants in the ownership tree)
9659
+ // to match the ownership semantics used elsewhere.
9660
+ const rawMetaJsonPaths = await watcher.walk([
9661
+ `${escapeGlob(ownerPath)}/**/.meta/meta.json`,
9662
+ ]);
9663
+ const candidateMetaPaths = [
9664
+ ...new Set(rawMetaJsonPaths.map((p) => posix.dirname(normalizePath(p)))),
9665
+ ].filter((p) => p !== normalized);
9666
+ const candidates = candidateMetaPaths
9667
+ .map((mp) => ({ metaPath: mp, ownerPath: posix.dirname(mp) }))
9668
+ .sort((a, b) => a.ownerPath.length - b.ownerPath.length);
9669
+ const directChildren = [];
9670
+ for (const c of candidates) {
9671
+ const nestedUnderExisting = directChildren.some((d) => c.ownerPath === d.ownerPath ||
9672
+ c.ownerPath.startsWith(d.ownerPath + '/'));
9673
+ if (!nestedUnderExisting)
9674
+ directChildren.push(c);
9675
+ }
9676
+ const children = directChildren.map((c) => ({
9677
+ metaPath: c.metaPath,
9678
+ ownerPath: c.ownerPath,
9679
+ treeDepth: 1,
9680
+ children: [],
9681
+ parent: null,
9682
+ }));
9683
+ const node = {
9684
+ metaPath: normalized,
9685
+ ownerPath,
9686
+ treeDepth: 0,
9687
+ children,
9688
+ parent: null,
9689
+ };
9690
+ for (const child of children) {
9691
+ child.parent = node;
9692
+ }
9693
+ return node;
10510
9694
  }
10511
9695
 
10512
9696
  /**
@@ -10560,6 +9744,41 @@ function enforceInvariant(state) {
10560
9744
  }
10561
9745
  return result;
10562
9746
  }
9747
+ // ── Invalidation cascades ──────────────────────────────────────────────
9748
+ /**
9749
+ * Architect invalidated: architect → pending; builder, critic → stale.
9750
+ * Triggers: _structureHash change, _steer change, _architect change,
9751
+ * _crossRefs declaration change, _synthesisCount \>= architectEvery.
9752
+ */
9753
+ function invalidateArchitect(state) {
9754
+ return enforceInvariant({
9755
+ architect: state.architect === 'failed' ? 'failed' : 'pending',
9756
+ builder: state.builder === 'fresh' ? 'stale' : state.builder,
9757
+ critic: state.critic === 'fresh' ? 'stale' : state.critic,
9758
+ });
9759
+ }
9760
+ /**
9761
+ * Builder invalidated (scope mtime or cross-ref _content change):
9762
+ * builder → pending; critic → stale.
9763
+ * Only applies when architect is fresh; otherwise, builder stays stale.
9764
+ */
9765
+ function invalidateBuilder(state) {
9766
+ if (state.architect !== 'fresh') {
9767
+ // Architect is not fresh — builder stays stale (or whatever it is)
9768
+ return enforceInvariant({
9769
+ ...state,
9770
+ builder: state.builder === 'fresh' || state.builder === 'stale'
9771
+ ? 'stale'
9772
+ : state.builder,
9773
+ critic: state.critic === 'fresh' ? 'stale' : state.critic,
9774
+ });
9775
+ }
9776
+ return enforceInvariant({
9777
+ ...state,
9778
+ builder: state.builder === 'failed' ? 'failed' : 'pending',
9779
+ critic: state.critic === 'fresh' ? 'stale' : state.critic,
9780
+ });
9781
+ }
10563
9782
  // ── Phase success transitions ──────────────────────────────────────────
10564
9783
  /**
10565
9784
  * Architect completes successfully.
@@ -10728,7 +9947,9 @@ function derivePhaseState(meta, inputs) {
10728
9947
  }
10729
9948
  // Check architect invalidation (when inputs are provided)
10730
9949
  if (inputs) {
10731
- const architectInvalidated = inputs.structureChanged ||
9950
+ // Progressive metas: structure changes invalidate builder, not architect
9951
+ const structureInvalidatesArchitect = inputs.structureChanged && meta._state === undefined;
9952
+ const architectInvalidated = structureInvalidatesArchitect ||
10732
9953
  inputs.steerChanged ||
10733
9954
  inputs.architectChanged ||
10734
9955
  inputs.crossRefsChanged ||
@@ -10740,6 +9961,14 @@ function derivePhaseState(meta, inputs) {
10740
9961
  critic: 'stale',
10741
9962
  };
10742
9963
  }
9964
+ // Progressive meta with structure change: builder-only invalidation
9965
+ if (inputs.structureChanged && meta._state !== undefined) {
9966
+ return {
9967
+ architect: 'fresh',
9968
+ builder: 'pending',
9969
+ critic: 'stale',
9970
+ };
9971
+ }
10743
9972
  }
10744
9973
  // Has _builder but no _content: builder is pending
10745
9974
  if (meta._builder && !meta._content) {
@@ -10761,6 +9990,154 @@ function derivePhaseState(meta, inputs) {
10761
9990
  return freshPhaseState();
10762
9991
  }
10763
9992
 
9993
+ /**
9994
+ * Compute a structure hash from a sorted file listing.
9995
+ *
9996
+ * Used to detect when directory structure changes, triggering
9997
+ * an architect re-run.
9998
+ *
9999
+ * @module structureHash
10000
+ */
10001
+ /**
10002
+ * Compute a SHA-256 hash of a sorted file listing.
10003
+ *
10004
+ * @param filePaths - Array of file paths in scope.
10005
+ * @returns Hex-encoded SHA-256 hash of the sorted, newline-joined paths.
10006
+ */
10007
+ function computeStructureHash(filePaths) {
10008
+ const sorted = [...filePaths].sort();
10009
+ const content = sorted.join('\n');
10010
+ return createHash('sha256').update(content).digest('hex');
10011
+ }
10012
+
10013
+ /**
10014
+ * Per-tick invalidation pass.
10015
+ *
10016
+ * Computes architect-invalidating and builder-invalidating inputs for a meta,
10017
+ * then applies the cascade to update _phaseState.
10018
+ *
10019
+ * @module phaseState/invalidate
10020
+ */
10021
+ /**
10022
+ * Compute invalidation inputs and apply cascade for a single meta.
10023
+ *
10024
+ * @param meta - Current meta.json content with existing _phaseState.
10025
+ * @param scopeFiles - Sorted file list from scope.
10026
+ * @param config - MetaConfig for architectEvery.
10027
+ * @param node - MetaNode for archive access.
10028
+ * @param crossRefMetas - Map of cross-ref owner paths to their current _content.
10029
+ * @param archiveCrossRefContent - Map of cross-ref owner paths to their archived _content.
10030
+ * @returns Updated phase state and invalidation details.
10031
+ */
10032
+ async function computeInvalidation(meta, scopeFiles, config, node, crossRefMetas, archiveCrossRefContent) {
10033
+ let phaseState = meta._phaseState ?? {
10034
+ architect: 'fresh',
10035
+ builder: 'fresh',
10036
+ critic: 'fresh',
10037
+ };
10038
+ // ── Architect-level inputs ──
10039
+ const structureHash = computeStructureHash(scopeFiles);
10040
+ const structureChanged = structureHash !== meta._structureHash;
10041
+ const latestArchive = await readLatestArchive(node.metaPath);
10042
+ const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
10043
+ // _architect change: compare current vs. archive
10044
+ const architectChanged = latestArchive
10045
+ ? (meta._architect ?? '') !== (latestArchive._architect ?? '')
10046
+ : Boolean(meta._architect);
10047
+ // _crossRefs declaration change
10048
+ const currentRefs = (meta._crossRefs ?? []).slice().sort().join(',');
10049
+ const archiveRefs = (latestArchive?._crossRefs ?? [])
10050
+ .slice()
10051
+ .sort()
10052
+ .join(',');
10053
+ const crossRefsDeclChanged = latestArchive
10054
+ ? currentRefs !== archiveRefs
10055
+ : currentRefs.length > 0;
10056
+ const architectInvalidators = [];
10057
+ if (structureChanged) {
10058
+ if (meta._state !== undefined) {
10059
+ // Progressive entity: new files → builder only (cursor handles incremental)
10060
+ phaseState = invalidateBuilder(phaseState);
10061
+ }
10062
+ else {
10063
+ architectInvalidators.push('structureHash');
10064
+ }
10065
+ }
10066
+ if (steerChanged)
10067
+ architectInvalidators.push('steer');
10068
+ if (architectChanged)
10069
+ architectInvalidators.push('_architect');
10070
+ if (crossRefsDeclChanged)
10071
+ architectInvalidators.push('_crossRefs');
10072
+ if ((meta._synthesisCount ?? 0) >= config.architectEvery) {
10073
+ architectInvalidators.push('architectEvery');
10074
+ }
10075
+ // First-run check: no _builder means architect must run
10076
+ const firstRun = !meta._builder;
10077
+ if (architectInvalidators.length > 0 || firstRun) {
10078
+ phaseState = invalidateArchitect(phaseState);
10079
+ }
10080
+ // ── Builder-level inputs ──
10081
+ // Scope file mtime check — if any file newer than _generatedAt
10082
+ const scopeMtimeMax = null;
10083
+ // Note: actual mtime check is done by the caller or via isStale;
10084
+ // here we just detect cross-ref content changes for the cascade.
10085
+ // Cross-ref _content change (builder-invalidating)
10086
+ let crossRefContentChanged = false;
10087
+ return {
10088
+ phaseState,
10089
+ architectInvalidators,
10090
+ stalenessInputs: {
10091
+ structureHash,
10092
+ steerChanged,
10093
+ architectChanged,
10094
+ crossRefsDeclChanged,
10095
+ scopeMtimeMax,
10096
+ crossRefContentChanged,
10097
+ },
10098
+ structureHash,
10099
+ steerChanged,
10100
+ };
10101
+ }
10102
+
10103
+ /**
10104
+ * Weighted staleness formula for candidate selection.
10105
+ *
10106
+ * effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
10107
+ *
10108
+ * @module scheduling/weightedFormula
10109
+ */
10110
+ /**
10111
+ * Compute effective staleness for a set of candidates.
10112
+ *
10113
+ * Normalizes depths so the minimum becomes 0, then applies the formula:
10114
+ * effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
10115
+ *
10116
+ * Per-meta _emphasis (default 1) multiplies depthWeight, allowing individual
10117
+ * metas to tune how much their tree position affects scheduling.
10118
+ *
10119
+ * @param candidates - Array of \{ node, meta, actualStaleness \}.
10120
+ * @param depthWeight - Exponent for depth weighting (0 = pure staleness).
10121
+ * @returns Same array with effectiveStaleness computed.
10122
+ */
10123
+ function computeEffectiveStaleness(candidates, depthWeight) {
10124
+ if (candidates.length === 0)
10125
+ return [];
10126
+ // Get depth for each candidate: use _depth override or tree depth
10127
+ const depths = candidates.map((c) => c.meta._depth ?? c.node.treeDepth);
10128
+ // Normalize: shift so minimum becomes 0
10129
+ const minDepth = Math.min(...depths);
10130
+ const normalizedDepths = depths.map((d) => Math.max(0, d - minDepth));
10131
+ return candidates.map((c, i) => {
10132
+ const emphasis = c.meta._emphasis ?? 1;
10133
+ return {
10134
+ ...c,
10135
+ effectiveStaleness: c.actualStaleness *
10136
+ Math.pow(normalizedDepths[i] + 1, depthWeight * emphasis),
10137
+ };
10138
+ });
10139
+ }
10140
+
10764
10141
  /**
10765
10142
  * Corpus-wide phase scheduler.
10766
10143
  *
@@ -10773,18 +10150,30 @@ function derivePhaseState(meta, inputs) {
10773
10150
  /**
10774
10151
  * Build phase candidates from listMetas entries.
10775
10152
  *
10776
- * Derives phase state and auto-retries failed phases for each entry.
10153
+ * Derives phase state, auto-retries failed phases, and applies Tier 1
10154
+ * cheap-invalidation (no I/O) for metas with persisted _phaseState.
10777
10155
  * Used by orchestratePhase, queue route, and status route.
10778
10156
  */
10779
- function buildPhaseCandidates(entries) {
10780
- return entries.map((entry) => ({
10781
- node: entry.node,
10782
- meta: entry.meta,
10783
- phaseState: retryAllFailed(derivePhaseState(entry.meta)),
10784
- actualStaleness: entry.stalenessSeconds,
10785
- locked: entry.locked,
10786
- disabled: entry.disabled,
10787
- }));
10157
+ function buildPhaseCandidates(entries, architectEvery) {
10158
+ return entries.map((entry) => {
10159
+ let ps = retryAllFailed(derivePhaseState(entry.meta));
10160
+ // Tier 1 cheap invalidation for metas with persisted _phaseState
10161
+ if (entry.meta._phaseState) {
10162
+ const needsArchitect = !entry.meta._builder ||
10163
+ (entry.meta._synthesisCount ?? 0) >= architectEvery;
10164
+ if (needsArchitect && ps.architect === 'fresh') {
10165
+ ps = { architect: 'pending', builder: 'stale', critic: 'stale' };
10166
+ }
10167
+ }
10168
+ return {
10169
+ node: entry.node,
10170
+ meta: entry.meta,
10171
+ phaseState: ps,
10172
+ actualStaleness: entry.stalenessSeconds,
10173
+ locked: entry.locked,
10174
+ disabled: entry.disabled,
10175
+ };
10176
+ });
10788
10177
  }
10789
10178
  /**
10790
10179
  * Rank all eligible phase candidates by priority.
@@ -10837,14 +10226,132 @@ function rankPhaseCandidates(metas, depthWeight) {
10837
10226
  return candidates;
10838
10227
  }
10839
10228
  /**
10840
- * Select the best phase candidate across the corpus.
10229
+ * Select the best phase candidate across the corpus.
10230
+ *
10231
+ * @param metas - Array of (node, meta, phaseState, stalenessSeconds) tuples.
10232
+ * @param depthWeight - Config depthWeight for staleness tiebreak.
10233
+ * @returns The winning candidate, or null if no phase is ready.
10234
+ */
10235
+ function selectPhaseCandidate(metas, depthWeight) {
10236
+ return rankPhaseCandidates(metas, depthWeight)[0] ?? null;
10237
+ }
10238
+
10239
+ /**
10240
+ * Shared error utilities.
10241
+ *
10242
+ * @module errors
10243
+ */
10244
+ /**
10245
+ * Wrap an unknown caught value into a MetaError.
10246
+ *
10247
+ * @param step - Which synthesis step failed.
10248
+ * @param err - The caught error value.
10249
+ * @param code - Error classification code.
10250
+ * @returns A structured MetaError.
10251
+ */
10252
+ function toMetaError(step, err, code = 'FAILED') {
10253
+ return {
10254
+ step,
10255
+ code,
10256
+ message: err instanceof Error ? err.message : String(err),
10257
+ };
10258
+ }
10259
+
10260
+ /**
10261
+ * Parse subprocess outputs for each synthesis step.
10262
+ *
10263
+ * - Architect: returns text \> _builder
10264
+ * - Builder: returns JSON \> _content + structured fields
10265
+ * - Critic: returns text \> _feedback
10266
+ *
10267
+ * @module orchestrator/parseOutput
10268
+ */
10269
+ /**
10270
+ * Parse architect output. The architect returns a task brief as text.
10271
+ *
10272
+ * @param output - Raw subprocess output.
10273
+ * @returns The task brief string.
10274
+ */
10275
+ function parseArchitectOutput(output) {
10276
+ return output.trim();
10277
+ }
10278
+ /**
10279
+ * Parse builder output. The builder returns JSON with _content and optional fields.
10280
+ *
10281
+ * Attempts JSON parse first. If that fails, treats the entire output as _content.
10282
+ *
10283
+ * @param output - Raw subprocess output.
10284
+ * @returns Parsed builder output with content and structured fields.
10285
+ */
10286
+ function parseBuilderOutput(output) {
10287
+ const trimmed = output.trim();
10288
+ // Strategy 1: Try to parse the entire output as JSON directly
10289
+ const direct = tryParseJson(trimmed);
10290
+ if (direct)
10291
+ return direct;
10292
+ // Strategy 2: Try all fenced code blocks (last match first — models often narrate then output)
10293
+ const fencePattern = /```(?:json)?\s*([\s\S]*?)```/g;
10294
+ const fenceMatches = [];
10295
+ let match;
10296
+ while ((match = fencePattern.exec(trimmed)) !== null) {
10297
+ fenceMatches.push(match[1].trim());
10298
+ }
10299
+ // Try last fence first (most likely to be the actual output)
10300
+ for (let i = fenceMatches.length - 1; i >= 0; i--) {
10301
+ const result = tryParseJson(fenceMatches[i]);
10302
+ if (result)
10303
+ return result;
10304
+ }
10305
+ // Strategy 3: Find outermost { ... } braces
10306
+ const firstBrace = trimmed.indexOf('{');
10307
+ const lastBrace = trimmed.lastIndexOf('}');
10308
+ if (firstBrace !== -1 && lastBrace > firstBrace) {
10309
+ const result = tryParseJson(trimmed.substring(firstBrace, lastBrace + 1));
10310
+ if (result)
10311
+ return result;
10312
+ }
10313
+ // Fallback: treat entire output as content
10314
+ return { content: trimmed, fields: {} };
10315
+ }
10316
+ /** Try to parse a string as JSON and extract builder output fields. */
10317
+ function tryParseJson(str) {
10318
+ try {
10319
+ const raw = JSON.parse(str);
10320
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
10321
+ return null;
10322
+ }
10323
+ const parsed = raw;
10324
+ // Extract _content
10325
+ const content = typeof parsed['_content'] === 'string'
10326
+ ? parsed['_content']
10327
+ : typeof parsed['content'] === 'string'
10328
+ ? parsed['content']
10329
+ : null;
10330
+ if (content === null)
10331
+ return null;
10332
+ // Extract _state (the ONLY underscore key the builder is allowed to set)
10333
+ const state = '_state' in parsed ? parsed['_state'] : undefined;
10334
+ // Extract non-underscore fields
10335
+ const fields = {};
10336
+ for (const [key, value] of Object.entries(parsed)) {
10337
+ if (!key.startsWith('_') && key !== 'content') {
10338
+ fields[key] = value;
10339
+ }
10340
+ }
10341
+ return { content, fields, ...(state !== undefined ? { state } : {}) };
10342
+ }
10343
+ catch {
10344
+ return null;
10345
+ }
10346
+ }
10347
+ /**
10348
+ * Parse critic output. The critic returns evaluation text.
10841
10349
  *
10842
- * @param metas - Array of (node, meta, phaseState, stalenessSeconds) tuples.
10843
- * @param depthWeight - Config depthWeight for staleness tiebreak.
10844
- * @returns The winning candidate, or null if no phase is ready.
10350
+ * @param output - Raw subprocess output.
10351
+ * @returns The feedback string.
10845
10352
  */
10846
- function selectPhaseCandidate(metas, depthWeight) {
10847
- return rankPhaseCandidates(metas, depthWeight)[0] ?? null;
10353
+ function parseCriticOutput(output) {
10354
+ return output.trim();
10848
10355
  }
10849
10356
 
10850
10357
  /**
@@ -11105,7 +10612,7 @@ async function orchestratePhase(config, executor, watcher, targetPath, onProgres
11105
10612
  if (metaResult.entries.length === 0)
11106
10613
  return { executed: false };
11107
10614
  // Build candidates with phase state (including invalidation + auto-retry)
11108
- const candidates = buildPhaseCandidates(metaResult.entries);
10615
+ const candidates = buildPhaseCandidates(metaResult.entries, config.architectEvery);
11109
10616
  // Select best phase candidate
11110
10617
  const winner = selectPhaseCandidate(candidates, config.depthWeight);
11111
10618
  if (!winner) {
@@ -11780,46 +11287,6 @@ function buildMetaRules(config) {
11780
11287
  },
11781
11288
  renderAs: 'md',
11782
11289
  },
11783
- {
11784
- name: 'meta-config',
11785
- description: 'jeeves-meta configuration file',
11786
- match: {
11787
- properties: {
11788
- file: {
11789
- properties: {
11790
- path: {
11791
- type: 'string',
11792
- glob: '**/jeeves-meta{.config.json,/config.json}',
11793
- },
11794
- },
11795
- },
11796
- },
11797
- },
11798
- schema: ['base', { properties: { domains: { set: ['meta-config'] } } }],
11799
- render: {
11800
- frontmatter: [
11801
- 'watcherUrl',
11802
- 'gatewayUrl',
11803
- 'architectEvery',
11804
- 'depthWeight',
11805
- 'maxArchive',
11806
- 'maxLines',
11807
- ],
11808
- body: [
11809
- {
11810
- path: 'json.defaultArchitect',
11811
- heading: 2,
11812
- label: 'Default Architect Prompt',
11813
- },
11814
- {
11815
- path: 'json.defaultCritic',
11816
- heading: 2,
11817
- label: 'Default Critic Prompt',
11818
- },
11819
- ],
11820
- },
11821
- renderAs: 'md',
11822
- },
11823
11290
  ];
11824
11291
  }
11825
11292
  /**
@@ -12063,13 +11530,15 @@ class Scheduler {
12063
11530
  queue;
12064
11531
  logger;
12065
11532
  watcher;
11533
+ cache;
12066
11534
  registrar = null;
12067
11535
  currentExpression;
12068
- constructor(config, queue, logger, watcher) {
11536
+ constructor(config, queue, logger, watcher, cache) {
12069
11537
  this.config = config;
12070
11538
  this.queue = queue;
12071
11539
  this.logger = logger;
12072
11540
  this.watcher = watcher;
11541
+ this.cache = cache;
12073
11542
  this.currentExpression = config.schedule;
12074
11543
  }
12075
11544
  /** Set the rule registrar for watcher restart detection. */
@@ -12186,8 +11655,8 @@ class Scheduler {
12186
11655
  */
12187
11656
  async discoverNextPhase() {
12188
11657
  try {
12189
- const result = await listMetas(this.config, this.watcher);
12190
- const candidates = buildPhaseCandidates(result.entries);
11658
+ const result = await this.cache.get(this.config, this.watcher);
11659
+ const candidates = buildPhaseCandidates(result.entries, this.config.architectEvery);
12191
11660
  const winner = selectPhaseCandidate(candidates, this.config.depthWeight);
12192
11661
  if (!winner)
12193
11662
  return null;
@@ -12612,11 +12081,11 @@ function registerMetasUpdateRoute(app, deps) {
12612
12081
  */
12613
12082
  function registerPreviewRoute(app, deps) {
12614
12083
  app.get('/preview', async (request, reply) => {
12615
- const { config, watcher } = deps;
12084
+ const { config, watcher, cache } = deps;
12616
12085
  const query = request.query;
12617
12086
  let result;
12618
12087
  try {
12619
- result = await listMetas(config, watcher);
12088
+ result = await cache.get(config, watcher);
12620
12089
  }
12621
12090
  catch {
12622
12091
  return reply.status(503).send({
@@ -12636,40 +12105,24 @@ function registerPreviewRoute(app, deps) {
12636
12105
  }
12637
12106
  }
12638
12107
  else {
12639
- // Select stalest candidate
12640
- const stale = result.entries
12641
- .filter((e) => e.stalenessSeconds > 0)
12642
- .map((e) => ({
12643
- node: e.node,
12644
- meta: e.meta,
12645
- actualStaleness: e.stalenessSeconds,
12646
- }));
12647
- const stalestPath = discoverStalestPath(stale, config.depthWeight);
12648
- if (!stalestPath) {
12108
+ // Select best phase candidate
12109
+ const candidates = buildPhaseCandidates(result.entries, config.architectEvery);
12110
+ const winner = selectPhaseCandidate(candidates, config.depthWeight);
12111
+ if (!winner) {
12649
12112
  return { message: 'No stale metas found. Nothing to synthesize.' };
12650
12113
  }
12651
- targetNode = findNode(result.tree, stalestPath);
12114
+ targetNode = findNode(result.tree, winner.node.metaPath);
12652
12115
  }
12653
12116
  const meta = await readMetaJson(targetNode.metaPath);
12654
12117
  // Scope files
12655
12118
  const { scopeFiles } = await getScopeFiles(targetNode, watcher);
12656
- const structureHash = computeStructureHash(scopeFiles);
12119
+ // Compute invalidation inputs (DRY: reuse phaseState/invalidate logic)
12120
+ const invalidation = await computeInvalidation(meta, scopeFiles, config, targetNode);
12121
+ const { architectInvalidators, stalenessInputs } = invalidation;
12122
+ const { structureHash } = invalidation;
12657
12123
  const structureChanged = structureHash !== meta._structureHash;
12658
- const latestArchive = await readLatestArchive(targetNode.metaPath);
12659
- const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
12660
- // _architect change detection
12661
- const architectChanged = latestArchive
12662
- ? (meta._architect ?? '') !== (latestArchive._architect ?? '')
12663
- : Boolean(meta._architect);
12664
- // _crossRefs declaration change detection
12665
- const currentRefs = (meta._crossRefs ?? []).slice().sort().join(',');
12666
- const archiveRefs = (latestArchive?._crossRefs ?? [])
12667
- .slice()
12668
- .sort()
12669
- .join(',');
12670
- const crossRefsDeclChanged = latestArchive
12671
- ? currentRefs !== archiveRefs
12672
- : currentRefs.length > 0;
12124
+ const { steerChanged } = invalidation;
12125
+ const { architectChanged, crossRefsDeclChanged } = stalenessInputs;
12673
12126
  const architectTriggered = isArchitectTriggered(meta, structureChanged, steerChanged, config.architectEvery);
12674
12127
  // Delta files
12675
12128
  const deltaFiles = getDeltaFiles(meta._generatedAt, scopeFiles);
@@ -12694,30 +12147,6 @@ function registerPreviewRoute(app, deps) {
12694
12147
  });
12695
12148
  const owedPhase = getOwedPhase(phaseState);
12696
12149
  const priorityBand = getPriorityBand(phaseState);
12697
- // Architect invalidators
12698
- const architectInvalidators = [];
12699
- if (owedPhase === 'architect') {
12700
- if (structureChanged)
12701
- architectInvalidators.push('structureHash');
12702
- if (steerChanged)
12703
- architectInvalidators.push('steer');
12704
- if (architectChanged)
12705
- architectInvalidators.push('_architect');
12706
- if (crossRefsDeclChanged)
12707
- architectInvalidators.push('_crossRefs');
12708
- if ((meta._synthesisCount ?? 0) >= config.architectEvery) {
12709
- architectInvalidators.push('architectEvery');
12710
- }
12711
- }
12712
- // Staleness inputs
12713
- const stalenessInputs = {
12714
- structureHash,
12715
- steerChanged,
12716
- architectChanged,
12717
- crossRefsDeclChanged,
12718
- scopeMtimeMax: null,
12719
- crossRefContentChanged: false,
12720
- };
12721
12150
  return {
12722
12151
  path: targetNode.metaPath,
12723
12152
  staleness: {
@@ -12791,8 +12220,8 @@ function registerQueueRoutes(app, deps) {
12791
12220
  // ranked by scheduler priority (computed on read, not persisted)
12792
12221
  let automatic = [];
12793
12222
  try {
12794
- const metaResult = await listMetas(deps.config, deps.watcher);
12795
- const candidates = buildPhaseCandidates(metaResult.entries);
12223
+ const metaResult = await deps.cache.get(deps.config, deps.watcher);
12224
+ const candidates = buildPhaseCandidates(metaResult.entries, deps.config.architectEvery);
12796
12225
  const ranked = rankPhaseCandidates(candidates, deps.config.depthWeight);
12797
12226
  automatic = ranked.map((c) => ({
12798
12227
  path: c.node.metaPath,
@@ -12987,7 +12416,7 @@ function registerStatusRoute(app, deps) {
12987
12416
  name: SERVICE_NAME,
12988
12417
  version: SERVICE_VERSION,
12989
12418
  getHealth: async () => {
12990
- const { config, queue, scheduler, stats, watcher } = deps;
12419
+ const { config, queue, scheduler, stats, watcher, cache } = deps;
12991
12420
  // On-demand dependency checks
12992
12421
  const [watcherHealth, gatewayHealth] = await Promise.all([
12993
12422
  checkWatcher(config.watcherUrl),
@@ -13001,7 +12430,7 @@ function registerStatusRoute(app, deps) {
13001
12430
  };
13002
12431
  let nextPhase = null;
13003
12432
  try {
13004
- const metaResult = await listMetas(config, watcher);
12433
+ const metaResult = await cache.get(config, watcher);
13005
12434
  // Count raw phase states (before retry) for display
13006
12435
  for (const entry of metaResult.entries) {
13007
12436
  const ps = derivePhaseState(entry.meta);
@@ -13010,7 +12439,7 @@ function registerStatusRoute(app, deps) {
13010
12439
  }
13011
12440
  }
13012
12441
  // Build candidates (with auto-retry) for scheduling
13013
- const candidates = buildPhaseCandidates(metaResult.entries);
12442
+ const candidates = buildPhaseCandidates(metaResult.entries, config.architectEvery);
13014
12443
  // Find next phase candidate
13015
12444
  const winner = selectPhaseCandidate(candidates, config.depthWeight);
13016
12445
  if (winner) {
@@ -13073,7 +12502,7 @@ const synthesizeBodySchema = z.object({
13073
12502
  function registerSynthesizeRoute(app, deps) {
13074
12503
  app.post('/synthesize', async (request, reply) => {
13075
12504
  const body = synthesizeBodySchema.parse(request.body);
13076
- const { config, watcher, queue } = deps;
12505
+ const { config, watcher, queue, cache } = deps;
13077
12506
  if (body.path) {
13078
12507
  // Path-targeted trigger: create override entry
13079
12508
  const targetPath = resolveMetaDir(body.path);
@@ -13110,7 +12539,7 @@ function registerSynthesizeRoute(app, deps) {
13110
12539
  // Corpus-wide trigger: discover stalest candidate
13111
12540
  let result;
13112
12541
  try {
13113
- result = await listMetas(config, watcher);
12542
+ result = await cache.get(config, watcher);
13114
12543
  }
13115
12544
  catch {
13116
12545
  return reply.status(503).send({
@@ -13118,20 +12547,15 @@ function registerSynthesizeRoute(app, deps) {
13118
12547
  message: 'Watcher unreachable — cannot discover candidates',
13119
12548
  });
13120
12549
  }
13121
- const stale = result.entries
13122
- .filter((e) => e.stalenessSeconds > 0 && !e.disabled)
13123
- .map((e) => ({
13124
- node: e.node,
13125
- meta: e.meta,
13126
- actualStaleness: e.stalenessSeconds,
13127
- }));
13128
- const stalest = discoverStalestPath(stale, config.depthWeight);
13129
- if (!stalest) {
12550
+ const candidates = buildPhaseCandidates(result.entries, config.architectEvery);
12551
+ const winner = selectPhaseCandidate(candidates, config.depthWeight);
12552
+ if (!winner) {
13130
12553
  return reply.code(200).send({
13131
12554
  status: 'skipped',
13132
12555
  message: 'No stale metas found. Nothing to synthesize.',
13133
12556
  });
13134
12557
  }
12558
+ const stalest = winner.node.metaPath;
13135
12559
  const enqueueResult = queue.enqueue(stalest);
13136
12560
  return reply.code(202).send({
13137
12561
  status: 'accepted',
@@ -13232,6 +12656,18 @@ function createServer(options) {
13232
12656
  // Fastify 5 requires `loggerInstance` for external pino loggers
13233
12657
  const app = Fastify({
13234
12658
  loggerInstance: options.logger,
12659
+ requestTimeout: 30_000,
12660
+ });
12661
+ // Readiness gate: return 503 while service is initializing
12662
+ app.addHook('onRequest', async (request, reply) => {
12663
+ if (options.deps.ready)
12664
+ return;
12665
+ const url = request.url;
12666
+ if (url === '/config' || url.startsWith('/config/apply'))
12667
+ return;
12668
+ return reply
12669
+ .status(503)
12670
+ .send({ status: 'starting', message: 'Service initializing' });
13235
12671
  });
13236
12672
  registerRoutes(app, options.deps);
13237
12673
  return app;
@@ -13407,8 +12843,9 @@ async function startService(config, configPath) {
13407
12843
  lastCycleAt: null,
13408
12844
  };
13409
12845
  const queue = new SynthesisQueue(logger);
12846
+ const cache = new MetaCache();
13410
12847
  // Scheduler (needs watcher for discovery)
13411
- const scheduler = new Scheduler(config, queue, logger, watcher);
12848
+ const scheduler = new Scheduler(config, queue, logger, watcher, cache);
13412
12849
  const routeDeps = {
13413
12850
  config,
13414
12851
  logger,
@@ -13416,6 +12853,8 @@ async function startService(config, configPath) {
13416
12853
  watcher,
13417
12854
  scheduler,
13418
12855
  stats,
12856
+ cache,
12857
+ ready: false,
13419
12858
  executor,
13420
12859
  configPath,
13421
12860
  };
@@ -13466,6 +12905,9 @@ async function startService(config, configPath) {
13466
12905
  }
13467
12906
  await progress.report(evt);
13468
12907
  }, logger);
12908
+ // Invalidate cache only when a phase was actually executed
12909
+ if (result.executed)
12910
+ cache.invalidate();
13469
12911
  const durationMs = Date.now() - startMs;
13470
12912
  if (!result.executed) {
13471
12913
  logger.debug({ path: ownerPath }, 'Phase skipped (fully fresh or locked)');
@@ -13529,9 +12971,13 @@ async function startService(config, configPath) {
13529
12971
  scheduler.setRegistrar(registrar);
13530
12972
  routeDeps.registrar = registrar;
13531
12973
  void registrar.register().then(() => {
12974
+ routeDeps.ready = true;
13532
12975
  if (registrar.isRegistered) {
13533
12976
  void verifyRuleApplication(watcher, logger);
13534
12977
  }
12978
+ }, () => {
12979
+ // Registration failed after max retries — mark ready anyway
12980
+ routeDeps.ready = true;
13535
12981
  });
13536
12982
  // Periodic watcher health check (independent of scheduler)
13537
12983
  const healthCheck = new WatcherHealthCheck({
@@ -13613,4 +13059,152 @@ const metaDescriptor = jeevesComponentDescriptorSchema.parse({
13613
13059
  customCliCommands: registerCustomCliCommands,
13614
13060
  });
13615
13061
 
13616
- export { DEFAULT_PORT, DEFAULT_PORT_STR, GatewayExecutor, HttpWatcherClient, MAX_STALENESS_SECONDS, ProgressReporter, RESTART_REQUIRED_FIELDS, RuleRegistrar, SERVICE_NAME, SERVICE_VERSION, Scheduler, SynthesisQueue, acquireLock, actualStaleness, buildArchitectTask, buildBuilderTask, buildContextPackage, buildCriticTask, buildOwnershipTree, cleanupStaleLocks, computeEffectiveStaleness, computeEma, computeStructureHash, createLogger, createServer, createSnapshot, discoverMetas, filterInScope, findNode, formatProgressEvent, getScopePrefix, hasSteerChanged, isArchitectTriggered, isLocked, isStale, listArchiveFiles, listMetas, loadServiceConfig, mergeAndWrite, metaConfigSchema, metaDescriptor, metaErrorSchema, metaJsonSchema, migrateConfigPath, normalizePath, orchestrate, orchestratePhase, parseArchitectOutput, parseBuilderOutput, parseCriticOutput, pruneArchive, readLatestArchive, readLockState, registerCustomCliCommands, registerRoutes, registerShutdownHandlers, releaseLock, resolveConfigPath, resolveMetaDir, runArchitect, runBuilder, runCritic, selectCandidate, serviceConfigSchema, sleepAsync as sleep, startService, toMetaError, verifyRuleApplication };
13062
+ /**
13063
+ * Exponential moving average helper for token tracking.
13064
+ *
13065
+ * @module ema
13066
+ */
13067
+ const DEFAULT_DECAY = 0.3;
13068
+ /**
13069
+ * Compute exponential moving average.
13070
+ *
13071
+ * @param current - New observation.
13072
+ * @param previous - Previous EMA value, or undefined for first observation.
13073
+ * @param decay - Decay factor (0-1). Higher = more weight on new value. Default 0.3.
13074
+ * @returns Updated EMA.
13075
+ */
13076
+ function computeEma(current, previous, decay = DEFAULT_DECAY) {
13077
+ if (previous === undefined)
13078
+ return current;
13079
+ return decay * current + (1 - decay) * previous;
13080
+ }
13081
+
13082
+ /**
13083
+ * Structured error from a synthesis step failure.
13084
+ *
13085
+ * @module schema/error
13086
+ */
13087
+ /** Zod schema for synthesis step errors. */
13088
+ const metaErrorSchema = z.object({
13089
+ /** Which step failed: 'architect', 'builder', or 'critic'. */
13090
+ step: z.enum(['architect', 'builder', 'critic']),
13091
+ /** Error classification code. */
13092
+ code: z.string(),
13093
+ /** Human-readable error message. */
13094
+ message: z.string(),
13095
+ });
13096
+
13097
+ /**
13098
+ * Zod schema for .meta/meta.json files.
13099
+ *
13100
+ * Reserved properties are underscore-prefixed and engine-managed.
13101
+ * All other keys are open schema (builder output).
13102
+ *
13103
+ * @module schema/meta
13104
+ */
13105
+ /** Valid states for a synthesis phase. */
13106
+ const phaseStatuses = [
13107
+ 'fresh',
13108
+ 'stale',
13109
+ 'pending',
13110
+ 'running',
13111
+ 'failed',
13112
+ ];
13113
+ /** Zod schema for a per-phase status value. */
13114
+ const phaseStatusSchema = z.enum(phaseStatuses);
13115
+ /** Zod schema for the per-meta phase state record. */
13116
+ const phaseStateSchema = z.object({
13117
+ architect: phaseStatusSchema,
13118
+ builder: phaseStatusSchema,
13119
+ critic: phaseStatusSchema,
13120
+ });
13121
+ /** Zod schema for the reserved (underscore-prefixed) meta.json properties. */
13122
+ const metaJsonSchema = z
13123
+ .object({
13124
+ /** Stable identity. Auto-generated on first synthesis if not provided. */
13125
+ _id: z.uuid().optional(),
13126
+ /** Human-provided steering prompt. Optional. */
13127
+ _steer: z.string().optional(),
13128
+ /**
13129
+ * Explicit cross-references to other meta owner paths.
13130
+ * Referenced metas' _content is included as architect/builder context.
13131
+ */
13132
+ _crossRefs: z.array(z.string()).optional(),
13133
+ /** Architect system prompt used this turn. Defaults from config. */
13134
+ _architect: z.string().optional(),
13135
+ /**
13136
+ * Task brief generated by the architect. Cached and reused across cycles;
13137
+ * regenerated only when triggered.
13138
+ */
13139
+ _builder: z.string().optional(),
13140
+ /** Critic system prompt used this turn. Defaults from config. */
13141
+ _critic: z.string().optional(),
13142
+ /** Timestamp of last synthesis. ISO 8601. */
13143
+ _generatedAt: z.iso.datetime().optional(),
13144
+ /** Narrative synthesis output. Rendered by watcher for embedding. */
13145
+ _content: z.string().optional(),
13146
+ /**
13147
+ * Hash of sorted file listing in scope. Detects directory structure
13148
+ * changes that trigger an architect re-run.
13149
+ */
13150
+ _structureHash: z.string().optional(),
13151
+ /**
13152
+ * Cycles since last architect run. Reset to 0 when architect runs.
13153
+ * Used with architectEvery to trigger periodic re-prompting.
13154
+ */
13155
+ _synthesisCount: z.number().int().min(0).optional(),
13156
+ /** Critic evaluation of the last synthesis. */
13157
+ _feedback: z.string().optional(),
13158
+ /**
13159
+ * Present and true on archive snapshots. Distinguishes live vs. archived
13160
+ * metas.
13161
+ */
13162
+ _archived: z.boolean().optional(),
13163
+ /** Timestamp when this snapshot was archived. ISO 8601. */
13164
+ _archivedAt: z.iso.datetime().optional(),
13165
+ /**
13166
+ * Scheduling priority. Higher = updates more often. Negative allowed;
13167
+ * normalized to min 0 at scheduling time.
13168
+ */
13169
+ _depth: z.number().optional(),
13170
+ /**
13171
+ * Emphasis multiplier for depth weighting in scheduling.
13172
+ * Default 1. Higher values increase this meta's scheduling priority
13173
+ * relative to its depth. Set to 0.5 to halve the depth effect,
13174
+ * 2 to double it, 0 to ignore depth entirely for this meta.
13175
+ */
13176
+ _emphasis: z.number().min(0).optional(),
13177
+ /** Token count from last architect subprocess call. */
13178
+ _architectTokens: z.number().int().optional(),
13179
+ /** Token count from last builder subprocess call. */
13180
+ _builderTokens: z.number().int().optional(),
13181
+ /** Token count from last critic subprocess call. */
13182
+ _criticTokens: z.number().int().optional(),
13183
+ /** Exponential moving average of architect token usage (decay 0.3). */
13184
+ _architectTokensAvg: z.number().optional(),
13185
+ /** Exponential moving average of builder token usage (decay 0.3). */
13186
+ _builderTokensAvg: z.number().optional(),
13187
+ /** Exponential moving average of critic token usage (decay 0.3). */
13188
+ _criticTokensAvg: z.number().optional(),
13189
+ /**
13190
+ * Opaque state carried across synthesis cycles for progressive work.
13191
+ * Set by the builder, passed back as context on next cycle.
13192
+ */
13193
+ _state: z.unknown().optional(),
13194
+ /**
13195
+ * Structured error from last cycle. Present when a step failed.
13196
+ * Cleared on successful cycle.
13197
+ */
13198
+ _error: metaErrorSchema.optional(),
13199
+ /** When true, this meta is skipped during staleness scheduling. Manual trigger still works. */
13200
+ _disabled: z.boolean().optional(),
13201
+ /**
13202
+ * Per-phase state machine record. Engine-managed.
13203
+ * Keyed by phase name (architect, builder, critic) with status values.
13204
+ * Persisted to survive ticks; derived on first load for back-compat.
13205
+ */
13206
+ _phaseState: phaseStateSchema.optional(),
13207
+ })
13208
+ .loose();
13209
+
13210
+ export { DEFAULT_PORT, DEFAULT_PORT_STR, GatewayExecutor, HttpWatcherClient, MAX_STALENESS_SECONDS, ProgressReporter, RESTART_REQUIRED_FIELDS, RuleRegistrar, SERVICE_NAME, SERVICE_VERSION, Scheduler, SynthesisQueue, acquireLock, actualStaleness, buildArchitectTask, buildBuilderTask, buildContextPackage, buildCriticTask, buildOwnershipTree, cleanupStaleLocks, computeEffectiveStaleness, computeEma, computeStructureHash, createLogger, createServer, createSnapshot, discoverMetas, filterInScope, findNode, formatProgressEvent, getScopePrefix, hasSteerChanged, isArchitectTriggered, isLocked, isStale, listArchiveFiles, listMetas, loadServiceConfig, metaConfigSchema, metaDescriptor, metaErrorSchema, metaJsonSchema, migrateConfigPath, normalizePath, orchestratePhase, parseArchitectOutput, parseBuilderOutput, parseCriticOutput, pruneArchive, readLatestArchive, readLockState, registerCustomCliCommands, registerRoutes, registerShutdownHandlers, releaseLock, resolveConfigPath, resolveMetaDir, runArchitect, runBuilder, runCritic, serviceConfigSchema, sleepAsync as sleep, startService, toMetaError, verifyRuleApplication };