@agfpd/iapeer 0.2.59 → 0.2.60

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.60",
4
4
  "description": "Foundation core for the iapeer multi-agent ecosystem: identity, registry, storage, codec.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,143 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ GateTracker,
4
+ classifyDirection,
5
+ classifySampleState,
6
+ type PeerSample,
7
+ } from './gate.ts'
8
+
9
+ // SYNTHETIC ACCEPTANCE (boris's Block-1 gate): prove the instrument classifies known-behind vs
10
+ // known-ahead and records coverage CORRECTLY — the live proof that it measures right before we
11
+ // trust any pty verdict for a flip. Pure: no tmux, no @xterm, booleans in, gate stats out.
12
+
13
+ const mkSample = (peer: string, runtime: string, over: Partial<PeerSample> = {}): PeerSample => ({
14
+ peer,
15
+ runtime,
16
+ occupancy: { pty: false, tmux: false },
17
+ readyGate: { pty: false, tmux: false },
18
+ liveness: { pty: true, tmux: true },
19
+ bytesFed: 0,
20
+ hasHistory: false,
21
+ ...over,
22
+ })
23
+
24
+ describe('classifyDirection (tmux authoritative; true = active/ready/alive pole)', () => {
25
+ test('equal → agree', () => {
26
+ expect(classifyDirection(true, true)).toBe('agree')
27
+ expect(classifyDirection(false, false)).toBe('agree')
28
+ })
29
+ test('pty under-reports (false vs true) → pty-behind (the stale class)', () => {
30
+ expect(classifyDirection(false, true)).toBe('pty-behind')
31
+ })
32
+ test('pty over-reports (true vs false) → pty-ahead', () => {
33
+ expect(classifyDirection(true, false)).toBe('pty-ahead')
34
+ })
35
+ })
36
+
37
+ describe('classifySampleState (coverage regimes — idle must be distinguishable, false-green lesson)', () => {
38
+ test('tmux occupancy → composer-busy (regardless of bytes)', () => {
39
+ expect(classifySampleState(true, 0)).toBe('composer-busy')
40
+ expect(classifySampleState(true, 999)).toBe('composer-busy')
41
+ })
42
+ test('no occupancy + output churn → model-advancing (non-trivial regime)', () => {
43
+ expect(classifySampleState(false, 4096)).toBe('model-advancing')
44
+ })
45
+ test('no occupancy + no churn → idle', () => {
46
+ expect(classifySampleState(false, 0)).toBe('idle')
47
+ })
48
+ })
49
+
50
+ describe('GateTracker — direction is scored hard vs benign per the adjudicated criterion', () => {
51
+ test('pty-BEHIND is ALWAYS hard (zero tolerance) and closes the verdict gate', () => {
52
+ const g = new GateTracker()
53
+ g.sample(0, mkSample('p', 'claude', { occupancy: { pty: false, tmux: true } }))
54
+ const s = g.summary()
55
+ expect(s.verdicts.occupancy.behind).toBe(1)
56
+ expect(s.verdicts.occupancy.gateClean).toBe(false)
57
+ expect(s.hardViolations.some(v => v.kind === 'behind' && v.verdict === 'occupancy')).toBe(true)
58
+ })
59
+
60
+ test('boot-window pty-AHEAD that self-heals within budget → BENIGN (latency recorded)', () => {
61
+ const g = new GateTracker({ settleTicks: 3, convergeBudgetTicks: 3 })
62
+ // tick0: readyGate ahead (pty ready, tmux not) — peer just born → boot window
63
+ g.sample(0, mkSample('p', 'claude', { readyGate: { pty: true, tmux: false } }))
64
+ // tick1: readyGate agrees (both ready) → transient closes, latency 1, boot+within budget → benign
65
+ g.sample(1, mkSample('p', 'claude', { readyGate: { pty: true, tmux: true } }))
66
+ const rg = g.summary().verdicts.readyGate
67
+ expect(rg.aheadBenign).toBe(1)
68
+ expect(rg.aheadHard).toBe(0)
69
+ expect(rg.gateClean).toBe(true)
70
+ expect(rg.selfHeal.count).toBe(1)
71
+ expect(rg.selfHeal.max).toBe(1)
72
+ })
73
+
74
+ test('READY-GATE non-converging pty-AHEAD is HARD, never benign (refinement #3: false-ready = msg-loss)', () => {
75
+ const g = new GateTracker({ settleTicks: 3, convergeBudgetTicks: 3 })
76
+ // hold readyGate ahead (pty ready, tmux NEVER ready → stays in boot window, never converges)
77
+ for (let t = 0; t <= 5; t++) g.sample(t, mkSample('p', 'codex', { readyGate: { pty: true, tmux: false } }))
78
+ const rg = g.summary().verdicts.readyGate
79
+ expect(rg.aheadHard).toBeGreaterThanOrEqual(1)
80
+ expect(rg.aheadBenign).toBe(0)
81
+ expect(rg.gateClean).toBe(false)
82
+ expect(g.summary().hardViolations.some(v => v.kind === 'ahead-nonconverged' && v.verdict === 'readyGate')).toBe(true)
83
+ })
84
+
85
+ test('STEADY-STATE pty-AHEAD is HARD immediately (cannot be a boot self-heal)', () => {
86
+ const g = new GateTracker({ settleTicks: 2, convergeBudgetTicks: 3 })
87
+ // boot the peer: readyGate true for settleTicks
88
+ g.sample(0, mkSample('p', 'claude', { readyGate: { pty: true, tmux: true } }))
89
+ g.sample(1, mkSample('p', 'claude', { readyGate: { pty: true, tmux: true } })) // bootDone now
90
+ // steady-state occupancy ahead → hard 'ahead-steady'
91
+ g.sample(2, mkSample('p', 'claude', { readyGate: { pty: true, tmux: true }, occupancy: { pty: true, tmux: false } }))
92
+ const occ = g.summary().verdicts.occupancy
93
+ expect(occ.aheadHard).toBeGreaterThanOrEqual(1)
94
+ expect(g.summary().hardViolations.some(v => v.kind === 'ahead-steady' && v.verdict === 'occupancy')).toBe(true)
95
+ })
96
+
97
+ test('full agreement → clean gate, zero hard violations', () => {
98
+ const g = new GateTracker()
99
+ for (let t = 0; t < 5; t++) g.sample(t, mkSample('p', 'claude', { readyGate: { pty: true, tmux: true } }))
100
+ const s = g.summary()
101
+ expect(s.hardViolations).toHaveLength(0)
102
+ expect(s.verdicts.readyGate.gateClean).toBe(true)
103
+ expect(s.verdicts.occupancy.gateClean).toBe(true)
104
+ expect(s.verdicts.liveness.gateClean).toBe(true)
105
+ })
106
+ })
107
+
108
+ describe('GateTracker — coverage matrix (clean counts only over traversed cells)', () => {
109
+ test('births split fresh vs wake-resume by history-at-first-sight', () => {
110
+ const g = new GateTracker()
111
+ g.tickStart(1)
112
+ g.sample(0, mkSample('fresh-peer', 'claude', { hasHistory: false }))
113
+ g.tickStart(2)
114
+ g.sample(1, mkSample('woke-peer', 'codex', { hasHistory: true })) // history at first sight = wake-resume
115
+ const cov = g.summary().coverage
116
+ expect(cov.births['claude/fresh']).toBe(1)
117
+ expect(cov.births['codex/wake-resume']).toBe(1)
118
+ })
119
+
120
+ test('a peer that died and reappeared is a restart', () => {
121
+ const g = new GateTracker()
122
+ g.sample(0, mkSample('p', 'claude'))
123
+ g.peerGone('p')
124
+ g.sample(5, mkSample('p', 'claude'))
125
+ expect(g.summary().coverage.restarts['claude']).toBe(1)
126
+ })
127
+
128
+ test('state cells, fleet width, and history samples are recorded', () => {
129
+ const g = new GateTracker()
130
+ g.tickStart(3) // fleet width 3 this tick
131
+ g.sample(0, mkSample('p', 'claude', { occupancy: { pty: true, tmux: true } })) // composer-busy
132
+ g.sample(1, mkSample('p', 'claude', { bytesFed: 4096 })) // model-advancing
133
+ g.sample(2, mkSample('p', 'claude', { bytesFed: 0 })) // idle
134
+ g.sample(3, mkSample('h', 'codex', { hasHistory: true }))
135
+ const cov = g.summary().coverage
136
+ expect(cov.fleetWidthMax).toBe(3)
137
+ expect(cov.byRuntimeState['claude/composer-busy']).toBe(1)
138
+ expect(cov.byRuntimeState['claude/model-advancing']).toBe(1)
139
+ expect(cov.byRuntimeState['claude/idle']).toBe(1)
140
+ expect(cov.historySamples['codex']).toBe(1)
141
+ expect(cov.runtimes).toEqual(['claude', 'codex'])
142
+ })
143
+ })
@@ -0,0 +1,306 @@
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
+ open: Record<Verdict, OpenAhead | null>
97
+ }
98
+
99
+ interface VerdictTally {
100
+ samples: number
101
+ agree: number
102
+ behind: number
103
+ aheadBenign: number
104
+ aheadHard: number
105
+ aheadLatencies: number[]
106
+ }
107
+
108
+ export class GateTracker {
109
+ private readonly settleTicks: number
110
+ private readonly budget: number
111
+ private peers = new Map<string, PeerState>()
112
+ private goneOnce = new Set<string>()
113
+ private tally: Record<Verdict, VerdictTally>
114
+ private hard: HardViolation[] = []
115
+ // coverage
116
+ private fleetWidthMax = 0
117
+ private stateCells = new Map<string, number>() // `${runtime}/${state}` → count
118
+ private births = new Map<string, number>() // `${runtime}/${fresh|wake-resume}` → count
119
+ private restarts = new Map<string, number>() // runtime → count
120
+ private historySamples = new Map<string, number>() // runtime → count
121
+ private runtimesSeen = new Set<string>()
122
+
123
+ constructor(cfg: GateConfig = {}) {
124
+ this.settleTicks = cfg.settleTicks ?? 3
125
+ this.budget = cfg.convergeBudgetTicks ?? 3
126
+ const mk = (): VerdictTally => ({ samples: 0, agree: 0, behind: 0, aheadBenign: 0, aheadHard: 0, aheadLatencies: [] })
127
+ this.tally = { occupancy: mk(), readyGate: mk(), liveness: mk() }
128
+ }
129
+
130
+ /** Once per tick, before the per-peer samples — records the concurrent fleet width. */
131
+ tickStart(nPeers: number): void {
132
+ if (nPeers > this.fleetWidthMax) this.fleetWidthMax = nPeers
133
+ }
134
+
135
+ /** The observer dropped a peer (session gone) — a later reappearance is a restart. */
136
+ peerGone(addr: string): void {
137
+ if (this.peers.delete(addr)) this.goneOnce.add(addr)
138
+ }
139
+
140
+ private inc(map: Map<string, number>, key: string): void {
141
+ map.set(key, (map.get(key) ?? 0) + 1)
142
+ }
143
+
144
+ /** Record one peer's sample at `tick`. Updates boot state, classifies every verdict's
145
+ * direction, manages ahead-transients, and accumulates the coverage matrix. */
146
+ sample(tick: number, s: PeerSample): void {
147
+ this.runtimesSeen.add(s.runtime)
148
+ let ps = this.peers.get(s.peer)
149
+ if (!ps) {
150
+ // First sight this lifetime → a birth (or a restart if we saw it die before).
151
+ const birthType = s.hasHistory ? 'wake-resume' : 'fresh'
152
+ this.inc(this.births, `${s.runtime}/${birthType}`)
153
+ if (this.goneOnce.has(s.peer)) {
154
+ this.inc(this.restarts, s.runtime)
155
+ this.goneOnce.delete(s.peer)
156
+ }
157
+ ps = { runtime: s.runtime, firstTick: tick, readyStreak: 0, bootDone: false, open: { occupancy: null, readyGate: null, liveness: null } }
158
+ this.peers.set(s.peer, ps)
159
+ }
160
+
161
+ // Boot-window: a peer is booted once tmux-ready has held true for settleTicks in a row.
162
+ if (s.readyGate.tmux === true) {
163
+ ps.readyStreak++
164
+ if (ps.readyStreak >= this.settleTicks) ps.bootDone = true
165
+ } else if (s.readyGate.tmux === false) {
166
+ ps.readyStreak = 0
167
+ }
168
+ const bootWindow = !ps.bootDone
169
+
170
+ // Coverage.
171
+ const state = classifySampleState(s.occupancy.tmux === true, s.bytesFed)
172
+ this.inc(this.stateCells, `${s.runtime}/${state}`)
173
+ if (s.hasHistory) this.inc(this.historySamples, s.runtime)
174
+
175
+ // Per-verdict direction + transient bookkeeping.
176
+ this.scoreVerdict('occupancy', tick, ps, bootWindow, s.runtime, s.peer, s.occupancy)
177
+ this.scoreVerdict('readyGate', tick, ps, bootWindow, s.runtime, s.peer, s.readyGate)
178
+ this.scoreVerdict('liveness', tick, ps, bootWindow, s.runtime, s.peer, s.liveness)
179
+ }
180
+
181
+ private scoreVerdict(
182
+ verdict: Verdict,
183
+ tick: number,
184
+ ps: PeerState,
185
+ bootWindow: boolean,
186
+ runtime: string,
187
+ peer: string,
188
+ vs: VerdictSample,
189
+ ): void {
190
+ if (vs.tmux === null) return // tmux side unavailable → not scored
191
+ const t = this.tally[verdict]
192
+ t.samples++
193
+ const dir = classifyDirection(vs.pty, vs.tmux)
194
+
195
+ // An open ahead-transient that exceeds the converge budget is a hard violation the moment
196
+ // it overruns (logged once), regardless of how it later resolves.
197
+ const open = ps.open[verdict]
198
+ if (open && !open.flaggedNonConverged && tick - open.startTick > this.budget) {
199
+ open.flaggedNonConverged = true
200
+ t.aheadHard++
201
+ this.hard.push({ peer, runtime, verdict, tick, kind: 'ahead-nonconverged', detail: `ahead open ${tick - open.startTick} ticks > budget ${this.budget}` })
202
+ }
203
+
204
+ if (dir === 'agree') {
205
+ t.agree++
206
+ if (open) {
207
+ // transient closed → measure self-heal latency; benign iff it opened in the boot
208
+ // window AND healed within budget (and was not already flagged non-converged).
209
+ const latency = tick - open.startTick
210
+ t.aheadLatencies.push(latency)
211
+ if (!open.flaggedNonConverged) {
212
+ if (open.bootWindow && latency <= this.budget) t.aheadBenign++
213
+ else {
214
+ t.aheadHard++
215
+ this.hard.push({ peer, runtime, verdict, tick, kind: open.bootWindow ? 'ahead-nonconverged' : 'ahead-steady', detail: `latency ${latency}, boot=${open.bootWindow}` })
216
+ }
217
+ }
218
+ ps.open[verdict] = null
219
+ }
220
+ return
221
+ }
222
+
223
+ if (dir === 'pty-behind') {
224
+ // The stale-frame class — always hard, zero tolerance. A behind also closes any open
225
+ // ahead-transient as an abnormal resolution (not a benign heal).
226
+ t.behind++
227
+ this.hard.push({ peer, runtime, verdict, tick, kind: 'behind' })
228
+ ps.open[verdict] = null
229
+ return
230
+ }
231
+
232
+ // pty-ahead: open a transient if none is open. Steady-state ahead is hard immediately
233
+ // (it cannot be a boot self-heal); boot-window ahead waits to see if it converges.
234
+ if (!open) {
235
+ if (!bootWindow) {
236
+ t.aheadHard++
237
+ this.hard.push({ peer, runtime, verdict, tick, kind: 'ahead-steady' })
238
+ // still open it so we can measure when it clears, but it is already counted hard
239
+ ps.open[verdict] = { startTick: tick, bootWindow: false, flaggedNonConverged: true }
240
+ } else {
241
+ ps.open[verdict] = { startTick: tick, bootWindow: true, flaggedNonConverged: false }
242
+ }
243
+ }
244
+ }
245
+
246
+ /** True iff no hard divergence has been recorded for `verdict` (the per-verdict gate bar). */
247
+ verdictGateClean(verdict: Verdict): boolean {
248
+ const t = this.tally[verdict]
249
+ return t.behind === 0 && t.aheadHard === 0
250
+ }
251
+
252
+ summary(): GateSummary {
253
+ const latStats = (xs: number[]): { count: number; max: number; mean: number } =>
254
+ 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 }
255
+ const verdicts = {} as Record<Verdict, VerdictGateStat>
256
+ for (const v of VERDICTS) {
257
+ const t = this.tally[v]
258
+ verdicts[v] = {
259
+ samples: t.samples,
260
+ agree: t.agree,
261
+ behind: t.behind,
262
+ aheadBenign: t.aheadBenign,
263
+ aheadHard: t.aheadHard,
264
+ selfHeal: latStats(t.aheadLatencies),
265
+ gateClean: this.verdictGateClean(v),
266
+ }
267
+ }
268
+ return {
269
+ verdicts,
270
+ hardViolations: this.hard.slice(),
271
+ coverage: {
272
+ fleetWidthMax: this.fleetWidthMax,
273
+ runtimes: [...this.runtimesSeen].sort(),
274
+ byRuntimeState: Object.fromEntries([...this.stateCells].sort()),
275
+ births: Object.fromEntries([...this.births].sort()),
276
+ restarts: Object.fromEntries([...this.restarts].sort()),
277
+ historySamples: Object.fromEntries([...this.historySamples].sort()),
278
+ },
279
+ }
280
+ }
281
+ }
282
+
283
+ export interface VerdictGateStat {
284
+ samples: number
285
+ agree: number
286
+ behind: number
287
+ aheadBenign: number
288
+ aheadHard: number
289
+ selfHeal: { count: number; max: number; mean: number }
290
+ gateClean: boolean
291
+ }
292
+
293
+ export interface GateCoverage {
294
+ fleetWidthMax: number
295
+ runtimes: string[]
296
+ byRuntimeState: Record<string, number>
297
+ births: Record<string, number>
298
+ restarts: Record<string, number>
299
+ historySamples: Record<string, number>
300
+ }
301
+
302
+ export interface GateSummary {
303
+ verdicts: Record<Verdict, VerdictGateStat>
304
+ hardViolations: HardViolation[]
305
+ coverage: GateCoverage
306
+ }
@@ -19,6 +19,7 @@ import { tmux, composerCaptureHasHumanInput, listOnlinePeers } from '../transpor
19
19
  import { getAdapter } from '../launch/index.ts'
20
20
  import { buildSocketPath, buildProcessAddress } from '../core/socket.ts'
21
21
  import type { Runtime } from '../core/constants.ts'
22
+ import { GateTracker, classifyDirection, VERDICTS } from './gate.ts'
22
23
 
23
24
  const SEED_BYTES = 4 * 1024 * 1024
24
25
  const promptGlyph = (rt: string): string => (rt === 'claude' ? '❯' : '›')
@@ -72,16 +73,18 @@ export interface ShadowOptions {
72
73
  log?: (s: string) => void
73
74
  }
74
75
 
75
- function feed(o: Obs): Promise<void> {
76
+ /** Feed the pane-log delta into the model; resolves with the byte count appended this tick
77
+ * (drives the coverage state classifier: >0 ⇒ the model is advancing, not idle). */
78
+ function feed(o: Obs): Promise<number> {
76
79
  const sz = fstatSync(o.fd).size
77
- if (sz <= o.offset) return Promise.resolve()
80
+ if (sz <= o.offset) return Promise.resolve(0)
78
81
  const buf = Buffer.alloc(sz - o.offset)
79
82
  readSync(o.fd, buf, 0, buf.length, o.offset)
80
83
  o.offset = sz
81
- return new Promise(res => o.term.write(buf, () => res()))
84
+ return new Promise(res => o.term.write(buf, () => res(buf.length)))
82
85
  }
83
86
 
84
- function enumerate(models: Map<string, Obs>, opts: ShadowOptions): void {
87
+ function enumerate(models: Map<string, Obs>, opts: ShadowOptions, tracker?: GateTracker): void {
85
88
  let peers: { runtime: Runtime; personality: string }[] = []
86
89
  try { peers = listOnlinePeers(opts.sockDir).filter(p => p.runtime === 'claude' || p.runtime === 'codex') } catch { return }
87
90
  const live = new Set<string>()
@@ -98,7 +101,7 @@ function enumerate(models: Map<string, Obs>, opts: ShadowOptions): void {
98
101
  models.set(addr, { addr, sock, runtime: p.runtime, cols, rows, term, fd, offset })
99
102
  } catch { /* a peer racing spawn/teardown — skip this round */ }
100
103
  }
101
- for (const [addr, o] of models) if (!live.has(addr)) { try { closeSync(o.fd) } catch { /* */ }; models.delete(addr) }
104
+ for (const [addr, o] of models) if (!live.has(addr)) { try { closeSync(o.fd) } catch { /* */ }; tracker?.peerGone(addr); models.delete(addr) }
102
105
  }
103
106
 
104
107
  /** Run the read-only fidelity observer. Resolves when once/maxMinutes/STOP completes. */
@@ -110,19 +113,20 @@ export async function runShadowFidelity(opts: ShadowOptions): Promise<void> {
110
113
  const interval = opts.intervalMs ?? 5000
111
114
  const log = opts.log ?? (() => { /* */ })
112
115
  const models = new Map<string, Obs>()
113
- const tot = { samples: 0, occDiv: 0, occNt: 0, rgDiv: 0, livDiv: 0 }
116
+ const tracker = new GateTracker()
114
117
  const startedISO = new Date().toISOString(), t0 = Date.now()
115
- enumerate(models, opts)
118
+ enumerate(models, opts, tracker)
116
119
  for (const o of models.values()) await feed(o) // seed each model
117
120
  log(`shadow-fidelity: ${models.size} warm peers, interval ${interval}ms\n`)
118
121
  let i = 0
119
122
  for (;;) {
120
123
  if (opts.maxMinutes && Date.now() - t0 >= opts.maxMinutes * 60000) break
121
124
  if (existsSync(stopFile)) break
122
- if (i > 0 && i % 15 === 0) enumerate(models, opts)
125
+ if (i > 0 && i % 15 === 0) enumerate(models, opts, tracker)
126
+ tracker.tickStart(models.size)
123
127
  for (const o of models.values()) {
124
128
  try {
125
- await feed(o)
129
+ const bytesFed = await feed(o)
126
130
  const adapter = getAdapter(o.runtime)
127
131
  const capE = tmux(o.sock, 'capture-pane', '-e', '-p', '-t', o.addr); if (!capE.ok) continue
128
132
  const occTmux = composerCaptureHasHumanInput(capE.out, adapter.deliveryMarkers)
@@ -134,34 +138,49 @@ export async function runShadowFidelity(opts: ShadowOptions): Promise<void> {
134
138
  const ppid = tmux(o.sock, 'display', '-p', '-t', o.addr, '#{pane_pid}')
135
139
  const pid = ppid.ok ? Number(ppid.out.trim()) : NaN
136
140
  const livPty = Number.isFinite(pid) ? pidAlive(pid) : false
141
+ const hasHistory = o.term.buffer.active.baseY > 0
137
142
 
138
- tot.samples++
139
- const occDiv = occPty !== occTmux, rgDiv = rgTmux !== null && rgPty !== rgTmux, livDiv = livPty !== livTmux
140
- const occNt = occPty || occTmux
141
- if (occNt) tot.occNt++
142
- if (occDiv) tot.occDiv++
143
- if (rgDiv) tot.rgDiv++
144
- if (livDiv) tot.livDiv++
145
- if (occDiv || rgDiv || livDiv) {
143
+ // Feed the gate tracker — it scores DIRECTION (behind=hard / ahead=benign-if-boot-self-heal)
144
+ // and accumulates the COVERAGE matrix. The boolean booleans never leave this call.
145
+ tracker.sample(i, {
146
+ peer: o.addr, runtime: o.runtime,
147
+ occupancy: { pty: occPty, tmux: occTmux },
148
+ readyGate: { pty: rgPty, tmux: rgTmux },
149
+ liveness: { pty: livPty, tmux: livTmux },
150
+ bytesFed, hasHistory,
151
+ })
152
+
153
+ // DIRECTION-AWARE per-event divergence trail (gate scoring lives in the tracker; this is
154
+ // the forensic log). Occupancy carries the raw capture + model frames for root-cause.
155
+ const occDir = classifyDirection(occPty, occTmux)
156
+ const rgDir = rgTmux === null ? 'agree' : classifyDirection(rgPty, rgTmux)
157
+ const livDir = classifyDirection(livPty, livTmux)
158
+ if (occDir !== 'agree' || rgDir !== 'agree' || livDir !== 'agree') {
146
159
  appendFileSync(jsonl, JSON.stringify({
147
- ts: new Date().toISOString(), peer: o.addr, geom: `${o.cols}x${o.rows}`,
148
- occupancy: { pty: occPty, tmux: occTmux, diverged: occDiv, nontrivial: occNt },
149
- readyGate: { pty: rgPty, tmux: rgTmux, diverged: rgDiv },
150
- liveness: { pty: livPty, tmux: livTmux, diverged: livDiv },
151
- ...(occDiv ? { capE: capE.out.slice(-2500), model: modelToPlainText(o.term, o.cols, o.rows).slice(-2500) } : {}),
160
+ ts: new Date().toISOString(), peer: o.addr, geom: `${o.cols}x${o.rows}`, hasHistory,
161
+ occupancy: { pty: occPty, tmux: occTmux, dir: occDir },
162
+ readyGate: { pty: rgPty, tmux: rgTmux, dir: rgDir },
163
+ liveness: { pty: livPty, tmux: livTmux, dir: livDir },
164
+ ...(occDir !== 'agree' ? { capE: capE.out.slice(-2500), model: modelToPlainText(o.term, o.cols, o.rows).slice(-2500) } : {}),
152
165
  }) + '\n')
153
- log(`DIVERGE ${o.addr} occ=${occDiv} rg=${rgDiv} liv=${livDiv}${occNt && occDiv ? ' *** NON-TRIVIAL occ ***' : ''}\n`)
166
+ log(`DIVERGE ${o.addr} occ=${occDir} rg=${rgDir} liv=${livDir}\n`)
154
167
  }
155
168
  } catch { /* an observer error must NEVER escape — it is a passive sidecar */ }
156
169
  }
157
170
  i++
171
+ const gs = tracker.summary()
172
+ const totalSamples = VERDICTS.reduce((n, v) => n + gs.verdicts[v].samples, 0)
158
173
  writeFileSync(statsFile, JSON.stringify({
159
174
  startedISO, lastISO: new Date().toISOString(), elapsedMin: +((Date.now() - t0) / 60000).toFixed(1),
160
- peers: models.size, samples: tot.samples,
161
- occupancy: { divergences: tot.occDiv, nonTrivialSamples: tot.occNt },
162
- readyGate: { divergences: tot.rgDiv },
163
- liveness: { divergences: tot.livDiv },
164
- fidelity: tot.samples ? +(1 - (tot.occDiv + tot.rgDiv + tot.livDiv) / (tot.samples * 3)).toFixed(6) : 1,
175
+ peers: models.size, samples: totalSamples,
176
+ // Gate (boris's criterion): clean iff zero hard divergences across all verdicts. Hard =
177
+ // every pty-behind + non-benign pty-ahead (steady-state or non-converging). Benign =
178
+ // boot-window ahead that self-heals within budget. Coverage proves the matrix was traversed.
179
+ gateClean: VERDICTS.every(v => gs.verdicts[v].gateClean),
180
+ hardTotal: gs.hardViolations.length,
181
+ verdicts: gs.verdicts,
182
+ coverage: gs.coverage,
183
+ recentHard: gs.hardViolations.slice(-20),
165
184
  }, null, 2))
166
185
  if (opts.once) break
167
186
  await new Promise(r => setTimeout(r, interval))