@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.
@@ -22,6 +22,7 @@ import {
22
22
  type CliSpecV4, type FsmCondition, type FsmTransition,
23
23
  outgoingTransitions, stateById,
24
24
  } from './fsm-types.js';
25
+ import { evaluateSignalLeaf, type SignalSnapshot } from './signal-envelope.js';
25
26
 
26
27
  export interface FsmClock {
27
28
  /** Wall-clock now (ms). Passed in so the evaluator stays pure. */
@@ -37,7 +38,7 @@ export interface FsmClock {
37
38
 
38
39
  /** Per-condition evaluation detail — the debugging payload. */
39
40
  export interface CondResult {
40
- kind: 'regex' | 'changed' | 'elapsed' | 'stable' | 'all' | 'any' | 'not';
41
+ kind: 'regex' | 'changed' | 'elapsed' | 'stable' | 'signal' | 'all' | 'any' | 'not';
41
42
  result: boolean;
42
43
  detail: string;
43
44
  /** Remaining ms until a time-based condition would flip to true. 0 if
@@ -47,6 +48,12 @@ export interface CondResult {
47
48
  * snapshot shows WHAT text the rule fired on — not just which regex. Never
48
49
  * read by the FSM; purely for the Spec Debug Snapshot. */
49
50
  matchedText?: string;
51
+ /** TX-FSM Stage 0 (shadow): present only on `signal` leaves. `result` above
52
+ * is ALWAYS true for a signal leaf (Stage-0 pass-through — the leaf never
53
+ * gates a transition); this payload records what the leaf WOULD have
54
+ * decided against the injected snapshot. shadowResult null = the signal
55
+ * was unavailable/unknown (fail-open). */
56
+ signal?: { name: string; available: boolean; value: boolean | null; shadowResult: boolean | null };
50
57
  children?: CondResult[];
51
58
  }
52
59
 
@@ -66,6 +73,16 @@ export interface TransitionEval {
66
73
  /** Overall: would this transition fire? */
67
74
  fires: boolean;
68
75
  priority: number;
76
+ /**
77
+ * TX-FSM Stage 0 (shadow): present only when the guard contains at least
78
+ * one `signal` leaf. Records the verdict the transition WOULD have
79
+ * produced if signal leaves gated (condResult/fires), so the shadow log
80
+ * can compare it against the real PTY-only outcome. `unknown` = at least
81
+ * one signal leaf had no observation (fail-open); the shadow verdict is
82
+ * then informational, not a counterfactual proof. NEVER consumed by the
83
+ * driver for any transition decision — log/debug surface only.
84
+ */
85
+ shadow?: { condResult: boolean; fires: boolean; unknown: boolean };
69
86
  }
70
87
 
71
88
  export interface FsmEvaluation {
@@ -123,11 +140,69 @@ function isAny(c: FsmCondition): c is import('./fsm-types.js').FsmAnyCondition {
123
140
  function isNot(c: FsmCondition): c is import('./fsm-types.js').FsmNotCondition {
124
141
  return 'not' in c;
125
142
  }
143
+ function isSignal(c: FsmCondition): c is import('./fsm-types.js').FsmSignalCondition {
144
+ return 'signal' in c;
145
+ }
146
+
147
+ /** True when the condition tree contains at least one `signal` leaf — only
148
+ * those trees get a Stage-0 shadow verdict. */
149
+ function containsSignal(c: FsmCondition): boolean {
150
+ if (isSignal(c)) return true;
151
+ if (isAll(c)) return c.all.some(containsSignal);
152
+ if (isAny(c)) return c.any.some(containsSignal);
153
+ if (isNot(c)) return containsSignal(c.not);
154
+ return false;
155
+ }
156
+
157
+ /**
158
+ * Fold an already-evaluated CondResult tree into the Stage-0 SHADOW verdict:
159
+ * signal leaves contribute their shadowResult (what they WOULD have decided),
160
+ * every other leaf contributes its real result, and all/any/not compose
161
+ * exactly as they did for the real verdict. Pure tree fold — no re-evaluation,
162
+ * so the shadow can never diverge from the real eval on PTY leaves.
163
+ *
164
+ * `unknown` tracks three-valued logic precisely: a definite child verdict
165
+ * decides the parent (false child ⇒ all is definitely false; true child ⇒ any
166
+ * is definitely true); only when no child decides does an unknown signal leaf
167
+ * make the parent unknown.
168
+ */
169
+ function foldShadow(cond: CondResult): { value: boolean; unknown: boolean } {
170
+ if (cond.kind === 'signal') {
171
+ const s = cond.signal;
172
+ if (s && s.shadowResult !== null && s.shadowResult !== undefined) {
173
+ return { value: s.shadowResult, unknown: false };
174
+ }
175
+ return { value: true, unknown: true }; // fail-open placeholder
176
+ }
177
+ if (cond.kind === 'not' && cond.children?.length) {
178
+ const c = foldShadow(cond.children[0]);
179
+ return { value: !c.value, unknown: c.unknown };
180
+ }
181
+ if (cond.kind === 'all' && cond.children) {
182
+ const kids = cond.children.map(foldShadow);
183
+ if (kids.some(k => !k.unknown && !k.value)) return { value: false, unknown: false };
184
+ if (kids.some(k => k.unknown)) return { value: true, unknown: true };
185
+ return { value: true, unknown: false };
186
+ }
187
+ if (cond.kind === 'any' && cond.children) {
188
+ const kids = cond.children.map(foldShadow);
189
+ if (kids.some(k => !k.unknown && k.value)) return { value: true, unknown: false };
190
+ if (kids.some(k => k.unknown)) return { value: false, unknown: true };
191
+ return { value: false, unknown: false };
192
+ }
193
+ return { value: cond.result, unknown: false };
194
+ }
126
195
 
127
196
  /**
128
197
  * Evaluate a single FSM condition into a {result, detail, remainingMs, children}.
129
198
  * Recurses through all/any/not; defers regex/changed to the shared evaluator;
130
199
  * handles elapsed_ms/stable_ms against the clock.
200
+ *
201
+ * `signalSnapshot` is the daemon-injected observation (TX-FSM Stage 0). A
202
+ * `signal` leaf evaluates to result=true UNCONDITIONALLY (Stage-0 pass-through
203
+ * — the leaf never gates a transition) and records its would-be verdict in
204
+ * CondResult.signal for the shadow fold. The pass-through, not the snapshot,
205
+ * is what the FSM sees.
131
206
  */
132
207
  function evalCond(
133
208
  cond: FsmCondition,
@@ -138,17 +213,18 @@ function evalCond(
138
213
  clock: FsmClock,
139
214
  legacyTrace: TraceEntry[],
140
215
  stateId: string,
216
+ signalSnapshot?: SignalSnapshot | null,
141
217
  ): CondResult {
142
218
  if (isAll(cond)) {
143
219
  const children = cond.all.map(c =>
144
- evalCond(c, sections, fullScreen, cursor, prevLines, clock, legacyTrace, stateId));
220
+ evalCond(c, sections, fullScreen, cursor, prevLines, clock, legacyTrace, stateId, signalSnapshot));
145
221
  const result = children.every(c => c.result);
146
222
  const remainingMs = result ? 0 : Math.max(0, ...children.filter(c => !c.result).map(c => c.remainingMs ?? 0));
147
223
  return { kind: 'all', result, detail: `all(${children.length})`, remainingMs, children };
148
224
  }
149
225
  if (isAny(cond)) {
150
226
  const children = cond.any.map(c =>
151
- evalCond(c, sections, fullScreen, cursor, prevLines, clock, legacyTrace, stateId));
227
+ evalCond(c, sections, fullScreen, cursor, prevLines, clock, legacyTrace, stateId, signalSnapshot));
152
228
  const result = children.some(c => c.result);
153
229
  // remaining = the soonest child that could flip true
154
230
  const pending = children.filter(c => !c.result).map(c => c.remainingMs ?? Infinity);
@@ -156,9 +232,26 @@ function evalCond(
156
232
  return { kind: 'any', result, detail: `any(${children.length})`, remainingMs: Number.isFinite(remainingMs) ? remainingMs : 0, children };
157
233
  }
158
234
  if (isNot(cond)) {
159
- const child = evalCond(cond.not, sections, fullScreen, cursor, prevLines, clock, legacyTrace, stateId);
235
+ const child = evalCond(cond.not, sections, fullScreen, cursor, prevLines, clock, legacyTrace, stateId, signalSnapshot);
160
236
  return { kind: 'not', result: !child.result, detail: `not`, remainingMs: 0, children: [child] };
161
237
  }
238
+ if (isSignal(cond)) {
239
+ const leaf = evaluateSignalLeaf(cond, signalSnapshot);
240
+ // STAGE-0 PASS-THROUGH: result is always true so the leaf can never
241
+ // change `fires` — not even when a spec author adds a signal condition
242
+ // today. The shadow fold (foldShadow) reads `signal.shadowResult`.
243
+ return {
244
+ kind: 'signal',
245
+ result: true,
246
+ detail: `${leaf.detail} (stage0 shadow — pass-through)`,
247
+ signal: {
248
+ name: cond.signal,
249
+ available: !!signalSnapshot?.available,
250
+ value: leaf.value,
251
+ shadowResult: leaf.shadowResult,
252
+ },
253
+ };
254
+ }
162
255
  if (isElapsed(cond)) {
163
256
  const age = clock.now - clock.stateEnteredAt;
164
257
  const result = age >= cond.elapsed_ms;
@@ -221,6 +314,7 @@ export function evaluateFsm(
221
314
  cursor: { row: number; col: number } | undefined,
222
315
  prevLines: string[] | undefined,
223
316
  clock: FsmClock,
317
+ signalSnapshot?: SignalSnapshot | null,
224
318
  ): FsmEvaluation {
225
319
  const legacyTrace: TraceEntry[] = [];
226
320
  const lines = screenText.split('\n').map(l => l.endsWith('\r') ? l.slice(0, -1) : l);
@@ -240,7 +334,7 @@ export function evaluateFsm(
240
334
  let cond: CondResult | undefined;
241
335
  let condResult = true;
242
336
  if (t.when) {
243
- cond = evalCond(t.when, sections, cleanScreen, cursor, prevLines, clock, legacyTrace, `${currentStateId}→${t.to}`);
337
+ cond = evalCond(t.when, sections, cleanScreen, cursor, prevLines, clock, legacyTrace, `${currentStateId}→${t.to}`, signalSnapshot);
244
338
  condResult = cond.result;
245
339
  }
246
340
 
@@ -256,6 +350,17 @@ export function evaluateFsm(
256
350
  fires,
257
351
  priority: t.priority ?? 0,
258
352
  };
353
+ // TX-FSM Stage 0 (shadow): record the counterfactual verdict for any
354
+ // guard containing a signal leaf. Read-only — `fires` above is already
355
+ // fixed by the pass-through rule before this runs.
356
+ if (t.when && containsSignal(t.when) && cond) {
357
+ const folded = foldShadow(cond);
358
+ te.shadow = {
359
+ condResult: folded.value,
360
+ fires: holdSatisfied && folded.value,
361
+ unknown: folded.unknown,
362
+ };
363
+ }
259
364
  transitions.push(te);
260
365
  if (fires && !fired) fired = te;
261
366
  }
@@ -113,6 +113,13 @@ function validateCondition(c: FsmCondition, sectionIds: Set<string>, path: strin
113
113
  return errs;
114
114
  }
115
115
  if ('cursor_above' in w && 'changed' in w) return errs;
116
+ if ('signal' in w) {
117
+ // TX-FSM Stage 0 (shadow) leaf — validated so a spec can declare it
118
+ // today (evaluation is shadow-only; see fsm-evaluator).
119
+ if (typeof w.signal !== 'string' || !w.signal.trim()) errs.push(`${path}.signal must be a non-empty string`);
120
+ if (w.equals !== undefined && typeof w.equals !== 'boolean') errs.push(`${path}.equals must be a boolean`);
121
+ return errs;
122
+ }
116
123
  if ('elapsed_ms' in w) { if (typeof w.elapsed_ms !== 'number') errs.push(`${path}.elapsed_ms must be a number`); return errs; }
117
124
  if ('stable_ms' in w) {
118
125
  if (typeof w.stable_ms !== 'number') errs.push(`${path}.stable_ms must be a number`);
@@ -95,11 +95,31 @@ export interface FsmNotCondition {
95
95
  not: FsmCondition;
96
96
  }
97
97
 
98
+ /**
99
+ * TX-FSM Stage 0 (shadow) — a daemon-signal leaf. References a NORMALIZED
100
+ * signal name from the SignalSnapshot envelope (never a provider name), so a
101
+ * spec can combine transcript evidence with PTY conditions via all/any/not:
102
+ *
103
+ * { all: [ { matches: "…" }, { signal: "final_assistant_present" } ] }
104
+ *
105
+ * STAGE-0 SEMANTICS: evaluated for the shadow log only. The leaf NEVER gates
106
+ * a transition — the evaluator treats it as pass-through for the real verdict
107
+ * and records what it WOULD have decided (TransitionEval.shadow). A missing
108
+ * or unavailable signal fails open. Promotion to a real gate is Stage 1+.
109
+ */
110
+ export interface FsmSignalCondition {
111
+ /** Normalized signal name (see SIGNAL_NAMES in signal-envelope.ts). */
112
+ signal: string;
113
+ /** Expected value; default true. */
114
+ equals?: boolean;
115
+ }
116
+
98
117
  export type FsmCondition =
99
118
  | RegexCondition
100
119
  | ChangedCondition
101
120
  | ElapsedCondition
102
121
  | StableCondition
122
+ | FsmSignalCondition
103
123
  | FsmAllCondition
104
124
  | FsmAnyCondition
105
125
  | FsmNotCondition;
@@ -0,0 +1,136 @@
1
+ /**
2
+ * TX-FSM Stage 0 (shadow) — the `signal` capability envelope.
3
+ *
4
+ * Stage 0 of the transcript+PTY dual-source redesign: the daemon collects
5
+ * native-transcript signals, normalizes them into this envelope, and injects
6
+ * the snapshot into the FsmDriver as a pure OBSERVATION. The FSM evaluates
7
+ * `signal` conditions against it and records what the verdict WOULD have been
8
+ * (shadow log) — but a signal condition NEVER gates a transition in Stage 0
9
+ * (pass-through, see fsm-evaluator.ts). Behavior change is exactly zero.
10
+ *
11
+ * Boundary contract:
12
+ * - This module is ENGINE-side: it defines the envelope + leaf evaluation and
13
+ * knows NOTHING about providers, file discovery, or parsing.
14
+ * - The daemon-side TranscriptSignalSource (providers/transcript-signal-source.ts)
15
+ * owns collection/normalization and is the only producer.
16
+ * - Approval is explicitly OUT of scope (deferred to Stage 4): no provider
17
+ * records its approval lifecycle in the native transcript, and an unfinished
18
+ * tool call cannot distinguish running/approval-pending/result-delayed/crash,
19
+ * so no approval signal exists in this envelope. PTY regex stays the sole
20
+ * approval authority.
21
+ *
22
+ * Fail-open contract: when no snapshot is available (non-native-source class,
23
+ * unresolved transcript, read error), every signal is null and every `signal`
24
+ * condition fails OPEN — a missing signal must never wedge a session.
25
+ */
26
+ 'use strict';
27
+
28
+ import type { TranscriptClass, CompletionTiming } from '../transcript-evidence.js';
29
+
30
+ export const SIGNAL_SNAPSHOT_KIND = 'adhdev:fsm/signal-snapshot@0' as const;
31
+
32
+ /** The normalized signal vocabulary. Stage 0 keeps this deliberately small —
33
+ * every name must be derivable from the FLATTENED native-transcript pipeline
34
+ * (which drops tool_use/tool_result correlation ids — that raw-JSONL read is
35
+ * Stage 4 territory and must NOT be added here). */
36
+ export type SignalName =
37
+ | 'final_assistant_present'
38
+ | 'in_turn_progress'
39
+ | 'transcript_growing';
40
+
41
+ export const SIGNAL_NAMES: readonly SignalName[] = [
42
+ 'final_assistant_present',
43
+ 'in_turn_progress',
44
+ 'transcript_growing',
45
+ ];
46
+
47
+ export type SignalUnavailableReason = 'no_native_source' | 'unresolved' | 'error';
48
+
49
+ /** The normalized, provider-agnostic observation the daemon hands the FSM. */
50
+ export interface SignalSnapshot {
51
+ kind: typeof SIGNAL_SNAPSHOT_KIND;
52
+ /** Wall-clock time the underlying transcript read happened (ms). */
53
+ sampledAt: number;
54
+ /** False ⇒ every signal is null and conditions fail open. */
55
+ available: boolean;
56
+ unavailableReason?: SignalUnavailableReason;
57
+ /** The authority profile class/timing this snapshot was produced under
58
+ * (via resolveTranscriptAuthorityProfile — never raw predicates). */
59
+ profile?: { class: TranscriptClass; timing: CompletionTiming };
60
+ /** Normalized signals; null = unknown (fail-open). */
61
+ signals: Record<SignalName, boolean | null>;
62
+ /** Raw sampling context for the shadow log / Stage 1-3 analysis. */
63
+ detail: {
64
+ msgCount: number;
65
+ sourceMtimeMs: number;
66
+ /** now - sourceMtimeMs; null when the mtime is unknown (0). */
67
+ ageMs: number | null;
68
+ };
69
+ }
70
+
71
+ /** The fail-open snapshot: no usable observation, every signal null. */
72
+ export function unavailableSignalSnapshot(
73
+ now: number,
74
+ reason: SignalUnavailableReason,
75
+ profile?: { class: TranscriptClass; timing: CompletionTiming },
76
+ ): SignalSnapshot {
77
+ return {
78
+ kind: SIGNAL_SNAPSHOT_KIND,
79
+ sampledAt: now,
80
+ available: false,
81
+ unavailableReason: reason,
82
+ ...(profile ? { profile } : {}),
83
+ signals: { final_assistant_present: null, in_turn_progress: null, transcript_growing: null },
84
+ detail: { msgCount: 0, sourceMtimeMs: 0, ageMs: null },
85
+ };
86
+ }
87
+
88
+ /** The spec's `signal` leaf: `{ signal: 'transcript_growing', equals?: boolean }`. */
89
+ export interface SignalLeaf {
90
+ signal: string;
91
+ equals?: boolean;
92
+ }
93
+
94
+ export interface SignalLeafEval {
95
+ /** The normalized signal value read from the snapshot; null = unknown. */
96
+ value: boolean | null;
97
+ /** What the leaf WOULD evaluate to if signals gated transitions; null =
98
+ * unknown (no snapshot / signal missing → fail-open). */
99
+ shadowResult: boolean | null;
100
+ /** Human-readable shadow-log fragment. */
101
+ detail: string;
102
+ }
103
+
104
+ /**
105
+ * Evaluate one `signal` leaf against the injected snapshot. This computes the
106
+ * SHADOW verdict only — the caller (fsm-evaluator) owns the Stage-0
107
+ * pass-through rule that keeps the real result true regardless of what this
108
+ * returns.
109
+ */
110
+ export function evaluateSignalLeaf(cond: SignalLeaf, snapshot: SignalSnapshot | null | undefined): SignalLeafEval {
111
+ const expected = cond.equals ?? true;
112
+ if (!snapshot || !snapshot.available) {
113
+ const why = !snapshot ? 'no snapshot' : `unavailable(${snapshot.unavailableReason ?? 'unknown'})`;
114
+ return {
115
+ value: null,
116
+ shadowResult: null,
117
+ detail: `signal ${cond.signal} ${why} → fail-open`,
118
+ };
119
+ }
120
+ const raw = Object.prototype.hasOwnProperty.call(snapshot.signals, cond.signal)
121
+ ? snapshot.signals[cond.signal as SignalName]
122
+ : null;
123
+ if (raw === null || raw === undefined) {
124
+ return {
125
+ value: null,
126
+ shadowResult: null,
127
+ detail: `signal ${cond.signal} unknown → fail-open`,
128
+ };
129
+ }
130
+ const shadowResult = raw === expected;
131
+ return {
132
+ value: raw,
133
+ shadowResult,
134
+ detail: `signal ${cond.signal}=${raw} expected=${expected} → ${shadowResult}`,
135
+ };
136
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * TranscriptSignalSource — TX-FSM transcript signal normalizer, daemon side.
3
+ *
4
+ * Owns the collection/normalization half of the dual-source redesign: given
5
+ * the daemon's EXISTING native-transcript reads (file discovery + session pin
6
+ * + parsing all stay in CliProviderInstance.readExternalCompletionMessages,
7
+ * which resolves providerSessionId / persisted pins / floor-claims), this
8
+ * source normalizes each read into the provider-agnostic SignalSnapshot
9
+ * envelope (spec/signal-envelope.ts).
10
+ *
11
+ * Consumers:
12
+ * - Stage 0 (shadow): the snapshot is injected into the FsmDriver as a pure
13
+ * observation; `signal` conditions stay pass-through there (fsm-evaluator).
14
+ * - Stage 1 (delegation): the instance's OWN stall/growth-hold judgments
15
+ * (checkMeshWorkerStall's transcript-advancing axis, the TRANSCRIPT-
16
+ * GROWTH-HOLD, and tryReconcileTranscriptCompletionForStall — the FIX 3
17
+ * probe) consume the SAME normalized snapshot instead of running private
18
+ * transcript scans. The FSM-side consumption remains shadow/pass-through.
19
+ *
20
+ * Hard contracts:
21
+ * - ZERO added I/O. update() is fed by transcript reads the daemon already
22
+ * performs (completion probe / stall sampling); it never opens a file
23
+ * itself. getState() keeps its zero-native-read invariant untouched.
24
+ * - Classification goes through resolveTranscriptAuthorityProfile ONLY (the
25
+ * P0 choke point) — never raw isNativeSourceCanonicalHistory /
26
+ * isPurePtyTranscriptProvider calls, and never a provider-name branch.
27
+ * - Fail-open: a non-native-source class, an unresolved transcript, or a
28
+ * read error yields an unavailable snapshot — consumers treat every signal
29
+ * as null and keep their pre-delegation fallbacks; a missing signal must
30
+ * never wedge a session or fabricate a completion.
31
+ * - No approval signal (Stage 4 scope — see signal-envelope.ts).
32
+ */
33
+ 'use strict';
34
+
35
+ import { LOG } from '../logging/logger.js';
36
+ import type { TranscriptAuthorityProfile } from './transcript-evidence.js';
37
+ import {
38
+ SIGNAL_SNAPSHOT_KIND,
39
+ unavailableSignalSnapshot,
40
+ type SignalSnapshot,
41
+ } from './spec/signal-envelope.js';
42
+
43
+ /** One daemon transcript read, normalized for the source. */
44
+ export interface TranscriptSignalSample {
45
+ /** Parsed native-transcript messages, or null when the read failed to
46
+ * resolve a source this time. */
47
+ messages: unknown[] | null;
48
+ /** The probe metadata the read refreshed (sourcePath/mtime/msgCount), or
49
+ * null when no source is bound. A null sourceMtimeMs is treated as "no
50
+ * freshness evidence" (same contract as the growth-hold path). */
51
+ probe: { msgCount: number; sourceMtimeMs: number | null; sourcePath?: string | null } | null;
52
+ /** True when the read threw — logged distinctly from a clean miss. */
53
+ error?: boolean;
54
+ }
55
+
56
+ export interface TranscriptSignalSourceOpts {
57
+ /** Log tag (provider type / session label) — never used for branching. */
58
+ label: string;
59
+ /** The P0 choke-point profile for this provider. */
60
+ profile: TranscriptAuthorityProfile;
61
+ /** Turn-boundary clock (adapter.currentTurnStartedAt) for turn-scoped
62
+ * final-assistant detection; may yield undefined pre-turn. */
63
+ turnStartedAt?: () => number | undefined;
64
+ /** In-turn final-assistant predicate over the flattened messages — the
65
+ * instance passes its existing completionHasFinalAssistantMessage so the
66
+ * signal reuses the exact completion machinery instead of duplicating it. */
67
+ finalAssistantPresent: (messages: unknown[], turnStartedAt?: number) => boolean;
68
+ /** Freshness window (ms) for transcript_growing. The caller passes the
69
+ * completion pipeline's MISSING_ASSISTANT_TRANSCRIPT_GROWTH_QUIET_MS so
70
+ * the shadow signal is comparable to the existing growth-hold judgment. */
71
+ growthQuietMs: number;
72
+ }
73
+
74
+ export class TranscriptSignalSource {
75
+ /** msgCount seen at the previous update — drives in_turn_progress. */
76
+ private prevMsgCount = -1;
77
+ /** Fingerprint of the last LOGGED snapshot, so the shadow log fires on
78
+ * change only (a quiet session must not spam one line per read). */
79
+ private lastLoggedFingerprint = '';
80
+
81
+ constructor(private readonly opts: TranscriptSignalSourceOpts) {}
82
+
83
+ /**
84
+ * Normalize one daemon read into a SignalSnapshot and emit the Stage-0
85
+ * shadow log when the observation changed. Pure w.r.t. the outside world:
86
+ * the only side effect is the (change-gated) log line. Returns the
87
+ * snapshot the caller should inject into the FSM driver.
88
+ */
89
+ update(sample: TranscriptSignalSample, now: number = Date.now()): SignalSnapshot {
90
+ const snapshot = this.buildSnapshot(sample, now);
91
+ this.logOnChange(snapshot);
92
+ return snapshot;
93
+ }
94
+
95
+ /** Pure normalization — separated from update() so tests can assert the
96
+ * envelope without touching the log path. */
97
+ buildSnapshot(sample: TranscriptSignalSample, now: number): SignalSnapshot {
98
+ const profileRef = { class: this.opts.profile.class, timing: this.opts.profile.timing };
99
+
100
+ // Choke-point classification: only native-source providers have an
101
+ // on-disk transcript to signal from. daemon-owned / pure-pty classes
102
+ // fail open — no signal is ever fabricated for them.
103
+ if (this.opts.profile.class !== 'native-source') {
104
+ return unavailableSignalSnapshot(now, 'no_native_source', profileRef);
105
+ }
106
+ if (sample.error) {
107
+ return unavailableSignalSnapshot(now, 'error', profileRef);
108
+ }
109
+ const probe = sample.probe;
110
+ if (!probe || !Array.isArray(sample.messages)) {
111
+ return unavailableSignalSnapshot(now, 'unresolved', profileRef);
112
+ }
113
+
114
+ const msgCount = typeof probe.msgCount === 'number' && Number.isFinite(probe.msgCount)
115
+ ? probe.msgCount
116
+ : sample.messages.length;
117
+ const sourceMtimeMs = typeof probe.sourceMtimeMs === 'number' && Number.isFinite(probe.sourceMtimeMs)
118
+ ? probe.sourceMtimeMs
119
+ : 0;
120
+ // mtime 0 = "no freshness evidence" — the growth-hold test lock treats
121
+ // it as unprovable, and so do we: transcript_growing stays null rather
122
+ // than guessing from a missing clock.
123
+ const ageMs = sourceMtimeMs > 0 ? Math.max(0, now - sourceMtimeMs) : null;
124
+
125
+ let turnStartedAt: number | undefined;
126
+ try { turnStartedAt = this.opts.turnStartedAt?.(); } catch { turnStartedAt = undefined; }
127
+
128
+ let finalAssistant: boolean | null = null;
129
+ try {
130
+ finalAssistant = this.opts.finalAssistantPresent(sample.messages, turnStartedAt);
131
+ } catch { finalAssistant = null; /* fail-open: never let a parse quirk fabricate a signal */ }
132
+
133
+ // in_turn_progress: the transcript ADVANCED since the previous sample
134
+ // (msgCount grew) or is still fresh. Conservative: the first sample
135
+ // reports progress only on freshness, never on a count jump from -1.
136
+ const countAdvanced = this.prevMsgCount >= 0 && msgCount > this.prevMsgCount;
137
+ const fresh = ageMs !== null && ageMs < this.opts.growthQuietMs;
138
+ const inTurnProgress = countAdvanced || fresh;
139
+ const transcriptGrowing = ageMs === null ? null : fresh;
140
+ this.prevMsgCount = msgCount;
141
+
142
+ return {
143
+ kind: SIGNAL_SNAPSHOT_KIND,
144
+ sampledAt: now,
145
+ available: true,
146
+ profile: profileRef,
147
+ signals: {
148
+ final_assistant_present: finalAssistant,
149
+ in_turn_progress: inTurnProgress,
150
+ transcript_growing: transcriptGrowing,
151
+ },
152
+ detail: { msgCount, sourceMtimeMs, ageMs },
153
+ };
154
+ }
155
+
156
+ /** Stage-0 shadow log: emit one line when the normalized observation
157
+ * CHANGES. This log is the Stage 1-3 judgment input ("which signals were
158
+ * actually observable, and when"), so it carries the full signal set —
159
+ * but only on change, so a steady-state session stays quiet. */
160
+ private logOnChange(snapshot: SignalSnapshot): void {
161
+ const s = snapshot.signals;
162
+ const fingerprint = snapshot.available
163
+ ? `1|fa=${s.final_assistant_present}|tp=${s.in_turn_progress}|tg=${s.transcript_growing}|n=${snapshot.detail.msgCount}`
164
+ : `0|${snapshot.unavailableReason ?? 'unknown'}`;
165
+ if (fingerprint === this.lastLoggedFingerprint) return;
166
+ this.lastLoggedFingerprint = fingerprint;
167
+ const age = snapshot.detail.ageMs === null ? 'n/a' : `${snapshot.detail.ageMs}ms`;
168
+ LOG.info(
169
+ 'TranscriptSignalSource',
170
+ `[${this.opts.label}] [shadow] signals available=${snapshot.available}`
171
+ + (snapshot.available
172
+ ? ` final_assistant_present=${s.final_assistant_present}`
173
+ + ` in_turn_progress=${s.in_turn_progress}`
174
+ + ` transcript_growing=${s.transcript_growing}`
175
+ + ` msgCount=${snapshot.detail.msgCount} age=${age}`
176
+ : ` reason=${snapshot.unavailableReason ?? 'unknown'}`),
177
+ );
178
+ }
179
+ }