skalpel 4.0.54 → 4.0.56
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 +165 -10
- package/catches.mjs +8 -1
- package/first-run.mjs +5 -1
- package/insights.mjs +8 -3
- package/ledger.mjs +8 -1
- package/package.json +1 -1
- package/termfx.mjs +103 -0
- package/verify-safeguards.test.mjs +24 -12
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/first-run.mjs
CHANGED
|
@@ -586,7 +586,11 @@ export function assignVariant(home, env = process.env) {
|
|
|
586
586
|
return forced;
|
|
587
587
|
}
|
|
588
588
|
if (state.variant === "reveal" || state.variant === "holdout") return state.variant;
|
|
589
|
-
|
|
589
|
+
// DEFAULT: everyone gets the reveal (founder decision 2026-07-14 — "everything off dark, to prod").
|
|
590
|
+
// The 50/50 holdout A/B is retired for new installs; the SKALPEL_FIRST_RUN_VARIANT override above
|
|
591
|
+
// still forces either arm (tests / a future re-run of the experiment), and previously-assigned
|
|
592
|
+
// holdout users keep their recorded arm (the persisted-state branch above) so old data stays clean.
|
|
593
|
+
const variant = "reveal";
|
|
590
594
|
state.variant = variant;
|
|
591
595
|
state.assigned_at = new Date().toISOString();
|
|
592
596
|
writeState(home, state);
|
package/insights.mjs
CHANGED
|
@@ -59,7 +59,7 @@ export function verifyConfigArmed() {
|
|
|
59
59
|
return null;
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
|
-
// revealEnabled(env) — is the USER-VISIBLE reveal armed? env override > consented config > default
|
|
62
|
+
// revealEnabled(env) — is the USER-VISIBLE reveal armed? env override > consented config > default ON.
|
|
63
63
|
// SKALPEL_VERIFY_SHADOW is deliberately NOT consulted here: it arms only the DARK log-only shadow (no
|
|
64
64
|
// reveal); the spawn gate ORs it in separately (verifySpawnArmed). Wrapped so it can never throw.
|
|
65
65
|
export function revealEnabled(env) {
|
|
@@ -67,11 +67,16 @@ export function revealEnabled(env) {
|
|
|
67
67
|
const envOverride = parseArmToken((env || {}).SKALPEL_VERIFY_REVEAL);
|
|
68
68
|
if (envOverride !== null) return envOverride; // 1) dev/CI env override wins
|
|
69
69
|
const cfg = verifyConfigArmed();
|
|
70
|
-
if (cfg !== null) return cfg; // 2) consented config
|
|
70
|
+
if (cfg !== null) return cfg; // 2) consented config (skalpel verify on|off still rules)
|
|
71
71
|
} catch {
|
|
72
72
|
/* fall through to default */
|
|
73
73
|
}
|
|
74
|
-
|
|
74
|
+
// 3) DEFAULT ON — founder decision 2026-07-14 ("everything off dark, to prod"): the live catch +
|
|
75
|
+
// reveal ship ARMED out of the box. The safeguards that make this sane are unchanged and still
|
|
76
|
+
// override this default: `skalpel verify off` (consented config, step 2), the env kill (step 1),
|
|
77
|
+
// the flake double-confirm before any red, recorded-failure corroboration on Codex, and the
|
|
78
|
+
// adjudicated auto-darken (precision < 90% over ≥ 10 verdicts silently re-darkens the reveal).
|
|
79
|
+
return true;
|
|
75
80
|
}
|
|
76
81
|
// verifySpawnArmed(env) — should the per-turn hook SPAWN the detached shadow worker at all? True when the
|
|
77
82
|
// reveal is armed OR the dev log-only shadow (SKALPEL_VERIFY_SHADOW=1) is set. This is the single gate the
|
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
|
-
|
|
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
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
|
+
}
|
|
@@ -37,11 +37,11 @@ beforeEach(() => {
|
|
|
37
37
|
});
|
|
38
38
|
const writeCfg = (o) => writeFileSync(CLIENT_JSON_PATH, JSON.stringify(o));
|
|
39
39
|
|
|
40
|
-
// ---- arming resolver precedence: env > config >
|
|
41
|
-
test("arming —
|
|
40
|
+
// ---- arming resolver precedence: env > config > DEFAULT ON (founder 2026-07-14, off-dark) -------
|
|
41
|
+
test("arming — DEFAULT ON when config absent and env unset (ships armed)", () => {
|
|
42
42
|
assert.equal(verifyConfigArmed(), null);
|
|
43
|
-
assert.equal(revealEnabled({}),
|
|
44
|
-
assert.equal(verifySpawnArmed({}),
|
|
43
|
+
assert.equal(revealEnabled({}), true);
|
|
44
|
+
assert.equal(verifySpawnArmed({}), true);
|
|
45
45
|
});
|
|
46
46
|
|
|
47
47
|
test("arming — consented config on/off", () => {
|
|
@@ -59,17 +59,29 @@ test("arming — env override BEATS config in both directions", () => {
|
|
|
59
59
|
assert.equal(revealEnabled({ SKALPEL_VERIFY_REVEAL: "1" }), true, "env on beats config off");
|
|
60
60
|
});
|
|
61
61
|
|
|
62
|
-
test("arming — SKALPEL_VERIFY_SHADOW arms the spawn
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
62
|
+
test("arming — SKALPEL_VERIFY_SHADOW arms the spawn even when the user turned the reveal OFF", () => {
|
|
63
|
+
// The meaningful spawn-vs-reveal split now lives on the DECLINED path: with config verify:"off",
|
|
64
|
+
// the reveal stays dark but the dev/CI log-only shadow env can still arm the spawn.
|
|
65
|
+
writeCfg({ verify: "off" });
|
|
66
|
+
assert.equal(
|
|
67
|
+
revealEnabled({ SKALPEL_VERIFY_SHADOW: "1" }),
|
|
68
|
+
false,
|
|
69
|
+
"user's off still rules the reveal",
|
|
70
|
+
);
|
|
71
|
+
assert.equal(
|
|
72
|
+
verifySpawnArmed({ SKALPEL_VERIFY_SHADOW: "1" }),
|
|
73
|
+
true,
|
|
74
|
+
"log-only shadow still spawns",
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("arming — fail-open: a corrupt client.json never throws, resolves to the DEFAULT (on)", () => {
|
|
69
79
|
writeFileSync(CLIENT_JSON_PATH, "{ this is not json");
|
|
70
80
|
assert.doesNotThrow(() => revealEnabled({}));
|
|
71
81
|
assert.equal(verifyConfigArmed(), null);
|
|
72
|
-
|
|
82
|
+
// Unreadable config falls through to the ships-armed default. A user who declined keeps OFF via a
|
|
83
|
+
// VALID config (previous test); the corrupt-file edge resolves like a fresh install.
|
|
84
|
+
assert.equal(revealEnabled({}), true);
|
|
73
85
|
});
|
|
74
86
|
|
|
75
87
|
// ---- adjudication ledger + <90% auto-darken derivation ------------------------------------------
|