cyborg-hunter 0.5.0 → 0.7.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 (48) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/CITATION.cff +29 -0
  3. package/LICENSE +21 -0
  4. package/README.md +78 -21
  5. package/package.json +10 -3
  6. package/src/cli/analyzers/edge-exit.js +4 -1
  7. package/src/cli/analyzers/phase-scope.js +83 -0
  8. package/src/cli/analyzers/summary.js +161 -28
  9. package/src/cli/analyzers/triage.js +59 -27
  10. package/src/cli/config.js +26 -1
  11. package/src/cli/ingest.js +623 -41
  12. package/src/cli/init.js +1 -1
  13. package/src/cli/renderers/event-log.js +18 -19
  14. package/src/cli/renderers/extensions.js +12 -3
  15. package/src/cli/renderers/html-index.js +163 -24
  16. package/src/cli/renderers/replay-assets.js +177 -0
  17. package/src/cli/renderers/replay-viewer.client.js +1022 -0
  18. package/src/cli/renderers/session-timeline.js +917 -0
  19. package/src/cli/renderers/summary-csv.js +5 -0
  20. package/src/cli/renderers/trajectories.js +68 -7
  21. package/src/cli/renderers/triage-md.js +10 -4
  22. package/src/cli/renderers/typing-profile.js +7 -1
  23. package/src/cli/report.js +42 -8
  24. package/src/core/monitor.js +60 -7
  25. package/src/core/scoring.js +11 -2
  26. package/src/core/signals/browser.js +51 -18
  27. package/src/core/signals/clipboard.js +10 -2
  28. package/src/core/signals/dom-protection.js +9 -0
  29. package/src/core/signals/focus.js +16 -2
  30. package/src/jspsych/extension-cyborg-hunter-replay.js +135 -0
  31. package/src/jspsych/extension-cyborg-hunter.js +9 -2
  32. package/src/jspsych/extension-guard-friction.js +32 -12
  33. package/src/jspsych/extension-guard-honeypot.js +25 -1
  34. package/src/replay/capture-dom.js +575 -0
  35. package/src/replay/capture-trace.js +468 -0
  36. package/src/replay/index.js +104 -0
  37. package/src/replay/persistence.js +141 -0
  38. package/src/replay/recorder.js +315 -0
  39. package/src/replay/serializer.js +119 -0
  40. package/src/shared/constants.js +12 -6
  41. package/src/shared/schema.js +5 -0
  42. package/src/shared/validation.js +55 -0
  43. package/dist/cyborg-hunter.esm.js +0 -1527
  44. package/dist/cyborg-hunter.min.js +0 -6
  45. package/dist/extension-cyborg-hunter.js +0 -1
  46. package/dist/extension-guard-friction.js +0 -36
  47. package/dist/extension-guard-honeypot.js +0 -1
  48. package/src/cli/renderers/tab-timeline.js +0 -149
@@ -42,6 +42,11 @@ const COLUMNS = [
42
42
  ['zoom_change_count', (s, t) => s.zoomChangeCount || 0],
43
43
  ['dev_tools_event_count', (s, t) => s.devToolsEventCount || 0],
44
44
  ['authoritative_soft_score', (s, t) => s.authoritativeSoftScore ?? ''],
45
+ // Guard-honeypot self-disclosure. Three states: 'YES' = box ticked,
46
+ // 'no' = honeypot present but unticked (negative evidence), '' = honeypot
47
+ // extension not used for this participant (honeypotAiUse is null).
48
+ ['honeypot_ai_use', (s, t) => s.honeypotAiUse == null ? '' : (s.honeypotAiUse ? 'YES' : 'no')],
49
+ ['honeypot_ai_report', (s, t) => s.honeypotAiReport || ''],
45
50
  ];
46
51
 
47
52
  export async function renderSummaryCSV(summaries, triage, config) {
@@ -1,6 +1,6 @@
1
1
  // Mouse trajectory rendering (node-canvas). One PNG per participant, grid of
2
2
  // per-trial panels. Mirrors the visual conventions of the Python reference
3
- // pipeline in card-games/rule-gallery/analysis/trajectories.py.
3
+ // pipeline used during development (not included in this repo).
4
4
  //
5
5
  // Per-panel visual elements:
6
6
  // - Time-colored path (blue → red gradient over the trial's duration)
@@ -16,13 +16,14 @@
16
16
 
17
17
  import { writeFileSync } from 'fs';
18
18
  import { join } from 'path';
19
+ import { ruleChronologicalCompare } from '../ingest.js';
19
20
 
20
21
  // Panel dimensions (pixels).
21
22
  const PANEL_W = 400;
22
23
  const PANEL_H = 300;
23
24
  const PANEL_PAD = 40;
24
25
  const TITLE_HEIGHT = 40;
25
- const HEADER_HEIGHT = 80; // accommodates 2-line legend
26
+ const HEADER_HEIGHT = 96; // accommodates 3-line legend
26
27
  const PANEL_MARGIN = 15; // inset between panel border and content area
27
28
 
28
29
  // Color palette. Hoisted from inline string literals to give the rendering
@@ -50,12 +51,50 @@ const COLORS = {
50
51
  pauseCircle: '#ccc',
51
52
  };
52
53
 
54
+ // Panel-background tints by trial phase (0.6.1, retro item 9) — same palette
55
+ // as the session-timeline phase strip (session-timeline.js C.phase*), so a
56
+ // panel's tint and the timeline band for the same phase read as one system.
57
+ // Trials without a recognized phase keep the neutral panelBg.
58
+ const PHASE_TINTS = {
59
+ gallery: '#ffe0b2', // peach — matches C.phaseGallery
60
+ post_gallery_query: '#e1bee7', // light purple — matches C.phaseTyping
61
+ typing: '#e1bee7',
62
+ classification: '#bbdefb', // light blue — matches C.phaseClass
63
+ end_requery: '#eceff1', // bluish grey — matches C.phasePre/Post
64
+ };
65
+
66
+ // Orders a participant's trials for the panel grid (0.6.1, retro item 3).
67
+ // config.trajectoryDisplayOrder:
68
+ // 'rule' (default) chronological by rule — (rulePosition, phase-rank,
69
+ // trialNumber), the order each rule was actually experienced.
70
+ // Same comparator the ingest uses when merging phaseTrials, so
71
+ // already-sorted data keeps its order (stable sort).
72
+ // 'time' wall-clock trial timestamps (fallback: trialNumber)
73
+ // 'insertion' raw ingest order (pre-0.6.1 behavior)
74
+ // Exported for tests.
75
+ export function orderTrials(trials, config) {
76
+ const mode = config.trajectoryDisplayOrder || 'rule';
77
+ if (mode === 'insertion') return trials;
78
+ const ordered = trials.slice();
79
+ if (mode === 'time') {
80
+ ordered.sort((a, b) => {
81
+ const at = a?.timestamp ? Date.parse(a.timestamp) : NaN;
82
+ const bt = b?.timestamp ? Date.parse(b.timestamp) : NaN;
83
+ if (Number.isFinite(at) && Number.isFinite(bt) && at !== bt) return at - bt;
84
+ return (a.trialNumber ?? 0) - (b.trialNumber ?? 0);
85
+ });
86
+ } else {
87
+ ordered.sort(ruleChronologicalCompare);
88
+ }
89
+ return ordered;
90
+ }
91
+
53
92
  export async function renderTrajectories(participants, triage, config) {
54
93
  const { createCanvas } = await import('canvas');
55
94
  const triageMap = new Map(triage.map(t => [t.participantId, t]));
56
95
 
57
96
  for (const p of participants) {
58
- const trials = p.trials;
97
+ const trials = orderTrials(p.trials, config);
59
98
  if (trials.length === 0) continue;
60
99
 
61
100
  // Compute grid layout
@@ -90,6 +129,10 @@ export async function renderTrajectories(participants, triage, config) {
90
129
  'Teal outer rect=screen Purple dashed rect=browser window Red panel frame=hard signal triggered on this trial',
91
130
  PANEL_PAD, 64
92
131
  );
132
+ ctx.fillText(
133
+ 'Panel tint=phase: peach gallery, purple typing/query, blue classification, grey re-query (neutral = unphased) — matches the session-timeline strip',
134
+ PANEL_PAD, 80
135
+ );
93
136
 
94
137
  // session.windowPositions is the 2-second-poll-plus-resize-event record
95
138
  // from core/signals/browser.js. Per-trial geometry uses the sample whose
@@ -128,7 +171,10 @@ export async function renderTrajectories(participants, triage, config) {
128
171
  function renderTrialPanel(ctx, trial, x0, y0, config, metadata) {
129
172
  const mouse = trial.mouseEvents || [];
130
173
  const tabAways = trial.tabAwayEvents || [];
131
- const trialId = trial.trialId || trial.ruleId || '?';
174
+ // String() guard: a numeric trialId/ruleId (from a hand-edited payload or a
175
+ // dynamicTyping CSV) would otherwise throw on .substring() below and, with no
176
+ // per-participant boundary, abort every remaining visual.
177
+ const trialId = String(trial.trialId || trial.ruleId || '?');
132
178
  const rt = (trial.duration_ms || trial.responseTime_ms || 0) / 1000;
133
179
  const moves = mouse.filter(e => e.type === 'move');
134
180
  const downs = mouse.filter(e => e.type === 'down');
@@ -141,8 +187,9 @@ function renderTrialPanel(ctx, trial, x0, y0, config, metadata) {
141
187
  ctx.lineWidth = anyHardHit ? 3 : 1;
142
188
  ctx.strokeRect(x0, y0 + TITLE_HEIGHT, PANEL_W, PANEL_H);
143
189
 
144
- // Panel background
145
- ctx.fillStyle = COLORS.panelBg;
190
+ // Panel background — tinted by trial phase (see PHASE_TINTS), neutral for
191
+ // unphased trials.
192
+ ctx.fillStyle = PHASE_TINTS[trial.phase] || COLORS.panelBg;
146
193
  ctx.fillRect(x0 + 1, y0 + TITLE_HEIGHT + 1, PANEL_W - 2, PANEL_H - 2);
147
194
 
148
195
  // Title
@@ -406,7 +453,21 @@ function sanitize(name) {
406
453
  // fall through its compatibility/auto-fit branches honestly.
407
454
  export function pickWindowGeometryForTrial(trial, windowPositions) {
408
455
  if (!Array.isArray(windowPositions) || windowPositions.length === 0) return null;
409
- const anchor = trial?.trialStart_perfNow;
456
+ // Time anchor for matching the right windowPositions sample.
457
+ //
458
+ // jsPsych extension data carries `trialStart_perfNow` (set by the wrapper's
459
+ // on_load). Raw-DOM extension users save
460
+ // `startTime` directly from `performance.now()` at trial start. Both are in
461
+ // the same reference frame as `windowPositions[].t` (performance.now()), so
462
+ // either works.
463
+ //
464
+ // Without this fallback, raw-DOM users see auto-fit or stale session-level
465
+ // window dimensions — symptom: the dashed "window" box in the trajectory
466
+ // PNG is sized for the pre-fullscreen viewport even when the trial happened
467
+ // in fullscreen, so mouse dots plot outside the box.
468
+ const anchor = (typeof trial?.trialStart_perfNow === 'number')
469
+ ? trial.trialStart_perfNow
470
+ : (typeof trial?.startTime === 'number' ? trial.startTime : null);
410
471
  if (typeof anchor !== 'number') return null;
411
472
 
412
473
  // Selection strategy (in priority order):
@@ -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('');
@@ -15,12 +15,18 @@ const LEFT_PAD = 60;
15
15
 
16
16
  export async function renderTypingProfiles(participants, config) {
17
17
  const { createCanvas } = await import('canvas');
18
- const threshold = config.typingSpeedThreshold_cps || 10;
19
18
 
20
19
  for (const p of participants) {
21
20
  const trials = p.trials;
22
21
  if (trials.length === 0) continue;
23
22
 
23
+ // Per-participant threshold line: prefer the typing-speed cutoff the library
24
+ // actually screened this participant with (saved in session.config.thresholds)
25
+ // so the red "fast typing" line matches summary.csv's count; fall back to the
26
+ // CLI/default. A strict participant (8 cps) otherwise shows a 10 cps line.
27
+ const threshold =
28
+ p.session?.config?.thresholds?.typingSpeedCps ?? config.typingSpeedThreshold_cps ?? 10;
29
+
24
30
  // Per-trial state. We distinguish three cases:
25
31
  // "typed" — charsPerSec is a real number, draw a normal bar
26
32
  // "paste-only" — charsPerSec is null but there's a paste event; draw a
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);
@@ -7,14 +7,14 @@
7
7
 
8
8
  import { createStateMachine, deepCopy } from './state-machine.js';
9
9
  import { DEFAULT_THRESHOLDS, PRESETS, VERSION } from '../shared/constants.js';
10
- import { validateConfig } from '../shared/validation.js';
10
+ import { validateConfig, validateScoringShape } from '../shared/validation.js';
11
11
  import { computeTrialScores, shouldScreenout as _shouldScreenout } from './scoring.js';
12
12
  import { attachClipboardSignals } from './signals/clipboard.js';
13
13
  import { attachFocusSignals, attachIdleGapSignals } from './signals/focus.js';
14
14
  import { attachMouseSignals, computeMouseMetrics } from './signals/mouse.js';
15
15
  import { attachTypingSignals, attachForeignInputSignals, computeTypingSpeed } from './signals/typing.js';
16
16
  import { attachBrowserSignals, attachElementTrace } from './signals/browser.js';
17
- import { injectDecoy, removeDecoyElement } from './signals/dom-protection.js';
17
+ import { injectDecoy, removeDecoyElement, resetDecoyState } from './signals/dom-protection.js';
18
18
 
19
19
  let _activeInstance = null; // Track for idempotent init
20
20
 
@@ -88,6 +88,14 @@ export function init(userConfig) {
88
88
  var warnings = validateConfig(userConfig, KNOWN_KEYS);
89
89
  warnings.forEach(function (w) { console.warn("[cyborg-hunter] " + w); });
90
90
 
91
+ // Shape-check the MERGED scoring config: a nested typo in an override
92
+ // (e.g. countThreshhold) silently disables a screenout rule because the
93
+ // rule object is replaced wholesale and top-level key validation can't see
94
+ // inside it. Warn so the misconfiguration is visible instead of silent.
95
+ validateScoringShape(mergedScoring).forEach(function (w) {
96
+ console.warn("[cyborg-hunter] " + w);
97
+ });
98
+
91
99
  // Freeze config
92
100
  Object.freeze(config);
93
101
  Object.freeze(config.signals);
@@ -141,20 +149,32 @@ export function init(userConfig) {
141
149
 
142
150
  // ── Internal state ──
143
151
  var sm = createStateMachine();
152
+ // viewportWidthShifts is the canonical name since 0.6.1 (the signal measures
153
+ // viewport-width changes, not Web-Vitals-style layout shift). layoutShifts is
154
+ // kept as a deprecated alias pointing at the SAME array, so serialized
155
+ // reports carry both keys and downstream consumers reading either keep
156
+ // working. The alias will be removed in a future major version.
157
+ var _viewportWidthShifts = [];
144
158
  var sessionData = {
145
159
  pasteCount: 0, copyCount: 0, dropCount: 0,
146
- tabAwaySums: [], charsPerSec: [],
160
+ tabAwaySums: [], tabAwayEvents: [], charsPerSec: [],
147
161
  sidebarEvents: [], devToolsEvents: [],
148
162
  aiExtensionsFound: [], keyboardShortcuts: [],
149
163
  windowPositions: [], idleGaps: [],
150
- extensionInjections: [], layoutShifts: [],
164
+ extensionInjections: [],
165
+ viewportWidthShifts: _viewportWidthShifts,
166
+ layoutShifts: _viewportWidthShifts,
151
167
  zoomChanges: [],
152
168
  hardScore: {}, softScore: 0, trialsCompleted: 0
153
169
  };
154
170
  var trialData = null;
155
171
  var listeners = [];
156
172
  var trialListeners = [];
157
- var intervals = [];
173
+ var intervals = []; // session-scoped, cleared at destroy()
174
+ var trialIntervals = []; // trial-scoped, cleared at endTrial() AND destroy()
175
+
176
+ // Module-level decoy state (cal-{N} numbering) must restart per instance.
177
+ resetDecoyState();
158
178
 
159
179
  function transition(to) {
160
180
  if (!sm.transition(to)) {
@@ -185,11 +205,17 @@ export function init(userConfig) {
185
205
  intervals.push(id);
186
206
  }
187
207
 
208
+ function addTrialInterval(id) {
209
+ trialIntervals.push(id);
210
+ }
211
+
188
212
  function removeTrialListeners() {
189
213
  trialListeners.forEach(function (l) {
190
214
  l.target.removeEventListener(l.event, l.handler, l.options);
191
215
  });
192
216
  trialListeners = [];
217
+ trialIntervals.forEach(function (id) { clearInterval(id); });
218
+ trialIntervals = [];
193
219
  removeDecoyElement();
194
220
  }
195
221
 
@@ -208,7 +234,7 @@ export function init(userConfig) {
208
234
  function buildTrialCtx() {
209
235
  return {
210
236
  config, sessionData, trialData, listeners,
211
- addTrialListener, addInterval, fireSignal, isKnownInput,
237
+ addTrialListener, addInterval, addTrialInterval, fireSignal, isKnownInput,
212
238
  getTrialData: function () { return trialData; }
213
239
  };
214
240
  }
@@ -316,6 +342,14 @@ export function init(userConfig) {
316
342
  report.duration_ms = performance.now() - trialData.startTime;
317
343
  report.libraryVersion = VERSION;
318
344
  report.participantId = config.participantId;
345
+ // Wall-clock stamp at trial end (ISO 8601). Downstream renderers anchor
346
+ // per-rule phase bands with Date.parse(trial.timestamp); before 0.6.1
347
+ // the report carried only performance.now() values, so payloads whose
348
+ // app layer didn't stamp its own timestamp produced NaN anchors.
349
+ // Note: in Shape-1 ingest the integrity sub-object wins key collisions,
350
+ // so for new data this stamp shadows an app-level trial timestamp —
351
+ // both are trial-end wall-clocks, so they agree to within milliseconds.
352
+ report.timestamp = new Date().toISOString();
319
353
 
320
354
  // Compute typing speed
321
355
  var cps = computeTypingSpeed(trialData.editTimestamps);
@@ -324,6 +358,17 @@ export function init(userConfig) {
324
358
  sessionData.charsPerSec.push(cps);
325
359
  }
326
360
 
361
+ // Privacy gate: the raw per-edit timestamps are keystroke-timing data.
362
+ // Persist them ONLY when keystroke dynamics are explicitly enabled
363
+ // (signals.keystrokeDynamics, or collectForPostHoc.fullKeystrokeTimestamps).
364
+ // Otherwise keep only the derived charsPerSec — the typing-speed signal
365
+ // still works, but the raw timings never leave the browser. Without this,
366
+ // the documented "off by default in permissive/standard" privacy toggle was
367
+ // inert and the timings were saved verbatim regardless.
368
+ if (!config.signals.keystrokeDynamics && !config.collectForPostHoc.fullKeystrokeTimestamps) {
369
+ report.editTimestamps = [];
370
+ }
371
+
327
372
  // Compute mouse bot metrics
328
373
  var mouseMetrics = computeMouseMetrics(
329
374
  trialData.mouseEvents,
@@ -370,9 +415,17 @@ export function init(userConfig) {
370
415
  return sessionData.hardScore[k].triggered;
371
416
  });
372
417
  report.trialsCompleted = sessionData.trialsCompleted;
418
+ // Carry the effective runtime settings the CLI report needs to interpret
419
+ // this participant's data with the SAME thresholds the library screened
420
+ // them against (e.g. a strict-preset participant's tab-away cutoff), rather
421
+ // than analyst-side CLI defaults.
373
422
  report.config = {
374
423
  preset: config.preset,
375
- participantId: config.participantId
424
+ participantId: config.participantId,
425
+ thresholds: {
426
+ tabAwayDurationMs: config.thresholds.tabAwayDurationMs,
427
+ typingSpeedCps: config.thresholds.typingSpeedCps
428
+ }
376
429
  };
377
430
  report.libraryVersion = VERSION;
378
431
  return report;
@@ -81,11 +81,20 @@ export function computeTrialScores(trialData, sessionData, config, report) {
81
81
  };
82
82
  }
83
83
 
84
+ // ── Trial window upper bound ──
85
+ // Session-scoped events (sidebar, devTools shortcuts) count toward this
86
+ // trial only if they fall inside [startTime, startTime + duration]. Using
87
+ // performance.now() here would silently widen the window if scoring were
88
+ // ever deferred past endTrial.
89
+ var trialEnd = trialData.startTime + (report && report.duration_ms != null
90
+ ? report.duration_ms
91
+ : performance.now() - trialData.startTime);
92
+
84
93
  // ── Soft signal: sidebarEvent ──
85
94
  // Counts sidebar events that occurred during this trial's time window.
86
95
  if (scoring.soft.sidebarEvent) {
87
96
  var trialSidebarHits = sessionData.sidebarEvents.filter(function (e) {
88
- return e.t >= trialData.startTime && e.t <= performance.now();
97
+ return e.t >= trialData.startTime && e.t <= trialEnd;
89
98
  }).length;
90
99
  if (trialSidebarHits > 0) {
91
100
  var sidebarScore = scoring.soft.sidebarEvent.weight;
@@ -98,7 +107,7 @@ export function computeTrialScores(trialData, sessionData, config, report) {
98
107
  // Counts keyboard shortcuts detected during this trial's time window.
99
108
  if (scoring.soft.devTools) {
100
109
  var trialDevToolsHits = sessionData.keyboardShortcuts.filter(function (e) {
101
- return e.t >= trialData.startTime && e.t <= performance.now();
110
+ return e.t >= trialData.startTime && e.t <= trialEnd;
102
111
  }).length;
103
112
  if (trialDevToolsHits > 0) {
104
113
  var devToolsScore = scoring.soft.devTools.weight;
@@ -24,8 +24,11 @@ export function attachBrowserSignals(ctx) {
24
24
  // ── Sidebar gap detection (2s polling) ──
25
25
  // Tracks state TRANSITIONS in innerWidth. A sidebar opening shrinks
26
26
  // innerWidth by 300-500px. Also checks layout compression (extension
27
- // padding/margin on <html> element).
28
- if (config.signals.sidebarGap || config.signals.devTools) {
27
+ // padding/margin on <html> element) and zoom changes. None of these are
28
+ // DevTools-related, so this interval is gated by `sidebarGap` alone — the
29
+ // earlier `|| devTools` here was a mis-routed toggle (DevTools evidence comes
30
+ // from the keyboard-shortcut listener below, not from viewport polling).
31
+ if (config.signals.sidebarGap) {
29
32
  var _lastZoom = window.devicePixelRatio || 1;
30
33
  var _baselineIW = window.innerWidth;
31
34
  var _sidebarOpen = false;
@@ -138,9 +141,12 @@ export function attachBrowserSignals(ctx) {
138
141
  ctx.addInterval(setInterval(scanExtensions, config.thresholds.extensionScanMs));
139
142
  }
140
143
 
141
- // ── Keyboard shortcut detection ──
142
- // Catches DevTools shortcuts (Ctrl+Shift+I/J/C, F12)
143
- if (config.signals.keyboardShortcuts) {
144
+ // ── Keyboard shortcut / DevTools-hotkey detection ──
145
+ // Catches DevTools shortcuts (Ctrl+Shift+I/J/C, F12). This listener IS the
146
+ // "DevTools" detector, so EITHER the `keyboardShortcuts` or the `devTools`
147
+ // signal enables it (the soft `devTools` weight then scores the captured
148
+ // hotkeys). Records into sessionData.keyboardShortcuts.
149
+ if (config.signals.keyboardShortcuts || config.signals.devTools) {
144
150
  ctx.addListener(document, "keydown", function (e) {
145
151
  var dominated = e.ctrlKey || e.metaKey;
146
152
  if (dominated && e.shiftKey && (e.key === "I" || e.key === "J" || e.key === "C")) {
@@ -237,25 +243,50 @@ export function attachBrowserSignals(ctx) {
237
243
  });
238
244
  }
239
245
 
240
- // ── ResizeObserver for layout compression ──
246
+ // ── ResizeObserver for viewport-width shifts ──
241
247
  // Fires when <html> element dimensions change. Catches extensions that
242
248
  // add margin-right/padding-right to <html> to make room for their panel.
249
+ //
250
+ // Renamed from "layoutShifts" in 0.6.1: the signal measures VIEWPORT-WIDTH
251
+ // changes, not Web-Vitals CLS-style layout shift — the old name collided
252
+ // with that semantics. Records go to sessionData.viewportWidthShifts
253
+ // (canonical); sessionData.layoutShifts aliases the same array.
254
+ //
255
+ // Debounced (0.6.1): a drag-resize fires the observer once per frame, so a
256
+ // single gesture used to log 5+ shift events. Now the shift is logged only
257
+ // after viewportShiftDebounceMs of quiet, as ONE event carrying the net
258
+ // old→new change of the whole gesture. A shift still settling when
259
+ // destroy() runs is dropped (the debounce timer is cleared with the other
260
+ // intervals) — acceptable, since destroy() means the session is over.
243
261
  if (config.signals.sidebarGap) {
244
262
  var baselineWidth = document.documentElement.clientWidth;
263
+ var _pendingWidth = null;
264
+ var _shiftTimer = null;
265
+ var flushShift = function () {
266
+ _shiftTimer = null;
267
+ if (_pendingWidth === null) return;
268
+ var settledWidth = _pendingWidth;
269
+ _pendingWidth = null;
270
+ var delta = baselineWidth - settledWidth;
271
+ if (Math.abs(delta) > config.thresholds.layoutCompressionPx) {
272
+ sessionData.viewportWidthShifts.push({
273
+ oldWidth: Math.round(baselineWidth),
274
+ newWidth: Math.round(settledWidth),
275
+ delta: Math.round(delta),
276
+ t: performance.now()
277
+ });
278
+ baselineWidth = settledWidth;
279
+ }
280
+ };
245
281
  var resizeObs = new ResizeObserver(function (entries) {
246
282
  for (var i = 0; i < entries.length; i++) {
247
- var currentWidth = entries[i].contentRect.width;
248
- var delta = baselineWidth - currentWidth;
249
- if (Math.abs(delta) > config.thresholds.layoutCompressionPx) {
250
- sessionData.layoutShifts.push({
251
- oldWidth: Math.round(baselineWidth),
252
- newWidth: Math.round(currentWidth),
253
- delta: Math.round(delta),
254
- t: performance.now()
255
- });
256
- baselineWidth = currentWidth;
257
- }
283
+ _pendingWidth = entries[i].contentRect.width;
258
284
  }
285
+ if (_shiftTimer) clearTimeout(_shiftTimer);
286
+ _shiftTimer = setTimeout(flushShift, config.thresholds.viewportShiftDebounceMs);
287
+ // Tracked so destroy() clears a still-pending debounce (clearInterval
288
+ // and clearTimeout are interchangeable per the HTML spec).
289
+ ctx.addInterval(_shiftTimer);
259
290
  });
260
291
  resizeObs.observe(document.documentElement);
261
292
  ctx.listeners.push({
@@ -298,6 +329,8 @@ export function attachElementTrace(ctx) {
298
329
  }
299
330
  } catch (e) { /* elementsFromPoint not supported */ }
300
331
  }, config.thresholds.elementTraceHz);
301
- ctx.addInterval(elementTraceId);
332
+ // Trial-scoped: cleared at endTrial. Registering session-scoped here
333
+ // leaked one live interval per trial until destroy().
334
+ ctx.addTrialInterval(elementTraceId);
302
335
  }
303
336
  }
@@ -57,17 +57,25 @@ export function attachClipboardSignals(ctx) {
57
57
  });
58
58
 
59
59
  // Cut is logged under the copy signal (same data concern — text leaving
60
- // the page). Stored in copyEvents alongside copy entries.
60
+ // the page). Stored in copyEvents alongside copy entries, and — like copy —
61
+ // increments the session copyCount so the hard-copy screenout counts cuts
62
+ // too. Without this, a cut showed up in the per-trial hard `trialHits` and
63
+ // in the soft-copy score but never in the session total that actually
64
+ // triggers, so strict-preset hard-copy could not fire on cuts.
61
65
  ctx.addTrialListener(document, "cut", function (e) {
62
66
  trialData.copyEvents.push({
63
67
  type: "cut", t: performance.now()
64
68
  });
69
+ ctx.sessionData.copyCount++;
65
70
  ctx.fireSignal("cut", e, {});
66
71
  });
67
72
  }
68
73
 
69
74
  // ── Drop detection ──
70
- if (config.signals.paste) {
75
+ // Gated on its own signal flag (presets declare drop explicitly). Was
76
+ // previously gated on paste by copy-paste error, so disabling paste
77
+ // silently disabled drop — a separately hard-scored signal.
78
+ if (config.signals.drop) {
71
79
  ctx.addTrialListener(document, "drop", function (e) {
72
80
  var text = "";
73
81
  try { text = e.dataTransfer.getData("text"); } catch (err) {}
@@ -143,6 +143,15 @@ function scanDomForDecoy(excludeSelectors) {
143
143
  // Internal trial counter for Tier 3/4 template variable
144
144
  var _decoyTrialIndex = 0;
145
145
 
146
+ /**
147
+ * Resets module-level decoy state. Called from monitor.js init() so a second
148
+ * CyborgHunter.init() in the same page load restarts cal-{N} numbering at 1
149
+ * instead of continuing where the previous instance left off.
150
+ */
151
+ export function resetDecoyState() {
152
+ _decoyTrialIndex = 0;
153
+ }
154
+
146
155
  /**
147
156
  * Main decoy injection function. Called from monitor.js at startTrial().
148
157
  *
@@ -33,10 +33,22 @@ export function attachFocusSignals(ctx) {
33
33
  var event = {
34
34
  start: _tabAwayStart,
35
35
  duration_ms: Math.round(duration),
36
- type: _tabAwayType
36
+ type: _tabAwayType,
37
+ // Wall-clock at the LEAVE moment (ISO 8601), reconstructed at return
38
+ // time. Lets timelines place session-level events absolutely without
39
+ // a perfNow→wall-clock offset estimator.
40
+ timestamp: new Date(Date.now() - duration).toISOString()
37
41
  };
38
42
  // Push to current trial if one is active
39
43
  if (ctx.getTrialData()) ctx.getTrialData().tabAwayEvents.push(event);
44
+ // Session-level record (0.6.1). tabAwaySums keeps only durations, so
45
+ // events OUTSIDE startTrial/endTrial (consent, tutorial, comprehension,
46
+ // gallery study) used to lose their timing entirely — timelines could
47
+ // count them but not place them. tabAwayEvents preserves the full
48
+ // {start, duration_ms, type, timestamp} for every tab-away in the
49
+ // session, on-trial or off. tabAwaySums is kept alongside for backward
50
+ // compatibility.
51
+ ctx.sessionData.tabAwayEvents.push(event);
40
52
  ctx.sessionData.tabAwaySums.push(Math.round(duration));
41
53
  ctx.fireSignal("tabReturn", null, {
42
54
  duration_ms: Math.round(duration),
@@ -96,6 +108,8 @@ export function attachIdleGapSignals(ctx) {
96
108
  lastActivityTime = performance.now();
97
109
  }
98
110
  }, config.thresholds.idleCheckIntervalMs);
99
- ctx.addInterval(idleCheckId);
111
+ // Trial-scoped: cleared at endTrial. Registering session-scoped here
112
+ // leaked one live interval per trial until destroy().
113
+ ctx.addTrialInterval(idleCheckId);
100
114
  }
101
115
  }