@svrnsec/pulse 0.7.0 → 0.9.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.
Files changed (49) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +883 -782
  3. package/SECURITY.md +27 -22
  4. package/bin/svrnsec-pulse.js +7 -7
  5. package/dist/{pulse.cjs.js → pulse.cjs} +6428 -6413
  6. package/dist/pulse.cjs.map +1 -0
  7. package/dist/pulse.esm.js +6429 -6415
  8. package/dist/pulse.esm.js.map +1 -1
  9. package/index.d.ts +949 -846
  10. package/package.json +189 -184
  11. package/pkg/pulse_core.js +174 -173
  12. package/src/analysis/audio.js +213 -213
  13. package/src/analysis/authenticityAudit.js +408 -393
  14. package/src/analysis/coherence.js +502 -502
  15. package/src/analysis/coordinatedBehavior.js +825 -804
  16. package/src/analysis/heuristic.js +428 -428
  17. package/src/analysis/jitter.js +446 -446
  18. package/src/analysis/llm.js +473 -472
  19. package/src/analysis/populationEntropy.js +404 -403
  20. package/src/analysis/provider.js +248 -248
  21. package/src/analysis/refraction.js +392 -391
  22. package/src/analysis/trustScore.js +356 -356
  23. package/src/cli/args.js +36 -36
  24. package/src/cli/commands/scan.js +192 -192
  25. package/src/cli/runner.js +157 -157
  26. package/src/collector/adaptive.js +200 -200
  27. package/src/collector/bio.js +297 -287
  28. package/src/collector/canvas.js +247 -239
  29. package/src/collector/dram.js +203 -203
  30. package/src/collector/enf.js +311 -311
  31. package/src/collector/entropy.js +195 -195
  32. package/src/collector/gpu.js +248 -245
  33. package/src/collector/idleAttestation.js +480 -480
  34. package/src/collector/sabTimer.js +189 -191
  35. package/src/errors.js +54 -0
  36. package/src/fingerprint.js +475 -475
  37. package/src/index.js +345 -342
  38. package/src/integrations/react-native.js +462 -459
  39. package/src/integrations/react.js +184 -185
  40. package/src/middleware/express.js +155 -155
  41. package/src/middleware/next.js +174 -175
  42. package/src/proof/challenge.js +249 -249
  43. package/src/proof/engagementToken.js +426 -394
  44. package/src/proof/fingerprint.js +268 -268
  45. package/src/proof/validator.js +82 -142
  46. package/src/registry/serializer.js +349 -349
  47. package/src/terminal.js +263 -263
  48. package/src/update-notifier.js +259 -264
  49. package/dist/pulse.cjs.js.map +0 -1
package/src/cli/args.js CHANGED
@@ -1,36 +1,36 @@
1
- /**
2
- * Minimal argument parser — zero dependencies.
3
- * Supports: flags (--flag), options (--key value), positional args.
4
- */
5
- export function parseArgs(argv = process.argv.slice(2)) {
6
- const flags = new Set();
7
- const opts = {};
8
- const pos = [];
9
-
10
- for (let i = 0; i < argv.length; i++) {
11
- const arg = argv[i];
12
- if (arg.startsWith('--')) {
13
- const key = arg.slice(2);
14
- const next = argv[i + 1];
15
- if (next && !next.startsWith('--')) {
16
- opts[key] = next;
17
- i++;
18
- } else {
19
- flags.add(key);
20
- }
21
- } else if (arg.startsWith('-') && arg.length === 2) {
22
- flags.add(arg.slice(1));
23
- } else {
24
- pos.push(arg);
25
- }
26
- }
27
-
28
- return {
29
- command: pos[0] ?? null,
30
- positional: pos.slice(1),
31
- flags,
32
- opts,
33
- has: (f) => flags.has(f),
34
- get: (k, def) => opts[k] ?? def,
35
- };
36
- }
1
+ /**
2
+ * Minimal argument parser — zero dependencies.
3
+ * Supports: flags (--flag), options (--key value), positional args.
4
+ */
5
+ export function parseArgs(argv = process.argv.slice(2)) {
6
+ const flags = new Set();
7
+ const opts = {};
8
+ const pos = [];
9
+
10
+ for (let i = 0; i < argv.length; i++) {
11
+ const arg = argv[i];
12
+ if (arg.startsWith('--')) {
13
+ const key = arg.slice(2);
14
+ const next = argv[i + 1];
15
+ if (next && !next.startsWith('--')) {
16
+ opts[key] = next;
17
+ i++;
18
+ } else {
19
+ flags.add(key);
20
+ }
21
+ } else if (arg.startsWith('-') && arg.length === 2) {
22
+ flags.add(arg.slice(1));
23
+ } else {
24
+ pos.push(arg);
25
+ }
26
+ }
27
+
28
+ return {
29
+ command: pos[0] ?? null,
30
+ positional: pos.slice(1),
31
+ flags,
32
+ opts,
33
+ has: (f) => flags.has(f),
34
+ get: (k, def) => opts[k] ?? def,
35
+ };
36
+ }
@@ -1,192 +1,192 @@
1
- /**
2
- * svrnsec-pulse scan
3
- *
4
- * Runs the full probe locally (Node.js JS engine, no browser required),
5
- * computes the TrustScore, and renders a pretty result card.
6
- *
7
- * Options:
8
- * --json output raw JSON instead of the visual card
9
- * --iterations override probe iteration count (default 200)
10
- * --no-banner suppress the banner
11
- */
12
-
13
- import { generateNonce } from '../../proof/validator.js';
14
- import { computeTrustScore, formatTrustScore } from '../../analysis/trustScore.js';
15
- import { collectDramTimings } from '../../collector/dram.js';
16
- import { collectEnfTimings } from '../../collector/enf.js';
17
- import { renderProbeResult } from '../../terminal.js';
18
- import { CURRENT_VERSION } from '../../update-notifier.js';
19
-
20
- // ANSI helpers (inlined — no dep on terminal.js palette export)
21
- const isTTY = () => process.stderr.isTTY && !process.env.NO_COLOR;
22
- const A = { reset:'\x1b[0m', bold:'\x1b[1m', dim:'\x1b[2m', gray:'\x1b[90m',
23
- bwhite:'\x1b[97m', bmagenta:'\x1b[95m', bcyan:'\x1b[96m', bgreen:'\x1b[92m',
24
- byellow:'\x1b[93m', bred:'\x1b[91m' };
25
- const c = (code, s) => isTTY() ? `${code}${s}${A.reset}` : s;
26
- const dim = (s) => c(A.dim, s);
27
- const mag = (s) => c(A.bmagenta, s);
28
- const wh = (s) => c(A.bwhite, s);
29
- const cy = (s) => c(A.bcyan, s);
30
- const gr = (s) => c(A.bgreen, s);
31
- const ye = (s) => c(A.byellow, s);
32
- const re = (s) => c(A.bred, s);
33
- const gy = (s) => c(A.gray, s);
34
- const bd = (s) => c(A.bold, s);
35
-
36
- function bar(pct, w = 24) {
37
- const f = Math.round(Math.min(1, pct) * w);
38
- const fill = isTTY() ? `\x1b[92m${'█'.repeat(f)}\x1b[0m` : '█'.repeat(f);
39
- const void_ = gy('░'.repeat(w - f));
40
- return fill + void_;
41
- }
42
-
43
- function spinner(ms = 80) {
44
- if (!isTTY()) return { stop: () => {} };
45
- const frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
46
- let i = 0;
47
- const iv = setInterval(() => {
48
- process.stderr.write(`\r ${gy(frames[i++ % frames.length])} `);
49
- }, ms);
50
- return { stop: (msg = '') => { clearInterval(iv); process.stderr.write(`\r ${msg}\n`); } };
51
- }
52
-
53
- export async function runScan(args) {
54
- const jsonMode = args.has('json') || args.has('j');
55
- const iterations = parseInt(args.get('iterations', '200'), 10);
56
- const noBanner = args.has('no-banner');
57
-
58
- if (!noBanner && isTTY()) {
59
- process.stderr.write(
60
- '\n' +
61
- gy(' ┌─────────────────────────────────────────────┐') + '\n' +
62
- gy(' │') + ` ${mag('SVRN')}${wh(':PULSE')} ${gy('scan')} ` +
63
- gy('Physical Turing Test │') + '\n' +
64
- gy(' │') + ` ${gy(`v${CURRENT_VERSION}`)} ` +
65
- cy('https://github.com/ayronny14-alt/Svrn-Pulse-Security') + ` ` +
66
- gy('│') + '\n' +
67
- gy(' └─────────────────────────────────────────────┘') + '\n\n'
68
- );
69
- }
70
-
71
- const t0 = Date.now();
72
- const nonce = generateNonce();
73
-
74
- // ── Entropy probe ─────────────────────────────────────────────────────────
75
- if (isTTY() && !jsonMode) {
76
- process.stderr.write(gy(' Probing entropy') + ' ');
77
- }
78
-
79
- const { collectEntropy } = await import('../../collector/entropy.js');
80
- const spin = spinner();
81
-
82
- let entropy;
83
- try {
84
- entropy = await collectEntropy({
85
- iterations,
86
- phased: true,
87
- adaptive: true,
88
- onBatch: (meta) => {
89
- if (isTTY() && !jsonMode) {
90
- spin.stop(
91
- `${bar(meta.pct / 100, 20)} ${gy(meta.pct + '%')} ` +
92
- `vm:${(meta.vmConf * 100).toFixed(0)}% hw:${(meta.hwConf * 100).toFixed(0)}%`
93
- );
94
- }
95
- },
96
- });
97
- spin.stop(gr('✓ entropy collected'));
98
- } catch (err) {
99
- spin.stop(re('✗ entropy probe failed: ' + err.message));
100
- process.exit(1);
101
- }
102
-
103
- // ── Extended signals ──────────────────────────────────────────────────────
104
- if (isTTY() && !jsonMode) process.stderr.write('\n');
105
-
106
- const [enf, dram] = await Promise.allSettled([
107
- collectEnfTimings(),
108
- Promise.resolve(collectDramTimings()),
109
- ]).then(results => results.map(r => r.status === 'fulfilled' ? r.value : null));
110
-
111
- // ── Analysis ──────────────────────────────────────────────────────────────
112
- const { classifyJitter } = await import('../../analysis/jitter.js');
113
- const { buildProof, buildCommitment } = await import('../../proof/fingerprint.js');
114
-
115
- // Minimal bio stub for non-browser context
116
- const bioStub = {
117
- mouse: { sampleCount:0,ieiMean:0,ieiCV:0,velocityP50:0,velocityP95:0,angularJerkMean:0,pressureVariance:0 },
118
- keyboard: { sampleCount:0,dwellMean:0,dwellCV:0,ikiMean:0,ikiCV:0 },
119
- interferenceCoefficient: 0,
120
- hasActivity: false,
121
- durationMs: 0,
122
- };
123
-
124
- const canvasStub = {
125
- webglRenderer:null,webglVendor:null,webglVersion:null,
126
- webglPixelHash:null,canvas2dHash:null,extensionCount:0,
127
- isSoftwareRenderer:false,available:false,
128
- };
129
-
130
- const audioStub = {
131
- available:false,workletAvailable:false,callbackJitterCV:0,
132
- noiseFloorMean:0,noiseFloorStd:0,sampleRate:0,callbackCount:0,
133
- jitterMeanMs:0,jitterP95Ms:0,
134
- };
135
-
136
- const jitter = classifyJitter(entropy.timings, { autocorrelations: entropy.autocorrelations });
137
- const payload = buildProof({ entropy, jitter, bio: bioStub, canvas: canvasStub, audio: audioStub, enf, dram, nonce });
138
- const { hash } = buildCommitment(payload);
139
-
140
- const ts = computeTrustScore(payload, { enf, dram });
141
- const elapsed = Date.now() - t0;
142
-
143
- // ── Output ────────────────────────────────────────────────────────────────
144
- if (jsonMode) {
145
- process.stdout.write(JSON.stringify({ payload, hash, trustScore: ts, elapsed }, null, 2) + '\n');
146
- return;
147
- }
148
-
149
- // Pretty result card
150
- renderProbeResult({ payload, hash, enf, dram, elapsedMs: elapsed });
151
-
152
- // TrustScore panel
153
- const gradeColor = ts.score >= 75 ? A.bgreen : ts.score >= 45 ? A.byellow : A.bred;
154
- const W = 54;
155
- const vb = gy('│');
156
-
157
- process.stderr.write(gy('╭' + '─'.repeat(W + 2) + '╮') + '\n');
158
- process.stderr.write(`${vb} ${bd('TRUST SCORE')}${' '.repeat(W - 9)} ${vb}\n`);
159
- process.stderr.write(gy('├' + '─'.repeat(W + 2) + '┤') + '\n');
160
- process.stderr.write(`${vb} ${' '.repeat(Math.floor((W - 8) / 2))}${c(gradeColor + A.bold, `${ts.score} / 100`)}${' '.repeat(Math.ceil((W - 8) / 2))} ${vb}\n`);
161
- process.stderr.write(`${vb} ${' '.repeat(Math.floor((W - ts.grade.length - ts.label.length - 3) / 2))}${c(gradeColor, ts.grade)} · ${c(gradeColor, ts.label)}${' '.repeat(Math.ceil((W - ts.grade.length - ts.label.length - 3) / 2))} ${vb}\n`);
162
- process.stderr.write(`${vb} ${' '.repeat(W)} ${vb}\n`);
163
-
164
- // Per-signal bars
165
- const layers = [
166
- ['Physics', ts.signals.physics, ts.breakdown.physics?.pts, 40],
167
- ['ENF', ts.signals.enf, ts.breakdown.enf?.pts, 20],
168
- ['GPU', ts.signals.gpu, ts.breakdown.gpu?.pts, 15],
169
- ['DRAM', ts.signals.dram, ts.breakdown.dram?.pts, 15],
170
- ['Bio/LLM', ts.signals.bio, ts.breakdown.bio?.pts, 10],
171
- ];
172
-
173
- for (const [name, pct, pts, max] of layers) {
174
- const lbl = gy(name.padEnd(10));
175
- const b = bar(pct ?? 0, 24);
176
- const ptsS = `${pts ?? 0}/${max}`.padStart(6);
177
- process.stderr.write(`${vb} ${lbl} ${b} ${gy(ptsS)} ${vb}\n`);
178
- }
179
-
180
- if (ts.penalties.length > 0) {
181
- process.stderr.write(`${vb} ${' '.repeat(W)} ${vb}\n`);
182
- for (const p of ts.penalties) {
183
- const msg = ye('⚠ ') + gy(p.reason.slice(0, W - 4));
184
- process.stderr.write(`${vb} ${msg}${' '.repeat(Math.max(0, W - 2 - msg.replace(/\x1b\[[0-9;]*m/g,''). length))} ${vb}\n`);
185
- }
186
- }
187
-
188
- process.stderr.write(`${vb} ${' '.repeat(W)} ${vb}\n`);
189
- process.stderr.write(`${vb} ${gy('BLAKE3 ' + hash.slice(0, 40) + '…')}${' '.repeat(W - 48)} ${vb}\n`);
190
- process.stderr.write(`${vb} ${gy(`elapsed ${(elapsed/1000).toFixed(2)}s`)}${' '.repeat(W - 14)} ${vb}\n`);
191
- process.stderr.write(gy('╰' + '─'.repeat(W + 2) + '╯') + '\n\n');
192
- }
1
+ /**
2
+ * svrnsec-pulse scan
3
+ *
4
+ * Runs the full probe locally (Node.js JS engine, no browser required),
5
+ * computes the TrustScore, and renders a pretty result card.
6
+ *
7
+ * Options:
8
+ * --json output raw JSON instead of the visual card
9
+ * --iterations override probe iteration count (default 200)
10
+ * --no-banner suppress the banner
11
+ */
12
+
13
+ import { generateNonce } from '../../proof/validator.js';
14
+ import { computeTrustScore, formatTrustScore } from '../../analysis/trustScore.js';
15
+ import { collectDramTimings } from '../../collector/dram.js';
16
+ import { collectEnfTimings } from '../../collector/enf.js';
17
+ import { renderProbeResult } from '../../terminal.js';
18
+ import { CURRENT_VERSION } from '../../update-notifier.js';
19
+
20
+ // ANSI helpers (inlined — no dep on terminal.js palette export)
21
+ const isTTY = () => process.stderr.isTTY && !process.env.NO_COLOR;
22
+ const A = { reset:'\x1b[0m', bold:'\x1b[1m', dim:'\x1b[2m', gray:'\x1b[90m',
23
+ bwhite:'\x1b[97m', bmagenta:'\x1b[95m', bcyan:'\x1b[96m', bgreen:'\x1b[92m',
24
+ byellow:'\x1b[93m', bred:'\x1b[91m' };
25
+ const c = (code, s) => isTTY() ? `${code}${s}${A.reset}` : s;
26
+ const dim = (s) => c(A.dim, s);
27
+ const mag = (s) => c(A.bmagenta, s);
28
+ const wh = (s) => c(A.bwhite, s);
29
+ const cy = (s) => c(A.bcyan, s);
30
+ const gr = (s) => c(A.bgreen, s);
31
+ const ye = (s) => c(A.byellow, s);
32
+ const re = (s) => c(A.bred, s);
33
+ const gy = (s) => c(A.gray, s);
34
+ const bd = (s) => c(A.bold, s);
35
+
36
+ function bar(pct, w = 24) {
37
+ const f = Math.round(Math.min(1, pct) * w);
38
+ const fill = isTTY() ? `\x1b[92m${'█'.repeat(f)}\x1b[0m` : '█'.repeat(f);
39
+ const void_ = gy('░'.repeat(w - f));
40
+ return fill + void_;
41
+ }
42
+
43
+ function spinner(ms = 80) {
44
+ if (!isTTY()) return { stop: () => {} };
45
+ const frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
46
+ let i = 0;
47
+ const iv = setInterval(() => {
48
+ process.stderr.write(`\r ${gy(frames[i++ % frames.length])} `);
49
+ }, ms);
50
+ return { stop: (msg = '') => { clearInterval(iv); process.stderr.write(`\r ${msg}\n`); } };
51
+ }
52
+
53
+ export async function runScan(args) {
54
+ const jsonMode = args.has('json') || args.has('j');
55
+ const iterations = parseInt(args.get('iterations', '200'), 10);
56
+ const noBanner = args.has('no-banner');
57
+
58
+ if (!noBanner && isTTY()) {
59
+ process.stderr.write(
60
+ '\n' +
61
+ gy(' ┌─────────────────────────────────────────────┐') + '\n' +
62
+ gy(' │') + ` ${mag('SVRN')}${wh(':PULSE')} ${gy('scan')} ` +
63
+ gy('Physical Turing Test │') + '\n' +
64
+ gy(' │') + ` ${gy(`v${CURRENT_VERSION}`)} ` +
65
+ cy('https://github.com/ayronny14-alt/Svrn-Pulse-Security') + ` ` +
66
+ gy('│') + '\n' +
67
+ gy(' └─────────────────────────────────────────────┘') + '\n\n'
68
+ );
69
+ }
70
+
71
+ const t0 = Date.now();
72
+ const nonce = generateNonce();
73
+
74
+ // ── Entropy probe ─────────────────────────────────────────────────────────
75
+ if (isTTY() && !jsonMode) {
76
+ process.stderr.write(gy(' Probing entropy') + ' ');
77
+ }
78
+
79
+ const { collectEntropy } = await import('../../collector/entropy.js');
80
+ const spin = spinner();
81
+
82
+ let entropy;
83
+ try {
84
+ entropy = await collectEntropy({
85
+ iterations,
86
+ phased: true,
87
+ adaptive: true,
88
+ onBatch: (meta) => {
89
+ if (isTTY() && !jsonMode) {
90
+ spin.stop(
91
+ `${bar(meta.pct / 100, 20)} ${gy(meta.pct + '%')} ` +
92
+ `vm:${(meta.vmConf * 100).toFixed(0)}% hw:${(meta.hwConf * 100).toFixed(0)}%`
93
+ );
94
+ }
95
+ },
96
+ });
97
+ spin.stop(gr('✓ entropy collected'));
98
+ } catch (err) {
99
+ spin.stop(re('✗ entropy probe failed: ' + err.message));
100
+ process.exit(1);
101
+ }
102
+
103
+ // ── Extended signals ──────────────────────────────────────────────────────
104
+ if (isTTY() && !jsonMode) process.stderr.write('\n');
105
+
106
+ const [enf, dram] = await Promise.allSettled([
107
+ collectEnfTimings(),
108
+ Promise.resolve(collectDramTimings()),
109
+ ]).then(results => results.map(r => r.status === 'fulfilled' ? r.value : null));
110
+
111
+ // ── Analysis ──────────────────────────────────────────────────────────────
112
+ const { classifyJitter } = await import('../../analysis/jitter.js');
113
+ const { buildProof, buildCommitment } = await import('../../proof/fingerprint.js');
114
+
115
+ // Minimal bio stub for non-browser context
116
+ const bioStub = {
117
+ mouse: { sampleCount:0,ieiMean:0,ieiCV:0,velocityP50:0,velocityP95:0,angularJerkMean:0,pressureVariance:0 },
118
+ keyboard: { sampleCount:0,dwellMean:0,dwellCV:0,ikiMean:0,ikiCV:0 },
119
+ interferenceCoefficient: 0,
120
+ hasActivity: false,
121
+ durationMs: 0,
122
+ };
123
+
124
+ const canvasStub = {
125
+ webglRenderer:null,webglVendor:null,webglVersion:null,
126
+ webglPixelHash:null,canvas2dHash:null,extensionCount:0,
127
+ isSoftwareRenderer:false,available:false,
128
+ };
129
+
130
+ const audioStub = {
131
+ available:false,workletAvailable:false,callbackJitterCV:0,
132
+ noiseFloorMean:0,noiseFloorStd:0,sampleRate:0,callbackCount:0,
133
+ jitterMeanMs:0,jitterP95Ms:0,
134
+ };
135
+
136
+ const jitter = classifyJitter(entropy.timings, { autocorrelations: entropy.autocorrelations });
137
+ const payload = buildProof({ entropy, jitter, bio: bioStub, canvas: canvasStub, audio: audioStub, enf, dram, nonce });
138
+ const { hash } = buildCommitment(payload);
139
+
140
+ const ts = computeTrustScore(payload, { enf, dram });
141
+ const elapsed = Date.now() - t0;
142
+
143
+ // ── Output ────────────────────────────────────────────────────────────────
144
+ if (jsonMode) {
145
+ process.stdout.write(JSON.stringify({ payload, hash, trustScore: ts, elapsed }, null, 2) + '\n');
146
+ return;
147
+ }
148
+
149
+ // Pretty result card
150
+ renderProbeResult({ payload, hash, enf, dram, elapsedMs: elapsed });
151
+
152
+ // TrustScore panel
153
+ const gradeColor = ts.score >= 75 ? A.bgreen : ts.score >= 45 ? A.byellow : A.bred;
154
+ const W = 54;
155
+ const vb = gy('│');
156
+
157
+ process.stderr.write(gy('╭' + '─'.repeat(W + 2) + '╮') + '\n');
158
+ process.stderr.write(`${vb} ${bd('TRUST SCORE')}${' '.repeat(W - 9)} ${vb}\n`);
159
+ process.stderr.write(gy('├' + '─'.repeat(W + 2) + '┤') + '\n');
160
+ process.stderr.write(`${vb} ${' '.repeat(Math.floor((W - 8) / 2))}${c(gradeColor + A.bold, `${ts.score} / 100`)}${' '.repeat(Math.ceil((W - 8) / 2))} ${vb}\n`);
161
+ process.stderr.write(`${vb} ${' '.repeat(Math.floor((W - ts.grade.length - ts.label.length - 3) / 2))}${c(gradeColor, ts.grade)} · ${c(gradeColor, ts.label)}${' '.repeat(Math.ceil((W - ts.grade.length - ts.label.length - 3) / 2))} ${vb}\n`);
162
+ process.stderr.write(`${vb} ${' '.repeat(W)} ${vb}\n`);
163
+
164
+ // Per-signal bars
165
+ const layers = [
166
+ ['Physics', ts.signals.physics, ts.breakdown.physics?.pts, 40],
167
+ ['ENF', ts.signals.enf, ts.breakdown.enf?.pts, 20],
168
+ ['GPU', ts.signals.gpu, ts.breakdown.gpu?.pts, 15],
169
+ ['DRAM', ts.signals.dram, ts.breakdown.dram?.pts, 15],
170
+ ['Bio/LLM', ts.signals.bio, ts.breakdown.bio?.pts, 10],
171
+ ];
172
+
173
+ for (const [name, pct, pts, max] of layers) {
174
+ const lbl = gy(name.padEnd(10));
175
+ const b = bar(pct ?? 0, 24);
176
+ const ptsS = `${pts ?? 0}/${max}`.padStart(6);
177
+ process.stderr.write(`${vb} ${lbl} ${b} ${gy(ptsS)} ${vb}\n`);
178
+ }
179
+
180
+ if (ts.penalties.length > 0) {
181
+ process.stderr.write(`${vb} ${' '.repeat(W)} ${vb}\n`);
182
+ for (const p of ts.penalties) {
183
+ const msg = ye('⚠ ') + gy(p.reason.slice(0, W - 4));
184
+ process.stderr.write(`${vb} ${msg}${' '.repeat(Math.max(0, W - 2 - msg.replace(/\x1b\[[0-9;]*m/g,''). length))} ${vb}\n`);
185
+ }
186
+ }
187
+
188
+ process.stderr.write(`${vb} ${' '.repeat(W)} ${vb}\n`);
189
+ process.stderr.write(`${vb} ${gy('BLAKE3 ' + hash.slice(0, 40) + '…')}${' '.repeat(W - 48)} ${vb}\n`);
190
+ process.stderr.write(`${vb} ${gy(`elapsed ${(elapsed/1000).toFixed(2)}s`)}${' '.repeat(W - 14)} ${vb}\n`);
191
+ process.stderr.write(gy('╰' + '─'.repeat(W + 2) + '╯') + '\n\n');
192
+ }