@karmaniverous/jeeves-meta 0.15.2 → 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.
@@ -15,9 +15,9 @@ import * as commander from 'commander';
15
15
  import { execSync } from 'node:child_process';
16
16
  import { homedir, tmpdir } from 'node:os';
17
17
  import { fileURLToPath } from 'node:url';
18
+ import Handlebars from 'handlebars';
18
19
  import { readFile, unlink, mkdir, writeFile, copyFile } from 'node:fs/promises';
19
20
  import pino from 'pino';
20
- import Handlebars from 'handlebars';
21
21
  import process$1 from 'node:process';
22
22
  import { Cron } from 'croner';
23
23
  import Fastify from 'fastify';
@@ -7239,6 +7239,11 @@ const workspaceCoreConfigSchema = z
7239
7239
  configRoot: z.string().optional().describe('Platform config root path'),
7240
7240
  /** OpenClaw gateway URL. */
7241
7241
  gatewayUrl: z.string().optional().describe('OpenClaw gateway URL'),
7242
+ /** Dev repo paths keyed by component name. */
7243
+ devRepos: z
7244
+ .record(z.string(), z.string())
7245
+ .optional()
7246
+ .describe('Dev repo paths by component name'),
7242
7247
  })
7243
7248
  .partial();
7244
7249
  /** Memory shared config section. */
@@ -7253,13 +7258,6 @@ const workspaceMemoryConfigSchema = z
7253
7258
  .max(1)
7254
7259
  .optional()
7255
7260
  .describe('Memory warning threshold'),
7256
- /** Staleness threshold in days. */
7257
- staleDays: z
7258
- .number()
7259
- .int()
7260
- .positive()
7261
- .optional()
7262
- .describe('Memory staleness threshold in days'),
7263
7261
  })
7264
7262
  .partial();
7265
7263
  /** Workspace config Zod schema. */
@@ -8167,16 +8165,115 @@ function createServiceCli(descriptor) {
8167
8165
  });
8168
8166
  // Apply custom CLI commands if provided
8169
8167
  if (descriptor.customCliCommands) {
8170
- // Cast required: @commander-js/extra-typings Command has generic type
8171
- // parameters that don't align with the descriptor's base Command type.
8172
- // The descriptor can't know the parent Command's exact generic parameters
8173
- // at definition time. The cast is safe — customCliCommands only adds
8174
- // subcommands and doesn't depend on the parent's generic state.
8175
8168
  descriptor.customCliCommands(program);
8176
8169
  }
8177
8170
  return program;
8178
8171
  }
8179
8172
 
8173
+ var toolsPlatformTemplate = `### Tool Hierarchy
8174
+
8175
+ When searching for information across indexed paths, **always use \`watcher_search\` before filesystem commands** (\`exec\`, \`grep\`, \`find\`). The semantic index covers the full indexed corpus and surfaces related files you may not have considered.
8176
+
8177
+ Use \`watcher_scan\` (no embeddings, no query string) for structural queries: file enumeration, staleness checks, domain listing, counts.
8178
+
8179
+ Direct filesystem access is for **acting on** search results, not bypassing them.
8180
+
8181
+ ### Shell Scripting
8182
+
8183
+ Default to \`node -e\` or \`.js\` scripts for \`exec\` calls. PowerShell corrupts multi-byte UTF-8 characters and mangles escaping. Use PowerShell only for Windows service management, registry operations, and similar platform-specific tasks.
8184
+
8185
+ ### File Bridge for External Repos
8186
+
8187
+ When editing files outside the workspace, use the bridge pattern: copy in → edit the workspace copy → bridge out. Never write temp patch scripts. The workspace is the authoritative working directory.
8188
+
8189
+ ### Gateway Self-Destruction Warning
8190
+
8191
+ ⚠️ Any command that stops the gateway **stops the assistant**. Never run \`openclaw gateway stop\` or \`openclaw gateway restart\` without explicit owner approval. When approved, it must be the **absolute last action** — all other work must be complete first, all messages sent, all files saved.
8192
+
8193
+ ### Messaging
8194
+
8195
+ **Same-channel replies:** Don't use the \`message\` tool. It fires immediately, jumping ahead of streaming narration. Just write text as your response.
8196
+
8197
+ **Cross-channel sends:** Use the \`message\` tool with an explicit \`target\` to send to a different channel or DM.
8198
+
8199
+ ### Plugin Lifecycle
8200
+
8201
+ \`\`\`bash
8202
+ # Platform bootstrap (content seeding)
8203
+ npx @karmaniverous/jeeves install
8204
+
8205
+ # Component plugin install
8206
+ npx @karmaniverous/jeeves-{component}-openclaw install
8207
+
8208
+ # Component plugin uninstall
8209
+ npx @karmaniverous/jeeves-{component}-openclaw uninstall
8210
+
8211
+ # Platform teardown (remove managed sections)
8212
+ npx @karmaniverous/jeeves uninstall
8213
+ \`\`\`
8214
+
8215
+ Never manually edit \`~/.openclaw/extensions/\`. Always use the CLI commands above.
8216
+
8217
+ ### Reference Templates
8218
+
8219
+ {{#if templatePath}}
8220
+ Reference templates are available at \`{{templatePath}}\`:
8221
+
8222
+ | Template | Purpose |
8223
+ |----------|---------|
8224
+ | \`spec.md\` | Skeleton for new product specifications — all section headers, decision format, dev plan format |
8225
+ | \`spec-to-code-guide.md\` | The spec-to-code development practice — 7-stage iterative process, convergence loops, release gates |
8226
+
8227
+ Read these templates when creating new specs, onboarding to new projects, or when asked about the development process.
8228
+ {{else}}
8229
+ > Reference templates not yet installed. Run \`npx @karmaniverous/jeeves install\` to seed templates.
8230
+ {{/if}}
8231
+
8232
+ ### Post-Upgrade Maintenance
8233
+
8234
+ After updating OpenClaw (\`npm install -g openclaw@latest\` or equivalent), reinstall all Jeeves component plugins to repair install state:
8235
+
8236
+ \`\`\`bash
8237
+ npx @karmaniverous/jeeves install
8238
+ npx @karmaniverous/jeeves-runner-openclaw install
8239
+ npx @karmaniverous/jeeves-watcher-openclaw install
8240
+ npx @karmaniverous/jeeves-server-openclaw install
8241
+ npx @karmaniverous/jeeves-meta-openclaw install
8242
+ \`\`\`
8243
+
8244
+ Then restart the gateway. Plugin installers copy dist files and patch config; reinstalling after an OpenClaw update ensures the extensions directory stays consistent.
8245
+
8246
+ ### Source Code Preference
8247
+
8248
+ {{#if devRepos}}
8249
+ When investigating, debugging, or analyzing Jeeves components, always read TypeScript source from dev repos — never compiled \`dist/\` from the global npm install. Dev repos:
8250
+
8251
+ | Component | Dev Repo |
8252
+ |-----------|----------|
8253
+ {{#each devRepos}}
8254
+ | {{@key}} | \`{{this}}\` |
8255
+ {{/each}}
8256
+
8257
+ Built code is minified, harder to reason about, and wastes context. Always \`git pull\` before analysis.
8258
+ {{else}}
8259
+ > Dev repo paths not configured. Add \`core.devRepos\` to \`jeeves.config.json\` to enable source code preference guidance.
8260
+ {{/if}}
8261
+ `;
8262
+
8263
+ /**
8264
+ * Internal function to maintain SOUL.md, AGENTS.md, and TOOLS.md Platform section.
8265
+ *
8266
+ * @remarks
8267
+ * Called by `ComponentWriter` on each cycle. Not directly exposed to components.
8268
+ * Reads content files from the package's `content/` directory, renders the
8269
+ * Platform template with live data, and writes managed sections using
8270
+ * `updateManagedSection`.
8271
+ */
8272
+ /** Compiled Handlebars template for the Platform section (cached at module level). */
8273
+ Handlebars.compile(toolsPlatformTemplate, {
8274
+ noEscape: true,
8275
+ });
8276
+
8180
8277
  /**
8181
8278
  * Core configuration schema and resolution.
8182
8279
  *
@@ -8273,10 +8370,10 @@ function getServiceUrl(serviceName, consumerName) {
8273
8370
  if (coreUrl)
8274
8371
  return coreUrl;
8275
8372
  // 3. Fall back to port constants
8276
- const port = DEFAULT_PORTS[serviceName];
8277
- {
8278
- return `http://127.0.0.1:${String(port)}`;
8373
+ if (serviceName in DEFAULT_PORTS) {
8374
+ return `http://127.0.0.1:${String(DEFAULT_PORTS[serviceName])}`;
8279
8375
  }
8376
+ throw new Error(`jeeves-core: unknown service "${serviceName}" and no config found`);
8280
8377
  }
8281
8378
 
8282
8379
  /**
@@ -8322,323 +8419,107 @@ function sleepAsync(ms) {
8322
8419
  }
8323
8420
 
8324
8421
  /**
8325
- * Shared live config hot-reload support.
8422
+ * Compute summary statistics from an array of MetaEntry objects.
8326
8423
  *
8327
- * Used by both file-watch reloads in bootstrap and POST /config/apply
8328
- * via the component descriptor's onConfigApply callback.
8424
+ * Shared between listMetas() (full list) and route handlers (filtered lists).
8329
8425
  *
8330
- * @module configHotReload
8426
+ * @module discovery/computeSummary
8331
8427
  */
8332
8428
  /**
8333
- * Fields that require a service restart to take effect.
8429
+ * Compute summary statistics from a list of meta entries.
8334
8430
  *
8335
- * Shared between the descriptor's `onConfigApply` and the file-watcher
8336
- * hot-reload in `bootstrap.ts`.
8431
+ * @param entries - Enriched meta entries (full or filtered).
8432
+ * @param depthWeight - Config depth weight for effective staleness calculation.
8433
+ * @returns Aggregated summary statistics.
8337
8434
  */
8338
- const RESTART_REQUIRED_FIELDS = [
8339
- 'port',
8340
- 'watcherUrl',
8341
- 'gatewayUrl',
8342
- 'gatewayApiKey',
8343
- 'defaultArchitect',
8344
- 'defaultCritic',
8345
- ];
8346
- let runtime = null;
8347
- /** Register the active service runtime for config-apply hot reload. */
8348
- function registerConfigHotReloadRuntime(nextRuntime) {
8349
- runtime = nextRuntime;
8350
- }
8351
- /** Apply hot-reloadable config changes to the live shared config object. */
8352
- function applyHotReloadedConfig(newConfig) {
8353
- if (!runtime)
8354
- return;
8355
- const { config, logger, scheduler } = runtime;
8356
- for (const field of RESTART_REQUIRED_FIELDS) {
8357
- const oldVal = config[field];
8358
- const nextVal = newConfig[field];
8359
- if (oldVal !== nextVal) {
8360
- logger.warn({ field, oldValue: oldVal, newValue: nextVal }, 'Config field changed but requires restart to take effect');
8361
- }
8362
- }
8363
- if (newConfig.schedule !== config.schedule) {
8364
- scheduler?.updateSchedule(newConfig.schedule);
8365
- config.schedule = newConfig.schedule;
8366
- logger.info({ schedule: newConfig.schedule }, 'Schedule hot-reloaded');
8367
- }
8368
- if (newConfig.logging.level !== config.logging.level) {
8369
- logger.level = newConfig.logging.level;
8370
- config.logging.level = newConfig.logging.level;
8371
- logger.info({ level: newConfig.logging.level }, 'Log level hot-reloaded');
8372
- }
8373
- const restartSet = new Set(RESTART_REQUIRED_FIELDS);
8374
- for (const key of Object.keys(newConfig)) {
8375
- if (restartSet.has(key) || key === 'logging' || key === 'schedule') {
8376
- continue;
8435
+ function computeSummary(entries, depthWeight) {
8436
+ let staleCount = 0;
8437
+ let errorCount = 0;
8438
+ let lockedCount = 0;
8439
+ let disabledCount = 0;
8440
+ let neverSynthesizedCount = 0;
8441
+ let totalArchitectTokens = 0;
8442
+ let totalBuilderTokens = 0;
8443
+ let totalCriticTokens = 0;
8444
+ let stalestPath = null;
8445
+ let stalestEffective = -1;
8446
+ let lastSynthesizedPath = null;
8447
+ let lastSynthesizedAt = null;
8448
+ for (const e of entries) {
8449
+ if (e.stalenessSeconds > 0)
8450
+ staleCount++;
8451
+ if (e.hasError)
8452
+ errorCount++;
8453
+ if (e.locked)
8454
+ lockedCount++;
8455
+ if (e.disabled)
8456
+ disabledCount++;
8457
+ if (e.lastSynthesized === null)
8458
+ neverSynthesizedCount++;
8459
+ totalArchitectTokens += e.architectTokens ?? 0;
8460
+ totalBuilderTokens += e.builderTokens ?? 0;
8461
+ totalCriticTokens += e.criticTokens ?? 0;
8462
+ // Track last synthesized
8463
+ if (e.lastSynthesized &&
8464
+ (!lastSynthesizedAt || e.lastSynthesized > lastSynthesizedAt)) {
8465
+ lastSynthesizedAt = e.lastSynthesized;
8466
+ lastSynthesizedPath = e.path;
8377
8467
  }
8378
- const oldVal = config[key];
8379
- const nextVal = newConfig[key];
8380
- if (JSON.stringify(oldVal) !== JSON.stringify(nextVal)) {
8381
- config[key] = nextVal;
8382
- logger.info({ field: key }, 'Config field hot-reloaded');
8468
+ // Track stalest (effective staleness for scheduling)
8469
+ const depthFactor = Math.pow(1 + depthWeight, e.depth);
8470
+ const effectiveStaleness = e.stalenessSeconds * depthFactor * e.emphasis;
8471
+ if (effectiveStaleness > stalestEffective) {
8472
+ stalestEffective = effectiveStaleness;
8473
+ stalestPath = e.path;
8383
8474
  }
8384
8475
  }
8476
+ return {
8477
+ total: entries.length,
8478
+ stale: staleCount,
8479
+ errors: errorCount,
8480
+ locked: lockedCount,
8481
+ disabled: disabledCount,
8482
+ neverSynthesized: neverSynthesizedCount,
8483
+ tokens: {
8484
+ architect: totalArchitectTokens,
8485
+ builder: totalBuilderTokens,
8486
+ critic: totalCriticTokens,
8487
+ },
8488
+ stalestPath,
8489
+ lastSynthesizedPath,
8490
+ lastSynthesizedAt,
8491
+ };
8385
8492
  }
8386
8493
 
8387
8494
  /**
8388
- * Zod schema for jeeves-meta service configuration.
8495
+ * Normalize file paths to forward slashes for consistency with watcher-indexed paths.
8389
8496
  *
8390
- * The service config is a strict superset of the core (library-compatible) meta config.
8497
+ * Watcher indexes paths with forward slashes (`j:/domains/...`). This utility
8498
+ * ensures all paths in the library use the same convention, regardless of
8499
+ * the platform's native separator.
8391
8500
  *
8392
- * @module schema/config
8501
+ * @module normalizePath
8393
8502
  */
8394
- /** Zod schema for the core (library-compatible) meta configuration. */
8395
- const metaConfigSchema = z.object({
8396
- /** Watcher service base URL. */
8397
- watcherUrl: z.url(),
8398
- /** OpenClaw gateway base URL for subprocess spawning. */
8399
- gatewayUrl: z.url().default('http://127.0.0.1:18789'),
8400
- /** Optional API key for gateway authentication. */
8401
- gatewayApiKey: z.string().optional(),
8402
- /** Run architect every N cycles (per meta). */
8403
- architectEvery: z.number().int().min(1).default(10),
8404
- /** Exponent for depth weighting in staleness formula. */
8405
- depthWeight: z.number().min(0).default(0.5),
8406
- /** Maximum archive snapshots to retain per meta. */
8407
- maxArchive: z.number().int().min(1).default(20),
8408
- /** Maximum lines of context to include in subprocess prompts. */
8409
- maxLines: z.number().int().min(50).default(500),
8410
- /** Architect subprocess timeout in seconds. */
8411
- architectTimeout: z.number().int().min(30).default(180),
8412
- /** Builder subprocess timeout in seconds. */
8413
- builderTimeout: z.number().int().min(60).default(360),
8414
- /** Critic subprocess timeout in seconds. */
8415
- criticTimeout: z.number().int().min(30).default(240),
8416
- /** Thinking level for spawned synthesis sessions. */
8417
- thinking: z.string().default('low'),
8418
- /** Resolved architect system prompt text. Falls back to built-in default. */
8419
- defaultArchitect: z.string().optional(),
8420
- /** Resolved critic system prompt text. Falls back to built-in default. */
8421
- defaultCritic: z.string().optional(),
8422
- /** Skip unchanged candidates, bump _generatedAt. */
8423
- skipUnchanged: z.boolean().default(true),
8424
- /** Watcher metadata properties applied to live .meta/meta.json files. */
8425
- metaProperty: z.record(z.string(), z.unknown()).default({ _meta: 'current' }),
8426
- /** Watcher metadata properties applied to archive snapshots. */
8427
- metaArchiveProperty: z
8428
- .record(z.string(), z.unknown())
8429
- .default({ _meta: 'archive' }),
8430
- });
8431
- /** Zod schema for logging configuration. */
8432
- const loggingSchema = z.object({
8433
- /** Log level. */
8434
- level: z.string().default('info'),
8435
- /** Optional file path for log output. */
8436
- file: z.string().optional(),
8437
- });
8438
- /** Zod schema for a single auto-seed policy rule. */
8439
- const autoSeedRuleSchema = z.object({
8440
- /** Glob pattern matched against watcher walk results. */
8441
- match: z.string(),
8442
- /** Optional steering prompt for seeded metas. */
8443
- steer: z.string().optional(),
8444
- /** Optional cross-references for seeded metas. */
8445
- crossRefs: z.array(z.string()).optional(),
8446
- });
8447
- /** Zod schema for jeeves-meta service configuration (superset of MetaConfig). */
8448
- const serviceConfigSchema = metaConfigSchema.extend({
8449
- /** HTTP port for the service (default: 1938). */
8450
- port: z.number().int().min(1).max(65535).default(1938),
8451
- /** Cron schedule for synthesis cycles (default: every 30 min). */
8452
- schedule: z.string().default('*/30 * * * *'),
8453
- /** Messaging channel name (e.g. 'slack'). Legacy: also used as target if reportTarget is unset. */
8454
- reportChannel: z.string().optional(),
8455
- /** Channel/user ID to send progress messages to. */
8456
- reportTarget: z.string().optional(),
8457
- /** Optional base URL for the service, used to construct entity links in progress reports. */
8458
- serverBaseUrl: z.string().optional(),
8459
- /** Interval in ms for periodic watcher health check. 0 = disabled. Default: 60000. */
8460
- watcherHealthIntervalMs: z.number().int().min(0).default(60_000),
8461
- /** Logging configuration. */
8462
- logging: loggingSchema.default(() => loggingSchema.parse({})),
8463
- /**
8464
- * Auto-seed policy: declarative rules for auto-creating .meta/ directories.
8465
- * Rules are evaluated in order; last match wins for steer/crossRefs.
8466
- */
8467
- autoSeed: z.array(autoSeedRuleSchema).optional().default([]),
8468
- });
8503
+ /**
8504
+ * Normalize a file path to forward slashes.
8505
+ *
8506
+ * @param p - File path (may contain backslashes).
8507
+ * @returns Path with all backslashes replaced by forward slashes.
8508
+ */
8509
+ function normalizePath(p) {
8510
+ return p.replaceAll('\\', '/');
8511
+ }
8469
8512
 
8470
8513
  /**
8471
- * Load and resolve jeeves-meta service config.
8514
+ * Discover .meta/ directories via watcher `/walk` endpoint.
8472
8515
  *
8473
- * Supports \@file: indirection and environment-variable substitution (dollar-brace pattern).
8516
+ * Uses filesystem enumeration through the watcher (not Qdrant) to find
8517
+ * all `.meta/meta.json` files and returns deduplicated meta directory paths.
8474
8518
  *
8475
- * @module configLoader
8519
+ * @module discovery/discoverMetas
8476
8520
  */
8477
8521
  /**
8478
- * Deep-walk a value, replacing `\${VAR\}` patterns with process.env values.
8479
- *
8480
- * @param value - Arbitrary JSON-compatible value.
8481
- * @returns Value with env-var placeholders resolved.
8482
- */
8483
- function substituteEnvVars(value) {
8484
- if (typeof value === 'string') {
8485
- return value.replace(/\$\{([^}]+)\}/g, (_match, name) => {
8486
- const envVal = process.env[name];
8487
- if (envVal === undefined) {
8488
- throw new Error(`Environment variable ${name} is not set`);
8489
- }
8490
- return envVal;
8491
- });
8492
- }
8493
- if (Array.isArray(value)) {
8494
- return value.map(substituteEnvVars);
8495
- }
8496
- if (value !== null && typeof value === 'object') {
8497
- const result = {};
8498
- for (const [key, val] of Object.entries(value)) {
8499
- result[key] = substituteEnvVars(val);
8500
- }
8501
- return result;
8502
- }
8503
- return value;
8504
- }
8505
- /**
8506
- * Resolve \@file: references in a config value.
8507
- *
8508
- * @param value - String value that may start with "\@file:".
8509
- * @param baseDir - Base directory for resolving relative paths.
8510
- * @returns The resolved string (file contents or original value).
8511
- */
8512
- function resolveFileRef(value, baseDir) {
8513
- if (!value.startsWith('@file:'))
8514
- return value;
8515
- const filePath = join(baseDir, value.slice(6));
8516
- return readFileSync(filePath, 'utf8');
8517
- }
8518
- /**
8519
- * Load service config from a JSON file.
8520
- *
8521
- * Resolves \@file: references for defaultArchitect and defaultCritic,
8522
- * and substitutes environment-variable placeholders throughout.
8523
- *
8524
- * @param configPath - Path to config JSON file.
8525
- * @returns Validated ServiceConfig.
8526
- */
8527
- function loadServiceConfig(configPath) {
8528
- const rawText = readFileSync(configPath, 'utf8');
8529
- const raw = substituteEnvVars(JSON.parse(rawText));
8530
- const baseDir = dirname(configPath);
8531
- if (typeof raw['defaultArchitect'] === 'string') {
8532
- raw['defaultArchitect'] = resolveFileRef(raw['defaultArchitect'], baseDir);
8533
- }
8534
- if (typeof raw['defaultCritic'] === 'string') {
8535
- raw['defaultCritic'] = resolveFileRef(raw['defaultCritic'], baseDir);
8536
- }
8537
- return serviceConfigSchema.parse(raw);
8538
- }
8539
-
8540
- /**
8541
- * Compute summary statistics from an array of MetaEntry objects.
8542
- *
8543
- * Shared between listMetas() (full list) and route handlers (filtered lists).
8544
- *
8545
- * @module discovery/computeSummary
8546
- */
8547
- /**
8548
- * Compute summary statistics from a list of meta entries.
8549
- *
8550
- * @param entries - Enriched meta entries (full or filtered).
8551
- * @param depthWeight - Config depth weight for effective staleness calculation.
8552
- * @returns Aggregated summary statistics.
8553
- */
8554
- function computeSummary(entries, depthWeight) {
8555
- let staleCount = 0;
8556
- let errorCount = 0;
8557
- let lockedCount = 0;
8558
- let disabledCount = 0;
8559
- let neverSynthesizedCount = 0;
8560
- let totalArchitectTokens = 0;
8561
- let totalBuilderTokens = 0;
8562
- let totalCriticTokens = 0;
8563
- let stalestPath = null;
8564
- let stalestEffective = -1;
8565
- let lastSynthesizedPath = null;
8566
- let lastSynthesizedAt = null;
8567
- for (const e of entries) {
8568
- if (e.stalenessSeconds > 0)
8569
- staleCount++;
8570
- if (e.hasError)
8571
- errorCount++;
8572
- if (e.locked)
8573
- lockedCount++;
8574
- if (e.disabled)
8575
- disabledCount++;
8576
- if (e.lastSynthesized === null)
8577
- neverSynthesizedCount++;
8578
- totalArchitectTokens += e.architectTokens ?? 0;
8579
- totalBuilderTokens += e.builderTokens ?? 0;
8580
- totalCriticTokens += e.criticTokens ?? 0;
8581
- // Track last synthesized
8582
- if (e.lastSynthesized &&
8583
- (!lastSynthesizedAt || e.lastSynthesized > lastSynthesizedAt)) {
8584
- lastSynthesizedAt = e.lastSynthesized;
8585
- lastSynthesizedPath = e.path;
8586
- }
8587
- // Track stalest (effective staleness for scheduling)
8588
- const depthFactor = Math.pow(1 + depthWeight, e.depth);
8589
- const effectiveStaleness = e.stalenessSeconds * depthFactor * e.emphasis;
8590
- if (effectiveStaleness > stalestEffective) {
8591
- stalestEffective = effectiveStaleness;
8592
- stalestPath = e.path;
8593
- }
8594
- }
8595
- return {
8596
- total: entries.length,
8597
- stale: staleCount,
8598
- errors: errorCount,
8599
- locked: lockedCount,
8600
- disabled: disabledCount,
8601
- neverSynthesized: neverSynthesizedCount,
8602
- tokens: {
8603
- architect: totalArchitectTokens,
8604
- builder: totalBuilderTokens,
8605
- critic: totalCriticTokens,
8606
- },
8607
- stalestPath,
8608
- lastSynthesizedPath,
8609
- lastSynthesizedAt,
8610
- };
8611
- }
8612
-
8613
- /**
8614
- * Normalize file paths to forward slashes for consistency with watcher-indexed paths.
8615
- *
8616
- * Watcher indexes paths with forward slashes (`j:/domains/...`). This utility
8617
- * ensures all paths in the library use the same convention, regardless of
8618
- * the platform's native separator.
8619
- *
8620
- * @module normalizePath
8621
- */
8622
- /**
8623
- * Normalize a file path to forward slashes.
8624
- *
8625
- * @param p - File path (may contain backslashes).
8626
- * @returns Path with all backslashes replaced by forward slashes.
8627
- */
8628
- function normalizePath(p) {
8629
- return p.replaceAll('\\', '/');
8630
- }
8631
-
8632
- /**
8633
- * Discover .meta/ directories via watcher `/walk` endpoint.
8634
- *
8635
- * Uses filesystem enumeration through the watcher (not Qdrant) to find
8636
- * all `.meta/meta.json` files and returns deduplicated meta directory paths.
8637
- *
8638
- * @module discovery/discoverMetas
8639
- */
8640
- /**
8641
- * Discover all .meta/ directories via watcher walk.
8522
+ * Discover all .meta/ directories via watcher walk.
8642
8523
  *
8643
8524
  * Uses the watcher's `/walk` endpoint to find all `.meta/meta.json` files
8644
8525
  * and returns deduplicated meta directory paths.
@@ -9193,6 +9074,263 @@ function getDeltaFiles(generatedAt, scopeFiles) {
9193
9074
  return filterModifiedAfter(scopeFiles, new Date(generatedAt).getTime());
9194
9075
  }
9195
9076
 
9077
+ /**
9078
+ * In-memory cache for listMetas results with TTL and concurrent refresh guard.
9079
+ *
9080
+ * @module cache
9081
+ */
9082
+ const TTL_MS = 60_000;
9083
+ /**
9084
+ * Caches listMetas results to avoid expensive repeated filesystem walks.
9085
+ * Supports concurrent refresh coalescing and manual invalidation.
9086
+ */
9087
+ class MetaCache {
9088
+ result = null;
9089
+ updatedAt = 0;
9090
+ refreshPromise = null;
9091
+ /** Get cached result or refresh if stale. */
9092
+ async get(config, watcher) {
9093
+ if (this.result && Date.now() - this.updatedAt < TTL_MS) {
9094
+ return this.result;
9095
+ }
9096
+ return this.refresh(config, watcher);
9097
+ }
9098
+ /** Force-expire the cache so next get() triggers a refresh. */
9099
+ invalidate() {
9100
+ this.updatedAt = 0;
9101
+ }
9102
+ async refresh(config, watcher) {
9103
+ if (this.refreshPromise)
9104
+ return this.refreshPromise;
9105
+ this.refreshPromise = listMetas(config, watcher)
9106
+ .then((result) => {
9107
+ this.result = result;
9108
+ this.updatedAt = Date.now();
9109
+ return result;
9110
+ })
9111
+ .finally(() => {
9112
+ this.refreshPromise = null;
9113
+ });
9114
+ return this.refreshPromise;
9115
+ }
9116
+ }
9117
+
9118
+ /**
9119
+ * Shared live config hot-reload support.
9120
+ *
9121
+ * Used by both file-watch reloads in bootstrap and POST /config/apply
9122
+ * via the component descriptor's onConfigApply callback.
9123
+ *
9124
+ * @module configHotReload
9125
+ */
9126
+ /**
9127
+ * Fields that require a service restart to take effect.
9128
+ *
9129
+ * Shared between the descriptor's `onConfigApply` and the file-watcher
9130
+ * hot-reload in `bootstrap.ts`.
9131
+ */
9132
+ const RESTART_REQUIRED_FIELDS = [
9133
+ 'port',
9134
+ 'watcherUrl',
9135
+ 'gatewayUrl',
9136
+ 'gatewayApiKey',
9137
+ 'defaultArchitect',
9138
+ 'defaultCritic',
9139
+ ];
9140
+ let runtime = null;
9141
+ /** Register the active service runtime for config-apply hot reload. */
9142
+ function registerConfigHotReloadRuntime(nextRuntime) {
9143
+ runtime = nextRuntime;
9144
+ }
9145
+ /** Apply hot-reloadable config changes to the live shared config object. */
9146
+ function applyHotReloadedConfig(newConfig) {
9147
+ if (!runtime)
9148
+ return;
9149
+ const { config, logger, scheduler } = runtime;
9150
+ for (const field of RESTART_REQUIRED_FIELDS) {
9151
+ const oldVal = config[field];
9152
+ const nextVal = newConfig[field];
9153
+ if (oldVal !== nextVal) {
9154
+ logger.warn({ field, oldValue: oldVal, newValue: nextVal }, 'Config field changed but requires restart to take effect');
9155
+ }
9156
+ }
9157
+ if (newConfig.schedule !== config.schedule) {
9158
+ scheduler?.updateSchedule(newConfig.schedule);
9159
+ config.schedule = newConfig.schedule;
9160
+ logger.info({ schedule: newConfig.schedule }, 'Schedule hot-reloaded');
9161
+ }
9162
+ if (newConfig.logging.level !== config.logging.level) {
9163
+ logger.level = newConfig.logging.level;
9164
+ config.logging.level = newConfig.logging.level;
9165
+ logger.info({ level: newConfig.logging.level }, 'Log level hot-reloaded');
9166
+ }
9167
+ const restartSet = new Set(RESTART_REQUIRED_FIELDS);
9168
+ for (const key of Object.keys(newConfig)) {
9169
+ if (restartSet.has(key) || key === 'logging' || key === 'schedule') {
9170
+ continue;
9171
+ }
9172
+ const oldVal = config[key];
9173
+ const nextVal = newConfig[key];
9174
+ if (JSON.stringify(oldVal) !== JSON.stringify(nextVal)) {
9175
+ config[key] = nextVal;
9176
+ logger.info({ field: key }, 'Config field hot-reloaded');
9177
+ }
9178
+ }
9179
+ }
9180
+
9181
+ /**
9182
+ * Zod schema for jeeves-meta service configuration.
9183
+ *
9184
+ * The service config is a strict superset of the core (library-compatible) meta config.
9185
+ *
9186
+ * @module schema/config
9187
+ */
9188
+ /** Zod schema for the core (library-compatible) meta configuration. */
9189
+ const metaConfigSchema = z.object({
9190
+ /** Watcher service base URL. */
9191
+ watcherUrl: z.url(),
9192
+ /** OpenClaw gateway base URL for subprocess spawning. */
9193
+ gatewayUrl: z.url().default('http://127.0.0.1:18789'),
9194
+ /** Optional API key for gateway authentication. */
9195
+ gatewayApiKey: z.string().optional(),
9196
+ /** Run architect every N cycles (per meta). */
9197
+ architectEvery: z.number().int().min(1).default(10),
9198
+ /** Exponent for depth weighting in staleness formula. */
9199
+ depthWeight: z.number().min(0).default(0.5),
9200
+ /** Maximum archive snapshots to retain per meta. */
9201
+ maxArchive: z.number().int().min(1).default(20),
9202
+ /** Maximum lines of context to include in subprocess prompts. */
9203
+ maxLines: z.number().int().min(50).default(500),
9204
+ /** Architect subprocess timeout in seconds. */
9205
+ architectTimeout: z.number().int().min(30).default(180),
9206
+ /** Builder subprocess timeout in seconds. */
9207
+ builderTimeout: z.number().int().min(60).default(360),
9208
+ /** Critic subprocess timeout in seconds. */
9209
+ criticTimeout: z.number().int().min(30).default(240),
9210
+ /** Thinking level for spawned synthesis sessions. */
9211
+ thinking: z.string().default('low'),
9212
+ /** Resolved architect system prompt text. Falls back to built-in default. */
9213
+ defaultArchitect: z.string().optional(),
9214
+ /** Resolved critic system prompt text. Falls back to built-in default. */
9215
+ defaultCritic: z.string().optional(),
9216
+ /** Skip unchanged candidates, bump _generatedAt. */
9217
+ skipUnchanged: z.boolean().default(true),
9218
+ /** Watcher metadata properties applied to live .meta/meta.json files. */
9219
+ metaProperty: z.record(z.string(), z.unknown()).default({ _meta: 'current' }),
9220
+ /** Watcher metadata properties applied to archive snapshots. */
9221
+ metaArchiveProperty: z
9222
+ .record(z.string(), z.unknown())
9223
+ .default({ _meta: 'archive' }),
9224
+ });
9225
+ /** Zod schema for logging configuration. */
9226
+ const loggingSchema = z.object({
9227
+ /** Log level. */
9228
+ level: z.string().default('info'),
9229
+ /** Optional file path for log output. */
9230
+ file: z.string().optional(),
9231
+ });
9232
+ /** Zod schema for a single auto-seed policy rule. */
9233
+ const autoSeedRuleSchema = z.object({
9234
+ /** Glob pattern matched against watcher walk results. */
9235
+ match: z.string(),
9236
+ /** Optional steering prompt for seeded metas. */
9237
+ steer: z.string().optional(),
9238
+ /** Optional cross-references for seeded metas. */
9239
+ crossRefs: z.array(z.string()).optional(),
9240
+ });
9241
+ /** Zod schema for jeeves-meta service configuration (superset of MetaConfig). */
9242
+ const serviceConfigSchema = metaConfigSchema.extend({
9243
+ /** HTTP port for the service (default: 1938). */
9244
+ port: z.number().int().min(1).max(65535).default(1938),
9245
+ /** Cron schedule for synthesis cycles (default: every 30 min). */
9246
+ schedule: z.string().default('*/30 * * * *'),
9247
+ /** Messaging channel name (e.g. 'slack'). Legacy: also used as target if reportTarget is unset. */
9248
+ reportChannel: z.string().optional(),
9249
+ /** Channel/user ID to send progress messages to. */
9250
+ reportTarget: z.string().optional(),
9251
+ /** Optional base URL for the service, used to construct entity links in progress reports. */
9252
+ serverBaseUrl: z.string().optional(),
9253
+ /** Interval in ms for periodic watcher health check. 0 = disabled. Default: 60000. */
9254
+ watcherHealthIntervalMs: z.number().int().min(0).default(60_000),
9255
+ /** Logging configuration. */
9256
+ logging: loggingSchema.default(() => loggingSchema.parse({})),
9257
+ /**
9258
+ * Auto-seed policy: declarative rules for auto-creating .meta/ directories.
9259
+ * Rules are evaluated in order; last match wins for steer/crossRefs.
9260
+ */
9261
+ autoSeed: z.array(autoSeedRuleSchema).optional().default([]),
9262
+ });
9263
+
9264
+ /**
9265
+ * Load and resolve jeeves-meta service config.
9266
+ *
9267
+ * Supports \@file: indirection and environment-variable substitution (dollar-brace pattern).
9268
+ *
9269
+ * @module configLoader
9270
+ */
9271
+ /**
9272
+ * Deep-walk a value, replacing `\${VAR\}` patterns with process.env values.
9273
+ *
9274
+ * @param value - Arbitrary JSON-compatible value.
9275
+ * @returns Value with env-var placeholders resolved.
9276
+ */
9277
+ function substituteEnvVars(value) {
9278
+ if (typeof value === 'string') {
9279
+ return value.replace(/\$\{([^}]+)\}/g, (_match, name) => {
9280
+ const envVal = process.env[name];
9281
+ if (envVal === undefined) {
9282
+ throw new Error(`Environment variable ${name} is not set`);
9283
+ }
9284
+ return envVal;
9285
+ });
9286
+ }
9287
+ if (Array.isArray(value)) {
9288
+ return value.map(substituteEnvVars);
9289
+ }
9290
+ if (value !== null && typeof value === 'object') {
9291
+ const result = {};
9292
+ for (const [key, val] of Object.entries(value)) {
9293
+ result[key] = substituteEnvVars(val);
9294
+ }
9295
+ return result;
9296
+ }
9297
+ return value;
9298
+ }
9299
+ /**
9300
+ * Resolve \@file: references in a config value.
9301
+ *
9302
+ * @param value - String value that may start with "\@file:".
9303
+ * @param baseDir - Base directory for resolving relative paths.
9304
+ * @returns The resolved string (file contents or original value).
9305
+ */
9306
+ function resolveFileRef(value, baseDir) {
9307
+ if (!value.startsWith('@file:'))
9308
+ return value;
9309
+ const filePath = join(baseDir, value.slice(6));
9310
+ return readFileSync(filePath, 'utf8');
9311
+ }
9312
+ /**
9313
+ * Load service config from a JSON file.
9314
+ *
9315
+ * Resolves \@file: references for defaultArchitect and defaultCritic,
9316
+ * and substitutes environment-variable placeholders throughout.
9317
+ *
9318
+ * @param configPath - Path to config JSON file.
9319
+ * @returns Validated ServiceConfig.
9320
+ */
9321
+ function loadServiceConfig(configPath) {
9322
+ const rawText = readFileSync(configPath, 'utf8');
9323
+ const raw = substituteEnvVars(JSON.parse(rawText));
9324
+ const baseDir = dirname(configPath);
9325
+ if (typeof raw['defaultArchitect'] === 'string') {
9326
+ raw['defaultArchitect'] = resolveFileRef(raw['defaultArchitect'], baseDir);
9327
+ }
9328
+ if (typeof raw['defaultCritic'] === 'string') {
9329
+ raw['defaultCritic'] = resolveFileRef(raw['defaultCritic'], baseDir);
9330
+ }
9331
+ return serviceConfigSchema.parse(raw);
9332
+ }
9333
+
9196
9334
  /**
9197
9335
  * Error thrown when a spawned subprocess is aborted via AbortController.
9198
9336
  *
@@ -9291,21 +9429,29 @@ class GatewayExecutor {
9291
9429
  }
9292
9430
  return data;
9293
9431
  }
9294
- /** Look up totalTokens for a session via sessions_list. */
9295
- async getSessionTokens(sessionKey) {
9432
+ /** Look up session metadata (tokens, completion status) via sessions_list. */
9433
+ async getSessionInfo(sessionKey) {
9296
9434
  try {
9297
9435
  const result = await this.invoke('sessions_list', {
9298
- limit: 20,
9436
+ limit: 200,
9299
9437
  messageLimit: 0,
9300
9438
  });
9301
9439
  const sessions = (result.result?.details?.sessions ??
9302
9440
  result.result?.sessions ??
9303
9441
  []);
9304
9442
  const match = sessions.find((s) => s.key === sessionKey);
9305
- return match?.totalTokens ?? undefined;
9443
+ if (!match) {
9444
+ // Session absent from list — likely cleaned up after completion.
9445
+ // With limit=200 this is reliable; a false positive here only
9446
+ // means we read the output file slightly early (still correct
9447
+ // if the file exists).
9448
+ return { completed: true };
9449
+ }
9450
+ const done = match.status === 'completed' || match.status === 'done';
9451
+ return { tokens: match.totalTokens, completed: done };
9306
9452
  }
9307
9453
  catch {
9308
- return undefined;
9454
+ return { completed: false };
9309
9455
  }
9310
9456
  }
9311
9457
  /** Whether this executor has been aborted by the operator. */
@@ -9335,7 +9481,7 @@ class GatewayExecutor {
9335
9481
  'Write your complete output to a file using the Write tool at:\n' +
9336
9482
  outputPath +
9337
9483
  '\n\n' +
9338
- 'Reply with ONLY the file path you wrote to. No other text.';
9484
+ 'After writing the file, reply with ONLY: NO_REPLY';
9339
9485
  // Step 1: Spawn the sub-agent session (unique label per cycle to avoid
9340
9486
  // "label already in use" errors — gateway labels persist after session completion)
9341
9487
  const labelBase = options?.label ?? 'jeeves-meta-synthesis';
@@ -9371,48 +9517,53 @@ class GatewayExecutor {
9371
9517
  historyResult.result?.messages ??
9372
9518
  [];
9373
9519
  const msgArray = messages;
9520
+ // Check 1: terminal stop reason in history
9521
+ let historyDone = false;
9374
9522
  if (msgArray.length > 0) {
9375
9523
  const lastMsg = msgArray[msgArray.length - 1];
9376
- // Complete when last message is assistant with a terminal stop reason
9377
9524
  if (lastMsg.role === 'assistant' &&
9378
9525
  lastMsg.stopReason &&
9379
9526
  lastMsg.stopReason !== 'toolUse' &&
9380
9527
  lastMsg.stopReason !== 'error') {
9381
- // Fetch token usage from session metadata
9382
- const tokens = await this.getSessionTokens(sessionKey);
9383
- // Read output from file (sub-agent wrote it via Write tool)
9384
- if (existsSync(outputPath)) {
9528
+ historyDone = true;
9529
+ }
9530
+ }
9531
+ // Check 2: session completion status via sessions_list
9532
+ const sessionInfo = await this.getSessionInfo(sessionKey);
9533
+ if (historyDone || sessionInfo.completed) {
9534
+ const tokens = sessionInfo.tokens;
9535
+ // Read output from file (sub-agent wrote it via Write tool)
9536
+ if (existsSync(outputPath)) {
9537
+ try {
9538
+ const output = readFileSync(outputPath, 'utf8');
9539
+ return { output, tokens };
9540
+ }
9541
+ finally {
9385
9542
  try {
9386
- const output = readFileSync(outputPath, 'utf8');
9387
- return { output, tokens };
9543
+ unlinkSync(outputPath);
9388
9544
  }
9389
- finally {
9390
- try {
9391
- unlinkSync(outputPath);
9392
- }
9393
- catch {
9394
- /* cleanup best-effort */
9395
- }
9545
+ catch {
9546
+ /* cleanup best-effort */
9396
9547
  }
9397
9548
  }
9398
- // Fallback: extract from message content if file wasn't written
9399
- for (let i = msgArray.length - 1; i >= 0; i--) {
9400
- const msg = msgArray[i];
9401
- if (msg.role === 'assistant' && msg.content) {
9402
- const text = typeof msg.content === 'string'
9549
+ }
9550
+ // Fallback: extract from message content if file wasn't written
9551
+ for (let i = msgArray.length - 1; i >= 0; i--) {
9552
+ const msg = msgArray[i];
9553
+ if (msg.role === 'assistant' && msg.content) {
9554
+ const text = typeof msg.content === 'string'
9555
+ ? msg.content
9556
+ : Array.isArray(msg.content)
9403
9557
  ? msg.content
9404
- : Array.isArray(msg.content)
9405
- ? msg.content
9406
- .filter((b) => b.type === 'text' && b.text)
9407
- .map((b) => b.text)
9408
- .join('\n')
9409
- : '';
9410
- if (text)
9411
- return { output: text, tokens };
9412
- }
9558
+ .filter((b) => b.type === 'text' && b.text)
9559
+ .map((b) => b.text)
9560
+ .join('\n')
9561
+ : '';
9562
+ if (text)
9563
+ return { output: text, tokens };
9413
9564
  }
9414
- return { output: '', tokens };
9415
9565
  }
9566
+ return { output: '', tokens };
9416
9567
  }
9417
9568
  }
9418
9569
  catch {
@@ -9775,6 +9926,7 @@ async function buildContextPackage(node, meta, watcher, logger) {
9775
9926
  *
9776
9927
  * @module orchestrator/buildTask
9777
9928
  */
9929
+ Handlebars.registerHelper('gt', (a, b) => a > b);
9778
9930
  /** Build the template context from synthesis inputs. */
9779
9931
  function buildTemplateContext(ctx, meta, config) {
9780
9932
  return {
@@ -9929,134 +10081,6 @@ function buildCriticTask(ctx, meta, config) {
9929
10081
  return compileTemplate(sections.join('\n'), buildTemplateContext(ctx, meta, config));
9930
10082
  }
9931
10083
 
9932
- /**
9933
- * Structured error from a synthesis step failure.
9934
- *
9935
- * @module schema/error
9936
- */
9937
- /** Zod schema for synthesis step errors. */
9938
- const metaErrorSchema = z.object({
9939
- /** Which step failed: 'architect', 'builder', or 'critic'. */
9940
- step: z.enum(['architect', 'builder', 'critic']),
9941
- /** Error classification code. */
9942
- code: z.string(),
9943
- /** Human-readable error message. */
9944
- message: z.string(),
9945
- });
9946
-
9947
- /**
9948
- * Zod schema for .meta/meta.json files.
9949
- *
9950
- * Reserved properties are underscore-prefixed and engine-managed.
9951
- * All other keys are open schema (builder output).
9952
- *
9953
- * @module schema/meta
9954
- */
9955
- /** Valid states for a synthesis phase. */
9956
- const phaseStatuses = [
9957
- 'fresh',
9958
- 'stale',
9959
- 'pending',
9960
- 'running',
9961
- 'failed',
9962
- ];
9963
- /** Zod schema for a per-phase status value. */
9964
- const phaseStatusSchema = z.enum(phaseStatuses);
9965
- /** Zod schema for the per-meta phase state record. */
9966
- const phaseStateSchema = z.object({
9967
- architect: phaseStatusSchema,
9968
- builder: phaseStatusSchema,
9969
- critic: phaseStatusSchema,
9970
- });
9971
- /** Zod schema for the reserved (underscore-prefixed) meta.json properties. */
9972
- z
9973
- .object({
9974
- /** Stable identity. Auto-generated on first synthesis if not provided. */
9975
- _id: z.uuid().optional(),
9976
- /** Human-provided steering prompt. Optional. */
9977
- _steer: z.string().optional(),
9978
- /**
9979
- * Explicit cross-references to other meta owner paths.
9980
- * Referenced metas' _content is included as architect/builder context.
9981
- */
9982
- _crossRefs: z.array(z.string()).optional(),
9983
- /** Architect system prompt used this turn. Defaults from config. */
9984
- _architect: z.string().optional(),
9985
- /**
9986
- * Task brief generated by the architect. Cached and reused across cycles;
9987
- * regenerated only when triggered.
9988
- */
9989
- _builder: z.string().optional(),
9990
- /** Critic system prompt used this turn. Defaults from config. */
9991
- _critic: z.string().optional(),
9992
- /** Timestamp of last synthesis. ISO 8601. */
9993
- _generatedAt: z.iso.datetime().optional(),
9994
- /** Narrative synthesis output. Rendered by watcher for embedding. */
9995
- _content: z.string().optional(),
9996
- /**
9997
- * Hash of sorted file listing in scope. Detects directory structure
9998
- * changes that trigger an architect re-run.
9999
- */
10000
- _structureHash: z.string().optional(),
10001
- /**
10002
- * Cycles since last architect run. Reset to 0 when architect runs.
10003
- * Used with architectEvery to trigger periodic re-prompting.
10004
- */
10005
- _synthesisCount: z.number().int().min(0).optional(),
10006
- /** Critic evaluation of the last synthesis. */
10007
- _feedback: z.string().optional(),
10008
- /**
10009
- * Present and true on archive snapshots. Distinguishes live vs. archived
10010
- * metas.
10011
- */
10012
- _archived: z.boolean().optional(),
10013
- /** Timestamp when this snapshot was archived. ISO 8601. */
10014
- _archivedAt: z.iso.datetime().optional(),
10015
- /**
10016
- * Scheduling priority. Higher = updates more often. Negative allowed;
10017
- * normalized to min 0 at scheduling time.
10018
- */
10019
- _depth: z.number().optional(),
10020
- /**
10021
- * Emphasis multiplier for depth weighting in scheduling.
10022
- * Default 1. Higher values increase this meta's scheduling priority
10023
- * relative to its depth. Set to 0.5 to halve the depth effect,
10024
- * 2 to double it, 0 to ignore depth entirely for this meta.
10025
- */
10026
- _emphasis: z.number().min(0).optional(),
10027
- /** Token count from last architect subprocess call. */
10028
- _architectTokens: z.number().int().optional(),
10029
- /** Token count from last builder subprocess call. */
10030
- _builderTokens: z.number().int().optional(),
10031
- /** Token count from last critic subprocess call. */
10032
- _criticTokens: z.number().int().optional(),
10033
- /** Exponential moving average of architect token usage (decay 0.3). */
10034
- _architectTokensAvg: z.number().optional(),
10035
- /** Exponential moving average of builder token usage (decay 0.3). */
10036
- _builderTokensAvg: z.number().optional(),
10037
- /** Exponential moving average of critic token usage (decay 0.3). */
10038
- _criticTokensAvg: z.number().optional(),
10039
- /**
10040
- * Opaque state carried across synthesis cycles for progressive work.
10041
- * Set by the builder, passed back as context on next cycle.
10042
- */
10043
- _state: z.unknown().optional(),
10044
- /**
10045
- * Structured error from last cycle. Present when a step failed.
10046
- * Cleared on successful cycle.
10047
- */
10048
- _error: metaErrorSchema.optional(),
10049
- /** When true, this meta is skipped during staleness scheduling. Manual trigger still works. */
10050
- _disabled: z.boolean().optional(),
10051
- /**
10052
- * Per-phase state machine record. Engine-managed.
10053
- * Keyed by phase name (architect, builder, critic) with status values.
10054
- * Persisted to survive ticks; derived on first load for back-compat.
10055
- */
10056
- _phaseState: phaseStateSchema.optional(),
10057
- })
10058
- .loose();
10059
-
10060
10084
  /**
10061
10085
  * Build a minimal MetaNode from a known meta path using watcher walk.
10062
10086
  *
@@ -10118,222 +10142,6 @@ async function buildMinimalNode(metaPath, watcher) {
10118
10142
  return node;
10119
10143
  }
10120
10144
 
10121
- /**
10122
- * Weighted staleness formula for candidate selection.
10123
- *
10124
- * effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
10125
- *
10126
- * @module scheduling/weightedFormula
10127
- */
10128
- /**
10129
- * Compute effective staleness for a set of candidates.
10130
- *
10131
- * Normalizes depths so the minimum becomes 0, then applies the formula:
10132
- * effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
10133
- *
10134
- * Per-meta _emphasis (default 1) multiplies depthWeight, allowing individual
10135
- * metas to tune how much their tree position affects scheduling.
10136
- *
10137
- * @param candidates - Array of \{ node, meta, actualStaleness \}.
10138
- * @param depthWeight - Exponent for depth weighting (0 = pure staleness).
10139
- * @returns Same array with effectiveStaleness computed.
10140
- */
10141
- function computeEffectiveStaleness(candidates, depthWeight) {
10142
- if (candidates.length === 0)
10143
- return [];
10144
- // Get depth for each candidate: use _depth override or tree depth
10145
- const depths = candidates.map((c) => c.meta._depth ?? c.node.treeDepth);
10146
- // Normalize: shift so minimum becomes 0
10147
- const minDepth = Math.min(...depths);
10148
- const normalizedDepths = depths.map((d) => Math.max(0, d - minDepth));
10149
- return candidates.map((c, i) => {
10150
- const emphasis = c.meta._emphasis ?? 1;
10151
- return {
10152
- ...c,
10153
- effectiveStaleness: c.actualStaleness *
10154
- Math.pow(normalizedDepths[i] + 1, depthWeight * emphasis),
10155
- };
10156
- });
10157
- }
10158
-
10159
- /**
10160
- * Select the best synthesis candidate from stale metas.
10161
- *
10162
- * Picks the meta with highest effective staleness.
10163
- *
10164
- * @module scheduling/selectCandidate
10165
- */
10166
- /**
10167
- * Select the candidate with the highest effective staleness.
10168
- *
10169
- * @param candidates - Array of candidates with computed effective staleness.
10170
- * @returns The winning candidate, or null if no candidates.
10171
- */
10172
- function selectCandidate(candidates) {
10173
- if (candidates.length === 0)
10174
- return null;
10175
- let best = candidates[0];
10176
- for (let i = 1; i < candidates.length; i++) {
10177
- if (candidates[i].effectiveStaleness > best.effectiveStaleness) {
10178
- best = candidates[i];
10179
- }
10180
- }
10181
- return best;
10182
- }
10183
- /**
10184
- * Extract stale candidates from a list and return the stalest path.
10185
- *
10186
- * Consolidates the repeated pattern of:
10187
- * filter → computeEffectiveStaleness → selectCandidate → return path
10188
- *
10189
- * @param candidates - Array with node, meta, and stalenessSeconds.
10190
- * @param depthWeight - Depth weighting exponent from config.
10191
- * @returns The stalest candidate's metaPath, or null if none are stale.
10192
- */
10193
- function discoverStalestPath(candidates, depthWeight) {
10194
- const weighted = computeEffectiveStaleness(candidates, depthWeight);
10195
- const winner = selectCandidate(weighted);
10196
- return winner?.node.metaPath ?? null;
10197
- }
10198
-
10199
- /**
10200
- * Shared error utilities.
10201
- *
10202
- * @module errors
10203
- */
10204
- /**
10205
- * Wrap an unknown caught value into a MetaError.
10206
- *
10207
- * @param step - Which synthesis step failed.
10208
- * @param err - The caught error value.
10209
- * @param code - Error classification code.
10210
- * @returns A structured MetaError.
10211
- */
10212
- function toMetaError(step, err, code = 'FAILED') {
10213
- return {
10214
- step,
10215
- code,
10216
- message: err instanceof Error ? err.message : String(err),
10217
- };
10218
- }
10219
-
10220
- /**
10221
- * Compute a structure hash from a sorted file listing.
10222
- *
10223
- * Used to detect when directory structure changes, triggering
10224
- * an architect re-run.
10225
- *
10226
- * @module structureHash
10227
- */
10228
- /**
10229
- * Compute a SHA-256 hash of a sorted file listing.
10230
- *
10231
- * @param filePaths - Array of file paths in scope.
10232
- * @returns Hex-encoded SHA-256 hash of the sorted, newline-joined paths.
10233
- */
10234
- function computeStructureHash(filePaths) {
10235
- const sorted = [...filePaths].sort();
10236
- const content = sorted.join('\n');
10237
- return createHash('sha256').update(content).digest('hex');
10238
- }
10239
-
10240
- /**
10241
- * Parse subprocess outputs for each synthesis step.
10242
- *
10243
- * - Architect: returns text \> _builder
10244
- * - Builder: returns JSON \> _content + structured fields
10245
- * - Critic: returns text \> _feedback
10246
- *
10247
- * @module orchestrator/parseOutput
10248
- */
10249
- /**
10250
- * Parse architect output. The architect returns a task brief as text.
10251
- *
10252
- * @param output - Raw subprocess output.
10253
- * @returns The task brief string.
10254
- */
10255
- function parseArchitectOutput(output) {
10256
- return output.trim();
10257
- }
10258
- /**
10259
- * Parse builder output. The builder returns JSON with _content and optional fields.
10260
- *
10261
- * Attempts JSON parse first. If that fails, treats the entire output as _content.
10262
- *
10263
- * @param output - Raw subprocess output.
10264
- * @returns Parsed builder output with content and structured fields.
10265
- */
10266
- function parseBuilderOutput(output) {
10267
- const trimmed = output.trim();
10268
- // Strategy 1: Try to parse the entire output as JSON directly
10269
- const direct = tryParseJson(trimmed);
10270
- if (direct)
10271
- return direct;
10272
- // Strategy 2: Try all fenced code blocks (last match first — models often narrate then output)
10273
- const fencePattern = /```(?:json)?\s*([\s\S]*?)```/g;
10274
- const fenceMatches = [];
10275
- let match;
10276
- while ((match = fencePattern.exec(trimmed)) !== null) {
10277
- fenceMatches.push(match[1].trim());
10278
- }
10279
- // Try last fence first (most likely to be the actual output)
10280
- for (let i = fenceMatches.length - 1; i >= 0; i--) {
10281
- const result = tryParseJson(fenceMatches[i]);
10282
- if (result)
10283
- return result;
10284
- }
10285
- // Strategy 3: Find outermost { ... } braces
10286
- const firstBrace = trimmed.indexOf('{');
10287
- const lastBrace = trimmed.lastIndexOf('}');
10288
- if (firstBrace !== -1 && lastBrace > firstBrace) {
10289
- const result = tryParseJson(trimmed.substring(firstBrace, lastBrace + 1));
10290
- if (result)
10291
- return result;
10292
- }
10293
- // Fallback: treat entire output as content
10294
- return { content: trimmed, fields: {} };
10295
- }
10296
- /** Try to parse a string as JSON and extract builder output fields. */
10297
- function tryParseJson(str) {
10298
- try {
10299
- const raw = JSON.parse(str);
10300
- if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
10301
- return null;
10302
- }
10303
- const parsed = raw;
10304
- // Extract _content
10305
- const content = typeof parsed['_content'] === 'string'
10306
- ? parsed['_content']
10307
- : typeof parsed['content'] === 'string'
10308
- ? parsed['content']
10309
- : null;
10310
- if (content === null)
10311
- return null;
10312
- // Extract _state (the ONLY underscore key the builder is allowed to set)
10313
- const state = '_state' in parsed ? parsed['_state'] : undefined;
10314
- // Extract non-underscore fields
10315
- const fields = {};
10316
- for (const [key, value] of Object.entries(parsed)) {
10317
- if (!key.startsWith('_') && key !== 'content') {
10318
- fields[key] = value;
10319
- }
10320
- }
10321
- return { content, fields, ...(state !== undefined ? { state } : {}) };
10322
- }
10323
- catch {
10324
- return null;
10325
- }
10326
- }
10327
- /**
10328
- * Parse critic output. The critic returns evaluation text.
10329
- *
10330
- * @param output - Raw subprocess output.
10331
- * @returns The feedback string.
10332
- */
10333
- function parseCriticOutput(output) {
10334
- return output.trim();
10335
- }
10336
-
10337
10145
  /**
10338
10146
  * Pure phase-state transition functions.
10339
10147
  *
@@ -10383,7 +10191,42 @@ function enforceInvariant(state) {
10383
10191
  // running in non-first position would be a bug, but don't mask it
10384
10192
  }
10385
10193
  }
10386
- return result;
10194
+ return result;
10195
+ }
10196
+ // ── Invalidation cascades ──────────────────────────────────────────────
10197
+ /**
10198
+ * Architect invalidated: architect → pending; builder, critic → stale.
10199
+ * Triggers: _structureHash change, _steer change, _architect change,
10200
+ * _crossRefs declaration change, _synthesisCount \>= architectEvery.
10201
+ */
10202
+ function invalidateArchitect(state) {
10203
+ return enforceInvariant({
10204
+ architect: state.architect === 'failed' ? 'failed' : 'pending',
10205
+ builder: state.builder === 'fresh' ? 'stale' : state.builder,
10206
+ critic: state.critic === 'fresh' ? 'stale' : state.critic,
10207
+ });
10208
+ }
10209
+ /**
10210
+ * Builder invalidated (scope mtime or cross-ref _content change):
10211
+ * builder → pending; critic → stale.
10212
+ * Only applies when architect is fresh; otherwise, builder stays stale.
10213
+ */
10214
+ function invalidateBuilder(state) {
10215
+ if (state.architect !== 'fresh') {
10216
+ // Architect is not fresh — builder stays stale (or whatever it is)
10217
+ return enforceInvariant({
10218
+ ...state,
10219
+ builder: state.builder === 'fresh' || state.builder === 'stale'
10220
+ ? 'stale'
10221
+ : state.builder,
10222
+ critic: state.critic === 'fresh' ? 'stale' : state.critic,
10223
+ });
10224
+ }
10225
+ return enforceInvariant({
10226
+ ...state,
10227
+ builder: state.builder === 'failed' ? 'failed' : 'pending',
10228
+ critic: state.critic === 'fresh' ? 'stale' : state.critic,
10229
+ });
10387
10230
  }
10388
10231
  // ── Phase success transitions ──────────────────────────────────────────
10389
10232
  /**
@@ -10553,7 +10396,9 @@ function derivePhaseState(meta, inputs) {
10553
10396
  }
10554
10397
  // Check architect invalidation (when inputs are provided)
10555
10398
  if (inputs) {
10556
- const architectInvalidated = inputs.structureChanged ||
10399
+ // Progressive metas: structure changes invalidate builder, not architect
10400
+ const structureInvalidatesArchitect = inputs.structureChanged && meta._state === undefined;
10401
+ const architectInvalidated = structureInvalidatesArchitect ||
10557
10402
  inputs.steerChanged ||
10558
10403
  inputs.architectChanged ||
10559
10404
  inputs.crossRefsChanged ||
@@ -10565,6 +10410,14 @@ function derivePhaseState(meta, inputs) {
10565
10410
  critic: 'stale',
10566
10411
  };
10567
10412
  }
10413
+ // Progressive meta with structure change: builder-only invalidation
10414
+ if (inputs.structureChanged && meta._state !== undefined) {
10415
+ return {
10416
+ architect: 'fresh',
10417
+ builder: 'pending',
10418
+ critic: 'stale',
10419
+ };
10420
+ }
10568
10421
  }
10569
10422
  // Has _builder but no _content: builder is pending
10570
10423
  if (meta._builder && !meta._content) {
@@ -10586,6 +10439,154 @@ function derivePhaseState(meta, inputs) {
10586
10439
  return freshPhaseState();
10587
10440
  }
10588
10441
 
10442
+ /**
10443
+ * Compute a structure hash from a sorted file listing.
10444
+ *
10445
+ * Used to detect when directory structure changes, triggering
10446
+ * an architect re-run.
10447
+ *
10448
+ * @module structureHash
10449
+ */
10450
+ /**
10451
+ * Compute a SHA-256 hash of a sorted file listing.
10452
+ *
10453
+ * @param filePaths - Array of file paths in scope.
10454
+ * @returns Hex-encoded SHA-256 hash of the sorted, newline-joined paths.
10455
+ */
10456
+ function computeStructureHash(filePaths) {
10457
+ const sorted = [...filePaths].sort();
10458
+ const content = sorted.join('\n');
10459
+ return createHash('sha256').update(content).digest('hex');
10460
+ }
10461
+
10462
+ /**
10463
+ * Per-tick invalidation pass.
10464
+ *
10465
+ * Computes architect-invalidating and builder-invalidating inputs for a meta,
10466
+ * then applies the cascade to update _phaseState.
10467
+ *
10468
+ * @module phaseState/invalidate
10469
+ */
10470
+ /**
10471
+ * Compute invalidation inputs and apply cascade for a single meta.
10472
+ *
10473
+ * @param meta - Current meta.json content with existing _phaseState.
10474
+ * @param scopeFiles - Sorted file list from scope.
10475
+ * @param config - MetaConfig for architectEvery.
10476
+ * @param node - MetaNode for archive access.
10477
+ * @param crossRefMetas - Map of cross-ref owner paths to their current _content.
10478
+ * @param archiveCrossRefContent - Map of cross-ref owner paths to their archived _content.
10479
+ * @returns Updated phase state and invalidation details.
10480
+ */
10481
+ async function computeInvalidation(meta, scopeFiles, config, node, crossRefMetas, archiveCrossRefContent) {
10482
+ let phaseState = meta._phaseState ?? {
10483
+ architect: 'fresh',
10484
+ builder: 'fresh',
10485
+ critic: 'fresh',
10486
+ };
10487
+ // ── Architect-level inputs ──
10488
+ const structureHash = computeStructureHash(scopeFiles);
10489
+ const structureChanged = structureHash !== meta._structureHash;
10490
+ const latestArchive = await readLatestArchive(node.metaPath);
10491
+ const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
10492
+ // _architect change: compare current vs. archive
10493
+ const architectChanged = latestArchive
10494
+ ? (meta._architect ?? '') !== (latestArchive._architect ?? '')
10495
+ : Boolean(meta._architect);
10496
+ // _crossRefs declaration change
10497
+ const currentRefs = (meta._crossRefs ?? []).slice().sort().join(',');
10498
+ const archiveRefs = (latestArchive?._crossRefs ?? [])
10499
+ .slice()
10500
+ .sort()
10501
+ .join(',');
10502
+ const crossRefsDeclChanged = latestArchive
10503
+ ? currentRefs !== archiveRefs
10504
+ : currentRefs.length > 0;
10505
+ const architectInvalidators = [];
10506
+ if (structureChanged) {
10507
+ if (meta._state !== undefined) {
10508
+ // Progressive entity: new files → builder only (cursor handles incremental)
10509
+ phaseState = invalidateBuilder(phaseState);
10510
+ }
10511
+ else {
10512
+ architectInvalidators.push('structureHash');
10513
+ }
10514
+ }
10515
+ if (steerChanged)
10516
+ architectInvalidators.push('steer');
10517
+ if (architectChanged)
10518
+ architectInvalidators.push('_architect');
10519
+ if (crossRefsDeclChanged)
10520
+ architectInvalidators.push('_crossRefs');
10521
+ if ((meta._synthesisCount ?? 0) >= config.architectEvery) {
10522
+ architectInvalidators.push('architectEvery');
10523
+ }
10524
+ // First-run check: no _builder means architect must run
10525
+ const firstRun = !meta._builder;
10526
+ if (architectInvalidators.length > 0 || firstRun) {
10527
+ phaseState = invalidateArchitect(phaseState);
10528
+ }
10529
+ // ── Builder-level inputs ──
10530
+ // Scope file mtime check — if any file newer than _generatedAt
10531
+ const scopeMtimeMax = null;
10532
+ // Note: actual mtime check is done by the caller or via isStale;
10533
+ // here we just detect cross-ref content changes for the cascade.
10534
+ // Cross-ref _content change (builder-invalidating)
10535
+ let crossRefContentChanged = false;
10536
+ return {
10537
+ phaseState,
10538
+ architectInvalidators,
10539
+ stalenessInputs: {
10540
+ structureHash,
10541
+ steerChanged,
10542
+ architectChanged,
10543
+ crossRefsDeclChanged,
10544
+ scopeMtimeMax,
10545
+ crossRefContentChanged,
10546
+ },
10547
+ structureHash,
10548
+ steerChanged,
10549
+ };
10550
+ }
10551
+
10552
+ /**
10553
+ * Weighted staleness formula for candidate selection.
10554
+ *
10555
+ * effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
10556
+ *
10557
+ * @module scheduling/weightedFormula
10558
+ */
10559
+ /**
10560
+ * Compute effective staleness for a set of candidates.
10561
+ *
10562
+ * Normalizes depths so the minimum becomes 0, then applies the formula:
10563
+ * effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
10564
+ *
10565
+ * Per-meta _emphasis (default 1) multiplies depthWeight, allowing individual
10566
+ * metas to tune how much their tree position affects scheduling.
10567
+ *
10568
+ * @param candidates - Array of \{ node, meta, actualStaleness \}.
10569
+ * @param depthWeight - Exponent for depth weighting (0 = pure staleness).
10570
+ * @returns Same array with effectiveStaleness computed.
10571
+ */
10572
+ function computeEffectiveStaleness(candidates, depthWeight) {
10573
+ if (candidates.length === 0)
10574
+ return [];
10575
+ // Get depth for each candidate: use _depth override or tree depth
10576
+ const depths = candidates.map((c) => c.meta._depth ?? c.node.treeDepth);
10577
+ // Normalize: shift so minimum becomes 0
10578
+ const minDepth = Math.min(...depths);
10579
+ const normalizedDepths = depths.map((d) => Math.max(0, d - minDepth));
10580
+ return candidates.map((c, i) => {
10581
+ const emphasis = c.meta._emphasis ?? 1;
10582
+ return {
10583
+ ...c,
10584
+ effectiveStaleness: c.actualStaleness *
10585
+ Math.pow(normalizedDepths[i] + 1, depthWeight * emphasis),
10586
+ };
10587
+ });
10588
+ }
10589
+
10589
10590
  /**
10590
10591
  * Corpus-wide phase scheduler.
10591
10592
  *
@@ -10598,18 +10599,30 @@ function derivePhaseState(meta, inputs) {
10598
10599
  /**
10599
10600
  * Build phase candidates from listMetas entries.
10600
10601
  *
10601
- * Derives phase state and auto-retries failed phases for each entry.
10602
+ * Derives phase state, auto-retries failed phases, and applies Tier 1
10603
+ * cheap-invalidation (no I/O) for metas with persisted _phaseState.
10602
10604
  * Used by orchestratePhase, queue route, and status route.
10603
10605
  */
10604
- function buildPhaseCandidates(entries) {
10605
- return entries.map((entry) => ({
10606
- node: entry.node,
10607
- meta: entry.meta,
10608
- phaseState: retryAllFailed(derivePhaseState(entry.meta)),
10609
- actualStaleness: entry.stalenessSeconds,
10610
- locked: entry.locked,
10611
- disabled: entry.disabled,
10612
- }));
10606
+ function buildPhaseCandidates(entries, architectEvery) {
10607
+ return entries.map((entry) => {
10608
+ let ps = retryAllFailed(derivePhaseState(entry.meta));
10609
+ // Tier 1 cheap invalidation for metas with persisted _phaseState
10610
+ if (entry.meta._phaseState) {
10611
+ const needsArchitect = !entry.meta._builder ||
10612
+ (entry.meta._synthesisCount ?? 0) >= architectEvery;
10613
+ if (needsArchitect && ps.architect === 'fresh') {
10614
+ ps = { architect: 'pending', builder: 'stale', critic: 'stale' };
10615
+ }
10616
+ }
10617
+ return {
10618
+ node: entry.node,
10619
+ meta: entry.meta,
10620
+ phaseState: ps,
10621
+ actualStaleness: entry.stalenessSeconds,
10622
+ locked: entry.locked,
10623
+ disabled: entry.disabled,
10624
+ };
10625
+ });
10613
10626
  }
10614
10627
  /**
10615
10628
  * Rank all eligible phase candidates by priority.
@@ -10672,6 +10685,124 @@ function selectPhaseCandidate(metas, depthWeight) {
10672
10685
  return rankPhaseCandidates(metas, depthWeight)[0] ?? null;
10673
10686
  }
10674
10687
 
10688
+ /**
10689
+ * Shared error utilities.
10690
+ *
10691
+ * @module errors
10692
+ */
10693
+ /**
10694
+ * Wrap an unknown caught value into a MetaError.
10695
+ *
10696
+ * @param step - Which synthesis step failed.
10697
+ * @param err - The caught error value.
10698
+ * @param code - Error classification code.
10699
+ * @returns A structured MetaError.
10700
+ */
10701
+ function toMetaError(step, err, code = 'FAILED') {
10702
+ return {
10703
+ step,
10704
+ code,
10705
+ message: err instanceof Error ? err.message : String(err),
10706
+ };
10707
+ }
10708
+
10709
+ /**
10710
+ * Parse subprocess outputs for each synthesis step.
10711
+ *
10712
+ * - Architect: returns text \> _builder
10713
+ * - Builder: returns JSON \> _content + structured fields
10714
+ * - Critic: returns text \> _feedback
10715
+ *
10716
+ * @module orchestrator/parseOutput
10717
+ */
10718
+ /**
10719
+ * Parse architect output. The architect returns a task brief as text.
10720
+ *
10721
+ * @param output - Raw subprocess output.
10722
+ * @returns The task brief string.
10723
+ */
10724
+ function parseArchitectOutput(output) {
10725
+ return output.trim();
10726
+ }
10727
+ /**
10728
+ * Parse builder output. The builder returns JSON with _content and optional fields.
10729
+ *
10730
+ * Attempts JSON parse first. If that fails, treats the entire output as _content.
10731
+ *
10732
+ * @param output - Raw subprocess output.
10733
+ * @returns Parsed builder output with content and structured fields.
10734
+ */
10735
+ function parseBuilderOutput(output) {
10736
+ const trimmed = output.trim();
10737
+ // Strategy 1: Try to parse the entire output as JSON directly
10738
+ const direct = tryParseJson(trimmed);
10739
+ if (direct)
10740
+ return direct;
10741
+ // Strategy 2: Try all fenced code blocks (last match first — models often narrate then output)
10742
+ const fencePattern = /```(?:json)?\s*([\s\S]*?)```/g;
10743
+ const fenceMatches = [];
10744
+ let match;
10745
+ while ((match = fencePattern.exec(trimmed)) !== null) {
10746
+ fenceMatches.push(match[1].trim());
10747
+ }
10748
+ // Try last fence first (most likely to be the actual output)
10749
+ for (let i = fenceMatches.length - 1; i >= 0; i--) {
10750
+ const result = tryParseJson(fenceMatches[i]);
10751
+ if (result)
10752
+ return result;
10753
+ }
10754
+ // Strategy 3: Find outermost { ... } braces
10755
+ const firstBrace = trimmed.indexOf('{');
10756
+ const lastBrace = trimmed.lastIndexOf('}');
10757
+ if (firstBrace !== -1 && lastBrace > firstBrace) {
10758
+ const result = tryParseJson(trimmed.substring(firstBrace, lastBrace + 1));
10759
+ if (result)
10760
+ return result;
10761
+ }
10762
+ // Fallback: treat entire output as content
10763
+ return { content: trimmed, fields: {} };
10764
+ }
10765
+ /** Try to parse a string as JSON and extract builder output fields. */
10766
+ function tryParseJson(str) {
10767
+ try {
10768
+ const raw = JSON.parse(str);
10769
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
10770
+ return null;
10771
+ }
10772
+ const parsed = raw;
10773
+ // Extract _content
10774
+ const content = typeof parsed['_content'] === 'string'
10775
+ ? parsed['_content']
10776
+ : typeof parsed['content'] === 'string'
10777
+ ? parsed['content']
10778
+ : null;
10779
+ if (content === null)
10780
+ return null;
10781
+ // Extract _state (the ONLY underscore key the builder is allowed to set)
10782
+ const state = '_state' in parsed ? parsed['_state'] : undefined;
10783
+ // Extract non-underscore fields
10784
+ const fields = {};
10785
+ for (const [key, value] of Object.entries(parsed)) {
10786
+ if (!key.startsWith('_') && key !== 'content') {
10787
+ fields[key] = value;
10788
+ }
10789
+ }
10790
+ return { content, fields, ...(state !== undefined ? { state } : {}) };
10791
+ }
10792
+ catch {
10793
+ return null;
10794
+ }
10795
+ }
10796
+ /**
10797
+ * Parse critic output. The critic returns evaluation text.
10798
+ *
10799
+ * @param output - Raw subprocess output.
10800
+ * @returns The feedback string.
10801
+ */
10802
+ function parseCriticOutput(output) {
10803
+ return output.trim();
10804
+ }
10805
+
10675
10806
  /**
10676
10807
  * Per-phase executors for the phase-state machine.
10677
10808
  *
@@ -10930,7 +11061,7 @@ async function orchestratePhase(config, executor, watcher, targetPath, onProgres
10930
11061
  if (metaResult.entries.length === 0)
10931
11062
  return { executed: false };
10932
11063
  // Build candidates with phase state (including invalidation + auto-retry)
10933
- const candidates = buildPhaseCandidates(metaResult.entries);
11064
+ const candidates = buildPhaseCandidates(metaResult.entries, config.architectEvery);
10934
11065
  // Select best phase candidate
10935
11066
  const winner = selectPhaseCandidate(candidates, config.depthWeight);
10936
11067
  if (!winner) {
@@ -11605,46 +11736,6 @@ function buildMetaRules(config) {
11605
11736
  },
11606
11737
  renderAs: 'md',
11607
11738
  },
11608
- {
11609
- name: 'meta-config',
11610
- description: 'jeeves-meta configuration file',
11611
- match: {
11612
- properties: {
11613
- file: {
11614
- properties: {
11615
- path: {
11616
- type: 'string',
11617
- glob: '**/jeeves-meta{.config.json,/config.json}',
11618
- },
11619
- },
11620
- },
11621
- },
11622
- },
11623
- schema: ['base', { properties: { domains: { set: ['meta-config'] } } }],
11624
- render: {
11625
- frontmatter: [
11626
- 'watcherUrl',
11627
- 'gatewayUrl',
11628
- 'architectEvery',
11629
- 'depthWeight',
11630
- 'maxArchive',
11631
- 'maxLines',
11632
- ],
11633
- body: [
11634
- {
11635
- path: 'json.defaultArchitect',
11636
- heading: 2,
11637
- label: 'Default Architect Prompt',
11638
- },
11639
- {
11640
- path: 'json.defaultCritic',
11641
- heading: 2,
11642
- label: 'Default Critic Prompt',
11643
- },
11644
- ],
11645
- },
11646
- renderAs: 'md',
11647
- },
11648
11739
  ];
11649
11740
  }
11650
11741
  /**
@@ -11888,13 +11979,15 @@ class Scheduler {
11888
11979
  queue;
11889
11980
  logger;
11890
11981
  watcher;
11982
+ cache;
11891
11983
  registrar = null;
11892
11984
  currentExpression;
11893
- constructor(config, queue, logger, watcher) {
11985
+ constructor(config, queue, logger, watcher, cache) {
11894
11986
  this.config = config;
11895
11987
  this.queue = queue;
11896
11988
  this.logger = logger;
11897
11989
  this.watcher = watcher;
11990
+ this.cache = cache;
11898
11991
  this.currentExpression = config.schedule;
11899
11992
  }
11900
11993
  /** Set the rule registrar for watcher restart detection. */
@@ -12011,8 +12104,8 @@ class Scheduler {
12011
12104
  */
12012
12105
  async discoverNextPhase() {
12013
12106
  try {
12014
- const result = await listMetas(this.config, this.watcher);
12015
- const candidates = buildPhaseCandidates(result.entries);
12107
+ const result = await this.cache.get(this.config, this.watcher);
12108
+ const candidates = buildPhaseCandidates(result.entries, this.config.architectEvery);
12016
12109
  const winner = selectPhaseCandidate(candidates, this.config.depthWeight);
12017
12110
  if (!winner)
12018
12111
  return null;
@@ -12437,11 +12530,11 @@ function registerMetasUpdateRoute(app, deps) {
12437
12530
  */
12438
12531
  function registerPreviewRoute(app, deps) {
12439
12532
  app.get('/preview', async (request, reply) => {
12440
- const { config, watcher } = deps;
12533
+ const { config, watcher, cache } = deps;
12441
12534
  const query = request.query;
12442
12535
  let result;
12443
12536
  try {
12444
- result = await listMetas(config, watcher);
12537
+ result = await cache.get(config, watcher);
12445
12538
  }
12446
12539
  catch {
12447
12540
  return reply.status(503).send({
@@ -12461,40 +12554,24 @@ function registerPreviewRoute(app, deps) {
12461
12554
  }
12462
12555
  }
12463
12556
  else {
12464
- // Select stalest candidate
12465
- const stale = result.entries
12466
- .filter((e) => e.stalenessSeconds > 0)
12467
- .map((e) => ({
12468
- node: e.node,
12469
- meta: e.meta,
12470
- actualStaleness: e.stalenessSeconds,
12471
- }));
12472
- const stalestPath = discoverStalestPath(stale, config.depthWeight);
12473
- if (!stalestPath) {
12557
+ // Select best phase candidate
12558
+ const candidates = buildPhaseCandidates(result.entries, config.architectEvery);
12559
+ const winner = selectPhaseCandidate(candidates, config.depthWeight);
12560
+ if (!winner) {
12474
12561
  return { message: 'No stale metas found. Nothing to synthesize.' };
12475
12562
  }
12476
- targetNode = findNode(result.tree, stalestPath);
12563
+ targetNode = findNode(result.tree, winner.node.metaPath);
12477
12564
  }
12478
12565
  const meta = await readMetaJson(targetNode.metaPath);
12479
12566
  // Scope files
12480
12567
  const { scopeFiles } = await getScopeFiles(targetNode, watcher);
12481
- const structureHash = computeStructureHash(scopeFiles);
12568
+ // Compute invalidation inputs (DRY: reuse phaseState/invalidate logic)
12569
+ const invalidation = await computeInvalidation(meta, scopeFiles, config, targetNode);
12570
+ const { architectInvalidators, stalenessInputs } = invalidation;
12571
+ const { structureHash } = invalidation;
12482
12572
  const structureChanged = structureHash !== meta._structureHash;
12483
- const latestArchive = await readLatestArchive(targetNode.metaPath);
12484
- const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
12485
- // _architect change detection
12486
- const architectChanged = latestArchive
12487
- ? (meta._architect ?? '') !== (latestArchive._architect ?? '')
12488
- : Boolean(meta._architect);
12489
- // _crossRefs declaration change detection
12490
- const currentRefs = (meta._crossRefs ?? []).slice().sort().join(',');
12491
- const archiveRefs = (latestArchive?._crossRefs ?? [])
12492
- .slice()
12493
- .sort()
12494
- .join(',');
12495
- const crossRefsDeclChanged = latestArchive
12496
- ? currentRefs !== archiveRefs
12497
- : currentRefs.length > 0;
12573
+ const { steerChanged } = invalidation;
12574
+ const { architectChanged, crossRefsDeclChanged } = stalenessInputs;
12498
12575
  const architectTriggered = isArchitectTriggered(meta, structureChanged, steerChanged, config.architectEvery);
12499
12576
  // Delta files
12500
12577
  const deltaFiles = getDeltaFiles(meta._generatedAt, scopeFiles);
@@ -12519,30 +12596,6 @@ function registerPreviewRoute(app, deps) {
12519
12596
  });
12520
12597
  const owedPhase = getOwedPhase(phaseState);
12521
12598
  const priorityBand = getPriorityBand(phaseState);
12522
- // Architect invalidators
12523
- const architectInvalidators = [];
12524
- if (owedPhase === 'architect') {
12525
- if (structureChanged)
12526
- architectInvalidators.push('structureHash');
12527
- if (steerChanged)
12528
- architectInvalidators.push('steer');
12529
- if (architectChanged)
12530
- architectInvalidators.push('_architect');
12531
- if (crossRefsDeclChanged)
12532
- architectInvalidators.push('_crossRefs');
12533
- if ((meta._synthesisCount ?? 0) >= config.architectEvery) {
12534
- architectInvalidators.push('architectEvery');
12535
- }
12536
- }
12537
- // Staleness inputs
12538
- const stalenessInputs = {
12539
- structureHash,
12540
- steerChanged,
12541
- architectChanged,
12542
- crossRefsDeclChanged,
12543
- scopeMtimeMax: null,
12544
- crossRefContentChanged: false,
12545
- };
12546
12599
  return {
12547
12600
  path: targetNode.metaPath,
12548
12601
  staleness: {
@@ -12616,8 +12669,8 @@ function registerQueueRoutes(app, deps) {
12616
12669
  // ranked by scheduler priority (computed on read, not persisted)
12617
12670
  let automatic = [];
12618
12671
  try {
12619
- const metaResult = await listMetas(deps.config, deps.watcher);
12620
- const candidates = buildPhaseCandidates(metaResult.entries);
12672
+ const metaResult = await deps.cache.get(deps.config, deps.watcher);
12673
+ const candidates = buildPhaseCandidates(metaResult.entries, deps.config.architectEvery);
12621
12674
  const ranked = rankPhaseCandidates(candidates, deps.config.depthWeight);
12622
12675
  automatic = ranked.map((c) => ({
12623
12676
  path: c.node.metaPath,
@@ -12840,7 +12893,7 @@ function registerStatusRoute(app, deps) {
12840
12893
  name: SERVICE_NAME,
12841
12894
  version: SERVICE_VERSION,
12842
12895
  getHealth: async () => {
12843
- const { config, queue, scheduler, stats, watcher } = deps;
12896
+ const { config, queue, scheduler, stats, watcher, cache } = deps;
12844
12897
  // On-demand dependency checks
12845
12898
  const [watcherHealth, gatewayHealth] = await Promise.all([
12846
12899
  checkWatcher(config.watcherUrl),
@@ -12854,7 +12907,7 @@ function registerStatusRoute(app, deps) {
12854
12907
  };
12855
12908
  let nextPhase = null;
12856
12909
  try {
12857
- const metaResult = await listMetas(config, watcher);
12910
+ const metaResult = await cache.get(config, watcher);
12858
12911
  // Count raw phase states (before retry) for display
12859
12912
  for (const entry of metaResult.entries) {
12860
12913
  const ps = derivePhaseState(entry.meta);
@@ -12863,7 +12916,7 @@ function registerStatusRoute(app, deps) {
12863
12916
  }
12864
12917
  }
12865
12918
  // Build candidates (with auto-retry) for scheduling
12866
- const candidates = buildPhaseCandidates(metaResult.entries);
12919
+ const candidates = buildPhaseCandidates(metaResult.entries, config.architectEvery);
12867
12920
  // Find next phase candidate
12868
12921
  const winner = selectPhaseCandidate(candidates, config.depthWeight);
12869
12922
  if (winner) {
@@ -12926,7 +12979,7 @@ const synthesizeBodySchema = z.object({
12926
12979
  function registerSynthesizeRoute(app, deps) {
12927
12980
  app.post('/synthesize', async (request, reply) => {
12928
12981
  const body = synthesizeBodySchema.parse(request.body);
12929
- const { config, watcher, queue } = deps;
12982
+ const { config, watcher, queue, cache } = deps;
12930
12983
  if (body.path) {
12931
12984
  // Path-targeted trigger: create override entry
12932
12985
  const targetPath = resolveMetaDir(body.path);
@@ -12963,7 +13016,7 @@ function registerSynthesizeRoute(app, deps) {
12963
13016
  // Corpus-wide trigger: discover stalest candidate
12964
13017
  let result;
12965
13018
  try {
12966
- result = await listMetas(config, watcher);
13019
+ result = await cache.get(config, watcher);
12967
13020
  }
12968
13021
  catch {
12969
13022
  return reply.status(503).send({
@@ -12971,20 +13024,15 @@ function registerSynthesizeRoute(app, deps) {
12971
13024
  message: 'Watcher unreachable — cannot discover candidates',
12972
13025
  });
12973
13026
  }
12974
- const stale = result.entries
12975
- .filter((e) => e.stalenessSeconds > 0 && !e.disabled)
12976
- .map((e) => ({
12977
- node: e.node,
12978
- meta: e.meta,
12979
- actualStaleness: e.stalenessSeconds,
12980
- }));
12981
- const stalest = discoverStalestPath(stale, config.depthWeight);
12982
- if (!stalest) {
13027
+ const candidates = buildPhaseCandidates(result.entries, config.architectEvery);
13028
+ const winner = selectPhaseCandidate(candidates, config.depthWeight);
13029
+ if (!winner) {
12983
13030
  return reply.code(200).send({
12984
13031
  status: 'skipped',
12985
13032
  message: 'No stale metas found. Nothing to synthesize.',
12986
13033
  });
12987
13034
  }
13035
+ const stalest = winner.node.metaPath;
12988
13036
  const enqueueResult = queue.enqueue(stalest);
12989
13037
  return reply.code(202).send({
12990
13038
  status: 'accepted',
@@ -13085,6 +13133,18 @@ function createServer(options) {
13085
13133
  // Fastify 5 requires `loggerInstance` for external pino loggers
13086
13134
  const app = Fastify({
13087
13135
  loggerInstance: options.logger,
13136
+ requestTimeout: 30_000,
13137
+ });
13138
+ // Readiness gate: return 503 while service is initializing
13139
+ app.addHook('onRequest', async (request, reply) => {
13140
+ if (options.deps.ready)
13141
+ return;
13142
+ const url = request.url;
13143
+ if (url === '/config' || url.startsWith('/config/apply'))
13144
+ return;
13145
+ return reply
13146
+ .status(503)
13147
+ .send({ status: 'starting', message: 'Service initializing' });
13088
13148
  });
13089
13149
  registerRoutes(app, options.deps);
13090
13150
  return app;
@@ -13260,8 +13320,9 @@ async function startService(config, configPath) {
13260
13320
  lastCycleAt: null,
13261
13321
  };
13262
13322
  const queue = new SynthesisQueue(logger);
13323
+ const cache = new MetaCache();
13263
13324
  // Scheduler (needs watcher for discovery)
13264
- const scheduler = new Scheduler(config, queue, logger, watcher);
13325
+ const scheduler = new Scheduler(config, queue, logger, watcher, cache);
13265
13326
  const routeDeps = {
13266
13327
  config,
13267
13328
  logger,
@@ -13269,6 +13330,8 @@ async function startService(config, configPath) {
13269
13330
  watcher,
13270
13331
  scheduler,
13271
13332
  stats,
13333
+ cache,
13334
+ ready: false,
13272
13335
  executor,
13273
13336
  configPath,
13274
13337
  };
@@ -13319,6 +13382,9 @@ async function startService(config, configPath) {
13319
13382
  }
13320
13383
  await progress.report(evt);
13321
13384
  }, logger);
13385
+ // Invalidate cache only when a phase was actually executed
13386
+ if (result.executed)
13387
+ cache.invalidate();
13322
13388
  const durationMs = Date.now() - startMs;
13323
13389
  if (!result.executed) {
13324
13390
  logger.debug({ path: ownerPath }, 'Phase skipped (fully fresh or locked)');
@@ -13382,9 +13448,13 @@ async function startService(config, configPath) {
13382
13448
  scheduler.setRegistrar(registrar);
13383
13449
  routeDeps.registrar = registrar;
13384
13450
  void registrar.register().then(() => {
13451
+ routeDeps.ready = true;
13385
13452
  if (registrar.isRegistered) {
13386
13453
  void verifyRuleApplication(watcher, logger);
13387
13454
  }
13455
+ }, () => {
13456
+ // Registration failed after max retries — mark ready anyway
13457
+ routeDeps.ready = true;
13388
13458
  });
13389
13459
  // Periodic watcher health check (independent of scheduler)
13390
13460
  const healthCheck = new WatcherHealthCheck({