@karmaniverous/jeeves-meta 0.14.0 → 0.15.0

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.
@@ -8917,22 +8917,6 @@ async function isStale(scopePrefix, meta, watcher) {
8917
8917
  }
8918
8918
  /** Maximum staleness for never-synthesized metas (1 year in seconds). */
8919
8919
  const MAX_STALENESS_SECONDS = 365 * 86_400;
8920
- /**
8921
- * Compute actual staleness in seconds (now minus _generatedAt).
8922
- *
8923
- * Never-synthesized metas are capped at {@link MAX_STALENESS_SECONDS}
8924
- * (1 year) so that depth weighting can differentiate them. Without
8925
- * bounding, `Infinity * depthFactor` = `Infinity` for all depths.
8926
- *
8927
- * @param meta - Current meta.json content.
8928
- * @returns Staleness in seconds, capped at 1 year for never-synthesized metas.
8929
- */
8930
- function actualStaleness(meta) {
8931
- if (!meta._generatedAt)
8932
- return MAX_STALENESS_SECONDS;
8933
- const generatedMs = new Date(meta._generatedAt).getTime();
8934
- return Math.min((Date.now() - generatedMs) / 1000, MAX_STALENESS_SECONDS);
8935
- }
8936
8920
  /**
8937
8921
  * Check whether the architect step should be triggered.
8938
8922
  *
@@ -9323,6 +9307,10 @@ class GatewayExecutor {
9323
9307
  return undefined;
9324
9308
  }
9325
9309
  }
9310
+ /** Whether this executor has been aborted by the operator. */
9311
+ get aborted() {
9312
+ return this.controller.signal.aborted;
9313
+ }
9326
9314
  /** Abort the currently running spawn, if any. */
9327
9315
  abort() {
9328
9316
  this.controller.abort();
@@ -9941,26 +9929,6 @@ function buildCriticTask(ctx, meta, config) {
9941
9929
  return compileTemplate(sections.join('\n'), buildTemplateContext(ctx, meta, config));
9942
9930
  }
9943
9931
 
9944
- /**
9945
- * Exponential moving average helper for token tracking.
9946
- *
9947
- * @module ema
9948
- */
9949
- const DEFAULT_DECAY = 0.3;
9950
- /**
9951
- * Compute exponential moving average.
9952
- *
9953
- * @param current - New observation.
9954
- * @param previous - Previous EMA value, or undefined for first observation.
9955
- * @param decay - Decay factor (0-1). Higher = more weight on new value. Default 0.3.
9956
- * @returns Updated EMA.
9957
- */
9958
- function computeEma(current, previous, decay = DEFAULT_DECAY) {
9959
- if (previous === undefined)
9960
- return current;
9961
- return decay * current + (1 - decay) * previous;
9962
- }
9963
-
9964
9932
  /**
9965
9933
  * Structured error from a synthesis step failure.
9966
9934
  *
@@ -9984,8 +9952,24 @@ const metaErrorSchema = z.object({
9984
9952
  *
9985
9953
  * @module schema/meta
9986
9954
  */
9955
+ /** Valid states for a synthesis phase. */
9956
+ const phaseStatuses = [
9957
+ 'fresh',
9958
+ 'stale',
9959
+ 'pending',
9960
+ 'running',
9961
+ 'failed',
9962
+ ];
9963
+ /** Zod schema for a per-phase status value. */
9964
+ const phaseStatusSchema = z.enum(phaseStatuses);
9965
+ /** Zod schema for the per-meta phase state record. */
9966
+ const phaseStateSchema = z.object({
9967
+ architect: phaseStatusSchema,
9968
+ builder: phaseStatusSchema,
9969
+ critic: phaseStatusSchema,
9970
+ });
9987
9971
  /** Zod schema for the reserved (underscore-prefixed) meta.json properties. */
9988
- const metaJsonSchema = z
9972
+ z
9989
9973
  .object({
9990
9974
  /** Stable identity. Auto-generated on first synthesis if not provided. */
9991
9975
  _id: z.uuid().optional(),
@@ -10064,105 +10048,15 @@ const metaJsonSchema = z
10064
10048
  _error: metaErrorSchema.optional(),
10065
10049
  /** When true, this meta is skipped during staleness scheduling. Manual trigger still works. */
10066
10050
  _disabled: z.boolean().optional(),
10051
+ /**
10052
+ * Per-phase state machine record. Engine-managed.
10053
+ * Keyed by phase name (architect, builder, critic) with status values.
10054
+ * Persisted to survive ticks; derived on first load for back-compat.
10055
+ */
10056
+ _phaseState: phaseStateSchema.optional(),
10067
10057
  })
10068
10058
  .loose();
10069
10059
 
10070
- /**
10071
- * Merge synthesis results into meta.json.
10072
- *
10073
- * Preserves human-set fields (_id, _steer, _depth).
10074
- * Writes engine fields (_generatedAt, _structureHash, etc.).
10075
- * Validates against schema before writing.
10076
- *
10077
- * @module orchestrator/merge
10078
- */
10079
- /**
10080
- * Merge results into meta.json and write atomically.
10081
- *
10082
- * @param options - Merge options.
10083
- * @returns The updated MetaJson.
10084
- * @throws If validation fails (malformed output).
10085
- */
10086
- async function mergeAndWrite(options) {
10087
- const merged = {
10088
- // Preserve human-set fields (auto-generate _id on first synthesis)
10089
- _id: options.current._id ?? randomUUID(),
10090
- _steer: options.current._steer,
10091
- _depth: options.current._depth,
10092
- _emphasis: options.current._emphasis,
10093
- // Engine fields
10094
- _architect: options.architect,
10095
- _builder: options.builder,
10096
- _critic: options.critic,
10097
- _generatedAt: options.stateOnly
10098
- ? options.current._generatedAt
10099
- : new Date().toISOString(),
10100
- _structureHash: options.structureHash,
10101
- _synthesisCount: options.synthesisCount,
10102
- // Token tracking
10103
- _architectTokens: options.architectTokens,
10104
- _builderTokens: options.builderTokens,
10105
- _criticTokens: options.criticTokens,
10106
- _architectTokensAvg: options.architectTokens !== undefined
10107
- ? computeEma(options.architectTokens, options.current._architectTokensAvg)
10108
- : options.current._architectTokensAvg,
10109
- _builderTokensAvg: options.builderTokens !== undefined
10110
- ? computeEma(options.builderTokens, options.current._builderTokensAvg)
10111
- : options.current._builderTokensAvg,
10112
- _criticTokensAvg: options.criticTokens !== undefined
10113
- ? computeEma(options.criticTokens, options.current._criticTokensAvg)
10114
- : options.current._criticTokensAvg,
10115
- // Content from builder (stateOnly preserves previous content)
10116
- _content: options.stateOnly
10117
- ? options.current._content
10118
- : (options.builderOutput?.content ?? options.current._content),
10119
- // Feedback from critic
10120
- _feedback: options.feedback ?? options.current._feedback,
10121
- // Progressive state
10122
- _state: options.state,
10123
- // Error handling
10124
- _error: options.error ?? undefined,
10125
- // Spread structured fields from builder
10126
- ...options.builderOutput?.fields,
10127
- };
10128
- // Clean up undefined optional fields
10129
- if (merged._steer === undefined)
10130
- delete merged._steer;
10131
- if (merged._depth === undefined)
10132
- delete merged._depth;
10133
- if (merged._emphasis === undefined)
10134
- delete merged._emphasis;
10135
- if (merged._architectTokens === undefined)
10136
- delete merged._architectTokens;
10137
- if (merged._builderTokens === undefined)
10138
- delete merged._builderTokens;
10139
- if (merged._criticTokens === undefined)
10140
- delete merged._criticTokens;
10141
- if (merged._architectTokensAvg === undefined)
10142
- delete merged._architectTokensAvg;
10143
- if (merged._builderTokensAvg === undefined)
10144
- delete merged._builderTokensAvg;
10145
- if (merged._criticTokensAvg === undefined)
10146
- delete merged._criticTokensAvg;
10147
- if (merged._state === undefined)
10148
- delete merged._state;
10149
- if (merged._error === undefined)
10150
- delete merged._error;
10151
- if (merged._content === undefined)
10152
- delete merged._content;
10153
- if (merged._feedback === undefined)
10154
- delete merged._feedback;
10155
- // Validate
10156
- const result = metaJsonSchema.safeParse(merged);
10157
- if (!result.success) {
10158
- throw new Error(`Meta validation failed: ${result.error.message}`);
10159
- }
10160
- // Write to specified path (lock staging) or default meta.json
10161
- const filePath = options.outputPath ?? join(options.metaPath, 'meta.json');
10162
- await writeFile(filePath, JSON.stringify(result.data, null, 2) + '\n');
10163
- return result.data;
10164
- }
10165
-
10166
10060
  /**
10167
10061
  * Build a minimal MetaNode from a known meta path using watcher walk.
10168
10062
  *
@@ -10343,43 +10237,6 @@ function computeStructureHash(filePaths) {
10343
10237
  return createHash('sha256').update(content).digest('hex');
10344
10238
  }
10345
10239
 
10346
- /**
10347
- * Lock-staged cycle finalization: write to .lock, copy to meta.json, archive, prune.
10348
- *
10349
- * @module orchestrator/finalizeCycle
10350
- */
10351
- /** Finalize a cycle using lock staging: write to .lock → copy to meta.json + archive → delete .lock. */
10352
- async function finalizeCycle(opts) {
10353
- const lockPath = join(opts.metaPath, '.lock');
10354
- const metaJsonPath = join(opts.metaPath, 'meta.json');
10355
- // Stage: write merged result to .lock (sequential — ordering matters)
10356
- const updated = await mergeAndWrite({
10357
- metaPath: opts.metaPath,
10358
- current: opts.current,
10359
- architect: opts.architect,
10360
- builder: opts.builder,
10361
- critic: opts.critic,
10362
- builderOutput: opts.builderOutput,
10363
- feedback: opts.feedback,
10364
- structureHash: opts.structureHash,
10365
- synthesisCount: opts.synthesisCount,
10366
- error: opts.error,
10367
- architectTokens: opts.architectTokens,
10368
- builderTokens: opts.builderTokens,
10369
- criticTokens: opts.criticTokens,
10370
- outputPath: lockPath,
10371
- state: opts.state,
10372
- stateOnly: opts.stateOnly,
10373
- });
10374
- // Commit: copy .lock → meta.json
10375
- await copyFile(lockPath, metaJsonPath);
10376
- // Archive + prune from the committed meta.json (sequential)
10377
- await createSnapshot(opts.metaPath, updated);
10378
- await pruneArchive(opts.metaPath, opts.config.maxArchive);
10379
- // .lock is cleaned up by the finally block (releaseLock)
10380
- return updated;
10381
- }
10382
-
10383
10240
  /**
10384
10241
  * Parse subprocess outputs for each synthesis step.
10385
10242
  *
@@ -10478,162 +10335,441 @@ function parseCriticOutput(output) {
10478
10335
  }
10479
10336
 
10480
10337
  /**
10481
- * Timeout recovery — salvage partial builder state after a SpawnTimeoutError.
10338
+ * Pure phase-state transition functions.
10339
+ *
10340
+ * Implements every row of the §8 "Transitions and invalidation cascade" table.
10341
+ * No I/O — pure functions over PhaseState and documented inputs.
10482
10342
  *
10483
- * @module orchestrator/timeoutRecovery
10343
+ * @module phaseState/phaseTransitions
10344
+ */
10345
+ /**
10346
+ * Create a fresh (fully-complete) phase state.
10347
+ */
10348
+ function freshPhaseState() {
10349
+ return { architect: 'fresh', builder: 'fresh', critic: 'fresh' };
10350
+ }
10351
+ /**
10352
+ * Create a phase state for a never-synthesized meta (all pending from architect).
10484
10353
  */
10354
+ function initialPhaseState() {
10355
+ return { architect: 'pending', builder: 'stale', critic: 'stale' };
10356
+ }
10485
10357
  /**
10486
- * Attempt to recover partial state from a timed-out builder spawn.
10358
+ * Enforce the per-meta invariant: at most one phase is pending or running,
10359
+ * and it is the first non-fresh phase in pipeline order.
10487
10360
  *
10488
- * Returns an {@link OrchestrateResult} if state was salvaged, or `null`
10489
- * if the caller should fall through to a hard failure.
10361
+ * Stale phases that become the first non-fresh phase are promoted to pending.
10490
10362
  */
10491
- async function attemptTimeoutRecovery(opts) {
10492
- const { err, currentMeta, metaPath, config, builderBrief, structureHash, synthesisCount, } = opts;
10493
- let partialOutput = null;
10494
- try {
10495
- const raw = await readFile(err.outputPath, 'utf8');
10496
- partialOutput = parseBuilderOutput(raw);
10363
+ function enforceInvariant(state) {
10364
+ const result = { ...state };
10365
+ let foundNonFresh = false;
10366
+ for (const phase of ['architect', 'builder', 'critic']) {
10367
+ const s = result[phase];
10368
+ if (s === 'fresh')
10369
+ continue;
10370
+ if (!foundNonFresh) {
10371
+ foundNonFresh = true;
10372
+ // First non-fresh: if stale, promote to pending
10373
+ if (s === 'stale') {
10374
+ result[phase] = 'pending';
10375
+ }
10376
+ // pending, running, failed stay as-is
10377
+ }
10378
+ else {
10379
+ // Subsequent non-fresh: must not be pending or running
10380
+ if (s === 'pending') {
10381
+ result[phase] = 'stale';
10382
+ }
10383
+ // running in non-first position would be a bug, but don't mask it
10384
+ }
10497
10385
  }
10498
- catch {
10499
- // Could not read partial output — fall through to hard failure
10500
- }
10501
- if (partialOutput?.state !== undefined) {
10502
- const currentState = JSON.stringify(currentMeta._state);
10503
- const newState = JSON.stringify(partialOutput.state);
10504
- if (newState !== currentState) {
10505
- const timeoutError = {
10506
- step: 'builder',
10507
- code: 'TIMEOUT',
10508
- message: err.message,
10509
- };
10510
- await finalizeCycle({
10511
- metaPath,
10512
- current: currentMeta,
10513
- config,
10514
- architect: currentMeta._architect ?? '',
10515
- builder: builderBrief,
10516
- critic: currentMeta._critic ?? '',
10517
- builderOutput: null,
10518
- feedback: null,
10519
- structureHash,
10520
- synthesisCount,
10521
- error: timeoutError,
10522
- state: partialOutput.state,
10523
- stateOnly: true,
10524
- });
10525
- return {
10526
- synthesized: true,
10527
- metaPath,
10528
- error: timeoutError,
10529
- };
10386
+ return result;
10387
+ }
10388
+ // ── Phase success transitions ──────────────────────────────────────────
10389
+ /**
10390
+ * Architect completes successfully.
10391
+ * architect → fresh; builder → pending; critic → stale.
10392
+ */
10393
+ function architectSuccess(state) {
10394
+ return enforceInvariant({
10395
+ architect: 'fresh',
10396
+ builder: state.builder === 'failed' ? 'failed' : 'pending',
10397
+ critic: state.critic === 'fresh' ? 'stale' : state.critic,
10398
+ });
10399
+ }
10400
+ /**
10401
+ * Builder completes successfully.
10402
+ * builder → fresh; critic → pending.
10403
+ */
10404
+ function builderSuccess(state) {
10405
+ return enforceInvariant({
10406
+ ...state,
10407
+ builder: 'fresh',
10408
+ critic: state.critic === 'failed' ? 'failed' : 'pending',
10409
+ });
10410
+ }
10411
+ /**
10412
+ * Critic completes successfully.
10413
+ * critic → fresh. Meta becomes fully fresh.
10414
+ */
10415
+ function criticSuccess(state) {
10416
+ return enforceInvariant({
10417
+ ...state,
10418
+ critic: 'fresh',
10419
+ });
10420
+ }
10421
+ // ── Failure transition ─────────────────────────────────────────────────
10422
+ /**
10423
+ * A phase fails (error, timeout, or abort).
10424
+ * Target phase → failed; upstream and downstream unchanged.
10425
+ */
10426
+ function phaseFailed(state, phase) {
10427
+ return enforceInvariant({
10428
+ ...state,
10429
+ [phase]: 'failed',
10430
+ });
10431
+ }
10432
+ // ── Surgical retry ─────────────────────────────────────────────────────
10433
+ /**
10434
+ * Retry a failed phase: failed → pending.
10435
+ * Only valid when the phase is currently failed.
10436
+ */
10437
+ function retryPhase(state, phase) {
10438
+ if (state[phase] !== 'failed')
10439
+ return state;
10440
+ return enforceInvariant({
10441
+ ...state,
10442
+ [phase]: 'pending',
10443
+ });
10444
+ }
10445
+ /**
10446
+ * Retry all failed phases: each failed phase → pending.
10447
+ * Used by scheduler ticks and queue reads to auto-promote failed phases.
10448
+ */
10449
+ function retryAllFailed(state) {
10450
+ let result = state;
10451
+ for (const phase of ['architect', 'builder', 'critic']) {
10452
+ if (result[phase] === 'failed') {
10453
+ result = retryPhase(result, phase);
10530
10454
  }
10531
10455
  }
10456
+ return result;
10457
+ }
10458
+ // ── Running transition ─────────────────────────────────────────────────
10459
+ /**
10460
+ * Mark a phase as running (scheduler picks it).
10461
+ */
10462
+ function phaseRunning(state, phase) {
10463
+ return {
10464
+ ...state,
10465
+ [phase]: 'running',
10466
+ };
10467
+ }
10468
+ // ── Query helpers ──────────────────────────────────────────────────────
10469
+ /**
10470
+ * Get the owed phase: first non-fresh phase in pipeline order, or null.
10471
+ */
10472
+ function getOwedPhase(state) {
10473
+ for (const phase of ['architect', 'builder', 'critic']) {
10474
+ if (state[phase] !== 'fresh')
10475
+ return phase;
10476
+ }
10532
10477
  return null;
10533
10478
  }
10479
+ /**
10480
+ * Check if a meta is fully fresh (all phases fresh).
10481
+ */
10482
+ function isFullyFresh(state) {
10483
+ return (state.architect === 'fresh' &&
10484
+ state.builder === 'fresh' &&
10485
+ state.critic === 'fresh');
10486
+ }
10487
+ /**
10488
+ * Get the scheduler priority band for a meta's owed phase.
10489
+ * 1 = critic (highest), 2 = builder, 3 = architect, null = fully fresh.
10490
+ */
10491
+ function getPriorityBand(state) {
10492
+ const owed = getOwedPhase(state);
10493
+ if (!owed)
10494
+ return null;
10495
+ if (owed === 'critic')
10496
+ return 1;
10497
+ if (owed === 'builder')
10498
+ return 2;
10499
+ return 3;
10500
+ }
10534
10501
 
10535
10502
  /**
10536
- * Single-node synthesis pipeline architect, builder, critic.
10503
+ * Backward-compatible derivation of _phaseState from existing meta fields.
10537
10504
  *
10538
- * @module orchestrator/synthesizeNode
10505
+ * When a meta is loaded from disk without _phaseState, this reconstructs
10506
+ * the phase state from _content, _builder, _state, _error.step, and
10507
+ * the architect-invalidating inputs.
10508
+ *
10509
+ * @module phaseState/derivePhaseState
10539
10510
  */
10540
- /** Run the architect/builder/critic pipeline on a single node. */
10541
- async function synthesizeNode(node, currentMeta, config, executor, watcher, onProgress, logger) {
10542
- // Step 5-6: Steer change detection
10543
- const latestArchive = await readLatestArchive(node.metaPath);
10544
- const steerChanged = hasSteerChanged(currentMeta._steer, latestArchive?._steer, Boolean(latestArchive));
10545
- // Step 7: Compute context (includes scope files and delta files)
10546
- const ctx = await buildContextPackage(node, currentMeta, watcher, logger);
10547
- // Skip empty-scope entities that have no prior content.
10548
- // Without scope files, child metas, or cross-refs there is nothing for
10549
- // the architect/builder to work with and the cycle will either time out
10550
- // or produce empty output.
10551
- const hasScope = ctx.scopeFiles.length > 0 ||
10552
- Object.keys(ctx.childMetas).length > 0 ||
10553
- Object.keys(ctx.crossRefMetas).length > 0;
10554
- if (!hasScope && !currentMeta._content) {
10555
- // Bump _generatedAt so this entity doesn't keep winning the staleness
10556
- // race every cycle. It will be re-evaluated when files appear.
10557
- // Uses lock-staging for atomic write consistency.
10558
- currentMeta._generatedAt = new Date().toISOString();
10559
- const lockPath = join(node.metaPath, '.lock');
10560
- const metaJsonPath = join(node.metaPath, 'meta.json');
10561
- await writeFile(lockPath, JSON.stringify(currentMeta, null, 2));
10562
- await copyFile(lockPath, metaJsonPath);
10563
- logger?.debug({ path: node.ownerPath }, 'Skipping empty-scope entity');
10564
- return { synthesized: false };
10565
- }
10566
- // Step 5 (deferred): Structure hash from context scope files
10567
- const newStructureHash = computeStructureHash(ctx.scopeFiles);
10568
- const structureChanged = newStructureHash !== currentMeta._structureHash;
10569
- // Step 8: Architect (conditional)
10570
- const architectTriggered = isArchitectTriggered(currentMeta, structureChanged, steerChanged, config.architectEvery);
10571
- let builderBrief = currentMeta._builder ?? '';
10572
- let synthesisCount = currentMeta._synthesisCount ?? 0;
10573
- let stepError = null;
10574
- let architectTokens;
10575
- let builderTokens;
10576
- let criticTokens;
10577
- // Shared base options for all finalizeCycle calls.
10578
- // Note: synthesisCount is excluded because it mutates during the pipeline.
10579
- const baseFinalizeOptions = {
10580
- metaPath: node.metaPath,
10581
- current: currentMeta,
10582
- config,
10583
- architect: currentMeta._architect ?? '',
10584
- critic: currentMeta._critic ?? '',
10585
- structureHash: newStructureHash,
10586
- };
10587
- if (architectTriggered) {
10588
- try {
10589
- await onProgress?.({
10590
- type: 'phase_start',
10591
- path: node.ownerPath,
10592
- phase: 'architect',
10593
- });
10594
- const phaseStart = Date.now();
10595
- const architectTask = buildArchitectTask(ctx, currentMeta, config);
10596
- const architectResult = await executor.spawn(architectTask, {
10597
- thinking: config.thinking,
10598
- timeout: config.architectTimeout,
10599
- label: 'meta-architect',
10600
- });
10601
- builderBrief = parseArchitectOutput(architectResult.output);
10602
- architectTokens = architectResult.tokens;
10603
- synthesisCount = 0;
10604
- await onProgress?.({
10605
- type: 'phase_complete',
10606
- path: node.ownerPath,
10607
- phase: 'architect',
10608
- tokens: architectTokens,
10609
- durationMs: Date.now() - phaseStart,
10610
- });
10611
- }
10612
- catch (err) {
10613
- stepError = toMetaError('architect', err);
10614
- if (!currentMeta._builder) {
10615
- // No cached builder — cycle fails
10616
- await finalizeCycle({
10617
- ...baseFinalizeOptions,
10618
- builder: '',
10619
- builderOutput: null,
10620
- feedback: null,
10621
- synthesisCount,
10622
- error: stepError,
10623
- architectTokens,
10624
- });
10625
- return {
10626
- synthesized: true,
10627
- metaPath: node.metaPath,
10628
- error: stepError,
10629
- };
10511
+ /**
10512
+ * Derive _phaseState from existing meta fields.
10513
+ *
10514
+ * If the meta already has _phaseState, returns it as-is.
10515
+ *
10516
+ * Otherwise, reconstructs from available fields:
10517
+ * - Never-synthesized meta (no _content, no _builder): all phases start pending/stale.
10518
+ * - Errored meta: the failed phase is mapped from _error.step.
10519
+ * - Mid-cycle meta with cached _builder but no _content: builder pending.
10520
+ * - Fully-fresh meta: all phases fresh.
10521
+ * - Meta with stale architect inputs: architect pending, downstream stale.
10522
+ *
10523
+ * @param meta - The meta.json content.
10524
+ * @param inputs - Optional derivation inputs. If not provided, a simpler
10525
+ * heuristic is used (no architect invalidation check).
10526
+ * @returns The derived PhaseState.
10527
+ */
10528
+ function derivePhaseState(meta, inputs) {
10529
+ // Already has _phaseState — use it
10530
+ if (meta._phaseState)
10531
+ return meta._phaseState;
10532
+ // Check for errors first — _error.step maps directly to failed phase
10533
+ if (meta._error) {
10534
+ const failedPhase = meta._error.step;
10535
+ const state = freshPhaseState();
10536
+ state[failedPhase] = 'failed';
10537
+ // If architect failed and no _builder, downstream is stale
10538
+ if (failedPhase === 'architect') {
10539
+ if (!meta._builder) {
10540
+ state.builder = 'stale';
10541
+ state.critic = 'stale';
10630
10542
  }
10631
- // Has cached builder — continue with existing
10632
10543
  }
10544
+ // If builder failed, critic is stale
10545
+ if (failedPhase === 'builder') {
10546
+ state.critic = 'stale';
10547
+ }
10548
+ return state;
10549
+ }
10550
+ // Never synthesized: no _content AND no _builder (and no error)
10551
+ if (!meta._content && !meta._builder) {
10552
+ return initialPhaseState();
10553
+ }
10554
+ // Check architect invalidation (when inputs are provided)
10555
+ if (inputs) {
10556
+ const architectInvalidated = inputs.structureChanged ||
10557
+ inputs.steerChanged ||
10558
+ inputs.architectChanged ||
10559
+ inputs.crossRefsChanged ||
10560
+ (meta._synthesisCount ?? 0) >= inputs.architectEvery;
10561
+ if (architectInvalidated) {
10562
+ return {
10563
+ architect: 'pending',
10564
+ builder: 'stale',
10565
+ critic: 'stale',
10566
+ };
10567
+ }
10568
+ }
10569
+ // Has _builder but no _content: builder is pending
10570
+ if (meta._builder && !meta._content) {
10571
+ return {
10572
+ architect: 'fresh',
10573
+ builder: 'pending',
10574
+ critic: 'stale',
10575
+ };
10576
+ }
10577
+ // Has _content but no _feedback: critic is pending
10578
+ if (meta._content && !meta._feedback) {
10579
+ return {
10580
+ architect: 'fresh',
10581
+ builder: 'fresh',
10582
+ critic: 'pending',
10583
+ };
10584
+ }
10585
+ // Default: fully fresh
10586
+ return freshPhaseState();
10587
+ }
10588
+
10589
+ /**
10590
+ * Corpus-wide phase scheduler.
10591
+ *
10592
+ * Selects the highest-priority ready phase across all metas.
10593
+ * Priority: critic (band 1) \> builder (band 2) \> architect (band 3).
10594
+ * Tiebreak within band: weighted staleness (§3.9).
10595
+ *
10596
+ * @module phaseState/phaseScheduler
10597
+ */
10598
+ /**
10599
+ * Build phase candidates from listMetas entries.
10600
+ *
10601
+ * Derives phase state and auto-retries failed phases for each entry.
10602
+ * Used by orchestratePhase, queue route, and status route.
10603
+ */
10604
+ function buildPhaseCandidates(entries) {
10605
+ return entries.map((entry) => ({
10606
+ node: entry.node,
10607
+ meta: entry.meta,
10608
+ phaseState: retryAllFailed(derivePhaseState(entry.meta)),
10609
+ actualStaleness: entry.stalenessSeconds,
10610
+ locked: entry.locked,
10611
+ disabled: entry.disabled,
10612
+ }));
10613
+ }
10614
+ /**
10615
+ * Rank all eligible phase candidates by priority.
10616
+ *
10617
+ * Filters to pending phases, computes effective staleness, and sorts by
10618
+ * band (ascending: critic first) then effective staleness (descending).
10619
+ *
10620
+ * Used by selectPhaseCandidate (returns first) and the queue route (returns all).
10621
+ */
10622
+ function rankPhaseCandidates(metas, depthWeight) {
10623
+ // Filter to metas with a pending (scheduler-eligible) phase
10624
+ const eligible = metas.filter((m) => {
10625
+ if (m.locked)
10626
+ return false;
10627
+ if (m.disabled && !m.isOverride)
10628
+ return false;
10629
+ const owed = getOwedPhase(m.phaseState);
10630
+ if (!owed)
10631
+ return false;
10632
+ return m.phaseState[owed] === 'pending';
10633
+ });
10634
+ if (eligible.length === 0)
10635
+ return [];
10636
+ // Compute effective staleness for tiebreaking
10637
+ const withStaleness = computeEffectiveStaleness(eligible.map((m) => ({
10638
+ node: m.node,
10639
+ meta: m.meta,
10640
+ actualStaleness: m.actualStaleness,
10641
+ })), depthWeight);
10642
+ // Build candidates with band info
10643
+ const candidates = withStaleness.map((ws, i) => {
10644
+ const m = eligible[i];
10645
+ const owedPhase = getOwedPhase(m.phaseState);
10646
+ return {
10647
+ node: ws.node,
10648
+ meta: ws.meta,
10649
+ phaseState: m.phaseState,
10650
+ owedPhase,
10651
+ band: getPriorityBand(m.phaseState),
10652
+ actualStaleness: ws.actualStaleness,
10653
+ effectiveStaleness: ws.effectiveStaleness,
10654
+ };
10655
+ });
10656
+ // Sort by band (ascending = critic first) then effective staleness (descending)
10657
+ candidates.sort((a, b) => {
10658
+ if (a.band !== b.band)
10659
+ return a.band - b.band;
10660
+ return b.effectiveStaleness - a.effectiveStaleness;
10661
+ });
10662
+ return candidates;
10663
+ }
10664
+ /**
10665
+ * Select the best phase candidate across the corpus.
10666
+ *
10667
+ * @param metas - Array of (node, meta, phaseState, stalenessSeconds) tuples.
10668
+ * @param depthWeight - Config depthWeight for staleness tiebreak.
10669
+ * @returns The winning candidate, or null if no phase is ready.
10670
+ */
10671
+ function selectPhaseCandidate(metas, depthWeight) {
10672
+ return rankPhaseCandidates(metas, depthWeight)[0] ?? null;
10673
+ }
10674
+
10675
+ /**
10676
+ * Per-phase executors for the phase-state machine.
10677
+ *
10678
+ * Each function runs exactly one phase on one meta, updates _phaseState
10679
+ * via pure transitions, and persists via the lock-staged write.
10680
+ *
10681
+ * @module orchestrator/runPhase
10682
+ */
10683
+ /** Write updated meta with phase state via lock staging. */
10684
+ async function persistPhaseState(base, phaseState, updates) {
10685
+ const lockPath = join(base.metaPath, '.lock');
10686
+ const metaJsonPath = join(base.metaPath, 'meta.json');
10687
+ const merged = {
10688
+ ...base.current,
10689
+ ...updates,
10690
+ _phaseState: phaseState,
10691
+ _structureHash: base.structureHash,
10692
+ };
10693
+ // Clean undefined
10694
+ if (merged._error === undefined)
10695
+ delete merged._error;
10696
+ await writeFile(lockPath, JSON.stringify(merged, null, 2) + '\n');
10697
+ await copyFile(lockPath, metaJsonPath);
10698
+ return merged;
10699
+ }
10700
+ /**
10701
+ * Handle phase failure (abort or error).
10702
+ *
10703
+ * Shared error path for all three phase executors. When the executor was
10704
+ * aborted, returns immediately without persisting (abort route handles it).
10705
+ * Otherwise, transitions the phase to failed and persists the error.
10706
+ */
10707
+ async function handlePhaseFailure(phase, err, executor, ps, base, additionalUpdates) {
10708
+ if (executor.aborted) {
10709
+ return {
10710
+ executed: true,
10711
+ phaseState: phaseFailed(ps, phase),
10712
+ error: { step: phase, code: 'ABORT', message: 'Aborted by operator' },
10713
+ };
10714
+ }
10715
+ const error = toMetaError(phase, err);
10716
+ const failedPs = phaseFailed(ps, phase);
10717
+ await persistPhaseState(base, failedPs, {
10718
+ _error: error,
10719
+ ...additionalUpdates,
10720
+ });
10721
+ return { executed: true, phaseState: failedPs, error };
10722
+ }
10723
+ // ── Architect executor ─────────────────────────────────────────────────
10724
+ async function runArchitect(node, currentMeta, phaseState, config, executor, watcher, structureHash, onProgress, logger) {
10725
+ let ps = phaseRunning(phaseState, 'architect');
10726
+ const ctx = await buildContextPackage(node, currentMeta, watcher, logger);
10727
+ try {
10728
+ await onProgress?.({
10729
+ type: 'phase_start',
10730
+ path: node.ownerPath,
10731
+ phase: 'architect',
10732
+ });
10733
+ const phaseStart = Date.now();
10734
+ const architectTask = buildArchitectTask(ctx, currentMeta, config);
10735
+ const result = await executor.spawn(architectTask, {
10736
+ thinking: config.thinking,
10737
+ timeout: config.architectTimeout,
10738
+ label: 'meta-architect',
10739
+ });
10740
+ const builderBrief = parseArchitectOutput(result.output);
10741
+ const architectTokens = result.tokens;
10742
+ // Architect success: architect → fresh, _synthesisCount → 0
10743
+ ps = architectSuccess(ps);
10744
+ const updatedMeta = await persistPhaseState({ metaPath: node.metaPath, current: currentMeta, config, structureHash }, ps, {
10745
+ _builder: builderBrief,
10746
+ _architect: currentMeta._architect ?? config.defaultArchitect ?? '',
10747
+ _synthesisCount: 0,
10748
+ _architectTokens: architectTokens,
10749
+ _generatedAt: new Date().toISOString(),
10750
+ _error: undefined,
10751
+ });
10752
+ await onProgress?.({
10753
+ type: 'phase_complete',
10754
+ path: node.ownerPath,
10755
+ phase: 'architect',
10756
+ tokens: architectTokens,
10757
+ durationMs: Date.now() - phaseStart,
10758
+ });
10759
+ return { executed: true, phaseState: ps, updatedMeta };
10760
+ }
10761
+ catch (err) {
10762
+ return handlePhaseFailure('architect', err, executor, ps, {
10763
+ metaPath: node.metaPath,
10764
+ current: currentMeta,
10765
+ structureHash,
10766
+ });
10633
10767
  }
10634
- // Step 9: Builder
10635
- const metaForBuilder = { ...currentMeta, _builder: builderBrief };
10636
- let builderOutput;
10768
+ }
10769
+ // ── Builder executor ───────────────────────────────────────────────────
10770
+ async function runBuilder(node, currentMeta, phaseState, config, executor, watcher, structureHash, onProgress, logger) {
10771
+ let ps = phaseRunning(phaseState, 'builder');
10772
+ const ctx = await buildContextPackage(node, currentMeta, watcher, logger);
10637
10773
  try {
10638
10774
  await onProgress?.({
10639
10775
  type: 'phase_start',
@@ -10641,15 +10777,24 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
10641
10777
  phase: 'builder',
10642
10778
  });
10643
10779
  const builderStart = Date.now();
10644
- const builderTask = buildBuilderTask(ctx, metaForBuilder, config);
10645
- const builderResult = await executor.spawn(builderTask, {
10780
+ const builderTask = buildBuilderTask(ctx, currentMeta, config);
10781
+ const result = await executor.spawn(builderTask, {
10646
10782
  thinking: config.thinking,
10647
10783
  timeout: config.builderTimeout,
10648
10784
  label: 'meta-builder',
10649
10785
  });
10650
- builderOutput = parseBuilderOutput(builderResult.output);
10651
- builderTokens = builderResult.tokens;
10652
- synthesisCount++;
10786
+ const builderOutput = parseBuilderOutput(result.output);
10787
+ const builderTokens = result.tokens;
10788
+ // Builder success: builder → fresh, critic → pending
10789
+ ps = builderSuccess(ps);
10790
+ const updatedMeta = await persistPhaseState({ metaPath: node.metaPath, current: currentMeta, config, structureHash }, ps, {
10791
+ _content: builderOutput.content,
10792
+ _state: builderOutput.state,
10793
+ _builderTokens: builderTokens,
10794
+ _generatedAt: new Date().toISOString(),
10795
+ _error: undefined,
10796
+ ...builderOutput.fields,
10797
+ });
10653
10798
  await onProgress?.({
10654
10799
  type: 'phase_complete',
10655
10800
  path: node.ownerPath,
@@ -10657,38 +10802,37 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
10657
10802
  tokens: builderTokens,
10658
10803
  durationMs: Date.now() - builderStart,
10659
10804
  });
10805
+ return { executed: true, phaseState: ps, updatedMeta };
10660
10806
  }
10661
10807
  catch (err) {
10808
+ // §4.6 partial _state recovery on timeout
10809
+ let partialState;
10662
10810
  if (err instanceof SpawnTimeoutError) {
10663
- const recovered = await attemptTimeoutRecovery({
10664
- err,
10665
- currentMeta,
10666
- metaPath: node.metaPath,
10667
- config,
10668
- builderBrief,
10669
- structureHash: newStructureHash,
10670
- synthesisCount,
10671
- });
10672
- if (recovered)
10673
- return recovered;
10674
- }
10675
- stepError = toMetaError('builder', err);
10676
- await finalizeCycle({
10677
- ...baseFinalizeOptions,
10678
- builder: builderBrief,
10679
- builderOutput: null,
10680
- feedback: null,
10681
- synthesisCount,
10682
- error: stepError,
10683
- });
10684
- return { synthesized: true, metaPath: node.metaPath, error: stepError };
10811
+ try {
10812
+ const raw = await readFile(err.outputPath, 'utf8');
10813
+ const partial = parseBuilderOutput(raw);
10814
+ if (partial.state !== undefined &&
10815
+ JSON.stringify(partial.state) !== JSON.stringify(currentMeta._state)) {
10816
+ partialState = { _state: partial.state };
10817
+ }
10818
+ }
10819
+ catch {
10820
+ // Could not read partial output — no state recovery
10821
+ }
10822
+ }
10823
+ return handlePhaseFailure('builder', err, executor, ps, {
10824
+ metaPath: node.metaPath,
10825
+ current: currentMeta,
10826
+ structureHash,
10827
+ }, partialState);
10685
10828
  }
10686
- // Step 10: Critic
10687
- const metaForCritic = {
10688
- ...currentMeta,
10689
- _content: builderOutput.content,
10690
- };
10691
- let feedback = null;
10829
+ }
10830
+ // ── Critic executor ────────────────────────────────────────────────────
10831
+ async function runCritic(node, currentMeta, phaseState, config, executor, watcher, structureHash, onProgress, logger) {
10832
+ let ps = phaseRunning(phaseState, 'critic');
10833
+ const ctx = await buildContextPackage(node, currentMeta, watcher, logger);
10834
+ // Build critic task using current meta's _content
10835
+ const metaForCritic = { ...currentMeta };
10692
10836
  try {
10693
10837
  await onProgress?.({
10694
10838
  type: 'phase_start',
@@ -10697,14 +10841,32 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
10697
10841
  });
10698
10842
  const criticStart = Date.now();
10699
10843
  const criticTask = buildCriticTask(ctx, metaForCritic, config);
10700
- const criticResult = await executor.spawn(criticTask, {
10844
+ const result = await executor.spawn(criticTask, {
10701
10845
  thinking: config.thinking,
10702
10846
  timeout: config.criticTimeout,
10703
10847
  label: 'meta-critic',
10704
10848
  });
10705
- feedback = parseCriticOutput(criticResult.output);
10706
- criticTokens = criticResult.tokens;
10707
- stepError = null; // Clear any architect error on full success
10849
+ const feedback = parseCriticOutput(result.output);
10850
+ const criticTokens = result.tokens;
10851
+ // Critic success: critic fresh
10852
+ ps = criticSuccess(ps);
10853
+ const cycleComplete = isFullyFresh(ps);
10854
+ const updates = {
10855
+ _feedback: feedback,
10856
+ _criticTokens: criticTokens,
10857
+ _error: undefined,
10858
+ };
10859
+ // Full-cycle completion: increment _synthesisCount, archive, emit.
10860
+ // Per spec: architect resets to 0, full-cycle increments on top.
10861
+ if (cycleComplete) {
10862
+ updates._synthesisCount = (currentMeta._synthesisCount ?? 0) + 1;
10863
+ }
10864
+ const updatedMeta = await persistPhaseState({ metaPath: node.metaPath, current: currentMeta, config, structureHash }, ps, updates);
10865
+ // Archive on full-cycle only
10866
+ if (cycleComplete) {
10867
+ await createSnapshot(node.metaPath, updatedMeta);
10868
+ await pruneArchive(node.metaPath, config.maxArchive);
10869
+ }
10708
10870
  await onProgress?.({
10709
10871
  type: 'phase_complete',
10710
10872
  path: node.ownerPath,
@@ -10712,139 +10874,144 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
10712
10874
  tokens: criticTokens,
10713
10875
  durationMs: Date.now() - criticStart,
10714
10876
  });
10877
+ return {
10878
+ executed: true,
10879
+ phaseState: ps,
10880
+ updatedMeta,
10881
+ cycleComplete,
10882
+ };
10715
10883
  }
10716
10884
  catch (err) {
10717
- stepError = stepError ?? toMetaError('critic', err);
10718
- }
10719
- // Steps 11-12: Merge, archive, prune
10720
- await finalizeCycle({
10721
- ...baseFinalizeOptions,
10722
- builder: builderBrief,
10723
- builderOutput,
10724
- feedback,
10725
- synthesisCount,
10726
- error: stepError,
10727
- architectTokens,
10728
- builderTokens,
10729
- criticTokens,
10730
- state: builderOutput.state,
10731
- });
10732
- return {
10733
- synthesized: true,
10734
- metaPath: node.metaPath,
10735
- error: stepError ?? undefined,
10736
- };
10885
+ return handlePhaseFailure('critic', err, executor, ps, {
10886
+ metaPath: node.metaPath,
10887
+ current: currentMeta,
10888
+ structureHash,
10889
+ });
10890
+ }
10737
10891
  }
10738
10892
 
10739
10893
  /**
10740
- * Main orchestration entry point — discovery, scheduling, candidate selection.
10894
+ * Phase-aware orchestration entry point.
10741
10895
  *
10742
- * @module orchestrator/orchestrate
10896
+ * Replaces the old staleness-based orchestrate() with phase-state-machine
10897
+ * scheduling: each tick discovers all metas, computes invalidation,
10898
+ * auto-retries failed phases, selects the best phase candidate, and
10899
+ * executes exactly one phase.
10900
+ *
10901
+ * @module orchestrator/orchestratePhase
10902
+ */
10903
+ /** Phase runner dispatch map — avoids repeating the same switch/case. */
10904
+ const phaseRunners = {
10905
+ architect: runArchitect,
10906
+ builder: runBuilder,
10907
+ critic: runCritic,
10908
+ };
10909
+ /**
10910
+ * Run a single phase-aware orchestration tick.
10911
+ *
10912
+ * When targetPath is provided (override entry), runs the owed phase for
10913
+ * that specific meta. Otherwise, discovers all metas, computes invalidation,
10914
+ * and selects the best phase candidate corpus-wide.
10743
10915
  */
10744
- async function orchestrateOnce(config, executor, watcher, targetPath, onProgress, logger) {
10745
- // When targetPath is provided, skip the expensive full discovery scan.
10746
- // Build a minimal node from the filesystem instead.
10916
+ async function orchestratePhase(config, executor, watcher, targetPath, onProgress, logger) {
10917
+ // ── Targeted path (override entry) ──
10747
10918
  if (targetPath) {
10748
- const normalizedTarget = normalizePath(targetPath);
10749
- const targetMetaJson = join(normalizedTarget, 'meta.json');
10750
- if (!existsSync(targetMetaJson))
10751
- return { synthesized: false };
10752
- const node = await buildMinimalNode(normalizedTarget, watcher);
10753
- if (!acquireLock(node.metaPath))
10754
- return { synthesized: false };
10755
- try {
10756
- const currentMeta = await readMetaJson(normalizedTarget);
10757
- return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress, logger);
10758
- }
10759
- finally {
10760
- releaseLock(node.metaPath);
10761
- }
10919
+ return orchestrateTargeted(config, executor, watcher, targetPath, onProgress, logger);
10762
10920
  }
10763
- // Full discovery path (scheduler-driven, no specific target)
10764
- // Step 1: Discover via watcher walk
10765
- const discoveryStart = Date.now();
10766
- const metaPaths = await discoverMetas(watcher);
10767
- logger?.debug({ paths: metaPaths.length, durationMs: Date.now() - discoveryStart }, 'discovery complete');
10768
- if (metaPaths.length === 0)
10769
- return { synthesized: false };
10770
- // Read meta.json for each discovered meta
10771
- const metas = new Map();
10772
- for (const mp of metaPaths) {
10773
- try {
10774
- metas.set(normalizePath(mp), await readMetaJson(mp));
10775
- }
10776
- catch {
10777
- // Skip metas with unreadable meta.json
10778
- continue;
10779
- }
10921
+ // ── Corpus-wide discovery + phase selection ──
10922
+ let metaResult;
10923
+ try {
10924
+ metaResult = await listMetas(config, watcher);
10780
10925
  }
10781
- // Only build tree from paths with readable meta.json (excludes orphaned/deleted entries)
10782
- const validPaths = metaPaths.filter((mp) => metas.has(normalizePath(mp)));
10783
- if (validPaths.length === 0)
10784
- return { synthesized: false };
10785
- const tree = buildOwnershipTree(validPaths);
10786
- // Steps 3-4: Staleness check + candidate selection
10787
- const candidates = [];
10788
- for (const treeNode of tree.nodes.values()) {
10789
- const meta = metas.get(treeNode.metaPath);
10790
- if (!meta)
10791
- continue;
10792
- const staleness = actualStaleness(meta);
10793
- if (staleness > 0) {
10794
- candidates.push({ node: treeNode, meta, actualStaleness: staleness });
10795
- }
10796
- }
10797
- const weighted = computeEffectiveStaleness(candidates, config.depthWeight);
10798
- // Sort by effective staleness descending
10799
- const ranked = [...weighted].sort((a, b) => b.effectiveStaleness - a.effectiveStaleness);
10800
- if (ranked.length === 0)
10801
- return { synthesized: false };
10802
- // Find the first candidate with actual changes (if skipUnchanged)
10803
- let winner = null;
10804
- for (const candidate of ranked) {
10805
- if (!acquireLock(candidate.node.metaPath))
10806
- continue;
10807
- const verifiedStale = await isStale(getScopePrefix(candidate.node), candidate.meta, watcher);
10808
- if (!verifiedStale && candidate.meta._generatedAt) {
10809
- // Bump _generatedAt so it doesn't win next cycle
10810
- const freshMeta = await readMetaJson(candidate.node.metaPath);
10811
- freshMeta._generatedAt = new Date().toISOString();
10812
- await writeFile(join(candidate.node.metaPath, 'meta.json'), JSON.stringify(freshMeta, null, 2));
10813
- releaseLock(candidate.node.metaPath);
10814
- if (config.skipUnchanged)
10815
- continue;
10816
- return { synthesized: false };
10926
+ catch (err) {
10927
+ logger?.warn({ err }, 'Failed to list metas for phase selection');
10928
+ return { executed: false };
10929
+ }
10930
+ if (metaResult.entries.length === 0)
10931
+ return { executed: false };
10932
+ // Build candidates with phase state (including invalidation + auto-retry)
10933
+ const candidates = buildPhaseCandidates(metaResult.entries);
10934
+ // Select best phase candidate
10935
+ const winner = selectPhaseCandidate(candidates, config.depthWeight);
10936
+ if (!winner) {
10937
+ return { executed: false };
10938
+ }
10939
+ // Acquire lock
10940
+ if (!acquireLock(winner.node.metaPath)) {
10941
+ logger?.debug({ path: winner.node.metaPath }, 'Selected candidate is locked, skipping');
10942
+ return { executed: false };
10943
+ }
10944
+ try {
10945
+ // Re-read meta under lock for freshness
10946
+ const currentMeta = await readMetaJson(winner.node.metaPath);
10947
+ const phaseState = retryAllFailed(derivePhaseState(currentMeta));
10948
+ const owedPhase = getOwedPhase(phaseState);
10949
+ if (!owedPhase || phaseState[owedPhase] !== 'pending') {
10950
+ // Nothing to do (race: became fresh between selection and lock)
10951
+ return { executed: false };
10952
+ }
10953
+ // Compute structure hash for the phase
10954
+ const { scopeFiles } = await getScopeFiles(winner.node, watcher);
10955
+ const structureHash = computeStructureHash(scopeFiles);
10956
+ // skipUnchanged: bump _generatedAt without altering _phaseState
10957
+ if (config.skipUnchanged && currentMeta._generatedAt) {
10958
+ const verifiedStale = await isStale(getScopePrefix(winner.node), currentMeta, watcher);
10959
+ if (!verifiedStale) {
10960
+ await persistPhaseState({
10961
+ metaPath: winner.node.metaPath,
10962
+ current: currentMeta,
10963
+ config,
10964
+ structureHash,
10965
+ }, phaseState, { _generatedAt: new Date().toISOString() });
10966
+ logger?.debug({ path: winner.node.ownerPath }, 'Skipped unchanged meta, bumped _generatedAt');
10967
+ return { executed: false };
10968
+ }
10817
10969
  }
10818
- winner = candidate;
10819
- break;
10970
+ return await executePhase(winner.node, currentMeta, phaseState, owedPhase, config, executor, watcher, structureHash, onProgress, logger);
10971
+ }
10972
+ finally {
10973
+ releaseLock(winner.node.metaPath);
10974
+ }
10975
+ }
10976
+ /**
10977
+ * Orchestrate a targeted (override) meta path.
10978
+ * Resolves the owed phase at execution time (not enqueue time).
10979
+ */
10980
+ async function orchestrateTargeted(config, executor, watcher, targetPath, onProgress, logger) {
10981
+ const normalizedTarget = normalizePath(targetPath);
10982
+ const node = await buildMinimalNode(normalizedTarget, watcher);
10983
+ if (!acquireLock(node.metaPath)) {
10984
+ return { executed: false };
10820
10985
  }
10821
- if (!winner)
10822
- return { synthesized: false };
10823
- const node = winner.node;
10824
10986
  try {
10825
- const currentMeta = await readMetaJson(node.metaPath);
10826
- return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress, logger);
10987
+ const currentMeta = await readMetaJson(normalizedTarget);
10988
+ const phaseState = retryAllFailed(derivePhaseState(currentMeta));
10989
+ const owedPhase = getOwedPhase(phaseState);
10990
+ if (!owedPhase) {
10991
+ // Fully fresh — override is a no-op (silently dropped per spec)
10992
+ return { executed: false, metaPath: normalizedTarget };
10993
+ }
10994
+ // Compute structure hash
10995
+ const { scopeFiles } = await getScopeFiles(node, watcher);
10996
+ const structureHash = computeStructureHash(scopeFiles);
10997
+ return await executePhase(node, currentMeta, phaseState, owedPhase, config, executor, watcher, structureHash, onProgress, logger);
10827
10998
  }
10828
10999
  finally {
10829
- // Step 13: Release lock
10830
11000
  releaseLock(node.metaPath);
10831
11001
  }
10832
11002
  }
10833
11003
  /**
10834
- * Run a single synthesis cycle.
10835
- *
10836
- * Selects the stalest candidate (or a specific target) and runs the
10837
- * full architect/builder/critic pipeline.
10838
- *
10839
- * @param config - Validated synthesis config.
10840
- * @param executor - Pluggable LLM executor.
10841
- * @param watcher - Watcher HTTP client.
10842
- * @param targetPath - Optional: specific meta/owner path to synthesize instead of stalest candidate.
10843
- * @returns Array with a single result.
11004
+ * Execute exactly one phase on a meta.
10844
11005
  */
10845
- async function orchestrate(config, executor, watcher, targetPath, onProgress, logger) {
10846
- const result = await orchestrateOnce(config, executor, watcher, targetPath, onProgress, logger);
10847
- return [result];
11006
+ async function executePhase(node, currentMeta, phaseState, phase, config, executor, watcher, structureHash, onProgress, logger) {
11007
+ const result = await phaseRunners[phase](node, currentMeta, phaseState, config, executor, watcher, structureHash, onProgress, logger);
11008
+ return {
11009
+ executed: true,
11010
+ metaPath: node.metaPath,
11011
+ phase,
11012
+ phaseResult: result,
11013
+ cycleComplete: result.cycleComplete,
11014
+ };
10848
11015
  }
10849
11016
 
10850
11017
  /**
@@ -10981,42 +11148,106 @@ class ProgressReporter {
10981
11148
  }
10982
11149
 
10983
11150
  /**
10984
- * Single-threaded synthesis queue with priority support and deduplication.
11151
+ * Hybrid 3-layer synthesis queue.
11152
+ *
11153
+ * Layer 1: Current — the single item currently executing (at most one).
11154
+ * Layer 2: Overrides — items manually enqueued via POST /synthesize with path.
11155
+ * FIFO among overrides, ahead of automatic candidates.
11156
+ * Layer 3: Automatic — computed on read, not persisted. All metas with a
11157
+ * pending phase, ranked by scheduler priority.
10985
11158
  *
10986
- * The scheduler enqueues the stalest candidate each tick. HTTP-triggered
10987
- * synthesis requests get priority (inserted at front). A path appears at
10988
- * most once in the queue; re-triggering returns the current position.
11159
+ * Legacy: `pending` array is the union of overrides + automatic.
10989
11160
  *
10990
11161
  * @module queue
10991
11162
  */
10992
11163
  const DEPTH_WARNING_THRESHOLD = 3;
10993
11164
  /**
10994
- * Single-threaded synthesis queue.
11165
+ * Hybrid 3-layer synthesis queue.
10995
11166
  *
10996
- * Only one synthesis runs at a time. Priority items are inserted at the
10997
- * front of the queue. Duplicate paths are rejected with their current
10998
- * position returned.
11167
+ * Only one synthesis runs at a time. Override items (explicit triggers)
11168
+ * take priority over automatic candidates.
10999
11169
  */
11000
11170
  class SynthesisQueue {
11171
+ /** Legacy queue (used by processQueue for backward compat). */
11001
11172
  queue = [];
11002
11173
  currentItem = null;
11003
11174
  processing = false;
11004
11175
  logger;
11005
11176
  onEnqueueCallback = null;
11006
- /**
11007
- * Create a new SynthesisQueue.
11008
- *
11009
- * @param logger - Pino logger instance.
11010
- */
11177
+ /** Explicit override entries (3-layer model). */
11178
+ overrideEntries = [];
11179
+ /** Currently executing item with phase info (3-layer model). */
11180
+ currentPhaseItem = null;
11011
11181
  constructor(logger) {
11012
11182
  this.logger = logger;
11013
11183
  }
11014
- /**
11015
- * Set a callback to invoke when a new (non-duplicate) item is enqueued.
11016
- */
11184
+ /** Set a callback to invoke when a new (non-duplicate) item is enqueued. */
11017
11185
  onEnqueue(callback) {
11018
11186
  this.onEnqueueCallback = callback;
11019
11187
  }
11188
+ // ── Override layer (3-layer model) ─────────────────────────────────
11189
+ /**
11190
+ * Add an explicit override entry (from POST /synthesize with path).
11191
+ * Deduped by path. Returns position and whether already queued.
11192
+ */
11193
+ enqueueOverride(path) {
11194
+ // Check if currently executing
11195
+ if (this.currentPhaseItem?.path === path ||
11196
+ this.currentItem?.path === path) {
11197
+ return { position: 0, alreadyQueued: true };
11198
+ }
11199
+ // Check if already in overrides
11200
+ const existing = this.overrideEntries.findIndex((e) => e.path === path);
11201
+ if (existing !== -1) {
11202
+ return { position: existing, alreadyQueued: true };
11203
+ }
11204
+ this.overrideEntries.push({
11205
+ path,
11206
+ enqueuedAt: new Date().toISOString(),
11207
+ });
11208
+ const position = this.overrideEntries.length - 1;
11209
+ if (this.overrideEntries.length > DEPTH_WARNING_THRESHOLD) {
11210
+ this.logger.warn({ depth: this.overrideEntries.length }, 'Override queue depth exceeds threshold');
11211
+ }
11212
+ this.onEnqueueCallback?.();
11213
+ return { position, alreadyQueued: false };
11214
+ }
11215
+ /** Dequeue the next override entry, or undefined if empty. */
11216
+ dequeueOverride() {
11217
+ return this.overrideEntries.shift();
11218
+ }
11219
+ /** Get all override entries (shallow copy). */
11220
+ get overrides() {
11221
+ return [...this.overrideEntries];
11222
+ }
11223
+ /** Clear all override entries. Returns count removed. */
11224
+ clearOverrides() {
11225
+ const count = this.overrideEntries.length;
11226
+ this.overrideEntries = [];
11227
+ return count;
11228
+ }
11229
+ /** Check if a path is in the override layer. */
11230
+ hasOverride(path) {
11231
+ return this.overrideEntries.some((e) => e.path === path);
11232
+ }
11233
+ // ── Current-item tracking (3-layer model) ──────────────────────────
11234
+ /** Set the currently executing phase item. */
11235
+ setCurrentPhase(path, phase) {
11236
+ this.currentPhaseItem = {
11237
+ path,
11238
+ phase,
11239
+ startedAt: new Date().toISOString(),
11240
+ };
11241
+ }
11242
+ /** Clear the current phase item. */
11243
+ clearCurrentPhase() {
11244
+ this.currentPhaseItem = null;
11245
+ }
11246
+ /** The currently executing phase item, or null. */
11247
+ get currentPhase() {
11248
+ return this.currentPhaseItem;
11249
+ }
11250
+ // ── Legacy queue interface (preserved for backward compat) ─────────
11020
11251
  /**
11021
11252
  * Add a path to the synthesis queue.
11022
11253
  *
@@ -11025,11 +11256,9 @@ class SynthesisQueue {
11025
11256
  * @returns Position and whether the path was already queued.
11026
11257
  */
11027
11258
  enqueue(path, priority = false) {
11028
- // Check if currently being synthesized.
11029
11259
  if (this.currentItem?.path === path) {
11030
11260
  return { position: 0, alreadyQueued: true };
11031
11261
  }
11032
- // Check if already in queue.
11033
11262
  const existingIndex = this.queue.findIndex((item) => item.path === path);
11034
11263
  if (existingIndex !== -1) {
11035
11264
  return { position: existingIndex, alreadyQueued: true };
@@ -11052,11 +11281,7 @@ class SynthesisQueue {
11052
11281
  this.onEnqueueCallback?.();
11053
11282
  return { position, alreadyQueued: false };
11054
11283
  }
11055
- /**
11056
- * Remove and return the next item from the queue.
11057
- *
11058
- * @returns The next QueueItem, or undefined if the queue is empty.
11059
- */
11284
+ /** Remove and return the next item from the queue. */
11060
11285
  dequeue() {
11061
11286
  const item = this.queue.shift();
11062
11287
  if (item) {
@@ -11086,7 +11311,6 @@ class SynthesisQueue {
11086
11311
  }
11087
11312
  /**
11088
11313
  * Remove all pending items from the queue.
11089
- *
11090
11314
  * Does not affect the currently-running item.
11091
11315
  *
11092
11316
  * @returns The number of items removed.
@@ -11096,45 +11320,56 @@ class SynthesisQueue {
11096
11320
  this.queue = [];
11097
11321
  return count;
11098
11322
  }
11099
- /**
11100
- * Check whether a path is in the queue or currently being synthesized.
11101
- *
11102
- * @param path - Meta path to look up.
11103
- * @returns True if the path is queued or currently running.
11104
- */
11323
+ /** Check whether a path is in the queue or currently being synthesized. */
11105
11324
  has(path) {
11106
11325
  if (this.currentItem?.path === path)
11107
11326
  return true;
11108
- return this.queue.some((item) => item.path === path);
11327
+ if (this.currentPhaseItem?.path === path)
11328
+ return true;
11329
+ return (this.queue.some((item) => item.path === path) ||
11330
+ this.overrideEntries.some((e) => e.path === path));
11109
11331
  }
11110
- /**
11111
- * Get the 0-indexed position of a path in the queue.
11112
- *
11113
- * @param path - Meta path to look up.
11114
- * @returns Position index, or null if not found in the queue.
11115
- */
11332
+ /** Get the 0-indexed position of a path in the queue. */
11116
11333
  getPosition(path) {
11334
+ // Check overrides first
11335
+ const overrideIdx = this.overrideEntries.findIndex((e) => e.path === path);
11336
+ if (overrideIdx !== -1)
11337
+ return overrideIdx;
11117
11338
  const index = this.queue.findIndex((item) => item.path === path);
11118
11339
  return index === -1 ? null : index;
11119
11340
  }
11120
- /**
11121
- * Return a snapshot of queue state for the /status endpoint.
11122
- *
11123
- * @returns Queue depth and item list.
11124
- */
11341
+ /** Dequeue the next item: overrides first, then legacy queue. */
11342
+ nextItem() {
11343
+ const override = this.dequeueOverride();
11344
+ if (override)
11345
+ return { path: override.path, source: 'override' };
11346
+ const item = this.dequeue();
11347
+ if (item)
11348
+ return { path: item.path, source: 'legacy' };
11349
+ return undefined;
11350
+ }
11351
+ /** Return a snapshot of queue state for the /status endpoint. */
11125
11352
  getState() {
11126
11353
  return {
11127
- depth: this.queue.length,
11128
- items: this.queue.map((item) => ({
11129
- path: item.path,
11130
- priority: item.priority,
11131
- enqueuedAt: item.enqueuedAt,
11132
- })),
11354
+ depth: this.queue.length + this.overrideEntries.length,
11355
+ items: [
11356
+ ...this.overrideEntries.map((e) => ({
11357
+ path: e.path,
11358
+ priority: true,
11359
+ enqueuedAt: e.enqueuedAt,
11360
+ })),
11361
+ ...this.queue.map((item) => ({
11362
+ path: item.path,
11363
+ priority: item.priority,
11364
+ enqueuedAt: item.enqueuedAt,
11365
+ })),
11366
+ ],
11133
11367
  };
11134
11368
  }
11135
11369
  /**
11136
- * Process queued items one at a time until the queue is empty.
11370
+ * Process queued items one at a time until all queues are empty.
11137
11371
  *
11372
+ * Override entries are processed first (FIFO), then legacy queue items.
11138
11373
  * Re-entry is prevented: if already processing, the call returns
11139
11374
  * immediately. Errors are logged and do not block subsequent items.
11140
11375
  *
@@ -11145,19 +11380,22 @@ class SynthesisQueue {
11145
11380
  return;
11146
11381
  this.processing = true;
11147
11382
  try {
11148
- let item = this.dequeue();
11149
- while (item) {
11383
+ let next = this.nextItem();
11384
+ while (next) {
11150
11385
  try {
11151
- await synthesizeFn(item.path);
11386
+ await synthesizeFn(next.path);
11152
11387
  }
11153
11388
  catch (err) {
11154
- this.logger.error({ path: item.path, err }, 'Synthesis failed');
11389
+ this.logger.error({ path: next.path, err }, 'Synthesis failed');
11155
11390
  }
11156
- this.complete();
11157
- item = this.dequeue();
11391
+ this.clearCurrentPhase();
11392
+ if (next.source === 'legacy')
11393
+ this.complete();
11394
+ next = this.nextItem();
11158
11395
  }
11159
11396
  }
11160
11397
  finally {
11398
+ this.clearCurrentPhase();
11161
11399
  this.processing = false;
11162
11400
  }
11163
11401
  }
@@ -11629,14 +11867,15 @@ async function autoSeedPass(rules, watcher, logger) {
11629
11867
  }
11630
11868
 
11631
11869
  /**
11632
- * Croner-based scheduler that discovers the stalest meta candidate each tick
11633
- * and enqueues it for synthesis.
11870
+ * Croner-based scheduler that discovers the highest-priority ready phase
11871
+ * across the corpus each tick and enqueues it for execution.
11634
11872
  *
11635
11873
  * @module scheduler
11636
11874
  */
11637
11875
  const MAX_BACKOFF_MULTIPLIER = 4;
11638
11876
  /**
11639
- * Periodic scheduler that discovers stale meta candidates and enqueues them.
11877
+ * Periodic scheduler that discovers the highest-priority ready phase
11878
+ * across all metas and enqueues it for execution.
11640
11879
  *
11641
11880
  * Supports adaptive backoff when no candidates are found and hot-reloadable
11642
11881
  * cron expressions via {@link Scheduler.updateSchedule}.
@@ -11691,10 +11930,10 @@ class Scheduler {
11691
11930
  this.logger.info({ schedule: expression }, 'Schedule updated');
11692
11931
  }
11693
11932
  }
11694
- /** Reset backoff multiplier (call after successful synthesis). */
11933
+ /** Reset backoff multiplier (call after successful phase execution). */
11695
11934
  resetBackoff() {
11696
11935
  if (this.backoffMultiplier > 1) {
11697
- this.logger.debug('Backoff reset after successful synthesis');
11936
+ this.logger.debug('Backoff reset after successful phase execution');
11698
11937
  }
11699
11938
  this.backoffMultiplier = 1;
11700
11939
  }
@@ -11709,10 +11948,9 @@ class Scheduler {
11709
11948
  return this.job.nextRun() ?? null;
11710
11949
  }
11711
11950
  /**
11712
- * Single tick: discover stalest candidate and enqueue it.
11951
+ * Single tick: discover the highest-priority ready phase and enqueue it.
11713
11952
  *
11714
- * Skips if the queue is currently processing. Applies adaptive backoff
11715
- * when no candidates are found.
11953
+ * Applies adaptive backoff when no candidates are found.
11716
11954
  */
11717
11955
  async tick() {
11718
11956
  this.tickCount++;
@@ -11737,14 +11975,15 @@ class Scheduler {
11737
11975
  this.logger.warn({ err }, 'Auto-seed pass failed');
11738
11976
  }
11739
11977
  }
11740
- const candidate = await this.discoverStalest();
11978
+ const candidate = await this.discoverNextPhase();
11741
11979
  if (!candidate) {
11742
11980
  this.backoffMultiplier = Math.min(this.backoffMultiplier * 2, MAX_BACKOFF_MULTIPLIER);
11743
- this.logger.debug({ backoffMultiplier: this.backoffMultiplier }, 'No stale candidates found, increasing backoff');
11981
+ this.logger.debug({ backoffMultiplier: this.backoffMultiplier }, 'No ready phases found, increasing backoff');
11744
11982
  return;
11745
11983
  }
11746
- this.queue.enqueue(candidate);
11747
- this.logger.info({ path: candidate }, 'Enqueued stale candidate');
11984
+ // Enqueue using the legacy queue path (backward compat with processQueue)
11985
+ this.queue.enqueue(candidate.path);
11986
+ this.logger.info({ path: candidate.path, phase: candidate.phase, band: candidate.band }, 'Enqueued phase candidate');
11748
11987
  // Opportunistic watcher restart detection
11749
11988
  if (this.registrar) {
11750
11989
  try {
@@ -11764,22 +12003,27 @@ class Scheduler {
11764
12003
  }
11765
12004
  }
11766
12005
  /**
11767
- * Discover the stalest meta candidate via watcher.
12006
+ * Discover the highest-priority ready phase across the corpus.
12007
+ *
12008
+ * Uses phase-state-aware scheduling: priority order is
12009
+ * critic (band 1) \> builder (band 2) \> architect (band 3),
12010
+ * with weighted staleness as tiebreaker within a band.
11768
12011
  */
11769
- async discoverStalest() {
12012
+ async discoverNextPhase() {
11770
12013
  try {
11771
12014
  const result = await listMetas(this.config, this.watcher);
11772
- const stale = result.entries
11773
- .filter((e) => e.stalenessSeconds > 0 && !e.disabled)
11774
- .map((e) => ({
11775
- node: e.node,
11776
- meta: e.meta,
11777
- actualStaleness: e.stalenessSeconds,
11778
- }));
11779
- return discoverStalestPath(stale, this.config.depthWeight);
12015
+ const candidates = buildPhaseCandidates(result.entries);
12016
+ const winner = selectPhaseCandidate(candidates, this.config.depthWeight);
12017
+ if (!winner)
12018
+ return null;
12019
+ return {
12020
+ path: winner.node.metaPath,
12021
+ phase: winner.owedPhase,
12022
+ band: winner.band,
12023
+ };
11780
12024
  }
11781
12025
  catch (err) {
11782
- this.logger.warn({ err }, 'Failed to discover stalest candidate');
12026
+ this.logger.warn({ err }, 'Failed to discover next phase candidate');
11783
12027
  return null;
11784
12028
  }
11785
12029
  }
@@ -11985,9 +12229,12 @@ function registerMetasRoutes(app, deps) {
11985
12229
  'architectTokens',
11986
12230
  'builderTokens',
11987
12231
  'criticTokens',
12232
+ 'phaseState',
12233
+ 'owedPhase',
11988
12234
  ];
11989
12235
  const projectedFields = fieldList ?? defaultFields;
11990
12236
  const metas = entries.map((e) => {
12237
+ const ps = derivePhaseState(e.meta);
11991
12238
  const full = {
11992
12239
  path: e.path,
11993
12240
  depth: e.depth,
@@ -12002,6 +12249,8 @@ function registerMetasRoutes(app, deps) {
12002
12249
  architectTokens: e.architectTokens,
12003
12250
  builderTokens: e.builderTokens,
12004
12251
  criticTokens: e.criticTokens,
12252
+ phaseState: ps,
12253
+ owedPhase: getOwedPhase(ps),
12005
12254
  };
12006
12255
  const projected = {};
12007
12256
  for (const f of projectedFields) {
@@ -12026,13 +12275,6 @@ function registerMetasRoutes(app, deps) {
12026
12275
  }
12027
12276
  const meta = JSON.parse(await readFile(join(targetNode.metaPath, 'meta.json'), 'utf8'));
12028
12277
  // Field projection
12029
- const defaultExclude = new Set([
12030
- '_architect',
12031
- '_builder',
12032
- '_critic',
12033
- '_content',
12034
- '_feedback',
12035
- ]);
12036
12278
  const fieldList = query.fields?.split(',');
12037
12279
  const projectMeta = (m) => {
12038
12280
  if (fieldList) {
@@ -12043,7 +12285,7 @@ function registerMetasRoutes(app, deps) {
12043
12285
  }
12044
12286
  const r = {};
12045
12287
  for (const [k, v] of Object.entries(m)) {
12046
- if (!defaultExclude.has(k))
12288
+ if (!DEFAULT_EXCLUDE_FIELDS.has(k))
12047
12289
  r[k] = v;
12048
12290
  }
12049
12291
  return r;
@@ -12056,6 +12298,10 @@ function registerMetasRoutes(app, deps) {
12056
12298
  ? Math.round((Date.now() - new Date(metaTyped._generatedAt).getTime()) / 1000)
12057
12299
  : null;
12058
12300
  const score = computeStalenessScore(staleSeconds, metaTyped._depth ?? 0, metaTyped._emphasis ?? 1, config.depthWeight);
12301
+ // Phase state
12302
+ const entry = result.entries.find((e) => e.node.metaPath === targetNode.metaPath);
12303
+ const phaseState = entry ? derivePhaseState(entry.meta) : null;
12304
+ const owedPhase = phaseState ? getOwedPhase(phaseState) : null;
12059
12305
  const response = {
12060
12306
  path: targetNode.metaPath,
12061
12307
  meta: projectMeta(meta),
@@ -12068,6 +12314,8 @@ function registerMetasRoutes(app, deps) {
12068
12314
  seconds: staleSeconds,
12069
12315
  score: Math.round(score * 100) / 100,
12070
12316
  },
12317
+ phaseState,
12318
+ owedPhase,
12071
12319
  };
12072
12320
  // Cross-refs status
12073
12321
  const crossRefsRaw = meta._crossRefs;
@@ -12170,16 +12418,9 @@ function registerMetasUpdateRoute(app, deps) {
12170
12418
  Object.assign(updated, toSet);
12171
12419
  await writeFile(metaJsonPath, JSON.stringify(updated, null, 2) + '\n');
12172
12420
  // Project the response — exclude the same large fields as the detail route.
12173
- const defaultExclude = new Set([
12174
- '_architect',
12175
- '_builder',
12176
- '_critic',
12177
- '_content',
12178
- '_feedback',
12179
- ]);
12180
12421
  const projected = {};
12181
12422
  for (const [k, v] of Object.entries(updated)) {
12182
- if (!defaultExclude.has(k))
12423
+ if (!DEFAULT_EXCLUDE_FIELDS.has(k))
12183
12424
  projected[k] = v;
12184
12425
  }
12185
12426
  return reply.send({
@@ -12241,6 +12482,19 @@ function registerPreviewRoute(app, deps) {
12241
12482
  const structureChanged = structureHash !== meta._structureHash;
12242
12483
  const latestArchive = await readLatestArchive(targetNode.metaPath);
12243
12484
  const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
12485
+ // _architect change detection
12486
+ const architectChanged = latestArchive
12487
+ ? (meta._architect ?? '') !== (latestArchive._architect ?? '')
12488
+ : Boolean(meta._architect);
12489
+ // _crossRefs declaration change detection
12490
+ const currentRefs = (meta._crossRefs ?? []).slice().sort().join(',');
12491
+ const archiveRefs = (latestArchive?._crossRefs ?? [])
12492
+ .slice()
12493
+ .sort()
12494
+ .join(',');
12495
+ const crossRefsDeclChanged = latestArchive
12496
+ ? currentRefs !== archiveRefs
12497
+ : currentRefs.length > 0;
12244
12498
  const architectTriggered = isArchitectTriggered(meta, structureChanged, steerChanged, config.architectEvery);
12245
12499
  // Delta files
12246
12500
  const deltaFiles = getDeltaFiles(meta._generatedAt, scopeFiles);
@@ -12255,6 +12509,40 @@ function registerPreviewRoute(app, deps) {
12255
12509
  ? Math.round((Date.now() - new Date(meta._generatedAt).getTime()) / 1000)
12256
12510
  : null;
12257
12511
  const stalenessScore = computeStalenessScore(stalenessSeconds, meta._depth ?? 0, meta._emphasis ?? 1, config.depthWeight);
12512
+ // Phase state
12513
+ const phaseState = derivePhaseState(meta, {
12514
+ structureChanged,
12515
+ steerChanged,
12516
+ architectChanged,
12517
+ crossRefsChanged: crossRefsDeclChanged,
12518
+ architectEvery: config.architectEvery,
12519
+ });
12520
+ const owedPhase = getOwedPhase(phaseState);
12521
+ const priorityBand = getPriorityBand(phaseState);
12522
+ // Architect invalidators
12523
+ const architectInvalidators = [];
12524
+ if (owedPhase === 'architect') {
12525
+ if (structureChanged)
12526
+ architectInvalidators.push('structureHash');
12527
+ if (steerChanged)
12528
+ architectInvalidators.push('steer');
12529
+ if (architectChanged)
12530
+ architectInvalidators.push('_architect');
12531
+ if (crossRefsDeclChanged)
12532
+ architectInvalidators.push('_crossRefs');
12533
+ if ((meta._synthesisCount ?? 0) >= config.architectEvery) {
12534
+ architectInvalidators.push('architectEvery');
12535
+ }
12536
+ }
12537
+ // Staleness inputs
12538
+ const stalenessInputs = {
12539
+ structureHash,
12540
+ steerChanged,
12541
+ architectChanged,
12542
+ crossRefsDeclChanged,
12543
+ scopeMtimeMax: null,
12544
+ crossRefContentChanged: false,
12545
+ };
12258
12546
  return {
12259
12547
  path: targetNode.metaPath,
12260
12548
  staleness: {
@@ -12279,6 +12567,12 @@ function registerPreviewRoute(app, deps) {
12279
12567
  deltaCount: deltaFiles.length,
12280
12568
  },
12281
12569
  estimatedTokens,
12570
+ // New phase-state fields (additive)
12571
+ owedPhase,
12572
+ priorityBand,
12573
+ phaseState,
12574
+ stalenessInputs,
12575
+ architectInvalidators,
12282
12576
  };
12283
12577
  });
12284
12578
  }
@@ -12286,8 +12580,8 @@ function registerPreviewRoute(app, deps) {
12286
12580
  /**
12287
12581
  * Queue management and abort routes.
12288
12582
  *
12289
- * - GET /queue — current queue state
12290
- * - POST /queue/clear — remove all pending items
12583
+ * - GET /queue — 3-layer queue model (current, overrides, automatic, pending)
12584
+ * - POST /queue/clear — remove override entries only
12291
12585
  * - POST /synthesize/abort — abort the current synthesis
12292
12586
  *
12293
12587
  * @module routes/queue
@@ -12295,33 +12589,134 @@ function registerPreviewRoute(app, deps) {
12295
12589
  /** Register queue management routes. */
12296
12590
  function registerQueueRoutes(app, deps) {
12297
12591
  const { queue } = deps;
12298
- app.get('/queue', () => ({
12299
- current: queue.current,
12300
- pending: queue.pending,
12301
- state: queue.getState(),
12302
- }));
12592
+ app.get('/queue', async () => {
12593
+ const currentPhase = queue.currentPhase;
12594
+ const overrides = queue.overrides;
12595
+ // Compute owedPhase for each override entry by reading meta state
12596
+ const enrichedOverrides = await Promise.all(overrides.map(async (o) => {
12597
+ try {
12598
+ const metaDir = resolveMetaDir(o.path);
12599
+ const meta = await readMetaJson(metaDir);
12600
+ const ps = derivePhaseState(meta);
12601
+ return {
12602
+ path: o.path,
12603
+ owedPhase: getOwedPhase(ps),
12604
+ enqueuedAt: o.enqueuedAt,
12605
+ };
12606
+ }
12607
+ catch {
12608
+ return {
12609
+ path: o.path,
12610
+ owedPhase: null,
12611
+ enqueuedAt: o.enqueuedAt,
12612
+ };
12613
+ }
12614
+ }));
12615
+ // Compute automatic layer: all metas with a pending owed phase,
12616
+ // ranked by scheduler priority (computed on read, not persisted)
12617
+ let automatic = [];
12618
+ try {
12619
+ const metaResult = await listMetas(deps.config, deps.watcher);
12620
+ const candidates = buildPhaseCandidates(metaResult.entries);
12621
+ const ranked = rankPhaseCandidates(candidates, deps.config.depthWeight);
12622
+ automatic = ranked.map((c) => ({
12623
+ path: c.node.metaPath,
12624
+ owedPhase: c.owedPhase,
12625
+ priorityBand: c.band,
12626
+ effectiveStaleness: c.effectiveStaleness,
12627
+ }));
12628
+ }
12629
+ catch {
12630
+ // If listing fails, automatic stays empty
12631
+ }
12632
+ // Legacy: pending is the union of overrides + automatic + legacy queue items
12633
+ const pendingItems = [
12634
+ ...enrichedOverrides.map((o) => ({
12635
+ path: o.path,
12636
+ owedPhase: o.owedPhase,
12637
+ })),
12638
+ ...automatic.map((a) => ({
12639
+ path: a.path,
12640
+ owedPhase: a.owedPhase,
12641
+ })),
12642
+ ...queue.pending.map((item) => ({
12643
+ path: item.path,
12644
+ owedPhase: null,
12645
+ })),
12646
+ ];
12647
+ return {
12648
+ current: currentPhase
12649
+ ? {
12650
+ path: currentPhase.path,
12651
+ phase: currentPhase.phase,
12652
+ startedAt: currentPhase.startedAt,
12653
+ }
12654
+ : queue.current
12655
+ ? {
12656
+ path: queue.current.path,
12657
+ phase: null,
12658
+ startedAt: queue.current.enqueuedAt,
12659
+ }
12660
+ : null,
12661
+ overrides: enrichedOverrides,
12662
+ automatic,
12663
+ pending: pendingItems,
12664
+ // Legacy state
12665
+ state: queue.getState(),
12666
+ };
12667
+ });
12303
12668
  app.post('/queue/clear', () => {
12304
- const removed = queue.clear();
12669
+ const removed = queue.clearOverrides();
12305
12670
  return { cleared: removed };
12306
12671
  });
12307
12672
  app.post('/synthesize/abort', async (_request, reply) => {
12308
- const current = queue.current;
12673
+ // Check 3-layer current first
12674
+ const currentPhase = queue.currentPhase;
12675
+ const current = currentPhase ?? queue.current;
12309
12676
  if (!current) {
12310
- return reply
12311
- .status(404)
12312
- .send({ error: 'NOT_FOUND', message: 'No synthesis in progress' });
12677
+ return reply.status(200).send({ status: 'idle' });
12313
12678
  }
12314
12679
  // Abort the executor
12315
12680
  deps.executor?.abort();
12681
+ const metaDir = resolveMetaDir(current.path);
12682
+ const phase = currentPhase?.phase ?? null;
12683
+ // Transition running phase to failed and write _error to meta.json
12684
+ if (phase) {
12685
+ try {
12686
+ const meta = await readMetaJson(metaDir);
12687
+ let ps = derivePhaseState(meta);
12688
+ ps = phaseFailed(ps, phase);
12689
+ const updated = {
12690
+ ...meta,
12691
+ _phaseState: ps,
12692
+ _error: {
12693
+ step: phase,
12694
+ code: 'ABORT',
12695
+ message: 'Aborted by operator',
12696
+ },
12697
+ };
12698
+ const lockPath = join(metaDir, '.lock');
12699
+ const metaJsonPath = join(metaDir, 'meta.json');
12700
+ await writeFile(lockPath, JSON.stringify(updated, null, 2) + '\n');
12701
+ await copyFile(lockPath, metaJsonPath);
12702
+ }
12703
+ catch {
12704
+ // Best-effort — meta may be unreadable
12705
+ }
12706
+ }
12316
12707
  // Release the lock for the current meta path
12317
12708
  try {
12318
- releaseLock(resolveMetaDir(current.path));
12709
+ releaseLock(metaDir);
12319
12710
  }
12320
12711
  catch {
12321
12712
  // Lock may already be released
12322
12713
  }
12323
12714
  deps.logger.info({ path: current.path }, 'Synthesis aborted');
12324
- return { status: 'aborted', path: current.path };
12715
+ return {
12716
+ status: 'aborted',
12717
+ path: current.path,
12718
+ ...(phase ? { phase } : {}),
12719
+ };
12325
12720
  });
12326
12721
  }
12327
12722
 
@@ -12406,7 +12801,6 @@ async function checkDependency(url, path) {
12406
12801
  return { url, status: 'unreachable', checkedAt };
12407
12802
  }
12408
12803
  }
12409
- /** Check watcher, surfacing initialScan.active as indexing state. */
12410
12804
  async function checkWatcher(url) {
12411
12805
  const checkedAt = new Date().toISOString();
12412
12806
  try {
@@ -12432,26 +12826,61 @@ async function checkWatcher(url) {
12432
12826
  function deriveServiceState(deps) {
12433
12827
  if (deps.shuttingDown)
12434
12828
  return 'stopping';
12435
- if (deps.queue.current)
12829
+ if (deps.queue.current || deps.queue.currentPhase)
12436
12830
  return 'synthesizing';
12437
- if (deps.queue.depth > 0)
12831
+ if (deps.queue.depth > 0 || deps.queue.overrides.length > 0)
12438
12832
  return 'waiting';
12439
12833
  return 'idle';
12440
12834
  }
12835
+ function emptyPhaseCounts() {
12836
+ return { fresh: 0, stale: 0, pending: 0, running: 0, failed: 0 };
12837
+ }
12441
12838
  function registerStatusRoute(app, deps) {
12442
12839
  const statusHandler = createStatusHandler({
12443
12840
  name: SERVICE_NAME,
12444
12841
  version: SERVICE_VERSION,
12445
12842
  getHealth: async () => {
12446
- const { config, queue, scheduler, stats } = deps;
12843
+ const { config, queue, scheduler, stats, watcher } = deps;
12447
12844
  // On-demand dependency checks
12448
12845
  const [watcherHealth, gatewayHealth] = await Promise.all([
12449
12846
  checkWatcher(config.watcherUrl),
12450
12847
  checkDependency(config.gatewayUrl, '/status'),
12451
12848
  ]);
12849
+ // Phase state summary
12850
+ const phaseStateSummary = {
12851
+ architect: emptyPhaseCounts(),
12852
+ builder: emptyPhaseCounts(),
12853
+ critic: emptyPhaseCounts(),
12854
+ };
12855
+ let nextPhase = null;
12856
+ try {
12857
+ const metaResult = await listMetas(config, watcher);
12858
+ // Count raw phase states (before retry) for display
12859
+ for (const entry of metaResult.entries) {
12860
+ const ps = derivePhaseState(entry.meta);
12861
+ for (const phase of ['architect', 'builder', 'critic']) {
12862
+ phaseStateSummary[phase][ps[phase]]++;
12863
+ }
12864
+ }
12865
+ // Build candidates (with auto-retry) for scheduling
12866
+ const candidates = buildPhaseCandidates(metaResult.entries);
12867
+ // Find next phase candidate
12868
+ const winner = selectPhaseCandidate(candidates, config.depthWeight);
12869
+ if (winner) {
12870
+ nextPhase = {
12871
+ path: winner.node.metaPath,
12872
+ phase: winner.owedPhase,
12873
+ band: winner.band,
12874
+ staleness: winner.effectiveStaleness,
12875
+ };
12876
+ }
12877
+ }
12878
+ catch {
12879
+ // Watcher unreachable — phase summary unavailable
12880
+ }
12452
12881
  return {
12453
12882
  serviceState: deriveServiceState(deps),
12454
- currentTarget: queue.current?.path ?? null,
12883
+ currentTarget: queue.current?.path ?? queue.currentPhase?.path ?? null,
12455
12884
  queue: queue.getState(),
12456
12885
  stats: {
12457
12886
  totalSyntheses: stats.totalSyntheses,
@@ -12471,6 +12900,8 @@ function registerStatusRoute(app, deps) {
12471
12900
  },
12472
12901
  gateway: gatewayHealth,
12473
12902
  },
12903
+ phaseStateSummary,
12904
+ nextPhase,
12474
12905
  };
12475
12906
  },
12476
12907
  });
@@ -12483,6 +12914,9 @@ function registerStatusRoute(app, deps) {
12483
12914
  /**
12484
12915
  * POST /synthesize route handler.
12485
12916
  *
12917
+ * Path-targeted triggers create explicit override entries in the queue.
12918
+ * Corpus-wide triggers discover the stalest candidate.
12919
+ *
12486
12920
  * @module routes/synthesize
12487
12921
  */
12488
12922
  const synthesizeBodySchema = z.object({
@@ -12493,44 +12927,70 @@ function registerSynthesizeRoute(app, deps) {
12493
12927
  app.post('/synthesize', async (request, reply) => {
12494
12928
  const body = synthesizeBodySchema.parse(request.body);
12495
12929
  const { config, watcher, queue } = deps;
12496
- let targetPath;
12497
12930
  if (body.path) {
12498
- targetPath = resolveMetaDir(body.path);
12499
- }
12500
- else {
12501
- // Discover stalest candidate
12502
- let result;
12931
+ // Path-targeted trigger: create override entry
12932
+ const targetPath = resolveMetaDir(body.path);
12933
+ // Read meta to determine owed phase
12934
+ let owedPhase = null;
12935
+ let meta;
12503
12936
  try {
12504
- result = await listMetas(config, watcher);
12937
+ meta = await readMetaJson(targetPath);
12938
+ const phaseState = derivePhaseState(meta);
12939
+ owedPhase = getOwedPhase(phaseState);
12505
12940
  }
12506
12941
  catch {
12507
- return reply.status(503).send({
12508
- error: 'SERVICE_UNAVAILABLE',
12509
- message: 'Watcher unreachable — cannot discover candidates',
12510
- });
12942
+ // Meta unreadable — proceed, phase will be evaluated at dequeue time
12511
12943
  }
12512
- const stale = result.entries
12513
- .filter((e) => e.stalenessSeconds > 0 && !e.disabled)
12514
- .map((e) => ({
12515
- node: e.node,
12516
- meta: e.meta,
12517
- actualStaleness: e.stalenessSeconds,
12518
- }));
12519
- const stalest = discoverStalestPath(stale, config.depthWeight);
12520
- if (!stalest) {
12521
- return reply.code(200).send({
12944
+ // Fully fresh meta → skip (reuse meta already read above)
12945
+ if (owedPhase === null && meta && (meta._phaseState || meta._content)) {
12946
+ return await reply.code(200).send({
12522
12947
  status: 'skipped',
12523
- message: 'No stale metas found. Nothing to synthesize.',
12948
+ path: targetPath,
12949
+ owedPhase: null,
12950
+ queuePosition: -1,
12951
+ alreadyQueued: false,
12524
12952
  });
12525
12953
  }
12526
- targetPath = stalest;
12954
+ const result = queue.enqueueOverride(targetPath);
12955
+ return reply.code(202).send({
12956
+ status: 'queued',
12957
+ path: targetPath,
12958
+ owedPhase,
12959
+ queuePosition: result.position,
12960
+ alreadyQueued: result.alreadyQueued,
12961
+ });
12962
+ }
12963
+ // Corpus-wide trigger: discover stalest candidate
12964
+ let result;
12965
+ try {
12966
+ result = await listMetas(config, watcher);
12527
12967
  }
12528
- const result = queue.enqueue(targetPath, body.path !== undefined);
12968
+ catch {
12969
+ return reply.status(503).send({
12970
+ error: 'SERVICE_UNAVAILABLE',
12971
+ message: 'Watcher unreachable — cannot discover candidates',
12972
+ });
12973
+ }
12974
+ const stale = result.entries
12975
+ .filter((e) => e.stalenessSeconds > 0 && !e.disabled)
12976
+ .map((e) => ({
12977
+ node: e.node,
12978
+ meta: e.meta,
12979
+ actualStaleness: e.stalenessSeconds,
12980
+ }));
12981
+ const stalest = discoverStalestPath(stale, config.depthWeight);
12982
+ if (!stalest) {
12983
+ return reply.code(200).send({
12984
+ status: 'skipped',
12985
+ message: 'No stale metas found. Nothing to synthesize.',
12986
+ });
12987
+ }
12988
+ const enqueueResult = queue.enqueue(stalest);
12529
12989
  return reply.code(202).send({
12530
12990
  status: 'accepted',
12531
- path: targetPath,
12532
- queuePosition: result.position,
12533
- alreadyQueued: result.alreadyQueued,
12991
+ path: stalest,
12992
+ queuePosition: enqueueResult.position,
12993
+ alreadyQueued: enqueueResult.alreadyQueued,
12534
12994
  });
12535
12995
  });
12536
12996
  }
@@ -12568,6 +13028,17 @@ function registerUnlockRoute(app, deps) {
12568
13028
  *
12569
13029
  * @module routes
12570
13030
  */
13031
+ /**
13032
+ * Large generated fields excluded from detail/update response projections.
13033
+ * Shared between metas detail and metasUpdate routes.
13034
+ */
13035
+ const DEFAULT_EXCLUDE_FIELDS = new Set([
13036
+ '_architect',
13037
+ '_builder',
13038
+ '_critic',
13039
+ '_content',
13040
+ '_feedback',
13041
+ ]);
12571
13042
  /** Register all HTTP routes on the Fastify instance. */
12572
13043
  function registerRoutes(app, deps) {
12573
13044
  // Global error handler for validation + watcher errors
@@ -12822,10 +13293,9 @@ async function startService(config, configPath) {
12822
13293
  }
12823
13294
  // Progress reporter — uses shared config reference so hot-reload propagates
12824
13295
  const progress = new ProgressReporter(config, logger);
12825
- // Wire queue processing — synthesize one meta per dequeue
13296
+ // Wire queue processing — execute one phase per dequeue (phase-state machine)
12826
13297
  const synthesizeFn = async (path) => {
12827
13298
  const startMs = Date.now();
12828
- let cycleTokens = 0;
12829
13299
  // Strip .meta suffix for human-readable progress reporting
12830
13300
  const ownerPath = path.replace(/\/?\.meta\/?$/, '');
12831
13301
  await progress.report({
@@ -12833,49 +13303,50 @@ async function startService(config, configPath) {
12833
13303
  path: ownerPath,
12834
13304
  });
12835
13305
  try {
12836
- const results = await orchestrate(config, executor, watcher, path, async (evt) => {
13306
+ const result = await orchestratePhase(config, executor, watcher, path, async (evt) => {
13307
+ // Wire current-phase tracking for GET /queue and POST /synthesize/abort
13308
+ if (evt.type === 'phase_start' && evt.phase) {
13309
+ queue.setCurrentPhase(ownerPath, evt.phase);
13310
+ }
12837
13311
  // Track token stats from phase completions
12838
13312
  if (evt.type === 'phase_complete') {
12839
13313
  if (evt.tokens !== undefined) {
12840
13314
  stats.totalTokens += evt.tokens;
12841
- if (cycleTokens !== undefined) {
12842
- cycleTokens += evt.tokens;
12843
- }
12844
13315
  }
12845
13316
  else {
12846
- cycleTokens = undefined;
12847
13317
  logger.warn({ path: ownerPath, phase: evt.phase }, 'Token count unavailable (session lookup may have timed out)');
12848
13318
  }
12849
13319
  }
12850
13320
  await progress.report(evt);
12851
13321
  }, logger);
12852
- // orchestrate() always returns exactly one result
12853
- const result = results[0];
12854
13322
  const durationMs = Date.now() - startMs;
12855
- if (!result.synthesized) {
12856
- // Entity was skipped (e.g. empty scope) — no progress to report.
12857
- logger.debug({ path: ownerPath }, 'Synthesis skipped');
13323
+ if (!result.executed) {
13324
+ logger.debug({ path: ownerPath }, 'Phase skipped (fully fresh or locked)');
12858
13325
  return;
12859
13326
  }
12860
13327
  // Update stats
12861
13328
  stats.totalSyntheses++;
12862
13329
  stats.lastCycleDurationMs = durationMs;
12863
13330
  stats.lastCycleAt = new Date().toISOString();
12864
- if (result.error) {
13331
+ const phaseResult = result.phaseResult;
13332
+ if (phaseResult?.error) {
12865
13333
  stats.totalErrors++;
12866
13334
  await progress.report({
12867
13335
  type: 'error',
12868
13336
  path: ownerPath,
12869
- phase: result.error.step,
12870
- error: result.error.message,
13337
+ phase: phaseResult.error.step,
13338
+ error: phaseResult.error.message,
12871
13339
  });
12872
13340
  }
12873
13341
  else {
13342
+ // Task #9: Reset backoff on ANY successful phase execution
12874
13343
  scheduler.resetBackoff();
13344
+ }
13345
+ // Emit synthesis_complete only on full-cycle completion
13346
+ if (result.cycleComplete) {
12875
13347
  await progress.report({
12876
13348
  type: 'synthesis_complete',
12877
13349
  path: ownerPath,
12878
- tokens: cycleTokens,
12879
13350
  durationMs,
12880
13351
  });
12881
13352
  }