@adhdev/daemon-core 1.0.28-rc.10 → 1.0.28-rc.12

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.
@@ -18,6 +18,8 @@ import { shortHash } from '../system/hash.js';
18
18
  import type { CliProviderModule } from '../cli-adapters/provider-cli-adapter.js';
19
19
  import type { MeshSendKeyItem, MeshSendKeyName } from '../cli-adapters/provider-cli-shared.js';
20
20
  import { resolveTranscriptAuthorityProfile } from './transcript-evidence.js';
21
+ import { TranscriptSignalSource } from './transcript-signal-source.js';
22
+ import type { SignalSnapshot } from './spec/signal-envelope.js';
21
23
  import { createCliAdapter } from './spec/route.js';
22
24
  import type { PtyRuntimeMetadata, PtyTransportFactory } from '../cli-adapters/pty-transport.js';
23
25
  import { StatusMonitor } from './status-monitor.js';
@@ -336,18 +338,20 @@ export class CliProviderInstance implements ProviderInstance {
336
338
  // MESH_WORKER_STALL_REFIRE_COOLDOWN_MS between successive emissions across
337
339
  // anchor re-arms (the per-anchor guard only covers a single continuous stall).
338
340
  private meshStallLastFiredAt = -1;
339
- // KIMI-MESH-COMPLETION-EMIT (axis 1): the native-source transcript progress
340
- // fingerprint (record count + source mtime) observed the LAST time the stall
341
- // watchdog checked a native-source provider (transcriptAuthority=provider e.g.
342
- // kimi wire.jsonl). A pure-PTY native-source worker running a long, screen-quiet
343
- // tool (no viewport bytes for minutes) looks stalled by the lastOutputAt clock
344
- // even though its transcript file is still growing. Before firing the stall, we
345
- // compare the current transcript fingerprint against this snapshot; if the
346
- // transcript advanced, the "stall" is a false positive of PTY-render stasis, so
347
- // we re-arm the anchor instead of paging the coordinator (whose no_progress
348
- // handling can lead to the worker being stopped mid-work → completion never
349
- // emitted). null = not yet sampled for this session/anchor.
350
- private meshStallNativeTranscriptSample: { msgCount: number; sourceMtimeMs: number } | null = null;
341
+ // KIMI-MESH-COMPLETION-EMIT (axis 1) TX-FSM Stage 1: whether the stall
342
+ // watchdog has consumed a usable native-transcript SIGNAL SNAPSHOT for the
343
+ // current stall episode. A pure-PTY native-source worker running a long,
344
+ // screen-quiet tool (no viewport bytes for minutes) looks stalled by the
345
+ // lastOutputAt clock even though its transcript file is still growing.
346
+ // Before firing the stall, the watchdog consults the shared
347
+ // TranscriptSignalSource's in_turn_progress signal; if the transcript is
348
+ // advancing, the "stall" is a false positive of PTY-render stasis, so we
349
+ // re-arm the anchor instead of paging the coordinator (whose no_progress
350
+ // handling can lead to the worker being stopped mid-work → completion
351
+ // never emitted). The FIRST usable sample of an episode still re-arms
352
+ // unconditionally (no episode-scoped baseline exists yet to prove stasis
353
+ // against) — the historical `!prev → advanced` semantics, preserved.
354
+ private meshStallTranscriptSignalSampled = false;
351
355
  // FALSE-IDLE continuity epoch: monotonically bumped on EVERY entry into a busy
352
356
  // phase (→generating or →waiting_approval). The completedDebouncePending snapshots
353
357
  // this value at arm time (busyEpochAtArm); the flush guard requires it UNCHANGED
@@ -1426,6 +1430,17 @@ export class CliProviderInstance implements ProviderInstance {
1426
1430
  private completedDebounceTimer: NodeJS.Timeout | null = null;
1427
1431
  private completedDebouncePending: CompletedDebouncePending | null = null;
1428
1432
  private lastExternalCompletionProbe: ExternalTranscriptProbe | null = null;
1433
+ /** TX-FSM: lazily-created transcript signal normalizer. Fed ONLY by
1434
+ * transcript reads this instance already performs — it adds zero I/O.
1435
+ * Stage 0: its output was a pure shadow observation for the FSM driver.
1436
+ * Stage 1: the instance's own stall/growth-hold judgments consume the
1437
+ * normalized snapshot too (single source of truth). */
1438
+ private transcriptSignalSource: TranscriptSignalSource | null = null;
1439
+ /** TX-FSM Stage 1: the latest snapshot the source produced (set inside
1440
+ * publishTranscriptSignalObservation). Consumed the same tick by the
1441
+ * stall-path / growth-hold judgments via probeNativeTranscriptSignals —
1442
+ * never treated as fresh across ticks. */
1443
+ private lastTranscriptSignalSnapshot: SignalSnapshot | null = null;
1429
1444
  /**
1430
1445
  * The final assistant summary of the last completed turn, cached at
1431
1446
  * completion-emit time. For a native-source provider (antigravity) whose
@@ -1649,6 +1664,7 @@ export class CliProviderInstance implements ProviderInstance {
1649
1664
  });
1650
1665
  if (restoredHistory.source !== 'provider-native') {
1651
1666
  this.lastExternalCompletionProbe = null;
1667
+ this.publishTranscriptSignalObservation(null);
1652
1668
  return null;
1653
1669
  }
1654
1670
  this.lastExternalCompletionProbe = buildExternalTranscriptProbe(
@@ -1656,9 +1672,61 @@ export class CliProviderInstance implements ProviderInstance {
1656
1672
  restoredHistory.sourcePath,
1657
1673
  restoredHistory.sourceMtimeMs,
1658
1674
  );
1675
+ this.publishTranscriptSignalObservation(restoredHistory.messages);
1659
1676
  return restoredHistory.messages;
1660
1677
  }
1661
1678
 
1679
+ /**
1680
+ * TX-FSM: normalize the transcript read that JUST happened into a
1681
+ * SignalSnapshot. Stage 0 injected it into the FSM driver as a pure
1682
+ * shadow observation (daemon → SpecCliAdapter → FsmDriver); Stage 1
1683
+ * additionally caches it (lastTranscriptSignalSnapshot) so the instance's
1684
+ * OWN stall/growth-hold judgments consume the SAME normalized snapshot
1685
+ * instead of re-running private transcript scans. Fed ONLY by reads this
1686
+ * method's caller already performs — it adds zero I/O, so the getState()
1687
+ * zero-native-read invariant and the stall-path read cadence are
1688
+ * untouched. The source update runs regardless of the adapter hook so a
1689
+ * non-spec provider still produces the instance-side snapshot (the FSM
1690
+ * injection is simply skipped there). Fail-open end to end — an
1691
+ * unresolved transcript or any throw degrades to "no observation", never
1692
+ * to a wedge.
1693
+ */
1694
+ private publishTranscriptSignalObservation(messages: unknown[] | null, error = false): void {
1695
+ try {
1696
+ if (!this.transcriptSignalSource) {
1697
+ this.transcriptSignalSource = new TranscriptSignalSource({
1698
+ label: this.type,
1699
+ // Choke point: class/timing come from the P0 profile
1700
+ // resolver, never from raw predicates or provider names.
1701
+ profile: resolveTranscriptAuthorityProfile(this.provider),
1702
+ turnStartedAt: () => {
1703
+ const t = (this.adapter as any)?.currentTurnStartedAt;
1704
+ if (typeof t === 'number' && Number.isFinite(t)) return t;
1705
+ // Mesh fallback: for an emitsPtyTurnEvents=false worker
1706
+ // (idle→idle collapse) currentTurnStartedAt may never
1707
+ // bind; scope to the task injection instead — the SAME
1708
+ // boundary the stall-path rescue uses, so the signal and
1709
+ // the rescue's payload extraction agree on the turn.
1710
+ return this.meshTaskInjectedAt > 0 ? this.meshTaskInjectedAt : undefined;
1711
+ },
1712
+ // Reuse the exact completion machinery (I1) for the
1713
+ // final_assistant_present signal rather than duplicating
1714
+ // the message scan.
1715
+ finalAssistantPresent: (msgs, ts) => this.completionHasFinalAssistantMessage(msgs, ts),
1716
+ growthQuietMs: MISSING_ASSISTANT_TRANSCRIPT_GROWTH_QUIET_MS,
1717
+ });
1718
+ }
1719
+ const snapshot = this.transcriptSignalSource.update(
1720
+ { messages, probe: this.lastExternalCompletionProbe, error },
1721
+ );
1722
+ this.lastTranscriptSignalSnapshot = snapshot;
1723
+ const adapter = this.adapter as { setSignalObservation?: (snapshot: unknown) => void } | null | undefined;
1724
+ if (typeof adapter?.setSignalObservation === 'function') {
1725
+ adapter.setSignalObservation(snapshot);
1726
+ }
1727
+ } catch { /* signal collection must never break the read path */ }
1728
+ }
1729
+
1662
1730
  /**
1663
1731
  * The content of the LAST visible assistant bubble in a message list, or ''
1664
1732
  * when the tail is not an assistant reply. Skips trailing system/tool/activity
@@ -2506,31 +2574,38 @@ export class CliProviderInstance implements ProviderInstance {
2506
2574
  const stalledMs = now - this.meshStallAnchorAt;
2507
2575
  if (stalledMs < threshold) return;
2508
2576
 
2509
- // KIMI-MESH-COMPLETION-EMIT (axis 1): transcript-aware stall for native-source
2510
- // providers. A pure-PTY native-source worker (transcriptAuthority=provider,
2511
- // e.g. kimi) running a long, screen-quiet tool emits no viewport bytes for
2512
- // minutes, so the lastOutputAt clock above reads it as stalled — but its
2513
- // authoritative transcript (wire.jsonl) is still being appended. Firing
2514
- // monitor:no_progress here surfaces a false stall to the coordinator whose
2515
- // downstream handling can stop the worker mid-work, killing the emit window
2516
- // before the real completion propagates. So before firing, sample the native
2517
- // transcript's progress fingerprint: if it advanced (more records OR a fresher
2518
- // source mtime) since the last sample, the worker is demonstrably alive — treat
2519
- // it as progress. Re-arm the anchor to `now` and reset the episode so the clock
2520
- // restarts from this proven-live moment; only a transcript that is ALSO static
2521
- // falls through to the genuine-stall fire below. No-op for pure-PTY providers
2522
- // (sample is null unchanged behavior).
2523
- const nativeSample = this.sampleNativeTranscriptProgress();
2524
- if (nativeSample) {
2525
- const prev = this.meshStallNativeTranscriptSample;
2526
- const advanced = !prev
2527
- || nativeSample.msgCount > prev.msgCount
2528
- || nativeSample.sourceMtimeMs > prev.sourceMtimeMs;
2529
- this.meshStallNativeTranscriptSample = nativeSample;
2530
- if (advanced) {
2577
+ // KIMI-MESH-COMPLETION-EMIT (axis 1) — TX-FSM Stage 1: transcript-aware
2578
+ // stall for native-source providers, now delegated to the SHARED
2579
+ // TranscriptSignalSource. A pure-PTY native-source worker
2580
+ // (transcriptAuthority=provider, e.g. kimi) running a long, screen-quiet
2581
+ // tool emits no viewport bytes for minutes, so the lastOutputAt clock
2582
+ // above reads it as stalled but its authoritative transcript
2583
+ // (wire.jsonl) is still being appended. Firing monitor:no_progress here
2584
+ // surfaces a false stall to the coordinator whose downstream handling
2585
+ // can stop the worker mid-work, killing the emit window before the real
2586
+ // completion propagates. So before firing, consume the signal source's
2587
+ // in_turn_progress (transcript advanced since the previous sample OR
2588
+ // source mtime inside the growth-quiet window the same liveness test
2589
+ // the completion growth-hold uses, normalized from the ONE read this
2590
+ // tick): if true, the worker is demonstrably alive treat it as
2591
+ // progress. Re-arm the anchor to `now` and reset the episode so the
2592
+ // clock restarts from this proven-live moment; only a transcript that
2593
+ // is ALSO static falls through to the genuine-stall fire below. The
2594
+ // FIRST usable sample of an episode re-arms unconditionally — no
2595
+ // episode-scoped baseline exists yet to prove stasis against (the
2596
+ // historical `!prev → advanced` semantics). Fail-open: a non-native-
2597
+ // source class, an unresolved transcript, or a read error yields no
2598
+ // usable snapshot → fall through to the unchanged lastOutputAt-only
2599
+ // judgment.
2600
+ const transcriptSignals = this.probeNativeTranscriptSignals();
2601
+ if (transcriptSignals?.snapshot?.available === true) {
2602
+ const firstSampleThisEpisode = !this.meshStallTranscriptSignalSampled;
2603
+ this.meshStallTranscriptSignalSampled = true;
2604
+ const signalDetail = transcriptSignals.snapshot.detail;
2605
+ if (firstSampleThisEpisode || transcriptSignals.snapshot.signals.in_turn_progress === true) {
2531
2606
  if (this.isMeshWorkerSession()) {
2532
2607
  traceMeshEventDrop('mesh_worker_stall_transcript_advancing', this.meshTraceCtx('monitor:no_progress'),
2533
- `msgCount=${nativeSample.msgCount} sourceMtime=${nativeSample.sourceMtimeMs} (PTY quiet ${Math.round(stalledMs / 1000)}s but transcript advancing)`);
2608
+ `msgCount=${signalDetail.msgCount} sourceMtime=${signalDetail.sourceMtimeMs} (PTY quiet ${Math.round(stalledMs / 1000)}s but transcript advancing)`);
2534
2609
  }
2535
2610
  this.meshStallAnchorAt = now;
2536
2611
  this.meshStallEmittedForAnchor = false;
@@ -2545,11 +2620,12 @@ export class CliProviderInstance implements ProviderInstance {
2545
2620
  // collapse) sits at a STATIC idle prompt; the status-agnostic watchdog
2546
2621
  // would misread that finished-but-quiet state as a wedge and false-fire
2547
2622
  // monitor:no_progress. If the class-appropriate transcript (PTY parse or
2548
- // authoritative native history completionFinalSummary picks) proves a
2549
- // finished turn, emit the missing completion and SUPPRESS the stall. A
2550
- // genuinely mid-turn / wedged worker with no in-turn final assistant falls
2551
- // through and the real stall fires below unchanged.
2552
- if (this.tryReconcileTranscriptCompletionForStall(observedStatus)) {
2623
+ // authoritative native history) proves a finished turn, emit the missing
2624
+ // completion and SUPPRESS the stall. A genuinely mid-turn / wedged worker
2625
+ // with no in-turn final assistant falls through and the real stall fires
2626
+ // below unchanged. TX-FSM Stage 1: the native-source verdict consumes the
2627
+ // SAME snapshot axis 1 just produced — no second read.
2628
+ if (this.tryReconcileTranscriptCompletionForStall(observedStatus, transcriptSignals)) {
2553
2629
  this.meshStallEmittedForAnchor = true;
2554
2630
  return;
2555
2631
  }
@@ -2604,49 +2680,51 @@ export class CliProviderInstance implements ProviderInstance {
2604
2680
  this.meshStallEmittedForAnchor = false;
2605
2681
  this.meshStallTurnActiveLast = undefined;
2606
2682
  this.meshStallLastFiredAt = -1;
2607
- this.meshStallNativeTranscriptSample = null;
2683
+ this.meshStallTranscriptSignalSampled = false;
2608
2684
  }
2609
2685
 
2686
+ /** The result of one native-transcript signal probe: the normalized
2687
+ * snapshot the shared TranscriptSignalSource produced from the read, and
2688
+ * the very messages it was normalized from (so a judgment site can pull
2689
+ * a payload — e.g. the final summary — from the SAME read with zero
2690
+ * added I/O). */
2610
2691
  /**
2611
- * KIMI-MESH-COMPLETION-EMIT (axis 1). For a native-source provider
2612
- * (transcriptAuthority=provider its authoritative history is an on-disk
2613
- * transcript file, e.g. kimi's wire.jsonl), sample the transcript's current
2614
- * progress fingerprint (record count + source-file mtime) WITHOUT parsing the
2615
- * PTY. Used by the stall watchdog to distinguish "PTY render is quiet but the
2616
- * worker is still doing long tool work (transcript growing)" from a genuine
2617
- * wedge. Returns null for a pure-PTY provider (no native source) or when the
2618
- * source cannot be resolved this tick — the caller then falls back to the
2619
- * unchanged lastOutputAt-only judgment.
2692
+ * TX-FSM Stage 1 the single native-transcript signal probe (replaces
2693
+ * the Stage-0 sampleNativeTranscriptProgress fingerprint sampler). For a
2694
+ * native-source provider (its authoritative history is an on-disk
2695
+ * transcript file, e.g. kimi's wire.jsonl), perform the read this
2696
+ * judgment point already owns SAME cadence as before, one
2697
+ * readExternalCompletionMessages() per call, never more and return the
2698
+ * NORMALIZED SignalSnapshot the shared TranscriptSignalSource produced
2699
+ * from it (publishTranscriptSignalObservation runs inside the read), plus
2700
+ * the messages that read returned. Judgment sites (the stall watchdog's
2701
+ * transcript-advancing axis, the completion growth-hold, the stall-path
2702
+ * completion rescue) consume the snapshot's SIGNALS instead of running
2703
+ * their own fingerprint/freshness/final-assistant scans — one source of
2704
+ * truth for "what does the transcript say right now".
2620
2705
  *
2621
- * Generalized on the provider's native-source flag, NOT a hardcoded provider
2622
- * type, so every current and future pure-PTY long-tool native-source provider
2623
- * benefits. Cheap enough for the stall path: it runs only at the stall threshold
2624
- * (≥180s of PTY stasis), never on the routine 5s tick.
2706
+ * Class gating goes through resolveTranscriptAuthorityProfile ONLY.
2707
+ * Returns null for a non-native-source class (nothing to signal from) and
2708
+ * a null snapshot when the read threw callers keep their fail-open
2709
+ * fallbacks ("couldn't tell" never blocks an idle verdict and never
2710
+ * fabricates a completion). Cheap enough for the stall path: it runs
2711
+ * only at the stall threshold (≥180s of PTY stasis) or during an armed
2712
+ * completion-debounce retry, never on the routine 5s tick.
2625
2713
  */
2626
- private sampleNativeTranscriptProgress(): { msgCount: number; sourceMtimeMs: number } | null {
2627
- // authority-ok: native fingerprint SAMPLING, not a stall verdict. This only
2628
- // decides whether a native source exists to sample a progress fingerprint from;
2629
- // the caller (checkMeshWorkerStall) makes the stall/no-progress decision over the
2630
- // returned fingerprint. Non-native-source classes have nothing to sample here and
2631
- // fall back to the unchanged lastOutputAt-only judgment.
2632
- if (!isNativeSourceCanonicalHistory(this.provider?.nativeHistory)) return null;
2714
+ private probeNativeTranscriptSignals(): { snapshot: SignalSnapshot | null; messages: unknown[] | null } | null {
2715
+ if (resolveTranscriptAuthorityProfile(this.provider).class !== 'native-source') return null;
2633
2716
  // readExternalCompletionMessages resolves this session's OWN native-source
2634
2717
  // conversation (providerSessionId / persisted pin / floor claim) and, as a
2635
- // side effect, refreshes this.lastExternalCompletionProbe with the source
2636
- // path, mtime, and message count. Reusing it keeps the resolution logic in
2637
- // one place and immune to the antigravity-style session-id quirks.
2718
+ // side effect, feeds the shared TranscriptSignalSource (which refreshes
2719
+ // this.lastTranscriptSignalSnapshot). Reusing it keeps the resolution
2720
+ // logic in one place and immune to the antigravity-style session-id quirks.
2638
2721
  let messages: unknown[] | null = null;
2639
2722
  try {
2640
2723
  messages = this.readExternalCompletionMessages();
2641
2724
  } catch {
2642
- return null; // best-effort: an unresolved transcript → fall back to PTY clock
2725
+ return { snapshot: null, messages: null }; // best-effort: fail-open
2643
2726
  }
2644
- const probe = this.lastExternalCompletionProbe;
2645
- if (!probe) return null;
2646
- return {
2647
- msgCount: typeof probe.msgCount === 'number' ? probe.msgCount : (Array.isArray(messages) ? messages.length : 0),
2648
- sourceMtimeMs: typeof probe.sourceMtimeMs === 'number' ? probe.sourceMtimeMs : 0,
2649
- };
2727
+ return { snapshot: this.lastTranscriptSignalSnapshot, messages };
2650
2728
  }
2651
2729
 
2652
2730
  /**
@@ -2749,8 +2827,25 @@ export class CliProviderInstance implements ProviderInstance {
2749
2827
  * false for a daemon-owned provider and for a genuinely mid-turn / wedged
2750
2828
  * worker with no in-turn final assistant, so a real stall still fires
2751
2829
  * unchanged. Evidence bar is identical to flushMeshCompletionBeforeCleanup.
2830
+ *
2831
+ * TX-FSM Stage 1 (FIX 3 probe delegation): for a native-source class the
2832
+ * completion VERDICT is the shared TranscriptSignalSource's
2833
+ * final_assistant_present signal, normalized from the ONE transcript read
2834
+ * the stall watchdog already performed this tick (passed in as
2835
+ * `transcriptSignals`) — not a second private read + scan. The emit
2836
+ * payload (finalSummary) is extracted from the SAME messages the signal
2837
+ * was normalized from, with the SAME turn boundary the legacy path used,
2838
+ * so the extraction re-proves the turn scope the signal checked. Fail-open
2839
+ * both ways: no usable snapshot (read failed / unresolved / a pure-PTY
2840
+ * class, which has no native signal by construction) → the legacy
2841
+ * class-appropriate evidence path (completionFinalSummary), unchanged; and
2842
+ * a present-but-unextractable signal yields false rather than a
2843
+ * payload-less emit.
2752
2844
  */
2753
- private tryReconcileTranscriptCompletionForStall(observedStatus: string): boolean {
2845
+ private tryReconcileTranscriptCompletionForStall(
2846
+ observedStatus: string,
2847
+ transcriptSignals?: { snapshot: SignalSnapshot | null; messages: unknown[] | null } | null,
2848
+ ): boolean {
2754
2849
  // daemon-owned transcripts get real PTY turn events — an idle-quiet
2755
2850
  // daemon-owned worker with no completion emit is a genuine anomaly the
2756
2851
  // stall should surface, exactly as before this unification.
@@ -2779,14 +2874,29 @@ export class CliProviderInstance implements ProviderInstance {
2779
2874
  try {
2780
2875
  parsedMessages = this.adapter?.getScriptParsedStatus?.()?.messages;
2781
2876
  } catch { parsedMessages = undefined; }
2782
- // completionFinalSummary turn-scopes the class-appropriate transcript (native
2783
- // history for a native-source provider, the PTY parse otherwise) — a stale
2784
- // prior-turn tail or a mid-turn worker with no in-turn final assistant yields
2785
- // '' return false below.
2877
+ // TX-FSM Stage 1: a native-source class with a usable signal snapshot
2878
+ // takes its verdict from the SHARED signal source (final_assistant_present),
2879
+ // and the payload from the SAME messages zero added I/O. Every other
2880
+ // case falls back to completionFinalSummary, which turn-scopes the
2881
+ // class-appropriate transcript (native history for a native-source
2882
+ // provider, the PTY parse otherwise) — a stale prior-turn tail or a
2883
+ // mid-turn worker with no in-turn final assistant yields '' → return
2884
+ // false below.
2786
2885
  let finalSummary: string | undefined;
2787
- try {
2788
- finalSummary = this.completionFinalSummary(parsedMessages, turnStartedAt);
2789
- } catch { finalSummary = undefined; }
2886
+ const signalSnapshot = transcriptSignals?.snapshot;
2887
+ if (profile.class === 'native-source' && signalSnapshot?.available === true) {
2888
+ // The signal is the verdict; the turn-scoped extraction re-proves
2889
+ // the scope and supplies the emit payload from the same read.
2890
+ if (signalSnapshot.signals.final_assistant_present !== true) return false;
2891
+ finalSummary = extractFinalSummaryFromMessagesAfter(
2892
+ (Array.isArray(transcriptSignals?.messages) ? transcriptSignals.messages : []) as any,
2893
+ turnStartedAt,
2894
+ ) || undefined;
2895
+ } else {
2896
+ try {
2897
+ finalSummary = this.completionFinalSummary(parsedMessages, turnStartedAt);
2898
+ } catch { finalSummary = undefined; }
2899
+ }
2790
2900
  // No in-turn final assistant evidence → not a proven turn-end. Let the real
2791
2901
  // stall fire so a genuinely-wedged worker is still surfaced.
2792
2902
  if (!finalSummary) return false;
@@ -3168,20 +3278,27 @@ export class CliProviderInstance implements ProviderInstance {
3168
3278
  // untouched; claude-cli's write-lag CANON-C immediate emit is likewise
3169
3279
  // untouched (its block deliberately omits noExternalTranscriptSource).
3170
3280
  if (blockReason === 'missing_final_assistant' && block.noExternalTranscriptSource === true) {
3171
- let nativeSample: { msgCount: number; sourceMtimeMs: number } | null = null;
3172
- try { nativeSample = this.sampleNativeTranscriptProgress(); } catch { nativeSample = null; }
3173
- const sourceMtimeMs = nativeSample?.sourceMtimeMs ?? 0;
3174
- if (nativeSample && sourceMtimeMs > 0 && Date.now() - sourceMtimeMs < MISSING_ASSISTANT_TRANSCRIPT_GROWTH_QUIET_MS) {
3281
+ // TX-FSM Stage 1: the freshness judgment is the shared signal
3282
+ // source's transcript_growing signal (same growth-quiet window,
3283
+ // normalized from the ONE re-probe this retry performs) instead
3284
+ // of a private mtime scan. Fail-open identically: no signal
3285
+ // (non-native-source / unresolved / read error / unknown mtime)
3286
+ // → falls through to the unchanged floor/cap logic.
3287
+ let growthSnapshot: SignalSnapshot | null = null;
3288
+ try { growthSnapshot = this.probeNativeTranscriptSignals()?.snapshot ?? null; } catch { growthSnapshot = null; }
3289
+ if (growthSnapshot?.available === true && growthSnapshot.signals.transcript_growing === true) {
3290
+ const growthDetail = growthSnapshot.detail;
3291
+ const mtimeAgeMs = growthDetail.ageMs ?? 0; // growing ⇒ ageMs non-null
3175
3292
  if (pending.loggedBlockReason !== 'native_transcript_advancing') {
3176
- LOG.info('CLI', `[${this.type}] holding pending completed (native_transcript_advancing: msgCount=${nativeSample.msgCount} mtimeAge=${Date.now() - sourceMtimeMs}ms < ${MISSING_ASSISTANT_TRANSCRIPT_GROWTH_QUIET_MS}ms) — transcript still growing, screen-idle verdict not trusted`);
3293
+ LOG.info('CLI', `[${this.type}] holding pending completed (native_transcript_advancing: msgCount=${growthDetail.msgCount} mtimeAge=${mtimeAgeMs}ms < ${MISSING_ASSISTANT_TRANSCRIPT_GROWTH_QUIET_MS}ms) — transcript still growing, screen-idle verdict not trusted`);
3177
3294
  if (this.isMeshWorkerSession()) {
3178
- traceMeshEventDrop('completion_gate_hold', this.meshTraceCtx(), `native_transcript_advancing msgCount=${nativeSample.msgCount} mtimeAge=${Date.now() - sourceMtimeMs}ms`);
3295
+ traceMeshEventDrop('completion_gate_hold', this.meshTraceCtx(), `native_transcript_advancing msgCount=${growthDetail.msgCount} mtimeAge=${mtimeAgeMs}ms`);
3179
3296
  }
3180
3297
  if (this.completionTraceOn()) this.recordCompletionGateTrace('hold', {
3181
3298
  blockReason: 'native_transcript_advancing',
3182
3299
  latestVisibleStatus,
3183
- msgCount: nativeSample.msgCount,
3184
- sourceMtimeAgeMs: Date.now() - sourceMtimeMs,
3300
+ msgCount: growthDetail.msgCount,
3301
+ sourceMtimeAgeMs: mtimeAgeMs,
3185
3302
  growthQuietMs: MISSING_ASSISTANT_TRANSCRIPT_GROWTH_QUIET_MS,
3186
3303
  waitedMs,
3187
3304
  });
@@ -864,6 +864,16 @@ export class SpecCliAdapter implements CliAdapter {
864
864
  }
865
865
  refreshProviderDefinition(): void { /* hot reload handled by SpecDriver fs.watch */ }
866
866
 
867
+ /**
868
+ * TX-FSM Stage 0 (shadow): forward the daemon's normalized signal
869
+ * observation into the FSM driver. Observation-only — failures here must
870
+ * never break the adapter, and the driver treats the envelope as a pure
871
+ * injected value (no reads cross the engine boundary).
872
+ */
873
+ setSignalObservation(snapshot: import('./signal-envelope.js').SignalSnapshot | null): void {
874
+ try { this.driver.setSignalObservation?.(snapshot); } catch { /* shadow-only: never break the adapter */ }
875
+ }
876
+
867
877
  private handleEvent(ev: DashboardEvent): void {
868
878
  this.lastEvent = ev;
869
879
  switch (ev.kind) {
@@ -30,6 +30,7 @@ import {
30
30
  type ResolvedSection, type TraceEntry,
31
31
  } from './evaluator.js';
32
32
  import { evaluateFsm, stableRegionKey, type FsmClock, type TransitionEval, type FsmEvaluation } from './fsm-evaluator.js';
33
+ import type { SignalSnapshot } from './signal-envelope.js';
33
34
  import {
34
35
  type CliSpecV4, type FsmState, type FsmTransition,
35
36
  initialState, stateById, statusForState, modalKindForState, outgoingTransitions,
@@ -144,6 +145,14 @@ export interface ISpecDriver {
144
145
  getFsmDebug?(): unknown;
145
146
  getFsmSnapshotHistory?(): ReadonlyArray<FsmSnapshotEntry>;
146
147
  getEventTimeline?(limit?: number): ReadonlyArray<SpecPtyEvent>;
148
+ /**
149
+ * TX-FSM Stage 0 (shadow): inject the daemon-normalized signal observation.
150
+ * Observation ONLY — the driver receives the envelope, never a reader; all
151
+ * file discovery / session pinning / parsing stays daemon-side so the FSM
152
+ * engine remains a generic PTY engine. The snapshot feeds the shadow
153
+ * verdict of `signal` conditions exclusively; it cannot gate a transition.
154
+ */
155
+ setSignalObservation?(snapshot: SignalSnapshot | null): void;
147
156
  }
148
157
 
149
158
  export interface SpecDriverOpts {
@@ -367,6 +376,15 @@ export class FsmDriver implements ISpecDriver {
367
376
  * transition — the rich pre-transition table that lastFsmEval only keeps
368
377
  * for the single most recent evaluation. Separate from stateHistory. */
369
378
  private fsmSnapshotHistory: FsmSnapshotEntry[] = [];
379
+ /** TX-FSM Stage 0 (shadow): the latest daemon-injected signal observation.
380
+ * Read by evalFsmNow for the shadow verdict of `signal` conditions ONLY —
381
+ * the Stage-0 pass-through in the evaluator means it can never alter
382
+ * which transition fires. */
383
+ private signalObservation: SignalSnapshot | null = null;
384
+ /** Per-transition last-logged shadow divergence (`${from}→${to}` → whether
385
+ * the shadow verdict currently disagrees with the real one), so the
386
+ * shadow log emits on FLIP only, not every frame. */
387
+ private shadowDivergenceLast = new Map<string, boolean>();
370
388
 
371
389
  constructor(private readonly opts: SpecDriverOpts) {
372
390
  this.loadSpecOrThrow();
@@ -451,6 +469,17 @@ export class FsmDriver implements ISpecDriver {
451
469
  this.adapter.updateMeta(meta, replace);
452
470
  }
453
471
 
472
+ /**
473
+ * TX-FSM Stage 0 (shadow): receive the daemon-normalized signal
474
+ * observation. The driver stores it verbatim — it does NOT poll, parse,
475
+ * or read anything itself (generic-PTY-engine boundary). The observation
476
+ * only feeds the shadow verdict of `signal` conditions; the evaluator's
477
+ * Stage-0 pass-through guarantees it cannot change a transition.
478
+ */
479
+ setSignalObservation(snapshot: SignalSnapshot | null): void {
480
+ this.signalObservation = snapshot ?? null;
481
+ }
482
+
454
483
  snapshot(): string { return this.adapter.snapshot(); }
455
484
  getCursorPosition(): { row: number; col: number } { return this.adapter.getCursorPosition(); }
456
485
  getScreen(): string { return this.adapter.snapshot(); }
@@ -643,7 +672,38 @@ export class FsmDriver implements ISpecDriver {
643
672
 
644
673
  private evalFsmNow(screen: string, cursor: { row: number; col: number }, now: number) {
645
674
  const prev = this.prevScreenLines.length > 0 ? this.prevScreenLines : undefined;
646
- return evaluateFsm(this.spec, this.currentStateId, screen, cursor, prev, this.buildClock(now));
675
+ return evaluateFsm(this.spec, this.currentStateId, screen, cursor, prev, this.buildClock(now), this.signalObservation);
676
+ }
677
+
678
+ /**
679
+ * TX-FSM Stage 0 (shadow): compare each signal-guarded transition's
680
+ * counterfactual verdict against the real (PTY-only) one and log on FLIP.
681
+ * This is the "만약 적용했다면" half of the shadow log — the Stage 1-3
682
+ * evidence for whether signal gating would have changed any verdict, and
683
+ * in which direction. Read-only: runs after the evaluation is complete
684
+ * and never feeds back into it.
685
+ */
686
+ private logShadowDivergence(ev: FsmEvaluation): void {
687
+ for (const t of ev.transitions) {
688
+ if (!t.shadow) continue;
689
+ const key = `${this.currentStateId}→${t.to}`;
690
+ const diverges = t.shadow.fires !== t.fires;
691
+ if ((this.shadowDivergenceLast.get(key) ?? false) === diverges) continue;
692
+ this.shadowDivergenceLast.set(key, diverges);
693
+ if (diverges) {
694
+ LOG.info(
695
+ 'FsmDriver',
696
+ `[${this.specTag()}] [shadow] ${key} (${t.label}): real fires=${t.fires}`
697
+ + ` but signal-gated verdict would be fires=${t.shadow.fires}`
698
+ + ` (shadowCond=${t.shadow.condResult}${t.shadow.unknown ? ', signal unknown/fail-open' : ''})`,
699
+ );
700
+ } else {
701
+ LOG.info(
702
+ 'FsmDriver',
703
+ `[${this.specTag()}] [shadow] ${key} (${t.label}): shadow verdict realigned with real fires=${t.fires}`,
704
+ );
705
+ }
706
+ }
647
707
  }
648
708
 
649
709
  private reevaluate(forceEmit = false): void {
@@ -659,6 +719,7 @@ export class FsmDriver implements ISpecDriver {
659
719
  const ev = this.evalFsmNow(screen, cursor, now);
660
720
  this.lastFsmEval = ev;
661
721
  this.prevScreenLines = currentLines;
722
+ this.logShadowDivergence(ev);
662
723
 
663
724
  if (ev.fired) {
664
725
  this.commitTransition(ev.fired, now, ev);