@karmaniverous/jeeves-meta 0.15.3 → 0.15.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8418,222 +8418,6 @@ function sleepAsync(ms) {
8418
8418
  return new Promise((r) => setTimeout(r, ms));
8419
8419
  }
8420
8420
 
8421
- /**
8422
- * Shared live config hot-reload support.
8423
- *
8424
- * Used by both file-watch reloads in bootstrap and POST /config/apply
8425
- * via the component descriptor's onConfigApply callback.
8426
- *
8427
- * @module configHotReload
8428
- */
8429
- /**
8430
- * Fields that require a service restart to take effect.
8431
- *
8432
- * Shared between the descriptor's `onConfigApply` and the file-watcher
8433
- * hot-reload in `bootstrap.ts`.
8434
- */
8435
- const RESTART_REQUIRED_FIELDS = [
8436
- 'port',
8437
- 'watcherUrl',
8438
- 'gatewayUrl',
8439
- 'gatewayApiKey',
8440
- 'defaultArchitect',
8441
- 'defaultCritic',
8442
- ];
8443
- let runtime = null;
8444
- /** Register the active service runtime for config-apply hot reload. */
8445
- function registerConfigHotReloadRuntime(nextRuntime) {
8446
- runtime = nextRuntime;
8447
- }
8448
- /** Apply hot-reloadable config changes to the live shared config object. */
8449
- function applyHotReloadedConfig(newConfig) {
8450
- if (!runtime)
8451
- return;
8452
- const { config, logger, scheduler } = runtime;
8453
- for (const field of RESTART_REQUIRED_FIELDS) {
8454
- const oldVal = config[field];
8455
- const nextVal = newConfig[field];
8456
- if (oldVal !== nextVal) {
8457
- logger.warn({ field, oldValue: oldVal, newValue: nextVal }, 'Config field changed but requires restart to take effect');
8458
- }
8459
- }
8460
- if (newConfig.schedule !== config.schedule) {
8461
- scheduler?.updateSchedule(newConfig.schedule);
8462
- config.schedule = newConfig.schedule;
8463
- logger.info({ schedule: newConfig.schedule }, 'Schedule hot-reloaded');
8464
- }
8465
- if (newConfig.logging.level !== config.logging.level) {
8466
- logger.level = newConfig.logging.level;
8467
- config.logging.level = newConfig.logging.level;
8468
- logger.info({ level: newConfig.logging.level }, 'Log level hot-reloaded');
8469
- }
8470
- const restartSet = new Set(RESTART_REQUIRED_FIELDS);
8471
- for (const key of Object.keys(newConfig)) {
8472
- if (restartSet.has(key) || key === 'logging' || key === 'schedule') {
8473
- continue;
8474
- }
8475
- const oldVal = config[key];
8476
- const nextVal = newConfig[key];
8477
- if (JSON.stringify(oldVal) !== JSON.stringify(nextVal)) {
8478
- config[key] = nextVal;
8479
- logger.info({ field: key }, 'Config field hot-reloaded');
8480
- }
8481
- }
8482
- }
8483
-
8484
- /**
8485
- * Zod schema for jeeves-meta service configuration.
8486
- *
8487
- * The service config is a strict superset of the core (library-compatible) meta config.
8488
- *
8489
- * @module schema/config
8490
- */
8491
- /** Zod schema for the core (library-compatible) meta configuration. */
8492
- const metaConfigSchema = z.object({
8493
- /** Watcher service base URL. */
8494
- watcherUrl: z.url(),
8495
- /** OpenClaw gateway base URL for subprocess spawning. */
8496
- gatewayUrl: z.url().default('http://127.0.0.1:18789'),
8497
- /** Optional API key for gateway authentication. */
8498
- gatewayApiKey: z.string().optional(),
8499
- /** Run architect every N cycles (per meta). */
8500
- architectEvery: z.number().int().min(1).default(10),
8501
- /** Exponent for depth weighting in staleness formula. */
8502
- depthWeight: z.number().min(0).default(0.5),
8503
- /** Maximum archive snapshots to retain per meta. */
8504
- maxArchive: z.number().int().min(1).default(20),
8505
- /** Maximum lines of context to include in subprocess prompts. */
8506
- maxLines: z.number().int().min(50).default(500),
8507
- /** Architect subprocess timeout in seconds. */
8508
- architectTimeout: z.number().int().min(30).default(180),
8509
- /** Builder subprocess timeout in seconds. */
8510
- builderTimeout: z.number().int().min(60).default(360),
8511
- /** Critic subprocess timeout in seconds. */
8512
- criticTimeout: z.number().int().min(30).default(240),
8513
- /** Thinking level for spawned synthesis sessions. */
8514
- thinking: z.string().default('low'),
8515
- /** Resolved architect system prompt text. Falls back to built-in default. */
8516
- defaultArchitect: z.string().optional(),
8517
- /** Resolved critic system prompt text. Falls back to built-in default. */
8518
- defaultCritic: z.string().optional(),
8519
- /** Skip unchanged candidates, bump _generatedAt. */
8520
- skipUnchanged: z.boolean().default(true),
8521
- /** Watcher metadata properties applied to live .meta/meta.json files. */
8522
- metaProperty: z.record(z.string(), z.unknown()).default({ _meta: 'current' }),
8523
- /** Watcher metadata properties applied to archive snapshots. */
8524
- metaArchiveProperty: z
8525
- .record(z.string(), z.unknown())
8526
- .default({ _meta: 'archive' }),
8527
- });
8528
- /** Zod schema for logging configuration. */
8529
- const loggingSchema = z.object({
8530
- /** Log level. */
8531
- level: z.string().default('info'),
8532
- /** Optional file path for log output. */
8533
- file: z.string().optional(),
8534
- });
8535
- /** Zod schema for a single auto-seed policy rule. */
8536
- const autoSeedRuleSchema = z.object({
8537
- /** Glob pattern matched against watcher walk results. */
8538
- match: z.string(),
8539
- /** Optional steering prompt for seeded metas. */
8540
- steer: z.string().optional(),
8541
- /** Optional cross-references for seeded metas. */
8542
- crossRefs: z.array(z.string()).optional(),
8543
- });
8544
- /** Zod schema for jeeves-meta service configuration (superset of MetaConfig). */
8545
- const serviceConfigSchema = metaConfigSchema.extend({
8546
- /** HTTP port for the service (default: 1938). */
8547
- port: z.number().int().min(1).max(65535).default(1938),
8548
- /** Cron schedule for synthesis cycles (default: every 30 min). */
8549
- schedule: z.string().default('*/30 * * * *'),
8550
- /** Messaging channel name (e.g. 'slack'). Legacy: also used as target if reportTarget is unset. */
8551
- reportChannel: z.string().optional(),
8552
- /** Channel/user ID to send progress messages to. */
8553
- reportTarget: z.string().optional(),
8554
- /** Optional base URL for the service, used to construct entity links in progress reports. */
8555
- serverBaseUrl: z.string().optional(),
8556
- /** Interval in ms for periodic watcher health check. 0 = disabled. Default: 60000. */
8557
- watcherHealthIntervalMs: z.number().int().min(0).default(60_000),
8558
- /** Logging configuration. */
8559
- logging: loggingSchema.default(() => loggingSchema.parse({})),
8560
- /**
8561
- * Auto-seed policy: declarative rules for auto-creating .meta/ directories.
8562
- * Rules are evaluated in order; last match wins for steer/crossRefs.
8563
- */
8564
- autoSeed: z.array(autoSeedRuleSchema).optional().default([]),
8565
- });
8566
-
8567
- /**
8568
- * Load and resolve jeeves-meta service config.
8569
- *
8570
- * Supports \@file: indirection and environment-variable substitution (dollar-brace pattern).
8571
- *
8572
- * @module configLoader
8573
- */
8574
- /**
8575
- * Deep-walk a value, replacing `\${VAR\}` patterns with process.env values.
8576
- *
8577
- * @param value - Arbitrary JSON-compatible value.
8578
- * @returns Value with env-var placeholders resolved.
8579
- */
8580
- function substituteEnvVars(value) {
8581
- if (typeof value === 'string') {
8582
- return value.replace(/\$\{([^}]+)\}/g, (_match, name) => {
8583
- const envVal = process.env[name];
8584
- if (envVal === undefined) {
8585
- throw new Error(`Environment variable ${name} is not set`);
8586
- }
8587
- return envVal;
8588
- });
8589
- }
8590
- if (Array.isArray(value)) {
8591
- return value.map(substituteEnvVars);
8592
- }
8593
- if (value !== null && typeof value === 'object') {
8594
- const result = {};
8595
- for (const [key, val] of Object.entries(value)) {
8596
- result[key] = substituteEnvVars(val);
8597
- }
8598
- return result;
8599
- }
8600
- return value;
8601
- }
8602
- /**
8603
- * Resolve \@file: references in a config value.
8604
- *
8605
- * @param value - String value that may start with "\@file:".
8606
- * @param baseDir - Base directory for resolving relative paths.
8607
- * @returns The resolved string (file contents or original value).
8608
- */
8609
- function resolveFileRef(value, baseDir) {
8610
- if (!value.startsWith('@file:'))
8611
- return value;
8612
- const filePath = join(baseDir, value.slice(6));
8613
- return readFileSync(filePath, 'utf8');
8614
- }
8615
- /**
8616
- * Load service config from a JSON file.
8617
- *
8618
- * Resolves \@file: references for defaultArchitect and defaultCritic,
8619
- * and substitutes environment-variable placeholders throughout.
8620
- *
8621
- * @param configPath - Path to config JSON file.
8622
- * @returns Validated ServiceConfig.
8623
- */
8624
- function loadServiceConfig(configPath) {
8625
- const rawText = readFileSync(configPath, 'utf8');
8626
- const raw = substituteEnvVars(JSON.parse(rawText));
8627
- const baseDir = dirname(configPath);
8628
- if (typeof raw['defaultArchitect'] === 'string') {
8629
- raw['defaultArchitect'] = resolveFileRef(raw['defaultArchitect'], baseDir);
8630
- }
8631
- if (typeof raw['defaultCritic'] === 'string') {
8632
- raw['defaultCritic'] = resolveFileRef(raw['defaultCritic'], baseDir);
8633
- }
8634
- return serviceConfigSchema.parse(raw);
8635
- }
8636
-
8637
8421
  /**
8638
8422
  * Compute summary statistics from an array of MetaEntry objects.
8639
8423
  *
@@ -9290,6 +9074,263 @@ function getDeltaFiles(generatedAt, scopeFiles) {
9290
9074
  return filterModifiedAfter(scopeFiles, new Date(generatedAt).getTime());
9291
9075
  }
9292
9076
 
9077
+ /**
9078
+ * In-memory cache for listMetas results with TTL and concurrent refresh guard.
9079
+ *
9080
+ * @module cache
9081
+ */
9082
+ const TTL_MS = 60_000;
9083
+ /**
9084
+ * Caches listMetas results to avoid expensive repeated filesystem walks.
9085
+ * Supports concurrent refresh coalescing and manual invalidation.
9086
+ */
9087
+ class MetaCache {
9088
+ result = null;
9089
+ updatedAt = 0;
9090
+ refreshPromise = null;
9091
+ /** Get cached result or refresh if stale. */
9092
+ async get(config, watcher) {
9093
+ if (this.result && Date.now() - this.updatedAt < TTL_MS) {
9094
+ return this.result;
9095
+ }
9096
+ return this.refresh(config, watcher);
9097
+ }
9098
+ /** Force-expire the cache so next get() triggers a refresh. */
9099
+ invalidate() {
9100
+ this.updatedAt = 0;
9101
+ }
9102
+ async refresh(config, watcher) {
9103
+ if (this.refreshPromise)
9104
+ return this.refreshPromise;
9105
+ this.refreshPromise = listMetas(config, watcher)
9106
+ .then((result) => {
9107
+ this.result = result;
9108
+ this.updatedAt = Date.now();
9109
+ return result;
9110
+ })
9111
+ .finally(() => {
9112
+ this.refreshPromise = null;
9113
+ });
9114
+ return this.refreshPromise;
9115
+ }
9116
+ }
9117
+
9118
+ /**
9119
+ * Shared live config hot-reload support.
9120
+ *
9121
+ * Used by both file-watch reloads in bootstrap and POST /config/apply
9122
+ * via the component descriptor's onConfigApply callback.
9123
+ *
9124
+ * @module configHotReload
9125
+ */
9126
+ /**
9127
+ * Fields that require a service restart to take effect.
9128
+ *
9129
+ * Shared between the descriptor's `onConfigApply` and the file-watcher
9130
+ * hot-reload in `bootstrap.ts`.
9131
+ */
9132
+ const RESTART_REQUIRED_FIELDS = [
9133
+ 'port',
9134
+ 'watcherUrl',
9135
+ 'gatewayUrl',
9136
+ 'gatewayApiKey',
9137
+ 'defaultArchitect',
9138
+ 'defaultCritic',
9139
+ ];
9140
+ let runtime = null;
9141
+ /** Register the active service runtime for config-apply hot reload. */
9142
+ function registerConfigHotReloadRuntime(nextRuntime) {
9143
+ runtime = nextRuntime;
9144
+ }
9145
+ /** Apply hot-reloadable config changes to the live shared config object. */
9146
+ function applyHotReloadedConfig(newConfig) {
9147
+ if (!runtime)
9148
+ return;
9149
+ const { config, logger, scheduler } = runtime;
9150
+ for (const field of RESTART_REQUIRED_FIELDS) {
9151
+ const oldVal = config[field];
9152
+ const nextVal = newConfig[field];
9153
+ if (oldVal !== nextVal) {
9154
+ logger.warn({ field, oldValue: oldVal, newValue: nextVal }, 'Config field changed but requires restart to take effect');
9155
+ }
9156
+ }
9157
+ if (newConfig.schedule !== config.schedule) {
9158
+ scheduler?.updateSchedule(newConfig.schedule);
9159
+ config.schedule = newConfig.schedule;
9160
+ logger.info({ schedule: newConfig.schedule }, 'Schedule hot-reloaded');
9161
+ }
9162
+ if (newConfig.logging.level !== config.logging.level) {
9163
+ logger.level = newConfig.logging.level;
9164
+ config.logging.level = newConfig.logging.level;
9165
+ logger.info({ level: newConfig.logging.level }, 'Log level hot-reloaded');
9166
+ }
9167
+ const restartSet = new Set(RESTART_REQUIRED_FIELDS);
9168
+ for (const key of Object.keys(newConfig)) {
9169
+ if (restartSet.has(key) || key === 'logging' || key === 'schedule') {
9170
+ continue;
9171
+ }
9172
+ const oldVal = config[key];
9173
+ const nextVal = newConfig[key];
9174
+ if (JSON.stringify(oldVal) !== JSON.stringify(nextVal)) {
9175
+ config[key] = nextVal;
9176
+ logger.info({ field: key }, 'Config field hot-reloaded');
9177
+ }
9178
+ }
9179
+ }
9180
+
9181
+ /**
9182
+ * Zod schema for jeeves-meta service configuration.
9183
+ *
9184
+ * The service config is a strict superset of the core (library-compatible) meta config.
9185
+ *
9186
+ * @module schema/config
9187
+ */
9188
+ /** Zod schema for the core (library-compatible) meta configuration. */
9189
+ const metaConfigSchema = z.object({
9190
+ /** Watcher service base URL. */
9191
+ watcherUrl: z.url(),
9192
+ /** OpenClaw gateway base URL for subprocess spawning. */
9193
+ gatewayUrl: z.url().default('http://127.0.0.1:18789'),
9194
+ /** Optional API key for gateway authentication. */
9195
+ gatewayApiKey: z.string().optional(),
9196
+ /** Run architect every N cycles (per meta). */
9197
+ architectEvery: z.number().int().min(1).default(10),
9198
+ /** Exponent for depth weighting in staleness formula. */
9199
+ depthWeight: z.number().min(0).default(0.5),
9200
+ /** Maximum archive snapshots to retain per meta. */
9201
+ maxArchive: z.number().int().min(1).default(20),
9202
+ /** Maximum lines of context to include in subprocess prompts. */
9203
+ maxLines: z.number().int().min(50).default(500),
9204
+ /** Architect subprocess timeout in seconds. */
9205
+ architectTimeout: z.number().int().min(30).default(180),
9206
+ /** Builder subprocess timeout in seconds. */
9207
+ builderTimeout: z.number().int().min(60).default(360),
9208
+ /** Critic subprocess timeout in seconds. */
9209
+ criticTimeout: z.number().int().min(30).default(240),
9210
+ /** Thinking level for spawned synthesis sessions. */
9211
+ thinking: z.string().default('low'),
9212
+ /** Resolved architect system prompt text. Falls back to built-in default. */
9213
+ defaultArchitect: z.string().optional(),
9214
+ /** Resolved critic system prompt text. Falls back to built-in default. */
9215
+ defaultCritic: z.string().optional(),
9216
+ /** Skip unchanged candidates, bump _generatedAt. */
9217
+ skipUnchanged: z.boolean().default(true),
9218
+ /** Watcher metadata properties applied to live .meta/meta.json files. */
9219
+ metaProperty: z.record(z.string(), z.unknown()).default({ _meta: 'current' }),
9220
+ /** Watcher metadata properties applied to archive snapshots. */
9221
+ metaArchiveProperty: z
9222
+ .record(z.string(), z.unknown())
9223
+ .default({ _meta: 'archive' }),
9224
+ });
9225
+ /** Zod schema for logging configuration. */
9226
+ const loggingSchema = z.object({
9227
+ /** Log level. */
9228
+ level: z.string().default('info'),
9229
+ /** Optional file path for log output. */
9230
+ file: z.string().optional(),
9231
+ });
9232
+ /** Zod schema for a single auto-seed policy rule. */
9233
+ const autoSeedRuleSchema = z.object({
9234
+ /** Glob pattern matched against watcher walk results. */
9235
+ match: z.string(),
9236
+ /** Optional steering prompt for seeded metas. */
9237
+ steer: z.string().optional(),
9238
+ /** Optional cross-references for seeded metas. */
9239
+ crossRefs: z.array(z.string()).optional(),
9240
+ });
9241
+ /** Zod schema for jeeves-meta service configuration (superset of MetaConfig). */
9242
+ const serviceConfigSchema = metaConfigSchema.extend({
9243
+ /** HTTP port for the service (default: 1938). */
9244
+ port: z.number().int().min(1).max(65535).default(1938),
9245
+ /** Cron schedule for synthesis cycles (default: every 30 min). */
9246
+ schedule: z.string().default('*/30 * * * *'),
9247
+ /** Messaging channel name (e.g. 'slack'). Legacy: also used as target if reportTarget is unset. */
9248
+ reportChannel: z.string().optional(),
9249
+ /** Channel/user ID to send progress messages to. */
9250
+ reportTarget: z.string().optional(),
9251
+ /** Optional base URL for the service, used to construct entity links in progress reports. */
9252
+ serverBaseUrl: z.string().optional(),
9253
+ /** Interval in ms for periodic watcher health check. 0 = disabled. Default: 60000. */
9254
+ watcherHealthIntervalMs: z.number().int().min(0).default(60_000),
9255
+ /** Logging configuration. */
9256
+ logging: loggingSchema.default(() => loggingSchema.parse({})),
9257
+ /**
9258
+ * Auto-seed policy: declarative rules for auto-creating .meta/ directories.
9259
+ * Rules are evaluated in order; last match wins for steer/crossRefs.
9260
+ */
9261
+ autoSeed: z.array(autoSeedRuleSchema).optional().default([]),
9262
+ });
9263
+
9264
+ /**
9265
+ * Load and resolve jeeves-meta service config.
9266
+ *
9267
+ * Supports \@file: indirection and environment-variable substitution (dollar-brace pattern).
9268
+ *
9269
+ * @module configLoader
9270
+ */
9271
+ /**
9272
+ * Deep-walk a value, replacing `\${VAR\}` patterns with process.env values.
9273
+ *
9274
+ * @param value - Arbitrary JSON-compatible value.
9275
+ * @returns Value with env-var placeholders resolved.
9276
+ */
9277
+ function substituteEnvVars(value) {
9278
+ if (typeof value === 'string') {
9279
+ return value.replace(/\$\{([^}]+)\}/g, (_match, name) => {
9280
+ const envVal = process.env[name];
9281
+ if (envVal === undefined) {
9282
+ throw new Error(`Environment variable ${name} is not set`);
9283
+ }
9284
+ return envVal;
9285
+ });
9286
+ }
9287
+ if (Array.isArray(value)) {
9288
+ return value.map(substituteEnvVars);
9289
+ }
9290
+ if (value !== null && typeof value === 'object') {
9291
+ const result = {};
9292
+ for (const [key, val] of Object.entries(value)) {
9293
+ result[key] = substituteEnvVars(val);
9294
+ }
9295
+ return result;
9296
+ }
9297
+ return value;
9298
+ }
9299
+ /**
9300
+ * Resolve \@file: references in a config value.
9301
+ *
9302
+ * @param value - String value that may start with "\@file:".
9303
+ * @param baseDir - Base directory for resolving relative paths.
9304
+ * @returns The resolved string (file contents or original value).
9305
+ */
9306
+ function resolveFileRef(value, baseDir) {
9307
+ if (!value.startsWith('@file:'))
9308
+ return value;
9309
+ const filePath = join(baseDir, value.slice(6));
9310
+ return readFileSync(filePath, 'utf8');
9311
+ }
9312
+ /**
9313
+ * Load service config from a JSON file.
9314
+ *
9315
+ * Resolves \@file: references for defaultArchitect and defaultCritic,
9316
+ * and substitutes environment-variable placeholders throughout.
9317
+ *
9318
+ * @param configPath - Path to config JSON file.
9319
+ * @returns Validated ServiceConfig.
9320
+ */
9321
+ function loadServiceConfig(configPath) {
9322
+ const rawText = readFileSync(configPath, 'utf8');
9323
+ const raw = substituteEnvVars(JSON.parse(rawText));
9324
+ const baseDir = dirname(configPath);
9325
+ if (typeof raw['defaultArchitect'] === 'string') {
9326
+ raw['defaultArchitect'] = resolveFileRef(raw['defaultArchitect'], baseDir);
9327
+ }
9328
+ if (typeof raw['defaultCritic'] === 'string') {
9329
+ raw['defaultCritic'] = resolveFileRef(raw['defaultCritic'], baseDir);
9330
+ }
9331
+ return serviceConfigSchema.parse(raw);
9332
+ }
9333
+
9293
9334
  /**
9294
9335
  * Error thrown when a spawned subprocess is aborted via AbortController.
9295
9336
  *
@@ -9388,21 +9429,29 @@ class GatewayExecutor {
9388
9429
  }
9389
9430
  return data;
9390
9431
  }
9391
- /** Look up totalTokens for a session via sessions_list. */
9392
- async getSessionTokens(sessionKey) {
9432
+ /** Look up session metadata (tokens, completion status) via sessions_list. */
9433
+ async getSessionInfo(sessionKey) {
9393
9434
  try {
9394
9435
  const result = await this.invoke('sessions_list', {
9395
- limit: 20,
9436
+ limit: 200,
9396
9437
  messageLimit: 0,
9397
9438
  });
9398
9439
  const sessions = (result.result?.details?.sessions ??
9399
9440
  result.result?.sessions ??
9400
9441
  []);
9401
9442
  const match = sessions.find((s) => s.key === sessionKey);
9402
- return match?.totalTokens ?? undefined;
9443
+ if (!match) {
9444
+ // Session absent from list — likely cleaned up after completion.
9445
+ // With limit=200 this is reliable; a false positive here only
9446
+ // means we read the output file slightly early (still correct
9447
+ // if the file exists).
9448
+ return { completed: true };
9449
+ }
9450
+ const done = match.status === 'completed' || match.status === 'done';
9451
+ return { tokens: match.totalTokens, completed: done };
9403
9452
  }
9404
9453
  catch {
9405
- return undefined;
9454
+ return { completed: false };
9406
9455
  }
9407
9456
  }
9408
9457
  /** Whether this executor has been aborted by the operator. */
@@ -9468,48 +9517,53 @@ class GatewayExecutor {
9468
9517
  historyResult.result?.messages ??
9469
9518
  [];
9470
9519
  const msgArray = messages;
9520
+ // Check 1: terminal stop reason in history
9521
+ let historyDone = false;
9471
9522
  if (msgArray.length > 0) {
9472
9523
  const lastMsg = msgArray[msgArray.length - 1];
9473
- // Complete when last message is assistant with a terminal stop reason
9474
9524
  if (lastMsg.role === 'assistant' &&
9475
9525
  lastMsg.stopReason &&
9476
9526
  lastMsg.stopReason !== 'toolUse' &&
9477
9527
  lastMsg.stopReason !== 'error') {
9478
- // Fetch token usage from session metadata
9479
- const tokens = await this.getSessionTokens(sessionKey);
9480
- // Read output from file (sub-agent wrote it via Write tool)
9481
- if (existsSync(outputPath)) {
9528
+ historyDone = true;
9529
+ }
9530
+ }
9531
+ // Check 2: session completion status via sessions_list
9532
+ const sessionInfo = await this.getSessionInfo(sessionKey);
9533
+ if (historyDone || sessionInfo.completed) {
9534
+ const tokens = sessionInfo.tokens;
9535
+ // Read output from file (sub-agent wrote it via Write tool)
9536
+ if (existsSync(outputPath)) {
9537
+ try {
9538
+ const output = readFileSync(outputPath, 'utf8');
9539
+ return { output, tokens };
9540
+ }
9541
+ finally {
9482
9542
  try {
9483
- const output = readFileSync(outputPath, 'utf8');
9484
- return { output, tokens };
9543
+ unlinkSync(outputPath);
9485
9544
  }
9486
- finally {
9487
- try {
9488
- unlinkSync(outputPath);
9489
- }
9490
- catch {
9491
- /* cleanup best-effort */
9492
- }
9545
+ catch {
9546
+ /* cleanup best-effort */
9493
9547
  }
9494
9548
  }
9495
- // Fallback: extract from message content if file wasn't written
9496
- for (let i = msgArray.length - 1; i >= 0; i--) {
9497
- const msg = msgArray[i];
9498
- if (msg.role === 'assistant' && msg.content) {
9499
- const text = typeof msg.content === 'string'
9549
+ }
9550
+ // Fallback: extract from message content if file wasn't written
9551
+ for (let i = msgArray.length - 1; i >= 0; i--) {
9552
+ const msg = msgArray[i];
9553
+ if (msg.role === 'assistant' && msg.content) {
9554
+ const text = typeof msg.content === 'string'
9555
+ ? msg.content
9556
+ : Array.isArray(msg.content)
9500
9557
  ? msg.content
9501
- : Array.isArray(msg.content)
9502
- ? msg.content
9503
- .filter((b) => b.type === 'text' && b.text)
9504
- .map((b) => b.text)
9505
- .join('\n')
9506
- : '';
9507
- if (text)
9508
- return { output: text, tokens };
9509
- }
9558
+ .filter((b) => b.type === 'text' && b.text)
9559
+ .map((b) => b.text)
9560
+ .join('\n')
9561
+ : '';
9562
+ if (text)
9563
+ return { output: text, tokens };
9510
9564
  }
9511
- return { output: '', tokens };
9512
9565
  }
9566
+ return { output: '', tokens };
9513
9567
  }
9514
9568
  }
9515
9569
  catch {
@@ -9872,6 +9926,7 @@ async function buildContextPackage(node, meta, watcher, logger) {
9872
9926
  *
9873
9927
  * @module orchestrator/buildTask
9874
9928
  */
9929
+ Handlebars.registerHelper('gt', (a, b) => a > b);
9875
9930
  /** Build the template context from synthesis inputs. */
9876
9931
  function buildTemplateContext(ctx, meta, config) {
9877
9932
  return {
@@ -10026,134 +10081,6 @@ function buildCriticTask(ctx, meta, config) {
10026
10081
  return compileTemplate(sections.join('\n'), buildTemplateContext(ctx, meta, config));
10027
10082
  }
10028
10083
 
10029
- /**
10030
- * Structured error from a synthesis step failure.
10031
- *
10032
- * @module schema/error
10033
- */
10034
- /** Zod schema for synthesis step errors. */
10035
- const metaErrorSchema = z.object({
10036
- /** Which step failed: 'architect', 'builder', or 'critic'. */
10037
- step: z.enum(['architect', 'builder', 'critic']),
10038
- /** Error classification code. */
10039
- code: z.string(),
10040
- /** Human-readable error message. */
10041
- message: z.string(),
10042
- });
10043
-
10044
- /**
10045
- * Zod schema for .meta/meta.json files.
10046
- *
10047
- * Reserved properties are underscore-prefixed and engine-managed.
10048
- * All other keys are open schema (builder output).
10049
- *
10050
- * @module schema/meta
10051
- */
10052
- /** Valid states for a synthesis phase. */
10053
- const phaseStatuses = [
10054
- 'fresh',
10055
- 'stale',
10056
- 'pending',
10057
- 'running',
10058
- 'failed',
10059
- ];
10060
- /** Zod schema for a per-phase status value. */
10061
- const phaseStatusSchema = z.enum(phaseStatuses);
10062
- /** Zod schema for the per-meta phase state record. */
10063
- const phaseStateSchema = z.object({
10064
- architect: phaseStatusSchema,
10065
- builder: phaseStatusSchema,
10066
- critic: phaseStatusSchema,
10067
- });
10068
- /** Zod schema for the reserved (underscore-prefixed) meta.json properties. */
10069
- z
10070
- .object({
10071
- /** Stable identity. Auto-generated on first synthesis if not provided. */
10072
- _id: z.uuid().optional(),
10073
- /** Human-provided steering prompt. Optional. */
10074
- _steer: z.string().optional(),
10075
- /**
10076
- * Explicit cross-references to other meta owner paths.
10077
- * Referenced metas' _content is included as architect/builder context.
10078
- */
10079
- _crossRefs: z.array(z.string()).optional(),
10080
- /** Architect system prompt used this turn. Defaults from config. */
10081
- _architect: z.string().optional(),
10082
- /**
10083
- * Task brief generated by the architect. Cached and reused across cycles;
10084
- * regenerated only when triggered.
10085
- */
10086
- _builder: z.string().optional(),
10087
- /** Critic system prompt used this turn. Defaults from config. */
10088
- _critic: z.string().optional(),
10089
- /** Timestamp of last synthesis. ISO 8601. */
10090
- _generatedAt: z.iso.datetime().optional(),
10091
- /** Narrative synthesis output. Rendered by watcher for embedding. */
10092
- _content: z.string().optional(),
10093
- /**
10094
- * Hash of sorted file listing in scope. Detects directory structure
10095
- * changes that trigger an architect re-run.
10096
- */
10097
- _structureHash: z.string().optional(),
10098
- /**
10099
- * Cycles since last architect run. Reset to 0 when architect runs.
10100
- * Used with architectEvery to trigger periodic re-prompting.
10101
- */
10102
- _synthesisCount: z.number().int().min(0).optional(),
10103
- /** Critic evaluation of the last synthesis. */
10104
- _feedback: z.string().optional(),
10105
- /**
10106
- * Present and true on archive snapshots. Distinguishes live vs. archived
10107
- * metas.
10108
- */
10109
- _archived: z.boolean().optional(),
10110
- /** Timestamp when this snapshot was archived. ISO 8601. */
10111
- _archivedAt: z.iso.datetime().optional(),
10112
- /**
10113
- * Scheduling priority. Higher = updates more often. Negative allowed;
10114
- * normalized to min 0 at scheduling time.
10115
- */
10116
- _depth: z.number().optional(),
10117
- /**
10118
- * Emphasis multiplier for depth weighting in scheduling.
10119
- * Default 1. Higher values increase this meta's scheduling priority
10120
- * relative to its depth. Set to 0.5 to halve the depth effect,
10121
- * 2 to double it, 0 to ignore depth entirely for this meta.
10122
- */
10123
- _emphasis: z.number().min(0).optional(),
10124
- /** Token count from last architect subprocess call. */
10125
- _architectTokens: z.number().int().optional(),
10126
- /** Token count from last builder subprocess call. */
10127
- _builderTokens: z.number().int().optional(),
10128
- /** Token count from last critic subprocess call. */
10129
- _criticTokens: z.number().int().optional(),
10130
- /** Exponential moving average of architect token usage (decay 0.3). */
10131
- _architectTokensAvg: z.number().optional(),
10132
- /** Exponential moving average of builder token usage (decay 0.3). */
10133
- _builderTokensAvg: z.number().optional(),
10134
- /** Exponential moving average of critic token usage (decay 0.3). */
10135
- _criticTokensAvg: z.number().optional(),
10136
- /**
10137
- * Opaque state carried across synthesis cycles for progressive work.
10138
- * Set by the builder, passed back as context on next cycle.
10139
- */
10140
- _state: z.unknown().optional(),
10141
- /**
10142
- * Structured error from last cycle. Present when a step failed.
10143
- * Cleared on successful cycle.
10144
- */
10145
- _error: metaErrorSchema.optional(),
10146
- /** When true, this meta is skipped during staleness scheduling. Manual trigger still works. */
10147
- _disabled: z.boolean().optional(),
10148
- /**
10149
- * Per-phase state machine record. Engine-managed.
10150
- * Keyed by phase name (architect, builder, critic) with status values.
10151
- * Persisted to survive ticks; derived on first load for back-compat.
10152
- */
10153
- _phaseState: phaseStateSchema.optional(),
10154
- })
10155
- .loose();
10156
-
10157
10084
  /**
10158
10085
  * Build a minimal MetaNode from a known meta path using watcher walk.
10159
10086
  *
@@ -10215,222 +10142,6 @@ async function buildMinimalNode(metaPath, watcher) {
10215
10142
  return node;
10216
10143
  }
10217
10144
 
10218
- /**
10219
- * Weighted staleness formula for candidate selection.
10220
- *
10221
- * effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
10222
- *
10223
- * @module scheduling/weightedFormula
10224
- */
10225
- /**
10226
- * Compute effective staleness for a set of candidates.
10227
- *
10228
- * Normalizes depths so the minimum becomes 0, then applies the formula:
10229
- * effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
10230
- *
10231
- * Per-meta _emphasis (default 1) multiplies depthWeight, allowing individual
10232
- * metas to tune how much their tree position affects scheduling.
10233
- *
10234
- * @param candidates - Array of \{ node, meta, actualStaleness \}.
10235
- * @param depthWeight - Exponent for depth weighting (0 = pure staleness).
10236
- * @returns Same array with effectiveStaleness computed.
10237
- */
10238
- function computeEffectiveStaleness(candidates, depthWeight) {
10239
- if (candidates.length === 0)
10240
- return [];
10241
- // Get depth for each candidate: use _depth override or tree depth
10242
- const depths = candidates.map((c) => c.meta._depth ?? c.node.treeDepth);
10243
- // Normalize: shift so minimum becomes 0
10244
- const minDepth = Math.min(...depths);
10245
- const normalizedDepths = depths.map((d) => Math.max(0, d - minDepth));
10246
- return candidates.map((c, i) => {
10247
- const emphasis = c.meta._emphasis ?? 1;
10248
- return {
10249
- ...c,
10250
- effectiveStaleness: c.actualStaleness *
10251
- Math.pow(normalizedDepths[i] + 1, depthWeight * emphasis),
10252
- };
10253
- });
10254
- }
10255
-
10256
- /**
10257
- * Select the best synthesis candidate from stale metas.
10258
- *
10259
- * Picks the meta with highest effective staleness.
10260
- *
10261
- * @module scheduling/selectCandidate
10262
- */
10263
- /**
10264
- * Select the candidate with the highest effective staleness.
10265
- *
10266
- * @param candidates - Array of candidates with computed effective staleness.
10267
- * @returns The winning candidate, or null if no candidates.
10268
- */
10269
- function selectCandidate(candidates) {
10270
- if (candidates.length === 0)
10271
- return null;
10272
- let best = candidates[0];
10273
- for (let i = 1; i < candidates.length; i++) {
10274
- if (candidates[i].effectiveStaleness > best.effectiveStaleness) {
10275
- best = candidates[i];
10276
- }
10277
- }
10278
- return best;
10279
- }
10280
- /**
10281
- * Extract stale candidates from a list and return the stalest path.
10282
- *
10283
- * Consolidates the repeated pattern of:
10284
- * filter → computeEffectiveStaleness → selectCandidate → return path
10285
- *
10286
- * @param candidates - Array with node, meta, and stalenessSeconds.
10287
- * @param depthWeight - Depth weighting exponent from config.
10288
- * @returns The stalest candidate's metaPath, or null if none are stale.
10289
- */
10290
- function discoverStalestPath(candidates, depthWeight) {
10291
- const weighted = computeEffectiveStaleness(candidates, depthWeight);
10292
- const winner = selectCandidate(weighted);
10293
- return winner?.node.metaPath ?? null;
10294
- }
10295
-
10296
- /**
10297
- * Shared error utilities.
10298
- *
10299
- * @module errors
10300
- */
10301
- /**
10302
- * Wrap an unknown caught value into a MetaError.
10303
- *
10304
- * @param step - Which synthesis step failed.
10305
- * @param err - The caught error value.
10306
- * @param code - Error classification code.
10307
- * @returns A structured MetaError.
10308
- */
10309
- function toMetaError(step, err, code = 'FAILED') {
10310
- return {
10311
- step,
10312
- code,
10313
- message: err instanceof Error ? err.message : String(err),
10314
- };
10315
- }
10316
-
10317
- /**
10318
- * Compute a structure hash from a sorted file listing.
10319
- *
10320
- * Used to detect when directory structure changes, triggering
10321
- * an architect re-run.
10322
- *
10323
- * @module structureHash
10324
- */
10325
- /**
10326
- * Compute a SHA-256 hash of a sorted file listing.
10327
- *
10328
- * @param filePaths - Array of file paths in scope.
10329
- * @returns Hex-encoded SHA-256 hash of the sorted, newline-joined paths.
10330
- */
10331
- function computeStructureHash(filePaths) {
10332
- const sorted = [...filePaths].sort();
10333
- const content = sorted.join('\n');
10334
- return createHash('sha256').update(content).digest('hex');
10335
- }
10336
-
10337
- /**
10338
- * Parse subprocess outputs for each synthesis step.
10339
- *
10340
- * - Architect: returns text \> _builder
10341
- * - Builder: returns JSON \> _content + structured fields
10342
- * - Critic: returns text \> _feedback
10343
- *
10344
- * @module orchestrator/parseOutput
10345
- */
10346
- /**
10347
- * Parse architect output. The architect returns a task brief as text.
10348
- *
10349
- * @param output - Raw subprocess output.
10350
- * @returns The task brief string.
10351
- */
10352
- function parseArchitectOutput(output) {
10353
- return output.trim();
10354
- }
10355
- /**
10356
- * Parse builder output. The builder returns JSON with _content and optional fields.
10357
- *
10358
- * Attempts JSON parse first. If that fails, treats the entire output as _content.
10359
- *
10360
- * @param output - Raw subprocess output.
10361
- * @returns Parsed builder output with content and structured fields.
10362
- */
10363
- function parseBuilderOutput(output) {
10364
- const trimmed = output.trim();
10365
- // Strategy 1: Try to parse the entire output as JSON directly
10366
- const direct = tryParseJson(trimmed);
10367
- if (direct)
10368
- return direct;
10369
- // Strategy 2: Try all fenced code blocks (last match first — models often narrate then output)
10370
- const fencePattern = /```(?:json)?\s*([\s\S]*?)```/g;
10371
- const fenceMatches = [];
10372
- let match;
10373
- while ((match = fencePattern.exec(trimmed)) !== null) {
10374
- fenceMatches.push(match[1].trim());
10375
- }
10376
- // Try last fence first (most likely to be the actual output)
10377
- for (let i = fenceMatches.length - 1; i >= 0; i--) {
10378
- const result = tryParseJson(fenceMatches[i]);
10379
- if (result)
10380
- return result;
10381
- }
10382
- // Strategy 3: Find outermost { ... } braces
10383
- const firstBrace = trimmed.indexOf('{');
10384
- const lastBrace = trimmed.lastIndexOf('}');
10385
- if (firstBrace !== -1 && lastBrace > firstBrace) {
10386
- const result = tryParseJson(trimmed.substring(firstBrace, lastBrace + 1));
10387
- if (result)
10388
- return result;
10389
- }
10390
- // Fallback: treat entire output as content
10391
- return { content: trimmed, fields: {} };
10392
- }
10393
- /** Try to parse a string as JSON and extract builder output fields. */
10394
- function tryParseJson(str) {
10395
- try {
10396
- const raw = JSON.parse(str);
10397
- if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
10398
- return null;
10399
- }
10400
- const parsed = raw;
10401
- // Extract _content
10402
- const content = typeof parsed['_content'] === 'string'
10403
- ? parsed['_content']
10404
- : typeof parsed['content'] === 'string'
10405
- ? parsed['content']
10406
- : null;
10407
- if (content === null)
10408
- return null;
10409
- // Extract _state (the ONLY underscore key the builder is allowed to set)
10410
- const state = '_state' in parsed ? parsed['_state'] : undefined;
10411
- // Extract non-underscore fields
10412
- const fields = {};
10413
- for (const [key, value] of Object.entries(parsed)) {
10414
- if (!key.startsWith('_') && key !== 'content') {
10415
- fields[key] = value;
10416
- }
10417
- }
10418
- return { content, fields, ...(state !== undefined ? { state } : {}) };
10419
- }
10420
- catch {
10421
- return null;
10422
- }
10423
- }
10424
- /**
10425
- * Parse critic output. The critic returns evaluation text.
10426
- *
10427
- * @param output - Raw subprocess output.
10428
- * @returns The feedback string.
10429
- */
10430
- function parseCriticOutput(output) {
10431
- return output.trim();
10432
- }
10433
-
10434
10145
  /**
10435
10146
  * Pure phase-state transition functions.
10436
10147
  *
@@ -10480,7 +10191,42 @@ function enforceInvariant(state) {
10480
10191
  // running in non-first position would be a bug, but don't mask it
10481
10192
  }
10482
10193
  }
10483
- return result;
10194
+ return result;
10195
+ }
10196
+ // ── Invalidation cascades ──────────────────────────────────────────────
10197
+ /**
10198
+ * Architect invalidated: architect → pending; builder, critic → stale.
10199
+ * Triggers: _structureHash change, _steer change, _architect change,
10200
+ * _crossRefs declaration change, _synthesisCount \>= architectEvery.
10201
+ */
10202
+ function invalidateArchitect(state) {
10203
+ return enforceInvariant({
10204
+ architect: state.architect === 'failed' ? 'failed' : 'pending',
10205
+ builder: state.builder === 'fresh' ? 'stale' : state.builder,
10206
+ critic: state.critic === 'fresh' ? 'stale' : state.critic,
10207
+ });
10208
+ }
10209
+ /**
10210
+ * Builder invalidated (scope mtime or cross-ref _content change):
10211
+ * builder → pending; critic → stale.
10212
+ * Only applies when architect is fresh; otherwise, builder stays stale.
10213
+ */
10214
+ function invalidateBuilder(state) {
10215
+ if (state.architect !== 'fresh') {
10216
+ // Architect is not fresh — builder stays stale (or whatever it is)
10217
+ return enforceInvariant({
10218
+ ...state,
10219
+ builder: state.builder === 'fresh' || state.builder === 'stale'
10220
+ ? 'stale'
10221
+ : state.builder,
10222
+ critic: state.critic === 'fresh' ? 'stale' : state.critic,
10223
+ });
10224
+ }
10225
+ return enforceInvariant({
10226
+ ...state,
10227
+ builder: state.builder === 'failed' ? 'failed' : 'pending',
10228
+ critic: state.critic === 'fresh' ? 'stale' : state.critic,
10229
+ });
10484
10230
  }
10485
10231
  // ── Phase success transitions ──────────────────────────────────────────
10486
10232
  /**
@@ -10650,7 +10396,9 @@ function derivePhaseState(meta, inputs) {
10650
10396
  }
10651
10397
  // Check architect invalidation (when inputs are provided)
10652
10398
  if (inputs) {
10653
- const architectInvalidated = inputs.structureChanged ||
10399
+ // Progressive metas: structure changes invalidate builder, not architect
10400
+ const structureInvalidatesArchitect = inputs.structureChanged && meta._state === undefined;
10401
+ const architectInvalidated = structureInvalidatesArchitect ||
10654
10402
  inputs.steerChanged ||
10655
10403
  inputs.architectChanged ||
10656
10404
  inputs.crossRefsChanged ||
@@ -10662,6 +10410,14 @@ function derivePhaseState(meta, inputs) {
10662
10410
  critic: 'stale',
10663
10411
  };
10664
10412
  }
10413
+ // Progressive meta with structure change: builder-only invalidation
10414
+ if (inputs.structureChanged && meta._state !== undefined) {
10415
+ return {
10416
+ architect: 'fresh',
10417
+ builder: 'pending',
10418
+ critic: 'stale',
10419
+ };
10420
+ }
10665
10421
  }
10666
10422
  // Has _builder but no _content: builder is pending
10667
10423
  if (meta._builder && !meta._content) {
@@ -10683,6 +10439,154 @@ function derivePhaseState(meta, inputs) {
10683
10439
  return freshPhaseState();
10684
10440
  }
10685
10441
 
10442
+ /**
10443
+ * Compute a structure hash from a sorted file listing.
10444
+ *
10445
+ * Used to detect when directory structure changes, triggering
10446
+ * an architect re-run.
10447
+ *
10448
+ * @module structureHash
10449
+ */
10450
+ /**
10451
+ * Compute a SHA-256 hash of a sorted file listing.
10452
+ *
10453
+ * @param filePaths - Array of file paths in scope.
10454
+ * @returns Hex-encoded SHA-256 hash of the sorted, newline-joined paths.
10455
+ */
10456
+ function computeStructureHash(filePaths) {
10457
+ const sorted = [...filePaths].sort();
10458
+ const content = sorted.join('\n');
10459
+ return createHash('sha256').update(content).digest('hex');
10460
+ }
10461
+
10462
+ /**
10463
+ * Per-tick invalidation pass.
10464
+ *
10465
+ * Computes architect-invalidating and builder-invalidating inputs for a meta,
10466
+ * then applies the cascade to update _phaseState.
10467
+ *
10468
+ * @module phaseState/invalidate
10469
+ */
10470
+ /**
10471
+ * Compute invalidation inputs and apply cascade for a single meta.
10472
+ *
10473
+ * @param meta - Current meta.json content with existing _phaseState.
10474
+ * @param scopeFiles - Sorted file list from scope.
10475
+ * @param config - MetaConfig for architectEvery.
10476
+ * @param node - MetaNode for archive access.
10477
+ * @param crossRefMetas - Map of cross-ref owner paths to their current _content.
10478
+ * @param archiveCrossRefContent - Map of cross-ref owner paths to their archived _content.
10479
+ * @returns Updated phase state and invalidation details.
10480
+ */
10481
+ async function computeInvalidation(meta, scopeFiles, config, node, crossRefMetas, archiveCrossRefContent) {
10482
+ let phaseState = meta._phaseState ?? {
10483
+ architect: 'fresh',
10484
+ builder: 'fresh',
10485
+ critic: 'fresh',
10486
+ };
10487
+ // ── Architect-level inputs ──
10488
+ const structureHash = computeStructureHash(scopeFiles);
10489
+ const structureChanged = structureHash !== meta._structureHash;
10490
+ const latestArchive = await readLatestArchive(node.metaPath);
10491
+ const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
10492
+ // _architect change: compare current vs. archive
10493
+ const architectChanged = latestArchive
10494
+ ? (meta._architect ?? '') !== (latestArchive._architect ?? '')
10495
+ : Boolean(meta._architect);
10496
+ // _crossRefs declaration change
10497
+ const currentRefs = (meta._crossRefs ?? []).slice().sort().join(',');
10498
+ const archiveRefs = (latestArchive?._crossRefs ?? [])
10499
+ .slice()
10500
+ .sort()
10501
+ .join(',');
10502
+ const crossRefsDeclChanged = latestArchive
10503
+ ? currentRefs !== archiveRefs
10504
+ : currentRefs.length > 0;
10505
+ const architectInvalidators = [];
10506
+ if (structureChanged) {
10507
+ if (meta._state !== undefined) {
10508
+ // Progressive entity: new files → builder only (cursor handles incremental)
10509
+ phaseState = invalidateBuilder(phaseState);
10510
+ }
10511
+ else {
10512
+ architectInvalidators.push('structureHash');
10513
+ }
10514
+ }
10515
+ if (steerChanged)
10516
+ architectInvalidators.push('steer');
10517
+ if (architectChanged)
10518
+ architectInvalidators.push('_architect');
10519
+ if (crossRefsDeclChanged)
10520
+ architectInvalidators.push('_crossRefs');
10521
+ if ((meta._synthesisCount ?? 0) >= config.architectEvery) {
10522
+ architectInvalidators.push('architectEvery');
10523
+ }
10524
+ // First-run check: no _builder means architect must run
10525
+ const firstRun = !meta._builder;
10526
+ if (architectInvalidators.length > 0 || firstRun) {
10527
+ phaseState = invalidateArchitect(phaseState);
10528
+ }
10529
+ // ── Builder-level inputs ──
10530
+ // Scope file mtime check — if any file newer than _generatedAt
10531
+ const scopeMtimeMax = null;
10532
+ // Note: actual mtime check is done by the caller or via isStale;
10533
+ // here we just detect cross-ref content changes for the cascade.
10534
+ // Cross-ref _content change (builder-invalidating)
10535
+ let crossRefContentChanged = false;
10536
+ return {
10537
+ phaseState,
10538
+ architectInvalidators,
10539
+ stalenessInputs: {
10540
+ structureHash,
10541
+ steerChanged,
10542
+ architectChanged,
10543
+ crossRefsDeclChanged,
10544
+ scopeMtimeMax,
10545
+ crossRefContentChanged,
10546
+ },
10547
+ structureHash,
10548
+ steerChanged,
10549
+ };
10550
+ }
10551
+
10552
+ /**
10553
+ * Weighted staleness formula for candidate selection.
10554
+ *
10555
+ * effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
10556
+ *
10557
+ * @module scheduling/weightedFormula
10558
+ */
10559
+ /**
10560
+ * Compute effective staleness for a set of candidates.
10561
+ *
10562
+ * Normalizes depths so the minimum becomes 0, then applies the formula:
10563
+ * effectiveStaleness = actualStaleness * (normalizedDepth + 1) ^ (depthWeight * emphasis)
10564
+ *
10565
+ * Per-meta _emphasis (default 1) multiplies depthWeight, allowing individual
10566
+ * metas to tune how much their tree position affects scheduling.
10567
+ *
10568
+ * @param candidates - Array of \{ node, meta, actualStaleness \}.
10569
+ * @param depthWeight - Exponent for depth weighting (0 = pure staleness).
10570
+ * @returns Same array with effectiveStaleness computed.
10571
+ */
10572
+ function computeEffectiveStaleness(candidates, depthWeight) {
10573
+ if (candidates.length === 0)
10574
+ return [];
10575
+ // Get depth for each candidate: use _depth override or tree depth
10576
+ const depths = candidates.map((c) => c.meta._depth ?? c.node.treeDepth);
10577
+ // Normalize: shift so minimum becomes 0
10578
+ const minDepth = Math.min(...depths);
10579
+ const normalizedDepths = depths.map((d) => Math.max(0, d - minDepth));
10580
+ return candidates.map((c, i) => {
10581
+ const emphasis = c.meta._emphasis ?? 1;
10582
+ return {
10583
+ ...c,
10584
+ effectiveStaleness: c.actualStaleness *
10585
+ Math.pow(normalizedDepths[i] + 1, depthWeight * emphasis),
10586
+ };
10587
+ });
10588
+ }
10589
+
10686
10590
  /**
10687
10591
  * Corpus-wide phase scheduler.
10688
10592
  *
@@ -10695,18 +10599,30 @@ function derivePhaseState(meta, inputs) {
10695
10599
  /**
10696
10600
  * Build phase candidates from listMetas entries.
10697
10601
  *
10698
- * Derives phase state and auto-retries failed phases for each entry.
10602
+ * Derives phase state, auto-retries failed phases, and applies Tier 1
10603
+ * cheap-invalidation (no I/O) for metas with persisted _phaseState.
10699
10604
  * Used by orchestratePhase, queue route, and status route.
10700
10605
  */
10701
- function buildPhaseCandidates(entries) {
10702
- return entries.map((entry) => ({
10703
- node: entry.node,
10704
- meta: entry.meta,
10705
- phaseState: retryAllFailed(derivePhaseState(entry.meta)),
10706
- actualStaleness: entry.stalenessSeconds,
10707
- locked: entry.locked,
10708
- disabled: entry.disabled,
10709
- }));
10606
+ function buildPhaseCandidates(entries, architectEvery) {
10607
+ return entries.map((entry) => {
10608
+ let ps = retryAllFailed(derivePhaseState(entry.meta));
10609
+ // Tier 1 cheap invalidation for metas with persisted _phaseState
10610
+ if (entry.meta._phaseState) {
10611
+ const needsArchitect = !entry.meta._builder ||
10612
+ (entry.meta._synthesisCount ?? 0) >= architectEvery;
10613
+ if (needsArchitect && ps.architect === 'fresh') {
10614
+ ps = { architect: 'pending', builder: 'stale', critic: 'stale' };
10615
+ }
10616
+ }
10617
+ return {
10618
+ node: entry.node,
10619
+ meta: entry.meta,
10620
+ phaseState: ps,
10621
+ actualStaleness: entry.stalenessSeconds,
10622
+ locked: entry.locked,
10623
+ disabled: entry.disabled,
10624
+ };
10625
+ });
10710
10626
  }
10711
10627
  /**
10712
10628
  * Rank all eligible phase candidates by priority.
@@ -10769,6 +10685,124 @@ function selectPhaseCandidate(metas, depthWeight) {
10769
10685
  return rankPhaseCandidates(metas, depthWeight)[0] ?? null;
10770
10686
  }
10771
10687
 
10688
+ /**
10689
+ * Shared error utilities.
10690
+ *
10691
+ * @module errors
10692
+ */
10693
+ /**
10694
+ * Wrap an unknown caught value into a MetaError.
10695
+ *
10696
+ * @param step - Which synthesis step failed.
10697
+ * @param err - The caught error value.
10698
+ * @param code - Error classification code.
10699
+ * @returns A structured MetaError.
10700
+ */
10701
+ function toMetaError(step, err, code = 'FAILED') {
10702
+ return {
10703
+ step,
10704
+ code,
10705
+ message: err instanceof Error ? err.message : String(err),
10706
+ };
10707
+ }
10708
+
10709
+ /**
10710
+ * Parse subprocess outputs for each synthesis step.
10711
+ *
10712
+ * - Architect: returns text \> _builder
10713
+ * - Builder: returns JSON \> _content + structured fields
10714
+ * - Critic: returns text \> _feedback
10715
+ *
10716
+ * @module orchestrator/parseOutput
10717
+ */
10718
+ /**
10719
+ * Parse architect output. The architect returns a task brief as text.
10720
+ *
10721
+ * @param output - Raw subprocess output.
10722
+ * @returns The task brief string.
10723
+ */
10724
+ function parseArchitectOutput(output) {
10725
+ return output.trim();
10726
+ }
10727
+ /**
10728
+ * Parse builder output. The builder returns JSON with _content and optional fields.
10729
+ *
10730
+ * Attempts JSON parse first. If that fails, treats the entire output as _content.
10731
+ *
10732
+ * @param output - Raw subprocess output.
10733
+ * @returns Parsed builder output with content and structured fields.
10734
+ */
10735
+ function parseBuilderOutput(output) {
10736
+ const trimmed = output.trim();
10737
+ // Strategy 1: Try to parse the entire output as JSON directly
10738
+ const direct = tryParseJson(trimmed);
10739
+ if (direct)
10740
+ return direct;
10741
+ // Strategy 2: Try all fenced code blocks (last match first — models often narrate then output)
10742
+ const fencePattern = /```(?:json)?\s*([\s\S]*?)```/g;
10743
+ const fenceMatches = [];
10744
+ let match;
10745
+ while ((match = fencePattern.exec(trimmed)) !== null) {
10746
+ fenceMatches.push(match[1].trim());
10747
+ }
10748
+ // Try last fence first (most likely to be the actual output)
10749
+ for (let i = fenceMatches.length - 1; i >= 0; i--) {
10750
+ const result = tryParseJson(fenceMatches[i]);
10751
+ if (result)
10752
+ return result;
10753
+ }
10754
+ // Strategy 3: Find outermost { ... } braces
10755
+ const firstBrace = trimmed.indexOf('{');
10756
+ const lastBrace = trimmed.lastIndexOf('}');
10757
+ if (firstBrace !== -1 && lastBrace > firstBrace) {
10758
+ const result = tryParseJson(trimmed.substring(firstBrace, lastBrace + 1));
10759
+ if (result)
10760
+ return result;
10761
+ }
10762
+ // Fallback: treat entire output as content
10763
+ return { content: trimmed, fields: {} };
10764
+ }
10765
+ /** Try to parse a string as JSON and extract builder output fields. */
10766
+ function tryParseJson(str) {
10767
+ try {
10768
+ const raw = JSON.parse(str);
10769
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
10770
+ return null;
10771
+ }
10772
+ const parsed = raw;
10773
+ // Extract _content
10774
+ const content = typeof parsed['_content'] === 'string'
10775
+ ? parsed['_content']
10776
+ : typeof parsed['content'] === 'string'
10777
+ ? parsed['content']
10778
+ : null;
10779
+ if (content === null)
10780
+ return null;
10781
+ // Extract _state (the ONLY underscore key the builder is allowed to set)
10782
+ const state = '_state' in parsed ? parsed['_state'] : undefined;
10783
+ // Extract non-underscore fields
10784
+ const fields = {};
10785
+ for (const [key, value] of Object.entries(parsed)) {
10786
+ if (!key.startsWith('_') && key !== 'content') {
10787
+ fields[key] = value;
10788
+ }
10789
+ }
10790
+ return { content, fields, ...(state !== undefined ? { state } : {}) };
10791
+ }
10792
+ catch {
10793
+ return null;
10794
+ }
10795
+ }
10796
+ /**
10797
+ * Parse critic output. The critic returns evaluation text.
10798
+ *
10799
+ * @param output - Raw subprocess output.
10800
+ * @returns The feedback string.
10801
+ */
10802
+ function parseCriticOutput(output) {
10803
+ return output.trim();
10804
+ }
10805
+
10772
10806
  /**
10773
10807
  * Per-phase executors for the phase-state machine.
10774
10808
  *
@@ -11027,7 +11061,7 @@ async function orchestratePhase(config, executor, watcher, targetPath, onProgres
11027
11061
  if (metaResult.entries.length === 0)
11028
11062
  return { executed: false };
11029
11063
  // Build candidates with phase state (including invalidation + auto-retry)
11030
- const candidates = buildPhaseCandidates(metaResult.entries);
11064
+ const candidates = buildPhaseCandidates(metaResult.entries, config.architectEvery);
11031
11065
  // Select best phase candidate
11032
11066
  const winner = selectPhaseCandidate(candidates, config.depthWeight);
11033
11067
  if (!winner) {
@@ -11702,46 +11736,6 @@ function buildMetaRules(config) {
11702
11736
  },
11703
11737
  renderAs: 'md',
11704
11738
  },
11705
- {
11706
- name: 'meta-config',
11707
- description: 'jeeves-meta configuration file',
11708
- match: {
11709
- properties: {
11710
- file: {
11711
- properties: {
11712
- path: {
11713
- type: 'string',
11714
- glob: '**/jeeves-meta{.config.json,/config.json}',
11715
- },
11716
- },
11717
- },
11718
- },
11719
- },
11720
- schema: ['base', { properties: { domains: { set: ['meta-config'] } } }],
11721
- render: {
11722
- frontmatter: [
11723
- 'watcherUrl',
11724
- 'gatewayUrl',
11725
- 'architectEvery',
11726
- 'depthWeight',
11727
- 'maxArchive',
11728
- 'maxLines',
11729
- ],
11730
- body: [
11731
- {
11732
- path: 'json.defaultArchitect',
11733
- heading: 2,
11734
- label: 'Default Architect Prompt',
11735
- },
11736
- {
11737
- path: 'json.defaultCritic',
11738
- heading: 2,
11739
- label: 'Default Critic Prompt',
11740
- },
11741
- ],
11742
- },
11743
- renderAs: 'md',
11744
- },
11745
11739
  ];
11746
11740
  }
11747
11741
  /**
@@ -11985,13 +11979,15 @@ class Scheduler {
11985
11979
  queue;
11986
11980
  logger;
11987
11981
  watcher;
11982
+ cache;
11988
11983
  registrar = null;
11989
11984
  currentExpression;
11990
- constructor(config, queue, logger, watcher) {
11985
+ constructor(config, queue, logger, watcher, cache) {
11991
11986
  this.config = config;
11992
11987
  this.queue = queue;
11993
11988
  this.logger = logger;
11994
11989
  this.watcher = watcher;
11990
+ this.cache = cache;
11995
11991
  this.currentExpression = config.schedule;
11996
11992
  }
11997
11993
  /** Set the rule registrar for watcher restart detection. */
@@ -12108,8 +12104,8 @@ class Scheduler {
12108
12104
  */
12109
12105
  async discoverNextPhase() {
12110
12106
  try {
12111
- const result = await listMetas(this.config, this.watcher);
12112
- const candidates = buildPhaseCandidates(result.entries);
12107
+ const result = await this.cache.get(this.config, this.watcher);
12108
+ const candidates = buildPhaseCandidates(result.entries, this.config.architectEvery);
12113
12109
  const winner = selectPhaseCandidate(candidates, this.config.depthWeight);
12114
12110
  if (!winner)
12115
12111
  return null;
@@ -12534,11 +12530,11 @@ function registerMetasUpdateRoute(app, deps) {
12534
12530
  */
12535
12531
  function registerPreviewRoute(app, deps) {
12536
12532
  app.get('/preview', async (request, reply) => {
12537
- const { config, watcher } = deps;
12533
+ const { config, watcher, cache } = deps;
12538
12534
  const query = request.query;
12539
12535
  let result;
12540
12536
  try {
12541
- result = await listMetas(config, watcher);
12537
+ result = await cache.get(config, watcher);
12542
12538
  }
12543
12539
  catch {
12544
12540
  return reply.status(503).send({
@@ -12558,40 +12554,24 @@ function registerPreviewRoute(app, deps) {
12558
12554
  }
12559
12555
  }
12560
12556
  else {
12561
- // Select stalest candidate
12562
- const stale = result.entries
12563
- .filter((e) => e.stalenessSeconds > 0)
12564
- .map((e) => ({
12565
- node: e.node,
12566
- meta: e.meta,
12567
- actualStaleness: e.stalenessSeconds,
12568
- }));
12569
- const stalestPath = discoverStalestPath(stale, config.depthWeight);
12570
- if (!stalestPath) {
12557
+ // Select best phase candidate
12558
+ const candidates = buildPhaseCandidates(result.entries, config.architectEvery);
12559
+ const winner = selectPhaseCandidate(candidates, config.depthWeight);
12560
+ if (!winner) {
12571
12561
  return { message: 'No stale metas found. Nothing to synthesize.' };
12572
12562
  }
12573
- targetNode = findNode(result.tree, stalestPath);
12563
+ targetNode = findNode(result.tree, winner.node.metaPath);
12574
12564
  }
12575
12565
  const meta = await readMetaJson(targetNode.metaPath);
12576
12566
  // Scope files
12577
12567
  const { scopeFiles } = await getScopeFiles(targetNode, watcher);
12578
- const structureHash = computeStructureHash(scopeFiles);
12568
+ // Compute invalidation inputs (DRY: reuse phaseState/invalidate logic)
12569
+ const invalidation = await computeInvalidation(meta, scopeFiles, config, targetNode);
12570
+ const { architectInvalidators, stalenessInputs } = invalidation;
12571
+ const { structureHash } = invalidation;
12579
12572
  const structureChanged = structureHash !== meta._structureHash;
12580
- const latestArchive = await readLatestArchive(targetNode.metaPath);
12581
- const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
12582
- // _architect change detection
12583
- const architectChanged = latestArchive
12584
- ? (meta._architect ?? '') !== (latestArchive._architect ?? '')
12585
- : Boolean(meta._architect);
12586
- // _crossRefs declaration change detection
12587
- const currentRefs = (meta._crossRefs ?? []).slice().sort().join(',');
12588
- const archiveRefs = (latestArchive?._crossRefs ?? [])
12589
- .slice()
12590
- .sort()
12591
- .join(',');
12592
- const crossRefsDeclChanged = latestArchive
12593
- ? currentRefs !== archiveRefs
12594
- : currentRefs.length > 0;
12573
+ const { steerChanged } = invalidation;
12574
+ const { architectChanged, crossRefsDeclChanged } = stalenessInputs;
12595
12575
  const architectTriggered = isArchitectTriggered(meta, structureChanged, steerChanged, config.architectEvery);
12596
12576
  // Delta files
12597
12577
  const deltaFiles = getDeltaFiles(meta._generatedAt, scopeFiles);
@@ -12616,30 +12596,6 @@ function registerPreviewRoute(app, deps) {
12616
12596
  });
12617
12597
  const owedPhase = getOwedPhase(phaseState);
12618
12598
  const priorityBand = getPriorityBand(phaseState);
12619
- // Architect invalidators
12620
- const architectInvalidators = [];
12621
- if (owedPhase === 'architect') {
12622
- if (structureChanged)
12623
- architectInvalidators.push('structureHash');
12624
- if (steerChanged)
12625
- architectInvalidators.push('steer');
12626
- if (architectChanged)
12627
- architectInvalidators.push('_architect');
12628
- if (crossRefsDeclChanged)
12629
- architectInvalidators.push('_crossRefs');
12630
- if ((meta._synthesisCount ?? 0) >= config.architectEvery) {
12631
- architectInvalidators.push('architectEvery');
12632
- }
12633
- }
12634
- // Staleness inputs
12635
- const stalenessInputs = {
12636
- structureHash,
12637
- steerChanged,
12638
- architectChanged,
12639
- crossRefsDeclChanged,
12640
- scopeMtimeMax: null,
12641
- crossRefContentChanged: false,
12642
- };
12643
12599
  return {
12644
12600
  path: targetNode.metaPath,
12645
12601
  staleness: {
@@ -12713,8 +12669,8 @@ function registerQueueRoutes(app, deps) {
12713
12669
  // ranked by scheduler priority (computed on read, not persisted)
12714
12670
  let automatic = [];
12715
12671
  try {
12716
- const metaResult = await listMetas(deps.config, deps.watcher);
12717
- const candidates = buildPhaseCandidates(metaResult.entries);
12672
+ const metaResult = await deps.cache.get(deps.config, deps.watcher);
12673
+ const candidates = buildPhaseCandidates(metaResult.entries, deps.config.architectEvery);
12718
12674
  const ranked = rankPhaseCandidates(candidates, deps.config.depthWeight);
12719
12675
  automatic = ranked.map((c) => ({
12720
12676
  path: c.node.metaPath,
@@ -12937,7 +12893,7 @@ function registerStatusRoute(app, deps) {
12937
12893
  name: SERVICE_NAME,
12938
12894
  version: SERVICE_VERSION,
12939
12895
  getHealth: async () => {
12940
- const { config, queue, scheduler, stats, watcher } = deps;
12896
+ const { config, queue, scheduler, stats, watcher, cache } = deps;
12941
12897
  // On-demand dependency checks
12942
12898
  const [watcherHealth, gatewayHealth] = await Promise.all([
12943
12899
  checkWatcher(config.watcherUrl),
@@ -12951,7 +12907,7 @@ function registerStatusRoute(app, deps) {
12951
12907
  };
12952
12908
  let nextPhase = null;
12953
12909
  try {
12954
- const metaResult = await listMetas(config, watcher);
12910
+ const metaResult = await cache.get(config, watcher);
12955
12911
  // Count raw phase states (before retry) for display
12956
12912
  for (const entry of metaResult.entries) {
12957
12913
  const ps = derivePhaseState(entry.meta);
@@ -12960,7 +12916,7 @@ function registerStatusRoute(app, deps) {
12960
12916
  }
12961
12917
  }
12962
12918
  // Build candidates (with auto-retry) for scheduling
12963
- const candidates = buildPhaseCandidates(metaResult.entries);
12919
+ const candidates = buildPhaseCandidates(metaResult.entries, config.architectEvery);
12964
12920
  // Find next phase candidate
12965
12921
  const winner = selectPhaseCandidate(candidates, config.depthWeight);
12966
12922
  if (winner) {
@@ -13023,7 +12979,7 @@ const synthesizeBodySchema = z.object({
13023
12979
  function registerSynthesizeRoute(app, deps) {
13024
12980
  app.post('/synthesize', async (request, reply) => {
13025
12981
  const body = synthesizeBodySchema.parse(request.body);
13026
- const { config, watcher, queue } = deps;
12982
+ const { config, watcher, queue, cache } = deps;
13027
12983
  if (body.path) {
13028
12984
  // Path-targeted trigger: create override entry
13029
12985
  const targetPath = resolveMetaDir(body.path);
@@ -13060,7 +13016,7 @@ function registerSynthesizeRoute(app, deps) {
13060
13016
  // Corpus-wide trigger: discover stalest candidate
13061
13017
  let result;
13062
13018
  try {
13063
- result = await listMetas(config, watcher);
13019
+ result = await cache.get(config, watcher);
13064
13020
  }
13065
13021
  catch {
13066
13022
  return reply.status(503).send({
@@ -13068,20 +13024,15 @@ function registerSynthesizeRoute(app, deps) {
13068
13024
  message: 'Watcher unreachable — cannot discover candidates',
13069
13025
  });
13070
13026
  }
13071
- const stale = result.entries
13072
- .filter((e) => e.stalenessSeconds > 0 && !e.disabled)
13073
- .map((e) => ({
13074
- node: e.node,
13075
- meta: e.meta,
13076
- actualStaleness: e.stalenessSeconds,
13077
- }));
13078
- const stalest = discoverStalestPath(stale, config.depthWeight);
13079
- if (!stalest) {
13027
+ const candidates = buildPhaseCandidates(result.entries, config.architectEvery);
13028
+ const winner = selectPhaseCandidate(candidates, config.depthWeight);
13029
+ if (!winner) {
13080
13030
  return reply.code(200).send({
13081
13031
  status: 'skipped',
13082
13032
  message: 'No stale metas found. Nothing to synthesize.',
13083
13033
  });
13084
13034
  }
13035
+ const stalest = winner.node.metaPath;
13085
13036
  const enqueueResult = queue.enqueue(stalest);
13086
13037
  return reply.code(202).send({
13087
13038
  status: 'accepted',
@@ -13182,6 +13133,18 @@ function createServer(options) {
13182
13133
  // Fastify 5 requires `loggerInstance` for external pino loggers
13183
13134
  const app = Fastify({
13184
13135
  loggerInstance: options.logger,
13136
+ requestTimeout: 30_000,
13137
+ });
13138
+ // Readiness gate: return 503 while service is initializing
13139
+ app.addHook('onRequest', async (request, reply) => {
13140
+ if (options.deps.ready)
13141
+ return;
13142
+ const url = request.url;
13143
+ if (url === '/config' || url.startsWith('/config/apply'))
13144
+ return;
13145
+ return reply
13146
+ .status(503)
13147
+ .send({ status: 'starting', message: 'Service initializing' });
13185
13148
  });
13186
13149
  registerRoutes(app, options.deps);
13187
13150
  return app;
@@ -13357,8 +13320,9 @@ async function startService(config, configPath) {
13357
13320
  lastCycleAt: null,
13358
13321
  };
13359
13322
  const queue = new SynthesisQueue(logger);
13323
+ const cache = new MetaCache();
13360
13324
  // Scheduler (needs watcher for discovery)
13361
- const scheduler = new Scheduler(config, queue, logger, watcher);
13325
+ const scheduler = new Scheduler(config, queue, logger, watcher, cache);
13362
13326
  const routeDeps = {
13363
13327
  config,
13364
13328
  logger,
@@ -13366,6 +13330,8 @@ async function startService(config, configPath) {
13366
13330
  watcher,
13367
13331
  scheduler,
13368
13332
  stats,
13333
+ cache,
13334
+ ready: false,
13369
13335
  executor,
13370
13336
  configPath,
13371
13337
  };
@@ -13416,6 +13382,9 @@ async function startService(config, configPath) {
13416
13382
  }
13417
13383
  await progress.report(evt);
13418
13384
  }, logger);
13385
+ // Invalidate cache only when a phase was actually executed
13386
+ if (result.executed)
13387
+ cache.invalidate();
13419
13388
  const durationMs = Date.now() - startMs;
13420
13389
  if (!result.executed) {
13421
13390
  logger.debug({ path: ownerPath }, 'Phase skipped (fully fresh or locked)');
@@ -13479,9 +13448,13 @@ async function startService(config, configPath) {
13479
13448
  scheduler.setRegistrar(registrar);
13480
13449
  routeDeps.registrar = registrar;
13481
13450
  void registrar.register().then(() => {
13451
+ routeDeps.ready = true;
13482
13452
  if (registrar.isRegistered) {
13483
13453
  void verifyRuleApplication(watcher, logger);
13484
13454
  }
13455
+ }, () => {
13456
+ // Registration failed after max retries — mark ready anyway
13457
+ routeDeps.ready = true;
13485
13458
  });
13486
13459
  // Periodic watcher health check (independent of scheduler)
13487
13460
  const healthCheck = new WatcherHealthCheck({