skalpel 4.0.53 → 4.0.55

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/analytics.mjs CHANGED
@@ -40,6 +40,18 @@ import { emitKeypressEvents } from "node:readline";
40
40
  // nothing (their CLI blocks are isMain-guarded).
41
41
  import { readLedgerRows, buildLedger, formatDuration } from "./ledger.mjs";
42
42
  import { VERIFY_LOG_PATH, extractFailCount } from "./verify-shadow.mjs";
43
+ // Terminal-feel helpers (TTY-only; see termfx.mjs restraint contract). None of this runs on the
44
+ // non-TTY path — pipes/CI still get renderStatic byte-identical to before.
45
+ import {
46
+ animEnabled,
47
+ sleep,
48
+ countUpFrames,
49
+ trustModel,
50
+ trustBar,
51
+ STAGGER_MS,
52
+ COUNT_MS,
53
+ COUNT_FRAMES,
54
+ } from "./termfx.mjs";
43
55
 
44
56
  // ---------- palette (the same vocabulary as ledger.mjs / card.mjs / the statusline) ----------
45
57
  const R = "\x1b[38;2;233;38;31m"; // skalpel red
@@ -219,12 +231,14 @@ export function collectData({
219
231
  }
220
232
 
221
233
  // ---------- (2) panel text (shared by the static snapshot and the interactive view) ----------
222
- function costLine(data) {
234
+ // msShown lets the TTY count-up render intermediate values of the SAME real measurement; every
235
+ // other caller (renderStatic included) uses the default — the exact measured total, unchanged.
236
+ function costLine(data, msShown = data.costMs) {
223
237
  if (data.ledger == null) return "no data yet";
224
238
  if (data.catches.length === 0) return "0 — nothing to measure";
225
239
  if (data.costPairs === 0)
226
240
  return `no measured red→green pair yet — ${data.catches.length} caught, none re-passed its own proof later (count only, nothing estimated)`;
227
- const dur = formatDuration(data.costMs) || "<1s";
241
+ const dur = formatDuration(msShown) || "<1s";
228
242
  const excl =
229
243
  data.unmeasured > 0
230
244
  ? ` · ${data.unmeasured} caught without a later PASS — excluded, never estimated`
@@ -366,8 +380,54 @@ function wrapText(text, width) {
366
380
  return out.length ? out : [""];
367
381
  }
368
382
 
369
- function listLines(data, cols, rows, state) {
383
+ // ---- interactive-only line builders (TTY view; the static snapshot above is untouched) ----
384
+ // The two headline-counter lines take a "shown" value so the opening count-up can render
385
+ // intermediate frames of the SAME real number. Every non-animated caller passes the real value.
386
+ function caughtLiesLine(data, falseShown) {
387
+ const headline =
388
+ data.ledger == null
389
+ ? `${DIM}no data yet${RESET}`
390
+ : `${R}${BOLD}${falseShown} verified FALSE${RESET} ${DIM}· ${data.ledger.checked} done-claims checked${RESET}`;
391
+ return ` ${CYAN}${BOLD}CAUGHT LIES${RESET} ${headline}`;
392
+ }
393
+
394
+ function measuredCostLine(data, msShown) {
395
+ return ` ${CYAN}${BOLD}MEASURED COST${RESET} ${DIM}${costLine(data, msShown)}${RESET}`;
396
+ }
397
+
398
+ // TRUST — honest gamification, interactive header only (the non-TTY snapshot stays byte-identical).
399
+ // Derived ONLY from real ledger counts via trustModel: held/checked ratio bar at ≥5 checked, counts
400
+ // only below 5, and an honest em-dash zero-state — never a fake full bar, no scores, no streaks.
401
+ export function trustLineFor(data) {
402
+ const m = trustModel(
403
+ data.ledger ? data.ledger.checked : 0,
404
+ data.ledger ? data.ledger.falseCount : 0,
405
+ );
406
+ const label = ` ${CYAN}${BOLD}TRUST${RESET} `;
407
+ if (m.kind === "none") return `${label}${DIM}— no claims checked yet${RESET}`;
408
+ const caught = m.caught > 0 ? ` ${R}· ${m.caught} caught${RESET}` : "";
409
+ const counts = `${CYAN}${m.held}${RESET} ${DIM}of${RESET} ${CYAN}${m.checked}${RESET} ${DIM}done-claim${m.checked === 1 ? "" : "s"} held${RESET}`;
410
+ if (m.kind === "counts") return `${label}${counts}${caught}`;
411
+ const bar = `${"▰".repeat(m.filled)}${DIM}${"▱".repeat(m.total - m.filled)}${RESET}`;
412
+ return `${label}${bar} ${counts}${caught}`;
413
+ }
414
+
415
+ // listLines(data, cols, rows, state, meta) — the interactive list view. `meta`, when given, is
416
+ // filled with what the ONE-TIME opening reveal needs: section boundaries (groupEnds) and the row
417
+ // indices of the two count-up lines. Passing meta changes NOTHING about the returned lines.
418
+ export function listLines(data, cols, rows, state, meta = null) {
370
419
  const L = [];
420
+ const noteAnim = (key, val) => {
421
+ if (meta) meta[key] = val;
422
+ };
423
+ const endGroup = () => {
424
+ if (meta) meta.groupEnds.push(L.length - 1);
425
+ };
426
+ if (meta) {
427
+ meta.groupEnds = [];
428
+ meta.animHead = -1;
429
+ meta.animCost = -1;
430
+ }
371
431
  L.push(` ${BOLD}🔬 skalpel analytics${RESET} ${DIM}· local · read-only · your own logs${RESET}`);
372
432
  L.push(` ${DIM}${"─".repeat(Math.max(4, Math.min(74, cols - 4)))}${RESET}`);
373
433
  if (data.empty) {
@@ -386,11 +446,10 @@ function listLines(data, cols, rows, state) {
386
446
  L.push(` ${DIM}q quit${RESET}`);
387
447
  return L;
388
448
  }
389
- const headline =
390
- data.ledger == null
391
- ? `${DIM}no data yet${RESET}`
392
- : `${R}${BOLD}${data.ledger.falseCount} verified FALSE${RESET} ${DIM}· ${data.ledger.checked} done-claims checked${RESET}`;
393
- L.push(` ${CYAN}${BOLD}CAUGHT LIES${RESET} ${headline}`);
449
+ L.push(trustLineFor(data));
450
+ endGroup(); // reveal group 1: title + rule + TRUST
451
+ if (data.ledger != null && data.ledger.falseCount > 0) noteAnim("animHead", L.length);
452
+ L.push(caughtLiesLine(data, data.ledger ? data.ledger.falseCount : 0));
394
453
  // The navigable list — window it so the selection is always visible.
395
454
  const fixedBelow = 6; // cost + steers + trend + rule + footer + breathing room
396
455
  const capacity = Math.max(0, rows - L.length - fixedBelow);
@@ -414,9 +473,12 @@ function listLines(data, cols, rows, state) {
414
473
  } else if (n > 0) {
415
474
  L.push(` ${DIM}${n} caught — terminal too small to list; drill with Enter${RESET}`);
416
475
  }
417
- L.push(` ${CYAN}${BOLD}MEASURED COST${RESET} ${DIM}${costLine(data)}${RESET}`);
476
+ endGroup(); // reveal group 2: the CAUGHT LIES block
477
+ if (data.costPairs > 0) noteAnim("animCost", L.length);
478
+ L.push(measuredCostLine(data, data.costMs));
418
479
  L.push(` ${CYAN}${BOLD}STEERS APPLIED${RESET} ${DIM}${steersLine(data)}${RESET}`);
419
480
  L.push(` ${CYAN}${BOLD}RE-ASK TREND${RESET} ${trendLine(data, Math.max(4, cols - 46))}`);
481
+ endGroup(); // reveal group 3: the three measured panels
420
482
  L.push(` ${DIM}${"─".repeat(Math.max(4, Math.min(74, cols - 4)))}${RESET}`);
421
483
  L.push(` ${DIM}↑/↓ j/k move · Enter details · q quit${RESET}`);
422
484
  return L;
@@ -470,6 +532,10 @@ function runInteractive(data) {
470
532
  const state = { view: "list", sel: 0, scroll: 0 };
471
533
  let cleaned = false;
472
534
  let pendingDraw = null;
535
+ // The ONE-TIME opening reveal (termfx contract: ≤ ~450ms total, once per invocation, ease-out).
536
+ // Any keypress or resize during it sets `skip` — the reveal aborts at its next beat and hands the
537
+ // screen to a normal instant draw(), so the user is never made to wait on an animation.
538
+ const reveal = { active: false, skip: false };
473
539
 
474
540
  // ONE cleanup, idempotent, never throws — every exit path funnels through it.
475
541
  const cleanup = () => {
@@ -560,6 +626,12 @@ function runInteractive(data) {
560
626
 
561
627
  // Coalesce resize bursts through ONE unref'd immediate — event-driven, never a poll loop.
562
628
  const scheduleDraw = () => {
629
+ if (reveal.active) {
630
+ // Mid-reveal input/resize: don't interleave a full redraw with the reveal's writes — flag
631
+ // it; the reveal aborts at its next beat and finishes with ONE instant draw() itself.
632
+ reveal.skip = true;
633
+ return;
634
+ }
563
635
  if (pendingDraw) return;
564
636
  pendingDraw = setImmediate(() => {
565
637
  pendingDraw = null;
@@ -606,7 +678,90 @@ function runInteractive(data) {
606
678
  process.stdout.on("resize", scheduleDraw); // node's SIGWINCH surface
607
679
  process.on("SIGWINCH", scheduleDraw); // belt-and-suspenders (no-op where unsupported)
608
680
 
609
- draw();
681
+ // openingReveal — REVEAL, not decoration, and only ever ONCE, on open:
682
+ // • sections land top-to-bottom, one write per section, one ~65ms beat between them
683
+ // (write, small sleep, write — no redraws);
684
+ // • the two headline counters (caught lies, measured cost) count up 0 → the REAL value in one
685
+ // shared ~250ms ease-out pass (9 frames; the final frame is the exact real number — the
686
+ // intermediate frames are pacing of the same measured value, never a different number);
687
+ // • total time-to-full-render ≈ 445ms; any key/resize aborts straight to an instant draw().
688
+ // Every later render (selector moves, drill-in, resize) is today's instant full draw().
689
+ const openingReveal = async () => {
690
+ reveal.active = true;
691
+ const finish = () => {
692
+ reveal.active = false;
693
+ if (reveal.skip) {
694
+ reveal.skip = false;
695
+ draw(); // apply whatever the mid-reveal keypress/resize changed — instantly, exact values
696
+ }
697
+ };
698
+ const cols = Math.max(10, process.stdout.columns || 80);
699
+ const rowsN = Math.max(3, process.stdout.rows || 24);
700
+ const meta = { groupEnds: [], animHead: -1, animCost: -1 };
701
+ const lines = listLines(data, cols, rowsN, state, meta).slice(0, rowsN);
702
+ const clip = (l) => clipAnsi(l, cols - 1);
703
+ const lineAt = (i, headV, costV) =>
704
+ i === meta.animHead
705
+ ? clip(caughtLiesLine(data, headV))
706
+ : i === meta.animCost
707
+ ? clip(measuredCostLine(data, costV))
708
+ : clip(lines[i]);
709
+ // Section groups from listLines' own boundaries (header / caught-lies / panels / footer).
710
+ const ends = meta.groupEnds.filter((e) => e >= 0 && e < lines.length);
711
+ const groups = [];
712
+ let g0 = 0;
713
+ for (const e of ends) {
714
+ if (e + 1 > g0) {
715
+ groups.push([g0, e + 1]);
716
+ g0 = e + 1;
717
+ }
718
+ }
719
+ if (g0 < lines.length) groups.push([g0, lines.length]);
720
+ process.stdout.write("\x1b[H\x1b[2J");
721
+ for (let gi = 0; gi < groups.length; gi++) {
722
+ if (reveal.skip) return finish();
723
+ const [a, b] = groups[gi];
724
+ let chunk = "";
725
+ for (let i = a; i < b; i++) chunk += lineAt(i, 0, 0) + (i < lines.length - 1 ? "\r\n" : "");
726
+ process.stdout.write(chunk);
727
+ if (gi < groups.length - 1) await sleep(STAGGER_MS);
728
+ }
729
+ // A counter line that fell below a too-short screen (the rowsN slice) must not be addressed —
730
+ // writing past the last row would scroll/clobber the footer on some terminals.
731
+ const doHead = meta.animHead >= 0 && meta.animHead < lines.length;
732
+ const doCost = meta.animCost >= 0 && meta.animCost < lines.length;
733
+ if (doHead || doCost) {
734
+ const headSeq = countUpFrames(data.ledger ? data.ledger.falseCount : 0, COUNT_FRAMES);
735
+ const costSeq = countUpFrames(data.costMs, COUNT_FRAMES);
736
+ const stepMs = Math.max(1, Math.round(COUNT_MS / (COUNT_FRAMES - 1)));
737
+ for (let f = 1; f < COUNT_FRAMES; f++) {
738
+ // frame 0 (both counters at 0) was written by the section reveal itself
739
+ if (reveal.skip) return finish();
740
+ await sleep(stepMs);
741
+ let buf = "";
742
+ if (doHead)
743
+ buf += `\x1b[${meta.animHead + 1};1H\x1b[2K` + lineAt(meta.animHead, headSeq[f], 0);
744
+ if (doCost)
745
+ buf += `\x1b[${meta.animCost + 1};1H\x1b[2K` + lineAt(meta.animCost, 0, costSeq[f]);
746
+ process.stdout.write(buf);
747
+ }
748
+ }
749
+ finish();
750
+ };
751
+
752
+ if (animEnabled(process.stdout) && !data.empty && state.view === "list") {
753
+ openingReveal().catch(() => {
754
+ // Any reveal hiccup degrades to the plain instant render — never a broken screen.
755
+ reveal.active = false;
756
+ try {
757
+ draw();
758
+ } catch {
759
+ die("skalpel analytics — could not render analytics");
760
+ }
761
+ });
762
+ } else {
763
+ draw();
764
+ }
610
765
  } catch {
611
766
  die("skalpel analytics — could not render analytics");
612
767
  }
package/catches.mjs CHANGED
@@ -44,6 +44,9 @@ import {
44
44
  // never drift. (This module keeps only the Claude-native loader below.)
45
45
  import { loadCodexEntries } from "./codex-normalize.mjs";
46
46
  import { recordInsight } from "./insights.mjs";
47
+ // TTY-only presentation of the SAME rendered text (see termfx.mjs restraint contract). The non-TTY
48
+ // path below never touches this — pipes/CI output stays byte-identical.
49
+ import { animEnabled, writeWithHeartbeat } from "./termfx.mjs";
47
50
 
48
51
  const HOME = homedir();
49
52
  const CLAUDE_PROJECTS = path.join(HOME, ".claude", "projects");
@@ -368,7 +371,11 @@ const isMain = (() => {
368
371
  if (isMain) {
369
372
  try {
370
373
  const result = runScan();
371
- process.stdout.write(renderReport(result) + "\n");
374
+ const out = renderReport(result) + "\n";
375
+ // TTY only: the 🔬 headline lands with one dim→bold beat (~120ms, a single \r rewrite of the
376
+ // SAME text). Non-TTY — and NO_COLOR / TERM=dumb / SKALPEL_NO_ANIM — write today's exact bytes.
377
+ if (animEnabled(process.stdout)) await writeWithHeartbeat(process.stdout, out);
378
+ else process.stdout.write(out);
372
379
  // MEASURABLE (local only, no network): record the honest fire-frequency for THIS user — how many
373
380
  // recent sessions were scanned and how many genuine catches were found. Never fabricated.
374
381
  try {
package/ledger.mjs CHANGED
@@ -36,6 +36,9 @@ import { cleanText } from "./insights.mjs";
36
36
  // READ-ONLY reuse of the shadow's single-sourced log path + its honest fail-count extractor. Importing
37
37
  // verify-shadow.mjs runs no side effects (its CLI block is guarded on isMain) and modifies nothing there.
38
38
  import { VERIFY_LOG_PATH, extractFailCount } from "./verify-shadow.mjs";
39
+ // TTY-only presentation of the SAME rendered text (see termfx.mjs restraint contract). The non-TTY
40
+ // path below never touches this — pipes/CI output stays byte-identical.
41
+ import { animEnabled, writeWithHeartbeat } from "./termfx.mjs";
39
42
 
40
43
  // ---------- (1) read the append-only history (verify-shadow.log) ----------
41
44
  // One NDJSON row per logged claim:
@@ -336,7 +339,11 @@ if (isMain) {
336
339
  const logPath = process.env.SKALPEL_LEDGER_LOG || VERIFY_LOG_PATH;
337
340
  const rows = readLedgerRows(logPath);
338
341
  const ledger = rows == null ? null : buildLedger(rows);
339
- process.stdout.write(renderLedger(ledger, { logPath }) + "\n");
342
+ const out = renderLedger(ledger, { logPath }) + "\n";
343
+ // TTY only: the 🔬 headline lands with one dim→bold beat (~120ms, a single \r rewrite of the
344
+ // SAME text). Non-TTY — and NO_COLOR / TERM=dumb / SKALPEL_NO_ANIM — write today's exact bytes.
345
+ if (animEnabled(process.stdout)) await writeWithHeartbeat(process.stdout, out);
346
+ else process.stdout.write(out);
340
347
  } catch {
341
348
  // READ-ONLY + fail-open: never throw at the user.
342
349
  process.stdout.write(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "4.0.53",
3
+ "version": "4.0.55",
4
4
  "description": "Behavioral graph for AI-assisted coding — learns how you work and steers Claude Code + Codex in real time.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -345,10 +345,10 @@ function compactMeasured(ms) {
345
345
  // This always-on aggregate counter is an EXPLICIT PRODUCT DECISION by the owner: it is skalpel's terminal
346
346
  // presence and must never be gated/hidden ("never remove it from the terminal otherwise skalpel is
347
347
  // invisible"). Aggregate-only + no fabrication is what keeps that always-on surface honest.
348
- function measuredCaughtTotalMs() {
348
+ function shadowTally() {
349
349
  try {
350
350
  const lines = tailLines(VERIFY_LOG_PATH, SHADOW_READ_BYTES);
351
- if (!lines.length) return 0;
351
+ if (!lines.length) return { totalMs: 0, checked: 0, lies: 0 };
352
352
  const rows = [];
353
353
  for (const l of lines) {
354
354
  try {
@@ -362,14 +362,25 @@ function measuredCaughtTotalMs() {
362
362
  // O(falseCount × rows) that re-scanned every row for each catch on EVERY bar render.
363
363
  const fixDeltaFor = buildFixDeltas(rows);
364
364
  let total = 0;
365
+ // The HEARTBEAT counts (same single pass — zero extra I/O on the bar's hot path):
366
+ // checked — claims the shadow got a GENUINE verdict for (its own proof/probe really ran: PASS /
367
+ // GENUINE_FAIL / SHIP_OK / SHIP_FAIL). HARNESS_ERROR rows are logged but NOT counted —
368
+ // identical to the ledger's "unverifiable" rule: we never claim a check we can't stand
369
+ // behind. So a fresh install (or harness-noise-only log) keeps checked = 0.
370
+ // lies — the caught-false subset (mismatch === true), the money metric.
371
+ const CHECKED = new Set(["PASS", "GENUINE_FAIL", "SHIP_OK", "SHIP_FAIL"]);
372
+ let checked = 0;
373
+ let lies = 0;
365
374
  for (const r of rows) {
375
+ if (CHECKED.has(r.outcome)) checked += 1;
366
376
  if (r.mismatch !== true) continue; // only a caught false "done" (verify-shadow's money metric)
377
+ lies += 1;
367
378
  const d = fixDeltaFor(r); // exact ledger.mjs logic; null-session rows excluded (Finding 3)
368
379
  if (d && Number.isFinite(d.ms) && d.ms > 0) total += d.ms; // real measured delta only; else +0
369
380
  }
370
- return total;
381
+ return { totalMs: total, checked, lies };
371
382
  } catch {
372
- return 0; // any error no measured total (fail-open: never break or slow the bar)
383
+ return { totalMs: 0, checked: 0, lies: 0 }; // fail-open: never break or slow the bar
373
384
  }
374
385
  }
375
386
 
@@ -435,10 +446,23 @@ function main() {
435
446
  // • Raahil's `~`-prefixed saved ESTIMATE ("· ~7m saved") — shown only when saved_min > 0 (its data is
436
447
  // the user's own steers.ndjson credit, not the shadow log, so it keeps his always-on #609 behavior).
437
448
  const { steers, savedMin } = readState();
449
+ const tally = SHOW_MEASURED ? shadowTally() : { totalMs: 0, checked: 0, lies: 0 };
438
450
  let line = `${BOLD}skalpel${RESET} · ${CYAN}${steers} steers${RESET}`;
439
451
  if (SHOW_MEASURED) {
440
- const caught = compactMeasured(measuredCaughtTotalMs()); // null when < 1s of real measured catches
452
+ const caught = compactMeasured(tally.totalMs); // null when < 1s of real measured catches
441
453
  if (caught) line += ` · ${CYAN}${caught} caught${RESET}`;
454
+ // THE HEARTBEAT (founder: "I only see 0 steers — the product is invisible while it works").
455
+ // Once the armed shadow has genuinely CHECKED at least one claim, the line SHOWS the work:
456
+ // clean so far → "· 3 claims verified" (the earned-silence receipt — real verdicts only)
457
+ // lies caught → "· 31 checked · 2 false" (the honest split; the measured clause carries the time)
458
+ // Real counts from the same single log pass. A fresh install (checked = 0) emits EXACTLY the
459
+ // baseline line — byte-identical, nothing invented.
460
+ if (tally.checked > 0) {
461
+ line +=
462
+ tally.lies > 0
463
+ ? ` · ${CYAN}${tally.checked} checked · ${tally.lies} false${RESET}`
464
+ : ` · ${CYAN}${tally.checked} claim${tally.checked === 1 ? "" : "s"} verified${RESET}`;
465
+ }
442
466
  }
443
467
  if (SHOW_SAVED) {
444
468
  const phrase = savedPhrase(savedMin);
package/termfx.mjs ADDED
@@ -0,0 +1,103 @@
1
+ // termfx.mjs — terminal-feel helpers for skalpel's USER-INVOKED surfaces (analytics/ledger/catches).
2
+ //
3
+ // RESTRAINT CONTRACT (why every function here is safe):
4
+ // • REVEAL, NOT DECORATION. Motion runs ONCE per invocation, ease-out, and the sum of it is well
5
+ // under half a second. No loops, no spinners, nothing that repeats.
6
+ // • NEVER THE HOT PATH. Nothing in this module is imported by skalpel-hook.mjs,
7
+ // skalpel-statusline.mjs, or verify-shadow.mjs — it exists only for user-invoked subcommands
8
+ // spawned from the package dir (skalpel-setup.mjs), so the per-turn cost is exactly zero.
9
+ // • NEVER A FABRICATED NUMBER. Helpers here only PACE or SHAPE real values computed elsewhere:
10
+ // countUpFrames ends on the exact real value; trustModel is pure arithmetic over real ledger
11
+ // counts (and refuses to draw a ratio bar on fewer than 5 checked claims — counts only).
12
+ // • RESPECT THE TERMINAL. animEnabled() is false for any non-TTY stream (pipes/CI stay
13
+ // byte-identical to the static render), and for NO_COLOR, TERM=dumb, or SKALPEL_NO_ANIM —
14
+ // those get today's plain render with zero timing or cursor-movement artifacts.
15
+ // • NODE CORE ONLY. No dependency, no side effects at import.
16
+
17
+ export const STAGGER_MS = 65; // one beat between revealed sections (60-80ms band)
18
+ export const COUNT_MS = 250; // total counter run — 0 → real value, ease-out
19
+ export const COUNT_FRAMES = 9; // 8-10 frames; final frame is ALWAYS the exact real number
20
+ export const HEARTBEAT_MS = 120; // dim → bold beat on the 🔬 headline
21
+
22
+ export const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
23
+
24
+ // animEnabled(stream, env) — the single gate every animation must pass. TTY only; any of the
25
+ // standard opt-outs (NO_COLOR non-empty, TERM=dumb, SKALPEL_NO_ANIM set) disables motion entirely.
26
+ export function animEnabled(stream = process.stdout, env = process.env) {
27
+ if (!stream || stream.isTTY !== true) return false;
28
+ if (env.NO_COLOR != null && env.NO_COLOR !== "") return false;
29
+ if ((env.TERM || "") === "dumb") return false;
30
+ const noAnim = env.SKALPEL_NO_ANIM;
31
+ if (noAnim != null && noAnim !== "" && noAnim !== "0") return false;
32
+ return true;
33
+ }
34
+
35
+ // countUpFrames(final, frames) — the integer values a counter shows on its way from 0 to the real
36
+ // value. Cubic ease-out (fast start, gentle landing), monotone non-decreasing, and the LAST frame
37
+ // is the exact real number — an intermediate frame may round, the landing never does.
38
+ export function countUpFrames(final, frames = COUNT_FRAMES) {
39
+ const n = Math.max(2, Math.floor(frames));
40
+ const target = Number.isFinite(final) && final > 0 ? final : 0;
41
+ const out = [];
42
+ for (let i = 0; i < n; i++) {
43
+ if (i === n - 1) {
44
+ out.push(target);
45
+ continue;
46
+ }
47
+ const t = i / (n - 1);
48
+ const eased = 1 - Math.pow(1 - t, 3);
49
+ out.push(Math.min(target, Math.round(target * eased)));
50
+ }
51
+ return out;
52
+ }
53
+
54
+ // trustModel(checked, falseCount) — the HONEST trust line, derived ONLY from real ledger counts.
55
+ // checked = done-claims that got a genuine re-run verdict (verified PASS or caught FALSE)
56
+ // falseCount = the caught lies among them
57
+ // Returns one of:
58
+ // { kind: "none" } — zero history: never a fake full bar
59
+ // { kind: "counts", held, checked, caught } — fewer than 5 checked: counts only, no ratio bar
60
+ // { kind: "bar", held, checked, caught, filled, total } — ratio bar; filled === total ONLY when
61
+ // every checked claim held (a 99% ratio floors to at most total-1 — the bar can never flatter).
62
+ export const TRUST_BAR_SEGMENTS = 8;
63
+ export function trustModel(checked, falseCount) {
64
+ const c = Number.isFinite(checked) && checked > 0 ? Math.floor(checked) : 0;
65
+ if (c <= 0) return { kind: "none", checked: 0, held: 0, caught: 0 };
66
+ const rawCaught = Number.isFinite(falseCount) && falseCount > 0 ? Math.floor(falseCount) : 0;
67
+ const caught = Math.min(rawCaught, c); // FALSE ⊆ checked — clamp a pathological log, never invent
68
+ const held = c - caught;
69
+ if (c < 5) return { kind: "counts", checked: c, held, caught };
70
+ const total = TRUST_BAR_SEGMENTS;
71
+ const filled = held === c ? total : Math.min(total - 1, Math.floor((held / c) * total));
72
+ return { kind: "bar", checked: c, held, caught, filled, total };
73
+ }
74
+
75
+ // trustBar(model) — the plain glyphs for a { kind: "bar" } model ("▰▰▰▰▰▰▱▱"). Color is the
76
+ // caller's job (the empty tail reads dim in analytics); the shape is shared so tests pin it.
77
+ export function trustBar(model) {
78
+ if (!model || model.kind !== "bar") return "";
79
+ return "▰".repeat(model.filled) + "▱".repeat(model.total - model.filled);
80
+ }
81
+
82
+ // writeWithHeartbeat(stream, text) — the 🔬 headline lands with a single beat: the line prints dim,
83
+ // holds ~120ms, then is re-printed bold over itself with ONE carriage-return rewrite (identical
84
+ // text, identical width — a heartbeat, not a typewriter). Everything before and after the headline
85
+ // is written exactly as-is. No 🔬 line → the text is written unchanged.
86
+ export async function writeWithHeartbeat(
87
+ stream,
88
+ text,
89
+ { ms = HEARTBEAT_MS, sleepFn = sleep } = {},
90
+ ) {
91
+ const s = String(text);
92
+ const lines = s.split("\n");
93
+ const idx = lines.findIndex((l) => l.includes("🔬"));
94
+ if (idx < 0) {
95
+ stream.write(s);
96
+ return;
97
+ }
98
+ if (idx > 0) stream.write(lines.slice(0, idx).join("\n") + "\n");
99
+ stream.write(`\x1b[2m${lines[idx]}\x1b[0m`); // dim, no newline — the cursor stays on the line
100
+ await sleepFn(ms);
101
+ stream.write(`\r\x1b[1m${lines[idx]}\x1b[0m\n`); // one \r rewrite, bold — the beat
102
+ stream.write(lines.slice(idx + 1).join("\n"));
103
+ }