@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.
- package/dist/cli-adapter-types.d.ts +7 -0
- package/dist/commands/upgrade-helper.d.ts +14 -0
- package/dist/index.js +623 -84
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +623 -84
- package/dist/index.mjs.map +1 -1
- package/dist/providers/cli-provider-instance.d.ts +69 -16
- package/dist/providers/spec/cli-adapter.d.ts +7 -0
- package/dist/providers/spec/fsm-driver.d.ts +35 -0
- package/dist/providers/spec/fsm-evaluator.d.ts +28 -2
- package/dist/providers/spec/fsm-types.d.ts +19 -1
- package/dist/providers/spec/signal-envelope.d.ts +59 -0
- package/dist/providers/transcript-signal-source.d.ts +59 -0
- package/package.json +3 -3
- package/src/cli-adapter-types.ts +7 -0
- package/src/commands/low-family/daemon-lifecycle.ts +41 -0
- package/src/commands/med-family/mesh-restart.ts +233 -30
- package/src/commands/upgrade-helper.ts +34 -3
- package/src/providers/cli-provider-instance.ts +204 -87
- package/src/providers/spec/cli-adapter.ts +10 -0
- package/src/providers/spec/fsm-driver.ts +62 -1
- package/src/providers/spec/fsm-evaluator.ts +110 -5
- package/src/providers/spec/fsm-loader.ts +7 -0
- package/src/providers/spec/fsm-types.ts +20 -0
- package/src/providers/spec/signal-envelope.ts +136 -0
- package/src/providers/transcript-signal-source.ts +179 -0
|
@@ -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
|
+
}
|