@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.
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({
@@ -9324,7 +9478,7 @@ class GatewayExecutor {
9324
9478
  'Write your complete output to a file using the Write tool at:\n' +
9325
9479
  outputPath +
9326
9480
  '\n\n' +
9327
- 'After writing the file, reply with ONLY: NO_REPLY';
9481
+ 'After writing the file, your final message must be exactly: ANNOUNCE_SKIP';
9328
9482
  // Step 1: Spawn the sub-agent session (unique label per cycle to avoid
9329
9483
  // "label already in use" errors — gateway labels persist after session completion)
9330
9484
  const labelBase = options?.label ?? 'jeeves-meta-synthesis';
@@ -9392,7 +9546,9 @@ class GatewayExecutor {
9392
9546
  }
9393
9547
  }
9394
9548
  }
9395
- // 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).
9396
9552
  for (let i = msgArray.length - 1; i >= 0; i--) {
9397
9553
  const msg = msgArray[i];
9398
9554
  if (msg.role === 'assistant' && msg.content) {
@@ -9404,7 +9560,7 @@ class GatewayExecutor {
9404
9560
  .map((b) => b.text)
9405
9561
  .join('\n')
9406
9562
  : '';
9407
- if (text)
9563
+ if (text && text.trim() !== 'ANNOUNCE_SKIP')
9408
9564
  return { output: text, tokens };
9409
9565
  }
9410
9566
  }
@@ -9434,11 +9590,16 @@ class GatewayExecutor {
9434
9590
  function createLogger(config) {
9435
9591
  const level = config?.level ?? 'info';
9436
9592
  if (config?.file) {
9437
- const transport = pino.transport({
9438
- target: 'pino/file',
9439
- options: { destination: config.file, mkdir: true },
9593
+ const fileStream = pino.destination({
9594
+ dest: config.file,
9595
+ sync: false,
9596
+ mkdir: true,
9440
9597
  });
9441
- return pino({ level }, transport);
9598
+ const multistream = pino.multistream([
9599
+ { stream: process.stdout },
9600
+ { stream: fileStream },
9601
+ ]);
9602
+ return pino({ level }, multistream);
9442
9603
  }
9443
9604
  return pino({ level });
9444
9605
  }
@@ -9560,6 +9721,21 @@ async function buildContextPackage(node, meta, watcher, logger) {
9560
9721
  }
9561
9722
  // Archive paths
9562
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
+ }
9563
9739
  return {
9564
9740
  path: node.metaPath,
9565
9741
  scopeFiles,
@@ -9571,6 +9747,7 @@ async function buildContextPackage(node, meta, watcher, logger) {
9571
9747
  steer: meta._steer ?? null,
9572
9748
  previousState: meta._state ?? null,
9573
9749
  archives,
9750
+ ancestorBuilder,
9574
9751
  };
9575
9752
  }
9576
9753
 
@@ -9618,6 +9795,12 @@ function appendMetaSections(sections, heading, metas) {
9618
9795
  sections.push(`### ${path}`, typeof content === 'string' ? content : '(not yet synthesized)');
9619
9796
  }
9620
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
+ }
9621
9804
  /** Append optional context sections shared across all step prompts. */
9622
9805
  function appendSharedSections(sections, ctx, options) {
9623
9806
  const opts = {
@@ -9657,7 +9840,7 @@ function buildArchitectTask(ctx, meta, config) {
9657
9840
  const sections = [
9658
9841
  `# jeeves-meta · ARCHITECT · ${ctx.path}`,
9659
9842
  '',
9660
- meta._architect ?? config.defaultArchitect ?? DEFAULT_ARCHITECT_PROMPT,
9843
+ config.defaultArchitect ?? DEFAULT_ARCHITECT_PROMPT,
9661
9844
  '',
9662
9845
  '## SCOPE',
9663
9846
  `Path: ${ctx.path}`,
@@ -9667,6 +9850,7 @@ function buildArchitectTask(ctx, meta, config) {
9667
9850
  '### File listing (scope)',
9668
9851
  condenseScopeFiles(ctx.scopeFiles),
9669
9852
  ];
9853
+ appendAncestorContext(sections, ctx);
9670
9854
  // Inject previous _builder so architect can see its own prior output
9671
9855
  if (meta._builder) {
9672
9856
  sections.push('', '## PREVIOUS TASK BRIEF', meta._builder);
@@ -9697,6 +9881,7 @@ function buildBuilderTask(ctx, meta, config) {
9697
9881
  `Delta files (${ctx.deltaFiles.length.toString()} changed):`,
9698
9882
  ...ctx.deltaFiles.slice(0, config.maxLines).map((f) => `- ${f}`),
9699
9883
  ];
9884
+ appendAncestorContext(sections, ctx);
9700
9885
  if (ctx.previousState != null) {
9701
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), '```');
9702
9887
  }
@@ -9719,7 +9904,7 @@ function buildCriticTask(ctx, meta, config) {
9719
9904
  const sections = [
9720
9905
  `# jeeves-meta · CRITIC · ${ctx.path}`,
9721
9906
  '',
9722
- meta._critic ?? config.defaultCritic ?? DEFAULT_CRITIC_PROMPT,
9907
+ config.defaultCritic ?? DEFAULT_CRITIC_PROMPT,
9723
9908
  '',
9724
9909
  '## SYNTHESIS TO EVALUATE',
9725
9910
  meta._content ?? '(No content produced)',
@@ -9853,7 +10038,7 @@ function enforceInvariant(state) {
9853
10038
  // ── Invalidation cascades ──────────────────────────────────────────────
9854
10039
  /**
9855
10040
  * Architect invalidated: architect → pending; builder, critic → stale.
9856
- * Triggers: _structureHash change, _steer change, _architect change,
10041
+ * Triggers: first run, _structureHash change, _steer change,
9857
10042
  * _crossRefs declaration change, _synthesisCount \>= architectEvery.
9858
10043
  */
9859
10044
  function invalidateArchitect(state) {
@@ -10051,31 +10236,6 @@ function derivePhaseState(meta, inputs) {
10051
10236
  if (!meta._content && !meta._builder) {
10052
10237
  return initialPhaseState();
10053
10238
  }
10054
- // Check architect invalidation (when inputs are provided)
10055
- if (inputs) {
10056
- // Progressive metas: structure changes invalidate builder, not architect
10057
- const structureInvalidatesArchitect = inputs.structureChanged && meta._state === undefined;
10058
- const architectInvalidated = structureInvalidatesArchitect ||
10059
- inputs.steerChanged ||
10060
- inputs.architectChanged ||
10061
- inputs.crossRefsChanged ||
10062
- (meta._synthesisCount ?? 0) >= inputs.architectEvery;
10063
- if (architectInvalidated) {
10064
- return {
10065
- architect: 'pending',
10066
- builder: 'stale',
10067
- critic: 'stale',
10068
- };
10069
- }
10070
- // Progressive meta with structure change: builder-only invalidation
10071
- if (inputs.structureChanged && meta._state !== undefined) {
10072
- return {
10073
- architect: 'fresh',
10074
- builder: 'pending',
10075
- critic: 'stale',
10076
- };
10077
- }
10078
- }
10079
10239
  // Has _builder but no _content: builder is pending
10080
10240
  if (meta._builder && !meta._content) {
10081
10241
  return {
@@ -10124,6 +10284,14 @@ function computeStructureHash(filePaths) {
10124
10284
  *
10125
10285
  * @module phaseState/invalidate
10126
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
+ }
10127
10295
  /**
10128
10296
  * Compute invalidation inputs and apply cascade for a single meta.
10129
10297
  *
@@ -10146,10 +10314,16 @@ async function computeInvalidation(meta, scopeFiles, config, node, crossRefMetas
10146
10314
  const structureChanged = structureHash !== meta._structureHash;
10147
10315
  const latestArchive = await readLatestArchive(node.metaPath);
10148
10316
  const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
10149
- // _architect change: compare current vs. archive
10150
- const architectChanged = latestArchive
10151
- ? (meta._architect ?? '') !== (latestArchive._architect ?? '')
10152
- : 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;
10153
10327
  // _crossRefs declaration change
10154
10328
  const currentRefs = (meta._crossRefs ?? []).slice().sort().join(',');
10155
10329
  const archiveRefs = (latestArchive?._crossRefs ?? [])
@@ -10171,38 +10345,30 @@ async function computeInvalidation(meta, scopeFiles, config, node, crossRefMetas
10171
10345
  }
10172
10346
  if (steerChanged)
10173
10347
  architectInvalidators.push('steer');
10174
- if (architectChanged)
10175
- architectInvalidators.push('_architect');
10176
10348
  if (crossRefsDeclChanged)
10177
10349
  architectInvalidators.push('_crossRefs');
10178
- if ((meta._synthesisCount ?? 0) >= config.architectEvery) {
10350
+ if (effectiveSynthesisCount >= config.architectEvery) {
10179
10351
  architectInvalidators.push('architectEvery');
10180
10352
  }
10181
- // First-run check: no _builder means architect must run
10182
- const firstRun = !meta._builder;
10183
- if (architectInvalidators.length > 0 || firstRun) {
10353
+ if (!meta._builder)
10354
+ architectInvalidators.push('firstRun');
10355
+ if (architectInvalidators.length > 0) {
10184
10356
  phaseState = invalidateArchitect(phaseState);
10185
10357
  }
10186
10358
  // ── Builder-level inputs ──
10187
- // Scope file mtime check — if any file newer than _generatedAt
10188
- const scopeMtimeMax = null;
10189
- // Note: actual mtime check is done by the caller or via isStale;
10190
- // here we just detect cross-ref content changes for the cascade.
10191
10359
  // Cross-ref _content change (builder-invalidating)
10192
10360
  let crossRefContentChanged = false;
10193
10361
  return {
10194
10362
  phaseState,
10195
10363
  architectInvalidators,
10196
- stalenessInputs: {
10364
+ inputStatus: {
10197
10365
  structureHash,
10198
10366
  steerChanged,
10199
10367
  architectChanged,
10368
+ criticChanged,
10200
10369
  crossRefsDeclChanged,
10201
- scopeMtimeMax,
10202
10370
  crossRefContentChanged,
10203
10371
  },
10204
- structureHash,
10205
- steerChanged,
10206
10372
  };
10207
10373
  }
10208
10374
 
@@ -10382,6 +10548,15 @@ function toMetaError(step, err, code = 'FAILED') {
10382
10548
  *
10383
10549
  * @module orchestrator/parseOutput
10384
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
+ }
10385
10560
  /**
10386
10561
  * Parse architect output. The architect returns a task brief as text.
10387
10562
  *
@@ -10389,7 +10564,7 @@ function toMetaError(step, err, code = 'FAILED') {
10389
10564
  * @returns The task brief string.
10390
10565
  */
10391
10566
  function parseArchitectOutput(output) {
10392
- return output.trim();
10567
+ return stripSentinel(output);
10393
10568
  }
10394
10569
  /**
10395
10570
  * Parse builder output. The builder returns JSON with _content and optional fields.
@@ -10400,7 +10575,7 @@ function parseArchitectOutput(output) {
10400
10575
  * @returns Parsed builder output with content and structured fields.
10401
10576
  */
10402
10577
  function parseBuilderOutput(output) {
10403
- const trimmed = output.trim();
10578
+ const trimmed = stripSentinel(output);
10404
10579
  // Strategy 1: Try to parse the entire output as JSON directly
10405
10580
  const direct = tryParseJson(trimmed);
10406
10581
  if (direct)
@@ -10467,7 +10642,7 @@ function tryParseJson(str) {
10467
10642
  * @returns The feedback string.
10468
10643
  */
10469
10644
  function parseCriticOutput(output) {
10470
- return output.trim();
10645
+ return stripSentinel(output);
10471
10646
  }
10472
10647
 
10473
10648
  /**
@@ -10478,6 +10653,12 @@ function parseCriticOutput(output) {
10478
10653
  *
10479
10654
  * @module orchestrator/runPhase
10480
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
+ }
10481
10662
  /** Write updated meta with phase state via lock staging. */
10482
10663
  async function persistPhaseState(base, phaseState, updates) {
10483
10664
  const lockPath = join(base.metaPath, '.lock');
@@ -10521,6 +10702,12 @@ async function handlePhaseFailure(phase, err, executor, ps, base, additionalUpda
10521
10702
  // ── Architect executor ─────────────────────────────────────────────────
10522
10703
  async function runArchitect(node, currentMeta, phaseState, config, executor, watcher, structureHash, onProgress, logger) {
10523
10704
  let ps = phaseRunning(phaseState, 'architect');
10705
+ const base = {
10706
+ metaPath: node.metaPath,
10707
+ current: currentMeta,
10708
+ config,
10709
+ structureHash,
10710
+ };
10524
10711
  const ctx = await buildContextPackage(node, currentMeta, watcher, logger);
10525
10712
  try {
10526
10713
  await onProgress?.({
@@ -10532,21 +10719,25 @@ async function runArchitect(node, currentMeta, phaseState, config, executor, wat
10532
10719
  const architectTask = buildArchitectTask(ctx, currentMeta, config);
10533
10720
  const result = await executor.spawn(architectTask, {
10534
10721
  thinking: config.thinking,
10535
- timeout: config.architectTimeout,
10722
+ timeout: currentMeta._architectTimeout ?? config.architectTimeout,
10536
10723
  label: 'meta-architect',
10537
10724
  });
10538
10725
  const builderBrief = parseArchitectOutput(result.output);
10539
10726
  const architectTokens = result.tokens;
10540
10727
  // Architect success: architect → fresh, _synthesisCount → 0
10541
10728
  ps = architectSuccess(ps);
10542
- const updatedMeta = await persistPhaseState({ metaPath: node.metaPath, current: currentMeta, config, structureHash }, ps, {
10729
+ const architectUpdates = {
10543
10730
  _builder: builderBrief,
10544
- _architect: currentMeta._architect ?? config.defaultArchitect ?? '',
10731
+ _architect: config.defaultArchitect ?? DEFAULT_ARCHITECT_PROMPT,
10545
10732
  _synthesisCount: 0,
10546
10733
  _architectTokens: architectTokens,
10547
10734
  _generatedAt: new Date().toISOString(),
10548
10735
  _error: undefined,
10549
- });
10736
+ };
10737
+ const ancestorHash = hashAncestorBuilder(ctx.ancestorBuilder);
10738
+ if (ancestorHash)
10739
+ architectUpdates._ancestorBuilderHash = ancestorHash;
10740
+ const updatedMeta = await persistPhaseState(base, ps, architectUpdates);
10550
10741
  await onProgress?.({
10551
10742
  type: 'phase_complete',
10552
10743
  path: node.ownerPath,
@@ -10557,16 +10748,18 @@ async function runArchitect(node, currentMeta, phaseState, config, executor, wat
10557
10748
  return { executed: true, phaseState: ps, updatedMeta };
10558
10749
  }
10559
10750
  catch (err) {
10560
- return handlePhaseFailure('architect', err, executor, ps, {
10561
- metaPath: node.metaPath,
10562
- current: currentMeta,
10563
- structureHash,
10564
- });
10751
+ return handlePhaseFailure('architect', err, executor, ps, base);
10565
10752
  }
10566
10753
  }
10567
10754
  // ── Builder executor ───────────────────────────────────────────────────
10568
10755
  async function runBuilder(node, currentMeta, phaseState, config, executor, watcher, structureHash, onProgress, logger) {
10569
10756
  let ps = phaseRunning(phaseState, 'builder');
10757
+ const base = {
10758
+ metaPath: node.metaPath,
10759
+ current: currentMeta,
10760
+ config,
10761
+ structureHash,
10762
+ };
10570
10763
  const ctx = await buildContextPackage(node, currentMeta, watcher, logger);
10571
10764
  try {
10572
10765
  await onProgress?.({
@@ -10578,21 +10771,25 @@ async function runBuilder(node, currentMeta, phaseState, config, executor, watch
10578
10771
  const builderTask = buildBuilderTask(ctx, currentMeta, config);
10579
10772
  const result = await executor.spawn(builderTask, {
10580
10773
  thinking: config.thinking,
10581
- timeout: config.builderTimeout,
10774
+ timeout: currentMeta._builderTimeout ?? config.builderTimeout,
10582
10775
  label: 'meta-builder',
10583
10776
  });
10584
10777
  const builderOutput = parseBuilderOutput(result.output);
10585
10778
  const builderTokens = result.tokens;
10586
10779
  // Builder success: builder → fresh, critic → pending
10587
10780
  ps = builderSuccess(ps);
10588
- const updatedMeta = await persistPhaseState({ metaPath: node.metaPath, current: currentMeta, config, structureHash }, ps, {
10781
+ const builderUpdates = {
10589
10782
  _content: builderOutput.content,
10590
10783
  _state: builderOutput.state,
10591
10784
  _builderTokens: builderTokens,
10592
10785
  _generatedAt: new Date().toISOString(),
10593
10786
  _error: undefined,
10594
10787
  ...builderOutput.fields,
10595
- });
10788
+ };
10789
+ const ancestorHash = hashAncestorBuilder(ctx.ancestorBuilder);
10790
+ if (ancestorHash)
10791
+ builderUpdates._ancestorBuilderHash = ancestorHash;
10792
+ const updatedMeta = await persistPhaseState(base, ps, builderUpdates);
10596
10793
  await onProgress?.({
10597
10794
  type: 'phase_complete',
10598
10795
  path: node.ownerPath,
@@ -10618,16 +10815,18 @@ async function runBuilder(node, currentMeta, phaseState, config, executor, watch
10618
10815
  // Could not read partial output — no state recovery
10619
10816
  }
10620
10817
  }
10621
- return handlePhaseFailure('builder', err, executor, ps, {
10622
- metaPath: node.metaPath,
10623
- current: currentMeta,
10624
- structureHash,
10625
- }, partialState);
10818
+ return handlePhaseFailure('builder', err, executor, ps, base, partialState);
10626
10819
  }
10627
10820
  }
10628
10821
  // ── Critic executor ────────────────────────────────────────────────────
10629
10822
  async function runCritic(node, currentMeta, phaseState, config, executor, watcher, structureHash, onProgress, logger) {
10630
10823
  let ps = phaseRunning(phaseState, 'critic');
10824
+ const base = {
10825
+ metaPath: node.metaPath,
10826
+ current: currentMeta,
10827
+ config,
10828
+ structureHash,
10829
+ };
10631
10830
  const ctx = await buildContextPackage(node, currentMeta, watcher, logger);
10632
10831
  // Build critic task using current meta's _content
10633
10832
  const metaForCritic = { ...currentMeta };
@@ -10641,7 +10840,7 @@ async function runCritic(node, currentMeta, phaseState, config, executor, watche
10641
10840
  const criticTask = buildCriticTask(ctx, metaForCritic, config);
10642
10841
  const result = await executor.spawn(criticTask, {
10643
10842
  thinking: config.thinking,
10644
- timeout: config.criticTimeout,
10843
+ timeout: currentMeta._criticTimeout ?? config.criticTimeout,
10645
10844
  label: 'meta-critic',
10646
10845
  });
10647
10846
  const feedback = parseCriticOutput(result.output);
@@ -10651,6 +10850,7 @@ async function runCritic(node, currentMeta, phaseState, config, executor, watche
10651
10850
  const cycleComplete = isFullyFresh(ps);
10652
10851
  const updates = {
10653
10852
  _feedback: feedback,
10853
+ _critic: config.defaultCritic ?? DEFAULT_CRITIC_PROMPT,
10654
10854
  _criticTokens: criticTokens,
10655
10855
  _error: undefined,
10656
10856
  };
@@ -10659,7 +10859,7 @@ async function runCritic(node, currentMeta, phaseState, config, executor, watche
10659
10859
  if (cycleComplete) {
10660
10860
  updates._synthesisCount = (currentMeta._synthesisCount ?? 0) + 1;
10661
10861
  }
10662
- const updatedMeta = await persistPhaseState({ metaPath: node.metaPath, current: currentMeta, config, structureHash }, ps, updates);
10862
+ const updatedMeta = await persistPhaseState(base, ps, updates);
10663
10863
  // Archive on full-cycle only
10664
10864
  if (cycleComplete) {
10665
10865
  await createSnapshot(node.metaPath, updatedMeta);
@@ -10680,11 +10880,7 @@ async function runCritic(node, currentMeta, phaseState, config, executor, watche
10680
10880
  };
10681
10881
  }
10682
10882
  catch (err) {
10683
- return handlePhaseFailure('critic', err, executor, ps, {
10684
- metaPath: node.metaPath,
10685
- current: currentMeta,
10686
- structureHash,
10687
- });
10883
+ return handlePhaseFailure('critic', err, executor, ps, base);
10688
10884
  }
10689
10885
  }
10690
10886
 
@@ -11261,12 +11457,6 @@ class WatcherHealthCheck {
11261
11457
  return;
11262
11458
  }
11263
11459
  const data = (await res.json());
11264
- // If rules were never successfully registered (startup failure),
11265
- // attempt registration now that the watcher is reachable.
11266
- if (!this.registrar.isRegistered) {
11267
- this.logger.info('Rules not registered — attempting registration');
11268
- await this.registrar.register();
11269
- }
11270
11460
  await this.registrar.checkAndReregister(data.uptime);
11271
11461
  }
11272
11462
  catch (err) {
@@ -11419,36 +11609,39 @@ class RuleRegistrar {
11419
11609
  logger;
11420
11610
  watcherClient;
11421
11611
  lastWatcherUptime = null;
11422
- registered = false;
11612
+ registering = false;
11423
11613
  constructor(config, logger, watcher) {
11424
11614
  this.config = config;
11425
11615
  this.logger = logger;
11426
11616
  this.watcherClient = watcher;
11427
11617
  }
11428
- /** Whether rules have been successfully registered. */
11429
- get isRegistered() {
11430
- return this.registered;
11431
- }
11432
11618
  /**
11433
11619
  * Register rules with watcher. Retries with exponential backoff.
11434
11620
  * Non-blocking — logs errors but never throws.
11435
11621
  */
11436
11622
  async register() {
11437
- const rules = buildMetaRules(this.config);
11438
- for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
11439
- try {
11440
- await this.watcherClient.registerRules(SOURCE, rules);
11441
- this.registered = true;
11442
- this.logger.info('Virtual rules registered with watcher');
11443
- return;
11444
- }
11445
- catch (err) {
11446
- const delayMs = RETRY_BASE_MS * Math.pow(2, attempt);
11447
- this.logger.warn({ attempt: attempt + 1, delayMs, err }, 'Rule registration failed, retrying');
11448
- 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
+ }
11449
11639
  }
11640
+ this.logger.error('Rule registration failed after max retries — service degraded');
11641
+ }
11642
+ finally {
11643
+ this.registering = false;
11450
11644
  }
11451
- this.logger.error('Rule registration failed after max retries — service degraded');
11452
11645
  }
11453
11646
  /**
11454
11647
  * Check watcher uptime and re-register if it decreased (restart detected).
@@ -11459,7 +11652,6 @@ class RuleRegistrar {
11459
11652
  if (this.lastWatcherUptime !== null &&
11460
11653
  currentUptime < this.lastWatcherUptime) {
11461
11654
  this.logger.info({ previous: this.lastWatcherUptime, current: currentUptime }, 'Watcher restart detected — re-registering rules');
11462
- this.registered = false;
11463
11655
  await this.register();
11464
11656
  }
11465
11657
  this.lastWatcherUptime = currentUptime;
@@ -11529,6 +11721,12 @@ async function createMeta(ownerPath, options) {
11529
11721
  metaJson._crossRefs = options.crossRefs;
11530
11722
  if (options?.steer !== undefined)
11531
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;
11532
11730
  const metaJsonPath = join(metaDir, 'meta.json');
11533
11731
  await writeFile(metaJsonPath, JSON.stringify(metaJson, null, 2) + '\n');
11534
11732
  return { metaDir, _id };
@@ -11557,15 +11755,24 @@ function metaExists(ownerPath) {
11557
11755
  /**
11558
11756
  * Extract parent directory paths from watcher walk results.
11559
11757
  *
11560
- * Walk returns file paths; we need the unique set of immediate parent
11561
- * 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.
11562
11762
  */
11563
- function extractDirectories(filePaths, logger) {
11763
+ function extractDirectories(filePaths, parentDepth = 0, logger) {
11564
11764
  const dirs = new Set();
11565
11765
  for (const fp of filePaths) {
11566
11766
  // Normalize backslash paths (Windows) to forward slashes before posix.dirname
11567
11767
  const normalized = normalizePath(fp);
11568
- 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
+ }
11569
11776
  if (dir !== '.' && dir !== '/') {
11570
11777
  dirs.add(dir);
11571
11778
  }
@@ -11590,11 +11797,14 @@ async function autoSeedPass(rules, watcher, logger) {
11590
11797
  const candidates = new Map();
11591
11798
  for (const rule of rules) {
11592
11799
  const files = await watcher.walk([rule.match]);
11593
- const dirs = extractDirectories(files, logger);
11800
+ const dirs = extractDirectories(files, rule.parentDepth, logger);
11594
11801
  for (const dir of dirs) {
11595
11802
  candidates.set(dir, {
11596
11803
  steer: rule.steer,
11597
11804
  crossRefs: rule.crossRefs,
11805
+ architectTimeout: rule.architectTimeout,
11806
+ builderTimeout: rule.builderTimeout,
11807
+ criticTimeout: rule.criticTimeout,
11598
11808
  });
11599
11809
  }
11600
11810
  }
@@ -11609,10 +11819,7 @@ async function autoSeedPass(rules, watcher, logger) {
11609
11819
  const seededPaths = [];
11610
11820
  for (const candidate of toSeed) {
11611
11821
  try {
11612
- await createMeta(candidate.path, {
11613
- steer: candidate.steer,
11614
- crossRefs: candidate.crossRefs,
11615
- });
11822
+ await createMeta(candidate.path, candidate);
11616
11823
  seededPaths.push(candidate.path);
11617
11824
  logger?.info({ path: candidate.path }, 'auto-seeded meta');
11618
11825
  }
@@ -11814,7 +12021,7 @@ class Scheduler {
11814
12021
  metaPath: t2.node.metaPath,
11815
12022
  current: currentMeta,
11816
12023
  config: this.config,
11817
- structureHash: result.structureHash,
12024
+ structureHash: result.inputStatus.structureHash,
11818
12025
  }, result.phaseState, {});
11819
12026
  this.cache.invalidate();
11820
12027
  return {
@@ -11828,8 +12035,10 @@ class Scheduler {
11828
12035
  metaPath: t2.node.metaPath,
11829
12036
  current: currentMeta,
11830
12037
  config: this.config,
11831
- structureHash: result.structureHash,
11832
- }, result.phaseState, { _generatedAt: new Date().toISOString() });
12038
+ structureHash: result.inputStatus.structureHash,
12039
+ }, result.phaseState, {
12040
+ _generatedAt: new Date().toISOString(),
12041
+ });
11833
12042
  dirty = true;
11834
12043
  }
11835
12044
  finally {
@@ -11859,7 +12068,7 @@ function sanitizeConfig(config) {
11859
12068
  }
11860
12069
  function registerConfigRoute(app, deps) {
11861
12070
  const configHandler = createConfigQueryHandler(() => sanitizeConfig(deps.config));
11862
- app.get('/config', async (request, reply) => {
12071
+ app.get(getEndpoint('config').path, async (request, reply) => {
11863
12072
  const { path } = request.query;
11864
12073
  const result = await configHandler({ path });
11865
12074
  return reply.status(result.status).send(result.body);
@@ -11878,7 +12087,7 @@ function registerConfigRoute(app, deps) {
11878
12087
  */
11879
12088
  /** Register the POST /config/apply route. */
11880
12089
  function registerConfigApplyRoute(app, configPath) {
11881
- app.post('/config/apply', async (request, reply) => {
12090
+ app.post(getEndpoint('configApply').path, async (request, reply) => {
11882
12091
  if (!configPath) {
11883
12092
  return reply
11884
12093
  .status(500)
@@ -11967,42 +12176,32 @@ function registerConfigApplyRoute(app, configPath) {
11967
12176
  *
11968
12177
  * @module routes/metas
11969
12178
  */
12179
+ /** Reusable Zod schema for boolean query string parameters ('true'/'false'). */
12180
+ const boolQueryParam = z.enum(['true', 'false']).transform((v) => v === 'true');
11970
12181
  const metasQuerySchema = z.object({
11971
12182
  pathPrefix: z.string().optional(),
11972
- hasError: z
11973
- .enum(['true', 'false'])
11974
- .transform((v) => v === 'true')
11975
- .optional(),
12183
+ hasError: boolQueryParam.optional(),
11976
12184
  staleHours: z
11977
12185
  .string()
11978
12186
  .transform(Number)
11979
12187
  .pipe(z.number().positive())
11980
12188
  .optional(),
11981
- neverSynthesized: z
11982
- .enum(['true', 'false'])
11983
- .transform((v) => v === 'true')
11984
- .optional(),
11985
- locked: z
11986
- .enum(['true', 'false'])
11987
- .transform((v) => v === 'true')
11988
- .optional(),
11989
- disabled: z
11990
- .enum(['true', 'false'])
11991
- .transform((v) => v === 'true')
11992
- .optional(),
12189
+ neverSynthesized: boolQueryParam.optional(),
12190
+ locked: boolQueryParam.optional(),
12191
+ disabled: boolQueryParam.optional(),
11993
12192
  fields: z.string().optional(),
11994
12193
  });
11995
12194
  const metaDetailQuerySchema = z.object({
11996
12195
  fields: z.string().optional(),
11997
12196
  includeArchive: z
11998
12197
  .union([
11999
- z.enum(['true', 'false']).transform((v) => v === 'true'),
12198
+ boolQueryParam,
12000
12199
  z.string().transform(Number).pipe(z.number().int().nonnegative()),
12001
12200
  ])
12002
12201
  .optional(),
12003
12202
  });
12004
12203
  function registerMetasRoutes(app, deps) {
12005
- app.get('/metas', async (request) => {
12204
+ app.get(getEndpoint('listMetas').path, async (request) => {
12006
12205
  const query = metasQuerySchema.parse(request.query);
12007
12206
  const { config, watcher } = deps;
12008
12207
  const result = await listMetas(config, watcher);
@@ -12074,7 +12273,7 @@ function registerMetasRoutes(app, deps) {
12074
12273
  });
12075
12274
  return { summary, metas };
12076
12275
  });
12077
- app.get('/metas/:path', async (request, reply) => {
12276
+ app.get(getEndpoint('metaDetail').path, async (request, reply) => {
12078
12277
  const query = metaDetailQuerySchema.parse(request.query);
12079
12278
  const { config, watcher } = deps;
12080
12279
  const targetPath = normalizePath(decodeURIComponent(request.params.path));
@@ -12168,7 +12367,7 @@ function registerMetasRoutes(app, deps) {
12168
12367
  /**
12169
12368
  * PATCH /metas/:path — update user-settable reserved properties on a meta.
12170
12369
  *
12171
- * Supported fields: _steer, _emphasis, _depth, _crossRefs, _disabled.
12370
+ * Supported fields: _steer, _emphasis, _depth, _crossRefs, _disabled, _architectTimeout, _builderTimeout, _criticTimeout.
12172
12371
  * Set a field to null to remove it. Unknown keys are rejected.
12173
12372
  *
12174
12373
  * @module routes/metasUpdate
@@ -12180,10 +12379,13 @@ const updateBodySchema = z
12180
12379
  _depth: z.union([z.number(), z.null()]).optional(),
12181
12380
  _crossRefs: z.union([z.array(z.string()), z.null()]).optional(),
12182
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(),
12183
12385
  })
12184
12386
  .strict();
12185
12387
  function registerMetasUpdateRoute(app, deps) {
12186
- app.patch('/metas/:path', async (request, reply) => {
12388
+ app.patch(getEndpoint('updateMeta').path, async (request, reply) => {
12187
12389
  const parseResult = updateBodySchema.safeParse(request.body);
12188
12390
  if (!parseResult.success) {
12189
12391
  return reply.status(400).send({
@@ -12205,13 +12407,7 @@ function registerMetasUpdateRoute(app, deps) {
12205
12407
  });
12206
12408
  }
12207
12409
  const metaJsonPath = join(metaDir, 'meta.json');
12208
- const KEYS = [
12209
- '_steer',
12210
- '_emphasis',
12211
- '_depth',
12212
- '_crossRefs',
12213
- '_disabled',
12214
- ];
12410
+ const KEYS = Object.keys(updateBodySchema.shape);
12215
12411
  const toDelete = new Set();
12216
12412
  const toSet = {};
12217
12413
  for (const key of KEYS) {
@@ -12249,7 +12445,7 @@ function registerMetasUpdateRoute(app, deps) {
12249
12445
  * @module routes/preview
12250
12446
  */
12251
12447
  function registerPreviewRoute(app, deps) {
12252
- app.get('/preview', async (request, reply) => {
12448
+ app.get(getEndpoint('preview').path, async (request, reply) => {
12253
12449
  const { config, watcher, cache } = deps;
12254
12450
  const query = request.query;
12255
12451
  let result;
@@ -12287,12 +12483,8 @@ function registerPreviewRoute(app, deps) {
12287
12483
  const { scopeFiles } = await getScopeFiles(targetNode, watcher);
12288
12484
  // Compute invalidation inputs (DRY: reuse phaseState/invalidate logic)
12289
12485
  const invalidation = await computeInvalidation(meta, scopeFiles, config, targetNode);
12290
- const { architectInvalidators, stalenessInputs } = invalidation;
12291
- const { structureHash } = invalidation;
12292
- const structureChanged = structureHash !== meta._structureHash;
12293
- const { steerChanged } = invalidation;
12294
- const { architectChanged, crossRefsDeclChanged } = stalenessInputs;
12295
- const architectTriggered = isArchitectTriggered(meta, structureChanged, steerChanged, config.architectEvery);
12486
+ const { architectInvalidators, inputStatus, phaseState } = invalidation;
12487
+ const architectTriggered = architectInvalidators.length > 0;
12296
12488
  // Delta files
12297
12489
  const deltaFiles = getDeltaFiles(meta._generatedAt, scopeFiles);
12298
12490
  // EMA token estimates
@@ -12306,14 +12498,6 @@ function registerPreviewRoute(app, deps) {
12306
12498
  ? Math.round((Date.now() - new Date(meta._generatedAt).getTime()) / 1000)
12307
12499
  : null;
12308
12500
  const stalenessScore = computeStalenessScore(stalenessSeconds, meta._depth ?? 0, meta._emphasis ?? 1, config.depthWeight);
12309
- // Phase state
12310
- const phaseState = derivePhaseState(meta, {
12311
- structureChanged,
12312
- steerChanged,
12313
- architectChanged,
12314
- crossRefsChanged: crossRefsDeclChanged,
12315
- architectEvery: config.architectEvery,
12316
- });
12317
12501
  const owedPhase = getOwedPhase(phaseState);
12318
12502
  const priorityBand = getPriorityBand(phaseState);
12319
12503
  return {
@@ -12324,10 +12508,17 @@ function registerPreviewRoute(app, deps) {
12324
12508
  },
12325
12509
  architectWillRun: architectTriggered,
12326
12510
  architectReason: [
12327
- ...(!meta._builder ? ['no cached builder (first run)'] : []),
12328
- ...(structureChanged ? ['structure changed'] : []),
12329
- ...(steerChanged ? ['steer changed'] : []),
12330
- ...((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')
12331
12522
  ? ['periodic refresh']
12332
12523
  : []),
12333
12524
  ].join(', ') || 'not triggered',
@@ -12344,7 +12535,7 @@ function registerPreviewRoute(app, deps) {
12344
12535
  owedPhase,
12345
12536
  priorityBand,
12346
12537
  phaseState,
12347
- stalenessInputs,
12538
+ inputStatus,
12348
12539
  architectInvalidators,
12349
12540
  };
12350
12541
  });
@@ -12362,7 +12553,7 @@ function registerPreviewRoute(app, deps) {
12362
12553
  /** Register queue management routes. */
12363
12554
  function registerQueueRoutes(app, deps) {
12364
12555
  const { queue } = deps;
12365
- app.get('/queue', async () => {
12556
+ app.get(getEndpoint('queue').path, async () => {
12366
12557
  const currentPhase = queue.currentPhase;
12367
12558
  const overrides = queue.overrides;
12368
12559
  // Compute owedPhase for each override entry by reading meta state
@@ -12438,11 +12629,11 @@ function registerQueueRoutes(app, deps) {
12438
12629
  state: queue.getState(),
12439
12630
  };
12440
12631
  });
12441
- app.post('/queue/clear', () => {
12632
+ app.post(getEndpoint('queueClear').path, () => {
12442
12633
  const removed = queue.clearOverrides();
12443
12634
  return { cleared: removed };
12444
12635
  });
12445
- app.post('/synthesize/abort', async (_request, reply) => {
12636
+ app.post(getEndpoint('abort').path, async (_request, reply) => {
12446
12637
  // Check 3-layer current first
12447
12638
  const currentPhase = queue.currentPhase;
12448
12639
  const current = currentPhase ?? queue.current;
@@ -12504,7 +12695,7 @@ const seedBodySchema = z.object({
12504
12695
  steer: z.string().optional(),
12505
12696
  });
12506
12697
  function registerSeedRoute(app, deps) {
12507
- app.post('/seed', async (request, reply) => {
12698
+ app.post(getEndpoint('seed').path, async (request, reply) => {
12508
12699
  const body = seedBodySchema.parse(request.body);
12509
12700
  if (metaExists(body.path)) {
12510
12701
  return reply.status(409).send({
@@ -12641,7 +12832,7 @@ function registerStatusRoute(app, deps) {
12641
12832
  dependencies: {
12642
12833
  watcher: {
12643
12834
  ...watcherHealth,
12644
- rulesRegistered: deps.registrar?.isRegistered ?? false,
12835
+ rulesRegistered: true,
12645
12836
  },
12646
12837
  gateway: gatewayHealth,
12647
12838
  },
@@ -12650,7 +12841,7 @@ function registerStatusRoute(app, deps) {
12650
12841
  };
12651
12842
  },
12652
12843
  });
12653
- app.get('/status', async (_request, reply) => {
12844
+ app.get(getEndpoint('status').path, async (_request, reply) => {
12654
12845
  const result = await statusHandler();
12655
12846
  return reply.status(result.status).send(result.body);
12656
12847
  });
@@ -12669,24 +12860,41 @@ const synthesizeBodySchema = z.object({
12669
12860
  });
12670
12861
  /** Register the POST /synthesize route. */
12671
12862
  function registerSynthesizeRoute(app, deps) {
12672
- app.post('/synthesize', async (request, reply) => {
12863
+ app.post(getEndpoint('synthesize').path, async (request, reply) => {
12673
12864
  const body = synthesizeBodySchema.parse(request.body);
12674
12865
  const { config, watcher, queue, cache } = deps;
12675
12866
  if (body.path) {
12676
12867
  // Path-targeted trigger: create override entry
12677
12868
  const targetPath = resolveMetaDir(body.path);
12678
- // 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.
12679
12872
  let owedPhase = null;
12680
12873
  let meta;
12681
12874
  try {
12682
12875
  meta = await readMetaJson(targetPath);
12683
- const phaseState = derivePhaseState(meta);
12684
- 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
+ }
12685
12892
  }
12686
12893
  catch {
12687
- // 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
12688
12896
  }
12689
- // Fully fresh meta → skip (reuse meta already read above)
12897
+ // Fully fresh meta → skip
12690
12898
  if (owedPhase === null && meta && (meta._phaseState || meta._content)) {
12691
12899
  return await reply.code(200).send({
12692
12900
  status: 'skipped',
@@ -12744,7 +12952,7 @@ const unlockBodySchema = z.object({
12744
12952
  path: z.string().min(1),
12745
12953
  });
12746
12954
  function registerUnlockRoute(app, deps) {
12747
- app.post('/unlock', (request, reply) => {
12955
+ app.post(getEndpoint('unlock').path, (request, reply) => {
12748
12956
  const body = unlockBodySchema.parse(request.body);
12749
12957
  const metaDir = resolveMetaDir(body.path);
12750
12958
  const lockPath = join(metaDir, '.lock');
@@ -13141,9 +13349,7 @@ async function startService(config, configPath) {
13141
13349
  routeDeps.registrar = registrar;
13142
13350
  void registrar.register().then(() => {
13143
13351
  routeDeps.ready = true;
13144
- if (registrar.isRegistered) {
13145
- void verifyRuleApplication(watcher, logger);
13146
- }
13352
+ void verifyRuleApplication(watcher, logger);
13147
13353
  }, () => {
13148
13354
  // Registration failed after max retries — mark ready anyway
13149
13355
  routeDeps.ready = true;
@@ -13336,6 +13542,17 @@ const metaJsonSchema = z
13336
13542
  _error: metaErrorSchema.optional(),
13337
13543
  /** When true, this meta is skipped during staleness scheduling. Manual trigger still works. */
13338
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(),
13339
13556
  /**
13340
13557
  * Per-phase state machine record. Engine-managed.
13341
13558
  * Keyed by phase name (architect, builder, critic) with status values.