@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.
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';
@@ -16,8 +16,8 @@ import { z } from 'zod';
16
16
  import * as commander from 'commander';
17
17
  import 'node:child_process';
18
18
  import { tmpdir } from 'node:os';
19
- import pino from 'pino';
20
19
  import Handlebars from 'handlebars';
20
+ import pino from 'pino';
21
21
  import { Cron } from 'croner';
22
22
  import Fastify from 'fastify';
23
23
 
@@ -7437,6 +7437,11 @@ const workspaceCoreConfigSchema = z
7437
7437
  configRoot: z.string().optional().describe('Platform config root path'),
7438
7438
  /** OpenClaw gateway URL. */
7439
7439
  gatewayUrl: z.string().optional().describe('OpenClaw gateway URL'),
7440
+ /** Dev repo paths keyed by component name. */
7441
+ devRepos: z
7442
+ .record(z.string(), z.string())
7443
+ .optional()
7444
+ .describe('Dev repo paths by component name'),
7440
7445
  })
7441
7446
  .partial();
7442
7447
  /** Memory shared config section. */
@@ -7451,13 +7456,6 @@ const workspaceMemoryConfigSchema = z
7451
7456
  .max(1)
7452
7457
  .optional()
7453
7458
  .describe('Memory warning threshold'),
7454
- /** Staleness threshold in days. */
7455
- staleDays: z
7456
- .number()
7457
- .int()
7458
- .positive()
7459
- .optional()
7460
- .describe('Memory staleness threshold in days'),
7461
7459
  })
7462
7460
  .partial();
7463
7461
  /** Workspace config Zod schema. */
@@ -7716,6 +7714,110 @@ async function postJson(url, body) {
7716
7714
  });
7717
7715
  }
7718
7716
 
7717
+ var toolsPlatformTemplate = `### Tool Hierarchy
7718
+
7719
+ 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.
7720
+
7721
+ Use \`watcher_scan\` (no embeddings, no query string) for structural queries: file enumeration, staleness checks, domain listing, counts.
7722
+
7723
+ Direct filesystem access is for **acting on** search results, not bypassing them.
7724
+
7725
+ ### Shell Scripting
7726
+
7727
+ 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.
7728
+
7729
+ ### File Bridge for External Repos
7730
+
7731
+ 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.
7732
+
7733
+ ### Gateway Self-Destruction Warning
7734
+
7735
+ ⚠️ 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.
7736
+
7737
+ ### Messaging
7738
+
7739
+ **Same-channel replies:** Don't use the \`message\` tool. It fires immediately, jumping ahead of streaming narration. Just write text as your response.
7740
+
7741
+ **Cross-channel sends:** Use the \`message\` tool with an explicit \`target\` to send to a different channel or DM.
7742
+
7743
+ ### Plugin Lifecycle
7744
+
7745
+ \`\`\`bash
7746
+ # Platform bootstrap (content seeding)
7747
+ npx @karmaniverous/jeeves install
7748
+
7749
+ # Component plugin install
7750
+ npx @karmaniverous/jeeves-{component}-openclaw install
7751
+
7752
+ # Component plugin uninstall
7753
+ npx @karmaniverous/jeeves-{component}-openclaw uninstall
7754
+
7755
+ # Platform teardown (remove managed sections)
7756
+ npx @karmaniverous/jeeves uninstall
7757
+ \`\`\`
7758
+
7759
+ Never manually edit \`~/.openclaw/extensions/\`. Always use the CLI commands above.
7760
+
7761
+ ### Reference Templates
7762
+
7763
+ {{#if templatePath}}
7764
+ Reference templates are available at \`{{templatePath}}\`:
7765
+
7766
+ | Template | Purpose |
7767
+ |----------|---------|
7768
+ | \`spec.md\` | Skeleton for new product specifications — all section headers, decision format, dev plan format |
7769
+ | \`spec-to-code-guide.md\` | The spec-to-code development practice — 7-stage iterative process, convergence loops, release gates |
7770
+
7771
+ Read these templates when creating new specs, onboarding to new projects, or when asked about the development process.
7772
+ {{else}}
7773
+ > Reference templates not yet installed. Run \`npx @karmaniverous/jeeves install\` to seed templates.
7774
+ {{/if}}
7775
+
7776
+ ### Post-Upgrade Maintenance
7777
+
7778
+ After updating OpenClaw (\`npm install -g openclaw@latest\` or equivalent), reinstall all Jeeves component plugins to repair install state:
7779
+
7780
+ \`\`\`bash
7781
+ npx @karmaniverous/jeeves install
7782
+ npx @karmaniverous/jeeves-runner-openclaw install
7783
+ npx @karmaniverous/jeeves-watcher-openclaw install
7784
+ npx @karmaniverous/jeeves-server-openclaw install
7785
+ npx @karmaniverous/jeeves-meta-openclaw install
7786
+ \`\`\`
7787
+
7788
+ Then restart the gateway. Plugin installers copy dist files and patch config; reinstalling after an OpenClaw update ensures the extensions directory stays consistent.
7789
+
7790
+ ### Source Code Preference
7791
+
7792
+ {{#if devRepos}}
7793
+ When investigating, debugging, or analyzing Jeeves components, always read TypeScript source from dev repos — never compiled \`dist/\` from the global npm install. Dev repos:
7794
+
7795
+ | Component | Dev Repo |
7796
+ |-----------|----------|
7797
+ {{#each devRepos}}
7798
+ | {{@key}} | \`{{this}}\` |
7799
+ {{/each}}
7800
+
7801
+ Built code is minified, harder to reason about, and wastes context. Always \`git pull\` before analysis.
7802
+ {{else}}
7803
+ > Dev repo paths not configured. Add \`core.devRepos\` to \`jeeves.config.json\` to enable source code preference guidance.
7804
+ {{/if}}
7805
+ `;
7806
+
7807
+ /**
7808
+ * Internal function to maintain SOUL.md, AGENTS.md, and TOOLS.md Platform section.
7809
+ *
7810
+ * @remarks
7811
+ * Called by `ComponentWriter` on each cycle. Not directly exposed to components.
7812
+ * Reads content files from the package's `content/` directory, renders the
7813
+ * Platform template with live data, and writes managed sections using
7814
+ * `updateManagedSection`.
7815
+ */
7816
+ /** Compiled Handlebars template for the Platform section (cached at module level). */
7817
+ Handlebars.compile(toolsPlatformTemplate, {
7818
+ noEscape: true,
7819
+ });
7820
+
7719
7821
  /**
7720
7822
  * Core configuration schema and resolution.
7721
7823
  *
@@ -7812,10 +7914,10 @@ function getServiceUrl(serviceName, consumerName) {
7812
7914
  if (coreUrl)
7813
7915
  return coreUrl;
7814
7916
  // 3. Fall back to port constants
7815
- const port = DEFAULT_PORTS[serviceName];
7816
- {
7817
- return `http://127.0.0.1:${String(port)}`;
7917
+ if (serviceName in DEFAULT_PORTS) {
7918
+ return `http://127.0.0.1:${String(DEFAULT_PORTS[serviceName])}`;
7818
7919
  }
7920
+ throw new Error(`jeeves-core: unknown service "${serviceName}" and no config found`);
7819
7921
  }
7820
7922
 
7821
7923
  /**
@@ -8014,355 +8116,94 @@ function registerCustomCliCommands(program) {
8014
8116
  }
8015
8117
 
8016
8118
  /**
8017
- * Shared live config hot-reload support.
8119
+ * Compute summary statistics from an array of MetaEntry objects.
8018
8120
  *
8019
- * Used by both file-watch reloads in bootstrap and POST /config/apply
8020
- * via the component descriptor's onConfigApply callback.
8121
+ * Shared between listMetas() (full list) and route handlers (filtered lists).
8021
8122
  *
8022
- * @module configHotReload
8123
+ * @module discovery/computeSummary
8023
8124
  */
8024
8125
  /**
8025
- * Fields that require a service restart to take effect.
8126
+ * Compute summary statistics from a list of meta entries.
8026
8127
  *
8027
- * Shared between the descriptor's `onConfigApply` and the file-watcher
8028
- * 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.
8029
8131
  */
8030
- const RESTART_REQUIRED_FIELDS = [
8031
- 'port',
8032
- 'watcherUrl',
8033
- 'gatewayUrl',
8034
- 'gatewayApiKey',
8035
- 'defaultArchitect',
8036
- 'defaultCritic',
8037
- ];
8038
- let runtime = null;
8039
- /** Register the active service runtime for config-apply hot reload. */
8040
- function registerConfigHotReloadRuntime(nextRuntime) {
8041
- runtime = nextRuntime;
8042
- }
8043
- /** Apply hot-reloadable config changes to the live shared config object. */
8044
- function applyHotReloadedConfig(newConfig) {
8045
- if (!runtime)
8046
- return;
8047
- const { config, logger, scheduler } = runtime;
8048
- for (const field of RESTART_REQUIRED_FIELDS) {
8049
- const oldVal = config[field];
8050
- const nextVal = newConfig[field];
8051
- if (oldVal !== nextVal) {
8052
- logger.warn({ field, oldValue: oldVal, newValue: nextVal }, 'Config field changed but requires restart to take effect');
8053
- }
8054
- }
8055
- if (newConfig.schedule !== config.schedule) {
8056
- scheduler?.updateSchedule(newConfig.schedule);
8057
- config.schedule = newConfig.schedule;
8058
- logger.info({ schedule: newConfig.schedule }, 'Schedule hot-reloaded');
8059
- }
8060
- if (newConfig.logging.level !== config.logging.level) {
8061
- logger.level = newConfig.logging.level;
8062
- config.logging.level = newConfig.logging.level;
8063
- logger.info({ level: newConfig.logging.level }, 'Log level hot-reloaded');
8064
- }
8065
- const restartSet = new Set(RESTART_REQUIRED_FIELDS);
8066
- for (const key of Object.keys(newConfig)) {
8067
- if (restartSet.has(key) || key === 'logging' || key === 'schedule') {
8068
- 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;
8069
8164
  }
8070
- const oldVal = config[key];
8071
- const nextVal = newConfig[key];
8072
- if (JSON.stringify(oldVal) !== JSON.stringify(nextVal)) {
8073
- config[key] = nextVal;
8074
- 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;
8075
8171
  }
8076
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
+ };
8077
8189
  }
8078
8190
 
8079
8191
  /**
8080
- * Zod schema for jeeves-meta service configuration.
8192
+ * Discover .meta/ directories via watcher `/walk` endpoint.
8081
8193
  *
8082
- * 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.
8083
8196
  *
8084
- * @module schema/config
8197
+ * @module discovery/discoverMetas
8085
8198
  */
8086
- /** Zod schema for the core (library-compatible) meta configuration. */
8087
- const metaConfigSchema = z.object({
8088
- /** Watcher service base URL. */
8089
- watcherUrl: z.url(),
8090
- /** OpenClaw gateway base URL for subprocess spawning. */
8091
- gatewayUrl: z.url().default('http://127.0.0.1:18789'),
8092
- /** Optional API key for gateway authentication. */
8093
- gatewayApiKey: z.string().optional(),
8094
- /** Run architect every N cycles (per meta). */
8095
- architectEvery: z.number().int().min(1).default(10),
8096
- /** Exponent for depth weighting in staleness formula. */
8097
- depthWeight: z.number().min(0).default(0.5),
8098
- /** Maximum archive snapshots to retain per meta. */
8099
- maxArchive: z.number().int().min(1).default(20),
8100
- /** Maximum lines of context to include in subprocess prompts. */
8101
- maxLines: z.number().int().min(50).default(500),
8102
- /** Architect subprocess timeout in seconds. */
8103
- architectTimeout: z.number().int().min(30).default(180),
8104
- /** Builder subprocess timeout in seconds. */
8105
- builderTimeout: z.number().int().min(60).default(360),
8106
- /** Critic subprocess timeout in seconds. */
8107
- criticTimeout: z.number().int().min(30).default(240),
8108
- /** Thinking level for spawned synthesis sessions. */
8109
- thinking: z.string().default('low'),
8110
- /** Resolved architect system prompt text. Falls back to built-in default. */
8111
- defaultArchitect: z.string().optional(),
8112
- /** Resolved critic system prompt text. Falls back to built-in default. */
8113
- defaultCritic: z.string().optional(),
8114
- /** Skip unchanged candidates, bump _generatedAt. */
8115
- skipUnchanged: z.boolean().default(true),
8116
- /** Watcher metadata properties applied to live .meta/meta.json files. */
8117
- metaProperty: z.record(z.string(), z.unknown()).default({ _meta: 'current' }),
8118
- /** Watcher metadata properties applied to archive snapshots. */
8119
- metaArchiveProperty: z
8120
- .record(z.string(), z.unknown())
8121
- .default({ _meta: 'archive' }),
8122
- });
8123
- /** Zod schema for logging configuration. */
8124
- const loggingSchema = z.object({
8125
- /** Log level. */
8126
- level: z.string().default('info'),
8127
- /** Optional file path for log output. */
8128
- file: z.string().optional(),
8129
- });
8130
- /** Zod schema for a single auto-seed policy rule. */
8131
- const autoSeedRuleSchema = z.object({
8132
- /** Glob pattern matched against watcher walk results. */
8133
- match: z.string(),
8134
- /** Optional steering prompt for seeded metas. */
8135
- steer: z.string().optional(),
8136
- /** Optional cross-references for seeded metas. */
8137
- crossRefs: z.array(z.string()).optional(),
8138
- });
8139
- /** Zod schema for jeeves-meta service configuration (superset of MetaConfig). */
8140
- const serviceConfigSchema = metaConfigSchema.extend({
8141
- /** HTTP port for the service (default: 1938). */
8142
- port: z.number().int().min(1).max(65535).default(1938),
8143
- /** Cron schedule for synthesis cycles (default: every 30 min). */
8144
- schedule: z.string().default('*/30 * * * *'),
8145
- /** Messaging channel name (e.g. 'slack'). Legacy: also used as target if reportTarget is unset. */
8146
- reportChannel: z.string().optional(),
8147
- /** Channel/user ID to send progress messages to. */
8148
- reportTarget: z.string().optional(),
8149
- /** Optional base URL for the service, used to construct entity links in progress reports. */
8150
- serverBaseUrl: z.string().optional(),
8151
- /** Interval in ms for periodic watcher health check. 0 = disabled. Default: 60000. */
8152
- watcherHealthIntervalMs: z.number().int().min(0).default(60_000),
8153
- /** Logging configuration. */
8154
- logging: loggingSchema.default(() => loggingSchema.parse({})),
8155
- /**
8156
- * Auto-seed policy: declarative rules for auto-creating .meta/ directories.
8157
- * Rules are evaluated in order; last match wins for steer/crossRefs.
8158
- */
8159
- autoSeed: z.array(autoSeedRuleSchema).optional().default([]),
8160
- });
8161
-
8162
8199
  /**
8163
- * Load and resolve jeeves-meta service config.
8164
- *
8165
- * Supports \@file: indirection and environment-variable substitution (dollar-brace pattern).
8200
+ * Discover all .meta/ directories via watcher walk.
8166
8201
  *
8167
- * @module configLoader
8168
- */
8169
- /**
8170
- * 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.
8171
8204
  *
8172
- * @param value - Arbitrary JSON-compatible value.
8173
- * @returns Value with env-var placeholders resolved.
8174
- */
8175
- function substituteEnvVars(value) {
8176
- if (typeof value === 'string') {
8177
- return value.replace(/\$\{([^}]+)\}/g, (_match, name) => {
8178
- const envVal = process.env[name];
8179
- if (envVal === undefined) {
8180
- throw new Error(`Environment variable ${name} is not set`);
8181
- }
8182
- return envVal;
8183
- });
8184
- }
8185
- if (Array.isArray(value)) {
8186
- return value.map(substituteEnvVars);
8187
- }
8188
- if (value !== null && typeof value === 'object') {
8189
- const result = {};
8190
- for (const [key, val] of Object.entries(value)) {
8191
- result[key] = substituteEnvVars(val);
8192
- }
8193
- return result;
8194
- }
8195
- return value;
8196
- }
8197
- /**
8198
- * Resolve \@file: references in a config value.
8199
- *
8200
- * @param value - String value that may start with "\@file:".
8201
- * @param baseDir - Base directory for resolving relative paths.
8202
- * @returns The resolved string (file contents or original value).
8203
- */
8204
- function resolveFileRef(value, baseDir) {
8205
- if (!value.startsWith('@file:'))
8206
- return value;
8207
- const filePath = join(baseDir, value.slice(6));
8208
- return readFileSync(filePath, 'utf8');
8209
- }
8210
- /**
8211
- * Migrate legacy config path to the new canonical location.
8212
- *
8213
- * If the old path `{configRoot}/jeeves-meta.config.json` exists and the new
8214
- * path `{configRoot}/jeeves-meta/config.json` does NOT exist, copies the file
8215
- * to the new location and logs a warning.
8216
- *
8217
- * @param configRoot - Root directory for configuration files.
8218
- * @param warn - Optional callback for logging the migration warning.
8219
- */
8220
- function migrateConfigPath(configRoot, warn) {
8221
- const oldPath = join(configRoot, 'jeeves-meta.config.json');
8222
- const newDir = join(configRoot, 'jeeves-meta');
8223
- const newPath = join(newDir, 'config.json');
8224
- if (existsSync(oldPath) && !existsSync(newPath)) {
8225
- mkdirSync(newDir, { recursive: true });
8226
- copyFileSync(oldPath, newPath);
8227
- const message = `Migrated config from ${oldPath} to ${newPath}. The old file can be removed.`;
8228
- if (warn) {
8229
- warn(message);
8230
- }
8231
- else {
8232
- console.warn(`[jeeves-meta] ${message}`);
8233
- }
8234
- }
8235
- }
8236
- /**
8237
- * Resolve config path from --config flag or JEEVES_META_CONFIG env var.
8238
- *
8239
- * @param args - CLI arguments (process.argv.slice(2)).
8240
- * @returns Resolved config path.
8241
- * @throws If no config path found.
8242
- */
8243
- function resolveConfigPath(args) {
8244
- let configIdx = args.indexOf('--config');
8245
- if (configIdx === -1)
8246
- configIdx = args.indexOf('-c');
8247
- if (configIdx !== -1 && args[configIdx + 1]) {
8248
- return args[configIdx + 1];
8249
- }
8250
- const envPath = process.env['JEEVES_META_CONFIG'];
8251
- if (envPath)
8252
- return envPath;
8253
- throw new Error('Config path required. Use --config <path> or set JEEVES_META_CONFIG env var.');
8254
- }
8255
- /**
8256
- * Load service config from a JSON file.
8257
- *
8258
- * Resolves \@file: references for defaultArchitect and defaultCritic,
8259
- * and substitutes environment-variable placeholders throughout.
8260
- *
8261
- * @param configPath - Path to config JSON file.
8262
- * @returns Validated ServiceConfig.
8263
- */
8264
- function loadServiceConfig(configPath) {
8265
- const rawText = readFileSync(configPath, 'utf8');
8266
- const raw = substituteEnvVars(JSON.parse(rawText));
8267
- const baseDir = dirname(configPath);
8268
- if (typeof raw['defaultArchitect'] === 'string') {
8269
- raw['defaultArchitect'] = resolveFileRef(raw['defaultArchitect'], baseDir);
8270
- }
8271
- if (typeof raw['defaultCritic'] === 'string') {
8272
- raw['defaultCritic'] = resolveFileRef(raw['defaultCritic'], baseDir);
8273
- }
8274
- return serviceConfigSchema.parse(raw);
8275
- }
8276
-
8277
- /**
8278
- * Compute summary statistics from an array of MetaEntry objects.
8279
- *
8280
- * Shared between listMetas() (full list) and route handlers (filtered lists).
8281
- *
8282
- * @module discovery/computeSummary
8283
- */
8284
- /**
8285
- * Compute summary statistics from a list of meta entries.
8286
- *
8287
- * @param entries - Enriched meta entries (full or filtered).
8288
- * @param depthWeight - Config depth weight for effective staleness calculation.
8289
- * @returns Aggregated summary statistics.
8290
- */
8291
- function computeSummary(entries, depthWeight) {
8292
- let staleCount = 0;
8293
- let errorCount = 0;
8294
- let lockedCount = 0;
8295
- let disabledCount = 0;
8296
- let neverSynthesizedCount = 0;
8297
- let totalArchitectTokens = 0;
8298
- let totalBuilderTokens = 0;
8299
- let totalCriticTokens = 0;
8300
- let stalestPath = null;
8301
- let stalestEffective = -1;
8302
- let lastSynthesizedPath = null;
8303
- let lastSynthesizedAt = null;
8304
- for (const e of entries) {
8305
- if (e.stalenessSeconds > 0)
8306
- staleCount++;
8307
- if (e.hasError)
8308
- errorCount++;
8309
- if (e.locked)
8310
- lockedCount++;
8311
- if (e.disabled)
8312
- disabledCount++;
8313
- if (e.lastSynthesized === null)
8314
- neverSynthesizedCount++;
8315
- totalArchitectTokens += e.architectTokens ?? 0;
8316
- totalBuilderTokens += e.builderTokens ?? 0;
8317
- totalCriticTokens += e.criticTokens ?? 0;
8318
- // Track last synthesized
8319
- if (e.lastSynthesized &&
8320
- (!lastSynthesizedAt || e.lastSynthesized > lastSynthesizedAt)) {
8321
- lastSynthesizedAt = e.lastSynthesized;
8322
- lastSynthesizedPath = e.path;
8323
- }
8324
- // Track stalest (effective staleness for scheduling)
8325
- const depthFactor = Math.pow(1 + depthWeight, e.depth);
8326
- const effectiveStaleness = e.stalenessSeconds * depthFactor * e.emphasis;
8327
- if (effectiveStaleness > stalestEffective) {
8328
- stalestEffective = effectiveStaleness;
8329
- stalestPath = e.path;
8330
- }
8331
- }
8332
- return {
8333
- total: entries.length,
8334
- stale: staleCount,
8335
- errors: errorCount,
8336
- locked: lockedCount,
8337
- disabled: disabledCount,
8338
- neverSynthesized: neverSynthesizedCount,
8339
- tokens: {
8340
- architect: totalArchitectTokens,
8341
- builder: totalBuilderTokens,
8342
- critic: totalCriticTokens,
8343
- },
8344
- stalestPath,
8345
- lastSynthesizedPath,
8346
- lastSynthesizedAt,
8347
- };
8348
- }
8349
-
8350
- /**
8351
- * Discover .meta/ directories via watcher `/walk` endpoint.
8352
- *
8353
- * Uses filesystem enumeration through the watcher (not Qdrant) to find
8354
- * all `.meta/meta.json` files and returns deduplicated meta directory paths.
8355
- *
8356
- * @module discovery/discoverMetas
8357
- */
8358
- /**
8359
- * Discover all .meta/ directories via watcher walk.
8360
- *
8361
- * Uses the watcher's `/walk` endpoint to find all `.meta/meta.json` files
8362
- * and returns deduplicated meta directory paths.
8363
- *
8364
- * @param watcher - WatcherClient for walk queries.
8365
- * @returns Array of normalized .meta/ directory paths.
8205
+ * @param watcher - WatcherClient for walk queries.
8206
+ * @returns Array of normalized .meta/ directory paths.
8366
8207
  */
8367
8208
  async function discoverMetas(watcher) {
8368
8209
  const allPaths = await watcher.walk(['**/.meta/meta.json']);
@@ -8928,1483 +8769,928 @@ function getDeltaFiles(generatedAt, scopeFiles) {
8928
8769
  }
8929
8770
 
8930
8771
  /**
8931
- * Error thrown when a spawned subprocess is aborted via AbortController.
8772
+ * In-memory cache for listMetas results with TTL and concurrent refresh guard.
8932
8773
  *
8933
- * @module executor/SpawnAbortedError
8774
+ * @module cache
8934
8775
  */
8935
- /** Error indicating a spawn was deliberately aborted. */
8936
- class SpawnAbortedError extends Error {
8937
- constructor(message = 'Synthesis was aborted') {
8938
- super(message);
8939
- this.name = 'SpawnAbortedError';
8940
- }
8941
- }
8942
-
8776
+ const TTL_MS = 60_000;
8943
8777
  /**
8944
- * Error thrown when a spawned subprocess times out.
8945
- *
8946
- * Carries the output file path so callers can attempt partial output recovery.
8947
- *
8948
- * @module executor/SpawnTimeoutError
8778
+ * Caches listMetas results to avoid expensive repeated filesystem walks.
8779
+ * Supports concurrent refresh coalescing and manual invalidation.
8949
8780
  */
8950
- /** Error indicating a spawn timeout with a recoverable output path. */
8951
- class SpawnTimeoutError extends Error {
8952
- /** Path to the (possibly partial) output file written before timeout. */
8953
- outputPath;
8954
- constructor(message, outputPath) {
8955
- super(message);
8956
- this.name = 'SpawnTimeoutError';
8957
- 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;
8958
8809
  }
8959
8810
  }
8960
8811
 
8961
8812
  /**
8962
- * MetaExecutor implementation using the OpenClaw gateway HTTP API.
8813
+ * Shared live config hot-reload support.
8963
8814
  *
8964
- * Lives in the library package so both plugin and runner can import it.
8965
- * Spawns sub-agent sessions via the gateway's `/tools/invoke` endpoint,
8966
- * 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.
8967
8817
  *
8968
- * @module executor/GatewayExecutor
8818
+ * @module configHotReload
8969
8819
  */
8970
- const DEFAULT_POLL_INTERVAL_MS = 5000;
8971
- const DEFAULT_TIMEOUT_MS$1 = 600_000; // 10 minutes
8972
8820
  /**
8973
- * MetaExecutor that spawns OpenClaw sessions via the gateway's
8974
- * `/tools/invoke` endpoint.
8821
+ * Fields that require a service restart to take effect.
8975
8822
  *
8976
- * Used by both the OpenClaw plugin (in-process tool calls) and the
8977
- * runner/CLI (external invocation). Constructs from `gatewayUrl` and
8978
- * optional `apiKey` — typically sourced from `MetaConfig`.
8823
+ * Shared between the descriptor's `onConfigApply` and the file-watcher
8824
+ * hot-reload in `bootstrap.ts`.
8979
8825
  */
8980
- class GatewayExecutor {
8981
- gatewayUrl;
8982
- apiKey;
8983
- pollIntervalMs;
8984
- workspaceDir;
8985
- controller = new AbortController();
8986
- constructor(options = {}) {
8987
- this.gatewayUrl = (options.gatewayUrl ?? 'http://127.0.0.1:18789').replace(/\/+$/, '');
8988
- this.apiKey = options.apiKey;
8989
- this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
8990
- this.workspaceDir = options.workspaceDir ?? join(tmpdir(), 'jeeves-meta');
8991
- }
8992
- /** Remove a temp output file if it exists. */
8993
- cleanupOutputFile(outputPath) {
8994
- try {
8995
- if (existsSync(outputPath))
8996
- unlinkSync(outputPath);
8997
- }
8998
- catch {
8999
- /* best-effort cleanup */
9000
- }
9001
- }
9002
- /** Invoke a gateway tool via the /tools/invoke HTTP endpoint. */
9003
- async invoke(tool, args, sessionKey) {
9004
- const headers = {
9005
- 'Content-Type': 'application/json',
9006
- };
9007
- if (this.apiKey) {
9008
- headers['Authorization'] = 'Bearer ' + this.apiKey;
9009
- }
9010
- const body = { tool, args };
9011
- if (sessionKey)
9012
- body.sessionKey = sessionKey;
9013
- const res = await fetch(this.gatewayUrl + '/tools/invoke', {
9014
- method: 'POST',
9015
- headers,
9016
- body: JSON.stringify(body),
9017
- });
9018
- if (!res.ok) {
9019
- const text = await res.text();
9020
- throw new Error(`Gateway ${tool} failed: HTTP ${res.status.toString()} - ${text}`);
9021
- }
9022
- const data = (await res.json());
9023
- if (data.ok === false || data.error) {
9024
- throw new Error(`Gateway ${tool} error: ${data.error?.message ?? JSON.stringify(data)}`);
9025
- }
9026
- return data;
9027
- }
9028
- /** Look up totalTokens for a session via sessions_list. */
9029
- async getSessionTokens(sessionKey) {
9030
- try {
9031
- const result = await this.invoke('sessions_list', {
9032
- limit: 20,
9033
- messageLimit: 0,
9034
- });
9035
- const sessions = (result.result?.details?.sessions ??
9036
- result.result?.sessions ??
9037
- []);
9038
- const match = sessions.find((s) => s.key === sessionKey);
9039
- return match?.totalTokens ?? undefined;
9040
- }
9041
- catch {
9042
- 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');
9043
8849
  }
9044
8850
  }
9045
- /** Whether this executor has been aborted by the operator. */
9046
- get aborted() {
9047
- 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');
9048
8855
  }
9049
- /** Abort the currently running spawn, if any. */
9050
- abort() {
9051
- 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');
9052
8860
  }
9053
- async spawn(task, options) {
9054
- // Fresh controller for each spawn call
9055
- this.controller = new AbortController();
9056
- const timeoutSeconds = options?.timeout ?? DEFAULT_TIMEOUT_MS$1 / 1000;
9057
- const timeoutMs = timeoutSeconds * 1000;
9058
- const deadline = Date.now() + timeoutMs;
9059
- // Ensure workspace dir exists
9060
- if (!existsSync(this.workspaceDir)) {
9061
- mkdirSync(this.workspaceDir, { recursive: true });
9062
- }
9063
- // Generate unique output path for file-based output
9064
- const outputId = randomUUID();
9065
- const outputPath = this.workspaceDir + '/output-' + outputId + '.json';
9066
- // Append file output instruction to the task
9067
- const taskWithOutput = task +
9068
- '\n\n## OUTPUT DELIVERY\n\n' +
9069
- 'Write your complete output to a file using the Write tool at:\n' +
9070
- outputPath +
9071
- '\n\n' +
9072
- 'Reply with ONLY the file path you wrote to. No other text.';
9073
- // Step 1: Spawn the sub-agent session (unique label per cycle to avoid
9074
- // "label already in use" errors — gateway labels persist after session completion)
9075
- const labelBase = options?.label ?? 'jeeves-meta-synthesis';
9076
- const label = labelBase + '-' + outputId.slice(0, 8);
9077
- const spawnResult = await this.invoke('sessions_spawn', {
9078
- task: taskWithOutput,
9079
- label,
9080
- runTimeoutSeconds: timeoutSeconds,
9081
- ...(options?.thinking ? { thinking: options.thinking } : {}),
9082
- ...(options?.model ? { model: options.model } : {}),
9083
- });
9084
- const details = (spawnResult.result?.details ?? spawnResult.result);
9085
- const sessionKey = details?.childSessionKey ?? details?.sessionKey;
9086
- if (typeof sessionKey !== 'string' || !sessionKey) {
9087
- throw new Error('Gateway sessions_spawn returned no sessionKey: ' +
9088
- 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;
9089
8865
  }
9090
- // Step 2: Poll for completion via sessions_history
9091
- await sleepAsync(3000);
9092
- while (Date.now() < deadline) {
9093
- // Check for abort before each poll iteration
9094
- if (this.controller.signal.aborted) {
9095
- this.cleanupOutputFile(outputPath);
9096
- throw new SpawnAbortedError();
9097
- }
9098
- try {
9099
- const historyResult = await this.invoke('sessions_history', {
9100
- sessionKey,
9101
- limit: 5,
9102
- includeTools: false,
9103
- });
9104
- const messages = historyResult.result?.details?.messages ??
9105
- historyResult.result?.messages ??
9106
- [];
9107
- const msgArray = messages;
9108
- if (msgArray.length > 0) {
9109
- const lastMsg = msgArray[msgArray.length - 1];
9110
- // Complete when last message is assistant with a terminal stop reason
9111
- if (lastMsg.role === 'assistant' &&
9112
- lastMsg.stopReason &&
9113
- lastMsg.stopReason !== 'toolUse' &&
9114
- lastMsg.stopReason !== 'error') {
9115
- // Fetch token usage from session metadata
9116
- const tokens = await this.getSessionTokens(sessionKey);
9117
- // Read output from file (sub-agent wrote it via Write tool)
9118
- if (existsSync(outputPath)) {
9119
- try {
9120
- const output = readFileSync(outputPath, 'utf8');
9121
- return { output, tokens };
9122
- }
9123
- finally {
9124
- try {
9125
- unlinkSync(outputPath);
9126
- }
9127
- catch {
9128
- /* cleanup best-effort */
9129
- }
9130
- }
9131
- }
9132
- // Fallback: extract from message content if file wasn't written
9133
- for (let i = msgArray.length - 1; i >= 0; i--) {
9134
- const msg = msgArray[i];
9135
- if (msg.role === 'assistant' && msg.content) {
9136
- const text = typeof msg.content === 'string'
9137
- ? msg.content
9138
- : Array.isArray(msg.content)
9139
- ? msg.content
9140
- .filter((b) => b.type === 'text' && b.text)
9141
- .map((b) => b.text)
9142
- .join('\n')
9143
- : '';
9144
- if (text)
9145
- return { output: text, tokens };
9146
- }
9147
- }
9148
- return { output: '', tokens };
9149
- }
9150
- }
9151
- }
9152
- catch {
9153
- // Transient poll failure — keep trying
9154
- }
9155
- 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');
9156
8871
  }
9157
- throw new SpawnTimeoutError('Synthesis subprocess timed out after ' + timeoutMs.toString() + 'ms', outputPath);
9158
8872
  }
9159
8873
  }
9160
8874
 
9161
8875
  /**
9162
- * Pino logger factory.
8876
+ * Zod schema for jeeves-meta service configuration.
9163
8877
  *
9164
- * @module logger
9165
- */
9166
- /**
9167
- * Create a pino logger instance.
8878
+ * The service config is a strict superset of the core (library-compatible) meta config.
9168
8879
  *
9169
- * @param config - Optional logger configuration.
9170
- * @returns Configured pino logger.
8880
+ * @module schema/config
9171
8881
  */
9172
- function createLogger(config) {
9173
- const level = config?.level ?? 'info';
9174
- if (config?.file) {
9175
- const transport = pino.transport({
9176
- target: 'pino/file',
9177
- options: { destination: config.file, mkdir: true },
9178
- });
9179
- return pino({ level }, transport);
9180
- }
9181
- return pino({ level });
9182
- }
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
+ });
9183
8957
 
9184
8958
  /**
9185
- * Built-in default prompts for the synthesis pipeline.
8959
+ * Load and resolve jeeves-meta service config.
9186
8960
  *
9187
- * Prompts ship as .md files bundled into dist/prompts/ via rollup-plugin-copy.
9188
- * Loaded at runtime relative to the compiled module location.
9189
- *
9190
- * Users can override via `defaultArchitect` / `defaultCritic` in the service
9191
- * config. Most installations should use the built-in defaults.
9192
- *
9193
- * @module prompts
9194
- */
9195
- const packageRoot = packageDirectorySync({
9196
- cwd: fileURLToPath(import.meta.url),
9197
- });
9198
- const promptDir = join(packageRoot, 'dist', 'prompts');
9199
- /** Built-in default architect prompt. */
9200
- const DEFAULT_ARCHITECT_PROMPT = readFileSync(join(promptDir, 'architect.md'), 'utf8');
9201
- /** Built-in default critic prompt. */
9202
- const DEFAULT_CRITIC_PROMPT = readFileSync(join(promptDir, 'critic.md'), 'utf8');
9203
-
9204
- /**
9205
- * Build the MetaContext for a synthesis cycle.
9206
- *
9207
- * Computes shared inputs once: scope files, delta files, child meta outputs,
9208
- * previous content/feedback, steer, and archive paths.
9209
- *
9210
- * @module orchestrator/contextPackage
9211
- */
9212
- /**
9213
- * Condense a file list into glob-like summaries.
9214
- * Groups by directory + extension pattern.
9215
- *
9216
- * @param files - Array of file paths.
9217
- * @param maxIndividual - Show individual files up to this count.
9218
- * @returns Condensed summary string.
9219
- */
9220
- function condenseScopeFiles(files, maxIndividual = 30) {
9221
- if (files.length <= maxIndividual)
9222
- return files.join('\n');
9223
- // Group by dir + extension
9224
- const groups = new Map();
9225
- for (const f of files) {
9226
- const dir = f.substring(0, f.lastIndexOf('/') + 1) || './';
9227
- const ext = f.includes('.') ? f.substring(f.lastIndexOf('.')) : '(no ext)';
9228
- const key = dir + '*' + ext;
9229
- groups.set(key, (groups.get(key) ?? 0) + 1);
9230
- }
9231
- // Sort by count descending
9232
- const sorted = [...groups.entries()].sort((a, b) => b[1] - a[1]);
9233
- return sorted
9234
- .map(([pattern, count]) => pattern + ' (' + count.toString() + ' files)')
9235
- .join('\n');
9236
- }
9237
- /**
9238
- * Read a meta.json file and extract its `_content` field.
9239
- *
9240
- * @param metaJsonPath - Absolute path to a meta.json file.
9241
- * @returns The `_content` string, or null if missing/unreadable.
9242
- */
9243
- async function readMetaContent(metaJsonPath) {
9244
- try {
9245
- const raw = await readFile(metaJsonPath, 'utf8');
9246
- const meta = JSON.parse(raw);
9247
- return meta._content ?? null;
9248
- }
9249
- catch {
9250
- return null;
9251
- }
9252
- }
9253
- /**
9254
- * Build the context package for a synthesis cycle.
9255
- *
9256
- * @param node - The meta node being synthesized.
9257
- * @param meta - Current meta.json content.
9258
- * @param watcher - WatcherClient for scope enumeration.
9259
- * @returns The computed context package.
9260
- */
9261
- async function buildContextPackage(node, meta, watcher, logger) {
9262
- // Scope and delta files via watcher walk
9263
- const scopeStart = Date.now();
9264
- const { scopeFiles } = await getScopeFiles(node, watcher, logger);
9265
- const deltaFiles = getDeltaFiles(meta._generatedAt, scopeFiles);
9266
- logger?.debug({
9267
- scopeFiles: scopeFiles.length,
9268
- deltaFiles: deltaFiles.length,
9269
- durationMs: Date.now() - scopeStart,
9270
- }, 'scope and delta files computed');
9271
- // Child meta outputs (parallel reads)
9272
- const childMetas = {};
9273
- const childEntries = await Promise.all(node.children.map(async (child) => {
9274
- const content = await readMetaContent(join(child.metaPath, 'meta.json'));
9275
- return [child.ownerPath, content];
9276
- }));
9277
- for (const [path, content] of childEntries) {
9278
- childMetas[path] = content;
9279
- }
9280
- // Cross-referenced meta outputs (parallel reads)
9281
- const crossRefMetas = {};
9282
- const seen = new Set();
9283
- const crossRefPaths = [];
9284
- for (const refPath of meta._crossRefs ?? []) {
9285
- if (refPath === node.ownerPath || refPath === node.metaPath)
9286
- continue;
9287
- if (seen.has(refPath))
9288
- continue;
9289
- seen.add(refPath);
9290
- crossRefPaths.push(refPath);
9291
- }
9292
- const crossRefEntries = await Promise.all(crossRefPaths.map(async (refPath) => {
9293
- const content = await readMetaContent(join(refPath, '.meta', 'meta.json'));
9294
- return [refPath, content];
9295
- }));
9296
- for (const [path, content] of crossRefEntries) {
9297
- crossRefMetas[path] = content;
9298
- }
9299
- // Archive paths
9300
- const archives = listArchiveFiles(node.metaPath);
9301
- return {
9302
- path: node.metaPath,
9303
- scopeFiles,
9304
- deltaFiles,
9305
- childMetas,
9306
- crossRefMetas,
9307
- previousContent: meta._content ?? null,
9308
- previousFeedback: meta._feedback ?? null,
9309
- steer: meta._steer ?? null,
9310
- previousState: meta._state ?? null,
9311
- archives,
9312
- };
9313
- }
9314
-
9315
- /**
9316
- * Build task prompts for each synthesis step.
9317
- *
9318
- * Prompts are compiled as Handlebars templates with access to config,
9319
- * meta, and scope context. The architect can write template expressions
9320
- * into its _builder output; these resolve when the builder task is compiled.
8961
+ * Supports \@file: indirection and environment-variable substitution (dollar-brace pattern).
9321
8962
  *
9322
- * @module orchestrator/buildTask
8963
+ * @module configLoader
9323
8964
  */
9324
- /** Build the template context from synthesis inputs. */
9325
- function buildTemplateContext(ctx, meta, config) {
9326
- return {
9327
- config,
9328
- meta,
9329
- scope: {
9330
- fileCount: ctx.scopeFiles.length,
9331
- deltaCount: ctx.deltaFiles.length,
9332
- childCount: Object.keys(ctx.childMetas).length,
9333
- crossRefCount: Object.keys(ctx.crossRefMetas).length,
9334
- },
9335
- };
9336
- }
9337
8965
  /**
9338
- * Compile a string as a Handlebars template with the given context.
9339
- * Returns the original string unchanged if compilation fails.
9340
- */
9341
- function compileTemplate(text, context) {
9342
- try {
9343
- return Handlebars.compile(text, { noEscape: true })(context);
9344
- }
9345
- catch {
9346
- return text;
9347
- }
9348
- }
9349
- /** Append a keyed record of meta outputs as subsections, if non-empty. */
9350
- function appendMetaSections(sections, heading, metas) {
9351
- if (Object.keys(metas).length === 0)
9352
- return;
9353
- sections.push('', heading);
9354
- for (const [path, content] of Object.entries(metas)) {
9355
- sections.push(`### ${path}`, typeof content === 'string' ? content : '(not yet synthesized)');
9356
- }
9357
- }
9358
- /** Append optional context sections shared across all step prompts. */
9359
- function appendSharedSections(sections, ctx, options) {
9360
- const opts = {
9361
- includeSteer: true,
9362
- includePreviousContent: true,
9363
- includePreviousFeedback: true,
9364
- feedbackHeading: '## PREVIOUS FEEDBACK',
9365
- includeChildMetas: true,
9366
- includeCrossRefs: true,
9367
- ...options,
9368
- };
9369
- if (opts.includeSteer && ctx.steer) {
9370
- sections.push('', '## STEERING PROMPT', ctx.steer);
9371
- }
9372
- if (opts.includePreviousContent && ctx.previousContent) {
9373
- sections.push('', '## PREVIOUS SYNTHESIS', ctx.previousContent);
9374
- }
9375
- if (opts.includePreviousFeedback && ctx.previousFeedback) {
9376
- sections.push('', opts.feedbackHeading, ctx.previousFeedback);
9377
- }
9378
- if (opts.includeChildMetas) {
9379
- appendMetaSections(sections, '## CHILD META OUTPUTS', ctx.childMetas);
9380
- }
9381
- if (opts.includeCrossRefs) {
9382
- appendMetaSections(sections, '## CROSS-REFERENCED METAS', ctx.crossRefMetas);
9383
- }
9384
- }
9385
- /**
9386
- * Build the architect task prompt.
8966
+ * Deep-walk a value, replacing `\${VAR\}` patterns with process.env values.
9387
8967
  *
9388
- * @param ctx - Synthesis context.
9389
- * @param meta - Current meta.json.
9390
- * @param config - Synthesis config.
9391
- * @returns The architect task prompt string.
8968
+ * @param value - Arbitrary JSON-compatible value.
8969
+ * @returns Value with env-var placeholders resolved.
9392
8970
  */
9393
- function buildArchitectTask(ctx, meta, config) {
9394
- const sections = [
9395
- `# jeeves-meta · ARCHITECT · ${ctx.path}`,
9396
- '',
9397
- meta._architect ?? config.defaultArchitect ?? DEFAULT_ARCHITECT_PROMPT,
9398
- '',
9399
- '## SCOPE',
9400
- `Path: ${ctx.path}`,
9401
- `Total files in scope: ${ctx.scopeFiles.length.toString()}`,
9402
- `Files changed since last synthesis: ${ctx.deltaFiles.length.toString()}`,
9403
- '',
9404
- '### File listing (scope)',
9405
- condenseScopeFiles(ctx.scopeFiles),
9406
- ];
9407
- // Inject previous _builder so architect can see its own prior output
9408
- if (meta._builder) {
9409
- 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
+ });
9410
8980
  }
9411
- appendSharedSections(sections, ctx);
9412
- if (ctx.archives.length > 0) {
9413
- 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);
9414
8983
  }
9415
- return compileTemplate(sections.join('\n'), buildTemplateContext(ctx, meta, config));
9416
- }
9417
- /**
9418
- * Build the builder task prompt.
9419
- *
9420
- * @param ctx - Synthesis context.
9421
- * @param meta - Current meta.json.
9422
- * @param config - Synthesis config.
9423
- * @returns The builder task prompt string.
9424
- */
9425
- function buildBuilderTask(ctx, meta, config) {
9426
- const sections = [
9427
- `# jeeves-meta · BUILDER · ${ctx.path}`,
9428
- '',
9429
- '## TASK BRIEF (from Architect)',
9430
- meta._builder ?? '(No architect brief available)',
9431
- '',
9432
- '## SCOPE',
9433
- `Path: ${ctx.path}`,
9434
- `Delta files (${ctx.deltaFiles.length.toString()} changed):`,
9435
- ...ctx.deltaFiles.slice(0, config.maxLines).map((f) => `- ${f}`),
9436
- ];
9437
- if (ctx.previousState != null) {
9438
- 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;
9439
8990
  }
9440
- appendSharedSections(sections, ctx, {
9441
- includeSteer: false,
9442
- feedbackHeading: '## FEEDBACK FROM CRITIC',
9443
- });
9444
- 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.');
9445
- return compileTemplate(sections.join('\n'), buildTemplateContext(ctx, meta, config));
9446
- }
9447
- /**
9448
- * Build the critic task prompt.
9449
- *
9450
- * @param ctx - Synthesis context.
9451
- * @param meta - Current meta.json (with _content already set by builder).
9452
- * @param config - Synthesis config.
9453
- * @returns The critic task prompt string.
9454
- */
9455
- function buildCriticTask(ctx, meta, config) {
9456
- const sections = [
9457
- `# jeeves-meta · CRITIC · ${ctx.path}`,
9458
- '',
9459
- meta._critic ?? config.defaultCritic ?? DEFAULT_CRITIC_PROMPT,
9460
- '',
9461
- '## SYNTHESIS TO EVALUATE',
9462
- meta._content ?? '(No content produced)',
9463
- '',
9464
- '## SCOPE',
9465
- `Path: ${ctx.path}`,
9466
- `Files in scope: ${ctx.scopeFiles.length.toString()}`,
9467
- ];
9468
- appendSharedSections(sections, ctx, {
9469
- includePreviousContent: false,
9470
- feedbackHeading: '## YOUR PREVIOUS FEEDBACK',
9471
- includeChildMetas: false,
9472
- includeCrossRefs: false,
9473
- });
9474
- sections.push('', '## OUTPUT FORMAT', 'Return your evaluation as Markdown text. Be specific and actionable.');
9475
- return compileTemplate(sections.join('\n'), buildTemplateContext(ctx, meta, config));
8991
+ return value;
9476
8992
  }
9477
-
9478
8993
  /**
9479
- * Exponential moving average helper for token tracking.
9480
- *
9481
- * @module ema
9482
- */
9483
- const DEFAULT_DECAY = 0.3;
9484
- /**
9485
- * Compute exponential moving average.
8994
+ * Resolve \@file: references in a config value.
9486
8995
  *
9487
- * @param current - New observation.
9488
- * @param previous - Previous EMA value, or undefined for first observation.
9489
- * @param decay - Decay factor (0-1). Higher = more weight on new value. Default 0.3.
9490
- * @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).
9491
8999
  */
9492
- function computeEma(current, previous, decay = DEFAULT_DECAY) {
9493
- if (previous === undefined)
9494
- return current;
9495
- 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');
9496
9005
  }
9497
-
9498
- /**
9499
- * Structured error from a synthesis step failure.
9500
- *
9501
- * @module schema/error
9502
- */
9503
- /** Zod schema for synthesis step errors. */
9504
- const metaErrorSchema = z.object({
9505
- /** Which step failed: 'architect', 'builder', or 'critic'. */
9506
- step: z.enum(['architect', 'builder', 'critic']),
9507
- /** Error classification code. */
9508
- code: z.string(),
9509
- /** Human-readable error message. */
9510
- message: z.string(),
9511
- });
9512
-
9513
9006
  /**
9514
- * Zod schema for .meta/meta.json files.
9007
+ * Migrate legacy config path to the new canonical location.
9515
9008
  *
9516
- * Reserved properties are underscore-prefixed and engine-managed.
9517
- * 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.
9518
9012
  *
9519
- * @module schema/meta
9520
- */
9521
- /** Valid states for a synthesis phase. */
9522
- const phaseStatuses = [
9523
- 'fresh',
9524
- 'stale',
9525
- 'pending',
9526
- 'running',
9527
- 'failed',
9528
- ];
9529
- /** Zod schema for a per-phase status value. */
9530
- const phaseStatusSchema = z.enum(phaseStatuses);
9531
- /** Zod schema for the per-meta phase state record. */
9532
- const phaseStateSchema = z.object({
9533
- architect: phaseStatusSchema,
9534
- builder: phaseStatusSchema,
9535
- critic: phaseStatusSchema,
9536
- });
9537
- /** Zod schema for the reserved (underscore-prefixed) meta.json properties. */
9538
- const metaJsonSchema = z
9539
- .object({
9540
- /** Stable identity. Auto-generated on first synthesis if not provided. */
9541
- _id: z.uuid().optional(),
9542
- /** Human-provided steering prompt. Optional. */
9543
- _steer: z.string().optional(),
9544
- /**
9545
- * Explicit cross-references to other meta owner paths.
9546
- * Referenced metas' _content is included as architect/builder context.
9547
- */
9548
- _crossRefs: z.array(z.string()).optional(),
9549
- /** Architect system prompt used this turn. Defaults from config. */
9550
- _architect: z.string().optional(),
9551
- /**
9552
- * Task brief generated by the architect. Cached and reused across cycles;
9553
- * regenerated only when triggered.
9554
- */
9555
- _builder: z.string().optional(),
9556
- /** Critic system prompt used this turn. Defaults from config. */
9557
- _critic: z.string().optional(),
9558
- /** Timestamp of last synthesis. ISO 8601. */
9559
- _generatedAt: z.iso.datetime().optional(),
9560
- /** Narrative synthesis output. Rendered by watcher for embedding. */
9561
- _content: z.string().optional(),
9562
- /**
9563
- * Hash of sorted file listing in scope. Detects directory structure
9564
- * changes that trigger an architect re-run.
9565
- */
9566
- _structureHash: z.string().optional(),
9567
- /**
9568
- * Cycles since last architect run. Reset to 0 when architect runs.
9569
- * Used with architectEvery to trigger periodic re-prompting.
9570
- */
9571
- _synthesisCount: z.number().int().min(0).optional(),
9572
- /** Critic evaluation of the last synthesis. */
9573
- _feedback: z.string().optional(),
9574
- /**
9575
- * Present and true on archive snapshots. Distinguishes live vs. archived
9576
- * metas.
9577
- */
9578
- _archived: z.boolean().optional(),
9579
- /** Timestamp when this snapshot was archived. ISO 8601. */
9580
- _archivedAt: z.iso.datetime().optional(),
9581
- /**
9582
- * Scheduling priority. Higher = updates more often. Negative allowed;
9583
- * normalized to min 0 at scheduling time.
9584
- */
9585
- _depth: z.number().optional(),
9586
- /**
9587
- * Emphasis multiplier for depth weighting in scheduling.
9588
- * Default 1. Higher values increase this meta's scheduling priority
9589
- * relative to its depth. Set to 0.5 to halve the depth effect,
9590
- * 2 to double it, 0 to ignore depth entirely for this meta.
9591
- */
9592
- _emphasis: z.number().min(0).optional(),
9593
- /** Token count from last architect subprocess call. */
9594
- _architectTokens: z.number().int().optional(),
9595
- /** Token count from last builder subprocess call. */
9596
- _builderTokens: z.number().int().optional(),
9597
- /** Token count from last critic subprocess call. */
9598
- _criticTokens: z.number().int().optional(),
9599
- /** Exponential moving average of architect token usage (decay 0.3). */
9600
- _architectTokensAvg: z.number().optional(),
9601
- /** Exponential moving average of builder token usage (decay 0.3). */
9602
- _builderTokensAvg: z.number().optional(),
9603
- /** Exponential moving average of critic token usage (decay 0.3). */
9604
- _criticTokensAvg: z.number().optional(),
9605
- /**
9606
- * Opaque state carried across synthesis cycles for progressive work.
9607
- * Set by the builder, passed back as context on next cycle.
9608
- */
9609
- _state: z.unknown().optional(),
9610
- /**
9611
- * Structured error from last cycle. Present when a step failed.
9612
- * Cleared on successful cycle.
9613
- */
9614
- _error: metaErrorSchema.optional(),
9615
- /** When true, this meta is skipped during staleness scheduling. Manual trigger still works. */
9616
- _disabled: z.boolean().optional(),
9617
- /**
9618
- * Per-phase state machine record. Engine-managed.
9619
- * Keyed by phase name (architect, builder, critic) with status values.
9620
- * Persisted to survive ticks; derived on first load for back-compat.
9621
- */
9622
- _phaseState: phaseStateSchema.optional(),
9623
- })
9624
- .loose();
9625
-
9013
+ * @param configRoot - Root directory for configuration files.
9014
+ * @param warn - Optional callback for logging the migration warning.
9015
+ */
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
+ }
9626
9051
  /**
9627
- * Merge synthesis results into meta.json.
9052
+ * Load service config from a JSON file.
9628
9053
  *
9629
- * Preserves human-set fields (_id, _steer, _depth).
9630
- * Writes engine fields (_generatedAt, _structureHash, etc.).
9631
- * Validates against schema before writing.
9054
+ * Resolves \@file: references for defaultArchitect and defaultCritic,
9055
+ * and substitutes environment-variable placeholders throughout.
9632
9056
  *
9633
- * @module orchestrator/merge
9057
+ * @param configPath - Path to config JSON file.
9058
+ * @returns Validated ServiceConfig.
9634
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
+
9635
9073
  /**
9636
- * Merge results into meta.json and write atomically.
9074
+ * Error thrown when a spawned subprocess is aborted via AbortController.
9637
9075
  *
9638
- * @param options - Merge options.
9639
- * @returns The updated MetaJson.
9640
- * @throws If validation fails (malformed output).
9076
+ * @module executor/SpawnAbortedError
9641
9077
  */
9642
- async function mergeAndWrite(options) {
9643
- const merged = {
9644
- // Preserve human-set fields (auto-generate _id on first synthesis)
9645
- _id: options.current._id ?? randomUUID(),
9646
- _steer: options.current._steer,
9647
- _depth: options.current._depth,
9648
- _emphasis: options.current._emphasis,
9649
- // Engine fields
9650
- _architect: options.architect,
9651
- _builder: options.builder,
9652
- _critic: options.critic,
9653
- _generatedAt: options.stateOnly
9654
- ? options.current._generatedAt
9655
- : new Date().toISOString(),
9656
- _structureHash: options.structureHash,
9657
- _synthesisCount: options.synthesisCount,
9658
- // Token tracking
9659
- _architectTokens: options.architectTokens,
9660
- _builderTokens: options.builderTokens,
9661
- _criticTokens: options.criticTokens,
9662
- _architectTokensAvg: options.architectTokens !== undefined
9663
- ? computeEma(options.architectTokens, options.current._architectTokensAvg)
9664
- : options.current._architectTokensAvg,
9665
- _builderTokensAvg: options.builderTokens !== undefined
9666
- ? computeEma(options.builderTokens, options.current._builderTokensAvg)
9667
- : options.current._builderTokensAvg,
9668
- _criticTokensAvg: options.criticTokens !== undefined
9669
- ? computeEma(options.criticTokens, options.current._criticTokensAvg)
9670
- : options.current._criticTokensAvg,
9671
- // Content from builder (stateOnly preserves previous content)
9672
- _content: options.stateOnly
9673
- ? options.current._content
9674
- : (options.builderOutput?.content ?? options.current._content),
9675
- // Feedback from critic
9676
- _feedback: options.feedback ?? options.current._feedback,
9677
- // Progressive state
9678
- _state: options.state,
9679
- // Error handling
9680
- _error: options.error ?? undefined,
9681
- // Phase state machine
9682
- _phaseState: options.phaseState,
9683
- // Spread structured fields from builder
9684
- ...options.builderOutput?.fields,
9685
- };
9686
- // Clean up undefined optional fields
9687
- if (merged._steer === undefined)
9688
- delete merged._steer;
9689
- if (merged._depth === undefined)
9690
- delete merged._depth;
9691
- if (merged._emphasis === undefined)
9692
- delete merged._emphasis;
9693
- if (merged._architectTokens === undefined)
9694
- delete merged._architectTokens;
9695
- if (merged._builderTokens === undefined)
9696
- delete merged._builderTokens;
9697
- if (merged._criticTokens === undefined)
9698
- delete merged._criticTokens;
9699
- if (merged._architectTokensAvg === undefined)
9700
- delete merged._architectTokensAvg;
9701
- if (merged._builderTokensAvg === undefined)
9702
- delete merged._builderTokensAvg;
9703
- if (merged._criticTokensAvg === undefined)
9704
- delete merged._criticTokensAvg;
9705
- if (merged._state === undefined)
9706
- delete merged._state;
9707
- if (merged._error === undefined)
9708
- delete merged._error;
9709
- if (merged._content === undefined)
9710
- delete merged._content;
9711
- if (merged._feedback === undefined)
9712
- delete merged._feedback;
9713
- if (merged._phaseState === undefined)
9714
- delete merged._phaseState;
9715
- // Validate
9716
- const result = metaJsonSchema.safeParse(merged);
9717
- if (!result.success) {
9718
- 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';
9719
9083
  }
9720
- // Write to specified path (lock staging) or default meta.json
9721
- const filePath = options.outputPath ?? join(options.metaPath, 'meta.json');
9722
- await writeFile(filePath, JSON.stringify(result.data, null, 2) + '\n');
9723
- return result.data;
9724
9084
  }
9725
9085
 
9726
9086
  /**
9727
- * Build a minimal MetaNode from a known meta path using watcher walk.
9087
+ * Error thrown when a spawned subprocess times out.
9728
9088
  *
9729
- * Used for targeted synthesis (when a specific path is requested) to avoid
9730
- * the full discovery + ownership tree build. Discovers only immediate child
9731
- * `.meta/` directories.
9089
+ * Carries the output file path so callers can attempt partial output recovery.
9732
9090
  *
9733
- * @module discovery/buildMinimalNode
9091
+ * @module executor/SpawnTimeoutError
9734
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
+
9735
9104
  /**
9736
- * Build a minimal MetaNode for a known meta path.
9105
+ * MetaExecutor implementation using the OpenClaw gateway HTTP API.
9737
9106
  *
9738
- * Walks the owner directory for child `.meta/meta.json` files and constructs
9739
- * 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.
9740
9110
  *
9741
- * @param metaPath - Absolute path to the `.meta/` directory.
9742
- * @param watcher - WatcherClient for filesystem enumeration.
9743
- * @returns MetaNode with direct children wired.
9111
+ * @module executor/GatewayExecutor
9744
9112
  */
9745
- async function buildMinimalNode(metaPath, watcher) {
9746
- const normalized = normalizePath(metaPath);
9747
- const ownerPath = posix.dirname(normalized);
9748
- // Find child metas using watcher walk.
9749
- // We include only *direct* children (nearest descendants in the ownership tree)
9750
- // to match the ownership semantics used elsewhere.
9751
- const rawMetaJsonPaths = await watcher.walk([
9752
- `${escapeGlob(ownerPath)}/**/.meta/meta.json`,
9753
- ]);
9754
- const candidateMetaPaths = [
9755
- ...new Set(rawMetaJsonPaths.map((p) => posix.dirname(normalizePath(p)))),
9756
- ].filter((p) => p !== normalized);
9757
- const candidates = candidateMetaPaths
9758
- .map((mp) => ({ metaPath: mp, ownerPath: posix.dirname(mp) }))
9759
- .sort((a, b) => a.ownerPath.length - b.ownerPath.length);
9760
- const directChildren = [];
9761
- for (const c of candidates) {
9762
- const nestedUnderExisting = directChildren.some((d) => c.ownerPath === d.ownerPath ||
9763
- c.ownerPath.startsWith(d.ownerPath + '/'));
9764
- if (!nestedUnderExisting)
9765
- 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');
9766
9134
  }
9767
- const children = directChildren.map((c) => ({
9768
- metaPath: c.metaPath,
9769
- ownerPath: c.ownerPath,
9770
- treeDepth: 1,
9771
- children: [],
9772
- parent: null,
9773
- }));
9774
- const node = {
9775
- metaPath: normalized,
9776
- ownerPath,
9777
- treeDepth: 0,
9778
- children,
9779
- parent: null,
9780
- };
9781
- for (const child of children) {
9782
- 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);
9783
9314
  }
9784
- return node;
9785
9315
  }
9786
9316
 
9787
9317
  /**
9788
- * Weighted staleness formula for candidate selection.
9318
+ * Pino logger factory.
9789
9319
  *
9790
- * effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
9320
+ * @module logger
9321
+ */
9322
+ /**
9323
+ * Create a pino logger instance.
9791
9324
  *
9792
- * @module scheduling/weightedFormula
9325
+ * @param config - Optional logger configuration.
9326
+ * @returns Configured pino logger.
9793
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
+
9794
9340
  /**
9795
- * Compute effective staleness for a set of candidates.
9341
+ * Built-in default prompts for the synthesis pipeline.
9796
9342
  *
9797
- * Normalizes depths so the minimum becomes 0, then applies the formula:
9798
- * 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.
9799
9345
  *
9800
- * Per-meta _emphasis (default 1) multiplies depthWeight, allowing individual
9801
- * 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.
9802
9348
  *
9803
- * @param candidates - Array of \{ node, meta, actualStaleness \}.
9804
- * @param depthWeight - Exponent for depth weighting (0 = pure staleness).
9805
- * @returns Same array with effectiveStaleness computed.
9349
+ * @module prompts
9806
9350
  */
9807
- function computeEffectiveStaleness(candidates, depthWeight) {
9808
- if (candidates.length === 0)
9809
- return [];
9810
- // Get depth for each candidate: use _depth override or tree depth
9811
- const depths = candidates.map((c) => c.meta._depth ?? c.node.treeDepth);
9812
- // Normalize: shift so minimum becomes 0
9813
- const minDepth = Math.min(...depths);
9814
- const normalizedDepths = depths.map((d) => Math.max(0, d - minDepth));
9815
- return candidates.map((c, i) => {
9816
- const emphasis = c.meta._emphasis ?? 1;
9817
- return {
9818
- ...c,
9819
- effectiveStaleness: c.actualStaleness *
9820
- Math.pow(normalizedDepths[i] + 1, depthWeight * emphasis),
9821
- };
9822
- });
9823
- }
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');
9824
9359
 
9825
9360
  /**
9826
- * Select the best synthesis candidate from stale metas.
9361
+ * Build the MetaContext for a synthesis cycle.
9827
9362
  *
9828
- * 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.
9829
9365
  *
9830
- * @module scheduling/selectCandidate
9366
+ * @module orchestrator/contextPackage
9831
9367
  */
9832
9368
  /**
9833
- * Select the candidate with the highest effective staleness.
9369
+ * Condense a file list into glob-like summaries.
9370
+ * Groups by directory + extension pattern.
9834
9371
  *
9835
- * @param candidates - Array of candidates with computed effective staleness.
9836
- * @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.
9837
9375
  */
9838
- function selectCandidate(candidates) {
9839
- if (candidates.length === 0)
9840
- return null;
9841
- let best = candidates[0];
9842
- for (let i = 1; i < candidates.length; i++) {
9843
- if (candidates[i].effectiveStaleness > best.effectiveStaleness) {
9844
- best = candidates[i];
9845
- }
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);
9846
9386
  }
9847
- 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');
9848
9392
  }
9849
9393
  /**
9850
- * Extract stale candidates from a list and return the stalest path.
9851
- *
9852
- * Consolidates the repeated pattern of:
9853
- * filter → computeEffectiveStaleness → selectCandidate → return path
9394
+ * Read a meta.json file and extract its `_content` field.
9854
9395
  *
9855
- * @param candidates - Array with node, meta, and stalenessSeconds.
9856
- * @param depthWeight - Depth weighting exponent from config.
9857
- * @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.
9858
9398
  */
9859
- function discoverStalestPath(candidates, depthWeight) {
9860
- const weighted = computeEffectiveStaleness(candidates, depthWeight);
9861
- const winner = selectCandidate(weighted);
9862
- 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
+ }
9863
9408
  }
9864
-
9865
- /**
9866
- * Shared error utilities.
9867
- *
9868
- * @module errors
9869
- */
9870
9409
  /**
9871
- * Wrap an unknown caught value into a MetaError.
9410
+ * Build the context package for a synthesis cycle.
9872
9411
  *
9873
- * @param step - Which synthesis step failed.
9874
- * @param err - The caught error value.
9875
- * @param code - Error classification code.
9876
- * @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.
9877
9416
  */
9878
- 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);
9879
9457
  return {
9880
- step,
9881
- code,
9882
- 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,
9883
9468
  };
9884
9469
  }
9885
9470
 
9886
9471
  /**
9887
- * Compute a structure hash from a sorted file listing.
9888
- *
9889
- * Used to detect when directory structure changes, triggering
9890
- * an architect re-run.
9891
- *
9892
- * @module structureHash
9893
- */
9894
- /**
9895
- * Compute a SHA-256 hash of a sorted file listing.
9896
- *
9897
- * @param filePaths - Array of file paths in scope.
9898
- * @returns Hex-encoded SHA-256 hash of the sorted, newline-joined paths.
9899
- */
9900
- function computeStructureHash(filePaths) {
9901
- const sorted = [...filePaths].sort();
9902
- const content = sorted.join('\n');
9903
- return createHash('sha256').update(content).digest('hex');
9904
- }
9905
-
9906
- /**
9907
- * Lock-staged cycle finalization: write to .lock, copy to meta.json, archive, prune.
9908
- *
9909
- * @module orchestrator/finalizeCycle
9910
- */
9911
- /** Finalize a cycle using lock staging: write to .lock → copy to meta.json + archive → delete .lock. */
9912
- async function finalizeCycle(opts) {
9913
- const lockPath = join(opts.metaPath, '.lock');
9914
- const metaJsonPath = join(opts.metaPath, 'meta.json');
9915
- // Stage: write merged result to .lock (sequential — ordering matters)
9916
- const updated = await mergeAndWrite({
9917
- metaPath: opts.metaPath,
9918
- current: opts.current,
9919
- architect: opts.architect,
9920
- builder: opts.builder,
9921
- critic: opts.critic,
9922
- builderOutput: opts.builderOutput,
9923
- feedback: opts.feedback,
9924
- structureHash: opts.structureHash,
9925
- synthesisCount: opts.synthesisCount,
9926
- error: opts.error,
9927
- architectTokens: opts.architectTokens,
9928
- builderTokens: opts.builderTokens,
9929
- criticTokens: opts.criticTokens,
9930
- outputPath: lockPath,
9931
- state: opts.state,
9932
- stateOnly: opts.stateOnly,
9933
- });
9934
- // Commit: copy .lock → meta.json
9935
- await copyFile(lockPath, metaJsonPath);
9936
- // Archive + prune from the committed meta.json (sequential)
9937
- await createSnapshot(opts.metaPath, updated);
9938
- await pruneArchive(opts.metaPath, opts.config.maxArchive);
9939
- // .lock is cleaned up by the finally block (releaseLock)
9940
- return updated;
9941
- }
9942
-
9943
- /**
9944
- * Parse subprocess outputs for each synthesis step.
9945
- *
9946
- * - Architect: returns text \> _builder
9947
- * - Builder: returns JSON \> _content + structured fields
9948
- * - Critic: returns text \> _feedback
9472
+ * Build task prompts for each synthesis step.
9949
9473
  *
9950
- * @module orchestrator/parseOutput
9951
- */
9952
- /**
9953
- * 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.
9954
9477
  *
9955
- * @param output - Raw subprocess output.
9956
- * @returns The task brief string.
9478
+ * @module orchestrator/buildTask
9957
9479
  */
9958
- function parseArchitectOutput(output) {
9959
- 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
+ };
9960
9493
  }
9961
9494
  /**
9962
- * Parse builder output. The builder returns JSON with _content and optional fields.
9963
- *
9964
- * Attempts JSON parse first. If that fails, treats the entire output as _content.
9965
- *
9966
- * @param output - Raw subprocess output.
9967
- * @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.
9968
9497
  */
9969
- function parseBuilderOutput(output) {
9970
- const trimmed = output.trim();
9971
- // Strategy 1: Try to parse the entire output as JSON directly
9972
- const direct = tryParseJson(trimmed);
9973
- if (direct)
9974
- return direct;
9975
- // Strategy 2: Try all fenced code blocks (last match first — models often narrate then output)
9976
- const fencePattern = /```(?:json)?\s*([\s\S]*?)```/g;
9977
- const fenceMatches = [];
9978
- let match;
9979
- while ((match = fencePattern.exec(trimmed)) !== null) {
9980
- fenceMatches.push(match[1].trim());
9498
+ function compileTemplate(text, context) {
9499
+ try {
9500
+ return Handlebars.compile(text, { noEscape: true })(context);
9981
9501
  }
9982
- // Try last fence first (most likely to be the actual output)
9983
- for (let i = fenceMatches.length - 1; i >= 0; i--) {
9984
- const result = tryParseJson(fenceMatches[i]);
9985
- if (result)
9986
- return result;
9502
+ catch {
9503
+ return text;
9987
9504
  }
9988
- // Strategy 3: Find outermost { ... } braces
9989
- const firstBrace = trimmed.indexOf('{');
9990
- const lastBrace = trimmed.lastIndexOf('}');
9991
- if (firstBrace !== -1 && lastBrace > firstBrace) {
9992
- const result = tryParseJson(trimmed.substring(firstBrace, lastBrace + 1));
9993
- if (result)
9994
- 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)');
9995
9513
  }
9996
- // Fallback: treat entire output as content
9997
- return { content: trimmed, fields: {} };
9998
9514
  }
9999
- /** Try to parse a string as JSON and extract builder output fields. */
10000
- function tryParseJson(str) {
10001
- try {
10002
- const raw = JSON.parse(str);
10003
- if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
10004
- return null;
10005
- }
10006
- const parsed = raw;
10007
- // Extract _content
10008
- const content = typeof parsed['_content'] === 'string'
10009
- ? parsed['_content']
10010
- : typeof parsed['content'] === 'string'
10011
- ? parsed['content']
10012
- : null;
10013
- if (content === null)
10014
- return null;
10015
- // Extract _state (the ONLY underscore key the builder is allowed to set)
10016
- const state = '_state' in parsed ? parsed['_state'] : undefined;
10017
- // Extract non-underscore fields
10018
- const fields = {};
10019
- for (const [key, value] of Object.entries(parsed)) {
10020
- if (!key.startsWith('_') && key !== 'content') {
10021
- fields[key] = value;
10022
- }
10023
- }
10024
- 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);
10025
9528
  }
10026
- catch {
10027
- 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);
10028
9540
  }
10029
9541
  }
10030
9542
  /**
10031
- * Parse critic output. The critic returns evaluation text.
9543
+ * Build the architect task prompt.
10032
9544
  *
10033
- * @param output - Raw subprocess output.
10034
- * @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.
10035
9549
  */
10036
- function parseCriticOutput(output) {
10037
- 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));
10038
9573
  }
10039
-
10040
- /**
10041
- * Timeout recovery — salvage partial builder state after a SpawnTimeoutError.
10042
- *
10043
- * @module orchestrator/timeoutRecovery
10044
- */
10045
9574
  /**
10046
- * Attempt to recover partial state from a timed-out builder spawn.
9575
+ * Build the builder task prompt.
10047
9576
  *
10048
- * Returns an {@link OrchestrateResult} if state was salvaged, or `null`
10049
- * 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.
10050
9581
  */
10051
- async function attemptTimeoutRecovery(opts) {
10052
- const { err, currentMeta, metaPath, config, builderBrief, structureHash, synthesisCount, } = opts;
10053
- let partialOutput = null;
10054
- try {
10055
- const raw = await readFile(err.outputPath, 'utf8');
10056
- partialOutput = parseBuilderOutput(raw);
10057
- }
10058
- catch {
10059
- // Could not read partial output — fall through to hard failure
10060
- }
10061
- if (partialOutput?.state !== undefined) {
10062
- const currentState = JSON.stringify(currentMeta._state);
10063
- const newState = JSON.stringify(partialOutput.state);
10064
- if (newState !== currentState) {
10065
- const timeoutError = {
10066
- step: 'builder',
10067
- code: 'TIMEOUT',
10068
- message: err.message,
10069
- };
10070
- await finalizeCycle({
10071
- metaPath,
10072
- current: currentMeta,
10073
- config,
10074
- architect: currentMeta._architect ?? '',
10075
- builder: builderBrief,
10076
- critic: currentMeta._critic ?? '',
10077
- builderOutput: null,
10078
- feedback: null,
10079
- structureHash,
10080
- synthesisCount,
10081
- error: timeoutError,
10082
- state: partialOutput.state,
10083
- stateOnly: true,
10084
- });
10085
- return {
10086
- synthesized: true,
10087
- metaPath,
10088
- error: timeoutError,
10089
- };
10090
- }
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), '```');
10091
9596
  }
10092
- 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));
10093
9603
  }
10094
-
10095
9604
  /**
10096
- * Single-node synthesis pipeline architect, builder, critic.
9605
+ * Build the critic task prompt.
10097
9606
  *
10098
- * @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.
10099
9611
  */
10100
- /** Run the architect/builder/critic pipeline on a single node. */
10101
- async function synthesizeNode(node, currentMeta, config, executor, watcher, onProgress, logger) {
10102
- // Step 5-6: Steer change detection
10103
- const latestArchive = await readLatestArchive(node.metaPath);
10104
- const steerChanged = hasSteerChanged(currentMeta._steer, latestArchive?._steer, Boolean(latestArchive));
10105
- // Step 7: Compute context (includes scope files and delta files)
10106
- const ctx = await buildContextPackage(node, currentMeta, watcher, logger);
10107
- // Skip empty-scope entities that have no prior content.
10108
- // Without scope files, child metas, or cross-refs there is nothing for
10109
- // the architect/builder to work with and the cycle will either time out
10110
- // or produce empty output.
10111
- const hasScope = ctx.scopeFiles.length > 0 ||
10112
- Object.keys(ctx.childMetas).length > 0 ||
10113
- Object.keys(ctx.crossRefMetas).length > 0;
10114
- if (!hasScope && !currentMeta._content) {
10115
- // Bump _generatedAt so this entity doesn't keep winning the staleness
10116
- // race every cycle. It will be re-evaluated when files appear.
10117
- // Uses lock-staging for atomic write consistency.
10118
- currentMeta._generatedAt = new Date().toISOString();
10119
- const lockPath = join(node.metaPath, '.lock');
10120
- const metaJsonPath = join(node.metaPath, 'meta.json');
10121
- await writeFile(lockPath, JSON.stringify(currentMeta, null, 2));
10122
- await copyFile(lockPath, metaJsonPath);
10123
- logger?.debug({ path: node.ownerPath }, 'Skipping empty-scope entity');
10124
- return { synthesized: false };
10125
- }
10126
- // Step 5 (deferred): Structure hash from context scope files
10127
- const newStructureHash = computeStructureHash(ctx.scopeFiles);
10128
- const structureChanged = newStructureHash !== currentMeta._structureHash;
10129
- // Step 8: Architect (conditional)
10130
- const architectTriggered = isArchitectTriggered(currentMeta, structureChanged, steerChanged, config.architectEvery);
10131
- let builderBrief = currentMeta._builder ?? '';
10132
- let synthesisCount = currentMeta._synthesisCount ?? 0;
10133
- let stepError = null;
10134
- let architectTokens;
10135
- let builderTokens;
10136
- let criticTokens;
10137
- // Shared base options for all finalizeCycle calls.
10138
- // Note: synthesisCount is excluded because it mutates during the pipeline.
10139
- const baseFinalizeOptions = {
10140
- metaPath: node.metaPath,
10141
- current: currentMeta,
10142
- config,
10143
- architect: currentMeta._architect ?? '',
10144
- critic: currentMeta._critic ?? '',
10145
- structureHash: newStructureHash,
10146
- };
10147
- if (architectTriggered) {
10148
- try {
10149
- await onProgress?.({
10150
- type: 'phase_start',
10151
- path: node.ownerPath,
10152
- phase: 'architect',
10153
- });
10154
- const phaseStart = Date.now();
10155
- const architectTask = buildArchitectTask(ctx, currentMeta, config);
10156
- const architectResult = await executor.spawn(architectTask, {
10157
- thinking: config.thinking,
10158
- timeout: config.architectTimeout,
10159
- label: 'meta-architect',
10160
- });
10161
- builderBrief = parseArchitectOutput(architectResult.output);
10162
- architectTokens = architectResult.tokens;
10163
- synthesisCount = 0;
10164
- await onProgress?.({
10165
- type: 'phase_complete',
10166
- path: node.ownerPath,
10167
- phase: 'architect',
10168
- tokens: architectTokens,
10169
- durationMs: Date.now() - phaseStart,
10170
- });
10171
- }
10172
- catch (err) {
10173
- stepError = toMetaError('architect', err);
10174
- if (!currentMeta._builder) {
10175
- // No cached builder — cycle fails
10176
- await finalizeCycle({
10177
- ...baseFinalizeOptions,
10178
- builder: '',
10179
- builderOutput: null,
10180
- feedback: null,
10181
- synthesisCount,
10182
- error: stepError,
10183
- architectTokens,
10184
- });
10185
- return {
10186
- synthesized: true,
10187
- metaPath: node.metaPath,
10188
- error: stepError,
10189
- };
10190
- }
10191
- // Has cached builder — continue with existing
10192
- }
10193
- }
10194
- // Step 9: Builder
10195
- const metaForBuilder = { ...currentMeta, _builder: builderBrief };
10196
- let builderOutput;
10197
- try {
10198
- await onProgress?.({
10199
- type: 'phase_start',
10200
- path: node.ownerPath,
10201
- phase: 'builder',
10202
- });
10203
- const builderStart = Date.now();
10204
- const builderTask = buildBuilderTask(ctx, metaForBuilder, config);
10205
- const builderResult = await executor.spawn(builderTask, {
10206
- thinking: config.thinking,
10207
- timeout: config.builderTimeout,
10208
- label: 'meta-builder',
10209
- });
10210
- builderOutput = parseBuilderOutput(builderResult.output);
10211
- builderTokens = builderResult.tokens;
10212
- synthesisCount++;
10213
- await onProgress?.({
10214
- type: 'phase_complete',
10215
- path: node.ownerPath,
10216
- phase: 'builder',
10217
- tokens: builderTokens,
10218
- durationMs: Date.now() - builderStart,
10219
- });
10220
- }
10221
- catch (err) {
10222
- if (err instanceof SpawnTimeoutError) {
10223
- const recovered = await attemptTimeoutRecovery({
10224
- err,
10225
- currentMeta,
10226
- metaPath: node.metaPath,
10227
- config,
10228
- builderBrief,
10229
- structureHash: newStructureHash,
10230
- synthesisCount,
10231
- });
10232
- if (recovered)
10233
- return recovered;
10234
- }
10235
- stepError = toMetaError('builder', err);
10236
- await finalizeCycle({
10237
- ...baseFinalizeOptions,
10238
- builder: builderBrief,
10239
- builderOutput: null,
10240
- feedback: null,
10241
- synthesisCount,
10242
- error: stepError,
10243
- });
10244
- return { synthesized: true, metaPath: node.metaPath, error: stepError };
10245
- }
10246
- // Step 10: Critic
10247
- const metaForCritic = {
10248
- ...currentMeta,
10249
- _content: builderOutput.content,
10250
- };
10251
- let feedback = null;
10252
- try {
10253
- await onProgress?.({
10254
- type: 'phase_start',
10255
- path: node.ownerPath,
10256
- phase: 'critic',
10257
- });
10258
- const criticStart = Date.now();
10259
- const criticTask = buildCriticTask(ctx, metaForCritic, config);
10260
- const criticResult = await executor.spawn(criticTask, {
10261
- thinking: config.thinking,
10262
- timeout: config.criticTimeout,
10263
- label: 'meta-critic',
10264
- });
10265
- feedback = parseCriticOutput(criticResult.output);
10266
- criticTokens = criticResult.tokens;
10267
- stepError = null; // Clear any architect error on full success
10268
- await onProgress?.({
10269
- type: 'phase_complete',
10270
- path: node.ownerPath,
10271
- phase: 'critic',
10272
- tokens: criticTokens,
10273
- durationMs: Date.now() - criticStart,
10274
- });
10275
- }
10276
- catch (err) {
10277
- stepError = stepError ?? toMetaError('critic', err);
10278
- }
10279
- // Steps 11-12: Merge, archive, prune
10280
- await finalizeCycle({
10281
- ...baseFinalizeOptions,
10282
- builder: builderBrief,
10283
- builderOutput,
10284
- feedback,
10285
- synthesisCount,
10286
- error: stepError,
10287
- architectTokens,
10288
- builderTokens,
10289
- criticTokens,
10290
- 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,
10291
9630
  });
10292
- return {
10293
- synthesized: true,
10294
- metaPath: node.metaPath,
10295
- error: stepError ?? undefined,
10296
- };
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));
10297
9633
  }
10298
9634
 
10299
9635
  /**
10300
- * 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.
10301
9641
  *
10302
- * @module orchestrator/orchestrate
9642
+ * @module discovery/buildMinimalNode
10303
9643
  */
10304
- async function orchestrateOnce(config, executor, watcher, targetPath, onProgress, logger) {
10305
- // When targetPath is provided, skip the expensive full discovery scan.
10306
- // Build a minimal node from the filesystem instead.
10307
- if (targetPath) {
10308
- const normalizedTarget = normalizePath(targetPath);
10309
- const targetMetaJson = join(normalizedTarget, 'meta.json');
10310
- if (!existsSync(targetMetaJson))
10311
- return { synthesized: false };
10312
- const node = await buildMinimalNode(normalizedTarget, watcher);
10313
- if (!acquireLock(node.metaPath))
10314
- return { synthesized: false };
10315
- try {
10316
- const currentMeta = await readMetaJson(normalizedTarget);
10317
- return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress, logger);
10318
- }
10319
- finally {
10320
- releaseLock(node.metaPath);
10321
- }
10322
- }
10323
- // Full discovery path (scheduler-driven, no specific target)
10324
- // Step 1: Discover via watcher walk
10325
- const discoveryStart = Date.now();
10326
- const metaPaths = await discoverMetas(watcher);
10327
- logger?.debug({ paths: metaPaths.length, durationMs: Date.now() - discoveryStart }, 'discovery complete');
10328
- if (metaPaths.length === 0)
10329
- return { synthesized: false };
10330
- // Read meta.json for each discovered meta
10331
- const metas = new Map();
10332
- for (const mp of metaPaths) {
10333
- try {
10334
- metas.set(normalizePath(mp), await readMetaJson(mp));
10335
- }
10336
- catch {
10337
- // Skip metas with unreadable meta.json
10338
- continue;
10339
- }
10340
- }
10341
- // Only build tree from paths with readable meta.json (excludes orphaned/deleted entries)
10342
- const validPaths = metaPaths.filter((mp) => metas.has(normalizePath(mp)));
10343
- if (validPaths.length === 0)
10344
- return { synthesized: false };
10345
- const tree = buildOwnershipTree(validPaths);
10346
- // Steps 3-4: Staleness check + candidate selection
10347
- const candidates = [];
10348
- for (const treeNode of tree.nodes.values()) {
10349
- const meta = metas.get(treeNode.metaPath);
10350
- if (!meta)
10351
- continue;
10352
- const staleness = actualStaleness(meta);
10353
- if (staleness > 0) {
10354
- candidates.push({ node: treeNode, meta, actualStaleness: staleness });
10355
- }
10356
- }
10357
- const weighted = computeEffectiveStaleness(candidates, config.depthWeight);
10358
- // Sort by effective staleness descending
10359
- const ranked = [...weighted].sort((a, b) => b.effectiveStaleness - a.effectiveStaleness);
10360
- if (ranked.length === 0)
10361
- return { synthesized: false };
10362
- // Find the first candidate with actual changes (if skipUnchanged)
10363
- let winner = null;
10364
- for (const candidate of ranked) {
10365
- if (!acquireLock(candidate.node.metaPath))
10366
- continue;
10367
- const verifiedStale = await isStale(getScopePrefix(candidate.node), candidate.meta, watcher);
10368
- if (!verifiedStale && candidate.meta._generatedAt) {
10369
- // Bump _generatedAt so it doesn't win next cycle
10370
- const freshMeta = await readMetaJson(candidate.node.metaPath);
10371
- freshMeta._generatedAt = new Date().toISOString();
10372
- await writeFile(join(candidate.node.metaPath, 'meta.json'), JSON.stringify(freshMeta, null, 2));
10373
- releaseLock(candidate.node.metaPath);
10374
- if (config.skipUnchanged)
10375
- continue;
10376
- return { synthesized: false };
10377
- }
10378
- winner = candidate;
10379
- break;
10380
- }
10381
- if (!winner)
10382
- return { synthesized: false };
10383
- const node = winner.node;
10384
- try {
10385
- const currentMeta = await readMetaJson(node.metaPath);
10386
- return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress, logger);
10387
- }
10388
- finally {
10389
- // Step 13: Release lock
10390
- releaseLock(node.metaPath);
10391
- }
10392
- }
10393
9644
  /**
10394
- * Run a single synthesis cycle.
9645
+ * Build a minimal MetaNode for a known meta path.
10395
9646
  *
10396
- * Selects the stalest candidate (or a specific target) and runs the
10397
- * 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).
10398
9649
  *
10399
- * @param config - Validated synthesis config.
10400
- * @param executor - Pluggable LLM executor.
10401
- * @param watcher - Watcher HTTP client.
10402
- * @param targetPath - Optional: specific meta/owner path to synthesize instead of stalest candidate.
10403
- * @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.
10404
9653
  */
10405
- async function orchestrate(config, executor, watcher, targetPath, onProgress, logger) {
10406
- const result = await orchestrateOnce(config, executor, watcher, targetPath, onProgress, logger);
10407
- 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;
10408
9694
  }
10409
9695
 
10410
9696
  /**
@@ -10458,6 +9744,41 @@ function enforceInvariant(state) {
10458
9744
  }
10459
9745
  return result;
10460
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
+ }
10461
9782
  // ── Phase success transitions ──────────────────────────────────────────
10462
9783
  /**
10463
9784
  * Architect completes successfully.
@@ -10626,7 +9947,9 @@ function derivePhaseState(meta, inputs) {
10626
9947
  }
10627
9948
  // Check architect invalidation (when inputs are provided)
10628
9949
  if (inputs) {
10629
- 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 ||
10630
9953
  inputs.steerChanged ||
10631
9954
  inputs.architectChanged ||
10632
9955
  inputs.crossRefsChanged ||
@@ -10638,6 +9961,14 @@ function derivePhaseState(meta, inputs) {
10638
9961
  critic: 'stale',
10639
9962
  };
10640
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
+ }
10641
9972
  }
10642
9973
  // Has _builder but no _content: builder is pending
10643
9974
  if (meta._builder && !meta._content) {
@@ -10659,6 +9990,154 @@ function derivePhaseState(meta, inputs) {
10659
9990
  return freshPhaseState();
10660
9991
  }
10661
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
+
10662
10141
  /**
10663
10142
  * Corpus-wide phase scheduler.
10664
10143
  *
@@ -10671,18 +10150,30 @@ function derivePhaseState(meta, inputs) {
10671
10150
  /**
10672
10151
  * Build phase candidates from listMetas entries.
10673
10152
  *
10674
- * 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.
10675
10155
  * Used by orchestratePhase, queue route, and status route.
10676
10156
  */
10677
- function buildPhaseCandidates(entries) {
10678
- return entries.map((entry) => ({
10679
- node: entry.node,
10680
- meta: entry.meta,
10681
- phaseState: retryAllFailed(derivePhaseState(entry.meta)),
10682
- actualStaleness: entry.stalenessSeconds,
10683
- locked: entry.locked,
10684
- disabled: entry.disabled,
10685
- }));
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
+ });
10686
10177
  }
10687
10178
  /**
10688
10179
  * Rank all eligible phase candidates by priority.
@@ -10735,14 +10226,132 @@ function rankPhaseCandidates(metas, depthWeight) {
10735
10226
  return candidates;
10736
10227
  }
10737
10228
  /**
10738
- * 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.
10739
10349
  *
10740
- * @param metas - Array of (node, meta, phaseState, stalenessSeconds) tuples.
10741
- * @param depthWeight - Config depthWeight for staleness tiebreak.
10742
- * @returns The winning candidate, or null if no phase is ready.
10350
+ * @param output - Raw subprocess output.
10351
+ * @returns The feedback string.
10743
10352
  */
10744
- function selectPhaseCandidate(metas, depthWeight) {
10745
- return rankPhaseCandidates(metas, depthWeight)[0] ?? null;
10353
+ function parseCriticOutput(output) {
10354
+ return output.trim();
10746
10355
  }
10747
10356
 
10748
10357
  /**
@@ -11003,7 +10612,7 @@ async function orchestratePhase(config, executor, watcher, targetPath, onProgres
11003
10612
  if (metaResult.entries.length === 0)
11004
10613
  return { executed: false };
11005
10614
  // Build candidates with phase state (including invalidation + auto-retry)
11006
- const candidates = buildPhaseCandidates(metaResult.entries);
10615
+ const candidates = buildPhaseCandidates(metaResult.entries, config.architectEvery);
11007
10616
  // Select best phase candidate
11008
10617
  const winner = selectPhaseCandidate(candidates, config.depthWeight);
11009
10618
  if (!winner) {
@@ -11678,46 +11287,6 @@ function buildMetaRules(config) {
11678
11287
  },
11679
11288
  renderAs: 'md',
11680
11289
  },
11681
- {
11682
- name: 'meta-config',
11683
- description: 'jeeves-meta configuration file',
11684
- match: {
11685
- properties: {
11686
- file: {
11687
- properties: {
11688
- path: {
11689
- type: 'string',
11690
- glob: '**/jeeves-meta{.config.json,/config.json}',
11691
- },
11692
- },
11693
- },
11694
- },
11695
- },
11696
- schema: ['base', { properties: { domains: { set: ['meta-config'] } } }],
11697
- render: {
11698
- frontmatter: [
11699
- 'watcherUrl',
11700
- 'gatewayUrl',
11701
- 'architectEvery',
11702
- 'depthWeight',
11703
- 'maxArchive',
11704
- 'maxLines',
11705
- ],
11706
- body: [
11707
- {
11708
- path: 'json.defaultArchitect',
11709
- heading: 2,
11710
- label: 'Default Architect Prompt',
11711
- },
11712
- {
11713
- path: 'json.defaultCritic',
11714
- heading: 2,
11715
- label: 'Default Critic Prompt',
11716
- },
11717
- ],
11718
- },
11719
- renderAs: 'md',
11720
- },
11721
11290
  ];
11722
11291
  }
11723
11292
  /**
@@ -11961,13 +11530,15 @@ class Scheduler {
11961
11530
  queue;
11962
11531
  logger;
11963
11532
  watcher;
11533
+ cache;
11964
11534
  registrar = null;
11965
11535
  currentExpression;
11966
- constructor(config, queue, logger, watcher) {
11536
+ constructor(config, queue, logger, watcher, cache) {
11967
11537
  this.config = config;
11968
11538
  this.queue = queue;
11969
11539
  this.logger = logger;
11970
11540
  this.watcher = watcher;
11541
+ this.cache = cache;
11971
11542
  this.currentExpression = config.schedule;
11972
11543
  }
11973
11544
  /** Set the rule registrar for watcher restart detection. */
@@ -12084,8 +11655,8 @@ class Scheduler {
12084
11655
  */
12085
11656
  async discoverNextPhase() {
12086
11657
  try {
12087
- const result = await listMetas(this.config, this.watcher);
12088
- 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);
12089
11660
  const winner = selectPhaseCandidate(candidates, this.config.depthWeight);
12090
11661
  if (!winner)
12091
11662
  return null;
@@ -12510,11 +12081,11 @@ function registerMetasUpdateRoute(app, deps) {
12510
12081
  */
12511
12082
  function registerPreviewRoute(app, deps) {
12512
12083
  app.get('/preview', async (request, reply) => {
12513
- const { config, watcher } = deps;
12084
+ const { config, watcher, cache } = deps;
12514
12085
  const query = request.query;
12515
12086
  let result;
12516
12087
  try {
12517
- result = await listMetas(config, watcher);
12088
+ result = await cache.get(config, watcher);
12518
12089
  }
12519
12090
  catch {
12520
12091
  return reply.status(503).send({
@@ -12534,40 +12105,24 @@ function registerPreviewRoute(app, deps) {
12534
12105
  }
12535
12106
  }
12536
12107
  else {
12537
- // Select stalest candidate
12538
- const stale = result.entries
12539
- .filter((e) => e.stalenessSeconds > 0)
12540
- .map((e) => ({
12541
- node: e.node,
12542
- meta: e.meta,
12543
- actualStaleness: e.stalenessSeconds,
12544
- }));
12545
- const stalestPath = discoverStalestPath(stale, config.depthWeight);
12546
- 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) {
12547
12112
  return { message: 'No stale metas found. Nothing to synthesize.' };
12548
12113
  }
12549
- targetNode = findNode(result.tree, stalestPath);
12114
+ targetNode = findNode(result.tree, winner.node.metaPath);
12550
12115
  }
12551
12116
  const meta = await readMetaJson(targetNode.metaPath);
12552
12117
  // Scope files
12553
12118
  const { scopeFiles } = await getScopeFiles(targetNode, watcher);
12554
- 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;
12555
12123
  const structureChanged = structureHash !== meta._structureHash;
12556
- const latestArchive = await readLatestArchive(targetNode.metaPath);
12557
- const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
12558
- // _architect change detection
12559
- const architectChanged = latestArchive
12560
- ? (meta._architect ?? '') !== (latestArchive._architect ?? '')
12561
- : Boolean(meta._architect);
12562
- // _crossRefs declaration change detection
12563
- const currentRefs = (meta._crossRefs ?? []).slice().sort().join(',');
12564
- const archiveRefs = (latestArchive?._crossRefs ?? [])
12565
- .slice()
12566
- .sort()
12567
- .join(',');
12568
- const crossRefsDeclChanged = latestArchive
12569
- ? currentRefs !== archiveRefs
12570
- : currentRefs.length > 0;
12124
+ const { steerChanged } = invalidation;
12125
+ const { architectChanged, crossRefsDeclChanged } = stalenessInputs;
12571
12126
  const architectTriggered = isArchitectTriggered(meta, structureChanged, steerChanged, config.architectEvery);
12572
12127
  // Delta files
12573
12128
  const deltaFiles = getDeltaFiles(meta._generatedAt, scopeFiles);
@@ -12592,30 +12147,6 @@ function registerPreviewRoute(app, deps) {
12592
12147
  });
12593
12148
  const owedPhase = getOwedPhase(phaseState);
12594
12149
  const priorityBand = getPriorityBand(phaseState);
12595
- // Architect invalidators
12596
- const architectInvalidators = [];
12597
- if (owedPhase === 'architect') {
12598
- if (structureChanged)
12599
- architectInvalidators.push('structureHash');
12600
- if (steerChanged)
12601
- architectInvalidators.push('steer');
12602
- if (architectChanged)
12603
- architectInvalidators.push('_architect');
12604
- if (crossRefsDeclChanged)
12605
- architectInvalidators.push('_crossRefs');
12606
- if ((meta._synthesisCount ?? 0) >= config.architectEvery) {
12607
- architectInvalidators.push('architectEvery');
12608
- }
12609
- }
12610
- // Staleness inputs
12611
- const stalenessInputs = {
12612
- structureHash,
12613
- steerChanged,
12614
- architectChanged,
12615
- crossRefsDeclChanged,
12616
- scopeMtimeMax: null,
12617
- crossRefContentChanged: false,
12618
- };
12619
12150
  return {
12620
12151
  path: targetNode.metaPath,
12621
12152
  staleness: {
@@ -12689,8 +12220,8 @@ function registerQueueRoutes(app, deps) {
12689
12220
  // ranked by scheduler priority (computed on read, not persisted)
12690
12221
  let automatic = [];
12691
12222
  try {
12692
- const metaResult = await listMetas(deps.config, deps.watcher);
12693
- 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);
12694
12225
  const ranked = rankPhaseCandidates(candidates, deps.config.depthWeight);
12695
12226
  automatic = ranked.map((c) => ({
12696
12227
  path: c.node.metaPath,
@@ -12885,7 +12416,7 @@ function registerStatusRoute(app, deps) {
12885
12416
  name: SERVICE_NAME,
12886
12417
  version: SERVICE_VERSION,
12887
12418
  getHealth: async () => {
12888
- const { config, queue, scheduler, stats, watcher } = deps;
12419
+ const { config, queue, scheduler, stats, watcher, cache } = deps;
12889
12420
  // On-demand dependency checks
12890
12421
  const [watcherHealth, gatewayHealth] = await Promise.all([
12891
12422
  checkWatcher(config.watcherUrl),
@@ -12899,7 +12430,7 @@ function registerStatusRoute(app, deps) {
12899
12430
  };
12900
12431
  let nextPhase = null;
12901
12432
  try {
12902
- const metaResult = await listMetas(config, watcher);
12433
+ const metaResult = await cache.get(config, watcher);
12903
12434
  // Count raw phase states (before retry) for display
12904
12435
  for (const entry of metaResult.entries) {
12905
12436
  const ps = derivePhaseState(entry.meta);
@@ -12908,7 +12439,7 @@ function registerStatusRoute(app, deps) {
12908
12439
  }
12909
12440
  }
12910
12441
  // Build candidates (with auto-retry) for scheduling
12911
- const candidates = buildPhaseCandidates(metaResult.entries);
12442
+ const candidates = buildPhaseCandidates(metaResult.entries, config.architectEvery);
12912
12443
  // Find next phase candidate
12913
12444
  const winner = selectPhaseCandidate(candidates, config.depthWeight);
12914
12445
  if (winner) {
@@ -12971,7 +12502,7 @@ const synthesizeBodySchema = z.object({
12971
12502
  function registerSynthesizeRoute(app, deps) {
12972
12503
  app.post('/synthesize', async (request, reply) => {
12973
12504
  const body = synthesizeBodySchema.parse(request.body);
12974
- const { config, watcher, queue } = deps;
12505
+ const { config, watcher, queue, cache } = deps;
12975
12506
  if (body.path) {
12976
12507
  // Path-targeted trigger: create override entry
12977
12508
  const targetPath = resolveMetaDir(body.path);
@@ -13008,7 +12539,7 @@ function registerSynthesizeRoute(app, deps) {
13008
12539
  // Corpus-wide trigger: discover stalest candidate
13009
12540
  let result;
13010
12541
  try {
13011
- result = await listMetas(config, watcher);
12542
+ result = await cache.get(config, watcher);
13012
12543
  }
13013
12544
  catch {
13014
12545
  return reply.status(503).send({
@@ -13016,20 +12547,15 @@ function registerSynthesizeRoute(app, deps) {
13016
12547
  message: 'Watcher unreachable — cannot discover candidates',
13017
12548
  });
13018
12549
  }
13019
- const stale = result.entries
13020
- .filter((e) => e.stalenessSeconds > 0 && !e.disabled)
13021
- .map((e) => ({
13022
- node: e.node,
13023
- meta: e.meta,
13024
- actualStaleness: e.stalenessSeconds,
13025
- }));
13026
- const stalest = discoverStalestPath(stale, config.depthWeight);
13027
- if (!stalest) {
12550
+ const candidates = buildPhaseCandidates(result.entries, config.architectEvery);
12551
+ const winner = selectPhaseCandidate(candidates, config.depthWeight);
12552
+ if (!winner) {
13028
12553
  return reply.code(200).send({
13029
12554
  status: 'skipped',
13030
12555
  message: 'No stale metas found. Nothing to synthesize.',
13031
12556
  });
13032
12557
  }
12558
+ const stalest = winner.node.metaPath;
13033
12559
  const enqueueResult = queue.enqueue(stalest);
13034
12560
  return reply.code(202).send({
13035
12561
  status: 'accepted',
@@ -13130,6 +12656,18 @@ function createServer(options) {
13130
12656
  // Fastify 5 requires `loggerInstance` for external pino loggers
13131
12657
  const app = Fastify({
13132
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' });
13133
12671
  });
13134
12672
  registerRoutes(app, options.deps);
13135
12673
  return app;
@@ -13305,8 +12843,9 @@ async function startService(config, configPath) {
13305
12843
  lastCycleAt: null,
13306
12844
  };
13307
12845
  const queue = new SynthesisQueue(logger);
12846
+ const cache = new MetaCache();
13308
12847
  // Scheduler (needs watcher for discovery)
13309
- const scheduler = new Scheduler(config, queue, logger, watcher);
12848
+ const scheduler = new Scheduler(config, queue, logger, watcher, cache);
13310
12849
  const routeDeps = {
13311
12850
  config,
13312
12851
  logger,
@@ -13314,6 +12853,8 @@ async function startService(config, configPath) {
13314
12853
  watcher,
13315
12854
  scheduler,
13316
12855
  stats,
12856
+ cache,
12857
+ ready: false,
13317
12858
  executor,
13318
12859
  configPath,
13319
12860
  };
@@ -13364,6 +12905,9 @@ async function startService(config, configPath) {
13364
12905
  }
13365
12906
  await progress.report(evt);
13366
12907
  }, logger);
12908
+ // Invalidate cache only when a phase was actually executed
12909
+ if (result.executed)
12910
+ cache.invalidate();
13367
12911
  const durationMs = Date.now() - startMs;
13368
12912
  if (!result.executed) {
13369
12913
  logger.debug({ path: ownerPath }, 'Phase skipped (fully fresh or locked)');
@@ -13427,9 +12971,13 @@ async function startService(config, configPath) {
13427
12971
  scheduler.setRegistrar(registrar);
13428
12972
  routeDeps.registrar = registrar;
13429
12973
  void registrar.register().then(() => {
12974
+ routeDeps.ready = true;
13430
12975
  if (registrar.isRegistered) {
13431
12976
  void verifyRuleApplication(watcher, logger);
13432
12977
  }
12978
+ }, () => {
12979
+ // Registration failed after max retries — mark ready anyway
12980
+ routeDeps.ready = true;
13433
12981
  });
13434
12982
  // Periodic watcher health check (independent of scheduler)
13435
12983
  const healthCheck = new WatcherHealthCheck({
@@ -13511,4 +13059,152 @@ const metaDescriptor = jeevesComponentDescriptorSchema.parse({
13511
13059
  customCliCommands: registerCustomCliCommands,
13512
13060
  });
13513
13061
 
13514
- 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 };