@karmaniverous/jeeves-meta 0.15.8 → 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({
@@ -9585,7 +9738,7 @@ class GatewayExecutor {
9585
9738
  'Write your complete output to a file using the Write tool at:\n' +
9586
9739
  outputPath +
9587
9740
  '\n\n' +
9588
- 'After writing the file, reply with ONLY: NO_REPLY';
9741
+ 'After writing the file, your final message must be exactly: ANNOUNCE_SKIP';
9589
9742
  // Step 1: Spawn the sub-agent session (unique label per cycle to avoid
9590
9743
  // "label already in use" errors — gateway labels persist after session completion)
9591
9744
  const labelBase = options?.label ?? 'jeeves-meta-synthesis';
@@ -9653,7 +9806,9 @@ class GatewayExecutor {
9653
9806
  }
9654
9807
  }
9655
9808
  }
9656
- // 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).
9657
9812
  for (let i = msgArray.length - 1; i >= 0; i--) {
9658
9813
  const msg = msgArray[i];
9659
9814
  if (msg.role === 'assistant' && msg.content) {
@@ -9665,7 +9820,7 @@ class GatewayExecutor {
9665
9820
  .map((b) => b.text)
9666
9821
  .join('\n')
9667
9822
  : '';
9668
- if (text)
9823
+ if (text && text.trim() !== 'ANNOUNCE_SKIP')
9669
9824
  return { output: text, tokens };
9670
9825
  }
9671
9826
  }
@@ -9695,11 +9850,16 @@ class GatewayExecutor {
9695
9850
  function createLogger(config) {
9696
9851
  const level = config?.level ?? 'info';
9697
9852
  if (config?.file) {
9698
- const transport = pino.transport({
9699
- target: 'pino/file',
9700
- options: { destination: config.file, mkdir: true },
9853
+ const fileStream = pino.destination({
9854
+ dest: config.file,
9855
+ sync: false,
9856
+ mkdir: true,
9701
9857
  });
9702
- return pino({ level }, transport);
9858
+ const multistream = pino.multistream([
9859
+ { stream: process.stdout },
9860
+ { stream: fileStream },
9861
+ ]);
9862
+ return pino({ level }, multistream);
9703
9863
  }
9704
9864
  return pino({ level });
9705
9865
  }
@@ -10009,6 +10169,21 @@ async function buildContextPackage(node, meta, watcher, logger) {
10009
10169
  }
10010
10170
  // Archive paths
10011
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
+ }
10012
10187
  return {
10013
10188
  path: node.metaPath,
10014
10189
  scopeFiles,
@@ -10020,6 +10195,7 @@ async function buildContextPackage(node, meta, watcher, logger) {
10020
10195
  steer: meta._steer ?? null,
10021
10196
  previousState: meta._state ?? null,
10022
10197
  archives,
10198
+ ancestorBuilder,
10023
10199
  };
10024
10200
  }
10025
10201
 
@@ -10067,6 +10243,12 @@ function appendMetaSections(sections, heading, metas) {
10067
10243
  sections.push(`### ${path}`, typeof content === 'string' ? content : '(not yet synthesized)');
10068
10244
  }
10069
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
+ }
10070
10252
  /** Append optional context sections shared across all step prompts. */
10071
10253
  function appendSharedSections(sections, ctx, options) {
10072
10254
  const opts = {
@@ -10106,7 +10288,7 @@ function buildArchitectTask(ctx, meta, config) {
10106
10288
  const sections = [
10107
10289
  `# jeeves-meta · ARCHITECT · ${ctx.path}`,
10108
10290
  '',
10109
- meta._architect ?? config.defaultArchitect ?? DEFAULT_ARCHITECT_PROMPT,
10291
+ config.defaultArchitect ?? DEFAULT_ARCHITECT_PROMPT,
10110
10292
  '',
10111
10293
  '## SCOPE',
10112
10294
  `Path: ${ctx.path}`,
@@ -10116,6 +10298,7 @@ function buildArchitectTask(ctx, meta, config) {
10116
10298
  '### File listing (scope)',
10117
10299
  condenseScopeFiles(ctx.scopeFiles),
10118
10300
  ];
10301
+ appendAncestorContext(sections, ctx);
10119
10302
  // Inject previous _builder so architect can see its own prior output
10120
10303
  if (meta._builder) {
10121
10304
  sections.push('', '## PREVIOUS TASK BRIEF', meta._builder);
@@ -10146,6 +10329,7 @@ function buildBuilderTask(ctx, meta, config) {
10146
10329
  `Delta files (${ctx.deltaFiles.length.toString()} changed):`,
10147
10330
  ...ctx.deltaFiles.slice(0, config.maxLines).map((f) => `- ${f}`),
10148
10331
  ];
10332
+ appendAncestorContext(sections, ctx);
10149
10333
  if (ctx.previousState != null) {
10150
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), '```');
10151
10335
  }
@@ -10168,7 +10352,7 @@ function buildCriticTask(ctx, meta, config) {
10168
10352
  const sections = [
10169
10353
  `# jeeves-meta · CRITIC · ${ctx.path}`,
10170
10354
  '',
10171
- meta._critic ?? config.defaultCritic ?? DEFAULT_CRITIC_PROMPT,
10355
+ config.defaultCritic ?? DEFAULT_CRITIC_PROMPT,
10172
10356
  '',
10173
10357
  '## SYNTHESIS TO EVALUATE',
10174
10358
  meta._content ?? '(No content produced)',
@@ -10302,7 +10486,7 @@ function enforceInvariant(state) {
10302
10486
  // ── Invalidation cascades ──────────────────────────────────────────────
10303
10487
  /**
10304
10488
  * Architect invalidated: architect → pending; builder, critic → stale.
10305
- * Triggers: _structureHash change, _steer change, _architect change,
10489
+ * Triggers: first run, _structureHash change, _steer change,
10306
10490
  * _crossRefs declaration change, _synthesisCount \>= architectEvery.
10307
10491
  */
10308
10492
  function invalidateArchitect(state) {
@@ -10500,31 +10684,6 @@ function derivePhaseState(meta, inputs) {
10500
10684
  if (!meta._content && !meta._builder) {
10501
10685
  return initialPhaseState();
10502
10686
  }
10503
- // Check architect invalidation (when inputs are provided)
10504
- if (inputs) {
10505
- // Progressive metas: structure changes invalidate builder, not architect
10506
- const structureInvalidatesArchitect = inputs.structureChanged && meta._state === undefined;
10507
- const architectInvalidated = structureInvalidatesArchitect ||
10508
- inputs.steerChanged ||
10509
- inputs.architectChanged ||
10510
- inputs.crossRefsChanged ||
10511
- (meta._synthesisCount ?? 0) >= inputs.architectEvery;
10512
- if (architectInvalidated) {
10513
- return {
10514
- architect: 'pending',
10515
- builder: 'stale',
10516
- critic: 'stale',
10517
- };
10518
- }
10519
- // Progressive meta with structure change: builder-only invalidation
10520
- if (inputs.structureChanged && meta._state !== undefined) {
10521
- return {
10522
- architect: 'fresh',
10523
- builder: 'pending',
10524
- critic: 'stale',
10525
- };
10526
- }
10527
- }
10528
10687
  // Has _builder but no _content: builder is pending
10529
10688
  if (meta._builder && !meta._content) {
10530
10689
  return {
@@ -10573,6 +10732,14 @@ function computeStructureHash(filePaths) {
10573
10732
  *
10574
10733
  * @module phaseState/invalidate
10575
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
+ }
10576
10743
  /**
10577
10744
  * Compute invalidation inputs and apply cascade for a single meta.
10578
10745
  *
@@ -10595,10 +10762,16 @@ async function computeInvalidation(meta, scopeFiles, config, node, crossRefMetas
10595
10762
  const structureChanged = structureHash !== meta._structureHash;
10596
10763
  const latestArchive = await readLatestArchive(node.metaPath);
10597
10764
  const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
10598
- // _architect change: compare current vs. archive
10599
- const architectChanged = latestArchive
10600
- ? (meta._architect ?? '') !== (latestArchive._architect ?? '')
10601
- : 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;
10602
10775
  // _crossRefs declaration change
10603
10776
  const currentRefs = (meta._crossRefs ?? []).slice().sort().join(',');
10604
10777
  const archiveRefs = (latestArchive?._crossRefs ?? [])
@@ -10620,38 +10793,30 @@ async function computeInvalidation(meta, scopeFiles, config, node, crossRefMetas
10620
10793
  }
10621
10794
  if (steerChanged)
10622
10795
  architectInvalidators.push('steer');
10623
- if (architectChanged)
10624
- architectInvalidators.push('_architect');
10625
10796
  if (crossRefsDeclChanged)
10626
10797
  architectInvalidators.push('_crossRefs');
10627
- if ((meta._synthesisCount ?? 0) >= config.architectEvery) {
10798
+ if (effectiveSynthesisCount >= config.architectEvery) {
10628
10799
  architectInvalidators.push('architectEvery');
10629
10800
  }
10630
- // First-run check: no _builder means architect must run
10631
- const firstRun = !meta._builder;
10632
- if (architectInvalidators.length > 0 || firstRun) {
10801
+ if (!meta._builder)
10802
+ architectInvalidators.push('firstRun');
10803
+ if (architectInvalidators.length > 0) {
10633
10804
  phaseState = invalidateArchitect(phaseState);
10634
10805
  }
10635
10806
  // ── Builder-level inputs ──
10636
- // Scope file mtime check — if any file newer than _generatedAt
10637
- const scopeMtimeMax = null;
10638
- // Note: actual mtime check is done by the caller or via isStale;
10639
- // here we just detect cross-ref content changes for the cascade.
10640
10807
  // Cross-ref _content change (builder-invalidating)
10641
10808
  let crossRefContentChanged = false;
10642
10809
  return {
10643
10810
  phaseState,
10644
10811
  architectInvalidators,
10645
- stalenessInputs: {
10812
+ inputStatus: {
10646
10813
  structureHash,
10647
10814
  steerChanged,
10648
10815
  architectChanged,
10816
+ criticChanged,
10649
10817
  crossRefsDeclChanged,
10650
- scopeMtimeMax,
10651
10818
  crossRefContentChanged,
10652
10819
  },
10653
- structureHash,
10654
- steerChanged,
10655
10820
  };
10656
10821
  }
10657
10822
 
@@ -10831,6 +10996,15 @@ function toMetaError(step, err, code = 'FAILED') {
10831
10996
  *
10832
10997
  * @module orchestrator/parseOutput
10833
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
+ }
10834
11008
  /**
10835
11009
  * Parse architect output. The architect returns a task brief as text.
10836
11010
  *
@@ -10838,7 +11012,7 @@ function toMetaError(step, err, code = 'FAILED') {
10838
11012
  * @returns The task brief string.
10839
11013
  */
10840
11014
  function parseArchitectOutput(output) {
10841
- return output.trim();
11015
+ return stripSentinel(output);
10842
11016
  }
10843
11017
  /**
10844
11018
  * Parse builder output. The builder returns JSON with _content and optional fields.
@@ -10849,7 +11023,7 @@ function parseArchitectOutput(output) {
10849
11023
  * @returns Parsed builder output with content and structured fields.
10850
11024
  */
10851
11025
  function parseBuilderOutput(output) {
10852
- const trimmed = output.trim();
11026
+ const trimmed = stripSentinel(output);
10853
11027
  // Strategy 1: Try to parse the entire output as JSON directly
10854
11028
  const direct = tryParseJson(trimmed);
10855
11029
  if (direct)
@@ -10916,7 +11090,7 @@ function tryParseJson(str) {
10916
11090
  * @returns The feedback string.
10917
11091
  */
10918
11092
  function parseCriticOutput(output) {
10919
- return output.trim();
11093
+ return stripSentinel(output);
10920
11094
  }
10921
11095
 
10922
11096
  /**
@@ -10927,6 +11101,12 @@ function parseCriticOutput(output) {
10927
11101
  *
10928
11102
  * @module orchestrator/runPhase
10929
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
+ }
10930
11110
  /** Write updated meta with phase state via lock staging. */
10931
11111
  async function persistPhaseState(base, phaseState, updates) {
10932
11112
  const lockPath = join(base.metaPath, '.lock');
@@ -10970,6 +11150,12 @@ async function handlePhaseFailure(phase, err, executor, ps, base, additionalUpda
10970
11150
  // ── Architect executor ─────────────────────────────────────────────────
10971
11151
  async function runArchitect(node, currentMeta, phaseState, config, executor, watcher, structureHash, onProgress, logger) {
10972
11152
  let ps = phaseRunning(phaseState, 'architect');
11153
+ const base = {
11154
+ metaPath: node.metaPath,
11155
+ current: currentMeta,
11156
+ config,
11157
+ structureHash,
11158
+ };
10973
11159
  const ctx = await buildContextPackage(node, currentMeta, watcher, logger);
10974
11160
  try {
10975
11161
  await onProgress?.({
@@ -10981,21 +11167,25 @@ async function runArchitect(node, currentMeta, phaseState, config, executor, wat
10981
11167
  const architectTask = buildArchitectTask(ctx, currentMeta, config);
10982
11168
  const result = await executor.spawn(architectTask, {
10983
11169
  thinking: config.thinking,
10984
- timeout: config.architectTimeout,
11170
+ timeout: currentMeta._architectTimeout ?? config.architectTimeout,
10985
11171
  label: 'meta-architect',
10986
11172
  });
10987
11173
  const builderBrief = parseArchitectOutput(result.output);
10988
11174
  const architectTokens = result.tokens;
10989
11175
  // Architect success: architect → fresh, _synthesisCount → 0
10990
11176
  ps = architectSuccess(ps);
10991
- const updatedMeta = await persistPhaseState({ metaPath: node.metaPath, current: currentMeta, config, structureHash }, ps, {
11177
+ const architectUpdates = {
10992
11178
  _builder: builderBrief,
10993
- _architect: currentMeta._architect ?? config.defaultArchitect ?? '',
11179
+ _architect: config.defaultArchitect ?? DEFAULT_ARCHITECT_PROMPT,
10994
11180
  _synthesisCount: 0,
10995
11181
  _architectTokens: architectTokens,
10996
11182
  _generatedAt: new Date().toISOString(),
10997
11183
  _error: undefined,
10998
- });
11184
+ };
11185
+ const ancestorHash = hashAncestorBuilder(ctx.ancestorBuilder);
11186
+ if (ancestorHash)
11187
+ architectUpdates._ancestorBuilderHash = ancestorHash;
11188
+ const updatedMeta = await persistPhaseState(base, ps, architectUpdates);
10999
11189
  await onProgress?.({
11000
11190
  type: 'phase_complete',
11001
11191
  path: node.ownerPath,
@@ -11006,16 +11196,18 @@ async function runArchitect(node, currentMeta, phaseState, config, executor, wat
11006
11196
  return { executed: true, phaseState: ps, updatedMeta };
11007
11197
  }
11008
11198
  catch (err) {
11009
- return handlePhaseFailure('architect', err, executor, ps, {
11010
- metaPath: node.metaPath,
11011
- current: currentMeta,
11012
- structureHash,
11013
- });
11199
+ return handlePhaseFailure('architect', err, executor, ps, base);
11014
11200
  }
11015
11201
  }
11016
11202
  // ── Builder executor ───────────────────────────────────────────────────
11017
11203
  async function runBuilder(node, currentMeta, phaseState, config, executor, watcher, structureHash, onProgress, logger) {
11018
11204
  let ps = phaseRunning(phaseState, 'builder');
11205
+ const base = {
11206
+ metaPath: node.metaPath,
11207
+ current: currentMeta,
11208
+ config,
11209
+ structureHash,
11210
+ };
11019
11211
  const ctx = await buildContextPackage(node, currentMeta, watcher, logger);
11020
11212
  try {
11021
11213
  await onProgress?.({
@@ -11027,21 +11219,25 @@ async function runBuilder(node, currentMeta, phaseState, config, executor, watch
11027
11219
  const builderTask = buildBuilderTask(ctx, currentMeta, config);
11028
11220
  const result = await executor.spawn(builderTask, {
11029
11221
  thinking: config.thinking,
11030
- timeout: config.builderTimeout,
11222
+ timeout: currentMeta._builderTimeout ?? config.builderTimeout,
11031
11223
  label: 'meta-builder',
11032
11224
  });
11033
11225
  const builderOutput = parseBuilderOutput(result.output);
11034
11226
  const builderTokens = result.tokens;
11035
11227
  // Builder success: builder → fresh, critic → pending
11036
11228
  ps = builderSuccess(ps);
11037
- const updatedMeta = await persistPhaseState({ metaPath: node.metaPath, current: currentMeta, config, structureHash }, ps, {
11229
+ const builderUpdates = {
11038
11230
  _content: builderOutput.content,
11039
11231
  _state: builderOutput.state,
11040
11232
  _builderTokens: builderTokens,
11041
11233
  _generatedAt: new Date().toISOString(),
11042
11234
  _error: undefined,
11043
11235
  ...builderOutput.fields,
11044
- });
11236
+ };
11237
+ const ancestorHash = hashAncestorBuilder(ctx.ancestorBuilder);
11238
+ if (ancestorHash)
11239
+ builderUpdates._ancestorBuilderHash = ancestorHash;
11240
+ const updatedMeta = await persistPhaseState(base, ps, builderUpdates);
11045
11241
  await onProgress?.({
11046
11242
  type: 'phase_complete',
11047
11243
  path: node.ownerPath,
@@ -11067,16 +11263,18 @@ async function runBuilder(node, currentMeta, phaseState, config, executor, watch
11067
11263
  // Could not read partial output — no state recovery
11068
11264
  }
11069
11265
  }
11070
- return handlePhaseFailure('builder', err, executor, ps, {
11071
- metaPath: node.metaPath,
11072
- current: currentMeta,
11073
- structureHash,
11074
- }, partialState);
11266
+ return handlePhaseFailure('builder', err, executor, ps, base, partialState);
11075
11267
  }
11076
11268
  }
11077
11269
  // ── Critic executor ────────────────────────────────────────────────────
11078
11270
  async function runCritic(node, currentMeta, phaseState, config, executor, watcher, structureHash, onProgress, logger) {
11079
11271
  let ps = phaseRunning(phaseState, 'critic');
11272
+ const base = {
11273
+ metaPath: node.metaPath,
11274
+ current: currentMeta,
11275
+ config,
11276
+ structureHash,
11277
+ };
11080
11278
  const ctx = await buildContextPackage(node, currentMeta, watcher, logger);
11081
11279
  // Build critic task using current meta's _content
11082
11280
  const metaForCritic = { ...currentMeta };
@@ -11090,7 +11288,7 @@ async function runCritic(node, currentMeta, phaseState, config, executor, watche
11090
11288
  const criticTask = buildCriticTask(ctx, metaForCritic, config);
11091
11289
  const result = await executor.spawn(criticTask, {
11092
11290
  thinking: config.thinking,
11093
- timeout: config.criticTimeout,
11291
+ timeout: currentMeta._criticTimeout ?? config.criticTimeout,
11094
11292
  label: 'meta-critic',
11095
11293
  });
11096
11294
  const feedback = parseCriticOutput(result.output);
@@ -11100,6 +11298,7 @@ async function runCritic(node, currentMeta, phaseState, config, executor, watche
11100
11298
  const cycleComplete = isFullyFresh(ps);
11101
11299
  const updates = {
11102
11300
  _feedback: feedback,
11301
+ _critic: config.defaultCritic ?? DEFAULT_CRITIC_PROMPT,
11103
11302
  _criticTokens: criticTokens,
11104
11303
  _error: undefined,
11105
11304
  };
@@ -11108,7 +11307,7 @@ async function runCritic(node, currentMeta, phaseState, config, executor, watche
11108
11307
  if (cycleComplete) {
11109
11308
  updates._synthesisCount = (currentMeta._synthesisCount ?? 0) + 1;
11110
11309
  }
11111
- const updatedMeta = await persistPhaseState({ metaPath: node.metaPath, current: currentMeta, config, structureHash }, ps, updates);
11310
+ const updatedMeta = await persistPhaseState(base, ps, updates);
11112
11311
  // Archive on full-cycle only
11113
11312
  if (cycleComplete) {
11114
11313
  await createSnapshot(node.metaPath, updatedMeta);
@@ -11129,11 +11328,7 @@ async function runCritic(node, currentMeta, phaseState, config, executor, watche
11129
11328
  };
11130
11329
  }
11131
11330
  catch (err) {
11132
- return handlePhaseFailure('critic', err, executor, ps, {
11133
- metaPath: node.metaPath,
11134
- current: currentMeta,
11135
- structureHash,
11136
- });
11331
+ return handlePhaseFailure('critic', err, executor, ps, base);
11137
11332
  }
11138
11333
  }
11139
11334
 
@@ -11710,12 +11905,6 @@ class WatcherHealthCheck {
11710
11905
  return;
11711
11906
  }
11712
11907
  const data = (await res.json());
11713
- // If rules were never successfully registered (startup failure),
11714
- // attempt registration now that the watcher is reachable.
11715
- if (!this.registrar.isRegistered) {
11716
- this.logger.info('Rules not registered — attempting registration');
11717
- await this.registrar.register();
11718
- }
11719
11908
  await this.registrar.checkAndReregister(data.uptime);
11720
11909
  }
11721
11910
  catch (err) {
@@ -11868,36 +12057,39 @@ class RuleRegistrar {
11868
12057
  logger;
11869
12058
  watcherClient;
11870
12059
  lastWatcherUptime = null;
11871
- registered = false;
12060
+ registering = false;
11872
12061
  constructor(config, logger, watcher) {
11873
12062
  this.config = config;
11874
12063
  this.logger = logger;
11875
12064
  this.watcherClient = watcher;
11876
12065
  }
11877
- /** Whether rules have been successfully registered. */
11878
- get isRegistered() {
11879
- return this.registered;
11880
- }
11881
12066
  /**
11882
12067
  * Register rules with watcher. Retries with exponential backoff.
11883
12068
  * Non-blocking — logs errors but never throws.
11884
12069
  */
11885
12070
  async register() {
11886
- const rules = buildMetaRules(this.config);
11887
- for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
11888
- try {
11889
- await this.watcherClient.registerRules(SOURCE, rules);
11890
- this.registered = true;
11891
- this.logger.info('Virtual rules registered with watcher');
11892
- return;
11893
- }
11894
- catch (err) {
11895
- const delayMs = RETRY_BASE_MS * Math.pow(2, attempt);
11896
- this.logger.warn({ attempt: attempt + 1, delayMs, err }, 'Rule registration failed, retrying');
11897
- 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
+ }
11898
12087
  }
12088
+ this.logger.error('Rule registration failed after max retries — service degraded');
12089
+ }
12090
+ finally {
12091
+ this.registering = false;
11899
12092
  }
11900
- this.logger.error('Rule registration failed after max retries — service degraded');
11901
12093
  }
11902
12094
  /**
11903
12095
  * Check watcher uptime and re-register if it decreased (restart detected).
@@ -11908,7 +12100,6 @@ class RuleRegistrar {
11908
12100
  if (this.lastWatcherUptime !== null &&
11909
12101
  currentUptime < this.lastWatcherUptime) {
11910
12102
  this.logger.info({ previous: this.lastWatcherUptime, current: currentUptime }, 'Watcher restart detected — re-registering rules');
11911
- this.registered = false;
11912
12103
  await this.register();
11913
12104
  }
11914
12105
  this.lastWatcherUptime = currentUptime;
@@ -11978,6 +12169,12 @@ async function createMeta(ownerPath, options) {
11978
12169
  metaJson._crossRefs = options.crossRefs;
11979
12170
  if (options?.steer !== undefined)
11980
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;
11981
12178
  const metaJsonPath = join(metaDir, 'meta.json');
11982
12179
  await writeFile(metaJsonPath, JSON.stringify(metaJson, null, 2) + '\n');
11983
12180
  return { metaDir, _id };
@@ -12006,15 +12203,24 @@ function metaExists(ownerPath) {
12006
12203
  /**
12007
12204
  * Extract parent directory paths from watcher walk results.
12008
12205
  *
12009
- * Walk returns file paths; we need the unique set of immediate parent
12010
- * 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.
12011
12210
  */
12012
- function extractDirectories(filePaths, logger) {
12211
+ function extractDirectories(filePaths, parentDepth = 0, logger) {
12013
12212
  const dirs = new Set();
12014
12213
  for (const fp of filePaths) {
12015
12214
  // Normalize backslash paths (Windows) to forward slashes before posix.dirname
12016
12215
  const normalized = normalizePath(fp);
12017
- 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
+ }
12018
12224
  if (dir !== '.' && dir !== '/') {
12019
12225
  dirs.add(dir);
12020
12226
  }
@@ -12039,11 +12245,14 @@ async function autoSeedPass(rules, watcher, logger) {
12039
12245
  const candidates = new Map();
12040
12246
  for (const rule of rules) {
12041
12247
  const files = await watcher.walk([rule.match]);
12042
- const dirs = extractDirectories(files, logger);
12248
+ const dirs = extractDirectories(files, rule.parentDepth, logger);
12043
12249
  for (const dir of dirs) {
12044
12250
  candidates.set(dir, {
12045
12251
  steer: rule.steer,
12046
12252
  crossRefs: rule.crossRefs,
12253
+ architectTimeout: rule.architectTimeout,
12254
+ builderTimeout: rule.builderTimeout,
12255
+ criticTimeout: rule.criticTimeout,
12047
12256
  });
12048
12257
  }
12049
12258
  }
@@ -12058,10 +12267,7 @@ async function autoSeedPass(rules, watcher, logger) {
12058
12267
  const seededPaths = [];
12059
12268
  for (const candidate of toSeed) {
12060
12269
  try {
12061
- await createMeta(candidate.path, {
12062
- steer: candidate.steer,
12063
- crossRefs: candidate.crossRefs,
12064
- });
12270
+ await createMeta(candidate.path, candidate);
12065
12271
  seededPaths.push(candidate.path);
12066
12272
  logger?.info({ path: candidate.path }, 'auto-seeded meta');
12067
12273
  }
@@ -12263,7 +12469,7 @@ class Scheduler {
12263
12469
  metaPath: t2.node.metaPath,
12264
12470
  current: currentMeta,
12265
12471
  config: this.config,
12266
- structureHash: result.structureHash,
12472
+ structureHash: result.inputStatus.structureHash,
12267
12473
  }, result.phaseState, {});
12268
12474
  this.cache.invalidate();
12269
12475
  return {
@@ -12277,8 +12483,10 @@ class Scheduler {
12277
12483
  metaPath: t2.node.metaPath,
12278
12484
  current: currentMeta,
12279
12485
  config: this.config,
12280
- structureHash: result.structureHash,
12281
- }, result.phaseState, { _generatedAt: new Date().toISOString() });
12486
+ structureHash: result.inputStatus.structureHash,
12487
+ }, result.phaseState, {
12488
+ _generatedAt: new Date().toISOString(),
12489
+ });
12282
12490
  dirty = true;
12283
12491
  }
12284
12492
  finally {
@@ -12308,7 +12516,7 @@ function sanitizeConfig(config) {
12308
12516
  }
12309
12517
  function registerConfigRoute(app, deps) {
12310
12518
  const configHandler = createConfigQueryHandler(() => sanitizeConfig(deps.config));
12311
- app.get('/config', async (request, reply) => {
12519
+ app.get(getEndpoint('config').path, async (request, reply) => {
12312
12520
  const { path } = request.query;
12313
12521
  const result = await configHandler({ path });
12314
12522
  return reply.status(result.status).send(result.body);
@@ -12327,7 +12535,7 @@ function registerConfigRoute(app, deps) {
12327
12535
  */
12328
12536
  /** Register the POST /config/apply route. */
12329
12537
  function registerConfigApplyRoute(app, configPath) {
12330
- app.post('/config/apply', async (request, reply) => {
12538
+ app.post(getEndpoint('configApply').path, async (request, reply) => {
12331
12539
  if (!configPath) {
12332
12540
  return reply
12333
12541
  .status(500)
@@ -12416,42 +12624,32 @@ function registerConfigApplyRoute(app, configPath) {
12416
12624
  *
12417
12625
  * @module routes/metas
12418
12626
  */
12627
+ /** Reusable Zod schema for boolean query string parameters ('true'/'false'). */
12628
+ const boolQueryParam = z.enum(['true', 'false']).transform((v) => v === 'true');
12419
12629
  const metasQuerySchema = z.object({
12420
12630
  pathPrefix: z.string().optional(),
12421
- hasError: z
12422
- .enum(['true', 'false'])
12423
- .transform((v) => v === 'true')
12424
- .optional(),
12631
+ hasError: boolQueryParam.optional(),
12425
12632
  staleHours: z
12426
12633
  .string()
12427
12634
  .transform(Number)
12428
12635
  .pipe(z.number().positive())
12429
12636
  .optional(),
12430
- neverSynthesized: z
12431
- .enum(['true', 'false'])
12432
- .transform((v) => v === 'true')
12433
- .optional(),
12434
- locked: z
12435
- .enum(['true', 'false'])
12436
- .transform((v) => v === 'true')
12437
- .optional(),
12438
- disabled: z
12439
- .enum(['true', 'false'])
12440
- .transform((v) => v === 'true')
12441
- .optional(),
12637
+ neverSynthesized: boolQueryParam.optional(),
12638
+ locked: boolQueryParam.optional(),
12639
+ disabled: boolQueryParam.optional(),
12442
12640
  fields: z.string().optional(),
12443
12641
  });
12444
12642
  const metaDetailQuerySchema = z.object({
12445
12643
  fields: z.string().optional(),
12446
12644
  includeArchive: z
12447
12645
  .union([
12448
- z.enum(['true', 'false']).transform((v) => v === 'true'),
12646
+ boolQueryParam,
12449
12647
  z.string().transform(Number).pipe(z.number().int().nonnegative()),
12450
12648
  ])
12451
12649
  .optional(),
12452
12650
  });
12453
12651
  function registerMetasRoutes(app, deps) {
12454
- app.get('/metas', async (request) => {
12652
+ app.get(getEndpoint('listMetas').path, async (request) => {
12455
12653
  const query = metasQuerySchema.parse(request.query);
12456
12654
  const { config, watcher } = deps;
12457
12655
  const result = await listMetas(config, watcher);
@@ -12523,7 +12721,7 @@ function registerMetasRoutes(app, deps) {
12523
12721
  });
12524
12722
  return { summary, metas };
12525
12723
  });
12526
- app.get('/metas/:path', async (request, reply) => {
12724
+ app.get(getEndpoint('metaDetail').path, async (request, reply) => {
12527
12725
  const query = metaDetailQuerySchema.parse(request.query);
12528
12726
  const { config, watcher } = deps;
12529
12727
  const targetPath = normalizePath(decodeURIComponent(request.params.path));
@@ -12617,7 +12815,7 @@ function registerMetasRoutes(app, deps) {
12617
12815
  /**
12618
12816
  * PATCH /metas/:path — update user-settable reserved properties on a meta.
12619
12817
  *
12620
- * Supported fields: _steer, _emphasis, _depth, _crossRefs, _disabled.
12818
+ * Supported fields: _steer, _emphasis, _depth, _crossRefs, _disabled, _architectTimeout, _builderTimeout, _criticTimeout.
12621
12819
  * Set a field to null to remove it. Unknown keys are rejected.
12622
12820
  *
12623
12821
  * @module routes/metasUpdate
@@ -12629,10 +12827,13 @@ const updateBodySchema = z
12629
12827
  _depth: z.union([z.number(), z.null()]).optional(),
12630
12828
  _crossRefs: z.union([z.array(z.string()), z.null()]).optional(),
12631
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(),
12632
12833
  })
12633
12834
  .strict();
12634
12835
  function registerMetasUpdateRoute(app, deps) {
12635
- app.patch('/metas/:path', async (request, reply) => {
12836
+ app.patch(getEndpoint('updateMeta').path, async (request, reply) => {
12636
12837
  const parseResult = updateBodySchema.safeParse(request.body);
12637
12838
  if (!parseResult.success) {
12638
12839
  return reply.status(400).send({
@@ -12654,13 +12855,7 @@ function registerMetasUpdateRoute(app, deps) {
12654
12855
  });
12655
12856
  }
12656
12857
  const metaJsonPath = join(metaDir, 'meta.json');
12657
- const KEYS = [
12658
- '_steer',
12659
- '_emphasis',
12660
- '_depth',
12661
- '_crossRefs',
12662
- '_disabled',
12663
- ];
12858
+ const KEYS = Object.keys(updateBodySchema.shape);
12664
12859
  const toDelete = new Set();
12665
12860
  const toSet = {};
12666
12861
  for (const key of KEYS) {
@@ -12698,7 +12893,7 @@ function registerMetasUpdateRoute(app, deps) {
12698
12893
  * @module routes/preview
12699
12894
  */
12700
12895
  function registerPreviewRoute(app, deps) {
12701
- app.get('/preview', async (request, reply) => {
12896
+ app.get(getEndpoint('preview').path, async (request, reply) => {
12702
12897
  const { config, watcher, cache } = deps;
12703
12898
  const query = request.query;
12704
12899
  let result;
@@ -12736,12 +12931,8 @@ function registerPreviewRoute(app, deps) {
12736
12931
  const { scopeFiles } = await getScopeFiles(targetNode, watcher);
12737
12932
  // Compute invalidation inputs (DRY: reuse phaseState/invalidate logic)
12738
12933
  const invalidation = await computeInvalidation(meta, scopeFiles, config, targetNode);
12739
- const { architectInvalidators, stalenessInputs } = invalidation;
12740
- const { structureHash } = invalidation;
12741
- const structureChanged = structureHash !== meta._structureHash;
12742
- const { steerChanged } = invalidation;
12743
- const { architectChanged, crossRefsDeclChanged } = stalenessInputs;
12744
- const architectTriggered = isArchitectTriggered(meta, structureChanged, steerChanged, config.architectEvery);
12934
+ const { architectInvalidators, inputStatus, phaseState } = invalidation;
12935
+ const architectTriggered = architectInvalidators.length > 0;
12745
12936
  // Delta files
12746
12937
  const deltaFiles = getDeltaFiles(meta._generatedAt, scopeFiles);
12747
12938
  // EMA token estimates
@@ -12755,14 +12946,6 @@ function registerPreviewRoute(app, deps) {
12755
12946
  ? Math.round((Date.now() - new Date(meta._generatedAt).getTime()) / 1000)
12756
12947
  : null;
12757
12948
  const stalenessScore = computeStalenessScore(stalenessSeconds, meta._depth ?? 0, meta._emphasis ?? 1, config.depthWeight);
12758
- // Phase state
12759
- const phaseState = derivePhaseState(meta, {
12760
- structureChanged,
12761
- steerChanged,
12762
- architectChanged,
12763
- crossRefsChanged: crossRefsDeclChanged,
12764
- architectEvery: config.architectEvery,
12765
- });
12766
12949
  const owedPhase = getOwedPhase(phaseState);
12767
12950
  const priorityBand = getPriorityBand(phaseState);
12768
12951
  return {
@@ -12773,10 +12956,17 @@ function registerPreviewRoute(app, deps) {
12773
12956
  },
12774
12957
  architectWillRun: architectTriggered,
12775
12958
  architectReason: [
12776
- ...(!meta._builder ? ['no cached builder (first run)'] : []),
12777
- ...(structureChanged ? ['structure changed'] : []),
12778
- ...(steerChanged ? ['steer changed'] : []),
12779
- ...((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')
12780
12970
  ? ['periodic refresh']
12781
12971
  : []),
12782
12972
  ].join(', ') || 'not triggered',
@@ -12793,7 +12983,7 @@ function registerPreviewRoute(app, deps) {
12793
12983
  owedPhase,
12794
12984
  priorityBand,
12795
12985
  phaseState,
12796
- stalenessInputs,
12986
+ inputStatus,
12797
12987
  architectInvalidators,
12798
12988
  };
12799
12989
  });
@@ -12811,7 +13001,7 @@ function registerPreviewRoute(app, deps) {
12811
13001
  /** Register queue management routes. */
12812
13002
  function registerQueueRoutes(app, deps) {
12813
13003
  const { queue } = deps;
12814
- app.get('/queue', async () => {
13004
+ app.get(getEndpoint('queue').path, async () => {
12815
13005
  const currentPhase = queue.currentPhase;
12816
13006
  const overrides = queue.overrides;
12817
13007
  // Compute owedPhase for each override entry by reading meta state
@@ -12887,11 +13077,11 @@ function registerQueueRoutes(app, deps) {
12887
13077
  state: queue.getState(),
12888
13078
  };
12889
13079
  });
12890
- app.post('/queue/clear', () => {
13080
+ app.post(getEndpoint('queueClear').path, () => {
12891
13081
  const removed = queue.clearOverrides();
12892
13082
  return { cleared: removed };
12893
13083
  });
12894
- app.post('/synthesize/abort', async (_request, reply) => {
13084
+ app.post(getEndpoint('abort').path, async (_request, reply) => {
12895
13085
  // Check 3-layer current first
12896
13086
  const currentPhase = queue.currentPhase;
12897
13087
  const current = currentPhase ?? queue.current;
@@ -12953,7 +13143,7 @@ const seedBodySchema = z.object({
12953
13143
  steer: z.string().optional(),
12954
13144
  });
12955
13145
  function registerSeedRoute(app, deps) {
12956
- app.post('/seed', async (request, reply) => {
13146
+ app.post(getEndpoint('seed').path, async (request, reply) => {
12957
13147
  const body = seedBodySchema.parse(request.body);
12958
13148
  if (metaExists(body.path)) {
12959
13149
  return reply.status(409).send({
@@ -13118,7 +13308,7 @@ function registerStatusRoute(app, deps) {
13118
13308
  dependencies: {
13119
13309
  watcher: {
13120
13310
  ...watcherHealth,
13121
- rulesRegistered: deps.registrar?.isRegistered ?? false,
13311
+ rulesRegistered: true,
13122
13312
  },
13123
13313
  gateway: gatewayHealth,
13124
13314
  },
@@ -13127,7 +13317,7 @@ function registerStatusRoute(app, deps) {
13127
13317
  };
13128
13318
  },
13129
13319
  });
13130
- app.get('/status', async (_request, reply) => {
13320
+ app.get(getEndpoint('status').path, async (_request, reply) => {
13131
13321
  const result = await statusHandler();
13132
13322
  return reply.status(result.status).send(result.body);
13133
13323
  });
@@ -13146,24 +13336,41 @@ const synthesizeBodySchema = z.object({
13146
13336
  });
13147
13337
  /** Register the POST /synthesize route. */
13148
13338
  function registerSynthesizeRoute(app, deps) {
13149
- app.post('/synthesize', async (request, reply) => {
13339
+ app.post(getEndpoint('synthesize').path, async (request, reply) => {
13150
13340
  const body = synthesizeBodySchema.parse(request.body);
13151
13341
  const { config, watcher, queue, cache } = deps;
13152
13342
  if (body.path) {
13153
13343
  // Path-targeted trigger: create override entry
13154
13344
  const targetPath = resolveMetaDir(body.path);
13155
- // 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.
13156
13348
  let owedPhase = null;
13157
13349
  let meta;
13158
13350
  try {
13159
13351
  meta = await readMetaJson(targetPath);
13160
- const phaseState = derivePhaseState(meta);
13161
- 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
+ }
13162
13368
  }
13163
13369
  catch {
13164
- // 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
13165
13372
  }
13166
- // Fully fresh meta → skip (reuse meta already read above)
13373
+ // Fully fresh meta → skip
13167
13374
  if (owedPhase === null && meta && (meta._phaseState || meta._content)) {
13168
13375
  return await reply.code(200).send({
13169
13376
  status: 'skipped',
@@ -13221,7 +13428,7 @@ const unlockBodySchema = z.object({
13221
13428
  path: z.string().min(1),
13222
13429
  });
13223
13430
  function registerUnlockRoute(app, deps) {
13224
- app.post('/unlock', (request, reply) => {
13431
+ app.post(getEndpoint('unlock').path, (request, reply) => {
13225
13432
  const body = unlockBodySchema.parse(request.body);
13226
13433
  const metaDir = resolveMetaDir(body.path);
13227
13434
  const lockPath = join(metaDir, '.lock');
@@ -13618,9 +13825,7 @@ async function startService(config, configPath) {
13618
13825
  routeDeps.registrar = registrar;
13619
13826
  void registrar.register().then(() => {
13620
13827
  routeDeps.ready = true;
13621
- if (registrar.isRegistered) {
13622
- void verifyRuleApplication(watcher, logger);
13623
- }
13828
+ void verifyRuleApplication(watcher, logger);
13624
13829
  }, () => {
13625
13830
  // Registration failed after max retries — mark ready anyway
13626
13831
  routeDeps.ready = true;