@adhdev/daemon-core 0.9.82-rc.260 → 0.9.82-rc.261
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +104 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +104 -5
- package/dist/index.mjs.map +1 -1
- package/dist/providers/spec/cli-adapter.d.ts +38 -0
- package/dist/providers/spec/fsm-driver.d.ts +41 -0
- package/package.json +1 -1
- package/src/providers/spec/adapter.ts +10 -3
- package/src/providers/spec/cli-adapter.ts +80 -0
- package/src/providers/spec/fsm-driver.ts +60 -3
|
@@ -26,6 +26,23 @@ export declare class SpecCliAdapter implements CliAdapter {
|
|
|
26
26
|
private activeInteractivePrompt;
|
|
27
27
|
private interactivePromptTransport;
|
|
28
28
|
private claudeTuiPromptCaptureInFlight;
|
|
29
|
+
/**
|
|
30
|
+
* Wall clock of the first frame on which a held interactive prompt was
|
|
31
|
+
* observed to have left the screen. Mirrors the approval FSM's
|
|
32
|
+
* `modalLostAt` hysteresis (see cli-state-engine.ts): claude-cli's TUI
|
|
33
|
+
* repaints the choice picker as several PTY chunks, so a single frame
|
|
34
|
+
* with no "Enter to select" footer is not proof the prompt is gone — it
|
|
35
|
+
* may just be mid-repaint. We only clear the held prompt once it has
|
|
36
|
+
* been absent across a short grace window. Reset to null the moment the
|
|
37
|
+
* prompt footer reappears.
|
|
38
|
+
*
|
|
39
|
+
* Without this, a choice prompt resolved *directly in the terminal* (the
|
|
40
|
+
* user picked an option without going through ADHDev's
|
|
41
|
+
* setInteractivePromptResponse) was never cleared from
|
|
42
|
+
* `activeInteractivePrompt`, so getStatus() re-emitted the same prompt
|
|
43
|
+
* forever — the choice-resolve-stuck bug.
|
|
44
|
+
*/
|
|
45
|
+
private interactivePromptLostAt;
|
|
29
46
|
private jsonLineTail;
|
|
30
47
|
private exited;
|
|
31
48
|
private spawned;
|
|
@@ -93,6 +110,27 @@ export declare class SpecCliAdapter implements CliAdapter {
|
|
|
93
110
|
private readCurrentScreenSections;
|
|
94
111
|
private extractProviderSessionIdFromScreen;
|
|
95
112
|
private readClaudeScreenAssistantMessages;
|
|
113
|
+
/**
|
|
114
|
+
* Grace window a held interactive prompt must be absent from the screen
|
|
115
|
+
* before we treat it as resolved-in-terminal and clear it. claude-cli
|
|
116
|
+
* repaints the picker across multiple PTY chunks, so a single
|
|
117
|
+
* footer-less frame is not proof the prompt is gone. Sized in the same
|
|
118
|
+
* spirit as the approval FSM's `approvalCooldown` modal-lost hysteresis.
|
|
119
|
+
*/
|
|
120
|
+
private static readonly INTERACTIVE_PROMPT_LOST_GRACE_MS;
|
|
121
|
+
/**
|
|
122
|
+
* Clear a held interactive prompt once the user has resolved it directly
|
|
123
|
+
* in the terminal (the choice picker leaves the screen without going
|
|
124
|
+
* through setInteractivePromptResponse). The approval path already does
|
|
125
|
+
* this via the FSM's modal-lost hysteresis; the interactive-prompt path
|
|
126
|
+
* had no equivalent, so a terminal-side answer left activeInteractivePrompt
|
|
127
|
+
* set and getStatus() re-emitted the same choice modal forever.
|
|
128
|
+
*
|
|
129
|
+
* Detection mirrors capture: the claude TUI picker is on-screen exactly
|
|
130
|
+
* while its "Enter to select" footer is rendered. When the footer is gone
|
|
131
|
+
* for INTERACTIVE_PROMPT_LOST_GRACE_MS the prompt is genuinely resolved.
|
|
132
|
+
*/
|
|
133
|
+
private maybeClearResolvedClaudeTuiPrompt;
|
|
96
134
|
private maybeCaptureClaudeTuiPrompt;
|
|
97
135
|
private readClaudeTuiHeaders;
|
|
98
136
|
private captureClaudeTuiPrompt;
|
|
@@ -82,6 +82,36 @@ export interface DriverHistoryEntry {
|
|
|
82
82
|
busyHoldMs?: number;
|
|
83
83
|
via?: string;
|
|
84
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* A frozen snapshot of the FULL FSM evaluation captured at the instant a
|
|
87
|
+
* transition fired — the rich `transitions[]` table (per-transition eligible /
|
|
88
|
+
* hold countdown / per-condition CondResult + remainingMs) that `getFsmDebug()`
|
|
89
|
+
* otherwise only computes live for the current instant. Kept in a separate ring
|
|
90
|
+
* buffer from `stateHistory` (which stays intentionally lightweight) so the
|
|
91
|
+
* "why did this rule fire just before the transition" question is answerable
|
|
92
|
+
* after the fact. before-only: this is the evaluation that PRODUCED the
|
|
93
|
+
* transition, not the post-transition state.
|
|
94
|
+
*/
|
|
95
|
+
export interface FsmSnapshotEntry {
|
|
96
|
+
/** State we transitioned out of. */
|
|
97
|
+
stateFrom: string;
|
|
98
|
+
/** State we transitioned into (the fired transition's destination). */
|
|
99
|
+
stateTo: string;
|
|
100
|
+
/** Wall-clock time the transition committed (ms). */
|
|
101
|
+
at: number;
|
|
102
|
+
/** The fired transition's destination state id (== stateTo; kept explicit
|
|
103
|
+
* to mirror the rule that fired). */
|
|
104
|
+
firedTo: string;
|
|
105
|
+
/** Human label of the fired transition (e.g. "approval→busy"). */
|
|
106
|
+
firedLabel: string;
|
|
107
|
+
/** Why-it-fired summary, same shape produced for stateHistory.matchedRules. */
|
|
108
|
+
reason: string[];
|
|
109
|
+
/** Every outgoing transition from `stateFrom` as evaluated at `at`, each
|
|
110
|
+
* with its eligible / hold / per-condition CondResult + remainingMs. This
|
|
111
|
+
* is the full pre-transition evaluation table — the whole point of the
|
|
112
|
+
* snapshot. */
|
|
113
|
+
transitions: TransitionEval[];
|
|
114
|
+
}
|
|
85
115
|
export interface ISpecDriver {
|
|
86
116
|
subscribe(listener: (ev: DashboardEvent) => void): () => void;
|
|
87
117
|
start(): void;
|
|
@@ -108,6 +138,7 @@ export interface ISpecDriver {
|
|
|
108
138
|
forceAfterMs: number;
|
|
109
139
|
} | null;
|
|
110
140
|
getFsmDebug?(): unknown;
|
|
141
|
+
getFsmSnapshotHistory?(): ReadonlyArray<FsmSnapshotEntry>;
|
|
111
142
|
}
|
|
112
143
|
export interface SpecDriverOpts {
|
|
113
144
|
specPath: string;
|
|
@@ -148,6 +179,10 @@ export declare class FsmDriver implements ISpecDriver {
|
|
|
148
179
|
private specWatcher;
|
|
149
180
|
/** Last full FSM evaluation, kept for the debugger. */
|
|
150
181
|
private lastFsmEval;
|
|
182
|
+
/** Ring buffer (max 20) of the full FSM evaluation captured at each
|
|
183
|
+
* transition — the rich pre-transition table that lastFsmEval only keeps
|
|
184
|
+
* for the single most recent evaluation. Separate from stateHistory. */
|
|
185
|
+
private fsmSnapshotHistory;
|
|
151
186
|
constructor(opts: SpecDriverOpts);
|
|
152
187
|
subscribe(listener: (ev: DashboardEvent) => void): () => void;
|
|
153
188
|
start(): void;
|
|
@@ -172,6 +207,7 @@ export declare class FsmDriver implements ISpecDriver {
|
|
|
172
207
|
transitions: TransitionEval[];
|
|
173
208
|
};
|
|
174
209
|
getStateHistory(): ReadonlyArray<HistoryEntry>;
|
|
210
|
+
getFsmSnapshotHistory(): ReadonlyArray<FsmSnapshotEntry>;
|
|
175
211
|
getSections(): Array<{
|
|
176
212
|
id: string;
|
|
177
213
|
text: string;
|
|
@@ -194,6 +230,11 @@ export declare class FsmDriver implements ISpecDriver {
|
|
|
194
230
|
private evalFsmNow;
|
|
195
231
|
private reevaluate;
|
|
196
232
|
private commitTransition;
|
|
233
|
+
/** Snapshot the full FSM evaluation that produced a transition into the
|
|
234
|
+
* separate fsmSnapshotHistory ring buffer (max 20). The transitions[]
|
|
235
|
+
* table is captured by reference — it is freshly built per evaluation in
|
|
236
|
+
* evaluateFsm and never mutated after, so no clone is needed. */
|
|
237
|
+
private pushFsmSnapshot;
|
|
197
238
|
/** Re-derive the visible modal + controls for the current state and emit a
|
|
198
239
|
* state_changed if anything differs from the last emit. */
|
|
199
240
|
private emitStateChanged;
|
package/package.json
CHANGED
|
@@ -62,9 +62,16 @@ export class TerminalAdapter {
|
|
|
62
62
|
this.screenDebounceMs = opts.screenChangeDebounceMs ?? 80;
|
|
63
63
|
this.tickIntervalMs = opts.tickIntervalMs ?? 0;
|
|
64
64
|
// Import NodePtyTransportFactory lazily to avoid loading node-pty in
|
|
65
|
-
// environments that don't need it (tests, fixture runners)
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
// environments that don't need it (tests, fixture runners) — only when
|
|
66
|
+
// no transport factory was injected. Doing it unconditionally pulled in
|
|
67
|
+
// the node-pty module (and broke source-level test runs) even when a
|
|
68
|
+
// fake factory was supplied.
|
|
69
|
+
if (opts.transportFactory) {
|
|
70
|
+
this.factory = opts.transportFactory;
|
|
71
|
+
} else {
|
|
72
|
+
const { NodePtyTransportFactory } = require('../../cli-adapters/pty-transport.js');
|
|
73
|
+
this.factory = new NodePtyTransportFactory();
|
|
74
|
+
}
|
|
68
75
|
this.screen = new TerminalScreen(this.rows, this.cols);
|
|
69
76
|
}
|
|
70
77
|
|
|
@@ -76,6 +76,23 @@ export class SpecCliAdapter implements CliAdapter {
|
|
|
76
76
|
private activeInteractivePrompt: InteractivePrompt | null = null;
|
|
77
77
|
private interactivePromptTransport: 'stream-json' | 'tui' | null = null;
|
|
78
78
|
private claudeTuiPromptCaptureInFlight = false;
|
|
79
|
+
/**
|
|
80
|
+
* Wall clock of the first frame on which a held interactive prompt was
|
|
81
|
+
* observed to have left the screen. Mirrors the approval FSM's
|
|
82
|
+
* `modalLostAt` hysteresis (see cli-state-engine.ts): claude-cli's TUI
|
|
83
|
+
* repaints the choice picker as several PTY chunks, so a single frame
|
|
84
|
+
* with no "Enter to select" footer is not proof the prompt is gone — it
|
|
85
|
+
* may just be mid-repaint. We only clear the held prompt once it has
|
|
86
|
+
* been absent across a short grace window. Reset to null the moment the
|
|
87
|
+
* prompt footer reappears.
|
|
88
|
+
*
|
|
89
|
+
* Without this, a choice prompt resolved *directly in the terminal* (the
|
|
90
|
+
* user picked an option without going through ADHDev's
|
|
91
|
+
* setInteractivePromptResponse) was never cleared from
|
|
92
|
+
* `activeInteractivePrompt`, so getStatus() re-emitted the same prompt
|
|
93
|
+
* forever — the choice-resolve-stuck bug.
|
|
94
|
+
*/
|
|
95
|
+
private interactivePromptLostAt: number | null = null;
|
|
79
96
|
private jsonLineTail = '';
|
|
80
97
|
private exited = false;
|
|
81
98
|
private spawned = false;
|
|
@@ -380,6 +397,11 @@ export class SpecCliAdapter implements CliAdapter {
|
|
|
380
397
|
// transition from the current state with its per-condition match
|
|
381
398
|
// result + countdown — the canonical "why isn't it moving" answer.
|
|
382
399
|
fsm: this.driver.getFsmDebug?.() ?? null,
|
|
400
|
+
// v4 FSM transition snapshot history (null for v3 specs). The full
|
|
401
|
+
// pre-transition evaluation table captured at each transition —
|
|
402
|
+
// answers "why did this rule fire" after the fact, unlike the live
|
|
403
|
+
// `fsm` field which only reflects the current instant.
|
|
404
|
+
fsmHistory: this.driver.getFsmSnapshotHistory?.() ?? null,
|
|
383
405
|
// Extended fields
|
|
384
406
|
name: this.cliName,
|
|
385
407
|
status: this.getStatus().status,
|
|
@@ -421,11 +443,13 @@ export class SpecCliAdapter implements CliAdapter {
|
|
|
421
443
|
if (ev.state.title) {
|
|
422
444
|
LOG.debug('SpecAdapter', `[${this.cliType}] state.title=${JSON.stringify(ev.state.title)}`);
|
|
423
445
|
}
|
|
446
|
+
this.maybeClearResolvedClaudeTuiPrompt();
|
|
424
447
|
this.maybeCaptureClaudeTuiPrompt();
|
|
425
448
|
this.statusCallback?.();
|
|
426
449
|
return;
|
|
427
450
|
case 'pty_data':
|
|
428
451
|
this.detectInteractivePromptFromPtyChunk(ev.chunk);
|
|
452
|
+
this.maybeClearResolvedClaudeTuiPrompt();
|
|
429
453
|
this.maybeCaptureClaudeTuiPrompt();
|
|
430
454
|
try { this.ptyDataCallback?.(ev.chunk); } catch { /* ignore */ }
|
|
431
455
|
return;
|
|
@@ -456,6 +480,7 @@ export class SpecCliAdapter implements CliAdapter {
|
|
|
456
480
|
if (!prompt) continue;
|
|
457
481
|
this.activeInteractivePrompt = prompt;
|
|
458
482
|
this.interactivePromptTransport = 'stream-json';
|
|
483
|
+
this.interactivePromptLostAt = null;
|
|
459
484
|
this.statusCallback?.();
|
|
460
485
|
} catch {
|
|
461
486
|
// PTY output is not guaranteed to be machine JSON.
|
|
@@ -514,6 +539,56 @@ export class SpecCliAdapter implements CliAdapter {
|
|
|
514
539
|
return messages;
|
|
515
540
|
}
|
|
516
541
|
|
|
542
|
+
/**
|
|
543
|
+
* Grace window a held interactive prompt must be absent from the screen
|
|
544
|
+
* before we treat it as resolved-in-terminal and clear it. claude-cli
|
|
545
|
+
* repaints the picker across multiple PTY chunks, so a single
|
|
546
|
+
* footer-less frame is not proof the prompt is gone. Sized in the same
|
|
547
|
+
* spirit as the approval FSM's `approvalCooldown` modal-lost hysteresis.
|
|
548
|
+
*/
|
|
549
|
+
private static readonly INTERACTIVE_PROMPT_LOST_GRACE_MS = 1500;
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Clear a held interactive prompt once the user has resolved it directly
|
|
553
|
+
* in the terminal (the choice picker leaves the screen without going
|
|
554
|
+
* through setInteractivePromptResponse). The approval path already does
|
|
555
|
+
* this via the FSM's modal-lost hysteresis; the interactive-prompt path
|
|
556
|
+
* had no equivalent, so a terminal-side answer left activeInteractivePrompt
|
|
557
|
+
* set and getStatus() re-emitted the same choice modal forever.
|
|
558
|
+
*
|
|
559
|
+
* Detection mirrors capture: the claude TUI picker is on-screen exactly
|
|
560
|
+
* while its "Enter to select" footer is rendered. When the footer is gone
|
|
561
|
+
* for INTERACTIVE_PROMPT_LOST_GRACE_MS the prompt is genuinely resolved.
|
|
562
|
+
*/
|
|
563
|
+
private maybeClearResolvedClaudeTuiPrompt(): void {
|
|
564
|
+
if (this.cliType !== 'claude-cli' || !this.activeInteractivePrompt) return;
|
|
565
|
+
// stream-json prompts are tracked by their tool-call lifecycle, not by
|
|
566
|
+
// screen footer, but claude renders the same TUI picker for both
|
|
567
|
+
// transports while awaiting an answer — so screen presence is a valid
|
|
568
|
+
// resolved-signal for either. (If the screen read fails, keep holding.)
|
|
569
|
+
let screenText = '';
|
|
570
|
+
try {
|
|
571
|
+
screenText = this.driver.snapshot();
|
|
572
|
+
} catch {
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
const stillOnScreen = screenText.includes('Enter to select');
|
|
576
|
+
if (stillOnScreen) {
|
|
577
|
+
// Prompt reappeared / never left — reset the hysteresis timer.
|
|
578
|
+
this.interactivePromptLostAt = null;
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
const lostAt = this.interactivePromptLostAt ?? Date.now();
|
|
582
|
+
if (this.interactivePromptLostAt === null) this.interactivePromptLostAt = lostAt;
|
|
583
|
+
if (Date.now() - lostAt < SpecCliAdapter.INTERACTIVE_PROMPT_LOST_GRACE_MS) return;
|
|
584
|
+
// Resolved in the terminal — drop the held prompt so getStatus() stops
|
|
585
|
+
// re-emitting it.
|
|
586
|
+
this.activeInteractivePrompt = null;
|
|
587
|
+
this.interactivePromptTransport = null;
|
|
588
|
+
this.interactivePromptLostAt = null;
|
|
589
|
+
this.statusCallback?.();
|
|
590
|
+
}
|
|
591
|
+
|
|
517
592
|
private maybeCaptureClaudeTuiPrompt(): void {
|
|
518
593
|
if (this.cliType !== 'claude-cli'
|
|
519
594
|
|| this.activeInteractivePrompt
|
|
@@ -529,6 +604,7 @@ export class SpecCliAdapter implements CliAdapter {
|
|
|
529
604
|
if (!prompt) return;
|
|
530
605
|
this.activeInteractivePrompt = prompt;
|
|
531
606
|
this.interactivePromptTransport = 'tui';
|
|
607
|
+
this.interactivePromptLostAt = null;
|
|
532
608
|
this.statusCallback?.();
|
|
533
609
|
return;
|
|
534
610
|
}
|
|
@@ -568,6 +644,7 @@ export class SpecCliAdapter implements CliAdapter {
|
|
|
568
644
|
if (!prompt) return;
|
|
569
645
|
this.activeInteractivePrompt = prompt;
|
|
570
646
|
this.interactivePromptTransport = 'tui';
|
|
647
|
+
this.interactivePromptLostAt = null;
|
|
571
648
|
this.statusCallback?.();
|
|
572
649
|
}
|
|
573
650
|
|
|
@@ -626,6 +703,9 @@ export class SpecCliAdapter implements CliAdapter {
|
|
|
626
703
|
// and countdown. This is the canonical "why isn't it transitioning"
|
|
627
704
|
// answer — no screenshots needed.
|
|
628
705
|
fsm: this.driver.getFsmDebug?.() ?? null,
|
|
706
|
+
// v4 FSM transition snapshot history — the captured pre-transition
|
|
707
|
+
// evaluation table at each transition (null for v3 specs).
|
|
708
|
+
fsmHistory: this.driver.getFsmSnapshotHistory?.() ?? null,
|
|
629
709
|
messages,
|
|
630
710
|
committedMessages: messages,
|
|
631
711
|
};
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
resolveSections, sectionText, extractTitle, extractButtonsFromRule,
|
|
29
29
|
type ResolvedSection, type TraceEntry,
|
|
30
30
|
} from './evaluator.js';
|
|
31
|
-
import { evaluateFsm, type FsmClock, type TransitionEval } from './fsm-evaluator.js';
|
|
31
|
+
import { evaluateFsm, type FsmClock, type TransitionEval, type FsmEvaluation } from './fsm-evaluator.js';
|
|
32
32
|
import {
|
|
33
33
|
type CliSpecV4, type FsmState, type FsmTransition,
|
|
34
34
|
initialState, stateById, statusForState, outgoingTransitions,
|
|
@@ -74,6 +74,37 @@ export interface DriverHistoryEntry {
|
|
|
74
74
|
via?: string;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/**
|
|
78
|
+
* A frozen snapshot of the FULL FSM evaluation captured at the instant a
|
|
79
|
+
* transition fired — the rich `transitions[]` table (per-transition eligible /
|
|
80
|
+
* hold countdown / per-condition CondResult + remainingMs) that `getFsmDebug()`
|
|
81
|
+
* otherwise only computes live for the current instant. Kept in a separate ring
|
|
82
|
+
* buffer from `stateHistory` (which stays intentionally lightweight) so the
|
|
83
|
+
* "why did this rule fire just before the transition" question is answerable
|
|
84
|
+
* after the fact. before-only: this is the evaluation that PRODUCED the
|
|
85
|
+
* transition, not the post-transition state.
|
|
86
|
+
*/
|
|
87
|
+
export interface FsmSnapshotEntry {
|
|
88
|
+
/** State we transitioned out of. */
|
|
89
|
+
stateFrom: string;
|
|
90
|
+
/** State we transitioned into (the fired transition's destination). */
|
|
91
|
+
stateTo: string;
|
|
92
|
+
/** Wall-clock time the transition committed (ms). */
|
|
93
|
+
at: number;
|
|
94
|
+
/** The fired transition's destination state id (== stateTo; kept explicit
|
|
95
|
+
* to mirror the rule that fired). */
|
|
96
|
+
firedTo: string;
|
|
97
|
+
/** Human label of the fired transition (e.g. "approval→busy"). */
|
|
98
|
+
firedLabel: string;
|
|
99
|
+
/** Why-it-fired summary, same shape produced for stateHistory.matchedRules. */
|
|
100
|
+
reason: string[];
|
|
101
|
+
/** Every outgoing transition from `stateFrom` as evaluated at `at`, each
|
|
102
|
+
* with its eligible / hold / per-condition CondResult + remainingMs. This
|
|
103
|
+
* is the full pre-transition evaluation table — the whole point of the
|
|
104
|
+
* snapshot. */
|
|
105
|
+
transitions: TransitionEval[];
|
|
106
|
+
}
|
|
107
|
+
|
|
77
108
|
export interface ISpecDriver {
|
|
78
109
|
subscribe(listener: (ev: DashboardEvent) => void): () => void;
|
|
79
110
|
start(): void;
|
|
@@ -89,6 +120,7 @@ export interface ISpecDriver {
|
|
|
89
120
|
hasIdleHoldPending(): boolean;
|
|
90
121
|
getCompletionIdleDebounceState(): { active: boolean; ageMs: number; holdMs: number; forceAfterMs: number } | null;
|
|
91
122
|
getFsmDebug?(): unknown;
|
|
123
|
+
getFsmSnapshotHistory?(): ReadonlyArray<FsmSnapshotEntry>;
|
|
92
124
|
}
|
|
93
125
|
|
|
94
126
|
export interface SpecDriverOpts {
|
|
@@ -182,6 +214,10 @@ export class FsmDriver implements ISpecDriver {
|
|
|
182
214
|
private specWatcher: fs.FSWatcher | null = null;
|
|
183
215
|
/** Last full FSM evaluation, kept for the debugger. */
|
|
184
216
|
private lastFsmEval: ReturnType<typeof evaluateFsm> | null = null;
|
|
217
|
+
/** Ring buffer (max 20) of the full FSM evaluation captured at each
|
|
218
|
+
* transition — the rich pre-transition table that lastFsmEval only keeps
|
|
219
|
+
* for the single most recent evaluation. Separate from stateHistory. */
|
|
220
|
+
private fsmSnapshotHistory: FsmSnapshotEntry[] = [];
|
|
185
221
|
|
|
186
222
|
constructor(private readonly opts: SpecDriverOpts) {
|
|
187
223
|
this.loadSpecOrThrow();
|
|
@@ -272,6 +308,7 @@ export class FsmDriver implements ISpecDriver {
|
|
|
272
308
|
}
|
|
273
309
|
|
|
274
310
|
getStateHistory(): ReadonlyArray<HistoryEntry> { return this.stateHistory; }
|
|
311
|
+
getFsmSnapshotHistory(): ReadonlyArray<FsmSnapshotEntry> { return this.fsmSnapshotHistory; }
|
|
275
312
|
getSections(): Array<{ id: string; text: string }> | null {
|
|
276
313
|
try {
|
|
277
314
|
const screen = this.adapter.snapshot();
|
|
@@ -385,7 +422,7 @@ export class FsmDriver implements ISpecDriver {
|
|
|
385
422
|
this.prevScreenLines = currentLines;
|
|
386
423
|
|
|
387
424
|
if (ev.fired) {
|
|
388
|
-
this.commitTransition(ev.fired, now);
|
|
425
|
+
this.commitTransition(ev.fired, now, ev);
|
|
389
426
|
// After a transition, immediately re-derive controls/modal for the
|
|
390
427
|
// new state and emit. Re-run once so a chain like approval→busy
|
|
391
428
|
// that's already satisfied doesn't wait for the next PTY frame.
|
|
@@ -403,8 +440,11 @@ export class FsmDriver implements ISpecDriver {
|
|
|
403
440
|
this.maybeMarkReady();
|
|
404
441
|
}
|
|
405
442
|
|
|
406
|
-
private commitTransition(fired: TransitionEval, now: number): void {
|
|
443
|
+
private commitTransition(fired: TransitionEval, now: number, ev: FsmEvaluation): void {
|
|
407
444
|
const from = this.currentStateId;
|
|
445
|
+
// Capture the full pre-transition evaluation BEFORE we mutate state, so
|
|
446
|
+
// the snapshot records why this transition fired from `from`.
|
|
447
|
+
this.pushFsmSnapshot(from, fired, now, ev);
|
|
408
448
|
this.currentStateId = fired.to;
|
|
409
449
|
this.stateEnteredAt = now;
|
|
410
450
|
// Region change timestamps are relative to the previous state's
|
|
@@ -418,6 +458,23 @@ export class FsmDriver implements ISpecDriver {
|
|
|
418
458
|
LOG.info('FsmDriver', `[${this.specTag()}] ${from} → ${fired.to} (${fired.label})`);
|
|
419
459
|
}
|
|
420
460
|
|
|
461
|
+
/** Snapshot the full FSM evaluation that produced a transition into the
|
|
462
|
+
* separate fsmSnapshotHistory ring buffer (max 20). The transitions[]
|
|
463
|
+
* table is captured by reference — it is freshly built per evaluation in
|
|
464
|
+
* evaluateFsm and never mutated after, so no clone is needed. */
|
|
465
|
+
private pushFsmSnapshot(from: string, fired: TransitionEval, now: number, ev: FsmEvaluation): void {
|
|
466
|
+
this.fsmSnapshotHistory.push({
|
|
467
|
+
stateFrom: from,
|
|
468
|
+
stateTo: fired.to,
|
|
469
|
+
at: now,
|
|
470
|
+
firedTo: fired.to,
|
|
471
|
+
firedLabel: fired.label,
|
|
472
|
+
reason: summarizeTransition(fired),
|
|
473
|
+
transitions: ev.transitions,
|
|
474
|
+
});
|
|
475
|
+
if (this.fsmSnapshotHistory.length > 20) this.fsmSnapshotHistory.shift();
|
|
476
|
+
}
|
|
477
|
+
|
|
421
478
|
/** Re-derive the visible modal + controls for the current state and emit a
|
|
422
479
|
* state_changed if anything differs from the last emit. */
|
|
423
480
|
private emitStateChanged(forceEmit: boolean): void {
|