@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.
package/dist/index.js CHANGED
@@ -9042,6 +9042,10 @@ class GatewayExecutor {
9042
9042
  return undefined;
9043
9043
  }
9044
9044
  }
9045
+ /** Whether this executor has been aborted by the operator. */
9046
+ get aborted() {
9047
+ return this.controller.signal.aborted;
9048
+ }
9045
9049
  /** Abort the currently running spawn, if any. */
9046
9050
  abort() {
9047
9051
  this.controller.abort();
@@ -9515,6 +9519,22 @@ const metaErrorSchema = z.object({
9515
9519
  *
9516
9520
  * @module schema/meta
9517
9521
  */
9522
+ /** Valid states for a synthesis phase. */
9523
+ const phaseStatuses = [
9524
+ 'fresh',
9525
+ 'stale',
9526
+ 'pending',
9527
+ 'running',
9528
+ 'failed',
9529
+ ];
9530
+ /** Zod schema for a per-phase status value. */
9531
+ const phaseStatusSchema = z.enum(phaseStatuses);
9532
+ /** Zod schema for the per-meta phase state record. */
9533
+ const phaseStateSchema = z.object({
9534
+ architect: phaseStatusSchema,
9535
+ builder: phaseStatusSchema,
9536
+ critic: phaseStatusSchema,
9537
+ });
9518
9538
  /** Zod schema for the reserved (underscore-prefixed) meta.json properties. */
9519
9539
  const metaJsonSchema = z
9520
9540
  .object({
@@ -9595,6 +9615,12 @@ const metaJsonSchema = z
9595
9615
  _error: metaErrorSchema.optional(),
9596
9616
  /** When true, this meta is skipped during staleness scheduling. Manual trigger still works. */
9597
9617
  _disabled: z.boolean().optional(),
9618
+ /**
9619
+ * Per-phase state machine record. Engine-managed.
9620
+ * Keyed by phase name (architect, builder, critic) with status values.
9621
+ * Persisted to survive ticks; derived on first load for back-compat.
9622
+ */
9623
+ _phaseState: phaseStateSchema.optional(),
9598
9624
  })
9599
9625
  .loose();
9600
9626
 
@@ -9653,6 +9679,8 @@ async function mergeAndWrite(options) {
9653
9679
  _state: options.state,
9654
9680
  // Error handling
9655
9681
  _error: options.error ?? undefined,
9682
+ // Phase state machine
9683
+ _phaseState: options.phaseState,
9656
9684
  // Spread structured fields from builder
9657
9685
  ...options.builderOutput?.fields,
9658
9686
  };
@@ -9683,6 +9711,8 @@ async function mergeAndWrite(options) {
9683
9711
  delete merged._content;
9684
9712
  if (merged._feedback === undefined)
9685
9713
  delete merged._feedback;
9714
+ if (merged._phaseState === undefined)
9715
+ delete merged._phaseState;
9686
9716
  // Validate
9687
9717
  const result = metaJsonSchema.safeParse(merged);
9688
9718
  if (!result.success) {
@@ -10378,6 +10408,686 @@ async function orchestrate(config, executor, watcher, targetPath, onProgress, lo
10378
10408
  return [result];
10379
10409
  }
10380
10410
 
10411
+ /**
10412
+ * Pure phase-state transition functions.
10413
+ *
10414
+ * Implements every row of the §8 "Transitions and invalidation cascade" table.
10415
+ * No I/O — pure functions over PhaseState and documented inputs.
10416
+ *
10417
+ * @module phaseState/phaseTransitions
10418
+ */
10419
+ /**
10420
+ * Create a fresh (fully-complete) phase state.
10421
+ */
10422
+ function freshPhaseState() {
10423
+ return { architect: 'fresh', builder: 'fresh', critic: 'fresh' };
10424
+ }
10425
+ /**
10426
+ * Create a phase state for a never-synthesized meta (all pending from architect).
10427
+ */
10428
+ function initialPhaseState() {
10429
+ return { architect: 'pending', builder: 'stale', critic: 'stale' };
10430
+ }
10431
+ /**
10432
+ * Enforce the per-meta invariant: at most one phase is pending or running,
10433
+ * and it is the first non-fresh phase in pipeline order.
10434
+ *
10435
+ * Stale phases that become the first non-fresh phase are promoted to pending.
10436
+ */
10437
+ function enforceInvariant(state) {
10438
+ const result = { ...state };
10439
+ let foundNonFresh = false;
10440
+ for (const phase of ['architect', 'builder', 'critic']) {
10441
+ const s = result[phase];
10442
+ if (s === 'fresh')
10443
+ continue;
10444
+ if (!foundNonFresh) {
10445
+ foundNonFresh = true;
10446
+ // First non-fresh: if stale, promote to pending
10447
+ if (s === 'stale') {
10448
+ result[phase] = 'pending';
10449
+ }
10450
+ // pending, running, failed stay as-is
10451
+ }
10452
+ else {
10453
+ // Subsequent non-fresh: must not be pending or running
10454
+ if (s === 'pending') {
10455
+ result[phase] = 'stale';
10456
+ }
10457
+ // running in non-first position would be a bug, but don't mask it
10458
+ }
10459
+ }
10460
+ return result;
10461
+ }
10462
+ // ── Phase success transitions ──────────────────────────────────────────
10463
+ /**
10464
+ * Architect completes successfully.
10465
+ * architect → fresh; builder → pending; critic → stale.
10466
+ */
10467
+ function architectSuccess(state) {
10468
+ return enforceInvariant({
10469
+ architect: 'fresh',
10470
+ builder: state.builder === 'failed' ? 'failed' : 'pending',
10471
+ critic: state.critic === 'fresh' ? 'stale' : state.critic,
10472
+ });
10473
+ }
10474
+ /**
10475
+ * Builder completes successfully.
10476
+ * builder → fresh; critic → pending.
10477
+ */
10478
+ function builderSuccess(state) {
10479
+ return enforceInvariant({
10480
+ ...state,
10481
+ builder: 'fresh',
10482
+ critic: state.critic === 'failed' ? 'failed' : 'pending',
10483
+ });
10484
+ }
10485
+ /**
10486
+ * Critic completes successfully.
10487
+ * critic → fresh. Meta becomes fully fresh.
10488
+ */
10489
+ function criticSuccess(state) {
10490
+ return enforceInvariant({
10491
+ ...state,
10492
+ critic: 'fresh',
10493
+ });
10494
+ }
10495
+ // ── Failure transition ─────────────────────────────────────────────────
10496
+ /**
10497
+ * A phase fails (error, timeout, or abort).
10498
+ * Target phase → failed; upstream and downstream unchanged.
10499
+ */
10500
+ function phaseFailed(state, phase) {
10501
+ return enforceInvariant({
10502
+ ...state,
10503
+ [phase]: 'failed',
10504
+ });
10505
+ }
10506
+ // ── Surgical retry ─────────────────────────────────────────────────────
10507
+ /**
10508
+ * Retry a failed phase: failed → pending.
10509
+ * Only valid when the phase is currently failed.
10510
+ */
10511
+ function retryPhase(state, phase) {
10512
+ if (state[phase] !== 'failed')
10513
+ return state;
10514
+ return enforceInvariant({
10515
+ ...state,
10516
+ [phase]: 'pending',
10517
+ });
10518
+ }
10519
+ /**
10520
+ * Retry all failed phases: each failed phase → pending.
10521
+ * Used by scheduler ticks and queue reads to auto-promote failed phases.
10522
+ */
10523
+ function retryAllFailed(state) {
10524
+ let result = state;
10525
+ for (const phase of ['architect', 'builder', 'critic']) {
10526
+ if (result[phase] === 'failed') {
10527
+ result = retryPhase(result, phase);
10528
+ }
10529
+ }
10530
+ return result;
10531
+ }
10532
+ // ── Running transition ─────────────────────────────────────────────────
10533
+ /**
10534
+ * Mark a phase as running (scheduler picks it).
10535
+ */
10536
+ function phaseRunning(state, phase) {
10537
+ return {
10538
+ ...state,
10539
+ [phase]: 'running',
10540
+ };
10541
+ }
10542
+ // ── Query helpers ──────────────────────────────────────────────────────
10543
+ /**
10544
+ * Get the owed phase: first non-fresh phase in pipeline order, or null.
10545
+ */
10546
+ function getOwedPhase(state) {
10547
+ for (const phase of ['architect', 'builder', 'critic']) {
10548
+ if (state[phase] !== 'fresh')
10549
+ return phase;
10550
+ }
10551
+ return null;
10552
+ }
10553
+ /**
10554
+ * Check if a meta is fully fresh (all phases fresh).
10555
+ */
10556
+ function isFullyFresh(state) {
10557
+ return (state.architect === 'fresh' &&
10558
+ state.builder === 'fresh' &&
10559
+ state.critic === 'fresh');
10560
+ }
10561
+ /**
10562
+ * Get the scheduler priority band for a meta's owed phase.
10563
+ * 1 = critic (highest), 2 = builder, 3 = architect, null = fully fresh.
10564
+ */
10565
+ function getPriorityBand(state) {
10566
+ const owed = getOwedPhase(state);
10567
+ if (!owed)
10568
+ return null;
10569
+ if (owed === 'critic')
10570
+ return 1;
10571
+ if (owed === 'builder')
10572
+ return 2;
10573
+ return 3;
10574
+ }
10575
+
10576
+ /**
10577
+ * Backward-compatible derivation of _phaseState from existing meta fields.
10578
+ *
10579
+ * When a meta is loaded from disk without _phaseState, this reconstructs
10580
+ * the phase state from _content, _builder, _state, _error.step, and
10581
+ * the architect-invalidating inputs.
10582
+ *
10583
+ * @module phaseState/derivePhaseState
10584
+ */
10585
+ /**
10586
+ * Derive _phaseState from existing meta fields.
10587
+ *
10588
+ * If the meta already has _phaseState, returns it as-is.
10589
+ *
10590
+ * Otherwise, reconstructs from available fields:
10591
+ * - Never-synthesized meta (no _content, no _builder): all phases start pending/stale.
10592
+ * - Errored meta: the failed phase is mapped from _error.step.
10593
+ * - Mid-cycle meta with cached _builder but no _content: builder pending.
10594
+ * - Fully-fresh meta: all phases fresh.
10595
+ * - Meta with stale architect inputs: architect pending, downstream stale.
10596
+ *
10597
+ * @param meta - The meta.json content.
10598
+ * @param inputs - Optional derivation inputs. If not provided, a simpler
10599
+ * heuristic is used (no architect invalidation check).
10600
+ * @returns The derived PhaseState.
10601
+ */
10602
+ function derivePhaseState(meta, inputs) {
10603
+ // Already has _phaseState — use it
10604
+ if (meta._phaseState)
10605
+ return meta._phaseState;
10606
+ // Check for errors first — _error.step maps directly to failed phase
10607
+ if (meta._error) {
10608
+ const failedPhase = meta._error.step;
10609
+ const state = freshPhaseState();
10610
+ state[failedPhase] = 'failed';
10611
+ // If architect failed and no _builder, downstream is stale
10612
+ if (failedPhase === 'architect') {
10613
+ if (!meta._builder) {
10614
+ state.builder = 'stale';
10615
+ state.critic = 'stale';
10616
+ }
10617
+ }
10618
+ // If builder failed, critic is stale
10619
+ if (failedPhase === 'builder') {
10620
+ state.critic = 'stale';
10621
+ }
10622
+ return state;
10623
+ }
10624
+ // Never synthesized: no _content AND no _builder (and no error)
10625
+ if (!meta._content && !meta._builder) {
10626
+ return initialPhaseState();
10627
+ }
10628
+ // Check architect invalidation (when inputs are provided)
10629
+ if (inputs) {
10630
+ const architectInvalidated = inputs.structureChanged ||
10631
+ inputs.steerChanged ||
10632
+ inputs.architectChanged ||
10633
+ inputs.crossRefsChanged ||
10634
+ (meta._synthesisCount ?? 0) >= inputs.architectEvery;
10635
+ if (architectInvalidated) {
10636
+ return {
10637
+ architect: 'pending',
10638
+ builder: 'stale',
10639
+ critic: 'stale',
10640
+ };
10641
+ }
10642
+ }
10643
+ // Has _builder but no _content: builder is pending
10644
+ if (meta._builder && !meta._content) {
10645
+ return {
10646
+ architect: 'fresh',
10647
+ builder: 'pending',
10648
+ critic: 'stale',
10649
+ };
10650
+ }
10651
+ // Has _content but no _feedback: critic is pending
10652
+ if (meta._content && !meta._feedback) {
10653
+ return {
10654
+ architect: 'fresh',
10655
+ builder: 'fresh',
10656
+ critic: 'pending',
10657
+ };
10658
+ }
10659
+ // Default: fully fresh
10660
+ return freshPhaseState();
10661
+ }
10662
+
10663
+ /**
10664
+ * Corpus-wide phase scheduler.
10665
+ *
10666
+ * Selects the highest-priority ready phase across all metas.
10667
+ * Priority: critic (band 1) \> builder (band 2) \> architect (band 3).
10668
+ * Tiebreak within band: weighted staleness (§3.9).
10669
+ *
10670
+ * @module phaseState/phaseScheduler
10671
+ */
10672
+ /**
10673
+ * Build phase candidates from listMetas entries.
10674
+ *
10675
+ * Derives phase state and auto-retries failed phases for each entry.
10676
+ * Used by orchestratePhase, queue route, and status route.
10677
+ */
10678
+ function buildPhaseCandidates(entries) {
10679
+ return entries.map((entry) => ({
10680
+ node: entry.node,
10681
+ meta: entry.meta,
10682
+ phaseState: retryAllFailed(derivePhaseState(entry.meta)),
10683
+ actualStaleness: entry.stalenessSeconds,
10684
+ locked: entry.locked,
10685
+ disabled: entry.disabled,
10686
+ }));
10687
+ }
10688
+ /**
10689
+ * Rank all eligible phase candidates by priority.
10690
+ *
10691
+ * Filters to pending phases, computes effective staleness, and sorts by
10692
+ * band (ascending: critic first) then effective staleness (descending).
10693
+ *
10694
+ * Used by selectPhaseCandidate (returns first) and the queue route (returns all).
10695
+ */
10696
+ function rankPhaseCandidates(metas, depthWeight) {
10697
+ // Filter to metas with a pending (scheduler-eligible) phase
10698
+ const eligible = metas.filter((m) => {
10699
+ if (m.locked)
10700
+ return false;
10701
+ if (m.disabled && !m.isOverride)
10702
+ return false;
10703
+ const owed = getOwedPhase(m.phaseState);
10704
+ if (!owed)
10705
+ return false;
10706
+ return m.phaseState[owed] === 'pending';
10707
+ });
10708
+ if (eligible.length === 0)
10709
+ return [];
10710
+ // Compute effective staleness for tiebreaking
10711
+ const withStaleness = computeEffectiveStaleness(eligible.map((m) => ({
10712
+ node: m.node,
10713
+ meta: m.meta,
10714
+ actualStaleness: m.actualStaleness,
10715
+ })), depthWeight);
10716
+ // Build candidates with band info
10717
+ const candidates = withStaleness.map((ws, i) => {
10718
+ const m = eligible[i];
10719
+ const owedPhase = getOwedPhase(m.phaseState);
10720
+ return {
10721
+ node: ws.node,
10722
+ meta: ws.meta,
10723
+ phaseState: m.phaseState,
10724
+ owedPhase,
10725
+ band: getPriorityBand(m.phaseState),
10726
+ actualStaleness: ws.actualStaleness,
10727
+ effectiveStaleness: ws.effectiveStaleness,
10728
+ };
10729
+ });
10730
+ // Sort by band (ascending = critic first) then effective staleness (descending)
10731
+ candidates.sort((a, b) => {
10732
+ if (a.band !== b.band)
10733
+ return a.band - b.band;
10734
+ return b.effectiveStaleness - a.effectiveStaleness;
10735
+ });
10736
+ return candidates;
10737
+ }
10738
+ /**
10739
+ * Select the best phase candidate across the corpus.
10740
+ *
10741
+ * @param metas - Array of (node, meta, phaseState, stalenessSeconds) tuples.
10742
+ * @param depthWeight - Config depthWeight for staleness tiebreak.
10743
+ * @returns The winning candidate, or null if no phase is ready.
10744
+ */
10745
+ function selectPhaseCandidate(metas, depthWeight) {
10746
+ return rankPhaseCandidates(metas, depthWeight)[0] ?? null;
10747
+ }
10748
+
10749
+ /**
10750
+ * Per-phase executors for the phase-state machine.
10751
+ *
10752
+ * Each function runs exactly one phase on one meta, updates _phaseState
10753
+ * via pure transitions, and persists via the lock-staged write.
10754
+ *
10755
+ * @module orchestrator/runPhase
10756
+ */
10757
+ /** Write updated meta with phase state via lock staging. */
10758
+ async function persistPhaseState(base, phaseState, updates) {
10759
+ const lockPath = join(base.metaPath, '.lock');
10760
+ const metaJsonPath = join(base.metaPath, 'meta.json');
10761
+ const merged = {
10762
+ ...base.current,
10763
+ ...updates,
10764
+ _phaseState: phaseState,
10765
+ _structureHash: base.structureHash,
10766
+ };
10767
+ // Clean undefined
10768
+ if (merged._error === undefined)
10769
+ delete merged._error;
10770
+ await writeFile(lockPath, JSON.stringify(merged, null, 2) + '\n');
10771
+ await copyFile(lockPath, metaJsonPath);
10772
+ return merged;
10773
+ }
10774
+ /**
10775
+ * Handle phase failure (abort or error).
10776
+ *
10777
+ * Shared error path for all three phase executors. When the executor was
10778
+ * aborted, returns immediately without persisting (abort route handles it).
10779
+ * Otherwise, transitions the phase to failed and persists the error.
10780
+ */
10781
+ async function handlePhaseFailure(phase, err, executor, ps, base, additionalUpdates) {
10782
+ if (executor.aborted) {
10783
+ return {
10784
+ executed: true,
10785
+ phaseState: phaseFailed(ps, phase),
10786
+ error: { step: phase, code: 'ABORT', message: 'Aborted by operator' },
10787
+ };
10788
+ }
10789
+ const error = toMetaError(phase, err);
10790
+ const failedPs = phaseFailed(ps, phase);
10791
+ await persistPhaseState(base, failedPs, {
10792
+ _error: error,
10793
+ ...additionalUpdates,
10794
+ });
10795
+ return { executed: true, phaseState: failedPs, error };
10796
+ }
10797
+ // ── Architect executor ─────────────────────────────────────────────────
10798
+ async function runArchitect(node, currentMeta, phaseState, config, executor, watcher, structureHash, onProgress, logger) {
10799
+ let ps = phaseRunning(phaseState, 'architect');
10800
+ const ctx = await buildContextPackage(node, currentMeta, watcher, logger);
10801
+ try {
10802
+ await onProgress?.({
10803
+ type: 'phase_start',
10804
+ path: node.ownerPath,
10805
+ phase: 'architect',
10806
+ });
10807
+ const phaseStart = Date.now();
10808
+ const architectTask = buildArchitectTask(ctx, currentMeta, config);
10809
+ const result = await executor.spawn(architectTask, {
10810
+ thinking: config.thinking,
10811
+ timeout: config.architectTimeout,
10812
+ label: 'meta-architect',
10813
+ });
10814
+ const builderBrief = parseArchitectOutput(result.output);
10815
+ const architectTokens = result.tokens;
10816
+ // Architect success: architect → fresh, _synthesisCount → 0
10817
+ ps = architectSuccess(ps);
10818
+ const updatedMeta = await persistPhaseState({ metaPath: node.metaPath, current: currentMeta, config, structureHash }, ps, {
10819
+ _builder: builderBrief,
10820
+ _architect: currentMeta._architect ?? config.defaultArchitect ?? '',
10821
+ _synthesisCount: 0,
10822
+ _architectTokens: architectTokens,
10823
+ _generatedAt: new Date().toISOString(),
10824
+ _error: undefined,
10825
+ });
10826
+ await onProgress?.({
10827
+ type: 'phase_complete',
10828
+ path: node.ownerPath,
10829
+ phase: 'architect',
10830
+ tokens: architectTokens,
10831
+ durationMs: Date.now() - phaseStart,
10832
+ });
10833
+ return { executed: true, phaseState: ps, updatedMeta };
10834
+ }
10835
+ catch (err) {
10836
+ return handlePhaseFailure('architect', err, executor, ps, {
10837
+ metaPath: node.metaPath,
10838
+ current: currentMeta,
10839
+ structureHash,
10840
+ });
10841
+ }
10842
+ }
10843
+ // ── Builder executor ───────────────────────────────────────────────────
10844
+ async function runBuilder(node, currentMeta, phaseState, config, executor, watcher, structureHash, onProgress, logger) {
10845
+ let ps = phaseRunning(phaseState, 'builder');
10846
+ const ctx = await buildContextPackage(node, currentMeta, watcher, logger);
10847
+ try {
10848
+ await onProgress?.({
10849
+ type: 'phase_start',
10850
+ path: node.ownerPath,
10851
+ phase: 'builder',
10852
+ });
10853
+ const builderStart = Date.now();
10854
+ const builderTask = buildBuilderTask(ctx, currentMeta, config);
10855
+ const result = await executor.spawn(builderTask, {
10856
+ thinking: config.thinking,
10857
+ timeout: config.builderTimeout,
10858
+ label: 'meta-builder',
10859
+ });
10860
+ const builderOutput = parseBuilderOutput(result.output);
10861
+ const builderTokens = result.tokens;
10862
+ // Builder success: builder → fresh, critic → pending
10863
+ ps = builderSuccess(ps);
10864
+ const updatedMeta = await persistPhaseState({ metaPath: node.metaPath, current: currentMeta, config, structureHash }, ps, {
10865
+ _content: builderOutput.content,
10866
+ _state: builderOutput.state,
10867
+ _builderTokens: builderTokens,
10868
+ _generatedAt: new Date().toISOString(),
10869
+ _error: undefined,
10870
+ ...builderOutput.fields,
10871
+ });
10872
+ await onProgress?.({
10873
+ type: 'phase_complete',
10874
+ path: node.ownerPath,
10875
+ phase: 'builder',
10876
+ tokens: builderTokens,
10877
+ durationMs: Date.now() - builderStart,
10878
+ });
10879
+ return { executed: true, phaseState: ps, updatedMeta };
10880
+ }
10881
+ catch (err) {
10882
+ // §4.6 partial _state recovery on timeout
10883
+ let partialState;
10884
+ if (err instanceof SpawnTimeoutError) {
10885
+ try {
10886
+ const raw = await readFile(err.outputPath, 'utf8');
10887
+ const partial = parseBuilderOutput(raw);
10888
+ if (partial.state !== undefined &&
10889
+ JSON.stringify(partial.state) !== JSON.stringify(currentMeta._state)) {
10890
+ partialState = { _state: partial.state };
10891
+ }
10892
+ }
10893
+ catch {
10894
+ // Could not read partial output — no state recovery
10895
+ }
10896
+ }
10897
+ return handlePhaseFailure('builder', err, executor, ps, {
10898
+ metaPath: node.metaPath,
10899
+ current: currentMeta,
10900
+ structureHash,
10901
+ }, partialState);
10902
+ }
10903
+ }
10904
+ // ── Critic executor ────────────────────────────────────────────────────
10905
+ async function runCritic(node, currentMeta, phaseState, config, executor, watcher, structureHash, onProgress, logger) {
10906
+ let ps = phaseRunning(phaseState, 'critic');
10907
+ const ctx = await buildContextPackage(node, currentMeta, watcher, logger);
10908
+ // Build critic task using current meta's _content
10909
+ const metaForCritic = { ...currentMeta };
10910
+ try {
10911
+ await onProgress?.({
10912
+ type: 'phase_start',
10913
+ path: node.ownerPath,
10914
+ phase: 'critic',
10915
+ });
10916
+ const criticStart = Date.now();
10917
+ const criticTask = buildCriticTask(ctx, metaForCritic, config);
10918
+ const result = await executor.spawn(criticTask, {
10919
+ thinking: config.thinking,
10920
+ timeout: config.criticTimeout,
10921
+ label: 'meta-critic',
10922
+ });
10923
+ const feedback = parseCriticOutput(result.output);
10924
+ const criticTokens = result.tokens;
10925
+ // Critic success: critic → fresh
10926
+ ps = criticSuccess(ps);
10927
+ const cycleComplete = isFullyFresh(ps);
10928
+ const updates = {
10929
+ _feedback: feedback,
10930
+ _criticTokens: criticTokens,
10931
+ _error: undefined,
10932
+ };
10933
+ // Full-cycle completion: increment _synthesisCount, archive, emit.
10934
+ // Per spec: architect resets to 0, full-cycle increments on top.
10935
+ if (cycleComplete) {
10936
+ updates._synthesisCount = (currentMeta._synthesisCount ?? 0) + 1;
10937
+ }
10938
+ const updatedMeta = await persistPhaseState({ metaPath: node.metaPath, current: currentMeta, config, structureHash }, ps, updates);
10939
+ // Archive on full-cycle only
10940
+ if (cycleComplete) {
10941
+ await createSnapshot(node.metaPath, updatedMeta);
10942
+ await pruneArchive(node.metaPath, config.maxArchive);
10943
+ }
10944
+ await onProgress?.({
10945
+ type: 'phase_complete',
10946
+ path: node.ownerPath,
10947
+ phase: 'critic',
10948
+ tokens: criticTokens,
10949
+ durationMs: Date.now() - criticStart,
10950
+ });
10951
+ return {
10952
+ executed: true,
10953
+ phaseState: ps,
10954
+ updatedMeta,
10955
+ cycleComplete,
10956
+ };
10957
+ }
10958
+ catch (err) {
10959
+ return handlePhaseFailure('critic', err, executor, ps, {
10960
+ metaPath: node.metaPath,
10961
+ current: currentMeta,
10962
+ structureHash,
10963
+ });
10964
+ }
10965
+ }
10966
+
10967
+ /**
10968
+ * Phase-aware orchestration entry point.
10969
+ *
10970
+ * Replaces the old staleness-based orchestrate() with phase-state-machine
10971
+ * scheduling: each tick discovers all metas, computes invalidation,
10972
+ * auto-retries failed phases, selects the best phase candidate, and
10973
+ * executes exactly one phase.
10974
+ *
10975
+ * @module orchestrator/orchestratePhase
10976
+ */
10977
+ /** Phase runner dispatch map — avoids repeating the same switch/case. */
10978
+ const phaseRunners = {
10979
+ architect: runArchitect,
10980
+ builder: runBuilder,
10981
+ critic: runCritic,
10982
+ };
10983
+ /**
10984
+ * Run a single phase-aware orchestration tick.
10985
+ *
10986
+ * When targetPath is provided (override entry), runs the owed phase for
10987
+ * that specific meta. Otherwise, discovers all metas, computes invalidation,
10988
+ * and selects the best phase candidate corpus-wide.
10989
+ */
10990
+ async function orchestratePhase(config, executor, watcher, targetPath, onProgress, logger) {
10991
+ // ── Targeted path (override entry) ──
10992
+ if (targetPath) {
10993
+ return orchestrateTargeted(config, executor, watcher, targetPath, onProgress, logger);
10994
+ }
10995
+ // ── Corpus-wide discovery + phase selection ──
10996
+ let metaResult;
10997
+ try {
10998
+ metaResult = await listMetas(config, watcher);
10999
+ }
11000
+ catch (err) {
11001
+ logger?.warn({ err }, 'Failed to list metas for phase selection');
11002
+ return { executed: false };
11003
+ }
11004
+ if (metaResult.entries.length === 0)
11005
+ return { executed: false };
11006
+ // Build candidates with phase state (including invalidation + auto-retry)
11007
+ const candidates = buildPhaseCandidates(metaResult.entries);
11008
+ // Select best phase candidate
11009
+ const winner = selectPhaseCandidate(candidates, config.depthWeight);
11010
+ if (!winner) {
11011
+ return { executed: false };
11012
+ }
11013
+ // Acquire lock
11014
+ if (!acquireLock(winner.node.metaPath)) {
11015
+ logger?.debug({ path: winner.node.metaPath }, 'Selected candidate is locked, skipping');
11016
+ return { executed: false };
11017
+ }
11018
+ try {
11019
+ // Re-read meta under lock for freshness
11020
+ const currentMeta = await readMetaJson(winner.node.metaPath);
11021
+ const phaseState = retryAllFailed(derivePhaseState(currentMeta));
11022
+ const owedPhase = getOwedPhase(phaseState);
11023
+ if (!owedPhase || phaseState[owedPhase] !== 'pending') {
11024
+ // Nothing to do (race: became fresh between selection and lock)
11025
+ return { executed: false };
11026
+ }
11027
+ // Compute structure hash for the phase
11028
+ const { scopeFiles } = await getScopeFiles(winner.node, watcher);
11029
+ const structureHash = computeStructureHash(scopeFiles);
11030
+ // skipUnchanged: bump _generatedAt without altering _phaseState
11031
+ if (config.skipUnchanged && currentMeta._generatedAt) {
11032
+ const verifiedStale = await isStale(getScopePrefix(winner.node), currentMeta, watcher);
11033
+ if (!verifiedStale) {
11034
+ await persistPhaseState({
11035
+ metaPath: winner.node.metaPath,
11036
+ current: currentMeta,
11037
+ config,
11038
+ structureHash,
11039
+ }, phaseState, { _generatedAt: new Date().toISOString() });
11040
+ logger?.debug({ path: winner.node.ownerPath }, 'Skipped unchanged meta, bumped _generatedAt');
11041
+ return { executed: false };
11042
+ }
11043
+ }
11044
+ return await executePhase(winner.node, currentMeta, phaseState, owedPhase, config, executor, watcher, structureHash, onProgress, logger);
11045
+ }
11046
+ finally {
11047
+ releaseLock(winner.node.metaPath);
11048
+ }
11049
+ }
11050
+ /**
11051
+ * Orchestrate a targeted (override) meta path.
11052
+ * Resolves the owed phase at execution time (not enqueue time).
11053
+ */
11054
+ async function orchestrateTargeted(config, executor, watcher, targetPath, onProgress, logger) {
11055
+ const normalizedTarget = normalizePath(targetPath);
11056
+ const node = await buildMinimalNode(normalizedTarget, watcher);
11057
+ if (!acquireLock(node.metaPath)) {
11058
+ return { executed: false };
11059
+ }
11060
+ try {
11061
+ const currentMeta = await readMetaJson(normalizedTarget);
11062
+ const phaseState = retryAllFailed(derivePhaseState(currentMeta));
11063
+ const owedPhase = getOwedPhase(phaseState);
11064
+ if (!owedPhase) {
11065
+ // Fully fresh — override is a no-op (silently dropped per spec)
11066
+ return { executed: false, metaPath: normalizedTarget };
11067
+ }
11068
+ // Compute structure hash
11069
+ const { scopeFiles } = await getScopeFiles(node, watcher);
11070
+ const structureHash = computeStructureHash(scopeFiles);
11071
+ return await executePhase(node, currentMeta, phaseState, owedPhase, config, executor, watcher, structureHash, onProgress, logger);
11072
+ }
11073
+ finally {
11074
+ releaseLock(node.metaPath);
11075
+ }
11076
+ }
11077
+ /**
11078
+ * Execute exactly one phase on a meta.
11079
+ */
11080
+ async function executePhase(node, currentMeta, phaseState, phase, config, executor, watcher, structureHash, onProgress, logger) {
11081
+ const result = await phaseRunners[phase](node, currentMeta, phaseState, config, executor, watcher, structureHash, onProgress, logger);
11082
+ return {
11083
+ executed: true,
11084
+ metaPath: node.metaPath,
11085
+ phase,
11086
+ phaseResult: result,
11087
+ cycleComplete: result.cycleComplete,
11088
+ };
11089
+ }
11090
+
10381
11091
  /**
10382
11092
  * Progress reporting via OpenClaw gateway `/tools/invoke` → `message` tool.
10383
11093
  *
@@ -10512,42 +11222,106 @@ class ProgressReporter {
10512
11222
  }
10513
11223
 
10514
11224
  /**
10515
- * Single-threaded synthesis queue with priority support and deduplication.
11225
+ * Hybrid 3-layer synthesis queue.
11226
+ *
11227
+ * Layer 1: Current — the single item currently executing (at most one).
11228
+ * Layer 2: Overrides — items manually enqueued via POST /synthesize with path.
11229
+ * FIFO among overrides, ahead of automatic candidates.
11230
+ * Layer 3: Automatic — computed on read, not persisted. All metas with a
11231
+ * pending phase, ranked by scheduler priority.
10516
11232
  *
10517
- * The scheduler enqueues the stalest candidate each tick. HTTP-triggered
10518
- * synthesis requests get priority (inserted at front). A path appears at
10519
- * most once in the queue; re-triggering returns the current position.
11233
+ * Legacy: `pending` array is the union of overrides + automatic.
10520
11234
  *
10521
11235
  * @module queue
10522
11236
  */
10523
11237
  const DEPTH_WARNING_THRESHOLD = 3;
10524
11238
  /**
10525
- * Single-threaded synthesis queue.
11239
+ * Hybrid 3-layer synthesis queue.
10526
11240
  *
10527
- * Only one synthesis runs at a time. Priority items are inserted at the
10528
- * front of the queue. Duplicate paths are rejected with their current
10529
- * position returned.
11241
+ * Only one synthesis runs at a time. Override items (explicit triggers)
11242
+ * take priority over automatic candidates.
10530
11243
  */
10531
11244
  class SynthesisQueue {
11245
+ /** Legacy queue (used by processQueue for backward compat). */
10532
11246
  queue = [];
10533
11247
  currentItem = null;
10534
11248
  processing = false;
10535
11249
  logger;
10536
11250
  onEnqueueCallback = null;
10537
- /**
10538
- * Create a new SynthesisQueue.
10539
- *
10540
- * @param logger - Pino logger instance.
10541
- */
11251
+ /** Explicit override entries (3-layer model). */
11252
+ overrideEntries = [];
11253
+ /** Currently executing item with phase info (3-layer model). */
11254
+ currentPhaseItem = null;
10542
11255
  constructor(logger) {
10543
11256
  this.logger = logger;
10544
11257
  }
10545
- /**
10546
- * Set a callback to invoke when a new (non-duplicate) item is enqueued.
10547
- */
11258
+ /** Set a callback to invoke when a new (non-duplicate) item is enqueued. */
10548
11259
  onEnqueue(callback) {
10549
11260
  this.onEnqueueCallback = callback;
10550
11261
  }
11262
+ // ── Override layer (3-layer model) ─────────────────────────────────
11263
+ /**
11264
+ * Add an explicit override entry (from POST /synthesize with path).
11265
+ * Deduped by path. Returns position and whether already queued.
11266
+ */
11267
+ enqueueOverride(path) {
11268
+ // Check if currently executing
11269
+ if (this.currentPhaseItem?.path === path ||
11270
+ this.currentItem?.path === path) {
11271
+ return { position: 0, alreadyQueued: true };
11272
+ }
11273
+ // Check if already in overrides
11274
+ const existing = this.overrideEntries.findIndex((e) => e.path === path);
11275
+ if (existing !== -1) {
11276
+ return { position: existing, alreadyQueued: true };
11277
+ }
11278
+ this.overrideEntries.push({
11279
+ path,
11280
+ enqueuedAt: new Date().toISOString(),
11281
+ });
11282
+ const position = this.overrideEntries.length - 1;
11283
+ if (this.overrideEntries.length > DEPTH_WARNING_THRESHOLD) {
11284
+ this.logger.warn({ depth: this.overrideEntries.length }, 'Override queue depth exceeds threshold');
11285
+ }
11286
+ this.onEnqueueCallback?.();
11287
+ return { position, alreadyQueued: false };
11288
+ }
11289
+ /** Dequeue the next override entry, or undefined if empty. */
11290
+ dequeueOverride() {
11291
+ return this.overrideEntries.shift();
11292
+ }
11293
+ /** Get all override entries (shallow copy). */
11294
+ get overrides() {
11295
+ return [...this.overrideEntries];
11296
+ }
11297
+ /** Clear all override entries. Returns count removed. */
11298
+ clearOverrides() {
11299
+ const count = this.overrideEntries.length;
11300
+ this.overrideEntries = [];
11301
+ return count;
11302
+ }
11303
+ /** Check if a path is in the override layer. */
11304
+ hasOverride(path) {
11305
+ return this.overrideEntries.some((e) => e.path === path);
11306
+ }
11307
+ // ── Current-item tracking (3-layer model) ──────────────────────────
11308
+ /** Set the currently executing phase item. */
11309
+ setCurrentPhase(path, phase) {
11310
+ this.currentPhaseItem = {
11311
+ path,
11312
+ phase,
11313
+ startedAt: new Date().toISOString(),
11314
+ };
11315
+ }
11316
+ /** Clear the current phase item. */
11317
+ clearCurrentPhase() {
11318
+ this.currentPhaseItem = null;
11319
+ }
11320
+ /** The currently executing phase item, or null. */
11321
+ get currentPhase() {
11322
+ return this.currentPhaseItem;
11323
+ }
11324
+ // ── Legacy queue interface (preserved for backward compat) ─────────
10551
11325
  /**
10552
11326
  * Add a path to the synthesis queue.
10553
11327
  *
@@ -10556,11 +11330,9 @@ class SynthesisQueue {
10556
11330
  * @returns Position and whether the path was already queued.
10557
11331
  */
10558
11332
  enqueue(path, priority = false) {
10559
- // Check if currently being synthesized.
10560
11333
  if (this.currentItem?.path === path) {
10561
11334
  return { position: 0, alreadyQueued: true };
10562
11335
  }
10563
- // Check if already in queue.
10564
11336
  const existingIndex = this.queue.findIndex((item) => item.path === path);
10565
11337
  if (existingIndex !== -1) {
10566
11338
  return { position: existingIndex, alreadyQueued: true };
@@ -10583,11 +11355,7 @@ class SynthesisQueue {
10583
11355
  this.onEnqueueCallback?.();
10584
11356
  return { position, alreadyQueued: false };
10585
11357
  }
10586
- /**
10587
- * Remove and return the next item from the queue.
10588
- *
10589
- * @returns The next QueueItem, or undefined if the queue is empty.
10590
- */
11358
+ /** Remove and return the next item from the queue. */
10591
11359
  dequeue() {
10592
11360
  const item = this.queue.shift();
10593
11361
  if (item) {
@@ -10617,7 +11385,6 @@ class SynthesisQueue {
10617
11385
  }
10618
11386
  /**
10619
11387
  * Remove all pending items from the queue.
10620
- *
10621
11388
  * Does not affect the currently-running item.
10622
11389
  *
10623
11390
  * @returns The number of items removed.
@@ -10627,45 +11394,56 @@ class SynthesisQueue {
10627
11394
  this.queue = [];
10628
11395
  return count;
10629
11396
  }
10630
- /**
10631
- * Check whether a path is in the queue or currently being synthesized.
10632
- *
10633
- * @param path - Meta path to look up.
10634
- * @returns True if the path is queued or currently running.
10635
- */
11397
+ /** Check whether a path is in the queue or currently being synthesized. */
10636
11398
  has(path) {
10637
11399
  if (this.currentItem?.path === path)
10638
11400
  return true;
10639
- return this.queue.some((item) => item.path === path);
11401
+ if (this.currentPhaseItem?.path === path)
11402
+ return true;
11403
+ return (this.queue.some((item) => item.path === path) ||
11404
+ this.overrideEntries.some((e) => e.path === path));
10640
11405
  }
10641
- /**
10642
- * Get the 0-indexed position of a path in the queue.
10643
- *
10644
- * @param path - Meta path to look up.
10645
- * @returns Position index, or null if not found in the queue.
10646
- */
11406
+ /** Get the 0-indexed position of a path in the queue. */
10647
11407
  getPosition(path) {
11408
+ // Check overrides first
11409
+ const overrideIdx = this.overrideEntries.findIndex((e) => e.path === path);
11410
+ if (overrideIdx !== -1)
11411
+ return overrideIdx;
10648
11412
  const index = this.queue.findIndex((item) => item.path === path);
10649
11413
  return index === -1 ? null : index;
10650
11414
  }
10651
- /**
10652
- * Return a snapshot of queue state for the /status endpoint.
10653
- *
10654
- * @returns Queue depth and item list.
10655
- */
11415
+ /** Dequeue the next item: overrides first, then legacy queue. */
11416
+ nextItem() {
11417
+ const override = this.dequeueOverride();
11418
+ if (override)
11419
+ return { path: override.path, source: 'override' };
11420
+ const item = this.dequeue();
11421
+ if (item)
11422
+ return { path: item.path, source: 'legacy' };
11423
+ return undefined;
11424
+ }
11425
+ /** Return a snapshot of queue state for the /status endpoint. */
10656
11426
  getState() {
10657
11427
  return {
10658
- depth: this.queue.length,
10659
- items: this.queue.map((item) => ({
10660
- path: item.path,
10661
- priority: item.priority,
10662
- enqueuedAt: item.enqueuedAt,
10663
- })),
11428
+ depth: this.queue.length + this.overrideEntries.length,
11429
+ items: [
11430
+ ...this.overrideEntries.map((e) => ({
11431
+ path: e.path,
11432
+ priority: true,
11433
+ enqueuedAt: e.enqueuedAt,
11434
+ })),
11435
+ ...this.queue.map((item) => ({
11436
+ path: item.path,
11437
+ priority: item.priority,
11438
+ enqueuedAt: item.enqueuedAt,
11439
+ })),
11440
+ ],
10664
11441
  };
10665
11442
  }
10666
11443
  /**
10667
- * Process queued items one at a time until the queue is empty.
11444
+ * Process queued items one at a time until all queues are empty.
10668
11445
  *
11446
+ * Override entries are processed first (FIFO), then legacy queue items.
10669
11447
  * Re-entry is prevented: if already processing, the call returns
10670
11448
  * immediately. Errors are logged and do not block subsequent items.
10671
11449
  *
@@ -10676,19 +11454,22 @@ class SynthesisQueue {
10676
11454
  return;
10677
11455
  this.processing = true;
10678
11456
  try {
10679
- let item = this.dequeue();
10680
- while (item) {
11457
+ let next = this.nextItem();
11458
+ while (next) {
10681
11459
  try {
10682
- await synthesizeFn(item.path);
11460
+ await synthesizeFn(next.path);
10683
11461
  }
10684
11462
  catch (err) {
10685
- this.logger.error({ path: item.path, err }, 'Synthesis failed');
11463
+ this.logger.error({ path: next.path, err }, 'Synthesis failed');
10686
11464
  }
10687
- this.complete();
10688
- item = this.dequeue();
11465
+ this.clearCurrentPhase();
11466
+ if (next.source === 'legacy')
11467
+ this.complete();
11468
+ next = this.nextItem();
10689
11469
  }
10690
11470
  }
10691
11471
  finally {
11472
+ this.clearCurrentPhase();
10692
11473
  this.processing = false;
10693
11474
  }
10694
11475
  }
@@ -11160,14 +11941,15 @@ async function autoSeedPass(rules, watcher, logger) {
11160
11941
  }
11161
11942
 
11162
11943
  /**
11163
- * Croner-based scheduler that discovers the stalest meta candidate each tick
11164
- * and enqueues it for synthesis.
11944
+ * Croner-based scheduler that discovers the highest-priority ready phase
11945
+ * across the corpus each tick and enqueues it for execution.
11165
11946
  *
11166
11947
  * @module scheduler
11167
11948
  */
11168
11949
  const MAX_BACKOFF_MULTIPLIER = 4;
11169
11950
  /**
11170
- * Periodic scheduler that discovers stale meta candidates and enqueues them.
11951
+ * Periodic scheduler that discovers the highest-priority ready phase
11952
+ * across all metas and enqueues it for execution.
11171
11953
  *
11172
11954
  * Supports adaptive backoff when no candidates are found and hot-reloadable
11173
11955
  * cron expressions via {@link Scheduler.updateSchedule}.
@@ -11222,10 +12004,10 @@ class Scheduler {
11222
12004
  this.logger.info({ schedule: expression }, 'Schedule updated');
11223
12005
  }
11224
12006
  }
11225
- /** Reset backoff multiplier (call after successful synthesis). */
12007
+ /** Reset backoff multiplier (call after successful phase execution). */
11226
12008
  resetBackoff() {
11227
12009
  if (this.backoffMultiplier > 1) {
11228
- this.logger.debug('Backoff reset after successful synthesis');
12010
+ this.logger.debug('Backoff reset after successful phase execution');
11229
12011
  }
11230
12012
  this.backoffMultiplier = 1;
11231
12013
  }
@@ -11240,10 +12022,9 @@ class Scheduler {
11240
12022
  return this.job.nextRun() ?? null;
11241
12023
  }
11242
12024
  /**
11243
- * Single tick: discover stalest candidate and enqueue it.
12025
+ * Single tick: discover the highest-priority ready phase and enqueue it.
11244
12026
  *
11245
- * Skips if the queue is currently processing. Applies adaptive backoff
11246
- * when no candidates are found.
12027
+ * Applies adaptive backoff when no candidates are found.
11247
12028
  */
11248
12029
  async tick() {
11249
12030
  this.tickCount++;
@@ -11268,14 +12049,15 @@ class Scheduler {
11268
12049
  this.logger.warn({ err }, 'Auto-seed pass failed');
11269
12050
  }
11270
12051
  }
11271
- const candidate = await this.discoverStalest();
12052
+ const candidate = await this.discoverNextPhase();
11272
12053
  if (!candidate) {
11273
12054
  this.backoffMultiplier = Math.min(this.backoffMultiplier * 2, MAX_BACKOFF_MULTIPLIER);
11274
- this.logger.debug({ backoffMultiplier: this.backoffMultiplier }, 'No stale candidates found, increasing backoff');
12055
+ this.logger.debug({ backoffMultiplier: this.backoffMultiplier }, 'No ready phases found, increasing backoff');
11275
12056
  return;
11276
12057
  }
11277
- this.queue.enqueue(candidate);
11278
- this.logger.info({ path: candidate }, 'Enqueued stale candidate');
12058
+ // Enqueue using the legacy queue path (backward compat with processQueue)
12059
+ this.queue.enqueue(candidate.path);
12060
+ this.logger.info({ path: candidate.path, phase: candidate.phase, band: candidate.band }, 'Enqueued phase candidate');
11279
12061
  // Opportunistic watcher restart detection
11280
12062
  if (this.registrar) {
11281
12063
  try {
@@ -11295,22 +12077,27 @@ class Scheduler {
11295
12077
  }
11296
12078
  }
11297
12079
  /**
11298
- * Discover the stalest meta candidate via watcher.
12080
+ * Discover the highest-priority ready phase across the corpus.
12081
+ *
12082
+ * Uses phase-state-aware scheduling: priority order is
12083
+ * critic (band 1) \> builder (band 2) \> architect (band 3),
12084
+ * with weighted staleness as tiebreaker within a band.
11299
12085
  */
11300
- async discoverStalest() {
12086
+ async discoverNextPhase() {
11301
12087
  try {
11302
12088
  const result = await listMetas(this.config, this.watcher);
11303
- const stale = result.entries
11304
- .filter((e) => e.stalenessSeconds > 0 && !e.disabled)
11305
- .map((e) => ({
11306
- node: e.node,
11307
- meta: e.meta,
11308
- actualStaleness: e.stalenessSeconds,
11309
- }));
11310
- return discoverStalestPath(stale, this.config.depthWeight);
12089
+ const candidates = buildPhaseCandidates(result.entries);
12090
+ const winner = selectPhaseCandidate(candidates, this.config.depthWeight);
12091
+ if (!winner)
12092
+ return null;
12093
+ return {
12094
+ path: winner.node.metaPath,
12095
+ phase: winner.owedPhase,
12096
+ band: winner.band,
12097
+ };
11311
12098
  }
11312
12099
  catch (err) {
11313
- this.logger.warn({ err }, 'Failed to discover stalest candidate');
12100
+ this.logger.warn({ err }, 'Failed to discover next phase candidate');
11314
12101
  return null;
11315
12102
  }
11316
12103
  }
@@ -11516,9 +12303,12 @@ function registerMetasRoutes(app, deps) {
11516
12303
  'architectTokens',
11517
12304
  'builderTokens',
11518
12305
  'criticTokens',
12306
+ 'phaseState',
12307
+ 'owedPhase',
11519
12308
  ];
11520
12309
  const projectedFields = fieldList ?? defaultFields;
11521
12310
  const metas = entries.map((e) => {
12311
+ const ps = derivePhaseState(e.meta);
11522
12312
  const full = {
11523
12313
  path: e.path,
11524
12314
  depth: e.depth,
@@ -11533,6 +12323,8 @@ function registerMetasRoutes(app, deps) {
11533
12323
  architectTokens: e.architectTokens,
11534
12324
  builderTokens: e.builderTokens,
11535
12325
  criticTokens: e.criticTokens,
12326
+ phaseState: ps,
12327
+ owedPhase: getOwedPhase(ps),
11536
12328
  };
11537
12329
  const projected = {};
11538
12330
  for (const f of projectedFields) {
@@ -11557,13 +12349,6 @@ function registerMetasRoutes(app, deps) {
11557
12349
  }
11558
12350
  const meta = JSON.parse(await readFile(join(targetNode.metaPath, 'meta.json'), 'utf8'));
11559
12351
  // Field projection
11560
- const defaultExclude = new Set([
11561
- '_architect',
11562
- '_builder',
11563
- '_critic',
11564
- '_content',
11565
- '_feedback',
11566
- ]);
11567
12352
  const fieldList = query.fields?.split(',');
11568
12353
  const projectMeta = (m) => {
11569
12354
  if (fieldList) {
@@ -11574,7 +12359,7 @@ function registerMetasRoutes(app, deps) {
11574
12359
  }
11575
12360
  const r = {};
11576
12361
  for (const [k, v] of Object.entries(m)) {
11577
- if (!defaultExclude.has(k))
12362
+ if (!DEFAULT_EXCLUDE_FIELDS.has(k))
11578
12363
  r[k] = v;
11579
12364
  }
11580
12365
  return r;
@@ -11587,6 +12372,10 @@ function registerMetasRoutes(app, deps) {
11587
12372
  ? Math.round((Date.now() - new Date(metaTyped._generatedAt).getTime()) / 1000)
11588
12373
  : null;
11589
12374
  const score = computeStalenessScore(staleSeconds, metaTyped._depth ?? 0, metaTyped._emphasis ?? 1, config.depthWeight);
12375
+ // Phase state
12376
+ const entry = result.entries.find((e) => e.node.metaPath === targetNode.metaPath);
12377
+ const phaseState = entry ? derivePhaseState(entry.meta) : null;
12378
+ const owedPhase = phaseState ? getOwedPhase(phaseState) : null;
11590
12379
  const response = {
11591
12380
  path: targetNode.metaPath,
11592
12381
  meta: projectMeta(meta),
@@ -11599,6 +12388,8 @@ function registerMetasRoutes(app, deps) {
11599
12388
  seconds: staleSeconds,
11600
12389
  score: Math.round(score * 100) / 100,
11601
12390
  },
12391
+ phaseState,
12392
+ owedPhase,
11602
12393
  };
11603
12394
  // Cross-refs status
11604
12395
  const crossRefsRaw = meta._crossRefs;
@@ -11701,16 +12492,9 @@ function registerMetasUpdateRoute(app, deps) {
11701
12492
  Object.assign(updated, toSet);
11702
12493
  await writeFile(metaJsonPath, JSON.stringify(updated, null, 2) + '\n');
11703
12494
  // Project the response — exclude the same large fields as the detail route.
11704
- const defaultExclude = new Set([
11705
- '_architect',
11706
- '_builder',
11707
- '_critic',
11708
- '_content',
11709
- '_feedback',
11710
- ]);
11711
12495
  const projected = {};
11712
12496
  for (const [k, v] of Object.entries(updated)) {
11713
- if (!defaultExclude.has(k))
12497
+ if (!DEFAULT_EXCLUDE_FIELDS.has(k))
11714
12498
  projected[k] = v;
11715
12499
  }
11716
12500
  return reply.send({
@@ -11772,6 +12556,19 @@ function registerPreviewRoute(app, deps) {
11772
12556
  const structureChanged = structureHash !== meta._structureHash;
11773
12557
  const latestArchive = await readLatestArchive(targetNode.metaPath);
11774
12558
  const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
12559
+ // _architect change detection
12560
+ const architectChanged = latestArchive
12561
+ ? (meta._architect ?? '') !== (latestArchive._architect ?? '')
12562
+ : Boolean(meta._architect);
12563
+ // _crossRefs declaration change detection
12564
+ const currentRefs = (meta._crossRefs ?? []).slice().sort().join(',');
12565
+ const archiveRefs = (latestArchive?._crossRefs ?? [])
12566
+ .slice()
12567
+ .sort()
12568
+ .join(',');
12569
+ const crossRefsDeclChanged = latestArchive
12570
+ ? currentRefs !== archiveRefs
12571
+ : currentRefs.length > 0;
11775
12572
  const architectTriggered = isArchitectTriggered(meta, structureChanged, steerChanged, config.architectEvery);
11776
12573
  // Delta files
11777
12574
  const deltaFiles = getDeltaFiles(meta._generatedAt, scopeFiles);
@@ -11786,6 +12583,40 @@ function registerPreviewRoute(app, deps) {
11786
12583
  ? Math.round((Date.now() - new Date(meta._generatedAt).getTime()) / 1000)
11787
12584
  : null;
11788
12585
  const stalenessScore = computeStalenessScore(stalenessSeconds, meta._depth ?? 0, meta._emphasis ?? 1, config.depthWeight);
12586
+ // Phase state
12587
+ const phaseState = derivePhaseState(meta, {
12588
+ structureChanged,
12589
+ steerChanged,
12590
+ architectChanged,
12591
+ crossRefsChanged: crossRefsDeclChanged,
12592
+ architectEvery: config.architectEvery,
12593
+ });
12594
+ const owedPhase = getOwedPhase(phaseState);
12595
+ const priorityBand = getPriorityBand(phaseState);
12596
+ // Architect invalidators
12597
+ const architectInvalidators = [];
12598
+ if (owedPhase === 'architect') {
12599
+ if (structureChanged)
12600
+ architectInvalidators.push('structureHash');
12601
+ if (steerChanged)
12602
+ architectInvalidators.push('steer');
12603
+ if (architectChanged)
12604
+ architectInvalidators.push('_architect');
12605
+ if (crossRefsDeclChanged)
12606
+ architectInvalidators.push('_crossRefs');
12607
+ if ((meta._synthesisCount ?? 0) >= config.architectEvery) {
12608
+ architectInvalidators.push('architectEvery');
12609
+ }
12610
+ }
12611
+ // Staleness inputs
12612
+ const stalenessInputs = {
12613
+ structureHash,
12614
+ steerChanged,
12615
+ architectChanged,
12616
+ crossRefsDeclChanged,
12617
+ scopeMtimeMax: null,
12618
+ crossRefContentChanged: false,
12619
+ };
11789
12620
  return {
11790
12621
  path: targetNode.metaPath,
11791
12622
  staleness: {
@@ -11810,6 +12641,12 @@ function registerPreviewRoute(app, deps) {
11810
12641
  deltaCount: deltaFiles.length,
11811
12642
  },
11812
12643
  estimatedTokens,
12644
+ // New phase-state fields (additive)
12645
+ owedPhase,
12646
+ priorityBand,
12647
+ phaseState,
12648
+ stalenessInputs,
12649
+ architectInvalidators,
11813
12650
  };
11814
12651
  });
11815
12652
  }
@@ -11817,8 +12654,8 @@ function registerPreviewRoute(app, deps) {
11817
12654
  /**
11818
12655
  * Queue management and abort routes.
11819
12656
  *
11820
- * - GET /queue — current queue state
11821
- * - POST /queue/clear — remove all pending items
12657
+ * - GET /queue — 3-layer queue model (current, overrides, automatic, pending)
12658
+ * - POST /queue/clear — remove override entries only
11822
12659
  * - POST /synthesize/abort — abort the current synthesis
11823
12660
  *
11824
12661
  * @module routes/queue
@@ -11826,33 +12663,134 @@ function registerPreviewRoute(app, deps) {
11826
12663
  /** Register queue management routes. */
11827
12664
  function registerQueueRoutes(app, deps) {
11828
12665
  const { queue } = deps;
11829
- app.get('/queue', () => ({
11830
- current: queue.current,
11831
- pending: queue.pending,
11832
- state: queue.getState(),
11833
- }));
12666
+ app.get('/queue', async () => {
12667
+ const currentPhase = queue.currentPhase;
12668
+ const overrides = queue.overrides;
12669
+ // Compute owedPhase for each override entry by reading meta state
12670
+ const enrichedOverrides = await Promise.all(overrides.map(async (o) => {
12671
+ try {
12672
+ const metaDir = resolveMetaDir(o.path);
12673
+ const meta = await readMetaJson(metaDir);
12674
+ const ps = derivePhaseState(meta);
12675
+ return {
12676
+ path: o.path,
12677
+ owedPhase: getOwedPhase(ps),
12678
+ enqueuedAt: o.enqueuedAt,
12679
+ };
12680
+ }
12681
+ catch {
12682
+ return {
12683
+ path: o.path,
12684
+ owedPhase: null,
12685
+ enqueuedAt: o.enqueuedAt,
12686
+ };
12687
+ }
12688
+ }));
12689
+ // Compute automatic layer: all metas with a pending owed phase,
12690
+ // ranked by scheduler priority (computed on read, not persisted)
12691
+ let automatic = [];
12692
+ try {
12693
+ const metaResult = await listMetas(deps.config, deps.watcher);
12694
+ const candidates = buildPhaseCandidates(metaResult.entries);
12695
+ const ranked = rankPhaseCandidates(candidates, deps.config.depthWeight);
12696
+ automatic = ranked.map((c) => ({
12697
+ path: c.node.metaPath,
12698
+ owedPhase: c.owedPhase,
12699
+ priorityBand: c.band,
12700
+ effectiveStaleness: c.effectiveStaleness,
12701
+ }));
12702
+ }
12703
+ catch {
12704
+ // If listing fails, automatic stays empty
12705
+ }
12706
+ // Legacy: pending is the union of overrides + automatic + legacy queue items
12707
+ const pendingItems = [
12708
+ ...enrichedOverrides.map((o) => ({
12709
+ path: o.path,
12710
+ owedPhase: o.owedPhase,
12711
+ })),
12712
+ ...automatic.map((a) => ({
12713
+ path: a.path,
12714
+ owedPhase: a.owedPhase,
12715
+ })),
12716
+ ...queue.pending.map((item) => ({
12717
+ path: item.path,
12718
+ owedPhase: null,
12719
+ })),
12720
+ ];
12721
+ return {
12722
+ current: currentPhase
12723
+ ? {
12724
+ path: currentPhase.path,
12725
+ phase: currentPhase.phase,
12726
+ startedAt: currentPhase.startedAt,
12727
+ }
12728
+ : queue.current
12729
+ ? {
12730
+ path: queue.current.path,
12731
+ phase: null,
12732
+ startedAt: queue.current.enqueuedAt,
12733
+ }
12734
+ : null,
12735
+ overrides: enrichedOverrides,
12736
+ automatic,
12737
+ pending: pendingItems,
12738
+ // Legacy state
12739
+ state: queue.getState(),
12740
+ };
12741
+ });
11834
12742
  app.post('/queue/clear', () => {
11835
- const removed = queue.clear();
12743
+ const removed = queue.clearOverrides();
11836
12744
  return { cleared: removed };
11837
12745
  });
11838
12746
  app.post('/synthesize/abort', async (_request, reply) => {
11839
- const current = queue.current;
12747
+ // Check 3-layer current first
12748
+ const currentPhase = queue.currentPhase;
12749
+ const current = currentPhase ?? queue.current;
11840
12750
  if (!current) {
11841
- return reply
11842
- .status(404)
11843
- .send({ error: 'NOT_FOUND', message: 'No synthesis in progress' });
12751
+ return reply.status(200).send({ status: 'idle' });
11844
12752
  }
11845
12753
  // Abort the executor
11846
12754
  deps.executor?.abort();
12755
+ const metaDir = resolveMetaDir(current.path);
12756
+ const phase = currentPhase?.phase ?? null;
12757
+ // Transition running phase to failed and write _error to meta.json
12758
+ if (phase) {
12759
+ try {
12760
+ const meta = await readMetaJson(metaDir);
12761
+ let ps = derivePhaseState(meta);
12762
+ ps = phaseFailed(ps, phase);
12763
+ const updated = {
12764
+ ...meta,
12765
+ _phaseState: ps,
12766
+ _error: {
12767
+ step: phase,
12768
+ code: 'ABORT',
12769
+ message: 'Aborted by operator',
12770
+ },
12771
+ };
12772
+ const lockPath = join(metaDir, '.lock');
12773
+ const metaJsonPath = join(metaDir, 'meta.json');
12774
+ await writeFile(lockPath, JSON.stringify(updated, null, 2) + '\n');
12775
+ await copyFile(lockPath, metaJsonPath);
12776
+ }
12777
+ catch {
12778
+ // Best-effort — meta may be unreadable
12779
+ }
12780
+ }
11847
12781
  // Release the lock for the current meta path
11848
12782
  try {
11849
- releaseLock(resolveMetaDir(current.path));
12783
+ releaseLock(metaDir);
11850
12784
  }
11851
12785
  catch {
11852
12786
  // Lock may already be released
11853
12787
  }
11854
12788
  deps.logger.info({ path: current.path }, 'Synthesis aborted');
11855
- return { status: 'aborted', path: current.path };
12789
+ return {
12790
+ status: 'aborted',
12791
+ path: current.path,
12792
+ ...(phase ? { phase } : {}),
12793
+ };
11856
12794
  });
11857
12795
  }
11858
12796
 
@@ -11909,7 +12847,6 @@ async function checkDependency(url, path) {
11909
12847
  return { url, status: 'unreachable', checkedAt };
11910
12848
  }
11911
12849
  }
11912
- /** Check watcher, surfacing initialScan.active as indexing state. */
11913
12850
  async function checkWatcher(url) {
11914
12851
  const checkedAt = new Date().toISOString();
11915
12852
  try {
@@ -11935,26 +12872,61 @@ async function checkWatcher(url) {
11935
12872
  function deriveServiceState(deps) {
11936
12873
  if (deps.shuttingDown)
11937
12874
  return 'stopping';
11938
- if (deps.queue.current)
12875
+ if (deps.queue.current || deps.queue.currentPhase)
11939
12876
  return 'synthesizing';
11940
- if (deps.queue.depth > 0)
12877
+ if (deps.queue.depth > 0 || deps.queue.overrides.length > 0)
11941
12878
  return 'waiting';
11942
12879
  return 'idle';
11943
12880
  }
12881
+ function emptyPhaseCounts() {
12882
+ return { fresh: 0, stale: 0, pending: 0, running: 0, failed: 0 };
12883
+ }
11944
12884
  function registerStatusRoute(app, deps) {
11945
12885
  const statusHandler = createStatusHandler({
11946
12886
  name: SERVICE_NAME,
11947
12887
  version: SERVICE_VERSION,
11948
12888
  getHealth: async () => {
11949
- const { config, queue, scheduler, stats } = deps;
12889
+ const { config, queue, scheduler, stats, watcher } = deps;
11950
12890
  // On-demand dependency checks
11951
12891
  const [watcherHealth, gatewayHealth] = await Promise.all([
11952
12892
  checkWatcher(config.watcherUrl),
11953
12893
  checkDependency(config.gatewayUrl, '/status'),
11954
12894
  ]);
12895
+ // Phase state summary
12896
+ const phaseStateSummary = {
12897
+ architect: emptyPhaseCounts(),
12898
+ builder: emptyPhaseCounts(),
12899
+ critic: emptyPhaseCounts(),
12900
+ };
12901
+ let nextPhase = null;
12902
+ try {
12903
+ const metaResult = await listMetas(config, watcher);
12904
+ // Count raw phase states (before retry) for display
12905
+ for (const entry of metaResult.entries) {
12906
+ const ps = derivePhaseState(entry.meta);
12907
+ for (const phase of ['architect', 'builder', 'critic']) {
12908
+ phaseStateSummary[phase][ps[phase]]++;
12909
+ }
12910
+ }
12911
+ // Build candidates (with auto-retry) for scheduling
12912
+ const candidates = buildPhaseCandidates(metaResult.entries);
12913
+ // Find next phase candidate
12914
+ const winner = selectPhaseCandidate(candidates, config.depthWeight);
12915
+ if (winner) {
12916
+ nextPhase = {
12917
+ path: winner.node.metaPath,
12918
+ phase: winner.owedPhase,
12919
+ band: winner.band,
12920
+ staleness: winner.effectiveStaleness,
12921
+ };
12922
+ }
12923
+ }
12924
+ catch {
12925
+ // Watcher unreachable — phase summary unavailable
12926
+ }
11955
12927
  return {
11956
12928
  serviceState: deriveServiceState(deps),
11957
- currentTarget: queue.current?.path ?? null,
12929
+ currentTarget: queue.current?.path ?? queue.currentPhase?.path ?? null,
11958
12930
  queue: queue.getState(),
11959
12931
  stats: {
11960
12932
  totalSyntheses: stats.totalSyntheses,
@@ -11974,6 +12946,8 @@ function registerStatusRoute(app, deps) {
11974
12946
  },
11975
12947
  gateway: gatewayHealth,
11976
12948
  },
12949
+ phaseStateSummary,
12950
+ nextPhase,
11977
12951
  };
11978
12952
  },
11979
12953
  });
@@ -11986,6 +12960,9 @@ function registerStatusRoute(app, deps) {
11986
12960
  /**
11987
12961
  * POST /synthesize route handler.
11988
12962
  *
12963
+ * Path-targeted triggers create explicit override entries in the queue.
12964
+ * Corpus-wide triggers discover the stalest candidate.
12965
+ *
11989
12966
  * @module routes/synthesize
11990
12967
  */
11991
12968
  const synthesizeBodySchema = z.object({
@@ -11996,44 +12973,70 @@ function registerSynthesizeRoute(app, deps) {
11996
12973
  app.post('/synthesize', async (request, reply) => {
11997
12974
  const body = synthesizeBodySchema.parse(request.body);
11998
12975
  const { config, watcher, queue } = deps;
11999
- let targetPath;
12000
12976
  if (body.path) {
12001
- targetPath = resolveMetaDir(body.path);
12002
- }
12003
- else {
12004
- // Discover stalest candidate
12005
- let result;
12977
+ // Path-targeted trigger: create override entry
12978
+ const targetPath = resolveMetaDir(body.path);
12979
+ // Read meta to determine owed phase
12980
+ let owedPhase = null;
12981
+ let meta;
12006
12982
  try {
12007
- result = await listMetas(config, watcher);
12983
+ meta = await readMetaJson(targetPath);
12984
+ const phaseState = derivePhaseState(meta);
12985
+ owedPhase = getOwedPhase(phaseState);
12008
12986
  }
12009
12987
  catch {
12010
- return reply.status(503).send({
12011
- error: 'SERVICE_UNAVAILABLE',
12012
- message: 'Watcher unreachable — cannot discover candidates',
12013
- });
12988
+ // Meta unreadable — proceed, phase will be evaluated at dequeue time
12014
12989
  }
12015
- const stale = result.entries
12016
- .filter((e) => e.stalenessSeconds > 0 && !e.disabled)
12017
- .map((e) => ({
12018
- node: e.node,
12019
- meta: e.meta,
12020
- actualStaleness: e.stalenessSeconds,
12021
- }));
12022
- const stalest = discoverStalestPath(stale, config.depthWeight);
12023
- if (!stalest) {
12024
- return reply.code(200).send({
12990
+ // Fully fresh meta → skip (reuse meta already read above)
12991
+ if (owedPhase === null && meta && (meta._phaseState || meta._content)) {
12992
+ return await reply.code(200).send({
12025
12993
  status: 'skipped',
12026
- message: 'No stale metas found. Nothing to synthesize.',
12994
+ path: targetPath,
12995
+ owedPhase: null,
12996
+ queuePosition: -1,
12997
+ alreadyQueued: false,
12027
12998
  });
12028
12999
  }
12029
- targetPath = stalest;
13000
+ const result = queue.enqueueOverride(targetPath);
13001
+ return reply.code(202).send({
13002
+ status: 'queued',
13003
+ path: targetPath,
13004
+ owedPhase,
13005
+ queuePosition: result.position,
13006
+ alreadyQueued: result.alreadyQueued,
13007
+ });
13008
+ }
13009
+ // Corpus-wide trigger: discover stalest candidate
13010
+ let result;
13011
+ try {
13012
+ result = await listMetas(config, watcher);
12030
13013
  }
12031
- const result = queue.enqueue(targetPath, body.path !== undefined);
13014
+ catch {
13015
+ return reply.status(503).send({
13016
+ error: 'SERVICE_UNAVAILABLE',
13017
+ message: 'Watcher unreachable — cannot discover candidates',
13018
+ });
13019
+ }
13020
+ const stale = result.entries
13021
+ .filter((e) => e.stalenessSeconds > 0 && !e.disabled)
13022
+ .map((e) => ({
13023
+ node: e.node,
13024
+ meta: e.meta,
13025
+ actualStaleness: e.stalenessSeconds,
13026
+ }));
13027
+ const stalest = discoverStalestPath(stale, config.depthWeight);
13028
+ if (!stalest) {
13029
+ return reply.code(200).send({
13030
+ status: 'skipped',
13031
+ message: 'No stale metas found. Nothing to synthesize.',
13032
+ });
13033
+ }
13034
+ const enqueueResult = queue.enqueue(stalest);
12032
13035
  return reply.code(202).send({
12033
13036
  status: 'accepted',
12034
- path: targetPath,
12035
- queuePosition: result.position,
12036
- alreadyQueued: result.alreadyQueued,
13037
+ path: stalest,
13038
+ queuePosition: enqueueResult.position,
13039
+ alreadyQueued: enqueueResult.alreadyQueued,
12037
13040
  });
12038
13041
  });
12039
13042
  }
@@ -12071,6 +13074,17 @@ function registerUnlockRoute(app, deps) {
12071
13074
  *
12072
13075
  * @module routes
12073
13076
  */
13077
+ /**
13078
+ * Large generated fields excluded from detail/update response projections.
13079
+ * Shared between metas detail and metasUpdate routes.
13080
+ */
13081
+ const DEFAULT_EXCLUDE_FIELDS = new Set([
13082
+ '_architect',
13083
+ '_builder',
13084
+ '_critic',
13085
+ '_content',
13086
+ '_feedback',
13087
+ ]);
12074
13088
  /** Register all HTTP routes on the Fastify instance. */
12075
13089
  function registerRoutes(app, deps) {
12076
13090
  // Global error handler for validation + watcher errors
@@ -12325,10 +13339,9 @@ async function startService(config, configPath) {
12325
13339
  }
12326
13340
  // Progress reporter — uses shared config reference so hot-reload propagates
12327
13341
  const progress = new ProgressReporter(config, logger);
12328
- // Wire queue processing — synthesize one meta per dequeue
13342
+ // Wire queue processing — execute one phase per dequeue (phase-state machine)
12329
13343
  const synthesizeFn = async (path) => {
12330
13344
  const startMs = Date.now();
12331
- let cycleTokens = 0;
12332
13345
  // Strip .meta suffix for human-readable progress reporting
12333
13346
  const ownerPath = path.replace(/\/?\.meta\/?$/, '');
12334
13347
  await progress.report({
@@ -12336,49 +13349,50 @@ async function startService(config, configPath) {
12336
13349
  path: ownerPath,
12337
13350
  });
12338
13351
  try {
12339
- const results = await orchestrate(config, executor, watcher, path, async (evt) => {
13352
+ const result = await orchestratePhase(config, executor, watcher, path, async (evt) => {
13353
+ // Wire current-phase tracking for GET /queue and POST /synthesize/abort
13354
+ if (evt.type === 'phase_start' && evt.phase) {
13355
+ queue.setCurrentPhase(ownerPath, evt.phase);
13356
+ }
12340
13357
  // Track token stats from phase completions
12341
13358
  if (evt.type === 'phase_complete') {
12342
13359
  if (evt.tokens !== undefined) {
12343
13360
  stats.totalTokens += evt.tokens;
12344
- if (cycleTokens !== undefined) {
12345
- cycleTokens += evt.tokens;
12346
- }
12347
13361
  }
12348
13362
  else {
12349
- cycleTokens = undefined;
12350
13363
  logger.warn({ path: ownerPath, phase: evt.phase }, 'Token count unavailable (session lookup may have timed out)');
12351
13364
  }
12352
13365
  }
12353
13366
  await progress.report(evt);
12354
13367
  }, logger);
12355
- // orchestrate() always returns exactly one result
12356
- const result = results[0];
12357
13368
  const durationMs = Date.now() - startMs;
12358
- if (!result.synthesized) {
12359
- // Entity was skipped (e.g. empty scope) — no progress to report.
12360
- logger.debug({ path: ownerPath }, 'Synthesis skipped');
13369
+ if (!result.executed) {
13370
+ logger.debug({ path: ownerPath }, 'Phase skipped (fully fresh or locked)');
12361
13371
  return;
12362
13372
  }
12363
13373
  // Update stats
12364
13374
  stats.totalSyntheses++;
12365
13375
  stats.lastCycleDurationMs = durationMs;
12366
13376
  stats.lastCycleAt = new Date().toISOString();
12367
- if (result.error) {
13377
+ const phaseResult = result.phaseResult;
13378
+ if (phaseResult?.error) {
12368
13379
  stats.totalErrors++;
12369
13380
  await progress.report({
12370
13381
  type: 'error',
12371
13382
  path: ownerPath,
12372
- phase: result.error.step,
12373
- error: result.error.message,
13383
+ phase: phaseResult.error.step,
13384
+ error: phaseResult.error.message,
12374
13385
  });
12375
13386
  }
12376
13387
  else {
13388
+ // Task #9: Reset backoff on ANY successful phase execution
12377
13389
  scheduler.resetBackoff();
13390
+ }
13391
+ // Emit synthesis_complete only on full-cycle completion
13392
+ if (result.cycleComplete) {
12378
13393
  await progress.report({
12379
13394
  type: 'synthesis_complete',
12380
13395
  path: ownerPath,
12381
- tokens: cycleTokens,
12382
13396
  durationMs,
12383
13397
  });
12384
13398
  }
@@ -12498,4 +13512,4 @@ const metaDescriptor = jeevesComponentDescriptorSchema.parse({
12498
13512
  customCliCommands: registerCustomCliCommands,
12499
13513
  });
12500
13514
 
12501
- export { DEFAULT_PORT, DEFAULT_PORT_STR, GatewayExecutor, HttpWatcherClient, MAX_STALENESS_SECONDS, ProgressReporter, RESTART_REQUIRED_FIELDS, RuleRegistrar, SERVICE_NAME, SERVICE_VERSION, Scheduler, SynthesisQueue, acquireLock, actualStaleness, buildArchitectTask, buildBuilderTask, buildContextPackage, buildCriticTask, buildOwnershipTree, cleanupStaleLocks, computeEffectiveStaleness, computeEma, computeStructureHash, createLogger, createServer, createSnapshot, discoverMetas, filterInScope, findNode, formatProgressEvent, getScopePrefix, hasSteerChanged, isArchitectTriggered, isLocked, isStale, listArchiveFiles, listMetas, loadServiceConfig, mergeAndWrite, metaConfigSchema, metaDescriptor, metaErrorSchema, metaJsonSchema, migrateConfigPath, normalizePath, orchestrate, parseArchitectOutput, parseBuilderOutput, parseCriticOutput, pruneArchive, readLatestArchive, readLockState, registerCustomCliCommands, registerRoutes, registerShutdownHandlers, releaseLock, resolveConfigPath, resolveMetaDir, selectCandidate, serviceConfigSchema, sleepAsync as sleep, startService, toMetaError, verifyRuleApplication };
13515
+ export { DEFAULT_PORT, DEFAULT_PORT_STR, GatewayExecutor, HttpWatcherClient, MAX_STALENESS_SECONDS, ProgressReporter, RESTART_REQUIRED_FIELDS, RuleRegistrar, SERVICE_NAME, SERVICE_VERSION, Scheduler, SynthesisQueue, acquireLock, actualStaleness, buildArchitectTask, buildBuilderTask, buildContextPackage, buildCriticTask, buildOwnershipTree, cleanupStaleLocks, computeEffectiveStaleness, computeEma, computeStructureHash, createLogger, createServer, createSnapshot, discoverMetas, filterInScope, findNode, formatProgressEvent, getScopePrefix, hasSteerChanged, isArchitectTriggered, isLocked, isStale, listArchiveFiles, listMetas, loadServiceConfig, mergeAndWrite, metaConfigSchema, metaDescriptor, metaErrorSchema, metaJsonSchema, migrateConfigPath, normalizePath, orchestrate, orchestratePhase, parseArchitectOutput, parseBuilderOutput, parseCriticOutput, pruneArchive, readLatestArchive, readLockState, registerCustomCliCommands, registerRoutes, registerShutdownHandlers, releaseLock, resolveConfigPath, resolveMetaDir, runArchitect, runBuilder, runCritic, selectCandidate, serviceConfigSchema, sleepAsync as sleep, startService, toMetaError, verifyRuleApplication };