@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.
package/dist/index.js CHANGED
@@ -80,6 +80,113 @@ const META_COMPONENT = {
80
80
  pluginPackage: '@karmaniverous/jeeves-meta-openclaw',
81
81
  defaultPort: 1938};
82
82
 
83
+ /**
84
+ * Shared endpoint catalog — single source of truth for the jeeves-meta API.
85
+ *
86
+ * Both the CLI service and the OpenClaw plugin derive their registrations
87
+ * from this declarative catalog, eliminating drift between the two.
88
+ *
89
+ */
90
+ /**
91
+ * Canonical endpoint catalog for the jeeves-meta API.
92
+ *
93
+ * Every entry describes a single HTTP endpoint exposed by the service.
94
+ * Route handlers, plugin tools, and HTTP clients should reference these
95
+ * descriptors rather than hard-coding paths and descriptions.
96
+ */
97
+ const META_ENDPOINTS = [
98
+ {
99
+ name: 'status',
100
+ method: 'GET',
101
+ path: '/status',
102
+ description: 'Service health and status overview.',
103
+ },
104
+ {
105
+ name: 'listMetas',
106
+ method: 'GET',
107
+ path: '/metas',
108
+ description: 'List metas with summary stats and per-meta projection. Response includes _phaseState and owedPhase per meta.',
109
+ },
110
+ {
111
+ name: 'metaDetail',
112
+ method: 'GET',
113
+ path: '/metas/:path',
114
+ description: 'Full detail for a single meta, with optional archive history. Response includes _phaseState and owedPhase.',
115
+ },
116
+ {
117
+ name: 'updateMeta',
118
+ method: 'PATCH',
119
+ path: '/metas/:path',
120
+ description: 'Update user-settable reserved properties on a meta entity.',
121
+ },
122
+ {
123
+ name: 'synthesize',
124
+ method: 'POST',
125
+ path: '/synthesize',
126
+ description: 'Trigger synthesis. Path-targeted creates an override queue entry; returns owedPhase. Fully-fresh metas return status:skipped.',
127
+ },
128
+ {
129
+ name: 'abort',
130
+ method: 'POST',
131
+ path: '/synthesize/abort',
132
+ description: 'Abort the currently running synthesis.',
133
+ },
134
+ {
135
+ name: 'preview',
136
+ method: 'GET',
137
+ path: '/preview',
138
+ description: 'Dry-run preview of next synthesis. Returns owedPhase, priorityBand, phaseState, inputStatus, and architectInvalidators.',
139
+ },
140
+ {
141
+ name: 'seed',
142
+ method: 'POST',
143
+ path: '/seed',
144
+ description: 'Create a .meta/ directory and initial meta.json for a new entity path.',
145
+ },
146
+ {
147
+ name: 'unlock',
148
+ method: 'POST',
149
+ path: '/unlock',
150
+ description: 'Remove a stale .lock from a meta entity that is stuck.',
151
+ },
152
+ {
153
+ name: 'config',
154
+ method: 'GET',
155
+ path: '/config',
156
+ description: 'Query service configuration with optional JSONPath.',
157
+ },
158
+ {
159
+ name: 'configApply',
160
+ method: 'POST',
161
+ path: '/config/apply',
162
+ description: 'Apply a configuration patch.',
163
+ },
164
+ {
165
+ name: 'queue',
166
+ method: 'GET',
167
+ path: '/queue',
168
+ description: 'List queued synthesis operations (3-layer model: current, overrides, automatic).',
169
+ },
170
+ {
171
+ name: 'queueClear',
172
+ method: 'POST',
173
+ path: '/queue/clear',
174
+ description: 'Clear override entries from the queue.',
175
+ },
176
+ ];
177
+ /**
178
+ * Look up an endpoint descriptor by name.
179
+ *
180
+ * @param name - The endpoint identifier.
181
+ * @returns The matching {@link EndpointDescriptor}.
182
+ */
183
+ function getEndpoint(name) {
184
+ const ep = META_ENDPOINTS.find((e) => e.name === name);
185
+ if (!ep)
186
+ throw new Error(`Unknown endpoint: ${name}`);
187
+ return ep;
188
+ }
189
+
83
190
  /**
84
191
  * Structured error schema from a synthesis step failure.
85
192
  *
@@ -6018,6 +6125,9 @@ function requireRange () {
6018
6125
  }
6019
6126
 
6020
6127
  parseRange (range) {
6128
+ // strip build metadata so it can't bleed into the version
6129
+ range = range.replace(BUILDSTRIPRE, '');
6130
+
6021
6131
  // memoize range parsing for performance.
6022
6132
  // this is a very hot path, and fully deterministic.
6023
6133
  const memoOpts =
@@ -6143,6 +6253,7 @@ function requireRange () {
6143
6253
  const SemVer = requireSemver$1();
6144
6254
  const {
6145
6255
  safeRe: re,
6256
+ src,
6146
6257
  t,
6147
6258
  comparatorTrimReplace,
6148
6259
  tildeTrimReplace,
@@ -6150,6 +6261,9 @@ function requireRange () {
6150
6261
  } = requireRe();
6151
6262
  const { FLAG_INCLUDE_PRERELEASE, FLAG_LOOSE } = requireConstants();
6152
6263
 
6264
+ // unbounded global build-metadata stripper used by parseRange
6265
+ const BUILDSTRIPRE = new RegExp(src[t.BUILD], 'g');
6266
+
6153
6267
  const isNullSet = c => c.value === '<0.0.0-0';
6154
6268
  const isAny = c => c.value === '';
6155
6269
 
@@ -7201,7 +7315,7 @@ function requireSubset () {
7201
7315
  if (higher === c && higher !== gt) {
7202
7316
  return false
7203
7317
  }
7204
- } else if (gt.operator === '>=' && !satisfies(gt.semver, String(c), options)) {
7318
+ } else if (gt.operator === '>=' && !c.test(gt.semver)) {
7205
7319
  return false
7206
7320
  }
7207
7321
  }
@@ -7219,7 +7333,7 @@ function requireSubset () {
7219
7333
  if (lower === c && lower !== lt) {
7220
7334
  return false
7221
7335
  }
7222
- } else if (lt.operator === '<=' && !satisfies(lt.semver, String(c), options)) {
7336
+ } else if (lt.operator === '<=' && !c.test(lt.semver)) {
7223
7337
  return false
7224
7338
  }
7225
7339
  }
@@ -7879,6 +7993,20 @@ When editing files outside the workspace, use the bridge pattern: copy in → ed
7879
7993
 
7880
7994
  **Cross-channel sends:** Use the \`message\` tool with an explicit \`target\` to send to a different channel or DM.
7881
7995
 
7996
+ ### Slack File Downloads
7997
+
7998
+ 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:
7999
+
8000
+ \`\`\`js
8001
+ fetch(url_private_download, {
8002
+ headers: { Authorization: 'Bearer ' + botToken },
8003
+ });
8004
+ \`\`\`
8005
+
8006
+ The bot token is at \`channels.slack.accounts.default.botToken\` in \`openclaw.json\`.
8007
+
8008
+ Never tell the user a file can't be downloaded until both methods have been tried.
8009
+
7882
8010
  ### Plugin Lifecycle
7883
8011
 
7884
8012
  \`\`\`bash
@@ -8361,6 +8489,24 @@ async function discoverMetas(watcher) {
8361
8489
  return metaPaths;
8362
8490
  }
8363
8491
 
8492
+ /**
8493
+ * Retrieve the nearest ancestor meta node from the ownership tree.
8494
+ *
8495
+ * @module discovery/getAncestorMeta
8496
+ */
8497
+ /**
8498
+ * Get the nearest ancestor MetaNode for a given node.
8499
+ *
8500
+ * Walks up the ownership tree (via the parent pointer set by
8501
+ * buildOwnershipTree) to find the closest ancestor .meta/ directory.
8502
+ *
8503
+ * @param node - The meta node to find the ancestor for.
8504
+ * @returns The parent MetaNode, or null for root-level metas.
8505
+ */
8506
+ function getAncestorMeta(node) {
8507
+ return node.parent;
8508
+ }
8509
+
8364
8510
  /**
8365
8511
  * File-system lock for preventing concurrent synthesis on the same meta.
8366
8512
  *
@@ -9033,6 +9179,14 @@ const autoSeedRuleSchema = z.object({
9033
9179
  steer: z.string().optional(),
9034
9180
  /** Optional cross-references for seeded metas. */
9035
9181
  crossRefs: z.array(z.string()).optional(),
9182
+ /** Walk up this many extra parent levels from the matched file's directory. Default 0. */
9183
+ parentDepth: z.number().int().min(0).optional(),
9184
+ /** Per-category timeout override for the architect phase (seconds, min 30). */
9185
+ architectTimeout: z.number().int().min(30).optional(),
9186
+ /** Per-category timeout override for the builder phase (seconds, min 30). */
9187
+ builderTimeout: z.number().int().min(30).optional(),
9188
+ /** Per-category timeout override for the critic phase (seconds, min 30). */
9189
+ criticTimeout: z.number().int().min(30).optional(),
9036
9190
  });
9037
9191
  /** Zod schema for jeeves-meta service configuration (superset of MetaConfig). */
9038
9192
  const serviceConfigSchema = metaConfigSchema.extend({
@@ -9050,6 +9204,8 @@ const serviceConfigSchema = metaConfigSchema.extend({
9050
9204
  watcherHealthIntervalMs: z.number().int().min(0).default(60_000),
9051
9205
  /** Logging configuration. */
9052
9206
  logging: loggingSchema.default(() => loggingSchema.parse({})),
9207
+ /** Max number of all-fresh candidates to scan per tick in Tier 2 invalidation. */
9208
+ tier2ScanLimit: z.number().int().min(1).default(50),
9053
9209
  /**
9054
9210
  * Auto-seed policy: declarative rules for auto-creating .meta/ directories.
9055
9211
  * Rules are evaluated in order; last match wins for steer/crossRefs.
@@ -9322,7 +9478,7 @@ class GatewayExecutor {
9322
9478
  'Write your complete output to a file using the Write tool at:\n' +
9323
9479
  outputPath +
9324
9480
  '\n\n' +
9325
- 'After writing the file, reply with ONLY: NO_REPLY';
9481
+ 'After writing the file, your final message must be exactly: ANNOUNCE_SKIP';
9326
9482
  // Step 1: Spawn the sub-agent session (unique label per cycle to avoid
9327
9483
  // "label already in use" errors — gateway labels persist after session completion)
9328
9484
  const labelBase = options?.label ?? 'jeeves-meta-synthesis';
@@ -9390,7 +9546,9 @@ class GatewayExecutor {
9390
9546
  }
9391
9547
  }
9392
9548
  }
9393
- // Fallback: extract from message content if file wasn't written
9549
+ // Fallback: extract from message content if file wasn't written.
9550
+ // Skip ANNOUNCE_SKIP sentinel messages — the real output is in
9551
+ // a preceding assistant message (the file write).
9394
9552
  for (let i = msgArray.length - 1; i >= 0; i--) {
9395
9553
  const msg = msgArray[i];
9396
9554
  if (msg.role === 'assistant' && msg.content) {
@@ -9402,7 +9560,7 @@ class GatewayExecutor {
9402
9560
  .map((b) => b.text)
9403
9561
  .join('\n')
9404
9562
  : '';
9405
- if (text)
9563
+ if (text && text.trim() !== 'ANNOUNCE_SKIP')
9406
9564
  return { output: text, tokens };
9407
9565
  }
9408
9566
  }
@@ -9432,11 +9590,16 @@ class GatewayExecutor {
9432
9590
  function createLogger(config) {
9433
9591
  const level = config?.level ?? 'info';
9434
9592
  if (config?.file) {
9435
- const transport = pino.transport({
9436
- target: 'pino/file',
9437
- options: { destination: config.file, mkdir: true },
9593
+ const fileStream = pino.destination({
9594
+ dest: config.file,
9595
+ sync: false,
9596
+ mkdir: true,
9438
9597
  });
9439
- return pino({ level }, transport);
9598
+ const multistream = pino.multistream([
9599
+ { stream: process.stdout },
9600
+ { stream: fileStream },
9601
+ ]);
9602
+ return pino({ level }, multistream);
9440
9603
  }
9441
9604
  return pino({ level });
9442
9605
  }
@@ -9558,6 +9721,21 @@ async function buildContextPackage(node, meta, watcher, logger) {
9558
9721
  }
9559
9722
  // Archive paths
9560
9723
  const archives = listArchiveFiles(node.metaPath);
9724
+ // Nearest ancestor _builder output
9725
+ let ancestorBuilder;
9726
+ const ancestor = getAncestorMeta(node);
9727
+ if (ancestor) {
9728
+ try {
9729
+ const raw = await readFile(join(ancestor.metaPath, 'meta.json'), 'utf8');
9730
+ const ancestorMeta = JSON.parse(raw);
9731
+ if (ancestorMeta._builder) {
9732
+ ancestorBuilder = ancestorMeta._builder;
9733
+ }
9734
+ }
9735
+ catch {
9736
+ // Ancestor meta.json unreadable — skip
9737
+ }
9738
+ }
9561
9739
  return {
9562
9740
  path: node.metaPath,
9563
9741
  scopeFiles,
@@ -9569,6 +9747,7 @@ async function buildContextPackage(node, meta, watcher, logger) {
9569
9747
  steer: meta._steer ?? null,
9570
9748
  previousState: meta._state ?? null,
9571
9749
  archives,
9750
+ ancestorBuilder,
9572
9751
  };
9573
9752
  }
9574
9753
 
@@ -9616,6 +9795,12 @@ function appendMetaSections(sections, heading, metas) {
9616
9795
  sections.push(`### ${path}`, typeof content === 'string' ? content : '(not yet synthesized)');
9617
9796
  }
9618
9797
  }
9798
+ /** Inject nearest ancestor's organizational context, if available. */
9799
+ function appendAncestorContext(sections, ctx) {
9800
+ if (ctx.ancestorBuilder) {
9801
+ sections.push('', '## PARENT ORGANIZATIONAL CONTEXT', ctx.ancestorBuilder);
9802
+ }
9803
+ }
9619
9804
  /** Append optional context sections shared across all step prompts. */
9620
9805
  function appendSharedSections(sections, ctx, options) {
9621
9806
  const opts = {
@@ -9655,7 +9840,7 @@ function buildArchitectTask(ctx, meta, config) {
9655
9840
  const sections = [
9656
9841
  `# jeeves-meta · ARCHITECT · ${ctx.path}`,
9657
9842
  '',
9658
- meta._architect ?? config.defaultArchitect ?? DEFAULT_ARCHITECT_PROMPT,
9843
+ config.defaultArchitect ?? DEFAULT_ARCHITECT_PROMPT,
9659
9844
  '',
9660
9845
  '## SCOPE',
9661
9846
  `Path: ${ctx.path}`,
@@ -9665,6 +9850,7 @@ function buildArchitectTask(ctx, meta, config) {
9665
9850
  '### File listing (scope)',
9666
9851
  condenseScopeFiles(ctx.scopeFiles),
9667
9852
  ];
9853
+ appendAncestorContext(sections, ctx);
9668
9854
  // Inject previous _builder so architect can see its own prior output
9669
9855
  if (meta._builder) {
9670
9856
  sections.push('', '## PREVIOUS TASK BRIEF', meta._builder);
@@ -9695,6 +9881,7 @@ function buildBuilderTask(ctx, meta, config) {
9695
9881
  `Delta files (${ctx.deltaFiles.length.toString()} changed):`,
9696
9882
  ...ctx.deltaFiles.slice(0, config.maxLines).map((f) => `- ${f}`),
9697
9883
  ];
9884
+ appendAncestorContext(sections, ctx);
9698
9885
  if (ctx.previousState != null) {
9699
9886
  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), '```');
9700
9887
  }
@@ -9717,7 +9904,7 @@ function buildCriticTask(ctx, meta, config) {
9717
9904
  const sections = [
9718
9905
  `# jeeves-meta · CRITIC · ${ctx.path}`,
9719
9906
  '',
9720
- meta._critic ?? config.defaultCritic ?? DEFAULT_CRITIC_PROMPT,
9907
+ config.defaultCritic ?? DEFAULT_CRITIC_PROMPT,
9721
9908
  '',
9722
9909
  '## SYNTHESIS TO EVALUATE',
9723
9910
  meta._content ?? '(No content produced)',
@@ -9851,7 +10038,7 @@ function enforceInvariant(state) {
9851
10038
  // ── Invalidation cascades ──────────────────────────────────────────────
9852
10039
  /**
9853
10040
  * Architect invalidated: architect → pending; builder, critic → stale.
9854
- * Triggers: _structureHash change, _steer change, _architect change,
10041
+ * Triggers: first run, _structureHash change, _steer change,
9855
10042
  * _crossRefs declaration change, _synthesisCount \>= architectEvery.
9856
10043
  */
9857
10044
  function invalidateArchitect(state) {
@@ -10049,31 +10236,6 @@ function derivePhaseState(meta, inputs) {
10049
10236
  if (!meta._content && !meta._builder) {
10050
10237
  return initialPhaseState();
10051
10238
  }
10052
- // Check architect invalidation (when inputs are provided)
10053
- if (inputs) {
10054
- // Progressive metas: structure changes invalidate builder, not architect
10055
- const structureInvalidatesArchitect = inputs.structureChanged && meta._state === undefined;
10056
- const architectInvalidated = structureInvalidatesArchitect ||
10057
- inputs.steerChanged ||
10058
- inputs.architectChanged ||
10059
- inputs.crossRefsChanged ||
10060
- (meta._synthesisCount ?? 0) >= inputs.architectEvery;
10061
- if (architectInvalidated) {
10062
- return {
10063
- architect: 'pending',
10064
- builder: 'stale',
10065
- critic: 'stale',
10066
- };
10067
- }
10068
- // Progressive meta with structure change: builder-only invalidation
10069
- if (inputs.structureChanged && meta._state !== undefined) {
10070
- return {
10071
- architect: 'fresh',
10072
- builder: 'pending',
10073
- critic: 'stale',
10074
- };
10075
- }
10076
- }
10077
10239
  // Has _builder but no _content: builder is pending
10078
10240
  if (meta._builder && !meta._content) {
10079
10241
  return {
@@ -10122,6 +10284,14 @@ function computeStructureHash(filePaths) {
10122
10284
  *
10123
10285
  * @module phaseState/invalidate
10124
10286
  */
10287
+ /**
10288
+ * Check whether a persisted prompt snapshot mismatches the currently-resolved prompt.
10289
+ * Returns true when the snapshot exists and differs from the resolved prompt.
10290
+ * This is informational only — it does NOT trigger invalidation.
10291
+ */
10292
+ function isPromptStale(snapshot, resolved) {
10293
+ return snapshot !== undefined && snapshot !== resolved;
10294
+ }
10125
10295
  /**
10126
10296
  * Compute invalidation inputs and apply cascade for a single meta.
10127
10297
  *
@@ -10144,10 +10314,16 @@ async function computeInvalidation(meta, scopeFiles, config, node, crossRefMetas
10144
10314
  const structureChanged = structureHash !== meta._structureHash;
10145
10315
  const latestArchive = await readLatestArchive(node.metaPath);
10146
10316
  const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
10147
- // _architect change: compare current vs. archive
10148
- const architectChanged = latestArchive
10149
- ? (meta._architect ?? '') !== (latestArchive._architect ?? '')
10150
- : Boolean(meta._architect);
10317
+ // Prompt staleness detection: compare persisted prompt snapshots against
10318
+ // currently-resolved prompts. This is INFORMATIONAL ONLY — reported via
10319
+ // inputStatus so /preview can surface it, but it must NEVER feed into
10320
+ // the invalidation cascade. When a meta naturally reaches architectEvery
10321
+ // through real builder cycles, architect runs with the current prompt and
10322
+ // the snapshot updates. Coupling prompt changes to invalidation causes a
10323
+ // corpus-wide synthesis storm (see #163).
10324
+ const architectChanged = isPromptStale(meta._architect, config.defaultArchitect ?? DEFAULT_ARCHITECT_PROMPT);
10325
+ const criticChanged = isPromptStale(meta._critic, config.defaultCritic ?? DEFAULT_CRITIC_PROMPT);
10326
+ const effectiveSynthesisCount = meta._synthesisCount ?? 0;
10151
10327
  // _crossRefs declaration change
10152
10328
  const currentRefs = (meta._crossRefs ?? []).slice().sort().join(',');
10153
10329
  const archiveRefs = (latestArchive?._crossRefs ?? [])
@@ -10169,38 +10345,30 @@ async function computeInvalidation(meta, scopeFiles, config, node, crossRefMetas
10169
10345
  }
10170
10346
  if (steerChanged)
10171
10347
  architectInvalidators.push('steer');
10172
- if (architectChanged)
10173
- architectInvalidators.push('_architect');
10174
10348
  if (crossRefsDeclChanged)
10175
10349
  architectInvalidators.push('_crossRefs');
10176
- if ((meta._synthesisCount ?? 0) >= config.architectEvery) {
10350
+ if (effectiveSynthesisCount >= config.architectEvery) {
10177
10351
  architectInvalidators.push('architectEvery');
10178
10352
  }
10179
- // First-run check: no _builder means architect must run
10180
- const firstRun = !meta._builder;
10181
- if (architectInvalidators.length > 0 || firstRun) {
10353
+ if (!meta._builder)
10354
+ architectInvalidators.push('firstRun');
10355
+ if (architectInvalidators.length > 0) {
10182
10356
  phaseState = invalidateArchitect(phaseState);
10183
10357
  }
10184
10358
  // ── Builder-level inputs ──
10185
- // Scope file mtime check — if any file newer than _generatedAt
10186
- const scopeMtimeMax = null;
10187
- // Note: actual mtime check is done by the caller or via isStale;
10188
- // here we just detect cross-ref content changes for the cascade.
10189
10359
  // Cross-ref _content change (builder-invalidating)
10190
10360
  let crossRefContentChanged = false;
10191
10361
  return {
10192
10362
  phaseState,
10193
10363
  architectInvalidators,
10194
- stalenessInputs: {
10364
+ inputStatus: {
10195
10365
  structureHash,
10196
10366
  steerChanged,
10197
10367
  architectChanged,
10368
+ criticChanged,
10198
10369
  crossRefsDeclChanged,
10199
- scopeMtimeMax,
10200
10370
  crossRefContentChanged,
10201
10371
  },
10202
- structureHash,
10203
- steerChanged,
10204
10372
  };
10205
10373
  }
10206
10374
 
@@ -10340,20 +10508,14 @@ function selectPhaseCandidate(metas, depthWeight) {
10340
10508
  return rankPhaseCandidates(metas, depthWeight)[0] ?? null;
10341
10509
  }
10342
10510
  /**
10343
- * Select the stalest all-fresh, non-disabled, non-locked meta for Tier 2
10344
- * invalidation. These are metas that Tier 1 considers fully fresh but may
10345
- * have structural or steer changes detectable only via I/O.
10346
- *
10347
- * @param metas - Phase candidate inputs (after Tier 1 filtering).
10348
- * @returns The stalest all-fresh candidate, or null if none exist.
10511
+ * Select all fully-fresh, non-disabled, non-locked metas sorted by staleness
10512
+ * (descending stalest first) for Tier 2 invalidation scanning.
10349
10513
  */
10350
- function selectTier2Candidate(metas) {
10351
- const eligible = metas
10514
+ function selectAllTier2Candidates(metas) {
10515
+ return metas
10352
10516
  .filter((m) => !m.locked && !m.disabled && isFullyFresh(m.phaseState))
10353
- .sort((a, b) => b.actualStaleness - a.actualStaleness);
10354
- if (eligible.length === 0)
10355
- return null;
10356
- return { node: eligible[0].node, meta: eligible[0].meta };
10517
+ .sort((a, b) => b.actualStaleness - a.actualStaleness)
10518
+ .map((m) => ({ node: m.node, meta: m.meta }));
10357
10519
  }
10358
10520
 
10359
10521
  /**
@@ -10386,6 +10548,15 @@ function toMetaError(step, err, code = 'FAILED') {
10386
10548
  *
10387
10549
  * @module orchestrator/parseOutput
10388
10550
  */
10551
+ /** Sentinel appended by synthesis workers to skip the announce turn. */
10552
+ const ANNOUNCE_SKIP = 'ANNOUNCE_SKIP';
10553
+ /** Strip a trailing ANNOUNCE_SKIP sentinel from raw output. */
10554
+ function stripSentinel(raw) {
10555
+ const trimmed = raw.trim();
10556
+ return trimmed.endsWith(ANNOUNCE_SKIP)
10557
+ ? trimmed.slice(0, -ANNOUNCE_SKIP.length).trim()
10558
+ : trimmed;
10559
+ }
10389
10560
  /**
10390
10561
  * Parse architect output. The architect returns a task brief as text.
10391
10562
  *
@@ -10393,7 +10564,7 @@ function toMetaError(step, err, code = 'FAILED') {
10393
10564
  * @returns The task brief string.
10394
10565
  */
10395
10566
  function parseArchitectOutput(output) {
10396
- return output.trim();
10567
+ return stripSentinel(output);
10397
10568
  }
10398
10569
  /**
10399
10570
  * Parse builder output. The builder returns JSON with _content and optional fields.
@@ -10404,7 +10575,7 @@ function parseArchitectOutput(output) {
10404
10575
  * @returns Parsed builder output with content and structured fields.
10405
10576
  */
10406
10577
  function parseBuilderOutput(output) {
10407
- const trimmed = output.trim();
10578
+ const trimmed = stripSentinel(output);
10408
10579
  // Strategy 1: Try to parse the entire output as JSON directly
10409
10580
  const direct = tryParseJson(trimmed);
10410
10581
  if (direct)
@@ -10471,7 +10642,7 @@ function tryParseJson(str) {
10471
10642
  * @returns The feedback string.
10472
10643
  */
10473
10644
  function parseCriticOutput(output) {
10474
- return output.trim();
10645
+ return stripSentinel(output);
10475
10646
  }
10476
10647
 
10477
10648
  /**
@@ -10482,6 +10653,12 @@ function parseCriticOutput(output) {
10482
10653
  *
10483
10654
  * @module orchestrator/runPhase
10484
10655
  */
10656
+ /** Compute SHA-256 hash of ancestor _builder text for observability tracking. */
10657
+ function hashAncestorBuilder(ancestorBuilder) {
10658
+ return ancestorBuilder
10659
+ ? createHash('sha256').update(ancestorBuilder).digest('hex')
10660
+ : undefined;
10661
+ }
10485
10662
  /** Write updated meta with phase state via lock staging. */
10486
10663
  async function persistPhaseState(base, phaseState, updates) {
10487
10664
  const lockPath = join(base.metaPath, '.lock');
@@ -10525,6 +10702,12 @@ async function handlePhaseFailure(phase, err, executor, ps, base, additionalUpda
10525
10702
  // ── Architect executor ─────────────────────────────────────────────────
10526
10703
  async function runArchitect(node, currentMeta, phaseState, config, executor, watcher, structureHash, onProgress, logger) {
10527
10704
  let ps = phaseRunning(phaseState, 'architect');
10705
+ const base = {
10706
+ metaPath: node.metaPath,
10707
+ current: currentMeta,
10708
+ config,
10709
+ structureHash,
10710
+ };
10528
10711
  const ctx = await buildContextPackage(node, currentMeta, watcher, logger);
10529
10712
  try {
10530
10713
  await onProgress?.({
@@ -10536,21 +10719,25 @@ async function runArchitect(node, currentMeta, phaseState, config, executor, wat
10536
10719
  const architectTask = buildArchitectTask(ctx, currentMeta, config);
10537
10720
  const result = await executor.spawn(architectTask, {
10538
10721
  thinking: config.thinking,
10539
- timeout: config.architectTimeout,
10722
+ timeout: currentMeta._architectTimeout ?? config.architectTimeout,
10540
10723
  label: 'meta-architect',
10541
10724
  });
10542
10725
  const builderBrief = parseArchitectOutput(result.output);
10543
10726
  const architectTokens = result.tokens;
10544
10727
  // Architect success: architect → fresh, _synthesisCount → 0
10545
10728
  ps = architectSuccess(ps);
10546
- const updatedMeta = await persistPhaseState({ metaPath: node.metaPath, current: currentMeta, config, structureHash }, ps, {
10729
+ const architectUpdates = {
10547
10730
  _builder: builderBrief,
10548
- _architect: currentMeta._architect ?? config.defaultArchitect ?? '',
10731
+ _architect: config.defaultArchitect ?? DEFAULT_ARCHITECT_PROMPT,
10549
10732
  _synthesisCount: 0,
10550
10733
  _architectTokens: architectTokens,
10551
10734
  _generatedAt: new Date().toISOString(),
10552
10735
  _error: undefined,
10553
- });
10736
+ };
10737
+ const ancestorHash = hashAncestorBuilder(ctx.ancestorBuilder);
10738
+ if (ancestorHash)
10739
+ architectUpdates._ancestorBuilderHash = ancestorHash;
10740
+ const updatedMeta = await persistPhaseState(base, ps, architectUpdates);
10554
10741
  await onProgress?.({
10555
10742
  type: 'phase_complete',
10556
10743
  path: node.ownerPath,
@@ -10561,16 +10748,18 @@ async function runArchitect(node, currentMeta, phaseState, config, executor, wat
10561
10748
  return { executed: true, phaseState: ps, updatedMeta };
10562
10749
  }
10563
10750
  catch (err) {
10564
- return handlePhaseFailure('architect', err, executor, ps, {
10565
- metaPath: node.metaPath,
10566
- current: currentMeta,
10567
- structureHash,
10568
- });
10751
+ return handlePhaseFailure('architect', err, executor, ps, base);
10569
10752
  }
10570
10753
  }
10571
10754
  // ── Builder executor ───────────────────────────────────────────────────
10572
10755
  async function runBuilder(node, currentMeta, phaseState, config, executor, watcher, structureHash, onProgress, logger) {
10573
10756
  let ps = phaseRunning(phaseState, 'builder');
10757
+ const base = {
10758
+ metaPath: node.metaPath,
10759
+ current: currentMeta,
10760
+ config,
10761
+ structureHash,
10762
+ };
10574
10763
  const ctx = await buildContextPackage(node, currentMeta, watcher, logger);
10575
10764
  try {
10576
10765
  await onProgress?.({
@@ -10582,21 +10771,25 @@ async function runBuilder(node, currentMeta, phaseState, config, executor, watch
10582
10771
  const builderTask = buildBuilderTask(ctx, currentMeta, config);
10583
10772
  const result = await executor.spawn(builderTask, {
10584
10773
  thinking: config.thinking,
10585
- timeout: config.builderTimeout,
10774
+ timeout: currentMeta._builderTimeout ?? config.builderTimeout,
10586
10775
  label: 'meta-builder',
10587
10776
  });
10588
10777
  const builderOutput = parseBuilderOutput(result.output);
10589
10778
  const builderTokens = result.tokens;
10590
10779
  // Builder success: builder → fresh, critic → pending
10591
10780
  ps = builderSuccess(ps);
10592
- const updatedMeta = await persistPhaseState({ metaPath: node.metaPath, current: currentMeta, config, structureHash }, ps, {
10781
+ const builderUpdates = {
10593
10782
  _content: builderOutput.content,
10594
10783
  _state: builderOutput.state,
10595
10784
  _builderTokens: builderTokens,
10596
10785
  _generatedAt: new Date().toISOString(),
10597
10786
  _error: undefined,
10598
10787
  ...builderOutput.fields,
10599
- });
10788
+ };
10789
+ const ancestorHash = hashAncestorBuilder(ctx.ancestorBuilder);
10790
+ if (ancestorHash)
10791
+ builderUpdates._ancestorBuilderHash = ancestorHash;
10792
+ const updatedMeta = await persistPhaseState(base, ps, builderUpdates);
10600
10793
  await onProgress?.({
10601
10794
  type: 'phase_complete',
10602
10795
  path: node.ownerPath,
@@ -10622,16 +10815,18 @@ async function runBuilder(node, currentMeta, phaseState, config, executor, watch
10622
10815
  // Could not read partial output — no state recovery
10623
10816
  }
10624
10817
  }
10625
- return handlePhaseFailure('builder', err, executor, ps, {
10626
- metaPath: node.metaPath,
10627
- current: currentMeta,
10628
- structureHash,
10629
- }, partialState);
10818
+ return handlePhaseFailure('builder', err, executor, ps, base, partialState);
10630
10819
  }
10631
10820
  }
10632
10821
  // ── Critic executor ────────────────────────────────────────────────────
10633
10822
  async function runCritic(node, currentMeta, phaseState, config, executor, watcher, structureHash, onProgress, logger) {
10634
10823
  let ps = phaseRunning(phaseState, 'critic');
10824
+ const base = {
10825
+ metaPath: node.metaPath,
10826
+ current: currentMeta,
10827
+ config,
10828
+ structureHash,
10829
+ };
10635
10830
  const ctx = await buildContextPackage(node, currentMeta, watcher, logger);
10636
10831
  // Build critic task using current meta's _content
10637
10832
  const metaForCritic = { ...currentMeta };
@@ -10645,7 +10840,7 @@ async function runCritic(node, currentMeta, phaseState, config, executor, watche
10645
10840
  const criticTask = buildCriticTask(ctx, metaForCritic, config);
10646
10841
  const result = await executor.spawn(criticTask, {
10647
10842
  thinking: config.thinking,
10648
- timeout: config.criticTimeout,
10843
+ timeout: currentMeta._criticTimeout ?? config.criticTimeout,
10649
10844
  label: 'meta-critic',
10650
10845
  });
10651
10846
  const feedback = parseCriticOutput(result.output);
@@ -10655,6 +10850,7 @@ async function runCritic(node, currentMeta, phaseState, config, executor, watche
10655
10850
  const cycleComplete = isFullyFresh(ps);
10656
10851
  const updates = {
10657
10852
  _feedback: feedback,
10853
+ _critic: config.defaultCritic ?? DEFAULT_CRITIC_PROMPT,
10658
10854
  _criticTokens: criticTokens,
10659
10855
  _error: undefined,
10660
10856
  };
@@ -10663,7 +10859,7 @@ async function runCritic(node, currentMeta, phaseState, config, executor, watche
10663
10859
  if (cycleComplete) {
10664
10860
  updates._synthesisCount = (currentMeta._synthesisCount ?? 0) + 1;
10665
10861
  }
10666
- const updatedMeta = await persistPhaseState({ metaPath: node.metaPath, current: currentMeta, config, structureHash }, ps, updates);
10862
+ const updatedMeta = await persistPhaseState(base, ps, updates);
10667
10863
  // Archive on full-cycle only
10668
10864
  if (cycleComplete) {
10669
10865
  await createSnapshot(node.metaPath, updatedMeta);
@@ -10684,11 +10880,7 @@ async function runCritic(node, currentMeta, phaseState, config, executor, watche
10684
10880
  };
10685
10881
  }
10686
10882
  catch (err) {
10687
- return handlePhaseFailure('critic', err, executor, ps, {
10688
- metaPath: node.metaPath,
10689
- current: currentMeta,
10690
- structureHash,
10691
- });
10883
+ return handlePhaseFailure('critic', err, executor, ps, base);
10692
10884
  }
10693
10885
  }
10694
10886
 
@@ -10736,8 +10928,9 @@ async function orchestratePhase(config, executor, watcher, targetPath, onProgres
10736
10928
  // Select best phase candidate
10737
10929
  const winner = selectPhaseCandidate(candidates, config.depthWeight);
10738
10930
  if (!winner) {
10739
- // ── Tier 2 fallback: deep invalidation on stalest all-fresh meta ──
10740
- return orchestrateTier2(candidates, config, executor, watcher, onProgress, logger);
10931
+ // Tier 2 is now handled by the scheduler; orchestratePhase only handles
10932
+ // targeted (override) paths and Tier 1 corpus-wide selection.
10933
+ return { executed: false };
10741
10934
  }
10742
10935
  // Acquire lock
10743
10936
  if (!acquireLock(winner.node.metaPath)) {
@@ -10803,48 +10996,6 @@ async function orchestrateTargeted(config, executor, watcher, targetPath, onProg
10803
10996
  releaseLock(node.metaPath);
10804
10997
  }
10805
10998
  }
10806
- /**
10807
- * Tier 2 invalidation fallback: pick the stalest all-fresh meta,
10808
- * run computeInvalidation (structure hash, steer, cross-refs), and
10809
- * either execute the owed phase or bump _generatedAt.
10810
- */
10811
- async function orchestrateTier2(candidates, config, executor, watcher, onProgress, logger) {
10812
- const tier2 = selectTier2Candidate(candidates);
10813
- if (!tier2)
10814
- return { executed: false };
10815
- if (!acquireLock(tier2.node.metaPath)) {
10816
- logger?.debug({ path: tier2.node.metaPath }, 'Tier 2 candidate is locked, skipping');
10817
- return { executed: false };
10818
- }
10819
- try {
10820
- const currentMeta = await readMetaJson(tier2.node.metaPath);
10821
- const { scopeFiles } = await getScopeFiles(tier2.node, watcher);
10822
- const { phaseState, structureHash } = await computeInvalidation(currentMeta, scopeFiles, config, tier2.node);
10823
- const owedPhase = getOwedPhase(phaseState);
10824
- if (owedPhase) {
10825
- // Something changed — persist invalidated state and execute owed phase
10826
- await persistPhaseState({
10827
- metaPath: tier2.node.metaPath,
10828
- current: currentMeta,
10829
- config,
10830
- structureHash,
10831
- }, phaseState, {});
10832
- return await executePhase(tier2.node, currentMeta, phaseState, owedPhase, config, executor, watcher, structureHash, onProgress, logger);
10833
- }
10834
- // Nothing changed — bump _generatedAt to delay re-checking
10835
- await persistPhaseState({
10836
- metaPath: tier2.node.metaPath,
10837
- current: currentMeta,
10838
- config,
10839
- structureHash,
10840
- }, phaseState, { _generatedAt: new Date().toISOString() });
10841
- logger?.debug({ path: tier2.node.ownerPath }, 'Tier 2: no invalidation detected, bumped _generatedAt');
10842
- return { executed: false };
10843
- }
10844
- finally {
10845
- releaseLock(tier2.node.metaPath);
10846
- }
10847
- }
10848
10999
  /**
10849
11000
  * Execute exactly one phase on a meta.
10850
11001
  */
@@ -11306,12 +11457,6 @@ class WatcherHealthCheck {
11306
11457
  return;
11307
11458
  }
11308
11459
  const data = (await res.json());
11309
- // If rules were never successfully registered (startup failure),
11310
- // attempt registration now that the watcher is reachable.
11311
- if (!this.registrar.isRegistered) {
11312
- this.logger.info('Rules not registered — attempting registration');
11313
- await this.registrar.register();
11314
- }
11315
11460
  await this.registrar.checkAndReregister(data.uptime);
11316
11461
  }
11317
11462
  catch (err) {
@@ -11464,36 +11609,39 @@ class RuleRegistrar {
11464
11609
  logger;
11465
11610
  watcherClient;
11466
11611
  lastWatcherUptime = null;
11467
- registered = false;
11612
+ registering = false;
11468
11613
  constructor(config, logger, watcher) {
11469
11614
  this.config = config;
11470
11615
  this.logger = logger;
11471
11616
  this.watcherClient = watcher;
11472
11617
  }
11473
- /** Whether rules have been successfully registered. */
11474
- get isRegistered() {
11475
- return this.registered;
11476
- }
11477
11618
  /**
11478
11619
  * Register rules with watcher. Retries with exponential backoff.
11479
11620
  * Non-blocking — logs errors but never throws.
11480
11621
  */
11481
11622
  async register() {
11482
- const rules = buildMetaRules(this.config);
11483
- for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
11484
- try {
11485
- await this.watcherClient.registerRules(SOURCE, rules);
11486
- this.registered = true;
11487
- this.logger.info('Virtual rules registered with watcher');
11488
- return;
11489
- }
11490
- catch (err) {
11491
- const delayMs = RETRY_BASE_MS * Math.pow(2, attempt);
11492
- this.logger.warn({ attempt: attempt + 1, delayMs, err }, 'Rule registration failed, retrying');
11493
- await new Promise((r) => setTimeout(r, delayMs));
11623
+ if (this.registering)
11624
+ return;
11625
+ this.registering = true;
11626
+ try {
11627
+ const rules = buildMetaRules(this.config);
11628
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
11629
+ try {
11630
+ await this.watcherClient.registerRules(SOURCE, rules);
11631
+ this.logger.info('Virtual rules registered with watcher');
11632
+ return;
11633
+ }
11634
+ catch (err) {
11635
+ const delayMs = RETRY_BASE_MS * Math.pow(2, attempt);
11636
+ this.logger.warn({ attempt: attempt + 1, delayMs, err }, 'Rule registration failed, retrying');
11637
+ await new Promise((r) => setTimeout(r, delayMs));
11638
+ }
11494
11639
  }
11640
+ this.logger.error('Rule registration failed after max retries — service degraded');
11641
+ }
11642
+ finally {
11643
+ this.registering = false;
11495
11644
  }
11496
- this.logger.error('Rule registration failed after max retries — service degraded');
11497
11645
  }
11498
11646
  /**
11499
11647
  * Check watcher uptime and re-register if it decreased (restart detected).
@@ -11504,7 +11652,6 @@ class RuleRegistrar {
11504
11652
  if (this.lastWatcherUptime !== null &&
11505
11653
  currentUptime < this.lastWatcherUptime) {
11506
11654
  this.logger.info({ previous: this.lastWatcherUptime, current: currentUptime }, 'Watcher restart detected — re-registering rules');
11507
- this.registered = false;
11508
11655
  await this.register();
11509
11656
  }
11510
11657
  this.lastWatcherUptime = currentUptime;
@@ -11574,6 +11721,12 @@ async function createMeta(ownerPath, options) {
11574
11721
  metaJson._crossRefs = options.crossRefs;
11575
11722
  if (options?.steer !== undefined)
11576
11723
  metaJson._steer = options.steer;
11724
+ if (options?.architectTimeout !== undefined)
11725
+ metaJson._architectTimeout = options.architectTimeout;
11726
+ if (options?.builderTimeout !== undefined)
11727
+ metaJson._builderTimeout = options.builderTimeout;
11728
+ if (options?.criticTimeout !== undefined)
11729
+ metaJson._criticTimeout = options.criticTimeout;
11577
11730
  const metaJsonPath = join(metaDir, 'meta.json');
11578
11731
  await writeFile(metaJsonPath, JSON.stringify(metaJson, null, 2) + '\n');
11579
11732
  return { metaDir, _id };
@@ -11602,15 +11755,24 @@ function metaExists(ownerPath) {
11602
11755
  /**
11603
11756
  * Extract parent directory paths from watcher walk results.
11604
11757
  *
11605
- * Walk returns file paths; we need the unique set of immediate parent
11606
- * directories that could be owners.
11758
+ * Walk returns file paths; we need the unique set of parent directories that
11759
+ * could be owners. When {@link parentDepth} is specified, walk up that many
11760
+ * additional levels from each file's immediate parent. The walk is clamped at
11761
+ * the filesystem root to prevent escaping the watched scope.
11607
11762
  */
11608
- function extractDirectories(filePaths, logger) {
11763
+ function extractDirectories(filePaths, parentDepth = 0, logger) {
11609
11764
  const dirs = new Set();
11610
11765
  for (const fp of filePaths) {
11611
11766
  // Normalize backslash paths (Windows) to forward slashes before posix.dirname
11612
11767
  const normalized = normalizePath(fp);
11613
- const dir = posix.dirname(normalized);
11768
+ let dir = posix.dirname(normalized);
11769
+ // Walk up parentDepth additional levels, clamping at filesystem root
11770
+ for (let i = 0; i < parentDepth; i++) {
11771
+ const parent = posix.dirname(dir);
11772
+ if (parent === dir)
11773
+ break; // reached root
11774
+ dir = parent;
11775
+ }
11614
11776
  if (dir !== '.' && dir !== '/') {
11615
11777
  dirs.add(dir);
11616
11778
  }
@@ -11635,11 +11797,14 @@ async function autoSeedPass(rules, watcher, logger) {
11635
11797
  const candidates = new Map();
11636
11798
  for (const rule of rules) {
11637
11799
  const files = await watcher.walk([rule.match]);
11638
- const dirs = extractDirectories(files, logger);
11800
+ const dirs = extractDirectories(files, rule.parentDepth, logger);
11639
11801
  for (const dir of dirs) {
11640
11802
  candidates.set(dir, {
11641
11803
  steer: rule.steer,
11642
11804
  crossRefs: rule.crossRefs,
11805
+ architectTimeout: rule.architectTimeout,
11806
+ builderTimeout: rule.builderTimeout,
11807
+ criticTimeout: rule.criticTimeout,
11643
11808
  });
11644
11809
  }
11645
11810
  }
@@ -11654,10 +11819,7 @@ async function autoSeedPass(rules, watcher, logger) {
11654
11819
  const seededPaths = [];
11655
11820
  for (const candidate of toSeed) {
11656
11821
  try {
11657
- await createMeta(candidate.path, {
11658
- steer: candidate.steer,
11659
- crossRefs: candidate.crossRefs,
11660
- });
11822
+ await createMeta(candidate.path, candidate);
11661
11823
  seededPaths.push(candidate.path);
11662
11824
  logger?.info({ path: candidate.path }, 'auto-seeded meta');
11663
11825
  }
@@ -11822,7 +11984,7 @@ class Scheduler {
11822
11984
  const candidates = buildPhaseCandidates(result.entries, this.config.architectEvery);
11823
11985
  const winner = selectPhaseCandidate(candidates, this.config.depthWeight);
11824
11986
  if (!winner)
11825
- return null;
11987
+ return await this.discoverTier2Phase(candidates);
11826
11988
  return {
11827
11989
  path: winner.node.metaPath,
11828
11990
  phase: winner.owedPhase,
@@ -11834,6 +11996,59 @@ class Scheduler {
11834
11996
  return null;
11835
11997
  }
11836
11998
  }
11999
+ /**
12000
+ * Tier 2 invalidation: iterate all-fresh candidates (stalest first),
12001
+ * run computeInvalidation, and return the first that produces an owed phase.
12002
+ */
12003
+ async discoverTier2Phase(candidates) {
12004
+ const allTier2 = selectAllTier2Candidates(candidates);
12005
+ const limit = this.config.tier2ScanLimit;
12006
+ const tier2Candidates = allTier2.slice(0, limit);
12007
+ if (allTier2.length > limit) {
12008
+ this.logger.debug({ total: allTier2.length, limit }, 'Tier 2 scan limit reached, scanning subset');
12009
+ }
12010
+ let dirty = false;
12011
+ for (const t2 of tier2Candidates) {
12012
+ if (!acquireLock(t2.node.metaPath))
12013
+ continue;
12014
+ try {
12015
+ const currentMeta = await readMetaJson(t2.node.metaPath);
12016
+ const { scopeFiles } = await getScopeFiles(t2.node, this.watcher);
12017
+ const result = await computeInvalidation(currentMeta, scopeFiles, this.config, t2.node);
12018
+ const owedPhase = getOwedPhase(result.phaseState);
12019
+ if (owedPhase) {
12020
+ await persistPhaseState({
12021
+ metaPath: t2.node.metaPath,
12022
+ current: currentMeta,
12023
+ config: this.config,
12024
+ structureHash: result.inputStatus.structureHash,
12025
+ }, result.phaseState, {});
12026
+ this.cache.invalidate();
12027
+ return {
12028
+ path: t2.node.metaPath,
12029
+ phase: owedPhase,
12030
+ band: getPriorityBand(result.phaseState),
12031
+ };
12032
+ }
12033
+ // No invalidation — bump _generatedAt to delay re-checking
12034
+ await persistPhaseState({
12035
+ metaPath: t2.node.metaPath,
12036
+ current: currentMeta,
12037
+ config: this.config,
12038
+ structureHash: result.inputStatus.structureHash,
12039
+ }, result.phaseState, {
12040
+ _generatedAt: new Date().toISOString(),
12041
+ });
12042
+ dirty = true;
12043
+ }
12044
+ finally {
12045
+ releaseLock(t2.node.metaPath);
12046
+ }
12047
+ }
12048
+ if (dirty)
12049
+ this.cache.invalidate();
12050
+ return null;
12051
+ }
11837
12052
  }
11838
12053
 
11839
12054
  /**
@@ -11853,7 +12068,7 @@ function sanitizeConfig(config) {
11853
12068
  }
11854
12069
  function registerConfigRoute(app, deps) {
11855
12070
  const configHandler = createConfigQueryHandler(() => sanitizeConfig(deps.config));
11856
- app.get('/config', async (request, reply) => {
12071
+ app.get(getEndpoint('config').path, async (request, reply) => {
11857
12072
  const { path } = request.query;
11858
12073
  const result = await configHandler({ path });
11859
12074
  return reply.status(result.status).send(result.body);
@@ -11872,7 +12087,7 @@ function registerConfigRoute(app, deps) {
11872
12087
  */
11873
12088
  /** Register the POST /config/apply route. */
11874
12089
  function registerConfigApplyRoute(app, configPath) {
11875
- app.post('/config/apply', async (request, reply) => {
12090
+ app.post(getEndpoint('configApply').path, async (request, reply) => {
11876
12091
  if (!configPath) {
11877
12092
  return reply
11878
12093
  .status(500)
@@ -11961,42 +12176,32 @@ function registerConfigApplyRoute(app, configPath) {
11961
12176
  *
11962
12177
  * @module routes/metas
11963
12178
  */
12179
+ /** Reusable Zod schema for boolean query string parameters ('true'/'false'). */
12180
+ const boolQueryParam = z.enum(['true', 'false']).transform((v) => v === 'true');
11964
12181
  const metasQuerySchema = z.object({
11965
12182
  pathPrefix: z.string().optional(),
11966
- hasError: z
11967
- .enum(['true', 'false'])
11968
- .transform((v) => v === 'true')
11969
- .optional(),
12183
+ hasError: boolQueryParam.optional(),
11970
12184
  staleHours: z
11971
12185
  .string()
11972
12186
  .transform(Number)
11973
12187
  .pipe(z.number().positive())
11974
12188
  .optional(),
11975
- neverSynthesized: z
11976
- .enum(['true', 'false'])
11977
- .transform((v) => v === 'true')
11978
- .optional(),
11979
- locked: z
11980
- .enum(['true', 'false'])
11981
- .transform((v) => v === 'true')
11982
- .optional(),
11983
- disabled: z
11984
- .enum(['true', 'false'])
11985
- .transform((v) => v === 'true')
11986
- .optional(),
12189
+ neverSynthesized: boolQueryParam.optional(),
12190
+ locked: boolQueryParam.optional(),
12191
+ disabled: boolQueryParam.optional(),
11987
12192
  fields: z.string().optional(),
11988
12193
  });
11989
12194
  const metaDetailQuerySchema = z.object({
11990
12195
  fields: z.string().optional(),
11991
12196
  includeArchive: z
11992
12197
  .union([
11993
- z.enum(['true', 'false']).transform((v) => v === 'true'),
12198
+ boolQueryParam,
11994
12199
  z.string().transform(Number).pipe(z.number().int().nonnegative()),
11995
12200
  ])
11996
12201
  .optional(),
11997
12202
  });
11998
12203
  function registerMetasRoutes(app, deps) {
11999
- app.get('/metas', async (request) => {
12204
+ app.get(getEndpoint('listMetas').path, async (request) => {
12000
12205
  const query = metasQuerySchema.parse(request.query);
12001
12206
  const { config, watcher } = deps;
12002
12207
  const result = await listMetas(config, watcher);
@@ -12068,7 +12273,7 @@ function registerMetasRoutes(app, deps) {
12068
12273
  });
12069
12274
  return { summary, metas };
12070
12275
  });
12071
- app.get('/metas/:path', async (request, reply) => {
12276
+ app.get(getEndpoint('metaDetail').path, async (request, reply) => {
12072
12277
  const query = metaDetailQuerySchema.parse(request.query);
12073
12278
  const { config, watcher } = deps;
12074
12279
  const targetPath = normalizePath(decodeURIComponent(request.params.path));
@@ -12162,7 +12367,7 @@ function registerMetasRoutes(app, deps) {
12162
12367
  /**
12163
12368
  * PATCH /metas/:path — update user-settable reserved properties on a meta.
12164
12369
  *
12165
- * Supported fields: _steer, _emphasis, _depth, _crossRefs, _disabled.
12370
+ * Supported fields: _steer, _emphasis, _depth, _crossRefs, _disabled, _architectTimeout, _builderTimeout, _criticTimeout.
12166
12371
  * Set a field to null to remove it. Unknown keys are rejected.
12167
12372
  *
12168
12373
  * @module routes/metasUpdate
@@ -12174,10 +12379,13 @@ const updateBodySchema = z
12174
12379
  _depth: z.union([z.number(), z.null()]).optional(),
12175
12380
  _crossRefs: z.union([z.array(z.string()), z.null()]).optional(),
12176
12381
  _disabled: z.union([z.boolean(), z.null()]).optional(),
12382
+ _architectTimeout: z.union([z.number().int().min(30), z.null()]).optional(),
12383
+ _builderTimeout: z.union([z.number().int().min(30), z.null()]).optional(),
12384
+ _criticTimeout: z.union([z.number().int().min(30), z.null()]).optional(),
12177
12385
  })
12178
12386
  .strict();
12179
12387
  function registerMetasUpdateRoute(app, deps) {
12180
- app.patch('/metas/:path', async (request, reply) => {
12388
+ app.patch(getEndpoint('updateMeta').path, async (request, reply) => {
12181
12389
  const parseResult = updateBodySchema.safeParse(request.body);
12182
12390
  if (!parseResult.success) {
12183
12391
  return reply.status(400).send({
@@ -12199,13 +12407,7 @@ function registerMetasUpdateRoute(app, deps) {
12199
12407
  });
12200
12408
  }
12201
12409
  const metaJsonPath = join(metaDir, 'meta.json');
12202
- const KEYS = [
12203
- '_steer',
12204
- '_emphasis',
12205
- '_depth',
12206
- '_crossRefs',
12207
- '_disabled',
12208
- ];
12410
+ const KEYS = Object.keys(updateBodySchema.shape);
12209
12411
  const toDelete = new Set();
12210
12412
  const toSet = {};
12211
12413
  for (const key of KEYS) {
@@ -12243,7 +12445,7 @@ function registerMetasUpdateRoute(app, deps) {
12243
12445
  * @module routes/preview
12244
12446
  */
12245
12447
  function registerPreviewRoute(app, deps) {
12246
- app.get('/preview', async (request, reply) => {
12448
+ app.get(getEndpoint('preview').path, async (request, reply) => {
12247
12449
  const { config, watcher, cache } = deps;
12248
12450
  const query = request.query;
12249
12451
  let result;
@@ -12281,12 +12483,8 @@ function registerPreviewRoute(app, deps) {
12281
12483
  const { scopeFiles } = await getScopeFiles(targetNode, watcher);
12282
12484
  // Compute invalidation inputs (DRY: reuse phaseState/invalidate logic)
12283
12485
  const invalidation = await computeInvalidation(meta, scopeFiles, config, targetNode);
12284
- const { architectInvalidators, stalenessInputs } = invalidation;
12285
- const { structureHash } = invalidation;
12286
- const structureChanged = structureHash !== meta._structureHash;
12287
- const { steerChanged } = invalidation;
12288
- const { architectChanged, crossRefsDeclChanged } = stalenessInputs;
12289
- const architectTriggered = isArchitectTriggered(meta, structureChanged, steerChanged, config.architectEvery);
12486
+ const { architectInvalidators, inputStatus, phaseState } = invalidation;
12487
+ const architectTriggered = architectInvalidators.length > 0;
12290
12488
  // Delta files
12291
12489
  const deltaFiles = getDeltaFiles(meta._generatedAt, scopeFiles);
12292
12490
  // EMA token estimates
@@ -12300,14 +12498,6 @@ function registerPreviewRoute(app, deps) {
12300
12498
  ? Math.round((Date.now() - new Date(meta._generatedAt).getTime()) / 1000)
12301
12499
  : null;
12302
12500
  const stalenessScore = computeStalenessScore(stalenessSeconds, meta._depth ?? 0, meta._emphasis ?? 1, config.depthWeight);
12303
- // Phase state
12304
- const phaseState = derivePhaseState(meta, {
12305
- structureChanged,
12306
- steerChanged,
12307
- architectChanged,
12308
- crossRefsChanged: crossRefsDeclChanged,
12309
- architectEvery: config.architectEvery,
12310
- });
12311
12501
  const owedPhase = getOwedPhase(phaseState);
12312
12502
  const priorityBand = getPriorityBand(phaseState);
12313
12503
  return {
@@ -12318,10 +12508,17 @@ function registerPreviewRoute(app, deps) {
12318
12508
  },
12319
12509
  architectWillRun: architectTriggered,
12320
12510
  architectReason: [
12321
- ...(!meta._builder ? ['no cached builder (first run)'] : []),
12322
- ...(structureChanged ? ['structure changed'] : []),
12323
- ...(steerChanged ? ['steer changed'] : []),
12324
- ...((meta._synthesisCount ?? 0) >= config.architectEvery
12511
+ ...(architectInvalidators.includes('firstRun')
12512
+ ? ['no cached builder (first run)']
12513
+ : []),
12514
+ ...(architectInvalidators.includes('structureHash')
12515
+ ? ['structure changed']
12516
+ : []),
12517
+ ...(architectInvalidators.includes('steer') ? ['steer changed'] : []),
12518
+ ...(architectInvalidators.includes('_crossRefs')
12519
+ ? ['cross-refs changed']
12520
+ : []),
12521
+ ...(architectInvalidators.includes('architectEvery')
12325
12522
  ? ['periodic refresh']
12326
12523
  : []),
12327
12524
  ].join(', ') || 'not triggered',
@@ -12338,7 +12535,7 @@ function registerPreviewRoute(app, deps) {
12338
12535
  owedPhase,
12339
12536
  priorityBand,
12340
12537
  phaseState,
12341
- stalenessInputs,
12538
+ inputStatus,
12342
12539
  architectInvalidators,
12343
12540
  };
12344
12541
  });
@@ -12356,7 +12553,7 @@ function registerPreviewRoute(app, deps) {
12356
12553
  /** Register queue management routes. */
12357
12554
  function registerQueueRoutes(app, deps) {
12358
12555
  const { queue } = deps;
12359
- app.get('/queue', async () => {
12556
+ app.get(getEndpoint('queue').path, async () => {
12360
12557
  const currentPhase = queue.currentPhase;
12361
12558
  const overrides = queue.overrides;
12362
12559
  // Compute owedPhase for each override entry by reading meta state
@@ -12432,11 +12629,11 @@ function registerQueueRoutes(app, deps) {
12432
12629
  state: queue.getState(),
12433
12630
  };
12434
12631
  });
12435
- app.post('/queue/clear', () => {
12632
+ app.post(getEndpoint('queueClear').path, () => {
12436
12633
  const removed = queue.clearOverrides();
12437
12634
  return { cleared: removed };
12438
12635
  });
12439
- app.post('/synthesize/abort', async (_request, reply) => {
12636
+ app.post(getEndpoint('abort').path, async (_request, reply) => {
12440
12637
  // Check 3-layer current first
12441
12638
  const currentPhase = queue.currentPhase;
12442
12639
  const current = currentPhase ?? queue.current;
@@ -12498,7 +12695,7 @@ const seedBodySchema = z.object({
12498
12695
  steer: z.string().optional(),
12499
12696
  });
12500
12697
  function registerSeedRoute(app, deps) {
12501
- app.post('/seed', async (request, reply) => {
12698
+ app.post(getEndpoint('seed').path, async (request, reply) => {
12502
12699
  const body = seedBodySchema.parse(request.body);
12503
12700
  if (metaExists(body.path)) {
12504
12701
  return reply.status(409).send({
@@ -12635,7 +12832,7 @@ function registerStatusRoute(app, deps) {
12635
12832
  dependencies: {
12636
12833
  watcher: {
12637
12834
  ...watcherHealth,
12638
- rulesRegistered: deps.registrar?.isRegistered ?? false,
12835
+ rulesRegistered: true,
12639
12836
  },
12640
12837
  gateway: gatewayHealth,
12641
12838
  },
@@ -12644,7 +12841,7 @@ function registerStatusRoute(app, deps) {
12644
12841
  };
12645
12842
  },
12646
12843
  });
12647
- app.get('/status', async (_request, reply) => {
12844
+ app.get(getEndpoint('status').path, async (_request, reply) => {
12648
12845
  const result = await statusHandler();
12649
12846
  return reply.status(result.status).send(result.body);
12650
12847
  });
@@ -12663,24 +12860,41 @@ const synthesizeBodySchema = z.object({
12663
12860
  });
12664
12861
  /** Register the POST /synthesize route. */
12665
12862
  function registerSynthesizeRoute(app, deps) {
12666
- app.post('/synthesize', async (request, reply) => {
12863
+ app.post(getEndpoint('synthesize').path, async (request, reply) => {
12667
12864
  const body = synthesizeBodySchema.parse(request.body);
12668
12865
  const { config, watcher, queue, cache } = deps;
12669
12866
  if (body.path) {
12670
12867
  // Path-targeted trigger: create override entry
12671
12868
  const targetPath = resolveMetaDir(body.path);
12672
- // Read meta to determine owed phase
12869
+ // Read meta and recompute invalidation against current inputs
12870
+ // (structure hash, steer, cross-refs, prompt snapshots) rather than
12871
+ // trusting the cached _phaseState. Fixes #160.
12673
12872
  let owedPhase = null;
12674
12873
  let meta;
12675
12874
  try {
12676
12875
  meta = await readMetaJson(targetPath);
12677
- const phaseState = derivePhaseState(meta);
12678
- owedPhase = getOwedPhase(phaseState);
12876
+ const node = await buildMinimalNode(normalizePath(targetPath), watcher);
12877
+ const { scopeFiles } = await getScopeFiles(node, watcher);
12878
+ const invalidation = await computeInvalidation(meta, scopeFiles, config, node);
12879
+ owedPhase = getOwedPhase(invalidation.phaseState);
12880
+ // Persist recomputed phase state + structure hash when stale.
12881
+ // Matches the scheduler's Tier 2 pattern: always persist so the
12882
+ // stored _phaseState reflects reality for subsequent reads.
12883
+ if (owedPhase) {
12884
+ await persistPhaseState({
12885
+ metaPath: targetPath,
12886
+ current: meta,
12887
+ config,
12888
+ structureHash: invalidation.inputStatus.structureHash,
12889
+ }, invalidation.phaseState, {});
12890
+ cache.invalidate();
12891
+ }
12679
12892
  }
12680
12893
  catch {
12681
- // Meta unreadable — proceed, phase will be evaluated at dequeue time
12894
+ // Meta unreadable or watcher unavailable — proceed,
12895
+ // phase will be evaluated at dequeue time
12682
12896
  }
12683
- // Fully fresh meta → skip (reuse meta already read above)
12897
+ // Fully fresh meta → skip
12684
12898
  if (owedPhase === null && meta && (meta._phaseState || meta._content)) {
12685
12899
  return await reply.code(200).send({
12686
12900
  status: 'skipped',
@@ -12738,7 +12952,7 @@ const unlockBodySchema = z.object({
12738
12952
  path: z.string().min(1),
12739
12953
  });
12740
12954
  function registerUnlockRoute(app, deps) {
12741
- app.post('/unlock', (request, reply) => {
12955
+ app.post(getEndpoint('unlock').path, (request, reply) => {
12742
12956
  const body = unlockBodySchema.parse(request.body);
12743
12957
  const metaDir = resolveMetaDir(body.path);
12744
12958
  const lockPath = join(metaDir, '.lock');
@@ -13135,9 +13349,7 @@ async function startService(config, configPath) {
13135
13349
  routeDeps.registrar = registrar;
13136
13350
  void registrar.register().then(() => {
13137
13351
  routeDeps.ready = true;
13138
- if (registrar.isRegistered) {
13139
- void verifyRuleApplication(watcher, logger);
13140
- }
13352
+ void verifyRuleApplication(watcher, logger);
13141
13353
  }, () => {
13142
13354
  // Registration failed after max retries — mark ready anyway
13143
13355
  routeDeps.ready = true;
@@ -13330,6 +13542,17 @@ const metaJsonSchema = z
13330
13542
  _error: metaErrorSchema.optional(),
13331
13543
  /** When true, this meta is skipped during staleness scheduling. Manual trigger still works. */
13332
13544
  _disabled: z.boolean().optional(),
13545
+ /** Per-entity timeout override for the architect phase (seconds, min 30). */
13546
+ _architectTimeout: z.number().int().min(30).optional(),
13547
+ /** Per-entity timeout override for the builder phase (seconds, min 30). */
13548
+ _builderTimeout: z.number().int().min(30).optional(),
13549
+ /** Per-entity timeout override for the critic phase (seconds, min 30). */
13550
+ _criticTimeout: z.number().int().min(30).optional(),
13551
+ /**
13552
+ * SHA-256 hash of ancestor _builder text at last synthesis.
13553
+ * Observability only — no invalidation cascade.
13554
+ */
13555
+ _ancestorBuilderHash: z.string().optional(),
13333
13556
  /**
13334
13557
  * Per-phase state machine record. Engine-managed.
13335
13558
  * Keyed by phase name (architect, builder, critic) with status values.