ravensight-playtest 0.3.1 → 0.3.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.
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Console noise a developer expects, which is never a finding.
3
+ *
4
+ * Two sources: the built in rule (a 401 on a who am I check is what a
5
+ * signed out page does when it asks "who am I?"; the Snowball round filed it
6
+ * as a bug), and the brief's own `expected_noise` patterns, which live in the
7
+ * local brief (see ../briefLocal.js).
8
+ *
9
+ * A pattern is matched against an automatic console note:
10
+ *
11
+ * - `/.../` is a case insensitive regular expression over the note's text and URL;
12
+ * - otherwise any status codes (`401`) and paths (`/auth/me`) in it must all
13
+ * appear in the note (its status, URL or text), and the other words are a
14
+ * description for the reader, such as "before login";
15
+ * - a pattern with neither is a case insensitive substring of the note's text.
16
+ */
17
+
18
+ /** The built in rule, as the prompt and the README state it. */
19
+ export const BUILT_IN_NOISE = 'a 401 on a who am I check, a request whose last path segment is /me, /whoami, /session or /current_user such as /auth/me, while signed out is not a finding';
20
+
21
+ /**
22
+ * A request whose final path segment asks who the visitor is: `/auth/me`,
23
+ * `/me`, `/whoami`, `/session`, `/current_user`. Only the last segment counts,
24
+ * so `/me/inventory`, `/user/42/save`, `/account/purchase`, `/token` and
25
+ * `/login` are never this rule's: a 401 there can be a real failure.
26
+ */
27
+ const AUTH_CHECK = /\/(?:me|whoami|session|current[_-]?user)\/?(?=$|[?#\s)'",])/i;
28
+
29
+ /**
30
+ * @param {Object} note
31
+ * @returns {string}
32
+ */
33
+ function haystack(note) {
34
+ return `${note && note.text ? note.text : ''} ${note && note.url ? note.url : ''}`;
35
+ }
36
+
37
+ /**
38
+ * Whether a note is the built in case: a 401 on a who am I check.
39
+ *
40
+ * @param {Object} note
41
+ * @returns {boolean}
42
+ */
43
+ export function isAuthCheck401(note) {
44
+ if (!note) return false;
45
+ const is401 = note.status === 401 || /\b401\b/.test(String(note.text || ''));
46
+ return is401 && AUTH_CHECK.test(haystack(note));
47
+ }
48
+
49
+ /**
50
+ * Whether one pattern matches a note.
51
+ *
52
+ * @param {Object} note
53
+ * @param {string} pattern
54
+ * @returns {boolean}
55
+ */
56
+ export function matchesPattern(note, pattern) {
57
+ const value = String(pattern == null ? '' : pattern).trim();
58
+ if (!value || !note) return false;
59
+ const text = haystack(note);
60
+ const regex = value.match(/^\/(.+)\/([a-z]*)$/);
61
+ if (regex) {
62
+ try {
63
+ return new RegExp(regex[1], regex[2].includes('i') ? regex[2] : `${regex[2]}i`).test(text);
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+ const statuses = value.match(/\b[1-5]\d\d\b/g) || [];
69
+ const paths = (value.match(/(?:^|\s)(\/[^\s]*)/g) || []).map(entry => entry.trim().toLowerCase());
70
+ if (statuses.length === 0 && paths.length === 0) return text.toLowerCase().includes(value.toLowerCase());
71
+ const lower = text.toLowerCase();
72
+ return statuses.every(code => note.status === Number(code) || new RegExp(`\\b${code}\\b`).test(text))
73
+ && paths.every(entry => lower.includes(entry));
74
+ }
75
+
76
+ /**
77
+ * Whether an automatic note is expected noise. A persona's own note never is:
78
+ * if the persona saw the game break, that is a finding whatever the console said.
79
+ *
80
+ * @param {Object} note
81
+ * @param {Array<string>} [patterns] the brief's expected_noise
82
+ * @returns {boolean}
83
+ */
84
+ export function isExpectedNoise(note, patterns = []) {
85
+ if (!note || !note.automatic) return false;
86
+ if (isAuthCheck401(note)) return true;
87
+ return (Array.isArray(patterns) ? patterns : []).some(pattern => matchesPattern(note, pattern));
88
+ }
89
+
90
+ /**
91
+ * The expected noise block of the persona prompt.
92
+ *
93
+ * @param {Array<string>} [patterns]
94
+ * @returns {string}
95
+ */
96
+ export function expectedNoiseForPrompt(patterns = []) {
97
+ const lines = [`- built in: ${BUILT_IN_NOISE}`];
98
+ for (const pattern of Array.isArray(patterns) ? patterns : []) if (typeof pattern === 'string' && pattern.trim()) lines.push(`- ${pattern.trim()}`);
99
+ return lines.join('\n');
100
+ }
101
+
102
+ export default { isExpectedNoise, isAuthCheck401, matchesPattern, expectedNoiseForPrompt, BUILT_IN_NOISE };
@@ -1,17 +1,19 @@
1
1
  import { rewriteMarkdown } from './dropped.js';
2
2
  import { selectAction, JEV_CONTROLLER_PROMPT } from './actionSelector.js';
3
- import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { prepareMessages, MAX_IMAGES_IN_CONTEXT, KEEP_OBSERVATIONS, shrinkImages } from './context.js';
6
6
  import { withRollingCacheBreakpoint } from './fixLoop.js';
7
7
  import { callStepWithRetry, ProxyError, STEPS } from './model.js';
8
8
  import { RUN_FILES } from './paths.js';
9
- import { checkEvidence, createReportValidator, lintMarkdownSections, normaliseEvidence, repairInstruction, REQUIRED_MARKDOWN_SECTIONS, stampReport, writeReportFiles } from './report.js';
9
+ import { appendNormalisedMarkdown, checkEvidence, createReportValidator, lintMarkdownSections, normaliseEnums, normaliseEvidence, repairInstruction, REQUIRED_MARKDOWN_SECTIONS, stampReport, writeReportFiles } from './report.js';
10
10
  import { assertNoSecrets } from './secretScan.js';
11
11
  import { ACTION_TOOLS, PLAY_TOOLS, SWIPE_TOOL, REPORT_TOOLS, executeTool } from './tools.js';
12
12
  import { createTranscript } from './transcript.js';
13
13
  import { createUsageLedger, writeUsageFile } from './usage.js';
14
14
  import { fillPlaceholders, fillReportPlaceholders } from './placeholders.js';
15
+ import { BUILT_IN_NOISE, expectedNoiseForPrompt, isExpectedNoise } from './noise.js';
16
+ import { MIN_PASSWORD_CHARS } from '../briefLocal.js';
15
17
 
16
18
  /**
17
19
  * One persona's playthrough: PLAYING then REPORTING (spec 03).
@@ -130,6 +132,9 @@ export async function runPersona(args) {
130
132
  runDirectory,
131
133
  skill,
132
134
  brief = null,
135
+ // The brief fields that stay on this machine (../briefLocal.js):
136
+ // expected_noise and test_account.
137
+ localBrief = {},
133
138
  intentNotes = [],
134
139
  schemas,
135
140
  maxActions,
@@ -267,6 +272,7 @@ export async function runPersona(args) {
267
272
  validator,
268
273
  skill: args.reportSkill || skill,
269
274
  brief,
275
+ localBrief,
270
276
  intentNotes,
271
277
  transcript,
272
278
  ledger,
@@ -330,6 +336,7 @@ async function playLoop({
330
336
  target,
331
337
  skill,
332
338
  brief,
339
+ localBrief = {},
333
340
  intentNotes,
334
341
  maxActions,
335
342
  transcript,
@@ -344,8 +351,10 @@ async function playLoop({
344
351
  onBudgetWarning = null,
345
352
  log = () => {}
346
353
  }) {
347
- const system = buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions, runId });
354
+ const system = buildSystem({ skill, persona, brief, localBrief, intentNotes, driverName, target, maxActions, runId });
348
355
  const identity = runIdentity({ persona, runId });
356
+ const secrets = secretsOf(localBrief);
357
+ const noisePatterns = (localBrief && Array.isArray(localBrief.expected_noise)) ? localBrief.expected_noise : [];
349
358
  if (controller === 'jev') system.push({ type: 'text', text: JEV_CONTROLLER_PROMPT });
350
359
  const availableTools = driver.supportedActions
351
360
  ? [...PLAY_TOOLS, ...(driver.supportedActions.includes('swipe') ? [SWIPE_TOOL] : [])].filter(tool => !ACTION_TOOLS.includes(tool.name) || driver.supportedActions.includes(tool.name))
@@ -391,7 +400,9 @@ async function playLoop({
391
400
  return entry;
392
401
  };
393
402
  const noted = note => {
394
- const entry = { ...note, at_action: actions, observation: observations.length, order: nextOrder() };
403
+ // A note never carries the test account password, in the transcript, a
404
+ // partial report or the report prompt.
405
+ const entry = { ...scrubSecrets(note, secrets), at_action: actions, observation: observations.length, order: nextOrder() };
395
406
  notes.push(entry);
396
407
  awaitingAfter.push(entry);
397
408
  return entry;
@@ -520,7 +531,7 @@ async function playLoop({
520
531
  // overwrite the transcript line's `kind`, and every note would
521
532
  // read as a different sort of line.
522
533
  noted(note);
523
- transcript.write({ kind: 'note', tool: toolUse.name, note });
534
+ transcript.write({ kind: 'note', tool: toolUse.name, note: scrubSecrets(note, secrets) });
524
535
  },
525
536
  onScreenshot: shot => {
526
537
  screenshots.push({ ...shot, observation: observations.length, order: nextOrder() });
@@ -533,8 +544,8 @@ async function playLoop({
533
544
  const actionLine = {
534
545
  kind: 'action',
535
546
  tool: toolUse.name,
536
- input: redactToolInput(toolUse),
537
- thought: (toolUse.input && toolUse.input.thought_in_character) || null,
547
+ input: redactToolInput(toolUse, secrets),
548
+ thought: scrubSecrets((toolUse.input && toolUse.input.thought_in_character) || null, secrets),
538
549
  ok: !executed.error,
539
550
  error: executed.error,
540
551
  changed: executed.changed,
@@ -597,8 +608,12 @@ async function playLoop({
597
608
  ...(entry.url && Number.isInteger(entry.status) ? { status: entry.status } : {}),
598
609
  ...(shot ? { screenshot: shot.relativePath } : {})
599
610
  };
611
+ // Noise the developer said to expect (or the built in rule: a 401 on
612
+ // an auth check while signed out) is marked, so the report step is
613
+ // told and the runner can drop a finding that rests only on it.
614
+ if (isExpectedNoise({ ...note, status: Number.isInteger(entry.status) ? entry.status : note.status }, noisePatterns)) note.expected_noise = true;
600
615
  noted(note);
601
- transcript.write({ kind: 'note', note });
616
+ transcript.write({ kind: 'note', note: scrubSecrets(note, secrets) });
602
617
  }
603
618
  asides.push(`The game logged ${consoleErrors.length} error(s): ${consoleErrors.map(entry => entry.text).join(' | ').slice(0, 800)}${shot ? ` A screenshot is saved at ${shot.relativePath}.` : ''}`);
604
619
  }
@@ -664,9 +679,11 @@ async function reportPhase({
664
679
  driverName,
665
680
  target,
666
681
  runDirectory,
682
+ schemas = [],
667
683
  validator,
668
684
  skill,
669
685
  brief,
686
+ localBrief = {},
670
687
  intentNotes,
671
688
  transcript,
672
689
  ledger,
@@ -678,7 +695,11 @@ async function reportPhase({
678
695
  endedAt,
679
696
  log
680
697
  }) {
681
- const system = buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions: play.actionsTaken, runId });
698
+ // The report step never sees the test account: it has nothing to sign in
699
+ // to, and the password crosses the model proxy during play only.
700
+ const secrets = secretsOf(localBrief);
701
+ const reportLocal = localBrief && Array.isArray(localBrief.expected_noise) ? { expected_noise: localBrief.expected_noise } : {};
702
+ const system = buildSystem({ skill, persona, brief, localBrief: reportLocal, intentNotes, driverName, target, maxActions: play.actionsTaken, runId });
682
703
  const messages = [{
683
704
  role: 'user',
684
705
  content: [{
@@ -693,6 +714,7 @@ async function reportPhase({
693
714
  '',
694
715
  'Notes marked automatic were recorded by the runner from the console, with no severity: decide one from what the persona saw. Network notes are context, not bugs: a failed request is a finding only when the persona saw a broken screen because of it. A finding whose only evidence is automatic network notes is capped at minor by the runner.',
695
716
  'Driver errors are never evidence of a game bug. An observation marked [driver error: ...] or a note "written on a driver error" means the runner could not carry out the action (a target that matched two elements, locator code, a ref not on screen, a timeout on a page that did not change); the game did nothing wrong there. Never report a blocked loop, a frozen screen or a broken button from them. The runner drops any blocker or major whose only cited observations are driver errors.',
717
+ `Notes marked expected noise match the brief's expected_noise or the built in rule (${BUILT_IN_NOISE}); they are never a finding of any severity, and the runner drops a finding whose transcript evidence is only expected noise.`,
696
718
  'Notes of kind waiting ("waiting for X to finish") are context, never a finding of any severity, and the runner drops a finding cited only by them. Before reporting a loading problem, check the later observations and screenshots: if they show the content loaded, it is not a finding.',
697
719
  '',
698
720
  'Observations the persona was shown, by index:',
@@ -707,7 +729,7 @@ async function reportPhase({
707
729
  networkNoteLines(notes),
708
730
  '',
709
731
  'Each note carries "after": the URL and title of the observation that followed it. A blocker or major finding must cite the observation index where the failure is visible, as evidence {"type": "transcript_step", "ref": "observation N"}, and must not be contradicted by its note\'s "after" state or by a later persona note: if the next screen or a later note shows the game working, it is not a blocker or a major. Cite a note as {"type": "transcript_step", "ref": "note N"}.',
710
- 'A blocker or major finding must also cite the screenshot taken at or after that observation (the list below says which observation each one followed), and that screenshot must not contradict it: if it shows the thing the finding says is missing or broken, such as the field or the button, it is not a blocker or a major. A text observation can be cut short on a long page; the screenshot is what the player saw.',
732
+ 'A blocker or major finding must also cite the screenshot taken at or after that observation (the list below says which observation each one followed), and that screenshot must not contradict it: if it shows the thing the finding says is missing or broken, such as the field or the button, it is not a blocker or a major. A text observation can be cut short on a long page; the screenshot is what the player saw. After you write the report, the runner shows each blocker or major (at most three) its cited screenshot and downgrades one step any finding the image contradicts.',
711
733
  '',
712
734
  'Every evidence entry is an object with exactly two keys, "type" and "ref", and nothing else. A screenshot is {"type": "screenshot", "ref": "screenshots/04-example.png"}, never "path" or "file".',
713
735
  '',
@@ -722,6 +744,9 @@ async function reportPhase({
722
744
  ].join('\n')
723
745
  }]
724
746
  }];
747
+ // The prompt is built from notes, observations and the action log; none
748
+ // of them may carry the test account password into the report step.
749
+ messages[0].content[0].text = scrubSecrets(messages[0].content[0].text, secrets);
725
750
 
726
751
  let report = null;
727
752
  let markdown = null;
@@ -733,6 +758,8 @@ async function reportPhase({
733
758
  let continued = false;
734
759
  let droppedByRunner = [];
735
760
  let downgradedByRunner = [];
761
+ let normalisedByRunner = [];
762
+ const reportSchema = ((schemas || []).find(schema => schema && schema.name === 'playtest-report') || {}).content || null;
736
763
 
737
764
  // One repair at most when the schema or the evidence refs refuse what was
738
765
  // written: each turn is a metered question, and the dogfood gate asks for
@@ -833,7 +860,16 @@ async function reportPhase({
833
860
  if (normalised && normalised.changed > 0) {
834
861
  transcript.write({ kind: 'report_evidence_normalised', attempt, entries: normalised.changed });
835
862
  }
836
- const capped = normalised ? capNetworkOnlyFindings(stampReport(normalised.report, facts), { notes, screenshots }) : null;
863
+ // Known out of range values (a category written as the severity, an
864
+ // unknown category) are brought back into the schema before anything
865
+ // judges the report, so a run is never lost to them for want of a repair
866
+ // turn. The server's schema stays strict.
867
+ const enumFixed = normalised ? normaliseEnums(normalised.report, reportSchema) : { report: null, changes: [] };
868
+ normalisedByRunner = enumFixed.changes;
869
+ if (enumFixed.changes.length > 0) {
870
+ transcript.write({ kind: 'report_values_normalised', attempt, changes: enumFixed.changes.map(change => ({ id: change.id, field: change.field, from: change.from, to: change.to })) });
871
+ }
872
+ const capped = normalised ? capNetworkOnlyFindings(stampReport(enumFixed.report, facts), { notes, screenshots }) : null;
837
873
  const runnerDrop = capped ? dropRunnerRejectedFindings(capped, { notes, observations }) : { report: null, dropped: [] };
838
874
  for (const entry of runnerDrop.dropped) {
839
875
  transcript.write({ kind: 'finding_dropped', id: entry.finding.id, severity: entry.finding.severity, title: entry.finding.title, reason: entry.reason });
@@ -906,6 +942,22 @@ async function reportPhase({
906
942
  });
907
943
  }
908
944
 
945
+ // One bounded look at the evidence: each blocker or major (at most
946
+ // MAX_CLAIM_CHECKS) is shown the screenshot it cites with its claim. It runs
947
+ // after the report is settled, so a refusal can cost the check and never
948
+ // the report.
949
+ report = scrubSecrets(report, secrets);
950
+ if (!shrunk) {
951
+ const checked = await checkClaimsAgainstScreenshots({ client, jobId, runId, report, screenshots, runDirectory, ledger, transcript, log });
952
+ if (checked.downgraded.length > 0) {
953
+ report = checked.report;
954
+ downgradedByRunner = [...downgradedByRunner, ...checked.downgraded];
955
+ log(`downgraded ${checked.downgraded.length} finding(s) their own screenshot contradicts: ${checked.downgraded.map(entry => `${entry.id} (${entry.from} to ${entry.to}, ${entry.screenshot})`).join(', ')}`);
956
+ }
957
+ } else {
958
+ transcript.write({ kind: 'claim_check_skipped', reason: 'the report turn needed a smaller output ceiling to fit the budget' });
959
+ }
960
+
909
961
  if (!markdown) {
910
962
  markdown = renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason: 'The report step wrote report.json but no report.md.' });
911
963
  }
@@ -915,16 +967,152 @@ async function reportPhase({
915
967
  }
916
968
  // The uploaded pair agree: report.md says the severity report.json does.
917
969
  if (downgradedByRunner.length > 0) markdown = rewriteDowngradedMarkdown(markdown, downgradedByRunner);
970
+ if (normalisedByRunner.length > 0) {
971
+ markdown = appendNormalisedMarkdown(markdown, normalisedByRunner);
972
+ log(`normalised ${normalisedByRunner.length} value(s) the report schema does not allow: ${normalisedByRunner.map(change => `${change.id ? `${change.id} ` : ''}${change.field} ${JSON.stringify(change.from)} to ${JSON.stringify(change.to)}`).join(', ')}`);
973
+ }
918
974
  const generatedAt = new Date().toISOString();
919
975
  const facts = reportFacts({ persona, driverName, target, play });
920
976
  markdown = stampGeneratedAt(markdown, generatedAt, facts);
921
977
  // The same slots in the prose the dashboard shows from report.json.
922
978
  report = fillReportPlaceholders(report, { generatedAt, ...facts });
979
+ // Whatever the model wrote, the password does not leave in a report.
980
+ markdown = scrubSecrets(markdown, secrets);
981
+ report = scrubSecrets(report, secrets);
923
982
 
924
983
  await writeReportFiles({ runDirectory, markdown, report });
925
984
  return { report, markdown, lint };
926
985
  }
927
986
 
987
+ /** The most blocker or major findings whose screenshot is checked against the claim. */
988
+ export const MAX_CLAIM_CHECKS = 3;
989
+
990
+ /** Output ceiling for one claim check: the answer is one word. */
991
+ export const CLAIM_CHECK_MAX_TOKENS = 32;
992
+
993
+ /** The instruction for a claim check. */
994
+ export const CLAIM_CHECK_SYSTEM = 'You check playtest findings against their evidence. You are shown one screenshot and one claim about it. Answer with exactly one word: yes if the image contradicts the claim, no if it does not, unclear if the image cannot settle it.';
995
+
996
+ /**
997
+ * The screenshot a finding's claim is checked against: of those it cites, the
998
+ * one taken nearest after the latest observation it cites (the later of two on
999
+ * the same observation), else the last screenshot it cites. Null when it cites none that exists in the list.
1000
+ *
1001
+ * @param {Object} finding
1002
+ * @param {Array<Object>} screenshots
1003
+ * @returns {Object|null}
1004
+ */
1005
+ export function citedAfterScreenshot(finding, screenshots) {
1006
+ const evidence = Array.isArray(finding && finding.evidence) ? finding.evidence : [];
1007
+ const byPath = new Map((screenshots || []).filter(shot => shot && shot.relativePath).map(shot => [shot.relativePath, shot]));
1008
+ const cited = evidence.filter(item => item && item.type === 'screenshot' && byPath.has(String(item.ref))).map(item => byPath.get(String(item.ref)));
1009
+ if (cited.length === 0) return null;
1010
+ const observations = evidence
1011
+ .map(item => (item && item.type === 'transcript_step' ? String(item.ref || '').match(/^\s*observation\s*#?\s*(\d+)\s*$/i) : null))
1012
+ .filter(Boolean)
1013
+ .map(match => Number(match[1]));
1014
+ if (observations.length > 0) {
1015
+ const latest = Math.max(...observations);
1016
+ // The nearest screen after the claim: the lowest observation at or after
1017
+ // it, and of two taken on the same observation the later one.
1018
+ const order = new Map((screenshots || []).map((shot, index) => [shot, index]));
1019
+ const after = cited
1020
+ .filter(shot => Number.isInteger(shot.observation) && shot.observation >= latest)
1021
+ .sort((a, b) => a.observation - b.observation || order.get(b) - order.get(a))[0];
1022
+ if (after) return after;
1023
+ }
1024
+ return cited[cited.length - 1];
1025
+ }
1026
+
1027
+ /**
1028
+ * Read a claim check answer: yes, no or unclear, from its first word.
1029
+ *
1030
+ * @param {string} text
1031
+ * @returns {'yes'|'no'|'unclear'}
1032
+ */
1033
+ export function claimCheckAnswer(text) {
1034
+ const match = String(text || '').match(/^[^A-Za-z]*(yes|no|unclear)\b/i);
1035
+ return match ? match[1].toLowerCase() : 'unclear';
1036
+ }
1037
+
1038
+ /**
1039
+ * The bounded verification turn of the report step. For each blocker or
1040
+ * major, at most {@link MAX_CLAIM_CHECKS}, the cited `after` screenshot is
1041
+ * sent with the finding's claim and the question "does this image contradict
1042
+ * the claim: yes, no, unclear". A yes downgrades the finding one step, with a
1043
+ * note naming the screenshot. Texas and Snowball both filed a finding its own
1044
+ * screenshot contradicted.
1045
+ *
1046
+ * It goes to `persona_playtest.act`, the run's one step that accepts an image
1047
+ * (the report step accepts none), with no tools and a one word ceiling, and
1048
+ * it is spent from the same run budget the warning at 80 percent keeps for the
1049
+ * report step. A budget refusal, or any other refusal, ends the checks and
1050
+ * keeps the report as it is.
1051
+ *
1052
+ * @returns {Promise<{report: Object, downgraded: Array<Object>}>}
1053
+ */
1054
+ export async function checkClaimsAgainstScreenshots({ client, jobId, runId, report, screenshots, runDirectory, ledger, transcript, log = () => {} }) {
1055
+ if (!report || !Array.isArray(report.findings)) return { report, downgraded: [] };
1056
+ const targets = report.findings.filter(finding => finding && ABOVE_MINOR.includes(finding.severity)).slice(0, MAX_CLAIM_CHECKS);
1057
+ const downgraded = [];
1058
+ const verdicts = new Map();
1059
+ for (const finding of targets) {
1060
+ const shot = citedAfterScreenshot(finding, screenshots);
1061
+ if (!shot) {
1062
+ transcript.write({ kind: 'claim_check', id: finding.id, screenshot: null, answer: 'skipped', reason: 'no cited screenshot' });
1063
+ continue;
1064
+ }
1065
+ let data;
1066
+ try {
1067
+ data = (await readFile(path.join(runDirectory, shot.relativePath))).toString('base64');
1068
+ } catch {
1069
+ transcript.write({ kind: 'claim_check', id: finding.id, screenshot: shot.relativePath, answer: 'skipped', reason: 'the screenshot could not be read' });
1070
+ continue;
1071
+ }
1072
+ let answer;
1073
+ const askedAt = Date.now();
1074
+ try {
1075
+ answer = await callStepWithRetry({
1076
+ client,
1077
+ jobId,
1078
+ runId,
1079
+ step: STEPS.personaAct,
1080
+ system: CLAIM_CHECK_SYSTEM,
1081
+ messages: [{
1082
+ role: 'user',
1083
+ content: [
1084
+ { type: 'image', source: { type: 'base64', media_type: 'image/png', data } },
1085
+ { type: 'text', text: `Claim (${finding.severity}): ${finding.title || ''}\n${String(finding.description || '').slice(0, 1200)}\n\nDoes this image contradict the claim: yes, no, unclear?` }
1086
+ ]
1087
+ }],
1088
+ maxTokens: CLAIM_CHECK_MAX_TOKENS,
1089
+ escalate: false,
1090
+ maxAttempts: 1
1091
+ });
1092
+ } catch (error) {
1093
+ const reason = error instanceof ProxyError && error.isBudget ? 'the run budget is spent' : String(error && error.message ? error.message : error);
1094
+ transcript.write({ kind: 'claim_check', id: finding.id, screenshot: shot.relativePath, answer: 'skipped', reason });
1095
+ log(`the screenshot check stopped (${reason}); the report is kept as written.`);
1096
+ break;
1097
+ }
1098
+ if (ledger) ledger.record({ model: answer.model, usage: answer.usage, ms: Date.now() - askedAt });
1099
+ const verdict = claimCheckAnswer(answer.text);
1100
+ transcript.write({ kind: 'claim_check', id: finding.id, screenshot: shot.relativePath, answer: verdict });
1101
+ if (verdict === 'yes' && DOWNGRADE[finding.severity]) verdicts.set(finding, shot.relativePath);
1102
+ }
1103
+ if (verdicts.size === 0) return { report, downgraded };
1104
+ const findings = report.findings.map(finding => {
1105
+ if (!verdicts.has(finding)) return finding;
1106
+ const screenshot = verdicts.get(finding);
1107
+ const to = DOWNGRADE[finding.severity];
1108
+ const note = `its own screenshot ${screenshot} contradicts the claim`;
1109
+ downgraded.push({ id: finding.id, from: finding.severity, to, screenshot, note });
1110
+ const description = String(finding.description || '');
1111
+ return { ...finding, severity: to, description: description ? `${description}\n\nDowngraded by the runner: ${note}.` : `Downgraded by the runner: ${note}.` };
1112
+ });
1113
+ return { report: { ...report, findings }, downgraded };
1114
+ }
1115
+
928
1116
  /** The smallest output ceiling worth retrying a report turn with. */
929
1117
  export const REPORT_MIN_TOKENS = 1536;
930
1118
 
@@ -1024,8 +1212,9 @@ async function callWithImageRetry({ imageBudget, keepObservations, messages, onI
1024
1212
  * product where the same few thousand tokens are re-sent tens of times. The
1025
1213
  * proxy allowlists `system` and passes the blocks through unchanged.
1026
1214
  */
1027
- export function buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions, runId = null }) {
1215
+ export function buildSystem({ skill, persona, brief, localBrief = {}, intentNotes, driverName, target, maxActions, runId = null }) {
1028
1216
  const identity = runIdentity({ persona, runId });
1217
+ const account = testAccountOf(localBrief);
1029
1218
  return [
1030
1219
  { type: 'text', text: skill },
1031
1220
  { type: 'text', text: `<persona_file>\n${persona.body}\n</persona_file>` },
@@ -1037,7 +1226,9 @@ export function buildSystem({ skill, persona, brief, intentNotes, driverName, ta
1037
1226
  `max_actions: ${maxActions}`,
1038
1227
  `<expectations_brief>\n${briefForPrompt(brief)}\n</expectations_brief>`,
1039
1228
  `<design_intent_notes>\n${intentNotesForPrompt(brief, intentNotes)}\n</design_intent_notes>`,
1040
- ...(identity ? [`<run_identity>\n${runIdentityForPrompt(identity)}\n</run_identity>`] : [])
1229
+ `<expected_noise>\nConsole errors and failed requests matching these are expected and never a finding:\n${expectedNoiseForPrompt(localBrief && localBrief.expected_noise)}\n</expected_noise>`,
1230
+ ...(account ? [`<test_account>\n${testAccountForPrompt(account)}\n</test_account>`] : []),
1231
+ ...(identity ? [`<run_identity>\n${runIdentityForPrompt(identity, { testAccount: Boolean(account) })}\n</run_identity>`] : [])
1041
1232
  ].join('\n'),
1042
1233
  cache_control: { type: 'ephemeral' }
1043
1234
  }
@@ -1077,11 +1268,41 @@ export function runIdentity({ persona, runId }) {
1077
1268
  * @param {{email: string, displayName: string}} identity
1078
1269
  * @returns {string}
1079
1270
  */
1080
- export function runIdentityForPrompt(identity) {
1271
+ export function runIdentityForPrompt(identity, { testAccount = false } = {}) {
1081
1272
  return [
1082
1273
  `email: ${identity.email}`,
1083
1274
  `display_name: ${identity.displayName}`,
1084
- 'Use this email and this display name whenever a form asks for an email, a name, a username or a coach or player name, unless the brief says otherwise. They are new for this run, so an account made with them does not exist yet. Never reuse an email from an earlier run.'
1275
+ testAccount
1276
+ ? 'Sign in with the test account above. Use this email and this display name only if you have to make a new account, and whenever a form asks for a display name, a coach or a player name. Never reuse an email from an earlier run.'
1277
+ : 'Use this email and this display name whenever a form asks for an email, a name, a username or a coach or player name, unless the brief says otherwise. They are new for this run, so an account made with them does not exist yet. Never reuse an email from an earlier run.'
1278
+ ].join('\n');
1279
+ }
1280
+
1281
+ /**
1282
+ * The local brief's test account, or null when it has none that can be used.
1283
+ *
1284
+ * @param {Object} localBrief
1285
+ * @returns {{email: string, password: string, how_to_sign_in: string|null}|null}
1286
+ */
1287
+ export function testAccountOf(localBrief) {
1288
+ const account = localBrief && localBrief.test_account;
1289
+ if (!account || typeof account !== 'object' || typeof account.email !== 'string' || !account.email || typeof account.password !== 'string' || account.password.length < MIN_PASSWORD_CHARS) return null;
1290
+ return { email: account.email, password: account.password, how_to_sign_in: typeof account.how_to_sign_in === 'string' && account.how_to_sign_in.trim() ? account.how_to_sign_in.trim() : null };
1291
+ }
1292
+
1293
+ /**
1294
+ * The test account block of the persona prompt: an account the developer made
1295
+ * so a run can start past sign up.
1296
+ *
1297
+ * @param {{email: string, password: string, how_to_sign_in: string|null}} account
1298
+ * @returns {string}
1299
+ */
1300
+ export function testAccountForPrompt(account) {
1301
+ return [
1302
+ `email: ${account.email}`,
1303
+ `password: ${account.password}`,
1304
+ ...(account.how_to_sign_in ? [`how_to_sign_in: ${account.how_to_sign_in}`] : []),
1305
+ 'This is a throwaway account the developer made for this playtest. Sign in with it rather than signing up, so your actions go to the game itself. Never write the password in a note or a report.'
1085
1306
  ].join('\n');
1086
1307
  }
1087
1308
 
@@ -1152,11 +1373,54 @@ export function assistantContent(answer) {
1152
1373
  return content;
1153
1374
  }
1154
1375
 
1155
- /** Keep the transcript readable without storing a whole screenshot in it. */
1156
- function redactToolInput(toolUse) {
1376
+ /** What a typed secret is written as in the transcript and the action log. */
1377
+ export const REDACTED_SECRET = '[test password]';
1378
+
1379
+ /**
1380
+ * Every string in a value with each secret replaced by {@link REDACTED_SECRET},
1381
+ * through arrays and plain objects. A new value; the input is not changed.
1382
+ *
1383
+ * @param {any} value
1384
+ * @param {Array<string>} secrets
1385
+ * @returns {any}
1386
+ */
1387
+ export function scrubSecrets(value, secrets = []) {
1388
+ if (!Array.isArray(secrets) || secrets.length === 0) return value;
1389
+ if (typeof value === 'string') {
1390
+ let text = value;
1391
+ // Whole tokens only: the password with no letter, digit or underscore
1392
+ // directly before or after it, so it is replaced where it was written
1393
+ // (alone, quoted, after a colon) and never inside a longer word.
1394
+ for (const secret of secrets) {
1395
+ if (!secret) continue;
1396
+ const escaped = secret.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1397
+ text = text.replace(new RegExp(`(?<![\\p{L}\\p{N}_])${escaped}(?![\\p{L}\\p{N}_])`, 'gu'), REDACTED_SECRET);
1398
+ }
1399
+ return text;
1400
+ }
1401
+ if (Array.isArray(value)) return value.map(entry => scrubSecrets(entry, secrets));
1402
+ if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, scrubSecrets(entry, secrets)]));
1403
+ return value;
1404
+ }
1405
+
1406
+ /**
1407
+ * The strings the transcript must not repeat: the local brief's test account
1408
+ * password. It is in the prompt (that is how a persona can type it), but the
1409
+ * transcript can be uploaded and the action log is shown in the report step.
1410
+ *
1411
+ * @param {Object} localBrief
1412
+ * @returns {Array<string>}
1413
+ */
1414
+ export function secretsOf(localBrief) {
1415
+ const password = localBrief && localBrief.test_account && localBrief.test_account.password;
1416
+ return typeof password === 'string' && password.length >= MIN_PASSWORD_CHARS ? [password] : [];
1417
+ }
1418
+
1419
+ /** Keep the transcript readable without storing a whole screenshot in it, or a test account password. */
1420
+ export function redactToolInput(toolUse, secrets = []) {
1157
1421
  if (!toolUse.input) return null;
1158
1422
  const { thought_in_character: ignored, ...rest } = toolUse.input;
1159
- return rest;
1423
+ return scrubSecrets(rest, secrets);
1160
1424
  }
1161
1425
 
1162
1426
  async function safeScreenshot(driver, name, screenshots, transcript, observation = null) {
@@ -1299,6 +1563,8 @@ export function notesToFindings(notes, screenshots) {
1299
1563
  if (!category || !note.text) continue;
1300
1564
  // A blocker or major written on a driver error is the runner's failure.
1301
1565
  if (note.driver_error && ABOVE_MINOR.includes(note.severity)) continue;
1566
+ // Console noise the developer said to expect is never a finding.
1567
+ if (note.expected_noise) continue;
1302
1568
  const text = String(note.text);
1303
1569
  const shot = nearestScreenshot(note, screenshots);
1304
1570
  findings.push({
@@ -1377,6 +1643,7 @@ export function noteLine(note, i) {
1377
1643
  const tags = [
1378
1644
  note.automatic ? 'automatic' : null,
1379
1645
  note.driver_error ? 'written on a driver error' : null,
1646
+ note.expected_noise ? 'expected noise' : null,
1380
1647
  Number.isInteger(note.observation) && note.observation > 0 ? `seen at observation ${note.observation}` : null,
1381
1648
  note.screenshot ? `screenshot ${note.screenshot}` : null
1382
1649
  ].filter(Boolean);
@@ -1449,8 +1716,8 @@ export function observationTitle(observation) {
1449
1716
  */
1450
1717
  /** How report.md lists what the runner dropped. */
1451
1718
  export const RUNNER_DROPPED_SECTION = Object.freeze({
1452
- heading: '## Dropped by the runner (driver errors or waiting notes only)',
1453
- explanation: 'The runner removes a blocker or major whose only cited observations are driver errors (the runner could not carry out an action; the game did nothing wrong), and any finding cited only by waiting notes. They are not sent to the server. They are kept here so you can judge them yourself.',
1719
+ heading: '## Dropped by the runner (driver errors, waiting notes or expected noise only)',
1720
+ explanation: 'The runner removes a blocker or major whose only cited observations are driver errors (the runner could not carry out an action; the game did nothing wrong), and any finding cited only by waiting notes or by console noise the brief says to expect. They are not sent to the server. They are kept here so you can judge them yourself.',
1454
1721
  emptyNote: '- Nothing here that the runner kept. See the dropped findings below.'
1455
1722
  });
1456
1723
 
@@ -1473,11 +1740,26 @@ export const RUNNER_DROPPED_SECTION = Object.freeze({
1473
1740
  export function dropRunnerRejectedFindings(report, { notes = [], observations = [] } = {}) {
1474
1741
  if (!report || !Array.isArray(report.findings)) return { report, dropped: [] };
1475
1742
  const byIndex = new Map(observations.map(entry => [entry.index, entry]));
1743
+ // The runner's console-error screenshot of a moment whose every console
1744
+ // note was expected noise says nothing more than those notes do.
1745
+ const noiseShots = new Set();
1746
+ const shotNotes = new Map();
1747
+ for (const note of notes) {
1748
+ if (!note || !note.automatic || !note.screenshot) continue;
1749
+ if (!shotNotes.has(note.screenshot)) shotNotes.set(note.screenshot, []);
1750
+ shotNotes.get(note.screenshot).push(note);
1751
+ }
1752
+ for (const [shot, attached] of shotNotes) if (attached.every(note => note.expected_noise)) noiseShots.add(shot);
1476
1753
  const verdict = finding => {
1477
1754
  const cited = [];
1478
1755
  for (const item of Array.isArray(finding.evidence) ? finding.evidence : []) {
1479
- if (!item || item.type !== 'transcript_step') continue;
1756
+ if (!item) continue;
1480
1757
  const ref = String(item.ref || '');
1758
+ if (item.type === 'screenshot') {
1759
+ if (noiseShots.has(ref)) cited.push('noise');
1760
+ continue;
1761
+ }
1762
+ if (item.type !== 'transcript_step') continue;
1481
1763
  const observation = ref.match(/^\s*observation\s*#?\s*(\d+)\s*$/i);
1482
1764
  const note = ref.match(/^\s*note\s*#?\s*(\d+)\s*$/i);
1483
1765
  if (observation) {
@@ -1485,7 +1767,7 @@ export function dropRunnerRejectedFindings(report, { notes = [], observations =
1485
1767
  cited.push(entry && entry.driver_error ? 'driver' : 'other');
1486
1768
  } else if (note) {
1487
1769
  const entry = notes[Number(note[1]) - 1];
1488
- cited.push(entry && entry.kind === 'waiting' ? 'waiting' : entry && entry.driver_error ? 'driver' : 'other');
1770
+ cited.push(entry && entry.kind === 'waiting' ? 'waiting' : entry && entry.expected_noise ? 'noise' : entry && entry.driver_error ? 'driver' : 'other');
1489
1771
  } else {
1490
1772
  cited.push('other');
1491
1773
  }
@@ -1493,7 +1775,9 @@ export function dropRunnerRejectedFindings(report, { notes = [], observations =
1493
1775
  if (cited.length === 0) return null;
1494
1776
  if (ABOVE_MINOR.includes(finding.severity) && cited.every(kind => kind === 'driver')) return 'driver errors only';
1495
1777
  if (cited.every(kind => kind === 'waiting')) return 'waiting notes only';
1496
- if (ABOVE_MINOR.includes(finding.severity) && cited.every(kind => kind === 'driver' || kind === 'waiting')) return 'driver errors and waiting notes only';
1778
+ if (cited.every(kind => kind === 'noise')) return 'expected console noise only';
1779
+ if (cited.every(kind => kind === 'waiting' || kind === 'noise')) return 'expected console noise and waiting notes only';
1780
+ if (ABOVE_MINOR.includes(finding.severity) && cited.every(kind => kind === 'driver' || kind === 'waiting' || kind === 'noise')) return 'driver errors, waiting notes and expected noise only';
1497
1781
  return null;
1498
1782
  };
1499
1783
  const dropped = [];
@@ -1722,7 +2006,7 @@ export function downgradeContradictedFindings(report, log) {
1722
2006
  }
1723
2007
 
1724
2008
  /** How report.md lists the findings the runner downgraded. */
1725
- export const DOWNGRADED_HEADING = '## Downgraded by the runner (the action log contradicts the claim)';
2009
+ export const DOWNGRADED_HEADING = '## Downgraded by the runner (the action log or its own screenshot contradicts the claim)';
1726
2010
 
1727
2011
  /**
1728
2012
  * report.md agreeing with report.json about downgraded findings: on every