@agfpd/iapeer 0.2.59 → 0.2.61

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agfpd/iapeer",
3
- "version": "0.2.59",
3
+ "version": "0.2.61",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -33,6 +33,7 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@modelcontextprotocol/sdk": "1.29.0",
36
+ "@xterm/addon-serialize": "0.14.0",
36
37
  "@xterm/headless": "6.0.0",
37
38
  "proper-lockfile": "^4.1.2",
38
39
  "transliteration": "^2.6.1"
package/src/cli/index.ts CHANGED
@@ -939,6 +939,7 @@ const USAGE = `usage: iapeer <verb> [args]
939
939
  shadow [--once | --minutes <m>] [--interval <ms>] READ-ONLY tmux→pty fidelity observer (migration burn-in): compares pty-model verdicts vs live tmux on the warm fleet → ~/.iapeer/logs/iapeer/shadow-fidelity.json. Never delivers/mutates.
940
940
  shadow-install install + load the always-on shadow burn-in launchd job (com.iapeer.shadow-fidelity); idempotent, re-cycled by update/rollback
941
941
  shadow-uninstall bootout + remove the shadow burn-in launchd job (the correct way to stop it; STOP file is respawned past by KeepAlive)
942
+ supervisor up|start|attach|list|kill <sess> [runtime] DARK (cutover Block 2): detach-persistent pty-supervisor PoC port; serves nothing on the live fleet (throwaway validation only)
942
943
  `
943
944
 
944
945
  export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.env): Promise<number> {
@@ -1801,6 +1802,14 @@ export async function runCli(argv: string[], env: NodeJS.ProcessEnv = process.en
1801
1802
  out(`shadow burn-in job ${r.action}: ${r.path}\n`)
1802
1803
  return 0
1803
1804
  }
1805
+ case 'supervisor': {
1806
+ // DARK (cutover Block 2): the detach-persistent pty-supervisor (PoC pts.mjs port). NOT
1807
+ // wired into delivery/launch — it serves NOTHING on the live fleet; it is validated on
1808
+ // throwaway `tick` sessions. The dynamic import keeps @xterm OUT of the daemon hot path
1809
+ // (the daemon never loads this). Sub-commands: up|start|attach|list|kill|daemon.
1810
+ const { runSupervisorCli } = await import('../supervisor/index.ts')
1811
+ return await runSupervisorCli(rest)
1812
+ }
1804
1813
  default: {
1805
1814
  // `iapeer <runtime>` (launch) — folder-launch the cwd's peer, ALWAYS fresh.
1806
1815
  if (verb && isRuntime(verb)) {
@@ -0,0 +1,249 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ GateTracker,
4
+ classifyDirection,
5
+ classifySampleState,
6
+ evaluateReadyGateFlipEligibility,
7
+ type GateSummary,
8
+ type PeerSample,
9
+ type VerdictGateStat,
10
+ } from './gate.ts'
11
+
12
+ // SYNTHETIC ACCEPTANCE (boris's Block-1 gate): prove the instrument classifies known-behind vs
13
+ // known-ahead and records coverage CORRECTLY — the live proof that it measures right before we
14
+ // trust any pty verdict for a flip. Pure: no tmux, no @xterm, booleans in, gate stats out.
15
+
16
+ const mkSample = (peer: string, runtime: string, over: Partial<PeerSample> = {}): PeerSample => ({
17
+ peer,
18
+ runtime,
19
+ occupancy: { pty: false, tmux: false },
20
+ readyGate: { pty: false, tmux: false },
21
+ liveness: { pty: true, tmux: true },
22
+ bytesFed: 0,
23
+ hasHistory: false,
24
+ ...over,
25
+ })
26
+
27
+ describe('classifyDirection (tmux authoritative; true = active/ready/alive pole)', () => {
28
+ test('equal → agree', () => {
29
+ expect(classifyDirection(true, true)).toBe('agree')
30
+ expect(classifyDirection(false, false)).toBe('agree')
31
+ })
32
+ test('pty under-reports (false vs true) → pty-behind (the stale class)', () => {
33
+ expect(classifyDirection(false, true)).toBe('pty-behind')
34
+ })
35
+ test('pty over-reports (true vs false) → pty-ahead', () => {
36
+ expect(classifyDirection(true, false)).toBe('pty-ahead')
37
+ })
38
+ })
39
+
40
+ describe('classifySampleState (coverage regimes — idle must be distinguishable, false-green lesson)', () => {
41
+ test('tmux occupancy → composer-busy (regardless of bytes)', () => {
42
+ expect(classifySampleState(true, 0)).toBe('composer-busy')
43
+ expect(classifySampleState(true, 999)).toBe('composer-busy')
44
+ })
45
+ test('no occupancy + output churn → model-advancing (non-trivial regime)', () => {
46
+ expect(classifySampleState(false, 4096)).toBe('model-advancing')
47
+ })
48
+ test('no occupancy + no churn → idle', () => {
49
+ expect(classifySampleState(false, 0)).toBe('idle')
50
+ })
51
+ })
52
+
53
+ describe('GateTracker — direction is scored hard vs benign per the adjudicated criterion', () => {
54
+ test('pty-BEHIND is ALWAYS hard (zero tolerance) and closes the verdict gate', () => {
55
+ const g = new GateTracker()
56
+ g.sample(0, mkSample('p', 'claude', { occupancy: { pty: false, tmux: true } }))
57
+ const s = g.summary()
58
+ expect(s.verdicts.occupancy.behind).toBe(1)
59
+ expect(s.verdicts.occupancy.gateClean).toBe(false)
60
+ expect(s.hardViolations.some(v => v.kind === 'behind' && v.verdict === 'occupancy')).toBe(true)
61
+ })
62
+
63
+ test('boot-window pty-AHEAD that self-heals within budget → BENIGN (latency recorded)', () => {
64
+ const g = new GateTracker({ settleTicks: 3, convergeBudgetTicks: 3 })
65
+ // tick0: readyGate ahead (pty ready, tmux not) — peer just born → boot window
66
+ g.sample(0, mkSample('p', 'claude', { readyGate: { pty: true, tmux: false } }))
67
+ // tick1: readyGate agrees (both ready) → transient closes, latency 1, boot+within budget → benign
68
+ g.sample(1, mkSample('p', 'claude', { readyGate: { pty: true, tmux: true } }))
69
+ const rg = g.summary().verdicts.readyGate
70
+ expect(rg.aheadBenign).toBe(1)
71
+ expect(rg.aheadHard).toBe(0)
72
+ expect(rg.gateClean).toBe(true)
73
+ expect(rg.selfHeal.count).toBe(1)
74
+ expect(rg.selfHeal.max).toBe(1)
75
+ })
76
+
77
+ test('READY-GATE non-converging pty-AHEAD is HARD, never benign (refinement #3: false-ready = msg-loss)', () => {
78
+ const g = new GateTracker({ settleTicks: 3, convergeBudgetTicks: 3 })
79
+ // hold readyGate ahead (pty ready, tmux NEVER ready → stays in boot window, never converges)
80
+ for (let t = 0; t <= 5; t++) g.sample(t, mkSample('p', 'codex', { readyGate: { pty: true, tmux: false } }))
81
+ const rg = g.summary().verdicts.readyGate
82
+ expect(rg.aheadHard).toBeGreaterThanOrEqual(1)
83
+ expect(rg.aheadBenign).toBe(0)
84
+ expect(rg.gateClean).toBe(false)
85
+ expect(g.summary().hardViolations.some(v => v.kind === 'ahead-nonconverged' && v.verdict === 'readyGate')).toBe(true)
86
+ })
87
+
88
+ test('STEADY-STATE pty-AHEAD is HARD immediately (cannot be a boot self-heal)', () => {
89
+ const g = new GateTracker({ settleTicks: 2, convergeBudgetTicks: 3 })
90
+ // boot the peer: readyGate true for settleTicks
91
+ g.sample(0, mkSample('p', 'claude', { readyGate: { pty: true, tmux: true } }))
92
+ g.sample(1, mkSample('p', 'claude', { readyGate: { pty: true, tmux: true } })) // bootDone now
93
+ // steady-state occupancy ahead → hard 'ahead-steady'
94
+ g.sample(2, mkSample('p', 'claude', { readyGate: { pty: true, tmux: true }, occupancy: { pty: true, tmux: false } }))
95
+ const occ = g.summary().verdicts.occupancy
96
+ expect(occ.aheadHard).toBeGreaterThanOrEqual(1)
97
+ expect(g.summary().hardViolations.some(v => v.kind === 'ahead-steady' && v.verdict === 'occupancy')).toBe(true)
98
+ })
99
+
100
+ test('full agreement → clean gate, zero hard violations', () => {
101
+ const g = new GateTracker()
102
+ for (let t = 0; t < 5; t++) g.sample(t, mkSample('p', 'claude', { readyGate: { pty: true, tmux: true } }))
103
+ const s = g.summary()
104
+ expect(s.hardViolations).toHaveLength(0)
105
+ expect(s.verdicts.readyGate.gateClean).toBe(true)
106
+ expect(s.verdicts.occupancy.gateClean).toBe(true)
107
+ expect(s.verdicts.liveness.gateClean).toBe(true)
108
+ })
109
+ })
110
+
111
+ describe('GateTracker — coverage matrix (clean counts only over traversed cells)', () => {
112
+ test('births split fresh vs wake-resume by history-at-first-sight', () => {
113
+ const g = new GateTracker()
114
+ g.tickStart(1)
115
+ g.sample(0, mkSample('fresh-peer', 'claude', { hasHistory: false }))
116
+ g.tickStart(2)
117
+ g.sample(1, mkSample('woke-peer', 'codex', { hasHistory: true })) // history at first sight = wake-resume
118
+ const cov = g.summary().coverage
119
+ expect(cov.births['claude/fresh']).toBe(1)
120
+ expect(cov.births['codex/wake-resume']).toBe(1)
121
+ })
122
+
123
+ test('a peer that died and reappeared is a restart', () => {
124
+ const g = new GateTracker()
125
+ g.sample(0, mkSample('p', 'claude'))
126
+ g.peerGone('p')
127
+ g.sample(5, mkSample('p', 'claude'))
128
+ expect(g.summary().coverage.restarts['claude']).toBe(1)
129
+ })
130
+
131
+ test('state cells, fleet width, and history samples are recorded', () => {
132
+ const g = new GateTracker()
133
+ g.tickStart(3) // fleet width 3 this tick
134
+ g.sample(0, mkSample('p', 'claude', { occupancy: { pty: true, tmux: true } })) // composer-busy
135
+ g.sample(1, mkSample('p', 'claude', { bytesFed: 4096 })) // model-advancing
136
+ g.sample(2, mkSample('p', 'claude', { bytesFed: 0 })) // idle
137
+ g.sample(3, mkSample('h', 'codex', { hasHistory: true }))
138
+ const cov = g.summary().coverage
139
+ expect(cov.fleetWidthMax).toBe(3)
140
+ expect(cov.byRuntimeState['claude/composer-busy']).toBe(1)
141
+ expect(cov.byRuntimeState['claude/model-advancing']).toBe(1)
142
+ expect(cov.byRuntimeState['claude/idle']).toBe(1)
143
+ expect(cov.historySamples['codex']).toBe(1)
144
+ expect(cov.runtimes).toEqual(['claude', 'codex'])
145
+ })
146
+ })
147
+
148
+ describe('GateTracker — center cell (codex×wake-resume×history readiness caught by the model)', () => {
149
+ test('a history-bearing peer whose readiness the model catches at the tmux transition → counted', () => {
150
+ const g = new GateTracker()
151
+ g.sample(0, mkSample('c', 'codex', { hasHistory: true, readyGate: { pty: false, tmux: false } }))
152
+ g.sample(1, mkSample('c', 'codex', { hasHistory: true, readyGate: { pty: true, tmux: true } })) // ready, model agreed
153
+ expect(g.summary().coverage.readyResumeCaught['codex']).toBe(1)
154
+ })
155
+ test('model MISSES readiness at the transition → NOT counted, and it is a pty-behind (dirty)', () => {
156
+ const g = new GateTracker()
157
+ g.sample(0, mkSample('c', 'codex', { hasHistory: true, readyGate: { pty: false, tmux: false } }))
158
+ g.sample(1, mkSample('c', 'codex', { hasHistory: true, readyGate: { pty: false, tmux: true } })) // tmux ready, model NOT
159
+ const s = g.summary()
160
+ expect(s.coverage.readyResumeCaught['codex']).toBeUndefined()
161
+ expect(s.verdicts.readyGate.behind).toBeGreaterThanOrEqual(1) // the exact splash-blind bug
162
+ })
163
+ test('a FRESH (no-history) peer ready transition is NOT a center-cell hit', () => {
164
+ const g = new GateTracker()
165
+ g.sample(0, mkSample('f', 'codex', { hasHistory: false, readyGate: { pty: false, tmux: false } }))
166
+ g.sample(1, mkSample('f', 'codex', { hasHistory: false, readyGate: { pty: true, tmux: true } }))
167
+ expect(g.summary().coverage.readyResumeCaught['codex']).toBeUndefined()
168
+ })
169
+ })
170
+
171
+ describe('evaluateReadyGateFlipEligibility (boris guardrail: the eligibility logic itself under test)', () => {
172
+ const stat = (over: Partial<VerdictGateStat> = {}): VerdictGateStat => ({
173
+ samples: 10,
174
+ agree: 10,
175
+ behind: 0,
176
+ aheadBenign: 0,
177
+ aheadHard: 0,
178
+ selfHeal: { count: 0, max: 0, mean: 0 },
179
+ gateClean: true,
180
+ ...over,
181
+ })
182
+ const mkSummary = (over: {
183
+ rgBehind?: number
184
+ rgAheadHard?: number
185
+ center?: Record<string, number>
186
+ births?: Record<string, number>
187
+ restarts?: Record<string, number>
188
+ } = {}): GateSummary => ({
189
+ verdicts: {
190
+ occupancy: stat(),
191
+ readyGate: stat({ behind: over.rgBehind ?? 0, aheadHard: over.rgAheadHard ?? 0 }),
192
+ liveness: stat(),
193
+ },
194
+ hardViolations: [],
195
+ coverage: {
196
+ fleetWidthMax: 2,
197
+ runtimes: ['claude', 'codex'],
198
+ byRuntimeState: {},
199
+ births: over.births ?? {},
200
+ restarts: over.restarts ?? {},
201
+ historySamples: {},
202
+ readyResumeCaught: over.center ?? {},
203
+ },
204
+ })
205
+ const COMPLETE = {
206
+ center: { codex: 1 },
207
+ births: { 'claude/fresh': 1, 'codex/wake-resume': 1 },
208
+ restarts: { codex: 1 },
209
+ }
210
+
211
+ test('incomplete (empty) → NOT eligible, reasons name every missing cell (actionable)', () => {
212
+ const e = evaluateReadyGateFlipEligibility(mkSummary())
213
+ expect(e.eligible).toBe(false)
214
+ expect(e.reasons.some(r => /center codex×wake-resume×history/.test(r))).toBe(true)
215
+ expect(e.reasons.some(r => /no codex birth/.test(r))).toBe(true)
216
+ expect(e.reasons.some(r => /no claude birth/.test(r))).toBe(true)
217
+ expect(e.reasons.some(r => /no restart/.test(r))).toBe(true)
218
+ })
219
+
220
+ test('complete + clean → eligible', () => {
221
+ const e = evaluateReadyGateFlipEligibility(mkSummary(COMPLETE))
222
+ expect(e.eligible).toBe(true)
223
+ expect(e.reasons).toHaveLength(0)
224
+ })
225
+
226
+ test('complete BUT dirty (readyGate behind) → NOT eligible, reason names the dirt', () => {
227
+ const e = evaluateReadyGateFlipEligibility(mkSummary({ ...COMPLETE, rgBehind: 2 }))
228
+ expect(e.eligible).toBe(false)
229
+ expect(e.reasons.some(r => /readyGate dirty: behind=2/.test(r))).toBe(true)
230
+ })
231
+
232
+ test('complete except the CENTER cell empty → NOT eligible, center reason only', () => {
233
+ const e = evaluateReadyGateFlipEligibility(mkSummary({ ...COMPLETE, center: {} }))
234
+ expect(e.eligible).toBe(false)
235
+ expect(e.reasons).toHaveLength(1)
236
+ expect(e.reasons[0]).toMatch(/center codex×wake-resume×history: 0/)
237
+ })
238
+
239
+ test('complete except no restart → NOT eligible, restart reason only', () => {
240
+ const e = evaluateReadyGateFlipEligibility(mkSummary({ ...COMPLETE, restarts: {} }))
241
+ expect(e.eligible).toBe(false)
242
+ expect(e.reasons).toEqual(['no restart observed'])
243
+ })
244
+
245
+ test('composer-busy is ORTHOGONAL — its absence never blocks ready-gate eligibility', () => {
246
+ // COMPLETE carries no composer-busy state anywhere; eligibility must still be true.
247
+ expect(evaluateReadyGateFlipEligibility(mkSummary(COMPLETE)).eligible).toBe(true)
248
+ })
249
+ })
@@ -0,0 +1,372 @@
1
+ // Gate-grade instrumentation for the tmux→pty fidelity burn-in (cutover Block 1).
2
+ //
3
+ // PURE classifiers + one stateful tracker, with NO tmux and NO @xterm import — the observer
4
+ // loop feeds it booleans, so all classification is hermetically unit-testable. That is exactly
5
+ // the synthetic acceptance boris requires: PROVE the instrument classifies known-behind vs
6
+ // known-ahead and records coverage correctly BEFORE we trust any pty verdict for a flip.
7
+ //
8
+ // It operationalizes the adjudicated gate criterion (NOT a flat sample count):
9
+ // • DIRECTION — pty-BEHIND (pty=False,tmux=True: a stale frame while live tmux moved on) is
10
+ // the reattach-stale class → ZERO tolerance, always hard. pty-AHEAD (pty=True,tmux=False)
11
+ // is benign ONLY when it (a) opened in the boot window, (b) self-heals within the converge
12
+ // budget, and (c) is not steady-state. Anything else → hard. For READY-GATE a non-converging
13
+ // ahead is a FALSE-ready (early-delivery → msg-loss class), so it is hard, never benign.
14
+ // • COVERAGE — which matrix cells the burn-in actually traversed {runtime × {idle,
15
+ // model-advancing, composer-busy}} + births (fresh vs wake-resume-with-history) + restarts +
16
+ // history-bearing samples + fleet width. "Clean" counts only over traversed cells (idle
17
+ // alone is a false green).
18
+
19
+ export type Verdict = 'occupancy' | 'readyGate' | 'liveness'
20
+ export const VERDICTS: Verdict[] = ['occupancy', 'readyGate', 'liveness']
21
+
22
+ export type Direction = 'agree' | 'pty-behind' | 'pty-ahead'
23
+
24
+ /**
25
+ * Direction of a (pty, tmux) verdict pair. tmux is AUTHORITATIVE; `true` is the
26
+ * active/ready/alive pole. pty UNDER-reporting it (pty=false, tmux=true) is BEHIND — the
27
+ * stale-frame class (zero tolerance). pty OVER-reporting (pty=true, tmux=false) is AHEAD.
28
+ */
29
+ export function classifyDirection(ptyVal: boolean, tmuxVal: boolean): Direction {
30
+ if (ptyVal === tmuxVal) return 'agree'
31
+ return tmuxVal ? 'pty-behind' : 'pty-ahead'
32
+ }
33
+
34
+ export type SampleState = 'idle' | 'model-advancing' | 'composer-busy'
35
+
36
+ /**
37
+ * Per-sample fleet state for the coverage matrix. composer-busy when tmux (authoritative) sees
38
+ * human occupancy; else model-advancing when output churned this tick (the model is actively
39
+ * rendering — the NON-trivial fidelity regime, not at rest); else idle. The split is what keeps
40
+ * an idle burn-in from reading as a false green (boris's acceptance lesson).
41
+ */
42
+ export function classifySampleState(occTmux: boolean, bytesFedThisTick: number): SampleState {
43
+ if (occTmux) return 'composer-busy'
44
+ if (bytesFedThisTick > 0) return 'model-advancing'
45
+ return 'idle'
46
+ }
47
+
48
+ export interface VerdictSample {
49
+ /** null when the tmux side was unavailable this tick (capture failed) — skipped, not scored. */
50
+ pty: boolean
51
+ tmux: boolean | null
52
+ }
53
+
54
+ export interface PeerSample {
55
+ peer: string
56
+ runtime: string
57
+ occupancy: VerdictSample
58
+ readyGate: VerdictSample
59
+ liveness: VerdictSample
60
+ /** Bytes appended to this peer's pane-log since the previous tick (drives state coverage). */
61
+ bytesFed: number
62
+ /** The model already carries scrollback (transcript taller than the pane) — a history-bearing
63
+ * peer; at first sight this means it woke WITH resume-history (vs a fresh empty birth). */
64
+ hasHistory: boolean
65
+ }
66
+
67
+ export interface GateConfig {
68
+ /** Consecutive tmux-ready ticks after which a peer is considered booted (out of the boot
69
+ * window). Default 3. */
70
+ settleTicks?: number
71
+ /** Max ticks an ahead-transient may stay open and still count as a benign self-heal.
72
+ * Default 3. */
73
+ convergeBudgetTicks?: number
74
+ }
75
+
76
+ export interface HardViolation {
77
+ peer: string
78
+ runtime: string
79
+ verdict: Verdict
80
+ tick: number
81
+ kind: 'behind' | 'ahead-steady' | 'ahead-nonconverged'
82
+ detail?: string
83
+ }
84
+
85
+ interface OpenAhead {
86
+ startTick: number
87
+ bootWindow: boolean
88
+ flaggedNonConverged: boolean
89
+ }
90
+
91
+ interface PeerState {
92
+ runtime: string
93
+ firstTick: number
94
+ readyStreak: number
95
+ bootDone: boolean
96
+ /** History present at first sight ⇒ this peer woke WITH resume-history (vs a fresh empty birth)
97
+ * — the population the center cell (codex×wake-resume×history) is measured on. */
98
+ bornWithHistory: boolean
99
+ /** Previous tmux ready value, to detect the false/none→true readiness transition. */
100
+ prevReadyTmux: boolean | null
101
+ open: Record<Verdict, OpenAhead | null>
102
+ }
103
+
104
+ interface VerdictTally {
105
+ samples: number
106
+ agree: number
107
+ behind: number
108
+ aheadBenign: number
109
+ aheadHard: number
110
+ aheadLatencies: number[]
111
+ }
112
+
113
+ export class GateTracker {
114
+ private readonly settleTicks: number
115
+ private readonly budget: number
116
+ private peers = new Map<string, PeerState>()
117
+ private goneOnce = new Set<string>()
118
+ private tally: Record<Verdict, VerdictTally>
119
+ private hard: HardViolation[] = []
120
+ // coverage
121
+ private fleetWidthMax = 0
122
+ private stateCells = new Map<string, number>() // `${runtime}/${state}` → count
123
+ private births = new Map<string, number>() // `${runtime}/${fresh|wake-resume}` → count
124
+ private restarts = new Map<string, number>() // runtime → count
125
+ private historySamples = new Map<string, number>() // runtime → count
126
+ // CENTER cell: a wake-resume-with-history peer whose readiness the MODEL caught at the tmux
127
+ // ready transition (rgTmux false/none→true with rgPty agreeing) — the codex splash-off-screen
128
+ // case the ready-gate flip exists for. A behind at that transition instead lands in readyGate.behind.
129
+ private readyResumeCaught = new Map<string, number>() // runtime → count
130
+ private runtimesSeen = new Set<string>()
131
+
132
+ constructor(cfg: GateConfig = {}) {
133
+ this.settleTicks = cfg.settleTicks ?? 3
134
+ this.budget = cfg.convergeBudgetTicks ?? 3
135
+ const mk = (): VerdictTally => ({ samples: 0, agree: 0, behind: 0, aheadBenign: 0, aheadHard: 0, aheadLatencies: [] })
136
+ this.tally = { occupancy: mk(), readyGate: mk(), liveness: mk() }
137
+ }
138
+
139
+ /** Once per tick, before the per-peer samples — records the concurrent fleet width. */
140
+ tickStart(nPeers: number): void {
141
+ if (nPeers > this.fleetWidthMax) this.fleetWidthMax = nPeers
142
+ }
143
+
144
+ /** The observer dropped a peer (session gone) — a later reappearance is a restart. */
145
+ peerGone(addr: string): void {
146
+ if (this.peers.delete(addr)) this.goneOnce.add(addr)
147
+ }
148
+
149
+ private inc(map: Map<string, number>, key: string): void {
150
+ map.set(key, (map.get(key) ?? 0) + 1)
151
+ }
152
+
153
+ /** Record one peer's sample at `tick`. Updates boot state, classifies every verdict's
154
+ * direction, manages ahead-transients, and accumulates the coverage matrix. */
155
+ sample(tick: number, s: PeerSample): void {
156
+ this.runtimesSeen.add(s.runtime)
157
+ let ps = this.peers.get(s.peer)
158
+ if (!ps) {
159
+ // First sight this lifetime → a birth (or a restart if we saw it die before).
160
+ const birthType = s.hasHistory ? 'wake-resume' : 'fresh'
161
+ this.inc(this.births, `${s.runtime}/${birthType}`)
162
+ if (this.goneOnce.has(s.peer)) {
163
+ this.inc(this.restarts, s.runtime)
164
+ this.goneOnce.delete(s.peer)
165
+ }
166
+ ps = {
167
+ runtime: s.runtime,
168
+ firstTick: tick,
169
+ readyStreak: 0,
170
+ bootDone: false,
171
+ bornWithHistory: s.hasHistory,
172
+ prevReadyTmux: null,
173
+ open: { occupancy: null, readyGate: null, liveness: null },
174
+ }
175
+ this.peers.set(s.peer, ps)
176
+ }
177
+
178
+ // Boot-window: a peer is booted once tmux-ready has held true for settleTicks in a row.
179
+ if (s.readyGate.tmux === true) {
180
+ ps.readyStreak++
181
+ if (ps.readyStreak >= this.settleTicks) ps.bootDone = true
182
+ } else if (s.readyGate.tmux === false) {
183
+ ps.readyStreak = 0
184
+ }
185
+ const bootWindow = !ps.bootDone
186
+
187
+ // Center-cell signal: at the readiness transition (rgTmux false/none→true) of a
188
+ // wake-resume-with-history peer, did the MODEL catch readiness too (rgPty=true)? That is the
189
+ // codex splash-off-screen case the ready-gate flip exists for. (A model MISS at this transition
190
+ // is a pty-behind → readyGate.behind → dirty → ineligible, which is exactly right.)
191
+ if (s.readyGate.tmux !== null) {
192
+ // becameReady requires an OBSERVED not-ready→ready transition (prev === false), NOT
193
+ // first-sight-already-ready (prev === null): the center cell must capture a REAL resume
194
+ // splash→ready the observer witnessed (boris's representativeness guardrail), not a warm
195
+ // peer that was already up when the observer attached.
196
+ const becameReady = s.readyGate.tmux === true && ps.prevReadyTmux === false
197
+ if (becameReady && ps.bornWithHistory && s.readyGate.pty === true) this.inc(this.readyResumeCaught, s.runtime)
198
+ ps.prevReadyTmux = s.readyGate.tmux
199
+ }
200
+
201
+ // Coverage.
202
+ const state = classifySampleState(s.occupancy.tmux === true, s.bytesFed)
203
+ this.inc(this.stateCells, `${s.runtime}/${state}`)
204
+ if (s.hasHistory) this.inc(this.historySamples, s.runtime)
205
+
206
+ // Per-verdict direction + transient bookkeeping.
207
+ this.scoreVerdict('occupancy', tick, ps, bootWindow, s.runtime, s.peer, s.occupancy)
208
+ this.scoreVerdict('readyGate', tick, ps, bootWindow, s.runtime, s.peer, s.readyGate)
209
+ this.scoreVerdict('liveness', tick, ps, bootWindow, s.runtime, s.peer, s.liveness)
210
+ }
211
+
212
+ private scoreVerdict(
213
+ verdict: Verdict,
214
+ tick: number,
215
+ ps: PeerState,
216
+ bootWindow: boolean,
217
+ runtime: string,
218
+ peer: string,
219
+ vs: VerdictSample,
220
+ ): void {
221
+ if (vs.tmux === null) return // tmux side unavailable → not scored
222
+ const t = this.tally[verdict]
223
+ t.samples++
224
+ const dir = classifyDirection(vs.pty, vs.tmux)
225
+
226
+ // An open ahead-transient that exceeds the converge budget is a hard violation the moment
227
+ // it overruns (logged once), regardless of how it later resolves.
228
+ const open = ps.open[verdict]
229
+ if (open && !open.flaggedNonConverged && tick - open.startTick > this.budget) {
230
+ open.flaggedNonConverged = true
231
+ t.aheadHard++
232
+ this.hard.push({ peer, runtime, verdict, tick, kind: 'ahead-nonconverged', detail: `ahead open ${tick - open.startTick} ticks > budget ${this.budget}` })
233
+ }
234
+
235
+ if (dir === 'agree') {
236
+ t.agree++
237
+ if (open) {
238
+ // transient closed → measure self-heal latency; benign iff it opened in the boot
239
+ // window AND healed within budget (and was not already flagged non-converged).
240
+ const latency = tick - open.startTick
241
+ t.aheadLatencies.push(latency)
242
+ if (!open.flaggedNonConverged) {
243
+ if (open.bootWindow && latency <= this.budget) t.aheadBenign++
244
+ else {
245
+ t.aheadHard++
246
+ this.hard.push({ peer, runtime, verdict, tick, kind: open.bootWindow ? 'ahead-nonconverged' : 'ahead-steady', detail: `latency ${latency}, boot=${open.bootWindow}` })
247
+ }
248
+ }
249
+ ps.open[verdict] = null
250
+ }
251
+ return
252
+ }
253
+
254
+ if (dir === 'pty-behind') {
255
+ // The stale-frame class — always hard, zero tolerance. A behind also closes any open
256
+ // ahead-transient as an abnormal resolution (not a benign heal).
257
+ t.behind++
258
+ this.hard.push({ peer, runtime, verdict, tick, kind: 'behind' })
259
+ ps.open[verdict] = null
260
+ return
261
+ }
262
+
263
+ // pty-ahead: open a transient if none is open. Steady-state ahead is hard immediately
264
+ // (it cannot be a boot self-heal); boot-window ahead waits to see if it converges.
265
+ if (!open) {
266
+ if (!bootWindow) {
267
+ t.aheadHard++
268
+ this.hard.push({ peer, runtime, verdict, tick, kind: 'ahead-steady' })
269
+ // still open it so we can measure when it clears, but it is already counted hard
270
+ ps.open[verdict] = { startTick: tick, bootWindow: false, flaggedNonConverged: true }
271
+ } else {
272
+ ps.open[verdict] = { startTick: tick, bootWindow: true, flaggedNonConverged: false }
273
+ }
274
+ }
275
+ }
276
+
277
+ /** True iff no hard divergence has been recorded for `verdict` (the per-verdict gate bar). */
278
+ verdictGateClean(verdict: Verdict): boolean {
279
+ const t = this.tally[verdict]
280
+ return t.behind === 0 && t.aheadHard === 0
281
+ }
282
+
283
+ summary(): GateSummary {
284
+ const latStats = (xs: number[]): { count: number; max: number; mean: number } =>
285
+ xs.length ? { count: xs.length, max: Math.max(...xs), mean: +(xs.reduce((a, b) => a + b, 0) / xs.length).toFixed(2) } : { count: 0, max: 0, mean: 0 }
286
+ const verdicts = {} as Record<Verdict, VerdictGateStat>
287
+ for (const v of VERDICTS) {
288
+ const t = this.tally[v]
289
+ verdicts[v] = {
290
+ samples: t.samples,
291
+ agree: t.agree,
292
+ behind: t.behind,
293
+ aheadBenign: t.aheadBenign,
294
+ aheadHard: t.aheadHard,
295
+ selfHeal: latStats(t.aheadLatencies),
296
+ gateClean: this.verdictGateClean(v),
297
+ }
298
+ }
299
+ return {
300
+ verdicts,
301
+ hardViolations: this.hard.slice(),
302
+ coverage: {
303
+ fleetWidthMax: this.fleetWidthMax,
304
+ runtimes: [...this.runtimesSeen].sort(),
305
+ byRuntimeState: Object.fromEntries([...this.stateCells].sort()),
306
+ births: Object.fromEntries([...this.births].sort()),
307
+ restarts: Object.fromEntries([...this.restarts].sort()),
308
+ historySamples: Object.fromEntries([...this.historySamples].sort()),
309
+ readyResumeCaught: Object.fromEntries([...this.readyResumeCaught].sort()),
310
+ },
311
+ }
312
+ }
313
+ }
314
+
315
+ /** Ready-gate FLIP eligibility — boris's verdict-scoped gate, operationalized so the burn-in
316
+ * SELF-REPORTS instead of being eyeballed. NECESSARY input, NOT an auto-trigger: the flip stays
317
+ * an explicit human decision (the representativeness of the filled cells — e.g. the splash truly
318
+ * scrolled off-screen — is verified separately on top of this). Eligible iff:
319
+ * • ready-gate is HARD-clean (behind=0 ∧ aheadHard=0), AND
320
+ * • the CENTER cell is filled: codex woke WITH history and the model caught its readiness, AND
321
+ * • a codex AND a claude birth were observed, AND
322
+ * • a restart was observed.
323
+ * composer-busy is ORTHOGONAL to readiness (a typing human already passed readiness) → excluded;
324
+ * it gates the LATER occupancy flip. On !eligible `reasons` say WHICH cell is empty/dirty —
325
+ * actionable, never a black-box "not yet". PURE → its own logic is unit-testable (boris's guardrail). */
326
+ export function evaluateReadyGateFlipEligibility(s: GateSummary): FlipEligibility {
327
+ const reasons: string[] = []
328
+ const rg = s.verdicts.readyGate
329
+ if (rg.behind > 0 || rg.aheadHard > 0) reasons.push(`readyGate dirty: behind=${rg.behind} aheadHard=${rg.aheadHard}`)
330
+ const center = s.coverage.readyResumeCaught['codex'] ?? 0
331
+ if (center < 1) reasons.push(`center codex×wake-resume×history: ${center} readiness caught (need ≥1, representatively splash-off-screen)`)
332
+ const hasBirth = (rt: string): boolean => Object.keys(s.coverage.births).some(k => k.startsWith(`${rt}/`))
333
+ if (!hasBirth('codex')) reasons.push('no codex birth observed')
334
+ if (!hasBirth('claude')) reasons.push('no claude birth observed')
335
+ const restarts = Object.values(s.coverage.restarts).reduce((a, b) => a + b, 0)
336
+ if (restarts < 1) reasons.push('no restart observed')
337
+ return { verdict: 'readyGate', eligible: reasons.length === 0, reasons }
338
+ }
339
+
340
+ export interface VerdictGateStat {
341
+ samples: number
342
+ agree: number
343
+ behind: number
344
+ aheadBenign: number
345
+ aheadHard: number
346
+ selfHeal: { count: number; max: number; mean: number }
347
+ gateClean: boolean
348
+ }
349
+
350
+ export interface GateCoverage {
351
+ fleetWidthMax: number
352
+ runtimes: string[]
353
+ byRuntimeState: Record<string, number>
354
+ births: Record<string, number>
355
+ restarts: Record<string, number>
356
+ historySamples: Record<string, number>
357
+ /** runtime → count of wake-resume-with-history readiness transitions the MODEL caught (center cell). */
358
+ readyResumeCaught: Record<string, number>
359
+ }
360
+
361
+ export interface GateSummary {
362
+ verdicts: Record<Verdict, VerdictGateStat>
363
+ hardViolations: HardViolation[]
364
+ coverage: GateCoverage
365
+ }
366
+
367
+ export interface FlipEligibility {
368
+ verdict: 'readyGate'
369
+ eligible: boolean
370
+ /** On !eligible: one actionable line per unmet requirement (which cell is empty/dirty). */
371
+ reasons: string[]
372
+ }