cyborg-hunter 0.9.1 → 0.10.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 (37) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/CITATION.cff +1 -1
  3. package/README.md +33 -32
  4. package/dist/ch.js +41 -0
  5. package/dist/cyborg-hunter-replay.js +3 -3
  6. package/dist/cyborg-hunter.esm.js +1 -1
  7. package/dist/cyborg-hunter.min.js +4 -3
  8. package/dist/extension-cyborg-hunter.js +1 -1
  9. package/dist/extension-guard-friction.js +5 -5
  10. package/dist/extension-guard-honeypot.js +1 -1
  11. package/package.json +11 -5
  12. package/src/cli/extract-core.js +70 -4
  13. package/src/cli/renderers/replay-client-source.js +2 -2
  14. package/src/cli/segment-reassembly.js +204 -0
  15. package/src/jspsych/extension-cyborg-hunter-replay.js +1 -1
  16. package/src/jspsych/extension-cyborg-hunter.js +1 -1
  17. package/src/jspsych/extension-guard-friction.js +21 -7
  18. package/src/jspsych/extension-guard-honeypot.js +13 -6
  19. package/src/oneliner/adapters/jspsych-extension.js +196 -0
  20. package/src/oneliner/adapters/jspsych.js +483 -0
  21. package/src/oneliner/adapters/vanilla.js +420 -0
  22. package/src/oneliner/api.js +151 -0
  23. package/src/oneliner/boot.js +270 -0
  24. package/src/oneliner/config.js +122 -0
  25. package/src/oneliner/debug.js +137 -0
  26. package/src/oneliner/entry.js +8 -0
  27. package/src/oneliner/errors.js +219 -0
  28. package/src/oneliner/guards.js +67 -0
  29. package/src/oneliner/participant-id.js +49 -0
  30. package/src/oneliner/replay-loader.js +296 -0
  31. package/src/oneliner/segment-diff.js +68 -0
  32. package/src/oneliner/segmenter.js +186 -0
  33. package/src/replay/dom-instantiate.js +10 -1
  34. package/src/replay/index.js +4 -2
  35. package/src/replay/recorder.js +5 -1
  36. package/src/shared/constants.js +1 -1
  37. package/src/shared/schema-v2-validator.js +14 -9
package/package.json CHANGED
@@ -1,6 +1,9 @@
1
1
  {
2
2
  "name": "cyborg-hunter",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
+ "workspaces": [
5
+ "packages/*"
6
+ ],
4
7
  "description": "Detect LLM-assisted 'cyborg' behavior in online behavioral experiments",
5
8
  "main": "dist/cyborg-hunter.min.js",
6
9
  "module": "dist/cyborg-hunter.esm.js",
@@ -9,16 +12,21 @@
9
12
  },
10
13
  "scripts": {
11
14
  "build": "node build.js",
15
+ "sync:conformance": "node scripts/sync-conformance.mjs",
16
+ "pretest": "node scripts/sync-conformance.mjs --check",
17
+ "prepack": "node scripts/sync-conformance.mjs",
18
+ "test:pack-smoke": "node scripts/pack-smoke.mjs",
12
19
  "demo:preview": "node tools/build-preview-core.mjs",
13
- "test:browser": "echo 'Open tests/browser/integrity-monitor.test.html in a browser'",
14
20
  "test:cli": "node --test tests/cli/*.test.js",
15
21
  "test:core": "node --test tests/core/*.test.js",
16
22
  "test:jspsych": "node --test tests/jspsych/*.test.js",
23
+ "test:oneliner": "node --test tests/oneliner/*.test.js",
17
24
  "test:tools": "node --test tests/tools/*.test.js",
18
25
  "test:demo": "node --test tests/demo/*.test.js",
19
- "test": "npm run test:core && npm run test:replay && npm run test:cli && npm run test:jspsych && npm run test:tools && npm run test:demo && npm run test:schema",
26
+ "test": "npm run test:core && npm run test:replay && npm run test:cli && npm run test:jspsych && npm run test:oneliner && npm run test:tools && npm run test:demo && npm run test:schema && npm run test:package",
20
27
  "test:replay": "node --test tests/replay/*.test.js",
21
28
  "test:schema": "node --test tests/replay/schema-v2/*.test.js",
29
+ "test:package": "npm test --workspace @cyborg-hunter/sessionrecording-conformance",
22
30
  "test:browser:replay": "node tests/browser/replay/run-replay-tests.mjs",
23
31
  "test:browser:e2e": "node tests/browser/replay/e2e-dogfood.mjs",
24
32
  "test:browser:alignment": "node tests/browser/replay/cursor-alignment.battery.mjs",
@@ -26,7 +34,6 @@
26
34
  "test:browser:canvas": "node tests/browser/replay/viewer-canvas.battery.mjs",
27
35
  "test:browser:seek": "node tests/browser/replay/seek-budget.battery.mjs",
28
36
  "test:browser:capture": "node tests/browser/replay/capture-chromium.battery.mjs",
29
- "test:browser:fork": "node tests/browser/replay/capture-fork-smoke.mjs",
30
37
  "test:e2e": "playwright test"
31
38
  },
32
39
  "files": [
@@ -66,7 +73,6 @@
66
73
  },
67
74
  "devDependencies": {
68
75
  "@playwright/test": "^1.62.0",
69
- "ajv": "^8.20.0",
70
76
  "esbuild": "^0.21.0",
71
77
  "happy-dom": "^20.9.0"
72
78
  },
@@ -15,6 +15,7 @@
15
15
 
16
16
  import { TRIAL_REPORT_FIELDS } from '../shared/schema.js';
17
17
  import { getByPath } from '../shared/paths.js';
18
+ import { collectSegments, reassembleSegments, rebaseTrialReport } from './segment-reassembly.js';
18
19
 
19
20
  // Extracts integrity trial data from a single participant's raw JSON.
20
21
  // Returns { participantId, trials, warnings, metadata }.
@@ -53,6 +54,27 @@ export function extractIntegrityData(raw, config) {
53
54
  trials = raw.trials
54
55
  .filter(t => t && t[intField])
55
56
  .map(t => ({ ...t, ...t[intField] }));
57
+ // One-line setup (0.10.0) across several pages: each page has its own
58
+ // performance.now() origin, recorded as integritySegment.pageOrigin.
59
+ // Re-base trials from later pages onto the first page's origin so the
60
+ // session timeline and the per-trial anchors below stay on one clock.
61
+ // Only the page-clock fields move (anchors + absolute-time event arrays,
62
+ // listed in segment-reassembly.js); trial-relative mouseTrack/elementTrace
63
+ // times must NOT move, or trajectories misplace every later-page trial.
64
+ // Single-page data, and data without segments, is untouched. A row whose
65
+ // trial was not segmented itself (it carries only the closing
66
+ // integritySegmentFinal) takes that segment's page.
67
+ const rowOrigin = t => t?.integritySegment?.pageOrigin ?? t?.integritySegmentFinal?.pageOrigin;
68
+ if (raw.trials.some(t => typeof rowOrigin(t) === 'number')) {
69
+ const origin0 = collectSegments(raw)[0]?.pageOrigin;
70
+ if (typeof origin0 === 'number') {
71
+ trials = trials.map(trial => {
72
+ const origin = rowOrigin(trial);
73
+ return (typeof origin === 'number' && origin !== origin0)
74
+ ? rebaseTrialReport(trial, origin - origin0) : trial;
75
+ });
76
+ }
77
+ }
56
78
  // jsPsych extension data carries trialStart_perfNow per trial (set by the
57
79
  // wrapper's on_load), so normalization takes the exact-subtraction fast
58
80
  // path. Without it, renderers see only session-absolute `start` values
@@ -156,7 +178,7 @@ export function extractIntegrityData(raw, config) {
156
178
  }
157
179
  }
158
180
 
159
- const { session, score } = findSessionData(raw, config);
181
+ const { session, score } = findSessionData(raw, config, warnings);
160
182
  if (session === null && trials.length > 0) {
161
183
  warnings.push('No session-level integrity data — some signals unavailable (did the experiment call getSessionReport()?)');
162
184
  }
@@ -173,6 +195,16 @@ export function extractIntegrityData(raw, config) {
173
195
  if (finalizeError) {
174
196
  warnings.push(`finalize() failed for this participant (cyborgHunterFinalizeError): ${finalizeError} — session data may be incomplete`);
175
197
  }
198
+ // The one-line setup (0.10.0) has no finalize(); when it hits an internal
199
+ // error it drops a `cyborgHunterError` marker instead. Same three locations.
200
+ const oneLinerError = raw.cyborgHunterError
201
+ ?? raw.metadata?.cyborgHunterError
202
+ ?? (Array.isArray(raw.trials)
203
+ ? raw.trials.find(t => t?.cyborgHunterError)?.cyborgHunterError
204
+ : undefined);
205
+ if (oneLinerError) {
206
+ warnings.push(`one-line setup reported an error for this participant (cyborgHunterError): ${oneLinerError} — data after that point may be incomplete`);
207
+ }
176
208
 
177
209
  return {
178
210
  participantId,
@@ -227,10 +259,17 @@ function looksLikeSessionData(obj) {
227
259
  // 4. raw.cyborgHunter — native top-level location used by raw-DOM
228
260
  // adopters before they adopt the
229
261
  // metadata.integritySession mirror. Added 2026-05-26.
230
- // Returns { session, score }, both null if not found.
231
- function findSessionData(raw, config) {
262
+ // 5. Rolling snapshot (0.10.0 one-line setup) — per-row integritySegment
263
+ // deltas, reassembled by src/cli/segment-reassembly.js.
264
+ // Returns { session, score }, both null if not found. `warnings` receives a
265
+ // note when a dumped session and rolling segments are both present. Not
266
+ // exported (ingest.js re-exports only extractIntegrityData and
267
+ // ruleChronologicalCompare); its one caller always passes `warnings`, and the
268
+ // `= []` default only keeps a warnings-less call from throwing.
269
+ function findSessionData(raw, config, warnings = []) {
232
270
  let session = null;
233
271
  let score = null;
272
+ let usedSegments = false;
234
273
 
235
274
  // 0. Analyst-specified location, e.g. "payload.cyborgHunter" for pipelines
236
275
  // that nest the getSessionReport() output somewhere non-standard. Falls
@@ -273,6 +312,26 @@ function findSessionData(raw, config) {
273
312
  session = raw.cyborgHunter;
274
313
  score = null;
275
314
  }
315
+ // 5. Rolling snapshot (0.10.0 one-line setup): per-row integritySegment deltas,
316
+ // concatenated in segmentIndex order into the same shape finalize() dumps.
317
+ else {
318
+ const segs = collectSegments(raw);
319
+ if (segs.length > 0) {
320
+ const rolled = reassembleSegments(segs);
321
+ session = rolled.session;
322
+ score = rolled.score;
323
+ usedSegments = true;
324
+ }
325
+ }
326
+
327
+ // A file carrying both is a study that mixed manual finalize() with the
328
+ // one-line setup. The dumped session is complete by construction, so it
329
+ // wins; say so rather than silently ignoring the segments. This also fires
330
+ // when branch 0 (the analyst's sessionIntegrityPath) found the session,
331
+ // since that leaves usedSegments false too.
332
+ if (session && !usedSegments && collectSegments(raw).length > 0) {
333
+ warnings.push('both a dumped integritySession and rolling segments (integritySegment) were found — the dumped integritySession was used; this file mixes manual mode and the one-line setup');
334
+ }
276
335
 
277
336
  // When no separate integrityScore blob was saved, synthesize the authoritative
278
337
  // score from the session object itself. getSessionReport() embeds the scoring
@@ -320,7 +379,14 @@ function scoreFromSession(session) {
320
379
  // (or metadata). Each entry is { reason, start, end, duration, in_progress };
321
380
  // the renderer keys off `t` (perfNow ms), so map start → t. Apps that hand-write
322
381
  // a top-level `raw.guardFriction` object still take precedence over this.
382
+ // One-line setup across several pages (0.10.0 vanilla host): each page's
383
+ // entries carry that page's `pageOrigin`, and `t` is re-based onto the first
384
+ // segment's origin like the rest of the session; entries without one keep
385
+ // their `start`.
323
386
  function findGuardViolations(raw) {
387
+ const origin0 = collectSegments(raw)[0]?.pageOrigin;
388
+ const offset = (v) => (typeof v.pageOrigin === 'number' && typeof origin0 === 'number')
389
+ ? v.pageOrigin - origin0 : 0;
324
390
  const parseArr = (v) => {
325
391
  if (Array.isArray(v)) return v;
326
392
  if (typeof v === 'string') { try { return JSON.parse(v); } catch { return null; } }
@@ -353,7 +419,7 @@ function findGuardViolations(raw) {
353
419
  if (Array.isArray(arr)) {
354
420
  const violations = arr
355
421
  .filter(v => v && typeof v.start === 'number')
356
- .map(v => ({ t: v.start, reason: v.reason || 'unknown', phase: 'unknown', duration_ms: v.duration }));
422
+ .map(v => ({ t: v.start + offset(v), reason: v.reason || 'unknown', phase: 'unknown', duration_ms: v.duration }));
357
423
  // Fall through to the next source on an empty/violation-less candidate
358
424
  // instead of locking onto it — otherwise an empty placeholder (e.g. a
359
425
  // metadata mirror set to []) would shadow real violations on the trial rows.
@@ -25,11 +25,11 @@
25
25
  // tools/assemble-demo-site.mjs → this module (writes the ASSEMBLED file,
26
26
  // which demo/results.js fetches by name)
27
27
  // cursor-alignment.battery.mjs → this module (Task 8's harness)
28
- // tools/investigate/probe-support.mjs → NOT a consumer of the assembly: it
28
+ // cyborg-hunter-lab: probes/investigate/probe-support.mjs → NOT a consumer of the assembly: it
29
29
  // extracts the literal `srcdocCsp()` out
30
30
  // of the client source and needs the raw
31
31
  // file, not the bundle.
32
- // tools/investigate/cursor-alignment-probe.mjs → superseded (Task 1).
32
+ // cyborg-hunter-lab: probes/investigate/cursor-alignment-probe.mjs → superseded (Task 1).
33
33
 
34
34
  import { readFileSync } from 'fs';
35
35
 
@@ -0,0 +1,204 @@
1
+ // src/cli/segment-reassembly.js
2
+ // CLI side of the rolling snapshot (0.10.0 one-line setup). The browser cuts
3
+ // the monitor's session arrays into per-segment deltas
4
+ // (src/oneliner/segment-diff.js) and saves one `integritySegment` per data
5
+ // row; this module puts them back together into the same session object
6
+ // finalize() dumps as `integritySession`, plus the score.
7
+ //
8
+ // Pure: no imports, no Node APIs — extract-core.js (bundled by the browser
9
+ // demo) depends on it.
10
+
11
+ // Field names that hold a performance.now()-style time. Everything else
12
+ // (duration_ms, ISO `timestamp` strings, counts) is left alone when re-basing.
13
+ // rebaseTimes() applies this at any depth, which is right for the SESSION
14
+ // arrays (every entry's t/start is a raw page performance.now()), but not for
15
+ // a whole trial report — use rebaseTrialReport() for those.
16
+ // Bare-number arrays are never shifted: editTimestamps holds absolute
17
+ // performance.now() values too, but the CLI only uses their differences
18
+ // (typing speed), so they are intentionally left on their own page's clock.
19
+ const TIME_KEYS = new Set(['t', 'start', 'startTime', 'trialStart_perfNow']);
20
+
21
+ // Mirrors ALIAS_KEYS in src/oneliner/segment-diff.js (a test pins the two
22
+ // equal): the alias key is not shipped in segments, so it is restored here
23
+ // pointing at its canonical key.
24
+ export const ALIAS_KEYS = { layoutShifts: 'viewportWidthShifts' };
25
+
26
+ // Trial-report fields that carry the page's performance.now() clock and so
27
+ // move with the page origin. Time base of each, from src/core:
28
+ // startTime performance.now() at startTrial (monitor.js startTrial)
29
+ // trialStart_perfNow performance.now() at on_load (jspsych extension on_load)
30
+ // pasteEvents[].t performance.now() (signals/clipboard.js)
31
+ // copyEvents[].t performance.now() (signals/clipboard.js)
32
+ // dropEvents[].t performance.now() (signals/clipboard.js)
33
+ // tabAwayEvents[].start performance.now() at leave (signals/focus.js)
34
+ // idleGaps[].t performance.now() (signals/focus.js)
35
+ // syntheticInsertions[].t performance.now() (signals/typing.js)
36
+ // foreignInputEvents[].t performance.now() (signals/typing.js)
37
+ // NOT shifted (left as they are):
38
+ // mouseTrack/mouseEvents[].t ms since trial start (signals/mouse.js)
39
+ // elementTrace[].t ms since trial start (signals/browser.js)
40
+ // mouseTrackingCappedAtMs, duration_ms durations
41
+ // editTimestamps absolute, bare numbers — see TIME_KEYS note
42
+ // anything else on the row (integrity, integritySegment, jsPsych columns)
43
+ const TRIAL_ANCHOR_KEYS = ['startTime', 'trialStart_perfNow'];
44
+ const TRIAL_PAGE_TIME_ARRAYS = ['pasteEvents', 'copyEvents', 'dropEvents', 'tabAwayEvents',
45
+ 'idleGaps', 'syntheticInsertions', 'foreignInputEvents'];
46
+
47
+ // Returns a shallow copy of a merged trial report with only the page-clock
48
+ // fields above shifted by offsetMs. Offset 0 returns the input itself.
49
+ export function rebaseTrialReport(trial, offsetMs) {
50
+ if (!offsetMs || !trial || typeof trial !== 'object') return trial;
51
+ const out = { ...trial };
52
+ for (const k of TRIAL_ANCHOR_KEYS) {
53
+ if (typeof out[k] === 'number') out[k] += offsetMs;
54
+ }
55
+ for (const k of TRIAL_PAGE_TIME_ARRAYS) {
56
+ if (Array.isArray(out[k])) out[k] = rebaseTimes(out[k], offsetMs);
57
+ }
58
+ return out;
59
+ }
60
+
61
+ // Returns a deep copy of `value` with every numeric property named t, start,
62
+ // startTime or trialStart_perfNow shifted by offsetMs, at any depth.
63
+ // Offset 0 returns the input itself (no copy), so a single-page session costs
64
+ // nothing.
65
+ export function rebaseTimes(value, offsetMs) {
66
+ if (!offsetMs) return value;
67
+ return shift(value, offsetMs);
68
+ }
69
+
70
+ function shift(value, offsetMs) {
71
+ if (Array.isArray(value)) return value.map(v => shift(v, offsetMs));
72
+ if (!value || typeof value !== 'object') return value;
73
+ const out = {};
74
+ for (const [k, v] of Object.entries(value)) {
75
+ out[k] = (TIME_KEYS.has(k) && typeof v === 'number') ? v + offsetMs : shift(v, offsetMs);
76
+ }
77
+ return out;
78
+ }
79
+
80
+ function isSegment(x) {
81
+ return !!x && typeof x === 'object' && !Array.isArray(x) && typeof x.segmentIndex === 'number';
82
+ }
83
+
84
+ // Gathers every segment saved in a participant file, sorted by segmentIndex.
85
+ // Sources: raw.trials[*].integritySegment, raw.trials[*].integritySegmentFinal
86
+ // (any row) and raw.integritySegments (array). Non-objects are skipped — a CSV
87
+ // cell that failed to JSON.parse stays a string. When the same index appears
88
+ // twice, the first occurrence wins.
89
+ export function collectSegments(raw) {
90
+ const found = [];
91
+ if (raw && Array.isArray(raw.trials)) {
92
+ for (const row of raw.trials) {
93
+ if (!row || typeof row !== 'object') continue;
94
+ if (isSegment(row.integritySegment)) found.push(row.integritySegment);
95
+ if (isSegment(row.integritySegmentFinal)) found.push(row.integritySegmentFinal);
96
+ }
97
+ }
98
+ if (raw && Array.isArray(raw.integritySegments)) {
99
+ for (const s of raw.integritySegments) if (isSegment(s)) found.push(s);
100
+ }
101
+ // Array.prototype.sort is stable, so on equal indices the earlier-found
102
+ // segment stays first and the duplicate check below keeps it.
103
+ found.sort((a, b) => a.segmentIndex - b.segmentIndex);
104
+ const out = [];
105
+ for (const s of found) {
106
+ if (out.length > 0 && out[out.length - 1].segmentIndex === s.segmentIndex) continue;
107
+ out.push(s);
108
+ }
109
+ return out;
110
+ }
111
+
112
+ // Concatenates segment deltas in segmentIndex order into the finalize() dump
113
+ // shape: { pasteCount, copyCount, dropCount, <every array key>, layoutShifts,
114
+ // config } — score fields and libraryVersion are NOT in the session (finalize()
115
+ // stores them separately). Returns { session, score, pageOrigins } or null for
116
+ // no segments. Segments from a later page (different pageOrigin) have their
117
+ // times re-based to the first page's origin. Counters and score add up the
118
+ // pages (pageTotals below); single-page data keeps the last segment's score
119
+ // object as it is.
120
+ export function reassembleSegments(segments) {
121
+ if (!Array.isArray(segments) || segments.length === 0) return null;
122
+ const sorted = segments.slice().sort((a, b) => a.segmentIndex - b.segmentIndex);
123
+
124
+ const pageOrigins = [];
125
+ for (const s of sorted) {
126
+ if (!pageOrigins.includes(s.pageOrigin)) pageOrigins.push(s.pageOrigin);
127
+ }
128
+
129
+ const session = {};
130
+ for (const s of sorted) {
131
+ const offset = (typeof s.pageOrigin === 'number' && typeof pageOrigins[0] === 'number')
132
+ ? s.pageOrigin - pageOrigins[0] : 0;
133
+ for (const [key, entries] of Object.entries(s.deltas || {})) {
134
+ if (!Array.isArray(entries)) continue;
135
+ session[key] = (session[key] || []).concat(rebaseTimes(entries, offset));
136
+ }
137
+ }
138
+ for (const [alias, canonical] of Object.entries(ALIAS_KEYS)) {
139
+ if (!session[canonical]) session[canonical] = [];
140
+ session[alias] = session[canonical]; // same array, as in the monitor
141
+ }
142
+
143
+ // Counters and score are cumulative per monitor, and every page load runs a
144
+ // new monitor: the session total is the sum over pages of each page's last
145
+ // segment. `?? 0` matches finalize()'s destructuring defaults.
146
+ const lasts = pageLasts(sorted);
147
+ for (const k of ['pasteCount', 'copyCount', 'dropCount']) {
148
+ session[k] = lasts.reduce((sum, s) => sum + ((s.counters || {})[k] ?? 0), 0);
149
+ }
150
+ const withConfig = sorted.find(s => s.config);
151
+ session.config = withConfig ? withConfig.config : undefined;
152
+
153
+ return { session, score: sessionScore(lasts), pageOrigins };
154
+ }
155
+
156
+ // The last segment of each page, in page order. A page is identified by its
157
+ // pageOrigin, not by a run of consecutive segments: a page shown again from
158
+ // the back/forward cache keeps its origin and its monitor, so its last
159
+ // segment already holds everything that monitor counted, earlier visit
160
+ // included (summing per run would count the first visit twice).
161
+ function pageLasts(sorted) {
162
+ const byOrigin = new Map();
163
+ for (const s of sorted) byOrigin.set(s.pageOrigin, s); // sorted: the last one wins
164
+ return [...byOrigin.values()].sort((a, b) => a.segmentIndex - b.segmentIndex);
165
+ }
166
+
167
+ // One page: that segment's score object, untouched. Several pages: the same
168
+ // shape as the monitor's (src/core/scoring.js:130-139, monitor.js:413-420),
169
+ // added up. Each hard signal's count is the sum of the pages' counts, its
170
+ // threshold the last page's, triggered = count >= threshold; softScore and
171
+ // trialsCompleted are sums; softScoreThreshold is the last page's.
172
+ function sessionScore(lasts) {
173
+ const scored = lasts.filter(s => s.score && typeof s.score === 'object');
174
+ if (scored.length === 0) return lasts[lasts.length - 1].score ?? null;
175
+ const last = scored[scored.length - 1].score;
176
+ if (lasts.length === 1) return last;
177
+
178
+ const hardScore = {};
179
+ let softScore = 0;
180
+ let trialsCompleted = 0;
181
+ for (const { score } of scored) {
182
+ for (const [k, h] of Object.entries(score.hardScore || {})) {
183
+ if (!h || typeof h !== 'object') continue;
184
+ if (!hardScore[k]) hardScore[k] = { count: 0, threshold: undefined, triggered: false };
185
+ hardScore[k].count += h.count ?? 0;
186
+ if (h.threshold !== undefined) hardScore[k].threshold = h.threshold;
187
+ }
188
+ softScore += score.softScore ?? 0;
189
+ trialsCompleted += score.trialsCompleted ?? 0;
190
+ }
191
+ for (const k of Object.keys(hardScore)) {
192
+ const lastThreshold = last.hardScore?.[k]?.threshold;
193
+ if (lastThreshold !== undefined) hardScore[k].threshold = lastThreshold;
194
+ hardScore[k].triggered = typeof hardScore[k].threshold === 'number' && hardScore[k].count >= hardScore[k].threshold;
195
+ }
196
+ return {
197
+ ...last,
198
+ hardScore,
199
+ softScore,
200
+ softScoreThreshold: last.softScoreThreshold,
201
+ anyHardTriggered: Object.values(hardScore).some(h => h.triggered),
202
+ trialsCompleted
203
+ };
204
+ }
@@ -35,7 +35,7 @@ import { buildReplayMeta } from '../replay/persistence.js';
35
35
  class CyborgHunterReplayExtension {
36
36
  static info = {
37
37
  name: 'cyborg-hunter-replay',
38
- version: '0.9.1',
38
+ version: '0.10.0',
39
39
  data: {} // per-trial return is {}; session meta goes via addProperties
40
40
  };
41
41
 
@@ -10,7 +10,7 @@ class CyborgHunterExtension {
10
10
  // and package.json). Hand-bumped on each release; if you forget, the
11
11
  // jsPsych developer console shows a stale number — the actual version
12
12
  // attached to data is read from window.CyborgHunter.VERSION at runtime.
13
- version: '0.9.1',
13
+ version: '0.10.0',
14
14
  data: { integrity: { type: 'object' } }
15
15
  };
16
16
 
@@ -954,6 +954,9 @@ export function exitFullscreenFnOf(doc) {
954
954
  active: state.active,
955
955
  in_violation: !!state.currentViolation,
956
956
  current_reason: state.currentViolation ? state.currentViolation.reason : null,
957
+ // true while violations are only logged (no curtain); the entry
958
+ // trial switches it off — enforcement starts at that mark.
959
+ observe_only: state.observeOnly,
957
960
  };
958
961
  }
959
962
 
@@ -981,7 +984,11 @@ export function exitFullscreenFnOf(doc) {
981
984
  logDebug('entry_trial.on_finish', { diagnostics: getDiagnostics() });
982
985
  requestFullscreen();
983
986
  _setTimeout(() => {
984
- const token = start({ jsPsych: state.jsPsych });
987
+ // observeOnly: false explicitly — start() keeps the prior
988
+ // value when the option is absent, so a session started
989
+ // observe-only (the one-liner's friction before its mark)
990
+ // would otherwise never enforce from this mark on.
991
+ const token = start({ jsPsych: state.jsPsych, observeOnly: false });
985
992
  // Non-enumerable stash so the experiment's on_finish can
986
993
  // pass the token to stop() without exposing it on a
987
994
  // discoverable property name. configurable:true so the
@@ -1118,11 +1125,18 @@ export function exitFullscreenFnOf(doc) {
1118
1125
  };
1119
1126
 
1120
1127
  Object.freeze(api);
1121
- Object.defineProperty(global, 'GuardFriction', {
1122
- value: api,
1123
- writable: false,
1124
- configurable: false,
1125
- });
1128
+ // A second copy of this core on the page (ch.js bundles it, so ch.js plus
1129
+ // this file's own <script> tag, in either order) keeps the first
1130
+ // definition instead of throwing "Cannot redefine property".
1131
+ if (!global.GuardFriction) {
1132
+ Object.defineProperty(global, 'GuardFriction', {
1133
+ value: api,
1134
+ writable: false,
1135
+ configurable: false,
1136
+ });
1137
+ } else {
1138
+ console.error('[cyborg-hunter] Not redefining GuardFriction: GuardFriction is already defined, so two scripts on this page include the friction guard. Fix: keep one of them (ch.js already contains the friction guard). https://github.com/cyborg-hunter/cyborg-hunter/blob/main/docs/advanced-integration.md#double-load');
1139
+ }
1126
1140
  })(window);
1127
1141
  // ----- jsPsych extension adapter -----
1128
1142
  // Wraps the GuardFriction core (above) in the jsPsych extension
@@ -1144,7 +1158,7 @@ class GuardFrictionExtension {
1144
1158
  // Hand-bumped on each release to track the package version
1145
1159
  // (package.json / src/shared/constants.js). Shown in the jsPsych developer
1146
1160
  // console only; the runtime library version is independent.
1147
- version: '0.7.0',
1161
+ version: '0.10.0',
1148
1162
  data: {}
1149
1163
  };
1150
1164
 
@@ -421,11 +421,18 @@
421
421
  };
422
422
 
423
423
  Object.freeze(api);
424
- Object.defineProperty(global, 'GuardHoneypot', {
425
- value: api,
426
- writable: false,
427
- configurable: false,
428
- });
424
+ // A second copy of this core on the page (ch.js bundles it, so ch.js plus
425
+ // this file's own <script> tag, in either order) keeps the first
426
+ // definition instead of throwing "Cannot redefine property".
427
+ if (!global.GuardHoneypot) {
428
+ Object.defineProperty(global, 'GuardHoneypot', {
429
+ value: api,
430
+ writable: false,
431
+ configurable: false,
432
+ });
433
+ } else {
434
+ console.error('[cyborg-hunter] Not redefining GuardHoneypot: GuardHoneypot is already defined, so two scripts on this page include the honeypot. Fix: keep one of them (ch.js already contains the honeypot). https://github.com/cyborg-hunter/cyborg-hunter/blob/main/docs/advanced-integration.md#double-load');
435
+ }
429
436
  })(window);
430
437
  // ----- jsPsych extension adapter -----
431
438
  // Wraps the GuardHoneypot core (above) in the jsPsych extension
@@ -442,7 +449,7 @@
442
449
  class GuardHoneypotExtension {
443
450
  static info = {
444
451
  name: 'guard-honeypot',
445
- version: '0.6.0',
452
+ version: '0.10.0',
446
453
  // Per-trial fields written by on_finish. Reflect violations for this
447
454
  // trial only; jsPsych spreads them onto each trial row (CSV columns).
448
455
  // Session totals are written as global properties by finalize().