@karmaniverous/jeeves-meta 0.15.7 → 0.15.9

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.
@@ -5615,6 +5615,9 @@ function requireRange () {
5615
5615
  }
5616
5616
 
5617
5617
  parseRange (range) {
5618
+ // strip build metadata so it can't bleed into the version
5619
+ range = range.replace(BUILDSTRIPRE, '');
5620
+
5618
5621
  // memoize range parsing for performance.
5619
5622
  // this is a very hot path, and fully deterministic.
5620
5623
  const memoOpts =
@@ -5740,6 +5743,7 @@ function requireRange () {
5740
5743
  const SemVer = requireSemver$1();
5741
5744
  const {
5742
5745
  safeRe: re,
5746
+ src,
5743
5747
  t,
5744
5748
  comparatorTrimReplace,
5745
5749
  tildeTrimReplace,
@@ -5747,6 +5751,9 @@ function requireRange () {
5747
5751
  } = requireRe();
5748
5752
  const { FLAG_INCLUDE_PRERELEASE, FLAG_LOOSE } = requireConstants();
5749
5753
 
5754
+ // unbounded global build-metadata stripper used by parseRange
5755
+ const BUILDSTRIPRE = new RegExp(src[t.BUILD], 'g');
5756
+
5750
5757
  const isNullSet = c => c.value === '<0.0.0-0';
5751
5758
  const isAny = c => c.value === '';
5752
5759
 
@@ -6798,7 +6805,7 @@ function requireSubset () {
6798
6805
  if (higher === c && higher !== gt) {
6799
6806
  return false
6800
6807
  }
6801
- } else if (gt.operator === '>=' && !satisfies(gt.semver, String(c), options)) {
6808
+ } else if (gt.operator === '>=' && !c.test(gt.semver)) {
6802
6809
  return false
6803
6810
  }
6804
6811
  }
@@ -6816,7 +6823,7 @@ function requireSubset () {
6816
6823
  if (lower === c && lower !== lt) {
6817
6824
  return false
6818
6825
  }
6819
- } else if (lt.operator === '<=' && !satisfies(lt.semver, String(c), options)) {
6826
+ } else if (lt.operator === '<=' && !c.test(lt.semver)) {
6820
6827
  return false
6821
6828
  }
6822
6829
  }
@@ -7112,12 +7119,26 @@ const DEFAULT_PORTS = {
7112
7119
  * - `{configRoot}/jeeves-{name}/` for each component
7113
7120
  */
7114
7121
  let state;
7122
+ const WINDOWS_DRIVE_RE = /^[a-zA-Z]:/;
7123
+ /**
7124
+ * Throw if a path looks like a Windows drive letter on a non-Windows platform.
7125
+ *
7126
+ * @param label - Human-readable name for the path (used in error messages).
7127
+ * @param value - The raw path string to validate.
7128
+ */
7129
+ function rejectWindowsDrivePath(label, value) {
7130
+ if (process.platform !== 'win32' && WINDOWS_DRIVE_RE.test(value)) {
7131
+ throw new Error(`jeeves-core: ${label} "${value}" looks like a Windows drive-letter path and will not resolve correctly on this platform.`);
7132
+ }
7133
+ }
7115
7134
  /**
7116
7135
  * Initialize the core library with workspace and config root paths.
7117
7136
  *
7118
7137
  * @param options - Workspace and config root paths.
7119
7138
  */
7120
7139
  function init(options) {
7140
+ rejectWindowsDrivePath('configRoot', options.configRoot);
7141
+ rejectWindowsDrivePath('workspacePath', options.workspacePath);
7121
7142
  state = {
7122
7143
  workspacePath: options.workspacePath,
7123
7144
  configRoot: options.configRoot,
@@ -8254,6 +8275,20 @@ When editing files outside the workspace, use the bridge pattern: copy in → ed
8254
8275
 
8255
8276
  **Cross-channel sends:** Use the \`message\` tool with an explicit \`target\` to send to a different channel or DM.
8256
8277
 
8278
+ ### Slack File Downloads
8279
+
8280
+ To download a Slack-hosted file, first try the \`message\` tool's \`download-file\` action. If that fails, fall back to a direct HTTP fetch using the bot token:
8281
+
8282
+ \`\`\`js
8283
+ fetch(url_private_download, {
8284
+ headers: { Authorization: 'Bearer ' + botToken },
8285
+ });
8286
+ \`\`\`
8287
+
8288
+ The bot token is at \`channels.slack.accounts.default.botToken\` in \`openclaw.json\`.
8289
+
8290
+ Never tell the user a file can't be downloaded until both methods have been tried.
8291
+
8257
8292
  ### Plugin Lifecycle
8258
8293
 
8259
8294
  \`\`\`bash
@@ -8489,6 +8524,113 @@ const META_COMPONENT = {
8489
8524
  pluginPackage: '@karmaniverous/jeeves-meta-openclaw',
8490
8525
  defaultPort: 1938};
8491
8526
 
8527
+ /**
8528
+ * Shared endpoint catalog — single source of truth for the jeeves-meta API.
8529
+ *
8530
+ * Both the CLI service and the OpenClaw plugin derive their registrations
8531
+ * from this declarative catalog, eliminating drift between the two.
8532
+ *
8533
+ */
8534
+ /**
8535
+ * Canonical endpoint catalog for the jeeves-meta API.
8536
+ *
8537
+ * Every entry describes a single HTTP endpoint exposed by the service.
8538
+ * Route handlers, plugin tools, and HTTP clients should reference these
8539
+ * descriptors rather than hard-coding paths and descriptions.
8540
+ */
8541
+ const META_ENDPOINTS = [
8542
+ {
8543
+ name: 'status',
8544
+ method: 'GET',
8545
+ path: '/status',
8546
+ description: 'Service health and status overview.',
8547
+ },
8548
+ {
8549
+ name: 'listMetas',
8550
+ method: 'GET',
8551
+ path: '/metas',
8552
+ description: 'List metas with summary stats and per-meta projection. Response includes _phaseState and owedPhase per meta.',
8553
+ },
8554
+ {
8555
+ name: 'metaDetail',
8556
+ method: 'GET',
8557
+ path: '/metas/:path',
8558
+ description: 'Full detail for a single meta, with optional archive history. Response includes _phaseState and owedPhase.',
8559
+ },
8560
+ {
8561
+ name: 'updateMeta',
8562
+ method: 'PATCH',
8563
+ path: '/metas/:path',
8564
+ description: 'Update user-settable reserved properties on a meta entity.',
8565
+ },
8566
+ {
8567
+ name: 'synthesize',
8568
+ method: 'POST',
8569
+ path: '/synthesize',
8570
+ description: 'Trigger synthesis. Path-targeted creates an override queue entry; returns owedPhase. Fully-fresh metas return status:skipped.',
8571
+ },
8572
+ {
8573
+ name: 'abort',
8574
+ method: 'POST',
8575
+ path: '/synthesize/abort',
8576
+ description: 'Abort the currently running synthesis.',
8577
+ },
8578
+ {
8579
+ name: 'preview',
8580
+ method: 'GET',
8581
+ path: '/preview',
8582
+ description: 'Dry-run preview of next synthesis. Returns owedPhase, priorityBand, phaseState, inputStatus, and architectInvalidators.',
8583
+ },
8584
+ {
8585
+ name: 'seed',
8586
+ method: 'POST',
8587
+ path: '/seed',
8588
+ description: 'Create a .meta/ directory and initial meta.json for a new entity path.',
8589
+ },
8590
+ {
8591
+ name: 'unlock',
8592
+ method: 'POST',
8593
+ path: '/unlock',
8594
+ description: 'Remove a stale .lock from a meta entity that is stuck.',
8595
+ },
8596
+ {
8597
+ name: 'config',
8598
+ method: 'GET',
8599
+ path: '/config',
8600
+ description: 'Query service configuration with optional JSONPath.',
8601
+ },
8602
+ {
8603
+ name: 'configApply',
8604
+ method: 'POST',
8605
+ path: '/config/apply',
8606
+ description: 'Apply a configuration patch.',
8607
+ },
8608
+ {
8609
+ name: 'queue',
8610
+ method: 'GET',
8611
+ path: '/queue',
8612
+ description: 'List queued synthesis operations (3-layer model: current, overrides, automatic).',
8613
+ },
8614
+ {
8615
+ name: 'queueClear',
8616
+ method: 'POST',
8617
+ path: '/queue/clear',
8618
+ description: 'Clear override entries from the queue.',
8619
+ },
8620
+ ];
8621
+ /**
8622
+ * Look up an endpoint descriptor by name.
8623
+ *
8624
+ * @param name - The endpoint identifier.
8625
+ * @returns The matching {@link EndpointDescriptor}.
8626
+ */
8627
+ function getEndpoint(name) {
8628
+ const ep = META_ENDPOINTS.find((e) => e.name === name);
8629
+ if (!ep)
8630
+ throw new Error(`Unknown endpoint: ${name}`);
8631
+ return ep;
8632
+ }
8633
+
8492
8634
  /**
8493
8635
  * Structured error schema from a synthesis step failure.
8494
8636
  *
@@ -8683,6 +8825,24 @@ async function discoverMetas(watcher) {
8683
8825
  return metaPaths;
8684
8826
  }
8685
8827
 
8828
+ /**
8829
+ * Retrieve the nearest ancestor meta node from the ownership tree.
8830
+ *
8831
+ * @module discovery/getAncestorMeta
8832
+ */
8833
+ /**
8834
+ * Get the nearest ancestor MetaNode for a given node.
8835
+ *
8836
+ * Walks up the ownership tree (via the parent pointer set by
8837
+ * buildOwnershipTree) to find the closest ancestor .meta/ directory.
8838
+ *
8839
+ * @param node - The meta node to find the ancestor for.
8840
+ * @returns The parent MetaNode, or null for root-level metas.
8841
+ */
8842
+ function getAncestorMeta(node) {
8843
+ return node.parent;
8844
+ }
8845
+
8686
8846
  /**
8687
8847
  * File-system lock for preventing concurrent synthesis on the same meta.
8688
8848
  *
@@ -8938,21 +9098,6 @@ async function isStale(scopePrefix, meta, watcher) {
8938
9098
  }
8939
9099
  /** Maximum staleness for never-synthesized metas (1 year in seconds). */
8940
9100
  const MAX_STALENESS_SECONDS = 365 * 86_400;
8941
- /**
8942
- * Check whether the architect step should be triggered.
8943
- *
8944
- * @param meta - Current meta.json.
8945
- * @param structureChanged - Whether the structure hash changed.
8946
- * @param steerChanged - Whether the steer directive changed.
8947
- * @param architectEvery - Config: run architect every N cycles.
8948
- * @returns True if the architect step should run.
8949
- */
8950
- function isArchitectTriggered(meta, structureChanged, steerChanged, architectEvery) {
8951
- return (!meta._builder ||
8952
- structureChanged ||
8953
- steerChanged ||
8954
- (meta._synthesisCount ?? 0) >= architectEvery);
8955
- }
8956
9101
  /**
8957
9102
  * Detect whether the steer directive changed since the last archive.
8958
9103
  *
@@ -9339,6 +9484,14 @@ const autoSeedRuleSchema = z.object({
9339
9484
  steer: z.string().optional(),
9340
9485
  /** Optional cross-references for seeded metas. */
9341
9486
  crossRefs: z.array(z.string()).optional(),
9487
+ /** Walk up this many extra parent levels from the matched file's directory. Default 0. */
9488
+ parentDepth: z.number().int().min(0).optional(),
9489
+ /** Per-category timeout override for the architect phase (seconds, min 30). */
9490
+ architectTimeout: z.number().int().min(30).optional(),
9491
+ /** Per-category timeout override for the builder phase (seconds, min 30). */
9492
+ builderTimeout: z.number().int().min(30).optional(),
9493
+ /** Per-category timeout override for the critic phase (seconds, min 30). */
9494
+ criticTimeout: z.number().int().min(30).optional(),
9342
9495
  });
9343
9496
  /** Zod schema for jeeves-meta service configuration (superset of MetaConfig). */
9344
9497
  const serviceConfigSchema = metaConfigSchema.extend({
@@ -9356,6 +9509,8 @@ const serviceConfigSchema = metaConfigSchema.extend({
9356
9509
  watcherHealthIntervalMs: z.number().int().min(0).default(60_000),
9357
9510
  /** Logging configuration. */
9358
9511
  logging: loggingSchema.default(() => loggingSchema.parse({})),
9512
+ /** Max number of all-fresh candidates to scan per tick in Tier 2 invalidation. */
9513
+ tier2ScanLimit: z.number().int().min(1).default(50),
9359
9514
  /**
9360
9515
  * Auto-seed policy: declarative rules for auto-creating .meta/ directories.
9361
9516
  * Rules are evaluated in order; last match wins for steer/crossRefs.
@@ -9583,7 +9738,7 @@ class GatewayExecutor {
9583
9738
  'Write your complete output to a file using the Write tool at:\n' +
9584
9739
  outputPath +
9585
9740
  '\n\n' +
9586
- 'After writing the file, reply with ONLY: NO_REPLY';
9741
+ 'After writing the file, your final message must be exactly: ANNOUNCE_SKIP';
9587
9742
  // Step 1: Spawn the sub-agent session (unique label per cycle to avoid
9588
9743
  // "label already in use" errors — gateway labels persist after session completion)
9589
9744
  const labelBase = options?.label ?? 'jeeves-meta-synthesis';
@@ -9651,7 +9806,9 @@ class GatewayExecutor {
9651
9806
  }
9652
9807
  }
9653
9808
  }
9654
- // Fallback: extract from message content if file wasn't written
9809
+ // Fallback: extract from message content if file wasn't written.
9810
+ // Skip ANNOUNCE_SKIP sentinel messages — the real output is in
9811
+ // a preceding assistant message (the file write).
9655
9812
  for (let i = msgArray.length - 1; i >= 0; i--) {
9656
9813
  const msg = msgArray[i];
9657
9814
  if (msg.role === 'assistant' && msg.content) {
@@ -9663,7 +9820,7 @@ class GatewayExecutor {
9663
9820
  .map((b) => b.text)
9664
9821
  .join('\n')
9665
9822
  : '';
9666
- if (text)
9823
+ if (text && text.trim() !== 'ANNOUNCE_SKIP')
9667
9824
  return { output: text, tokens };
9668
9825
  }
9669
9826
  }
@@ -9693,11 +9850,16 @@ class GatewayExecutor {
9693
9850
  function createLogger(config) {
9694
9851
  const level = config?.level ?? 'info';
9695
9852
  if (config?.file) {
9696
- const transport = pino.transport({
9697
- target: 'pino/file',
9698
- options: { destination: config.file, mkdir: true },
9853
+ const fileStream = pino.destination({
9854
+ dest: config.file,
9855
+ sync: false,
9856
+ mkdir: true,
9699
9857
  });
9700
- return pino({ level }, transport);
9858
+ const multistream = pino.multistream([
9859
+ { stream: process.stdout },
9860
+ { stream: fileStream },
9861
+ ]);
9862
+ return pino({ level }, multistream);
9701
9863
  }
9702
9864
  return pino({ level });
9703
9865
  }
@@ -10007,6 +10169,21 @@ async function buildContextPackage(node, meta, watcher, logger) {
10007
10169
  }
10008
10170
  // Archive paths
10009
10171
  const archives = listArchiveFiles(node.metaPath);
10172
+ // Nearest ancestor _builder output
10173
+ let ancestorBuilder;
10174
+ const ancestor = getAncestorMeta(node);
10175
+ if (ancestor) {
10176
+ try {
10177
+ const raw = await readFile(join(ancestor.metaPath, 'meta.json'), 'utf8');
10178
+ const ancestorMeta = JSON.parse(raw);
10179
+ if (ancestorMeta._builder) {
10180
+ ancestorBuilder = ancestorMeta._builder;
10181
+ }
10182
+ }
10183
+ catch {
10184
+ // Ancestor meta.json unreadable — skip
10185
+ }
10186
+ }
10010
10187
  return {
10011
10188
  path: node.metaPath,
10012
10189
  scopeFiles,
@@ -10018,6 +10195,7 @@ async function buildContextPackage(node, meta, watcher, logger) {
10018
10195
  steer: meta._steer ?? null,
10019
10196
  previousState: meta._state ?? null,
10020
10197
  archives,
10198
+ ancestorBuilder,
10021
10199
  };
10022
10200
  }
10023
10201
 
@@ -10065,6 +10243,12 @@ function appendMetaSections(sections, heading, metas) {
10065
10243
  sections.push(`### ${path}`, typeof content === 'string' ? content : '(not yet synthesized)');
10066
10244
  }
10067
10245
  }
10246
+ /** Inject nearest ancestor's organizational context, if available. */
10247
+ function appendAncestorContext(sections, ctx) {
10248
+ if (ctx.ancestorBuilder) {
10249
+ sections.push('', '## PARENT ORGANIZATIONAL CONTEXT', ctx.ancestorBuilder);
10250
+ }
10251
+ }
10068
10252
  /** Append optional context sections shared across all step prompts. */
10069
10253
  function appendSharedSections(sections, ctx, options) {
10070
10254
  const opts = {
@@ -10104,7 +10288,7 @@ function buildArchitectTask(ctx, meta, config) {
10104
10288
  const sections = [
10105
10289
  `# jeeves-meta · ARCHITECT · ${ctx.path}`,
10106
10290
  '',
10107
- meta._architect ?? config.defaultArchitect ?? DEFAULT_ARCHITECT_PROMPT,
10291
+ config.defaultArchitect ?? DEFAULT_ARCHITECT_PROMPT,
10108
10292
  '',
10109
10293
  '## SCOPE',
10110
10294
  `Path: ${ctx.path}`,
@@ -10114,6 +10298,7 @@ function buildArchitectTask(ctx, meta, config) {
10114
10298
  '### File listing (scope)',
10115
10299
  condenseScopeFiles(ctx.scopeFiles),
10116
10300
  ];
10301
+ appendAncestorContext(sections, ctx);
10117
10302
  // Inject previous _builder so architect can see its own prior output
10118
10303
  if (meta._builder) {
10119
10304
  sections.push('', '## PREVIOUS TASK BRIEF', meta._builder);
@@ -10144,6 +10329,7 @@ function buildBuilderTask(ctx, meta, config) {
10144
10329
  `Delta files (${ctx.deltaFiles.length.toString()} changed):`,
10145
10330
  ...ctx.deltaFiles.slice(0, config.maxLines).map((f) => `- ${f}`),
10146
10331
  ];
10332
+ appendAncestorContext(sections, ctx);
10147
10333
  if (ctx.previousState != null) {
10148
10334
  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), '```');
10149
10335
  }
@@ -10166,7 +10352,7 @@ function buildCriticTask(ctx, meta, config) {
10166
10352
  const sections = [
10167
10353
  `# jeeves-meta · CRITIC · ${ctx.path}`,
10168
10354
  '',
10169
- meta._critic ?? config.defaultCritic ?? DEFAULT_CRITIC_PROMPT,
10355
+ config.defaultCritic ?? DEFAULT_CRITIC_PROMPT,
10170
10356
  '',
10171
10357
  '## SYNTHESIS TO EVALUATE',
10172
10358
  meta._content ?? '(No content produced)',
@@ -10300,7 +10486,7 @@ function enforceInvariant(state) {
10300
10486
  // ── Invalidation cascades ──────────────────────────────────────────────
10301
10487
  /**
10302
10488
  * Architect invalidated: architect → pending; builder, critic → stale.
10303
- * Triggers: _structureHash change, _steer change, _architect change,
10489
+ * Triggers: first run, _structureHash change, _steer change,
10304
10490
  * _crossRefs declaration change, _synthesisCount \>= architectEvery.
10305
10491
  */
10306
10492
  function invalidateArchitect(state) {
@@ -10498,31 +10684,6 @@ function derivePhaseState(meta, inputs) {
10498
10684
  if (!meta._content && !meta._builder) {
10499
10685
  return initialPhaseState();
10500
10686
  }
10501
- // Check architect invalidation (when inputs are provided)
10502
- if (inputs) {
10503
- // Progressive metas: structure changes invalidate builder, not architect
10504
- const structureInvalidatesArchitect = inputs.structureChanged && meta._state === undefined;
10505
- const architectInvalidated = structureInvalidatesArchitect ||
10506
- inputs.steerChanged ||
10507
- inputs.architectChanged ||
10508
- inputs.crossRefsChanged ||
10509
- (meta._synthesisCount ?? 0) >= inputs.architectEvery;
10510
- if (architectInvalidated) {
10511
- return {
10512
- architect: 'pending',
10513
- builder: 'stale',
10514
- critic: 'stale',
10515
- };
10516
- }
10517
- // Progressive meta with structure change: builder-only invalidation
10518
- if (inputs.structureChanged && meta._state !== undefined) {
10519
- return {
10520
- architect: 'fresh',
10521
- builder: 'pending',
10522
- critic: 'stale',
10523
- };
10524
- }
10525
- }
10526
10687
  // Has _builder but no _content: builder is pending
10527
10688
  if (meta._builder && !meta._content) {
10528
10689
  return {
@@ -10571,6 +10732,14 @@ function computeStructureHash(filePaths) {
10571
10732
  *
10572
10733
  * @module phaseState/invalidate
10573
10734
  */
10735
+ /**
10736
+ * Check whether a persisted prompt snapshot mismatches the currently-resolved prompt.
10737
+ * Returns true when the snapshot exists and differs from the resolved prompt.
10738
+ * This is informational only — it does NOT trigger invalidation.
10739
+ */
10740
+ function isPromptStale(snapshot, resolved) {
10741
+ return snapshot !== undefined && snapshot !== resolved;
10742
+ }
10574
10743
  /**
10575
10744
  * Compute invalidation inputs and apply cascade for a single meta.
10576
10745
  *
@@ -10593,10 +10762,16 @@ async function computeInvalidation(meta, scopeFiles, config, node, crossRefMetas
10593
10762
  const structureChanged = structureHash !== meta._structureHash;
10594
10763
  const latestArchive = await readLatestArchive(node.metaPath);
10595
10764
  const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
10596
- // _architect change: compare current vs. archive
10597
- const architectChanged = latestArchive
10598
- ? (meta._architect ?? '') !== (latestArchive._architect ?? '')
10599
- : Boolean(meta._architect);
10765
+ // Prompt staleness detection: compare persisted prompt snapshots against
10766
+ // currently-resolved prompts. This is INFORMATIONAL ONLY — reported via
10767
+ // inputStatus so /preview can surface it, but it must NEVER feed into
10768
+ // the invalidation cascade. When a meta naturally reaches architectEvery
10769
+ // through real builder cycles, architect runs with the current prompt and
10770
+ // the snapshot updates. Coupling prompt changes to invalidation causes a
10771
+ // corpus-wide synthesis storm (see #163).
10772
+ const architectChanged = isPromptStale(meta._architect, config.defaultArchitect ?? DEFAULT_ARCHITECT_PROMPT);
10773
+ const criticChanged = isPromptStale(meta._critic, config.defaultCritic ?? DEFAULT_CRITIC_PROMPT);
10774
+ const effectiveSynthesisCount = meta._synthesisCount ?? 0;
10600
10775
  // _crossRefs declaration change
10601
10776
  const currentRefs = (meta._crossRefs ?? []).slice().sort().join(',');
10602
10777
  const archiveRefs = (latestArchive?._crossRefs ?? [])
@@ -10618,38 +10793,30 @@ async function computeInvalidation(meta, scopeFiles, config, node, crossRefMetas
10618
10793
  }
10619
10794
  if (steerChanged)
10620
10795
  architectInvalidators.push('steer');
10621
- if (architectChanged)
10622
- architectInvalidators.push('_architect');
10623
10796
  if (crossRefsDeclChanged)
10624
10797
  architectInvalidators.push('_crossRefs');
10625
- if ((meta._synthesisCount ?? 0) >= config.architectEvery) {
10798
+ if (effectiveSynthesisCount >= config.architectEvery) {
10626
10799
  architectInvalidators.push('architectEvery');
10627
10800
  }
10628
- // First-run check: no _builder means architect must run
10629
- const firstRun = !meta._builder;
10630
- if (architectInvalidators.length > 0 || firstRun) {
10801
+ if (!meta._builder)
10802
+ architectInvalidators.push('firstRun');
10803
+ if (architectInvalidators.length > 0) {
10631
10804
  phaseState = invalidateArchitect(phaseState);
10632
10805
  }
10633
10806
  // ── Builder-level inputs ──
10634
- // Scope file mtime check — if any file newer than _generatedAt
10635
- const scopeMtimeMax = null;
10636
- // Note: actual mtime check is done by the caller or via isStale;
10637
- // here we just detect cross-ref content changes for the cascade.
10638
10807
  // Cross-ref _content change (builder-invalidating)
10639
10808
  let crossRefContentChanged = false;
10640
10809
  return {
10641
10810
  phaseState,
10642
10811
  architectInvalidators,
10643
- stalenessInputs: {
10812
+ inputStatus: {
10644
10813
  structureHash,
10645
10814
  steerChanged,
10646
10815
  architectChanged,
10816
+ criticChanged,
10647
10817
  crossRefsDeclChanged,
10648
- scopeMtimeMax,
10649
10818
  crossRefContentChanged,
10650
10819
  },
10651
- structureHash,
10652
- steerChanged,
10653
10820
  };
10654
10821
  }
10655
10822
 
@@ -10789,20 +10956,14 @@ function selectPhaseCandidate(metas, depthWeight) {
10789
10956
  return rankPhaseCandidates(metas, depthWeight)[0] ?? null;
10790
10957
  }
10791
10958
  /**
10792
- * Select the stalest all-fresh, non-disabled, non-locked meta for Tier 2
10793
- * invalidation. These are metas that Tier 1 considers fully fresh but may
10794
- * have structural or steer changes detectable only via I/O.
10795
- *
10796
- * @param metas - Phase candidate inputs (after Tier 1 filtering).
10797
- * @returns The stalest all-fresh candidate, or null if none exist.
10959
+ * Select all fully-fresh, non-disabled, non-locked metas sorted by staleness
10960
+ * (descending stalest first) for Tier 2 invalidation scanning.
10798
10961
  */
10799
- function selectTier2Candidate(metas) {
10800
- const eligible = metas
10962
+ function selectAllTier2Candidates(metas) {
10963
+ return metas
10801
10964
  .filter((m) => !m.locked && !m.disabled && isFullyFresh(m.phaseState))
10802
- .sort((a, b) => b.actualStaleness - a.actualStaleness);
10803
- if (eligible.length === 0)
10804
- return null;
10805
- return { node: eligible[0].node, meta: eligible[0].meta };
10965
+ .sort((a, b) => b.actualStaleness - a.actualStaleness)
10966
+ .map((m) => ({ node: m.node, meta: m.meta }));
10806
10967
  }
10807
10968
 
10808
10969
  /**
@@ -10835,6 +10996,15 @@ function toMetaError(step, err, code = 'FAILED') {
10835
10996
  *
10836
10997
  * @module orchestrator/parseOutput
10837
10998
  */
10999
+ /** Sentinel appended by synthesis workers to skip the announce turn. */
11000
+ const ANNOUNCE_SKIP = 'ANNOUNCE_SKIP';
11001
+ /** Strip a trailing ANNOUNCE_SKIP sentinel from raw output. */
11002
+ function stripSentinel(raw) {
11003
+ const trimmed = raw.trim();
11004
+ return trimmed.endsWith(ANNOUNCE_SKIP)
11005
+ ? trimmed.slice(0, -ANNOUNCE_SKIP.length).trim()
11006
+ : trimmed;
11007
+ }
10838
11008
  /**
10839
11009
  * Parse architect output. The architect returns a task brief as text.
10840
11010
  *
@@ -10842,7 +11012,7 @@ function toMetaError(step, err, code = 'FAILED') {
10842
11012
  * @returns The task brief string.
10843
11013
  */
10844
11014
  function parseArchitectOutput(output) {
10845
- return output.trim();
11015
+ return stripSentinel(output);
10846
11016
  }
10847
11017
  /**
10848
11018
  * Parse builder output. The builder returns JSON with _content and optional fields.
@@ -10853,7 +11023,7 @@ function parseArchitectOutput(output) {
10853
11023
  * @returns Parsed builder output with content and structured fields.
10854
11024
  */
10855
11025
  function parseBuilderOutput(output) {
10856
- const trimmed = output.trim();
11026
+ const trimmed = stripSentinel(output);
10857
11027
  // Strategy 1: Try to parse the entire output as JSON directly
10858
11028
  const direct = tryParseJson(trimmed);
10859
11029
  if (direct)
@@ -10920,7 +11090,7 @@ function tryParseJson(str) {
10920
11090
  * @returns The feedback string.
10921
11091
  */
10922
11092
  function parseCriticOutput(output) {
10923
- return output.trim();
11093
+ return stripSentinel(output);
10924
11094
  }
10925
11095
 
10926
11096
  /**
@@ -10931,6 +11101,12 @@ function parseCriticOutput(output) {
10931
11101
  *
10932
11102
  * @module orchestrator/runPhase
10933
11103
  */
11104
+ /** Compute SHA-256 hash of ancestor _builder text for observability tracking. */
11105
+ function hashAncestorBuilder(ancestorBuilder) {
11106
+ return ancestorBuilder
11107
+ ? createHash('sha256').update(ancestorBuilder).digest('hex')
11108
+ : undefined;
11109
+ }
10934
11110
  /** Write updated meta with phase state via lock staging. */
10935
11111
  async function persistPhaseState(base, phaseState, updates) {
10936
11112
  const lockPath = join(base.metaPath, '.lock');
@@ -10974,6 +11150,12 @@ async function handlePhaseFailure(phase, err, executor, ps, base, additionalUpda
10974
11150
  // ── Architect executor ─────────────────────────────────────────────────
10975
11151
  async function runArchitect(node, currentMeta, phaseState, config, executor, watcher, structureHash, onProgress, logger) {
10976
11152
  let ps = phaseRunning(phaseState, 'architect');
11153
+ const base = {
11154
+ metaPath: node.metaPath,
11155
+ current: currentMeta,
11156
+ config,
11157
+ structureHash,
11158
+ };
10977
11159
  const ctx = await buildContextPackage(node, currentMeta, watcher, logger);
10978
11160
  try {
10979
11161
  await onProgress?.({
@@ -10985,21 +11167,25 @@ async function runArchitect(node, currentMeta, phaseState, config, executor, wat
10985
11167
  const architectTask = buildArchitectTask(ctx, currentMeta, config);
10986
11168
  const result = await executor.spawn(architectTask, {
10987
11169
  thinking: config.thinking,
10988
- timeout: config.architectTimeout,
11170
+ timeout: currentMeta._architectTimeout ?? config.architectTimeout,
10989
11171
  label: 'meta-architect',
10990
11172
  });
10991
11173
  const builderBrief = parseArchitectOutput(result.output);
10992
11174
  const architectTokens = result.tokens;
10993
11175
  // Architect success: architect → fresh, _synthesisCount → 0
10994
11176
  ps = architectSuccess(ps);
10995
- const updatedMeta = await persistPhaseState({ metaPath: node.metaPath, current: currentMeta, config, structureHash }, ps, {
11177
+ const architectUpdates = {
10996
11178
  _builder: builderBrief,
10997
- _architect: currentMeta._architect ?? config.defaultArchitect ?? '',
11179
+ _architect: config.defaultArchitect ?? DEFAULT_ARCHITECT_PROMPT,
10998
11180
  _synthesisCount: 0,
10999
11181
  _architectTokens: architectTokens,
11000
11182
  _generatedAt: new Date().toISOString(),
11001
11183
  _error: undefined,
11002
- });
11184
+ };
11185
+ const ancestorHash = hashAncestorBuilder(ctx.ancestorBuilder);
11186
+ if (ancestorHash)
11187
+ architectUpdates._ancestorBuilderHash = ancestorHash;
11188
+ const updatedMeta = await persistPhaseState(base, ps, architectUpdates);
11003
11189
  await onProgress?.({
11004
11190
  type: 'phase_complete',
11005
11191
  path: node.ownerPath,
@@ -11010,16 +11196,18 @@ async function runArchitect(node, currentMeta, phaseState, config, executor, wat
11010
11196
  return { executed: true, phaseState: ps, updatedMeta };
11011
11197
  }
11012
11198
  catch (err) {
11013
- return handlePhaseFailure('architect', err, executor, ps, {
11014
- metaPath: node.metaPath,
11015
- current: currentMeta,
11016
- structureHash,
11017
- });
11199
+ return handlePhaseFailure('architect', err, executor, ps, base);
11018
11200
  }
11019
11201
  }
11020
11202
  // ── Builder executor ───────────────────────────────────────────────────
11021
11203
  async function runBuilder(node, currentMeta, phaseState, config, executor, watcher, structureHash, onProgress, logger) {
11022
11204
  let ps = phaseRunning(phaseState, 'builder');
11205
+ const base = {
11206
+ metaPath: node.metaPath,
11207
+ current: currentMeta,
11208
+ config,
11209
+ structureHash,
11210
+ };
11023
11211
  const ctx = await buildContextPackage(node, currentMeta, watcher, logger);
11024
11212
  try {
11025
11213
  await onProgress?.({
@@ -11031,21 +11219,25 @@ async function runBuilder(node, currentMeta, phaseState, config, executor, watch
11031
11219
  const builderTask = buildBuilderTask(ctx, currentMeta, config);
11032
11220
  const result = await executor.spawn(builderTask, {
11033
11221
  thinking: config.thinking,
11034
- timeout: config.builderTimeout,
11222
+ timeout: currentMeta._builderTimeout ?? config.builderTimeout,
11035
11223
  label: 'meta-builder',
11036
11224
  });
11037
11225
  const builderOutput = parseBuilderOutput(result.output);
11038
11226
  const builderTokens = result.tokens;
11039
11227
  // Builder success: builder → fresh, critic → pending
11040
11228
  ps = builderSuccess(ps);
11041
- const updatedMeta = await persistPhaseState({ metaPath: node.metaPath, current: currentMeta, config, structureHash }, ps, {
11229
+ const builderUpdates = {
11042
11230
  _content: builderOutput.content,
11043
11231
  _state: builderOutput.state,
11044
11232
  _builderTokens: builderTokens,
11045
11233
  _generatedAt: new Date().toISOString(),
11046
11234
  _error: undefined,
11047
11235
  ...builderOutput.fields,
11048
- });
11236
+ };
11237
+ const ancestorHash = hashAncestorBuilder(ctx.ancestorBuilder);
11238
+ if (ancestorHash)
11239
+ builderUpdates._ancestorBuilderHash = ancestorHash;
11240
+ const updatedMeta = await persistPhaseState(base, ps, builderUpdates);
11049
11241
  await onProgress?.({
11050
11242
  type: 'phase_complete',
11051
11243
  path: node.ownerPath,
@@ -11071,16 +11263,18 @@ async function runBuilder(node, currentMeta, phaseState, config, executor, watch
11071
11263
  // Could not read partial output — no state recovery
11072
11264
  }
11073
11265
  }
11074
- return handlePhaseFailure('builder', err, executor, ps, {
11075
- metaPath: node.metaPath,
11076
- current: currentMeta,
11077
- structureHash,
11078
- }, partialState);
11266
+ return handlePhaseFailure('builder', err, executor, ps, base, partialState);
11079
11267
  }
11080
11268
  }
11081
11269
  // ── Critic executor ────────────────────────────────────────────────────
11082
11270
  async function runCritic(node, currentMeta, phaseState, config, executor, watcher, structureHash, onProgress, logger) {
11083
11271
  let ps = phaseRunning(phaseState, 'critic');
11272
+ const base = {
11273
+ metaPath: node.metaPath,
11274
+ current: currentMeta,
11275
+ config,
11276
+ structureHash,
11277
+ };
11084
11278
  const ctx = await buildContextPackage(node, currentMeta, watcher, logger);
11085
11279
  // Build critic task using current meta's _content
11086
11280
  const metaForCritic = { ...currentMeta };
@@ -11094,7 +11288,7 @@ async function runCritic(node, currentMeta, phaseState, config, executor, watche
11094
11288
  const criticTask = buildCriticTask(ctx, metaForCritic, config);
11095
11289
  const result = await executor.spawn(criticTask, {
11096
11290
  thinking: config.thinking,
11097
- timeout: config.criticTimeout,
11291
+ timeout: currentMeta._criticTimeout ?? config.criticTimeout,
11098
11292
  label: 'meta-critic',
11099
11293
  });
11100
11294
  const feedback = parseCriticOutput(result.output);
@@ -11104,6 +11298,7 @@ async function runCritic(node, currentMeta, phaseState, config, executor, watche
11104
11298
  const cycleComplete = isFullyFresh(ps);
11105
11299
  const updates = {
11106
11300
  _feedback: feedback,
11301
+ _critic: config.defaultCritic ?? DEFAULT_CRITIC_PROMPT,
11107
11302
  _criticTokens: criticTokens,
11108
11303
  _error: undefined,
11109
11304
  };
@@ -11112,7 +11307,7 @@ async function runCritic(node, currentMeta, phaseState, config, executor, watche
11112
11307
  if (cycleComplete) {
11113
11308
  updates._synthesisCount = (currentMeta._synthesisCount ?? 0) + 1;
11114
11309
  }
11115
- const updatedMeta = await persistPhaseState({ metaPath: node.metaPath, current: currentMeta, config, structureHash }, ps, updates);
11310
+ const updatedMeta = await persistPhaseState(base, ps, updates);
11116
11311
  // Archive on full-cycle only
11117
11312
  if (cycleComplete) {
11118
11313
  await createSnapshot(node.metaPath, updatedMeta);
@@ -11133,11 +11328,7 @@ async function runCritic(node, currentMeta, phaseState, config, executor, watche
11133
11328
  };
11134
11329
  }
11135
11330
  catch (err) {
11136
- return handlePhaseFailure('critic', err, executor, ps, {
11137
- metaPath: node.metaPath,
11138
- current: currentMeta,
11139
- structureHash,
11140
- });
11331
+ return handlePhaseFailure('critic', err, executor, ps, base);
11141
11332
  }
11142
11333
  }
11143
11334
 
@@ -11185,8 +11376,9 @@ async function orchestratePhase(config, executor, watcher, targetPath, onProgres
11185
11376
  // Select best phase candidate
11186
11377
  const winner = selectPhaseCandidate(candidates, config.depthWeight);
11187
11378
  if (!winner) {
11188
- // ── Tier 2 fallback: deep invalidation on stalest all-fresh meta ──
11189
- return orchestrateTier2(candidates, config, executor, watcher, onProgress, logger);
11379
+ // Tier 2 is now handled by the scheduler; orchestratePhase only handles
11380
+ // targeted (override) paths and Tier 1 corpus-wide selection.
11381
+ return { executed: false };
11190
11382
  }
11191
11383
  // Acquire lock
11192
11384
  if (!acquireLock(winner.node.metaPath)) {
@@ -11252,48 +11444,6 @@ async function orchestrateTargeted(config, executor, watcher, targetPath, onProg
11252
11444
  releaseLock(node.metaPath);
11253
11445
  }
11254
11446
  }
11255
- /**
11256
- * Tier 2 invalidation fallback: pick the stalest all-fresh meta,
11257
- * run computeInvalidation (structure hash, steer, cross-refs), and
11258
- * either execute the owed phase or bump _generatedAt.
11259
- */
11260
- async function orchestrateTier2(candidates, config, executor, watcher, onProgress, logger) {
11261
- const tier2 = selectTier2Candidate(candidates);
11262
- if (!tier2)
11263
- return { executed: false };
11264
- if (!acquireLock(tier2.node.metaPath)) {
11265
- logger?.debug({ path: tier2.node.metaPath }, 'Tier 2 candidate is locked, skipping');
11266
- return { executed: false };
11267
- }
11268
- try {
11269
- const currentMeta = await readMetaJson(tier2.node.metaPath);
11270
- const { scopeFiles } = await getScopeFiles(tier2.node, watcher);
11271
- const { phaseState, structureHash } = await computeInvalidation(currentMeta, scopeFiles, config, tier2.node);
11272
- const owedPhase = getOwedPhase(phaseState);
11273
- if (owedPhase) {
11274
- // Something changed — persist invalidated state and execute owed phase
11275
- await persistPhaseState({
11276
- metaPath: tier2.node.metaPath,
11277
- current: currentMeta,
11278
- config,
11279
- structureHash,
11280
- }, phaseState, {});
11281
- return await executePhase(tier2.node, currentMeta, phaseState, owedPhase, config, executor, watcher, structureHash, onProgress, logger);
11282
- }
11283
- // Nothing changed — bump _generatedAt to delay re-checking
11284
- await persistPhaseState({
11285
- metaPath: tier2.node.metaPath,
11286
- current: currentMeta,
11287
- config,
11288
- structureHash,
11289
- }, phaseState, { _generatedAt: new Date().toISOString() });
11290
- logger?.debug({ path: tier2.node.ownerPath }, 'Tier 2: no invalidation detected, bumped _generatedAt');
11291
- return { executed: false };
11292
- }
11293
- finally {
11294
- releaseLock(tier2.node.metaPath);
11295
- }
11296
- }
11297
11447
  /**
11298
11448
  * Execute exactly one phase on a meta.
11299
11449
  */
@@ -11755,12 +11905,6 @@ class WatcherHealthCheck {
11755
11905
  return;
11756
11906
  }
11757
11907
  const data = (await res.json());
11758
- // If rules were never successfully registered (startup failure),
11759
- // attempt registration now that the watcher is reachable.
11760
- if (!this.registrar.isRegistered) {
11761
- this.logger.info('Rules not registered — attempting registration');
11762
- await this.registrar.register();
11763
- }
11764
11908
  await this.registrar.checkAndReregister(data.uptime);
11765
11909
  }
11766
11910
  catch (err) {
@@ -11913,36 +12057,39 @@ class RuleRegistrar {
11913
12057
  logger;
11914
12058
  watcherClient;
11915
12059
  lastWatcherUptime = null;
11916
- registered = false;
12060
+ registering = false;
11917
12061
  constructor(config, logger, watcher) {
11918
12062
  this.config = config;
11919
12063
  this.logger = logger;
11920
12064
  this.watcherClient = watcher;
11921
12065
  }
11922
- /** Whether rules have been successfully registered. */
11923
- get isRegistered() {
11924
- return this.registered;
11925
- }
11926
12066
  /**
11927
12067
  * Register rules with watcher. Retries with exponential backoff.
11928
12068
  * Non-blocking — logs errors but never throws.
11929
12069
  */
11930
12070
  async register() {
11931
- const rules = buildMetaRules(this.config);
11932
- for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
11933
- try {
11934
- await this.watcherClient.registerRules(SOURCE, rules);
11935
- this.registered = true;
11936
- this.logger.info('Virtual rules registered with watcher');
11937
- return;
11938
- }
11939
- catch (err) {
11940
- const delayMs = RETRY_BASE_MS * Math.pow(2, attempt);
11941
- this.logger.warn({ attempt: attempt + 1, delayMs, err }, 'Rule registration failed, retrying');
11942
- await new Promise((r) => setTimeout(r, delayMs));
12071
+ if (this.registering)
12072
+ return;
12073
+ this.registering = true;
12074
+ try {
12075
+ const rules = buildMetaRules(this.config);
12076
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
12077
+ try {
12078
+ await this.watcherClient.registerRules(SOURCE, rules);
12079
+ this.logger.info('Virtual rules registered with watcher');
12080
+ return;
12081
+ }
12082
+ catch (err) {
12083
+ const delayMs = RETRY_BASE_MS * Math.pow(2, attempt);
12084
+ this.logger.warn({ attempt: attempt + 1, delayMs, err }, 'Rule registration failed, retrying');
12085
+ await new Promise((r) => setTimeout(r, delayMs));
12086
+ }
11943
12087
  }
12088
+ this.logger.error('Rule registration failed after max retries — service degraded');
12089
+ }
12090
+ finally {
12091
+ this.registering = false;
11944
12092
  }
11945
- this.logger.error('Rule registration failed after max retries — service degraded');
11946
12093
  }
11947
12094
  /**
11948
12095
  * Check watcher uptime and re-register if it decreased (restart detected).
@@ -11953,7 +12100,6 @@ class RuleRegistrar {
11953
12100
  if (this.lastWatcherUptime !== null &&
11954
12101
  currentUptime < this.lastWatcherUptime) {
11955
12102
  this.logger.info({ previous: this.lastWatcherUptime, current: currentUptime }, 'Watcher restart detected — re-registering rules');
11956
- this.registered = false;
11957
12103
  await this.register();
11958
12104
  }
11959
12105
  this.lastWatcherUptime = currentUptime;
@@ -12023,6 +12169,12 @@ async function createMeta(ownerPath, options) {
12023
12169
  metaJson._crossRefs = options.crossRefs;
12024
12170
  if (options?.steer !== undefined)
12025
12171
  metaJson._steer = options.steer;
12172
+ if (options?.architectTimeout !== undefined)
12173
+ metaJson._architectTimeout = options.architectTimeout;
12174
+ if (options?.builderTimeout !== undefined)
12175
+ metaJson._builderTimeout = options.builderTimeout;
12176
+ if (options?.criticTimeout !== undefined)
12177
+ metaJson._criticTimeout = options.criticTimeout;
12026
12178
  const metaJsonPath = join(metaDir, 'meta.json');
12027
12179
  await writeFile(metaJsonPath, JSON.stringify(metaJson, null, 2) + '\n');
12028
12180
  return { metaDir, _id };
@@ -12051,15 +12203,24 @@ function metaExists(ownerPath) {
12051
12203
  /**
12052
12204
  * Extract parent directory paths from watcher walk results.
12053
12205
  *
12054
- * Walk returns file paths; we need the unique set of immediate parent
12055
- * directories that could be owners.
12206
+ * Walk returns file paths; we need the unique set of parent directories that
12207
+ * could be owners. When {@link parentDepth} is specified, walk up that many
12208
+ * additional levels from each file's immediate parent. The walk is clamped at
12209
+ * the filesystem root to prevent escaping the watched scope.
12056
12210
  */
12057
- function extractDirectories(filePaths, logger) {
12211
+ function extractDirectories(filePaths, parentDepth = 0, logger) {
12058
12212
  const dirs = new Set();
12059
12213
  for (const fp of filePaths) {
12060
12214
  // Normalize backslash paths (Windows) to forward slashes before posix.dirname
12061
12215
  const normalized = normalizePath(fp);
12062
- const dir = posix.dirname(normalized);
12216
+ let dir = posix.dirname(normalized);
12217
+ // Walk up parentDepth additional levels, clamping at filesystem root
12218
+ for (let i = 0; i < parentDepth; i++) {
12219
+ const parent = posix.dirname(dir);
12220
+ if (parent === dir)
12221
+ break; // reached root
12222
+ dir = parent;
12223
+ }
12063
12224
  if (dir !== '.' && dir !== '/') {
12064
12225
  dirs.add(dir);
12065
12226
  }
@@ -12084,11 +12245,14 @@ async function autoSeedPass(rules, watcher, logger) {
12084
12245
  const candidates = new Map();
12085
12246
  for (const rule of rules) {
12086
12247
  const files = await watcher.walk([rule.match]);
12087
- const dirs = extractDirectories(files, logger);
12248
+ const dirs = extractDirectories(files, rule.parentDepth, logger);
12088
12249
  for (const dir of dirs) {
12089
12250
  candidates.set(dir, {
12090
12251
  steer: rule.steer,
12091
12252
  crossRefs: rule.crossRefs,
12253
+ architectTimeout: rule.architectTimeout,
12254
+ builderTimeout: rule.builderTimeout,
12255
+ criticTimeout: rule.criticTimeout,
12092
12256
  });
12093
12257
  }
12094
12258
  }
@@ -12103,10 +12267,7 @@ async function autoSeedPass(rules, watcher, logger) {
12103
12267
  const seededPaths = [];
12104
12268
  for (const candidate of toSeed) {
12105
12269
  try {
12106
- await createMeta(candidate.path, {
12107
- steer: candidate.steer,
12108
- crossRefs: candidate.crossRefs,
12109
- });
12270
+ await createMeta(candidate.path, candidate);
12110
12271
  seededPaths.push(candidate.path);
12111
12272
  logger?.info({ path: candidate.path }, 'auto-seeded meta');
12112
12273
  }
@@ -12271,7 +12432,7 @@ class Scheduler {
12271
12432
  const candidates = buildPhaseCandidates(result.entries, this.config.architectEvery);
12272
12433
  const winner = selectPhaseCandidate(candidates, this.config.depthWeight);
12273
12434
  if (!winner)
12274
- return null;
12435
+ return await this.discoverTier2Phase(candidates);
12275
12436
  return {
12276
12437
  path: winner.node.metaPath,
12277
12438
  phase: winner.owedPhase,
@@ -12283,6 +12444,59 @@ class Scheduler {
12283
12444
  return null;
12284
12445
  }
12285
12446
  }
12447
+ /**
12448
+ * Tier 2 invalidation: iterate all-fresh candidates (stalest first),
12449
+ * run computeInvalidation, and return the first that produces an owed phase.
12450
+ */
12451
+ async discoverTier2Phase(candidates) {
12452
+ const allTier2 = selectAllTier2Candidates(candidates);
12453
+ const limit = this.config.tier2ScanLimit;
12454
+ const tier2Candidates = allTier2.slice(0, limit);
12455
+ if (allTier2.length > limit) {
12456
+ this.logger.debug({ total: allTier2.length, limit }, 'Tier 2 scan limit reached, scanning subset');
12457
+ }
12458
+ let dirty = false;
12459
+ for (const t2 of tier2Candidates) {
12460
+ if (!acquireLock(t2.node.metaPath))
12461
+ continue;
12462
+ try {
12463
+ const currentMeta = await readMetaJson(t2.node.metaPath);
12464
+ const { scopeFiles } = await getScopeFiles(t2.node, this.watcher);
12465
+ const result = await computeInvalidation(currentMeta, scopeFiles, this.config, t2.node);
12466
+ const owedPhase = getOwedPhase(result.phaseState);
12467
+ if (owedPhase) {
12468
+ await persistPhaseState({
12469
+ metaPath: t2.node.metaPath,
12470
+ current: currentMeta,
12471
+ config: this.config,
12472
+ structureHash: result.inputStatus.structureHash,
12473
+ }, result.phaseState, {});
12474
+ this.cache.invalidate();
12475
+ return {
12476
+ path: t2.node.metaPath,
12477
+ phase: owedPhase,
12478
+ band: getPriorityBand(result.phaseState),
12479
+ };
12480
+ }
12481
+ // No invalidation — bump _generatedAt to delay re-checking
12482
+ await persistPhaseState({
12483
+ metaPath: t2.node.metaPath,
12484
+ current: currentMeta,
12485
+ config: this.config,
12486
+ structureHash: result.inputStatus.structureHash,
12487
+ }, result.phaseState, {
12488
+ _generatedAt: new Date().toISOString(),
12489
+ });
12490
+ dirty = true;
12491
+ }
12492
+ finally {
12493
+ releaseLock(t2.node.metaPath);
12494
+ }
12495
+ }
12496
+ if (dirty)
12497
+ this.cache.invalidate();
12498
+ return null;
12499
+ }
12286
12500
  }
12287
12501
 
12288
12502
  /**
@@ -12302,7 +12516,7 @@ function sanitizeConfig(config) {
12302
12516
  }
12303
12517
  function registerConfigRoute(app, deps) {
12304
12518
  const configHandler = createConfigQueryHandler(() => sanitizeConfig(deps.config));
12305
- app.get('/config', async (request, reply) => {
12519
+ app.get(getEndpoint('config').path, async (request, reply) => {
12306
12520
  const { path } = request.query;
12307
12521
  const result = await configHandler({ path });
12308
12522
  return reply.status(result.status).send(result.body);
@@ -12321,7 +12535,7 @@ function registerConfigRoute(app, deps) {
12321
12535
  */
12322
12536
  /** Register the POST /config/apply route. */
12323
12537
  function registerConfigApplyRoute(app, configPath) {
12324
- app.post('/config/apply', async (request, reply) => {
12538
+ app.post(getEndpoint('configApply').path, async (request, reply) => {
12325
12539
  if (!configPath) {
12326
12540
  return reply
12327
12541
  .status(500)
@@ -12410,42 +12624,32 @@ function registerConfigApplyRoute(app, configPath) {
12410
12624
  *
12411
12625
  * @module routes/metas
12412
12626
  */
12627
+ /** Reusable Zod schema for boolean query string parameters ('true'/'false'). */
12628
+ const boolQueryParam = z.enum(['true', 'false']).transform((v) => v === 'true');
12413
12629
  const metasQuerySchema = z.object({
12414
12630
  pathPrefix: z.string().optional(),
12415
- hasError: z
12416
- .enum(['true', 'false'])
12417
- .transform((v) => v === 'true')
12418
- .optional(),
12631
+ hasError: boolQueryParam.optional(),
12419
12632
  staleHours: z
12420
12633
  .string()
12421
12634
  .transform(Number)
12422
12635
  .pipe(z.number().positive())
12423
12636
  .optional(),
12424
- neverSynthesized: z
12425
- .enum(['true', 'false'])
12426
- .transform((v) => v === 'true')
12427
- .optional(),
12428
- locked: z
12429
- .enum(['true', 'false'])
12430
- .transform((v) => v === 'true')
12431
- .optional(),
12432
- disabled: z
12433
- .enum(['true', 'false'])
12434
- .transform((v) => v === 'true')
12435
- .optional(),
12637
+ neverSynthesized: boolQueryParam.optional(),
12638
+ locked: boolQueryParam.optional(),
12639
+ disabled: boolQueryParam.optional(),
12436
12640
  fields: z.string().optional(),
12437
12641
  });
12438
12642
  const metaDetailQuerySchema = z.object({
12439
12643
  fields: z.string().optional(),
12440
12644
  includeArchive: z
12441
12645
  .union([
12442
- z.enum(['true', 'false']).transform((v) => v === 'true'),
12646
+ boolQueryParam,
12443
12647
  z.string().transform(Number).pipe(z.number().int().nonnegative()),
12444
12648
  ])
12445
12649
  .optional(),
12446
12650
  });
12447
12651
  function registerMetasRoutes(app, deps) {
12448
- app.get('/metas', async (request) => {
12652
+ app.get(getEndpoint('listMetas').path, async (request) => {
12449
12653
  const query = metasQuerySchema.parse(request.query);
12450
12654
  const { config, watcher } = deps;
12451
12655
  const result = await listMetas(config, watcher);
@@ -12517,7 +12721,7 @@ function registerMetasRoutes(app, deps) {
12517
12721
  });
12518
12722
  return { summary, metas };
12519
12723
  });
12520
- app.get('/metas/:path', async (request, reply) => {
12724
+ app.get(getEndpoint('metaDetail').path, async (request, reply) => {
12521
12725
  const query = metaDetailQuerySchema.parse(request.query);
12522
12726
  const { config, watcher } = deps;
12523
12727
  const targetPath = normalizePath(decodeURIComponent(request.params.path));
@@ -12611,7 +12815,7 @@ function registerMetasRoutes(app, deps) {
12611
12815
  /**
12612
12816
  * PATCH /metas/:path — update user-settable reserved properties on a meta.
12613
12817
  *
12614
- * Supported fields: _steer, _emphasis, _depth, _crossRefs, _disabled.
12818
+ * Supported fields: _steer, _emphasis, _depth, _crossRefs, _disabled, _architectTimeout, _builderTimeout, _criticTimeout.
12615
12819
  * Set a field to null to remove it. Unknown keys are rejected.
12616
12820
  *
12617
12821
  * @module routes/metasUpdate
@@ -12623,10 +12827,13 @@ const updateBodySchema = z
12623
12827
  _depth: z.union([z.number(), z.null()]).optional(),
12624
12828
  _crossRefs: z.union([z.array(z.string()), z.null()]).optional(),
12625
12829
  _disabled: z.union([z.boolean(), z.null()]).optional(),
12830
+ _architectTimeout: z.union([z.number().int().min(30), z.null()]).optional(),
12831
+ _builderTimeout: z.union([z.number().int().min(30), z.null()]).optional(),
12832
+ _criticTimeout: z.union([z.number().int().min(30), z.null()]).optional(),
12626
12833
  })
12627
12834
  .strict();
12628
12835
  function registerMetasUpdateRoute(app, deps) {
12629
- app.patch('/metas/:path', async (request, reply) => {
12836
+ app.patch(getEndpoint('updateMeta').path, async (request, reply) => {
12630
12837
  const parseResult = updateBodySchema.safeParse(request.body);
12631
12838
  if (!parseResult.success) {
12632
12839
  return reply.status(400).send({
@@ -12648,13 +12855,7 @@ function registerMetasUpdateRoute(app, deps) {
12648
12855
  });
12649
12856
  }
12650
12857
  const metaJsonPath = join(metaDir, 'meta.json');
12651
- const KEYS = [
12652
- '_steer',
12653
- '_emphasis',
12654
- '_depth',
12655
- '_crossRefs',
12656
- '_disabled',
12657
- ];
12858
+ const KEYS = Object.keys(updateBodySchema.shape);
12658
12859
  const toDelete = new Set();
12659
12860
  const toSet = {};
12660
12861
  for (const key of KEYS) {
@@ -12692,7 +12893,7 @@ function registerMetasUpdateRoute(app, deps) {
12692
12893
  * @module routes/preview
12693
12894
  */
12694
12895
  function registerPreviewRoute(app, deps) {
12695
- app.get('/preview', async (request, reply) => {
12896
+ app.get(getEndpoint('preview').path, async (request, reply) => {
12696
12897
  const { config, watcher, cache } = deps;
12697
12898
  const query = request.query;
12698
12899
  let result;
@@ -12730,12 +12931,8 @@ function registerPreviewRoute(app, deps) {
12730
12931
  const { scopeFiles } = await getScopeFiles(targetNode, watcher);
12731
12932
  // Compute invalidation inputs (DRY: reuse phaseState/invalidate logic)
12732
12933
  const invalidation = await computeInvalidation(meta, scopeFiles, config, targetNode);
12733
- const { architectInvalidators, stalenessInputs } = invalidation;
12734
- const { structureHash } = invalidation;
12735
- const structureChanged = structureHash !== meta._structureHash;
12736
- const { steerChanged } = invalidation;
12737
- const { architectChanged, crossRefsDeclChanged } = stalenessInputs;
12738
- const architectTriggered = isArchitectTriggered(meta, structureChanged, steerChanged, config.architectEvery);
12934
+ const { architectInvalidators, inputStatus, phaseState } = invalidation;
12935
+ const architectTriggered = architectInvalidators.length > 0;
12739
12936
  // Delta files
12740
12937
  const deltaFiles = getDeltaFiles(meta._generatedAt, scopeFiles);
12741
12938
  // EMA token estimates
@@ -12749,14 +12946,6 @@ function registerPreviewRoute(app, deps) {
12749
12946
  ? Math.round((Date.now() - new Date(meta._generatedAt).getTime()) / 1000)
12750
12947
  : null;
12751
12948
  const stalenessScore = computeStalenessScore(stalenessSeconds, meta._depth ?? 0, meta._emphasis ?? 1, config.depthWeight);
12752
- // Phase state
12753
- const phaseState = derivePhaseState(meta, {
12754
- structureChanged,
12755
- steerChanged,
12756
- architectChanged,
12757
- crossRefsChanged: crossRefsDeclChanged,
12758
- architectEvery: config.architectEvery,
12759
- });
12760
12949
  const owedPhase = getOwedPhase(phaseState);
12761
12950
  const priorityBand = getPriorityBand(phaseState);
12762
12951
  return {
@@ -12767,10 +12956,17 @@ function registerPreviewRoute(app, deps) {
12767
12956
  },
12768
12957
  architectWillRun: architectTriggered,
12769
12958
  architectReason: [
12770
- ...(!meta._builder ? ['no cached builder (first run)'] : []),
12771
- ...(structureChanged ? ['structure changed'] : []),
12772
- ...(steerChanged ? ['steer changed'] : []),
12773
- ...((meta._synthesisCount ?? 0) >= config.architectEvery
12959
+ ...(architectInvalidators.includes('firstRun')
12960
+ ? ['no cached builder (first run)']
12961
+ : []),
12962
+ ...(architectInvalidators.includes('structureHash')
12963
+ ? ['structure changed']
12964
+ : []),
12965
+ ...(architectInvalidators.includes('steer') ? ['steer changed'] : []),
12966
+ ...(architectInvalidators.includes('_crossRefs')
12967
+ ? ['cross-refs changed']
12968
+ : []),
12969
+ ...(architectInvalidators.includes('architectEvery')
12774
12970
  ? ['periodic refresh']
12775
12971
  : []),
12776
12972
  ].join(', ') || 'not triggered',
@@ -12787,7 +12983,7 @@ function registerPreviewRoute(app, deps) {
12787
12983
  owedPhase,
12788
12984
  priorityBand,
12789
12985
  phaseState,
12790
- stalenessInputs,
12986
+ inputStatus,
12791
12987
  architectInvalidators,
12792
12988
  };
12793
12989
  });
@@ -12805,7 +13001,7 @@ function registerPreviewRoute(app, deps) {
12805
13001
  /** Register queue management routes. */
12806
13002
  function registerQueueRoutes(app, deps) {
12807
13003
  const { queue } = deps;
12808
- app.get('/queue', async () => {
13004
+ app.get(getEndpoint('queue').path, async () => {
12809
13005
  const currentPhase = queue.currentPhase;
12810
13006
  const overrides = queue.overrides;
12811
13007
  // Compute owedPhase for each override entry by reading meta state
@@ -12881,11 +13077,11 @@ function registerQueueRoutes(app, deps) {
12881
13077
  state: queue.getState(),
12882
13078
  };
12883
13079
  });
12884
- app.post('/queue/clear', () => {
13080
+ app.post(getEndpoint('queueClear').path, () => {
12885
13081
  const removed = queue.clearOverrides();
12886
13082
  return { cleared: removed };
12887
13083
  });
12888
- app.post('/synthesize/abort', async (_request, reply) => {
13084
+ app.post(getEndpoint('abort').path, async (_request, reply) => {
12889
13085
  // Check 3-layer current first
12890
13086
  const currentPhase = queue.currentPhase;
12891
13087
  const current = currentPhase ?? queue.current;
@@ -12947,7 +13143,7 @@ const seedBodySchema = z.object({
12947
13143
  steer: z.string().optional(),
12948
13144
  });
12949
13145
  function registerSeedRoute(app, deps) {
12950
- app.post('/seed', async (request, reply) => {
13146
+ app.post(getEndpoint('seed').path, async (request, reply) => {
12951
13147
  const body = seedBodySchema.parse(request.body);
12952
13148
  if (metaExists(body.path)) {
12953
13149
  return reply.status(409).send({
@@ -13112,7 +13308,7 @@ function registerStatusRoute(app, deps) {
13112
13308
  dependencies: {
13113
13309
  watcher: {
13114
13310
  ...watcherHealth,
13115
- rulesRegistered: deps.registrar?.isRegistered ?? false,
13311
+ rulesRegistered: true,
13116
13312
  },
13117
13313
  gateway: gatewayHealth,
13118
13314
  },
@@ -13121,7 +13317,7 @@ function registerStatusRoute(app, deps) {
13121
13317
  };
13122
13318
  },
13123
13319
  });
13124
- app.get('/status', async (_request, reply) => {
13320
+ app.get(getEndpoint('status').path, async (_request, reply) => {
13125
13321
  const result = await statusHandler();
13126
13322
  return reply.status(result.status).send(result.body);
13127
13323
  });
@@ -13140,24 +13336,41 @@ const synthesizeBodySchema = z.object({
13140
13336
  });
13141
13337
  /** Register the POST /synthesize route. */
13142
13338
  function registerSynthesizeRoute(app, deps) {
13143
- app.post('/synthesize', async (request, reply) => {
13339
+ app.post(getEndpoint('synthesize').path, async (request, reply) => {
13144
13340
  const body = synthesizeBodySchema.parse(request.body);
13145
13341
  const { config, watcher, queue, cache } = deps;
13146
13342
  if (body.path) {
13147
13343
  // Path-targeted trigger: create override entry
13148
13344
  const targetPath = resolveMetaDir(body.path);
13149
- // Read meta to determine owed phase
13345
+ // Read meta and recompute invalidation against current inputs
13346
+ // (structure hash, steer, cross-refs, prompt snapshots) rather than
13347
+ // trusting the cached _phaseState. Fixes #160.
13150
13348
  let owedPhase = null;
13151
13349
  let meta;
13152
13350
  try {
13153
13351
  meta = await readMetaJson(targetPath);
13154
- const phaseState = derivePhaseState(meta);
13155
- owedPhase = getOwedPhase(phaseState);
13352
+ const node = await buildMinimalNode(normalizePath(targetPath), watcher);
13353
+ const { scopeFiles } = await getScopeFiles(node, watcher);
13354
+ const invalidation = await computeInvalidation(meta, scopeFiles, config, node);
13355
+ owedPhase = getOwedPhase(invalidation.phaseState);
13356
+ // Persist recomputed phase state + structure hash when stale.
13357
+ // Matches the scheduler's Tier 2 pattern: always persist so the
13358
+ // stored _phaseState reflects reality for subsequent reads.
13359
+ if (owedPhase) {
13360
+ await persistPhaseState({
13361
+ metaPath: targetPath,
13362
+ current: meta,
13363
+ config,
13364
+ structureHash: invalidation.inputStatus.structureHash,
13365
+ }, invalidation.phaseState, {});
13366
+ cache.invalidate();
13367
+ }
13156
13368
  }
13157
13369
  catch {
13158
- // Meta unreadable — proceed, phase will be evaluated at dequeue time
13370
+ // Meta unreadable or watcher unavailable — proceed,
13371
+ // phase will be evaluated at dequeue time
13159
13372
  }
13160
- // Fully fresh meta → skip (reuse meta already read above)
13373
+ // Fully fresh meta → skip
13161
13374
  if (owedPhase === null && meta && (meta._phaseState || meta._content)) {
13162
13375
  return await reply.code(200).send({
13163
13376
  status: 'skipped',
@@ -13215,7 +13428,7 @@ const unlockBodySchema = z.object({
13215
13428
  path: z.string().min(1),
13216
13429
  });
13217
13430
  function registerUnlockRoute(app, deps) {
13218
- app.post('/unlock', (request, reply) => {
13431
+ app.post(getEndpoint('unlock').path, (request, reply) => {
13219
13432
  const body = unlockBodySchema.parse(request.body);
13220
13433
  const metaDir = resolveMetaDir(body.path);
13221
13434
  const lockPath = join(metaDir, '.lock');
@@ -13612,9 +13825,7 @@ async function startService(config, configPath) {
13612
13825
  routeDeps.registrar = registrar;
13613
13826
  void registrar.register().then(() => {
13614
13827
  routeDeps.ready = true;
13615
- if (registrar.isRegistered) {
13616
- void verifyRuleApplication(watcher, logger);
13617
- }
13828
+ void verifyRuleApplication(watcher, logger);
13618
13829
  }, () => {
13619
13830
  // Registration failed after max retries — mark ready anyway
13620
13831
  routeDeps.ready = true;