cyborg-hunter 0.5.0 → 0.7.2

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 (58) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/CITATION.cff +29 -0
  3. package/LICENSE +21 -0
  4. package/README.md +80 -21
  5. package/bin/cyborg-hunter.js +6 -2
  6. package/dist/cyborg-hunter-replay.js +3 -0
  7. package/dist/cyborg-hunter.esm.js +114 -22
  8. package/dist/cyborg-hunter.min.js +3 -3
  9. package/dist/extension-cyborg-hunter.js +1 -1
  10. package/dist/extension-guard-friction.js +5 -5
  11. package/dist/extension-guard-honeypot.js +1 -1
  12. package/package.json +15 -3
  13. package/src/cli/analyzers/edge-exit.js +4 -1
  14. package/src/cli/analyzers/phase-scope.js +83 -0
  15. package/src/cli/analyzers/summary.js +161 -28
  16. package/src/cli/analyzers/triage.js +59 -27
  17. package/src/cli/config.js +26 -1
  18. package/src/cli/extract-core.js +552 -0
  19. package/src/cli/ingest.js +250 -193
  20. package/src/cli/init.js +1 -1
  21. package/src/cli/preview-entry.js +36 -0
  22. package/src/cli/renderers/event-log.js +18 -19
  23. package/src/cli/renderers/extensions.js +12 -3
  24. package/src/cli/renderers/html-index-core.js +1273 -0
  25. package/src/cli/renderers/html-index.js +13 -1047
  26. package/src/cli/renderers/replay-assets.js +67 -0
  27. package/src/cli/renderers/replay-viewer.client.js +1185 -0
  28. package/src/cli/renderers/session-timeline-core.js +907 -0
  29. package/src/cli/renderers/session-timeline.js +33 -0
  30. package/src/cli/renderers/summary-csv.js +5 -0
  31. package/src/cli/renderers/trajectories-core.js +717 -0
  32. package/src/cli/renderers/trajectories.js +31 -635
  33. package/src/cli/renderers/triage-md.js +10 -4
  34. package/src/cli/renderers/typing-profile-core.js +211 -0
  35. package/src/cli/renderers/typing-profile.js +16 -186
  36. package/src/cli/report.js +42 -8
  37. package/src/core/monitor.js +77 -7
  38. package/src/core/scoring.js +11 -2
  39. package/src/core/signals/browser.js +51 -18
  40. package/src/core/signals/clipboard.js +10 -2
  41. package/src/core/signals/dom-protection.js +9 -0
  42. package/src/core/signals/focus.js +16 -2
  43. package/src/jspsych/extension-cyborg-hunter-replay.js +135 -0
  44. package/src/jspsych/extension-cyborg-hunter.js +9 -2
  45. package/src/jspsych/extension-guard-friction.js +43 -14
  46. package/src/jspsych/extension-guard-honeypot.js +25 -1
  47. package/src/replay/capture-dom.js +575 -0
  48. package/src/replay/capture-trace.js +468 -0
  49. package/src/replay/index.js +104 -0
  50. package/src/replay/persistence.js +141 -0
  51. package/src/replay/recorder.js +315 -0
  52. package/src/replay/serializer.js +119 -0
  53. package/src/replay/viewer-model.js +125 -0
  54. package/src/shared/constants.js +12 -6
  55. package/src/shared/paths.js +20 -0
  56. package/src/shared/schema.js +5 -0
  57. package/src/shared/validation.js +55 -0
  58. package/src/cli/renderers/tab-timeline.js +0 -149
@@ -11,15 +11,21 @@ export async function renderTriage(triage, config) {
11
11
  '',
12
12
  `_${triage.length} participants analyzed_`,
13
13
  '',
14
- '| Rank | Participant | Score | Hard | Reason |',
15
- '|------|-------------|-------|------|--------|',
14
+ '**Tier** is the library\'s two-tier screening verdict: `HARD` = a hard signal',
15
+ '(paste/drop/copy) crossed its count threshold; `soft` = library soft score ≥',
16
+ 'its threshold; `clean` = neither. **Score** is the CLI\'s separate ranking',
17
+ 'heuristic (5×paste + 5×copy + 3×sidebar + 1×tab-away) — it orders rows',
18
+ '*within* a tier and is not the library soft score.',
19
+ '',
20
+ '| Rank | Participant | Tier | Score | Reason |',
21
+ '|------|-------------|------|-------|--------|',
16
22
  ];
17
23
 
18
24
  triage.forEach((t, i) => {
19
- const hard = t.hardTriggered ? '**YES**' : 'no';
25
+ const tier = t.hardTriggered ? '**HARD**' : t.softFlagged ? 'soft' : 'clean';
20
26
  // Escape pipe characters in reason text to avoid breaking the table
21
27
  const reason = t.reason.replace(/\|/g, '\\|');
22
- lines.push(`| ${i + 1} | ${t.participantId} | ${t.score} | ${hard} | ${reason} |`);
28
+ lines.push(`| ${i + 1} | ${t.participantId} | ${tier} | ${t.score} | ${reason} |`);
23
29
  });
24
30
 
25
31
  lines.push('');
@@ -0,0 +1,211 @@
1
+ // src/cli/renderers/typing-profile-core.js
2
+ //
3
+ // Pure drawing core for per-participant typing-speed bar-chart images — no
4
+ // Node APIs, so a browser demo can bundle it directly (0.7.2-style
5
+ // extraction from cli/renderers/typing-profile.js, which is now a thin fs
6
+ // wrapper around this module: it acquires node-canvas, calls
7
+ // drawTypingProfile per participant, and writes the returned canvas to a
8
+ // PNG). Mirrors the session-timeline-core.js / trajectories-core.js split.
9
+ //
10
+ // drawTypingProfile(p, config, createCanvas) draws one participant's typing
11
+ // speed bar chart — one bar per trial's chars/sec, with a horizontal
12
+ // threshold line and bars above threshold colored red — and returns the
13
+ // drawn canvas (or null if there's nothing to draw: no trials, or no trial
14
+ // has either a typed speed or a paste event). createCanvas is injected so
15
+ // this module never imports the `canvas` package directly — a browser
16
+ // caller can pass a document-canvas-backed factory instead.
17
+
18
+ const BAR_WIDTH = 30;
19
+ const BAR_GAP = 8;
20
+ const CHART_HEIGHT = 200;
21
+ const TOP_PAD = 50;
22
+ const BOTTOM_PAD = 50;
23
+ const LEFT_PAD = 60;
24
+
25
+ // ── Per-participant renderer ─────────────────────────────────────────────
26
+ // Returns the drawn canvas, or null if there's no data to draw (caller
27
+ // decides what "no data" means — the fs wrapper skips the PNG write).
28
+ export function drawTypingProfile(p, config, createCanvas) {
29
+ const trials = p.trials;
30
+ if (trials.length === 0) return null;
31
+
32
+ // Per-participant threshold line: prefer the typing-speed cutoff the library
33
+ // actually screened this participant with (saved in session.config.thresholds)
34
+ // so the red "fast typing" line matches summary.csv's count; fall back to the
35
+ // CLI/default. A strict participant (8 cps) otherwise shows a 10 cps line.
36
+ const threshold =
37
+ p.session?.config?.thresholds?.typingSpeedCps ?? config.typingSpeedThreshold_cps ?? 10;
38
+
39
+ // Per-trial state. We distinguish three cases:
40
+ // "typed" — charsPerSec is a real number, draw a normal bar
41
+ // "paste-only" — charsPerSec is null but there's a paste event; draw a
42
+ // grey hatched marker so the absence is legible. Single
43
+ // paste fires one input event → editTimestamps.length < 2
44
+ // → computeTypingSpeed returns null. Without this, a
45
+ // pasted response looks identical to a very slow one.
46
+ // "no-data" — neither, leave blank
47
+ const kinds = trials.map(t => {
48
+ if (typeof t.charsPerSec === 'number') return 'typed';
49
+ if ((t.pasteEvents || []).length > 0) return 'paste-only';
50
+ return 'no-data';
51
+ });
52
+ const speeds = trials.map(t => (typeof t.charsPerSec === 'number' ? t.charsPerSec : 0));
53
+ if (kinds.every(k => k === 'no-data')) return null;
54
+
55
+ const canvasW = LEFT_PAD + trials.length * (BAR_WIDTH + BAR_GAP) + 20;
56
+ const canvasH = TOP_PAD + CHART_HEIGHT + BOTTOM_PAD;
57
+ const canvas = createCanvas(canvasW, canvasH);
58
+ const ctx = canvas.getContext('2d');
59
+
60
+ // Background
61
+ ctx.fillStyle = '#ffffff';
62
+ ctx.fillRect(0, 0, canvasW, canvasH);
63
+
64
+ // Title
65
+ ctx.fillStyle = '#333';
66
+ ctx.font = 'bold 14px sans-serif';
67
+ ctx.fillText(`Typing Speed Profile — ${p.participantId}`, LEFT_PAD, 20);
68
+ ctx.font = '10px sans-serif';
69
+ ctx.fillStyle = '#666';
70
+ ctx.fillText(`Threshold: ${threshold} chars/sec`, LEFT_PAD, 35);
71
+
72
+ // Legend — three states documented in the `kinds` comment above.
73
+ const legendY = 35;
74
+ const legendX0 = LEFT_PAD + 180;
75
+ ctx.fillStyle = '#2196F3';
76
+ ctx.fillRect(legendX0, legendY - 8, 12, 10);
77
+ ctx.fillStyle = '#666';
78
+ ctx.fillText('typed', legendX0 + 16, legendY);
79
+ ctx.fillStyle = '#f44336';
80
+ ctx.fillRect(legendX0 + 60, legendY - 8, 12, 10);
81
+ ctx.fillStyle = '#666';
82
+ ctx.fillText('> threshold', legendX0 + 76, legendY);
83
+ // Hatched swatch for paste-only
84
+ drawHatchedRect(ctx, legendX0 + 150, legendY - 8, 12, 10);
85
+ ctx.fillStyle = '#666';
86
+ ctx.fillText('paste (P)', legendX0 + 166, legendY);
87
+
88
+ // Y-axis scaling
89
+ const maxSpeed = Math.max(...speeds, threshold * 1.5);
90
+ const yScale = CHART_HEIGHT / maxSpeed;
91
+
92
+ // Y-axis labels
93
+ ctx.fillStyle = '#666';
94
+ ctx.font = '9px sans-serif';
95
+ const yTicks = 5;
96
+ for (let t = 0; t <= yTicks; t++) {
97
+ const val = (maxSpeed / yTicks) * t;
98
+ const y = TOP_PAD + CHART_HEIGHT - val * yScale;
99
+ ctx.fillText(`${val.toFixed(1)}`, 5, y + 3);
100
+ // Grid line
101
+ ctx.strokeStyle = '#eee';
102
+ ctx.lineWidth = 0.5;
103
+ ctx.beginPath();
104
+ ctx.moveTo(LEFT_PAD, y);
105
+ ctx.lineTo(canvasW - 10, y);
106
+ ctx.stroke();
107
+ }
108
+
109
+ // Threshold line
110
+ const threshY = TOP_PAD + CHART_HEIGHT - threshold * yScale;
111
+ ctx.strokeStyle = '#ff0000';
112
+ ctx.lineWidth = 1.5;
113
+ ctx.setLineDash([5, 3]);
114
+ ctx.beginPath();
115
+ ctx.moveTo(LEFT_PAD, threshY);
116
+ ctx.lineTo(canvasW - 10, threshY);
117
+ ctx.stroke();
118
+ ctx.setLineDash([]);
119
+
120
+ // Bars
121
+ const PASTE_BAR_H = CHART_HEIGHT * 0.2; // Paste-only marker height: fixed 20% of chart.
122
+ for (let i = 0; i < trials.length; i++) {
123
+ const kind = kinds[i];
124
+ const speed = speeds[i];
125
+ const x = LEFT_PAD + i * (BAR_WIDTH + BAR_GAP);
126
+
127
+ if (kind === 'paste-only') {
128
+ // Fixed-height hatched grey bar + "P" marker. The height is arbitrary
129
+ // — the point is visible presence, not magnitude (we have no CPS to
130
+ // show). Sits at the bottom like a normal bar.
131
+ const y = TOP_PAD + CHART_HEIGHT - PASTE_BAR_H;
132
+ drawHatchedRect(ctx, x, y, BAR_WIDTH, PASTE_BAR_H);
133
+ ctx.strokeStyle = '#888';
134
+ ctx.lineWidth = 0.5;
135
+ ctx.strokeRect(x, y, BAR_WIDTH, PASTE_BAR_H);
136
+ // "P" marker above the bar
137
+ ctx.fillStyle = '#555';
138
+ ctx.font = 'bold 11px sans-serif';
139
+ ctx.fillText('P', x + BAR_WIDTH / 2 - 3, y - 3);
140
+ } else if (kind === 'typed') {
141
+ const barH = speed * yScale;
142
+ const y = TOP_PAD + CHART_HEIGHT - barH;
143
+ ctx.fillStyle = speed > threshold ? '#f44336' : '#2196F3';
144
+ ctx.fillRect(x, y, BAR_WIDTH, barH);
145
+ ctx.strokeStyle = '#333';
146
+ ctx.lineWidth = 0.5;
147
+ ctx.strokeRect(x, y, BAR_WIDTH, barH);
148
+ if (speed > 0) {
149
+ ctx.fillStyle = '#fff';
150
+ ctx.font = 'bold 9px sans-serif';
151
+ ctx.fillText(speed.toFixed(1), x + 3, y + 12);
152
+ }
153
+ // Mixed: typed AND pasted in the same trial. The bar shows the typing
154
+ // rate (legitimate signal); the "P" glyph above it flags that *some*
155
+ // of the response came in via paste. Distinct from pure paste-only
156
+ // (which is a hatched bar, no colored fill).
157
+ if ((trials[i].pasteEvents || []).length > 0) {
158
+ ctx.fillStyle = '#555';
159
+ ctx.font = 'bold 11px sans-serif';
160
+ ctx.fillText('P', x + BAR_WIDTH / 2 - 3, y - 3);
161
+ }
162
+ }
163
+ // 'no-data' → render nothing (blank column)
164
+
165
+ // Trial label (always drawn, even for blank columns)
166
+ const trialId = trials[i].trialId || trials[i].ruleId || `T${i + 1}`;
167
+ ctx.fillStyle = '#333';
168
+ ctx.font = '8px sans-serif';
169
+ ctx.save();
170
+ ctx.translate(x + BAR_WIDTH / 2, TOP_PAD + CHART_HEIGHT + 10);
171
+ ctx.rotate(Math.PI / 4);
172
+ ctx.fillText(trialId.substring(0, 12), 0, 0);
173
+ ctx.restore();
174
+ }
175
+
176
+ // Baseline
177
+ ctx.strokeStyle = '#333';
178
+ ctx.lineWidth = 1;
179
+ ctx.beginPath();
180
+ ctx.moveTo(LEFT_PAD, TOP_PAD + CHART_HEIGHT);
181
+ ctx.lineTo(canvasW - 10, TOP_PAD + CHART_HEIGHT);
182
+ ctx.stroke();
183
+
184
+ return canvas;
185
+ }
186
+
187
+ // Cross-hatched fill for paste-only markers. Uses a light grey background
188
+ // with diagonal strokes so the bar reads as "distinct category, not zero".
189
+ function drawHatchedRect(ctx, x, y, w, h) {
190
+ ctx.save();
191
+ ctx.fillStyle = '#e8e8e8';
192
+ ctx.fillRect(x, y, w, h);
193
+ ctx.beginPath();
194
+ ctx.rect(x, y, w, h);
195
+ ctx.clip();
196
+ ctx.strokeStyle = '#888';
197
+ ctx.lineWidth = 0.8;
198
+ const step = 5;
199
+ for (let d = -h; d < w + h; d += step) {
200
+ ctx.beginPath();
201
+ ctx.moveTo(x + d, y);
202
+ ctx.lineTo(x + d + h, y + h);
203
+ ctx.stroke();
204
+ }
205
+ ctx.restore();
206
+ }
207
+
208
+ // Used by the fs wrapper to build the per-participant PNG filename.
209
+ export function sanitize(name) {
210
+ return name.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 40);
211
+ }
@@ -1,171 +1,26 @@
1
1
  // src/cli/renderers/typing-profile.js
2
- // Renders per-participant typing speed bar chart using node-canvas.
3
- // Each bar = one trial's chars/sec. A horizontal threshold line shows
4
- // the configured CPS threshold. Bars above threshold are colored red.
5
-
2
+ // Thin fs wrapper around the pure drawing core (typing-profile-core.js).
3
+ // The core takes an injected createCanvas and returns the drawn canvas (or
4
+ // null when there's nothing to draw); this wrapper owns node-canvas
5
+ // acquisition and PNG serialization so the core can also run in a browser
6
+ // context (demo plot adapter) with document canvases.
6
7
  import { writeFileSync } from 'fs';
7
8
  import { join } from 'path';
8
-
9
- const BAR_WIDTH = 30;
10
- const BAR_GAP = 8;
11
- const CHART_HEIGHT = 200;
12
- const TOP_PAD = 50;
13
- const BOTTOM_PAD = 50;
14
- const LEFT_PAD = 60;
15
-
9
+ import { drawTypingProfile, sanitize } from './typing-profile-core.js';
10
+ // Load-bearing re-export: drawTypingProfile serves this wrapper's own loop
11
+ // below and the future browser demo plot adapter — mirrors
12
+ // session-timeline.js / trajectories.js. (renderTypingProfiles remains the
13
+ // only symbol external consumers import from this file — see
14
+ // tests/cli/renderers.test.js and src/cli/report.js.)
15
+ export { drawTypingProfile };
16
+
17
+ // ── Public API ───────────────────────────────────────────────────────────
16
18
  export async function renderTypingProfiles(participants, config) {
17
19
  const { createCanvas } = await import('canvas');
18
- const threshold = config.typingSpeedThreshold_cps || 10;
19
20
 
20
21
  for (const p of participants) {
21
- const trials = p.trials;
22
- if (trials.length === 0) continue;
23
-
24
- // Per-trial state. We distinguish three cases:
25
- // "typed" — charsPerSec is a real number, draw a normal bar
26
- // "paste-only" — charsPerSec is null but there's a paste event; draw a
27
- // grey hatched marker so the absence is legible. Single
28
- // paste fires one input event → editTimestamps.length < 2
29
- // → computeTypingSpeed returns null. Without this, a
30
- // pasted response looks identical to a very slow one.
31
- // "no-data" — neither, leave blank
32
- const kinds = trials.map(t => {
33
- if (typeof t.charsPerSec === 'number') return 'typed';
34
- if ((t.pasteEvents || []).length > 0) return 'paste-only';
35
- return 'no-data';
36
- });
37
- const speeds = trials.map(t => (typeof t.charsPerSec === 'number' ? t.charsPerSec : 0));
38
- if (kinds.every(k => k === 'no-data')) continue;
39
-
40
- const canvasW = LEFT_PAD + trials.length * (BAR_WIDTH + BAR_GAP) + 20;
41
- const canvasH = TOP_PAD + CHART_HEIGHT + BOTTOM_PAD;
42
- const canvas = createCanvas(canvasW, canvasH);
43
- const ctx = canvas.getContext('2d');
44
-
45
- // Background
46
- ctx.fillStyle = '#ffffff';
47
- ctx.fillRect(0, 0, canvasW, canvasH);
48
-
49
- // Title
50
- ctx.fillStyle = '#333';
51
- ctx.font = 'bold 14px sans-serif';
52
- ctx.fillText(`Typing Speed Profile — ${p.participantId}`, LEFT_PAD, 20);
53
- ctx.font = '10px sans-serif';
54
- ctx.fillStyle = '#666';
55
- ctx.fillText(`Threshold: ${threshold} chars/sec`, LEFT_PAD, 35);
56
-
57
- // Legend — three states documented in the `kinds` comment above.
58
- const legendY = 35;
59
- const legendX0 = LEFT_PAD + 180;
60
- ctx.fillStyle = '#2196F3';
61
- ctx.fillRect(legendX0, legendY - 8, 12, 10);
62
- ctx.fillStyle = '#666';
63
- ctx.fillText('typed', legendX0 + 16, legendY);
64
- ctx.fillStyle = '#f44336';
65
- ctx.fillRect(legendX0 + 60, legendY - 8, 12, 10);
66
- ctx.fillStyle = '#666';
67
- ctx.fillText('> threshold', legendX0 + 76, legendY);
68
- // Hatched swatch for paste-only
69
- drawHatchedRect(ctx, legendX0 + 150, legendY - 8, 12, 10);
70
- ctx.fillStyle = '#666';
71
- ctx.fillText('paste (P)', legendX0 + 166, legendY);
72
-
73
- // Y-axis scaling
74
- const maxSpeed = Math.max(...speeds, threshold * 1.5);
75
- const yScale = CHART_HEIGHT / maxSpeed;
76
-
77
- // Y-axis labels
78
- ctx.fillStyle = '#666';
79
- ctx.font = '9px sans-serif';
80
- const yTicks = 5;
81
- for (let t = 0; t <= yTicks; t++) {
82
- const val = (maxSpeed / yTicks) * t;
83
- const y = TOP_PAD + CHART_HEIGHT - val * yScale;
84
- ctx.fillText(`${val.toFixed(1)}`, 5, y + 3);
85
- // Grid line
86
- ctx.strokeStyle = '#eee';
87
- ctx.lineWidth = 0.5;
88
- ctx.beginPath();
89
- ctx.moveTo(LEFT_PAD, y);
90
- ctx.lineTo(canvasW - 10, y);
91
- ctx.stroke();
92
- }
93
-
94
- // Threshold line
95
- const threshY = TOP_PAD + CHART_HEIGHT - threshold * yScale;
96
- ctx.strokeStyle = '#ff0000';
97
- ctx.lineWidth = 1.5;
98
- ctx.setLineDash([5, 3]);
99
- ctx.beginPath();
100
- ctx.moveTo(LEFT_PAD, threshY);
101
- ctx.lineTo(canvasW - 10, threshY);
102
- ctx.stroke();
103
- ctx.setLineDash([]);
104
-
105
- // Bars
106
- const PASTE_BAR_H = CHART_HEIGHT * 0.2; // Paste-only marker height: fixed 20% of chart.
107
- for (let i = 0; i < trials.length; i++) {
108
- const kind = kinds[i];
109
- const speed = speeds[i];
110
- const x = LEFT_PAD + i * (BAR_WIDTH + BAR_GAP);
111
-
112
- if (kind === 'paste-only') {
113
- // Fixed-height hatched grey bar + "P" marker. The height is arbitrary
114
- // — the point is visible presence, not magnitude (we have no CPS to
115
- // show). Sits at the bottom like a normal bar.
116
- const y = TOP_PAD + CHART_HEIGHT - PASTE_BAR_H;
117
- drawHatchedRect(ctx, x, y, BAR_WIDTH, PASTE_BAR_H);
118
- ctx.strokeStyle = '#888';
119
- ctx.lineWidth = 0.5;
120
- ctx.strokeRect(x, y, BAR_WIDTH, PASTE_BAR_H);
121
- // "P" marker above the bar
122
- ctx.fillStyle = '#555';
123
- ctx.font = 'bold 11px sans-serif';
124
- ctx.fillText('P', x + BAR_WIDTH / 2 - 3, y - 3);
125
- } else if (kind === 'typed') {
126
- const barH = speed * yScale;
127
- const y = TOP_PAD + CHART_HEIGHT - barH;
128
- ctx.fillStyle = speed > threshold ? '#f44336' : '#2196F3';
129
- ctx.fillRect(x, y, BAR_WIDTH, barH);
130
- ctx.strokeStyle = '#333';
131
- ctx.lineWidth = 0.5;
132
- ctx.strokeRect(x, y, BAR_WIDTH, barH);
133
- if (speed > 0) {
134
- ctx.fillStyle = '#fff';
135
- ctx.font = 'bold 9px sans-serif';
136
- ctx.fillText(speed.toFixed(1), x + 3, y + 12);
137
- }
138
- // Mixed: typed AND pasted in the same trial. The bar shows the typing
139
- // rate (legitimate signal); the "P" glyph above it flags that *some*
140
- // of the response came in via paste. Distinct from pure paste-only
141
- // (which is a hatched bar, no colored fill).
142
- if ((trials[i].pasteEvents || []).length > 0) {
143
- ctx.fillStyle = '#555';
144
- ctx.font = 'bold 11px sans-serif';
145
- ctx.fillText('P', x + BAR_WIDTH / 2 - 3, y - 3);
146
- }
147
- }
148
- // 'no-data' → render nothing (blank column)
149
-
150
- // Trial label (always drawn, even for blank columns)
151
- const trialId = trials[i].trialId || trials[i].ruleId || `T${i + 1}`;
152
- ctx.fillStyle = '#333';
153
- ctx.font = '8px sans-serif';
154
- ctx.save();
155
- ctx.translate(x + BAR_WIDTH / 2, TOP_PAD + CHART_HEIGHT + 10);
156
- ctx.rotate(Math.PI / 4);
157
- ctx.fillText(trialId.substring(0, 12), 0, 0);
158
- ctx.restore();
159
- }
160
-
161
- // Baseline
162
- ctx.strokeStyle = '#333';
163
- ctx.lineWidth = 1;
164
- ctx.beginPath();
165
- ctx.moveTo(LEFT_PAD, TOP_PAD + CHART_HEIGHT);
166
- ctx.lineTo(canvasW - 10, TOP_PAD + CHART_HEIGHT);
167
- ctx.stroke();
168
-
22
+ const canvas = drawTypingProfile(p, config, createCanvas);
23
+ if (!canvas) continue;
169
24
  const buf = canvas.toBuffer('image/png');
170
25
  const filename = `typing_profile_${sanitize(p.participantId)}.png`;
171
26
  writeFileSync(join(config.outputDir, 'images', filename), buf);
@@ -173,28 +28,3 @@ export async function renderTypingProfiles(participants, config) {
173
28
 
174
29
  console.log(` typing profiles — rendered`);
175
30
  }
176
-
177
- function sanitize(name) {
178
- return name.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 40);
179
- }
180
-
181
- // Cross-hatched fill for paste-only markers. Uses a light grey background
182
- // with diagonal strokes so the bar reads as "distinct category, not zero".
183
- function drawHatchedRect(ctx, x, y, w, h) {
184
- ctx.save();
185
- ctx.fillStyle = '#e8e8e8';
186
- ctx.fillRect(x, y, w, h);
187
- ctx.beginPath();
188
- ctx.rect(x, y, w, h);
189
- ctx.clip();
190
- ctx.strokeStyle = '#888';
191
- ctx.lineWidth = 0.8;
192
- const step = 5;
193
- for (let d = -h; d < w + h; d += step) {
194
- ctx.beginPath();
195
- ctx.moveTo(x + d, y);
196
- ctx.lineTo(x + d + h, y + h);
197
- ctx.stroke();
198
- }
199
- ctx.restore();
200
- }
package/src/cli/report.js CHANGED
@@ -12,6 +12,7 @@ import { ingest } from './ingest.js';
12
12
  import { computeSummary } from './analyzers/summary.js';
13
13
  import { detectEdgeExits } from './analyzers/edge-exit.js';
14
14
  import { rankTriage } from './analyzers/triage.js';
15
+ import { applyPhaseScope, describePhaseScope, findUnmatchedPhaseScopePhases } from './analyzers/phase-scope.js';
15
16
  import { VERSION } from '../shared/constants.js';
16
17
 
17
18
  export async function run(args) {
@@ -44,19 +45,43 @@ export async function run(args) {
44
45
  process.exit(1);
45
46
  }
46
47
 
47
- // 3. Analyze — compute summaries, detect edge exits, rank by triage priority
48
- const summaries = computeSummary(participants, config);
49
- const edgeExits = detectEdgeExits(participants, config);
48
+ // 3. Analyze — compute summaries, detect edge exits, rank by triage priority.
49
+ // config.phaseScope (0.6.1) filters which trials feed the analyzers so
50
+ // scores can honor pre-registered phase scoping; renderers below still get
51
+ // the FULL participants, so the visual evidence is never hidden.
52
+ const scoredParticipants = applyPhaseScope(participants, config.phaseScope);
53
+ if (scoredParticipants !== participants) {
54
+ console.log(`\nPhase scope active (${describePhaseScope(config.phaseScope)}):`);
55
+ console.log(` scores count per-trial signals inside the scope only; ambient session`);
56
+ console.log(` signals (sidebar, shortcuts, viewport shifts, zoom) stay session-wide.`);
57
+ // A configured phase name that matches no trial is almost always a typo. For
58
+ // an `include`, it silently filters every trial and reports the cohort clean.
59
+ const unmatched = findUnmatchedPhaseScopePhases(participants, config.phaseScope);
60
+ if (unmatched.length > 0) {
61
+ console.warn(`[cyborg-hunter] phaseScope names match NO trial in the data: ` +
62
+ `${unmatched.join(', ')} — likely a typo; scored trials may be wrongly emptied.`);
63
+ }
64
+ }
65
+ const summaries = computeSummary(scoredParticipants, config);
66
+ const edgeExits = detectEdgeExits(scoredParticipants, config);
50
67
  const triage = rankTriage(summaries, edgeExits, config);
51
68
 
52
69
  const flaggedHard = triage.filter(t => t.hardTriggered).length;
53
70
  const flaggedSoft = triage.filter(t => !t.hardTriggered && t.softFlagged).length;
54
71
  const clean = triage.length - flaggedHard - flaggedSoft;
55
72
 
73
+ // Two DIFFERENT numbers live in this report and used to share the word
74
+ // "flagged": the tier counts below come from the LIBRARY's two-tier
75
+ // screening (hard count thresholds / soft score vs its threshold), while
76
+ // triage.md is ORDERED by the CLI's separate composite triage score
77
+ // (5×paste + 5×copy + 3×sidebar + 1×tab-away) within each tier. Label both
78
+ // explicitly so the console summary can't be read as "top N of triage.md".
56
79
  console.log(`\nAnalyzing...`);
57
- console.log(` Participants flagged (hard signals): ${flaggedHard}`);
58
- console.log(` Participants flagged (soft score): ${flaggedSoft}`);
59
- console.log(` Participants clean: ${clean}`);
80
+ console.log(` Hard-flagged (hard signal crossed its count threshold): ${flaggedHard}`);
81
+ console.log(` Soft-flagged (library soft score >= its threshold): ${flaggedSoft}`);
82
+ console.log(` Clean: ${clean}`);
83
+ console.log(` Triage.md orders tier-first (hard > soft > clean), then by the CLI`);
84
+ console.log(` triage score (5xpaste + 5xcopy + 3xsidebar + 1xtab-away) within a tier.`);
60
85
 
61
86
  // 4. Render outputs
62
87
  console.log(`\nRendering...`);
@@ -92,19 +117,28 @@ export async function run(args) {
92
117
 
93
118
  if (canvas) {
94
119
  const { renderTrajectories } = await import('./renderers/trajectories.js');
95
- const { renderTabTimelines } = await import('./renderers/tab-timeline.js');
120
+ const { renderSessionTimelines } = await import('./renderers/session-timeline.js');
96
121
  const { renderTypingProfiles } = await import('./renderers/typing-profile.js');
97
122
 
98
123
  // Create images/ subdirectory for visual outputs
99
124
  mkdirSync(join(config.outputDir, 'images'), { recursive: true });
100
125
 
101
126
  await renderTrajectories(participants, triage, config);
102
- await renderTabTimelines(participants, config);
127
+ await renderSessionTimelines(participants, config);
103
128
  await renderTypingProfiles(participants, config);
104
129
  visualsRendered = true;
105
130
  }
106
131
  }
107
132
 
133
+ // Replay assets — per-participant JSONP models under replay/, lazy-loaded
134
+ // by the HTML report. Size is printed because dom-tier models dominate
135
+ // the report's disk footprint.
136
+ const { renderReplayAssets } = await import('./renderers/replay-assets.js');
137
+ const replayAssets = renderReplayAssets(participants, config.outputDir);
138
+ if (replayAssets.count > 0) {
139
+ console.log(` replay/ — ${replayAssets.count} session replays (${(replayAssets.totalBytes / 1024 / 1024).toFixed(1)} MB)`);
140
+ }
141
+
108
142
  // HTML index page — references images/ folder (not base64-embedded)
109
143
  const { renderHtmlIndex } = await import('./renderers/html-index.js');
110
144
  await renderHtmlIndex(summaries, triage, participants, config, visualsRendered);