simframe 0.12.2 → 0.13.0

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/src/index.js CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  isBlackFrame,
14
14
  maxCellDelta,
15
15
  CELL_CHANGE,
16
+ describeMotion,
16
17
  regionDeltas,
17
18
  regionMap,
18
19
  signatureDiff,
@@ -125,6 +126,33 @@ export const READY_TIMEOUT_MS = 20_000;
125
126
  */
126
127
  export const LIVE_DAEMON_CAP_MS = 60_000;
127
128
 
129
+ /**
130
+ * How many gestures may land on a motionless screen before that is evidence.
131
+ *
132
+ * **This replaces a duration threshold, and the replacement is the point.** The
133
+ * first version asked "has the screen been still for a long time?" and needed
134
+ * 20 s before it would speak. A field report then caught a frame roughly three
135
+ * hours stale, presented as 130 ms old, on a screen still for **8.2 seconds** —
136
+ * so the check sat silently under its own gate during exactly the failure it
137
+ * was written for. A threshold chosen from one earlier example is not a
138
+ * mechanism.
139
+ *
140
+ * What says "dead surface" is not how long the screen has been quiet. It is
141
+ * that we kept touching it and *nothing moved at all*. One tap that changes
142
+ * nothing is ordinary — a disabled control, a form refusing a submit. Three in
143
+ * a row, with not one pixel of response, is a different claim.
144
+ */
145
+ export const SURFACE_SUSPECT_INPUTS = 3;
146
+
147
+ /**
148
+ * Slack between "input landed" and "the screen should have moved".
149
+ *
150
+ * A gesture only counts against the surface if it landed *inside* the quiet
151
+ * period with room to spare — otherwise the tap that ends the quiet period
152
+ * counts as evidence against it.
153
+ */
154
+ export const SURFACE_MARGIN_MS = 400;
155
+
128
156
  export function daemonStatus(udid) {
129
157
  const meta = store.readJson(store.paths(udid).meta);
130
158
  const pid = meta?.pid ?? null;
@@ -149,10 +177,13 @@ export async function ensureDaemon(deviceQuery, options = {}) {
149
177
  // A dead daemon leaves its last state.json behind. Anything captured before
150
178
  // we (re)started the loop is not evidence of a live screen, so ignore it.
151
179
  const minCapturedAt = existing.alive ? 0 : Date.now();
180
+ // The pid we spawned, which is the only honest answer to "did a daemon come
181
+ // up" — see the comment on the wait below.
182
+ let spawnedPid = null;
152
183
  if (!existing.alive) {
153
184
  if (acquireSpawnLock(p.lock)) {
154
185
  try {
155
- await startEngine(device.udid, options);
186
+ spawnedPid = (await startEngine(device.udid, options)).pid;
156
187
  } finally {
157
188
  // Hold the lock briefly so a burst of callers does not double-spawn.
158
189
  setTimeout(() => releaseSpawnLock(p.lock), 1500).unref?.();
@@ -178,25 +209,64 @@ export async function ensureDaemon(deviceQuery, options = {}) {
178
209
  // had read identically, which cost a log dive to tell apart.
179
210
  const liveDeadline = Date.now() + LIVE_DAEMON_CAP_MS;
180
211
  let sawLiveDaemon = false;
212
+ let sawProcess = false;
181
213
  for (;;) {
182
214
  const state = store.readJson(p.state);
183
215
  if (state && state.capturedAt >= minCapturedAt && Date.now() - state.capturedAt < 30_000) {
184
216
  return { device, state, started: !existing.alive };
185
217
  }
186
- const alive = daemonStatus(device.udid).alive;
187
- sawLiveDaemon = sawLiveDaemon || alive;
188
- if (Date.now() >= (alive ? liveDeadline : deadline)) break;
218
+ // Two different questions, and for a long time one file answered both.
219
+ //
220
+ // `daemonStatus` reads meta.json, which the daemon writes in `claim()` —
221
+ // *after* `platform.attach`, its slowest startup step. So a daemon that has
222
+ // spawned and is attaching to CoreSimulator is indistinguishable, from
223
+ // here, from one that never started: both are `alive: false`. That is the
224
+ // condition the live cap below exists for, and it could never be reached in
225
+ // the case that produced it, because reaching it required the very file
226
+ // whose absence was the problem.
227
+ //
228
+ // Measured on a hosted runner, 2026-09-13: `simframe start` gave up after
229
+ // 20 s saying **"no daemon process came up"**, and the next step of the
230
+ // same job printed `● iPhone 16 Pro pid=9573 frame=#5 age=652ms` with a
231
+ // daemon log showing it had been capturing the whole time. The sentence was
232
+ // not merely unhelpful, it named the wrong condition — the fourth time this
233
+ // project has failed that way and the second time in two days.
234
+ //
235
+ // The pid we spawned is the honest signal and it was already in hand;
236
+ // `startEngine` was discarding it.
237
+ const claimed = daemonStatus(device.udid).alive;
238
+ const running = claimed || (spawnedPid != null && store.isProcessAlive(spawnedPid));
239
+ sawLiveDaemon = sawLiveDaemon || claimed;
240
+ sawProcess = sawProcess || running;
241
+ if (Date.now() >= (running ? liveDeadline : deadline)) break;
189
242
  await sleep(80);
190
243
  }
191
244
  const tail = readLogTail(p.log);
192
- const waited = sawLiveDaemon
193
- ? `the daemon is running and the display produced no frame in ${Math.round(LIVE_DAEMON_CAP_MS / 1000)}s`
194
- : 'no daemon process came up';
195
245
  throw new Error(
196
- `simframe daemon did not produce a frame for ${device.name} — ${waited}${tail ? `\n${tail}` : ''}`,
246
+ `simframe daemon did not produce a frame for ${device.name} — `
247
+ + `${readinessFailure({ sawLiveDaemon, sawProcess })}${tail ? `\n${tail}` : ''}`,
197
248
  );
198
249
  }
199
250
 
251
+ /**
252
+ * Which of three failures this was.
253
+ *
254
+ * Extracted so it can be tested, for the reason `screenshotFailure` was: the
255
+ * whole defect here is a message naming the wrong condition, and a message is
256
+ * only checkable if something can ask for it without a device. Two of these
257
+ * three sentences did not exist until a runner produced each of them in turn and
258
+ * both arrived reading as the third.
259
+ */
260
+ export function readinessFailure({ sawLiveDaemon, sawProcess }) {
261
+ const cap = Math.round(LIVE_DAEMON_CAP_MS / 1000);
262
+ if (sawLiveDaemon) return `the daemon is running and the display produced no frame in ${cap}s`;
263
+ if (sawProcess) {
264
+ return `the daemon process started but never claimed the device in ${cap}s`
265
+ + ' — it is stuck attaching to the simulator rather than failing to launch';
266
+ }
267
+ return 'no daemon process came up';
268
+ }
269
+
200
270
  /** Why the daemon was not used, when it was not. Surfaced by doctor. */
201
271
  export let engineFallbackReason = null;
202
272
 
@@ -252,14 +322,16 @@ async function startEngine(udid, options) {
252
322
  if (built.ok) {
253
323
  engineFallbackReason = null;
254
324
  recordFallback(udid, null);
255
- engine.spawnDaemon(udid, options);
256
- return 'simframed';
325
+ // The pid, not just the name. Whether a daemon came up is a question
326
+ // about a process, and answering it from meta.json — which the daemon
327
+ // writes only after attaching — could not tell "never started" from
328
+ // "still starting". See the wait in `ensureDaemon`.
329
+ return { engine: 'simframed', pid: engine.spawnDaemon(udid, options)?.pid ?? null };
257
330
  }
258
331
  engineFallbackReason = built.reason ?? 'simframed unavailable';
259
332
  recordFallback(udid, engineFallbackReason);
260
333
  }
261
- spawnNodeDaemon(udid, options);
262
- return 'screenshot';
334
+ return { engine: 'screenshot', pid: spawnNodeDaemon(udid, options)?.pid ?? null };
263
335
  }
264
336
 
265
337
  function spawnNodeDaemon(udid, options) {
@@ -283,6 +355,7 @@ function spawnNodeDaemon(udid, options) {
283
355
  env: process.env,
284
356
  });
285
357
  child.unref();
358
+ return child;
286
359
  }
287
360
 
288
361
  function acquireSpawnLock(lockFile) {
@@ -423,6 +496,49 @@ export function liveness(udid, state) {
423
496
  if (!damageDriven && ageMs > STALE_FRAME_MS) {
424
497
  return { ok: false, ageMs, stalled: false, note: `capture loop is stalled: newest frame is ${ageMs}ms old` };
425
498
  }
499
+
500
+ // A live loop re-reading a dead surface, which every other signal calls fine.
501
+ //
502
+ // Reported from the field on 0.12.2, and it is the most expensive shape this
503
+ // tool has produced: `sim_look` served the *login screen* for three minutes
504
+ // while the app was four screens deep in a wizard, and announced the frame as
505
+ // 66ms old. The daemon was not lying — the frame really was new. It was a new
506
+ // read of a surface that had stopped updating. Every check above passes: the
507
+ // process is alive, no read failed, the age is tiny.
508
+ //
509
+ // The contradiction is between two numbers we already have. `stableForMs`
510
+ // says the screen has not changed in N ms; `lastInputAt` says we were
511
+ // delivering taps and swipes during that window. A screen that has not moved
512
+ // since *before* we last touched it, for long enough that several gestures
513
+ // landed inside the quiet period, is not a calm screen. The tester's own
514
+ // figures: stable for 159,753 ms across three screen transitions.
515
+ //
516
+ // Deliberately a WARNING and not a failure. A genuinely inert screen is
517
+ // possible — a form that rejects every tap, a disabled control tapped twice —
518
+ // and turning that into a hard error would be a confident wrong answer of
519
+ // exactly the kind this project keeps paying for. It says what it sees and
520
+ // names the arbiter.
521
+ const stableForMs = state?.stableForMs;
522
+ if (Number.isFinite(stableForMs)) {
523
+ // The moment the screen last moved. Every gesture delivered after it landed
524
+ // on a screen that did not react.
525
+ const lastChange = Date.now() - stableForMs + SURFACE_MARGIN_MS;
526
+ const ignored = store.inputTimes(udid).filter((t) => t > lastChange);
527
+ if (ignored.length >= SURFACE_SUSPECT_INPUTS) {
528
+ const quiet = Math.round(stableForMs / 1000);
529
+ return {
530
+ ok: true,
531
+ ageMs,
532
+ stalled: false,
533
+ suspectSurface: true,
534
+ ignoredInputs: ignored.length,
535
+ note: `${ignored.length} gestures have been delivered without the screen moving at all `
536
+ + `(still for ${quiet < 1 ? `${Math.round(stableForMs)}ms` : `${quiet}s`}), `
537
+ + 'so the frames are new and the surface behind them may be dead. '
538
+ + 'The accessibility tree is read separately and is the tiebreaker; `simframe revive` re-attaches capture.',
539
+ };
540
+ }
541
+ }
426
542
  return { ok: true, ageMs, stalled: false, note: null };
427
543
  }
428
544
 
@@ -748,6 +864,37 @@ function timingOfNow(udid, state) {
748
864
  * sampled after the fact is the single most common way to wait for a change
749
865
  * that has already happened.
750
866
  */
867
+ /**
868
+ * How far back "still moving" looks, in milliseconds.
869
+ *
870
+ * **In time, not in frames**, and that distinction is the whole of it. The
871
+ * first version kept the last eight frame pairs, on the reasoning that at a
872
+ * 60 ms poll floor eight frames is about half a second. Frames are not a clock:
873
+ * capture is damage-driven with a slow idle floor, so on a quiet screen eight
874
+ * frames spanned *sixteen seconds* — and the window still held the transition
875
+ * that had brought us to the screen. A home screen with one animated widget
876
+ * duly reported movement in all thirty-two regions.
877
+ *
878
+ * One second, long enough that a stepping animation is not missed between two
879
+ * frames and short enough that the entry transition is gone by the time a
880
+ * settle gives up.
881
+ */
882
+ export const MOTION_WINDOW_MS = 1000;
883
+
884
+ /**
885
+ * How young a frame has to be to count as "the screen right now".
886
+ *
887
+ * The daemon's idle floor is 2,000 ms — on a screen with no damage it captures
888
+ * anyway, at that cadence, precisely so state stays fresh enough to reason
889
+ * about. So a frame younger than that floor plus a margin is the current
890
+ * screen; older than it means the loop has missed its own beat, which is what
891
+ * `liveness` is for.
892
+ *
893
+ * Tied to the daemon's floor rather than chosen, because a number chosen here
894
+ * would drift away from it silently the first time the floor moved.
895
+ */
896
+ export const FRAME_IS_CURRENT_MS = 2500;
897
+
751
898
  export async function waitFor(
752
899
  deviceQuery,
753
900
  { mode = 'settle', since, stableMs = 600, timeoutMs = 8000, reactionMs = 2500, baselineHash, options } = {},
@@ -789,6 +936,29 @@ export async function waitFor(
789
936
  ? hexToSignature(resolved.entry.sig)
790
937
  : (requested == null ? hexToSignature(currentSig(first) ?? '') : null);
791
938
  let smallChange = false;
939
+ /**
940
+ * Where the screen moved while we waited — item 123.
941
+ *
942
+ * The per-region maximum over a trailing window of frame pairs, so a settle
943
+ * that gives up can say *where* rather than only that it did.
944
+ *
945
+ * A window and not a running max over the whole wait, which is what the first
946
+ * version did and it was useless: every wait begins with the transition that
947
+ * brought us here, so a home screen with one animated widget reported
948
+ * movement in all thirty-two regions and "spread across the screen". The
949
+ * question a settle failure asks is *what is still moving*, not what has
950
+ * moved at any point since we started looking.
951
+ *
952
+ * A window rather than the last pair alone, because a blinking or stepping
953
+ * animation is between frames as often as not, and a single pair would
954
+ * report a still screen that is not one.
955
+ *
956
+ * Costs one subtraction per region per frame over a signature that was
957
+ * already read and parsed for `smallChange`.
958
+ */
959
+ const motionRing = [];
960
+ let motionPrev = null;
961
+ let motionSeq = null;
792
962
  /** Frames the display was not rendering at all. See `isBlackFrame`. */
793
963
  let blackFrames = 0;
794
964
  let blackSinceStart = null;
@@ -834,6 +1004,46 @@ export async function waitFor(
834
1004
  sawChange = false;
835
1005
  }
836
1006
 
1007
+ /**
1008
+ * What the daemon says is animating, if anything — and it has always known.
1009
+ *
1010
+ * `Motion.state` in the daemon localises a small persistent animation and
1011
+ * publishes it as `motion.animating`, a bounding box, alongside its own
1012
+ * `settled` flag. Nothing in JavaScript read either. `waitFor` decided
1013
+ * stillness from `stableForMs` alone, which is derived from a *mean* over the
1014
+ * grid, and a spinner does not move a mean.
1015
+ *
1016
+ * Measured on the testbed's Diagnostics screen, which now carries one on
1017
+ * purpose: frames arriving every 85 ms, `motion.animating` boxed at 13x13,
1018
+ * `motion.settled` **false** — and `stableForMs` **79,207**. Seventy-nine
1019
+ * seconds of claimed stillness on a screen that had never once stopped. A
1020
+ * field report had already called this in as `settled after 62ms` on a
1021
+ * still-loading screen.
1022
+ *
1023
+ * Deliberately reported and **not** acted on. Requiring the daemon's `settled`
1024
+ * here would be correct for a spinner and wrong for a text caret, which is
1025
+ * also small, also persistent, and must never stop a screen from settling —
1026
+ * and choosing between them needs a caret measured, not a threshold picked
1027
+ * today. So the disagreement becomes visible instead of being resolved by
1028
+ * guess: the caller is told a part of the screen is still moving and where,
1029
+ * and can decide. The decision is an open item with a number attached to it.
1030
+ */
1031
+ const animatingNow = () => {
1032
+ const box = last?.motion?.animating;
1033
+ if (!box) return null;
1034
+ return { ...box, daemonSettled: last.motion.settled === true };
1035
+ };
1036
+
1037
+ /** The last second of movement, collapsed to one per-region maximum. */
1038
+ function motionSummary() {
1039
+ const cutoff = Date.now() - MOTION_WINDOW_MS;
1040
+ const recent = motionRing.filter((e) => e.at >= cutoff);
1041
+ if (!recent.length) return null;
1042
+ const max = recent[0].deltas.map((_, i) => Math.max(...recent.map((e) => e.deltas[i] ?? 0)));
1043
+ if (!max.some((d) => d > 0)) return null;
1044
+ return { deltas: max, map: regionMap(max), windowMs: MOTION_WINDOW_MS, ...describeMotion(max) };
1045
+ }
1046
+
837
1047
  const done = (satisfied, extra = {}) => ({
838
1048
  device,
839
1049
  state: last,
@@ -851,6 +1061,24 @@ export async function waitFor(
851
1061
  // because the app drew black. What makes it the capture wedge is that it
852
1062
  // stays black while input is being delivered, and the caller knows that.
853
1063
  blackMs: blackSinceStart ? Date.now() - blackSinceStart : 0,
1064
+ // Only worth carrying when something actually moved; a still screen that
1065
+ // never satisfied the wait has nothing to point at.
1066
+ motion: motionSummary(),
1067
+ animating: animatingNow(),
1068
+ /**
1069
+ * Everything that decided this answer, for the failure message.
1070
+ *
1071
+ * Two CI cycles were spent guessing at a `screen did not settle within
1072
+ * 25008ms` — a sentence that names the one number which is never the
1073
+ * reason. The wait turns on three things and the log carried none of them:
1074
+ * how long the screen had actually been still, how much stillness was
1075
+ * being asked for, and whether any frame arrived at all. "Still for 1,200
1076
+ * of the 1,400 ms required" and "still for 135,000 ms and nothing arrived"
1077
+ * are opposite diagnoses and they had one sentence between them.
1078
+ */
1079
+ stillForMs: Number.isFinite(last?.stableForMs) ? last.stableForMs : null,
1080
+ stableMsRequired: stableMs,
1081
+ framesSeen: Number.isFinite(last?.seq) ? last.seq - startSeq : null,
854
1082
  baselineHash: baselineHashValue,
855
1083
  baselineResolved,
856
1084
  waitedMs: Date.now() - startedAt,
@@ -904,6 +1132,23 @@ export async function waitFor(
904
1132
  }
905
1133
  }
906
1134
 
1135
+ // Track where it is moving, on new frames only — comparing a frame with
1136
+ // itself is a row of zeros that would dilute nothing but waste the work.
1137
+ if (state.seq !== motionSeq) {
1138
+ const sigHex = currentSig(state);
1139
+ const sig = sigHex ? hexToSignature(sigHex) : null;
1140
+ if (sig) {
1141
+ if (motionPrev) {
1142
+ motionRing.push({ at: Date.now(), deltas: regionDeltas(sig, motionPrev) });
1143
+ // Bounded in time by the summary and in length here, so a screen
1144
+ // rendering at 60 fps for ten seconds cannot grow this without end.
1145
+ while (motionRing.length > 240) motionRing.shift();
1146
+ }
1147
+ motionPrev = sig;
1148
+ }
1149
+ motionSeq = state.seq;
1150
+ }
1151
+
907
1152
  // A pause that turned out not to be the end of the transition. Only
908
1153
  // pauses followed by more movement count: the quiet at the end of a
909
1154
  // settle is the answer, not a gap.
@@ -939,10 +1184,30 @@ export async function waitFor(
939
1184
  // the caller acted can never satisfy it; once the change is seen,
940
1185
  // stableForMs is measured from that change. Plain "stable" has no such
941
1186
  // requirement — an already-still screen genuinely is stable.
942
- // At least one frame must arrive during the call, so the answer is
943
- // never derived purely from what was already on disk.
1187
+ //
1188
+ // The evidence must be *current*, and that used to be spelled "at least
1189
+ // one frame must arrive during the call". The intent is right and the
1190
+ // spelling made the wait unsatisfiable on exactly the screens it is
1191
+ // asked about most. Capture is damage-driven: a screen that is not
1192
+ // moving produces no new frame by design, so on a still screen the
1193
+ // condition asks for evidence the system has deliberately chosen not to
1194
+ // generate, and the wait burns its whole budget and reports *"screen did
1195
+ // not settle"* about a screen that has been motionless throughout.
1196
+ //
1197
+ // Measured here rather than argued: a flow step reported `did not settle
1198
+ // within 1547ms` while its own evidence line read **still for 3548ms of
1199
+ // the 1400ms required; NO frame arrived while waiting**. Three and a half
1200
+ // seconds of stillness, two and a half times what was asked, refused.
1201
+ //
1202
+ // So currency is a question about time, not about a counter — the same
1203
+ // correction the motion window needed. A frame younger than the capture
1204
+ // loop's own idle floor *is* the screen as it is now. `liveness` is
1205
+ // checked at the top of every iteration and already rejects a stale file
1206
+ // from a dead daemon, which is the case the counter was really guarding.
944
1207
  const freshFrames = state.seq - startSeq;
945
- if (sawChange && freshFrames >= 1 && state.stableForMs >= stableMs) {
1208
+ const age = Date.now() - (state.capturedAt ?? 0);
1209
+ const current = freshFrames >= 1 || age <= FRAME_IS_CURRENT_MS;
1210
+ if (sawChange && current && state.stableForMs >= stableMs) {
946
1211
  return done(true);
947
1212
  }
948
1213
  }
@@ -1470,7 +1735,28 @@ async function locateWith(
1470
1735
  const target = index != null ? candidates[index] : candidates[0];
1471
1736
  if (!target) {
1472
1737
  const visible = entry.targets.filter((t) => t.label);
1473
- const sample = visible.slice(0, 12).map((t) => t.label).join(', ');
1738
+ const shown = visible.slice(0, 12).map((t) => t.label);
1739
+ // Say when the list is cut. A field report found `"Work Orders" is not on
1740
+ // this screen. Visible: …` on a screen whose own element map, three lines
1741
+ // below in the same reply, listed `#25 text 200,836 Work Orders` — it was
1742
+ // simply past the twelfth entry. A truncated list that does not announce
1743
+ // its truncation reads as an exhaustive one.
1744
+ const sample = shown.join(', ') + (visible.length > shown.length ? `, …and ${visible.length - shown.length} more` : '');
1745
+ // An index past the end is not an absent element, and reporting it as one
1746
+ // sent a tester looking for a control that was on the screen all along.
1747
+ // `{"tap": "Work Orders", "index": 1}` on a screen with exactly one match
1748
+ // said "not on this screen" while the map listed it.
1749
+ if (index != null && candidates.length) {
1750
+ throw metrics.tag(
1751
+ new Error(
1752
+ `"${query}" matches ${candidates.length} thing${candidates.length === 1 ? '' : 's'} on this screen,`
1753
+ + ` so index ${index} is out of range — valid indices are 0..${candidates.length - 1}.`
1754
+ + ` Nearest: ${candidates.slice(0, 4).map((c) => `[${candidates.indexOf(c)}] ${JSON.stringify(String(c.label ?? '').slice(0, 28))}`).join(', ')}`,
1755
+ ),
1756
+ 'ambiguous_intent',
1757
+ { candidates: candidates.slice(0, 8), intent: query, ambiguous: true },
1758
+ );
1759
+ }
1474
1760
  throw metrics.tag(
1475
1761
  new Error(`"${query}" is not on this screen. Visible: ${sample || '(nothing readable)'}`),
1476
1762
  from === 'memory' ? 'ambiguous_intent' : 'unknown_screen',
package/src/input.js CHANGED
@@ -623,6 +623,10 @@ export function shouldRebuildSession({ stale, bootedAt }, rebuiltFor) {
623
623
  const rebuiltForBoot = new Map();
624
624
  export async function ensureFreshSession(udid) {
625
625
  if (!udid) return null;
626
+ // Every input path calls this first, so it is the one place that knows the
627
+ // hands are about to move. `liveness` needs that timestamp to tell a calm
628
+ // screen from a dead surface — see `store.noteInput`.
629
+ store.noteInput(udid);
626
630
  const health = await sessionHealth(udid);
627
631
  if (!shouldRebuildSession(health, rebuiltForBoot.get(udid))) return null;
628
632
  rebuiltForBoot.set(udid, health.bootedAt);
package/src/mcp.js CHANGED
@@ -459,7 +459,13 @@ function header(device, state, ageMs, extra = '') {
459
459
  }
460
460
 
461
461
  function livenessLine(live) {
462
- return live?.ok ? null : `WARNING: ${live.note}`;
462
+ // `ok` is not the only thing worth saying. A surface that has stopped
463
+ // updating leaves every hard signal green — the loop is alive, no read
464
+ // failed, the frame is milliseconds old — and reporting nothing is how
465
+ // `sim_look` served a three-minute-old login screen while announcing it as
466
+ // 66ms old. A note that exists must reach the caller.
467
+ if (live?.note) return `WARNING: ${live.note}`;
468
+ return null;
463
469
  }
464
470
 
465
471
  function sinceLine(since) {
package/src/metrics.js CHANGED
@@ -300,11 +300,30 @@ const VERIFYING_STEPS = new Set([
300
300
  */
301
301
  export function reasonForStepError(step, err) {
302
302
  const tagged = escalationOf(err);
303
- if (tagged) return tagged;
303
+ if (tagged) return { ...tagged, classified: true };
304
304
  const action = step?.action;
305
- if (action === 'confirm' || action === 'chooseAny') return { reason: 'novel_dialog', candidates: [], tried: [] };
306
- if (VERIFYING_STEPS.has(action)) return { reason: 'verification_failed', candidates: [], tried: [] };
307
- return { reason: 'verification_failed', candidates: [], tried: [] };
305
+ if (action === 'confirm' || action === 'chooseAny') {
306
+ return { reason: 'novel_dialog', candidates: [], tried: [], classified: true };
307
+ }
308
+ // Everything else is a *fallback*, and it now says so.
309
+ //
310
+ // This had two branches that returned the same value, which made it look
311
+ // like it discriminated. It does not: any step that threw without a site
312
+ // tagging it lands here. In a real field session that was **90% of all
313
+ // escalations** — and `FACULTY` then reported every one of them as evidence
314
+ // against "sense of time (Phase 11)", a claim nothing in the record supports.
315
+ //
316
+ // The tester's own first call failed with `unknown step "wait_for"` — a typo
317
+ // — and that too would be filed as evidence about which faculty to build
318
+ // next. CLAUDE.md calls this log the steering wheel; a steering wheel that
319
+ // pools typos, inert controls and slow lists into one reason is pointing
320
+ // somewhere nobody chose.
321
+ //
322
+ // No sixth reason: "unknown is not a reason" stays, and a vocabulary that
323
+ // admits "other" collects a pile of "other". What changes is that the record
324
+ // carries whether the reason was *read off the failure* or *assumed*, and
325
+ // the report declines to recommend a faculty for the assumed ones.
326
+ return { reason: 'verification_failed', candidates: [], tried: [], classified: false };
308
327
  }
309
328
 
310
329
  /**
@@ -440,6 +459,10 @@ export function recordEscalation(udid, {
440
459
  modelTurns = 1,
441
460
  wallMs = null,
442
461
  detail = null,
462
+ // Was this reason read off the failure, or assumed because nothing said?
463
+ // Default `false`, so a caller that does not think about it cannot
464
+ // accidentally claim precision it does not have.
465
+ classified = false,
443
466
  } = {}) {
444
467
  if (!REASONS.includes(reason)) throw new Error(`not an escalation reason: ${reason}`);
445
468
  if (!OUTCOMES.includes(outcome)) throw new Error(`not an escalation outcome: ${outcome}`);
@@ -455,6 +478,7 @@ export function recordEscalation(udid, {
455
478
  // What was asked for, in the caller's words. Ground truth for Phase 17's
456
479
  // go/no-go, and on its own it answers "what kind of decision is costing us".
457
480
  intent: intent ? String(intent).slice(0, 120) : null,
481
+ classified: Boolean(classified),
458
482
  step_index: stepIndex,
459
483
  screen_fingerprint: fingerprint,
460
484
  reason,
@@ -635,7 +659,9 @@ export function hpi({ flows, baselines = {} }) {
635
659
  */
636
660
  export function breakdown(records, { session = null, flow = null } = {}) {
637
661
  const byReason = {};
638
- for (const r of REASONS) byReason[r] = 0;
662
+ const classifiedByReason = {};
663
+ const assumedByReason = {};
664
+ for (const r of REASONS) { byReason[r] = 0; classifiedByReason[r] = 0; assumedByReason[r] = 0; }
639
665
  const byScreen = new Map();
640
666
  const byOutcome = {};
641
667
  const bySession = new Map();
@@ -659,6 +685,12 @@ export function breakdown(records, { session = null, flow = null } = {}) {
659
685
  }
660
686
  if (r.flow_name) byFlow.set(r.flow_name, (byFlow.get(r.flow_name) ?? 0) + 1);
661
687
  byReason[r.reason] += 1;
688
+ // Three states, not two. A record written before this field existed makes
689
+ // no claim either way, and folding it in with "assumed" would make an old
690
+ // log look like a diagnosis failure — a warning that cries wolf is how a
691
+ // real one gets ignored, which this file already knows in another place.
692
+ if (r.classified === true) classifiedByReason[r.reason] += 1;
693
+ else if (r.classified === false) assumedByReason[r.reason] += 1;
662
694
  byOutcome[r.outcome] = (byOutcome[r.outcome] ?? 0) + 1;
663
695
  // Already avoided locally, so not avoidable by anything unbuilt.
664
696
  if (r.outcome !== 'resolved_locally') avoidable += 1;
@@ -685,9 +717,20 @@ export function breakdown(records, { session = null, flow = null } = {}) {
685
717
  pooled: sessions.length > 1 || unattributed > 0,
686
718
  by_flow: Object.fromEntries([...byFlow.entries()].sort((a, b) => b[1] - a[1])),
687
719
  by_reason: byReason,
720
+ // How many of each reason were *read off the failure* rather than assumed.
721
+ //
722
+ // The breakdown above picks the next phase, so its precision has to be
723
+ // visible in it. A reason that is mostly assumed is not a finding about an
724
+ // app; it is a count of things nothing could classify, and reading it as a
725
+ // verdict on a faculty is how the instrument came to disagree with a
726
+ // tester who was right.
727
+ classified_by_reason: classifiedByReason,
728
+ assumed_by_reason: assumedByReason,
688
729
  by_outcome: byOutcome,
730
+ // Only where the reason was actually read. A faculty named against a pile
731
+ // of assumptions is advice with nothing behind it.
689
732
  faculty: Object.fromEntries(
690
- REASONS.filter((r) => byReason[r]).map((r) => [r, `${FACULTY[r]}${BUILT_FACULTIES.has(FACULTY[r]) ? ' [built]' : ''}`]),
733
+ REASONS.filter((r) => classifiedByReason[r]).map((r) => [r, `${FACULTY[r]}${BUILT_FACULTIES.has(FACULTY[r]) ? ' [built]' : ''}`]),
691
734
  ),
692
735
  avoidable,
693
736
  avoidable_escalation_rate: total ? Number((avoidable / total).toFixed(3)) : null,
package/src/ollama.js CHANGED
@@ -49,13 +49,23 @@ export const DEFAULT_HOST = 'http://127.0.0.1:11434';
49
49
  * would produce exactly the invisible unfairness this function exists to
50
50
  * prevent.
51
51
  */
52
- export function readBrief(file = SWIFT) {
52
+ export function readBrief(file = SWIFT, { mayAbstain = false } = {}) {
53
53
  const src = fs.readFileSync(file, 'utf8');
54
- const open = src.indexOf('let instructions = """');
55
- if (open === -1) throw new Error(`no instructions block in ${file}`);
54
+ const base = block(src, 'let instructions = """', file);
55
+ if (!mayAbstain) return base;
56
+ // The addendum, from the same file and joined the way the Swift joins it.
57
+ // The fourth word has to reach both arms identically or the comparison is
58
+ // measuring two different briefs, which is the failure this whole function
59
+ // exists to prevent.
60
+ return `${base}\n\n${block(src, 'let abstainInstructions = instructions + "\\n\\n" + """', file)}`;
61
+ }
62
+
63
+ function block(src, opener, file) {
64
+ const open = src.indexOf(opener);
65
+ if (open === -1) throw new Error(`no ${JSON.stringify(opener)} block in ${file}`);
56
66
  const bodyStart = src.indexOf('\n', open) + 1;
57
67
  const close = src.indexOf('"""', bodyStart);
58
- if (close === -1) throw new Error(`unterminated instructions block in ${file}`);
68
+ if (close === -1) throw new Error(`unterminated block in ${file}`);
59
69
  return src
60
70
  .slice(bodyStart, close)
61
71
  .split('\n')
@@ -106,11 +116,28 @@ export function promptFor(s = {}) {
106
116
  * against would be a second difference between the arms, and the rationale is
107
117
  * the one thing a supervisor is explicitly not trusted for.
108
118
  */
109
- export const SCHEMA = {
119
+ export const DECISION_WORDS = ['wait', 'retry', 'stop'];
120
+
121
+ /**
122
+ * The fourth word, available on request.
123
+ *
124
+ * Not a new capability and it cannot become one: `abstain` means "behave as if
125
+ * there is no supervisor", which is the `null` every caller already handles on
126
+ * every failure path. It strictly *shrinks* what the model can cause to happen,
127
+ * which is why a component whose answer space is its safety property can afford
128
+ * to grow one.
129
+ */
130
+ export const ABSTAIN = 'abstain';
131
+
132
+ export const schemaFor = ({ mayAbstain = false } = {}) => ({
110
133
  type: 'object',
111
- properties: { decision: { type: 'string', enum: ['wait', 'retry', 'stop'] } },
134
+ properties: {
135
+ decision: { type: 'string', enum: mayAbstain ? [...DECISION_WORDS, ABSTAIN] : DECISION_WORDS },
136
+ },
112
137
  required: ['decision'],
113
- };
138
+ });
139
+
140
+ export const SCHEMA = schemaFor();
114
141
 
115
142
  /** Parse `ollama`, `ollama:qwen3:14b`, `ollama:qwen3:8b@http://host:port`. */
116
143
  export function parseTarget(raw) {
@@ -147,15 +174,16 @@ async function post(host, route, body, timeoutMs) {
147
174
  */
148
175
  export async function ask(target, situation, timeoutMs = 2500) {
149
176
  const { model, host } = target;
177
+ const mayAbstain = Boolean(situation?.mayAbstain);
150
178
  const started = Date.now();
151
179
  const out = await post(host, '/api/chat', {
152
180
  model,
153
181
  messages: [
154
- { role: 'system', content: readBrief() },
182
+ { role: 'system', content: readBrief(SWIFT, { mayAbstain }) },
155
183
  { role: 'user', content: promptFor(situation) },
156
184
  ],
157
185
  stream: false,
158
- format: SCHEMA,
186
+ format: schemaFor({ mayAbstain }),
159
187
  // Qwen3 reasons out loud by default. Turned off for two reasons and both
160
188
  // are about fairness rather than speed: the Apple arm does not deliberate
161
189
  // either, and a supervisor that takes twenty seconds to answer has already