ravensight-playtest 0.3.0 → 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.
@@ -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,7 +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 });
354
+ const system = buildSystem({ skill, persona, brief, localBrief, intentNotes, driverName, target, maxActions, runId });
355
+ const identity = runIdentity({ persona, runId });
356
+ const secrets = secretsOf(localBrief);
357
+ const noisePatterns = (localBrief && Array.isArray(localBrief.expected_noise)) ? localBrief.expected_noise : [];
348
358
  if (controller === 'jev') system.push({ type: 'text', text: JEV_CONTROLLER_PROMPT });
349
359
  const availableTools = driver.supportedActions
350
360
  ? [...PLAY_TOOLS, ...(driver.supportedActions.includes('swipe') ? [SWIPE_TOOL] : [])].filter(tool => !ACTION_TOOLS.includes(tool.name) || driver.supportedActions.includes(tool.name))
@@ -362,6 +372,9 @@ async function playLoop({
362
372
  }
363
373
  })) : availableTools;
364
374
  const recentActions = [];
375
+ // The observation and action lines of the transcript, kept for the report
376
+ // step's "Actions you took" list.
377
+ const actionRecords = [];
365
378
  let latestObservation = '';
366
379
  let visualObservation = false;
367
380
  const messages = [];
@@ -387,7 +400,9 @@ async function playLoop({
387
400
  return entry;
388
401
  };
389
402
  const noted = note => {
390
- 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() };
391
406
  notes.push(entry);
392
407
  awaitingAfter.push(entry);
393
408
  return entry;
@@ -398,6 +413,7 @@ async function playLoop({
398
413
  latestObservation = first.text;
399
414
  visualObservation = Boolean(first.screenshotBase64);
400
415
  transcript.write({ kind: 'observation', step: first.step, text: first.text, url: first.url });
416
+ actionRecords.push({ kind: 'observation', text: first.text });
401
417
  const landing = await safeScreenshot(driver, 'landing', screenshots, transcript, observations.length);
402
418
  messages.push({
403
419
  role: 'user',
@@ -508,13 +524,14 @@ async function playLoop({
508
524
  driver,
509
525
  toolUse,
510
526
  staleRefs: screenChangedThisTurn,
527
+ identity,
511
528
  onNote: note => {
512
529
  // The note is nested rather than spread: its own `kind`
513
530
  // (bug, confusing, good, suggestion, reaction) would otherwise
514
531
  // overwrite the transcript line's `kind`, and every note would
515
532
  // read as a different sort of line.
516
533
  noted(note);
517
- transcript.write({ kind: 'note', tool: toolUse.name, note });
534
+ transcript.write({ kind: 'note', tool: toolUse.name, note: scrubSecrets(note, secrets) });
518
535
  },
519
536
  onScreenshot: shot => {
520
537
  screenshots.push({ ...shot, observation: observations.length, order: nextOrder() });
@@ -524,16 +541,18 @@ async function playLoop({
524
541
  results.push(executed.result);
525
542
  if (executed.spentAction) actions += 1;
526
543
  if (executed.changed && ACTION_TOOLS.includes(toolUse.name)) screenChangedThisTurn = true;
527
- transcript.write({
544
+ const actionLine = {
528
545
  kind: 'action',
529
546
  tool: toolUse.name,
530
- input: redactToolInput(toolUse),
531
- 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),
532
549
  ok: !executed.error,
533
550
  error: executed.error,
534
551
  changed: executed.changed,
535
552
  actions
536
- });
553
+ };
554
+ transcript.write(actionLine);
555
+ actionRecords.push(actionLine);
537
556
  if (executed.driverError) {
538
557
  // The driver failed, not the game: a target that matched twice,
539
558
  // locator code, a ref not on screen, a timeout on an unchanged page.
@@ -551,6 +570,7 @@ async function playLoop({
551
570
  recentActions.push(`${toolUse.name}: ${executed.changed ? 'screen changed' : 'no observed change'}`);
552
571
  if (recentActions.length > 10) recentActions.shift();
553
572
  transcript.write({ kind: 'observation', step: executed.observation.step, text: executed.observation.text, url: executed.observation.url });
573
+ actionRecords.push({ kind: 'observation', text: executed.observation.text });
554
574
  // Only an input to the game can tell us anything about being stuck. A
555
575
  // `snapshot` or a `screenshot` never changes the screen by design, so
556
576
  // counting those would have a persona that looks twice before acting
@@ -580,9 +600,20 @@ async function playLoop({
580
600
  const shot = await safeScreenshot(driver, 'console-error', screenshots, transcript, observations.length);
581
601
  for (const entry of consoleErrors) {
582
602
  const kind = RESOURCE_4XX.test(String(entry.text)) ? 'network' : 'bug';
583
- const note = { kind, text: `${entry.type}: ${entry.text}`, automatic: true, ...(shot ? { screenshot: shot.relativePath } : {}) };
603
+ const note = {
604
+ kind,
605
+ text: `${entry.type}: ${entry.text}`,
606
+ automatic: true,
607
+ ...(entry.url ? { url: String(entry.url).slice(0, 200) } : {}),
608
+ ...(entry.url && Number.isInteger(entry.status) ? { status: entry.status } : {}),
609
+ ...(shot ? { screenshot: shot.relativePath } : {})
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;
584
615
  noted(note);
585
- transcript.write({ kind: 'note', note });
616
+ transcript.write({ kind: 'note', note: scrubSecrets(note, secrets) });
586
617
  }
587
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}.` : ''}`);
588
619
  }
@@ -631,7 +662,8 @@ async function playLoop({
631
662
  budgetExceeded,
632
663
  canceled,
633
664
  imageBudget,
634
- finalScreenshot: final ? final.relativePath : null
665
+ finalScreenshot: final ? final.relativePath : null,
666
+ actionLog: actionLogFrom(actionRecords)
635
667
  };
636
668
  }
637
669
 
@@ -647,9 +679,11 @@ async function reportPhase({
647
679
  driverName,
648
680
  target,
649
681
  runDirectory,
682
+ schemas = [],
650
683
  validator,
651
684
  skill,
652
685
  brief,
686
+ localBrief = {},
653
687
  intentNotes,
654
688
  transcript,
655
689
  ledger,
@@ -661,7 +695,11 @@ async function reportPhase({
661
695
  endedAt,
662
696
  log
663
697
  }) {
664
- const system = buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions: play.actionsTaken });
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 });
665
703
  const messages = [{
666
704
  role: 'user',
667
705
  content: [{
@@ -676,13 +714,22 @@ async function reportPhase({
676
714
  '',
677
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.',
678
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.`,
679
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.',
680
719
  '',
681
720
  'Observations the persona was shown, by index:',
682
721
  observationLines(observations),
683
722
  '',
723
+ 'Actions you took (every action that went through, numbered by action; "screen changed" means the screen after it differed from the screen before):',
724
+ actionLogLines(play.actionLog),
725
+ '',
726
+ 'A claim in report.md or report.json that something was never done, never worked, did nothing or could not be done must agree with this list. If the list shows you clicked a control and the screen changed, do not write that you never clicked it or that it did nothing; describe what the screen showed after it instead. The runner downgrades a finding that says otherwise.',
727
+ '',
728
+ 'Network notes (failed requests the runner recorded, with the URL and the status; list them under a "Network notes" heading in report.md when there are any):',
729
+ networkNoteLines(notes),
730
+ '',
684
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"}.',
685
- '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.',
686
733
  '',
687
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".',
688
735
  '',
@@ -697,6 +744,9 @@ async function reportPhase({
697
744
  ].join('\n')
698
745
  }]
699
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);
700
750
 
701
751
  let report = null;
702
752
  let markdown = null;
@@ -707,6 +757,9 @@ async function reportPhase({
707
757
  let repaired = false;
708
758
  let continued = false;
709
759
  let droppedByRunner = [];
760
+ let downgradedByRunner = [];
761
+ let normalisedByRunner = [];
762
+ const reportSchema = ((schemas || []).find(schema => schema && schema.name === 'playtest-report') || {}).content || null;
710
763
 
711
764
  // One repair at most when the schema or the evidence refs refuse what was
712
765
  // written: each turn is a metered question, and the dogfood gate asks for
@@ -807,13 +860,30 @@ async function reportPhase({
807
860
  if (normalised && normalised.changed > 0) {
808
861
  transcript.write({ kind: 'report_evidence_normalised', attempt, entries: normalised.changed });
809
862
  }
810
- 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;
811
873
  const runnerDrop = capped ? dropRunnerRejectedFindings(capped, { notes, observations }) : { report: null, dropped: [] };
812
- const candidate = runnerDrop.report;
813
874
  for (const entry of runnerDrop.dropped) {
814
875
  transcript.write({ kind: 'finding_dropped', id: entry.finding.id, severity: entry.finding.severity, title: entry.finding.title, reason: entry.reason });
815
876
  }
816
877
  droppedByRunner = runnerDrop.dropped;
878
+ const contradicted = runnerDrop.report ? downgradeContradictedFindings(runnerDrop.report, play.actionLog) : { report: null, downgraded: [] };
879
+ const candidate = contradicted.report;
880
+ for (const entry of contradicted.downgraded) {
881
+ transcript.write({ kind: 'finding_downgraded', id: entry.id, from: entry.from, to: entry.to, control: entry.control, action: entry.action });
882
+ }
883
+ downgradedByRunner = contradicted.downgraded;
884
+ if (contradicted.downgraded.length > 0) {
885
+ log(`downgraded ${contradicted.downgraded.length} finding(s) the action log contradicts: ${contradicted.downgraded.map(entry => `${entry.id} (${entry.from} to ${entry.to}, ${entry.control} clicked at step ${entry.action})`).join(', ')}`);
886
+ }
817
887
  if (candidate) lastCandidate = candidate;
818
888
  const validation = candidate ? validator.validate(candidate) : { ok: false, details: [{ instancePath: '/', message: parseError || 'report.json was not written' }] };
819
889
  const evidenceProblems = candidate ? await checkEvidence(candidate, runDirectory) : [];
@@ -872,6 +942,22 @@ async function reportPhase({
872
942
  });
873
943
  }
874
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
+
875
961
  if (!markdown) {
876
962
  markdown = renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason: 'The report step wrote report.json but no report.md.' });
877
963
  }
@@ -879,16 +965,154 @@ async function reportPhase({
879
965
  markdown = rewriteMarkdown(markdown, droppedByRunner.map(entry => entry.finding), report.findings || [], RUNNER_DROPPED_SECTION);
880
966
  log(`dropped ${droppedByRunner.length} finding(s) the runner's evidence rules refuse: ${droppedByRunner.map(entry => `${entry.finding.id} (${entry.reason})`).join(', ')}`);
881
967
  }
968
+ // The uploaded pair agree: report.md says the severity report.json does.
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
+ }
882
974
  const generatedAt = new Date().toISOString();
883
975
  const facts = reportFacts({ persona, driverName, target, play });
884
976
  markdown = stampGeneratedAt(markdown, generatedAt, facts);
885
977
  // The same slots in the prose the dashboard shows from report.json.
886
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);
887
982
 
888
983
  await writeReportFiles({ runDirectory, markdown, report });
889
984
  return { report, markdown, lint };
890
985
  }
891
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
+
892
1116
  /** The smallest output ceiling worth retrying a report turn with. */
893
1117
  export const REPORT_MIN_TOKENS = 1536;
894
1118
 
@@ -988,7 +1212,9 @@ async function callWithImageRetry({ imageBudget, keepObservations, messages, onI
988
1212
  * product where the same few thousand tokens are re-sent tens of times. The
989
1213
  * proxy allowlists `system` and passes the blocks through unchanged.
990
1214
  */
991
- export function buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions }) {
1215
+ export function buildSystem({ skill, persona, brief, localBrief = {}, intentNotes, driverName, target, maxActions, runId = null }) {
1216
+ const identity = runIdentity({ persona, runId });
1217
+ const account = testAccountOf(localBrief);
992
1218
  return [
993
1219
  { type: 'text', text: skill },
994
1220
  { type: 'text', text: `<persona_file>\n${persona.body}\n</persona_file>` },
@@ -999,13 +1225,87 @@ export function buildSystem({ skill, persona, brief, intentNotes, driverName, ta
999
1225
  `target: ${target}`,
1000
1226
  `max_actions: ${maxActions}`,
1001
1227
  `<expectations_brief>\n${briefForPrompt(brief)}\n</expectations_brief>`,
1002
- `<design_intent_notes>\n${intentNotesForPrompt(brief, intentNotes)}\n</design_intent_notes>`
1228
+ `<design_intent_notes>\n${intentNotesForPrompt(brief, intentNotes)}\n</design_intent_notes>`,
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>`] : [])
1003
1232
  ].join('\n'),
1004
1233
  cache_control: { type: 'ephemeral' }
1005
1234
  }
1006
1235
  ];
1007
1236
  }
1008
1237
 
1238
+ /**
1239
+ * The run id cut to something short enough for an email or a display name:
1240
+ * the last eight letters and digits after the `pt_run_` prefix.
1241
+ *
1242
+ * @param {string} runId
1243
+ * @returns {string}
1244
+ */
1245
+ export function shortRunId(runId) {
1246
+ const compact = String(runId == null ? '' : runId).replace(/^pt_run_/, '').toLowerCase().replace(/[^a-z0-9]/g, '');
1247
+ return compact.slice(-8);
1248
+ }
1249
+
1250
+ /**
1251
+ * A fresh identity for every run, so a sign-up form never sees an email an
1252
+ * earlier run already registered. The Texas round lost actions to a 409 on
1253
+ * sign-up from a reused address. `example.test` is a reserved name that never
1254
+ * delivers mail.
1255
+ *
1256
+ * @param {{persona: {slug?: string, name?: string}, runId: string}} args
1257
+ * @returns {{email: string, displayName: string, short: string}|null}
1258
+ */
1259
+ export function runIdentity({ persona, runId }) {
1260
+ const short = shortRunId(runId);
1261
+ if (!short) return null;
1262
+ const slug = String((persona && persona.slug) || 'player').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'player';
1263
+ const name = String((persona && (persona.name || persona.slug)) || 'Player').replace(/\s+/g, ' ').trim().slice(0, 40) || 'Player';
1264
+ return { email: `persona-${slug}-${short}@example.test`, displayName: `${name} ${short}`, short };
1265
+ }
1266
+
1267
+ /**
1268
+ * @param {{email: string, displayName: string}} identity
1269
+ * @returns {string}
1270
+ */
1271
+ export function runIdentityForPrompt(identity, { testAccount = false } = {}) {
1272
+ return [
1273
+ `email: ${identity.email}`,
1274
+ `display_name: ${identity.displayName}`,
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.'
1306
+ ].join('\n');
1307
+ }
1308
+
1009
1309
  /**
1010
1310
  * The brief as the persona skill expects it: the `fields` object the
1011
1311
  * expectations-brief schema describes, with the version alongside so a report
@@ -1073,11 +1373,54 @@ export function assistantContent(answer) {
1073
1373
  return content;
1074
1374
  }
1075
1375
 
1076
- /** Keep the transcript readable without storing a whole screenshot in it. */
1077
- 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 = []) {
1078
1421
  if (!toolUse.input) return null;
1079
1422
  const { thought_in_character: ignored, ...rest } = toolUse.input;
1080
- return rest;
1423
+ return scrubSecrets(rest, secrets);
1081
1424
  }
1082
1425
 
1083
1426
  async function safeScreenshot(driver, name, screenshots, transcript, observation = null) {
@@ -1143,6 +1486,7 @@ async function finish(outcome, ledger, runDirectory, transcript, heartbeat) {
1143
1486
  export function renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason }) {
1144
1487
  const good = notes.filter(note => note.kind === 'good');
1145
1488
  const bad = notes.filter(note => note.kind === 'bug' || note.kind === 'confusing');
1489
+ const network = notes.filter(note => note.automatic && note.url);
1146
1490
  const list = entries => (entries.length > 0
1147
1491
  ? entries.map(note => `- **${note.kind}${note.severity ? `/${note.severity}` : ''}**: ${note.text}`).join('\n')
1148
1492
  : '- Nothing was logged.');
@@ -1170,6 +1514,7 @@ export function renderFallbackMarkdown({ persona, driverName, target, notes, scr
1170
1514
  '## Suggested next step',
1171
1515
  'Run this persona again once the reason above is resolved.',
1172
1516
  '',
1517
+ ...(network.length > 0 ? ['## Network notes', network.map(note => `- ${note.status ? `${note.status} ` : ''}${note.url}`).join('\n'), ''] : []),
1173
1518
  '## Evidence',
1174
1519
  screenshots.length > 0 ? screenshots.map(shot => `- ${shot.relativePath}`).join('\n') : '- (none)',
1175
1520
  ''
@@ -1218,6 +1563,8 @@ export function notesToFindings(notes, screenshots) {
1218
1563
  if (!category || !note.text) continue;
1219
1564
  // A blocker or major written on a driver error is the runner's failure.
1220
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;
1221
1568
  const text = String(note.text);
1222
1569
  const shot = nearestScreenshot(note, screenshots);
1223
1570
  findings.push({
@@ -1296,6 +1643,7 @@ export function noteLine(note, i) {
1296
1643
  const tags = [
1297
1644
  note.automatic ? 'automatic' : null,
1298
1645
  note.driver_error ? 'written on a driver error' : null,
1646
+ note.expected_noise ? 'expected noise' : null,
1299
1647
  Number.isInteger(note.observation) && note.observation > 0 ? `seen at observation ${note.observation}` : null,
1300
1648
  note.screenshot ? `screenshot ${note.screenshot}` : null
1301
1649
  ].filter(Boolean);
@@ -1368,8 +1716,8 @@ export function observationTitle(observation) {
1368
1716
  */
1369
1717
  /** How report.md lists what the runner dropped. */
1370
1718
  export const RUNNER_DROPPED_SECTION = Object.freeze({
1371
- heading: '## Dropped by the runner (driver errors or waiting notes only)',
1372
- 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.',
1373
1721
  emptyNote: '- Nothing here that the runner kept. See the dropped findings below.'
1374
1722
  });
1375
1723
 
@@ -1392,11 +1740,26 @@ export const RUNNER_DROPPED_SECTION = Object.freeze({
1392
1740
  export function dropRunnerRejectedFindings(report, { notes = [], observations = [] } = {}) {
1393
1741
  if (!report || !Array.isArray(report.findings)) return { report, dropped: [] };
1394
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);
1395
1753
  const verdict = finding => {
1396
1754
  const cited = [];
1397
1755
  for (const item of Array.isArray(finding.evidence) ? finding.evidence : []) {
1398
- if (!item || item.type !== 'transcript_step') continue;
1756
+ if (!item) continue;
1399
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;
1400
1763
  const observation = ref.match(/^\s*observation\s*#?\s*(\d+)\s*$/i);
1401
1764
  const note = ref.match(/^\s*note\s*#?\s*(\d+)\s*$/i);
1402
1765
  if (observation) {
@@ -1404,7 +1767,7 @@ export function dropRunnerRejectedFindings(report, { notes = [], observations =
1404
1767
  cited.push(entry && entry.driver_error ? 'driver' : 'other');
1405
1768
  } else if (note) {
1406
1769
  const entry = notes[Number(note[1]) - 1];
1407
- 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');
1408
1771
  } else {
1409
1772
  cited.push('other');
1410
1773
  }
@@ -1412,7 +1775,9 @@ export function dropRunnerRejectedFindings(report, { notes = [], observations =
1412
1775
  if (cited.length === 0) return null;
1413
1776
  if (ABOVE_MINOR.includes(finding.severity) && cited.every(kind => kind === 'driver')) return 'driver errors only';
1414
1777
  if (cited.every(kind => kind === 'waiting')) return 'waiting notes only';
1415
- 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';
1416
1781
  return null;
1417
1782
  };
1418
1783
  const dropped = [];
@@ -1425,6 +1790,273 @@ export function dropRunnerRejectedFindings(report, { notes = [], observations =
1425
1790
  return { report: dropped.length > 0 ? { ...report, findings } : report, dropped };
1426
1791
  }
1427
1792
 
1793
+ /** The most actions the report prompt lists; the newest are kept. */
1794
+ export const MAX_ACTIONS_IN_REPORT = 60;
1795
+
1796
+ /** The longest typed text an action line repeats. */
1797
+ const MAX_TYPED_CHARS = 40;
1798
+
1799
+ /**
1800
+ * The names of the refs a compacted snapshot shows: `- button "Start" [e12]`
1801
+ * gives e12 -> button "Start".
1802
+ *
1803
+ * @param {string} text
1804
+ * @returns {Map<string, {role: string, name: string}>}
1805
+ */
1806
+ export function refNamesFrom(text) {
1807
+ const names = new Map();
1808
+ for (const line of String(text == null ? '' : text).split('\n')) {
1809
+ const match = line.match(/^\s*- ([a-z]+) "([^"]*)"(?: \[[^\]]*\])*? \[(e\d+)\]/);
1810
+ if (match && match[2]) names.set(match[3], { role: match[1], name: match[2] });
1811
+ }
1812
+ return names;
1813
+ }
1814
+
1815
+ /**
1816
+ * Every action that went through, from the transcript's observation and
1817
+ * action lines, with the name of what it targeted when the screen before it
1818
+ * showed one.
1819
+ *
1820
+ * @param {Array<Object>} records transcript lines of kind observation and action, in order
1821
+ * @returns {Array<{action: number, kind: string, target: string, name: string|null, ref: string|null, changed: boolean}>}
1822
+ */
1823
+ export function actionLogFrom(records) {
1824
+ const log = [];
1825
+ let names = new Map();
1826
+ // Observations are numbered as the report prompt numbers them: one per
1827
+ // observation line, in order.
1828
+ let observation = 0;
1829
+ let previousText = '';
1830
+ let pending = null;
1831
+ for (const record of Array.isArray(records) ? records : []) {
1832
+ if (!record) continue;
1833
+ if (record.kind === 'observation') {
1834
+ observation += 1;
1835
+ const text = String(record.text == null ? '' : record.text);
1836
+ if (pending) {
1837
+ pending.observationAfter = observation;
1838
+ pending.introducedError = introducesError(previousText, text);
1839
+ pending = null;
1840
+ }
1841
+ previousText = text;
1842
+ const next = refNamesFrom(text);
1843
+ // An unchanged marker or a canvas shows no refs; the last names stand.
1844
+ if (next.size > 0) names = next;
1845
+ continue;
1846
+ }
1847
+ if (record.kind !== 'action') continue;
1848
+ pending = null;
1849
+ if (!record.ok || !ACTION_TOOLS.includes(record.tool)) continue;
1850
+ const input = record.input || {};
1851
+ const ref = typeof input.ref === 'string' && input.ref.trim() ? input.ref.trim() : null;
1852
+ const known = ref ? names.get(ref) : null;
1853
+ let name = known ? known.name : null;
1854
+ let target;
1855
+ if (record.tool === 'key') target = `key ${String(input.key || '')}`;
1856
+ else if (record.tool === 'navigate') target = String(input.url || '');
1857
+ else if (record.tool === 'swipe') target = `from (${input.x},${input.y}) to (${input.toX},${input.toY})`;
1858
+ else {
1859
+ const named = typeof input.target === 'string' && input.target.trim() ? input.target.trim() : null;
1860
+ if (!name && named) {
1861
+ const roleAndName = named.match(/^[a-z]+\s+"([^"]*)"$/);
1862
+ name = roleAndName ? roleAndName[1] : named;
1863
+ }
1864
+ const where = known ? `${known.role} "${known.name}" [${ref}]` : ref ? `[${ref}]` : named || (Number.isFinite(Number(input.x)) && Number.isFinite(Number(input.y)) ? `(${input.x},${input.y})` : 'the focused element');
1865
+ if (record.tool === 'type') {
1866
+ const text = String(input.text == null ? '' : input.text);
1867
+ target = `${JSON.stringify(text.length > MAX_TYPED_CHARS ? `${text.slice(0, MAX_TYPED_CHARS - 3)}...` : text)} into ${where}${input.replace ? ' (replacing)' : ''}${input.submit ? ' then Enter' : ''}`;
1868
+ } else {
1869
+ target = where;
1870
+ }
1871
+ }
1872
+ const entry = { action: Number(record.actions) || log.length + 1, kind: record.tool, target, name, ref, changed: Boolean(record.changed), observationBefore: observation, observationAfter: null, introducedError: false };
1873
+ log.push(entry);
1874
+ pending = entry;
1875
+ }
1876
+ return log;
1877
+ }
1878
+
1879
+ /** A line that reads as an error: an alert or a dialog, or error wording. */
1880
+ const ERROR_LINE = /^\s*- (?:alert|alertdialog|dialog)\b|\b(?:error|errors|failed|invalid)\b/i;
1881
+
1882
+ /**
1883
+ * Whether the screen after an action shows an alert, a dialog or error
1884
+ * wording that the screen before it did not: a change, but not the one the
1885
+ * persona wanted.
1886
+ *
1887
+ * @param {string} before
1888
+ * @param {string} after
1889
+ * @returns {boolean}
1890
+ */
1891
+ export function introducesError(before, after) {
1892
+ const seen = new Set(String(before || '').split('\n').map(line => line.trim()));
1893
+ return String(after || '').split('\n').some(line => !seen.has(line.trim()) && ERROR_LINE.test(line));
1894
+ }
1895
+
1896
+ /**
1897
+ * The action log as the report prompt's numbered list, bounded to the newest
1898
+ * {@link MAX_ACTIONS_IN_REPORT}.
1899
+ *
1900
+ * @param {Array<Object>} log
1901
+ * @returns {string}
1902
+ */
1903
+ export function actionLogLines(log) {
1904
+ if (!Array.isArray(log) || log.length === 0) return '(none)';
1905
+ const shown = log.slice(-MAX_ACTIONS_IN_REPORT);
1906
+ const lines = shown.map(entry => `${entry.action}. ${entry.kind} ${entry.target}: ${entry.changed ? 'screen changed' : 'no change'}`);
1907
+ if (shown.length < log.length) lines.unshift(`(the first ${log.length - shown.length} are left out)`);
1908
+ return lines.join('\n');
1909
+ }
1910
+
1911
+ /**
1912
+ * The failed requests the runner recorded, one line each, for the report
1913
+ * prompt's network notes.
1914
+ *
1915
+ * @param {Array<Object>} notes
1916
+ * @returns {string}
1917
+ */
1918
+ export function networkNoteLines(notes) {
1919
+ const lines = [];
1920
+ (notes || []).forEach((note, index) => {
1921
+ if (!note || !note.automatic || !note.url) return;
1922
+ lines.push(`- note ${index + 1}: ${note.status ? `${note.status} ` : ''}${note.url}`);
1923
+ });
1924
+ return lines.length > 0 ? lines.join('\n') : '(none)';
1925
+ }
1926
+
1927
+ /**
1928
+ * The "nothing happens" family: wording that says a control was used and
1929
+ * produced nothing. Deliberately narrow; "cannot" or "fails" can be true of a
1930
+ * control the persona clicked.
1931
+ */
1932
+ const NOTHING_HAPPENED = /\b(?:did nothing|does nothing|never kicked off|never started|no response|nothing happened)\b/i;
1933
+
1934
+ /** One step down the severity scale. Cosmetic and praise are never touched. */
1935
+ const DOWNGRADE = Object.freeze({ blocker: 'major', major: 'minor', minor: 'cosmetic' });
1936
+
1937
+ /**
1938
+ * Whether a text names a control as a whole word, ignoring case: "Play"
1939
+ * matches "press Play" and never "Players".
1940
+ *
1941
+ * @param {string} text
1942
+ * @param {string} name
1943
+ * @returns {boolean}
1944
+ */
1945
+ export function namesControl(text, name) {
1946
+ const value = String(name || '').trim();
1947
+ if (value.length < 3) return false;
1948
+ const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1949
+ return new RegExp(`(?<![\\p{L}\\p{N}])${escaped}(?![\\p{L}\\p{N}])`, 'iu').test(String(text || ''));
1950
+ }
1951
+
1952
+ /**
1953
+ * Downgrade, never drop, a finding the action log contradicts. All of these
1954
+ * have to hold, or the severity is left alone:
1955
+ *
1956
+ * - its title or description is in the "nothing happens" family ("did
1957
+ * nothing", "does nothing", "never kicked off", "never started", "no
1958
+ * response", "nothing happened") and names a control, whole word, of at
1959
+ * least three characters;
1960
+ * - the most recent click on that control changed the screen, and the screen
1961
+ * after it gained no alert, dialog or error wording;
1962
+ * - that click happened at or after the latest observation the finding cites
1963
+ * (a finding citing no observation is left alone).
1964
+ *
1965
+ * The Texas round's report said the season was never kicked off, with the
1966
+ * click on KICK OFF THE SEASON and a changed screen in its own transcript.
1967
+ *
1968
+ * @param {Object} report
1969
+ * @param {Array<Object>} log from {@link actionLogFrom}
1970
+ * @returns {{report: Object, downgraded: Array<{id: string, from: string, to: string, control: string, action: number, note: string}>}}
1971
+ */
1972
+ export function downgradeContradictedFindings(report, log) {
1973
+ if (!report || !Array.isArray(report.findings) || !Array.isArray(log) || log.length === 0) return { report, downgraded: [] };
1974
+ const clicks = log.filter(entry => entry.kind === 'click' && entry.name && entry.name.trim().length >= 3);
1975
+ if (clicks.length === 0) return { report, downgraded: [] };
1976
+ const downgraded = [];
1977
+ const findings = report.findings.map(finding => {
1978
+ if (!finding || !DOWNGRADE[finding.severity]) return finding;
1979
+ const text = `${finding.title || ''}\n${finding.description || ''}`;
1980
+ if (!NOTHING_HAPPENED.test(text)) return finding;
1981
+ const cited = (Array.isArray(finding.evidence) ? finding.evidence : [])
1982
+ .map(item => (item && item.type === 'transcript_step' ? String(item.ref || '').match(/^\s*observation\s*#?\s*(\d+)\s*$/i) : null))
1983
+ .filter(Boolean)
1984
+ .map(match => Number(match[1]));
1985
+ if (cited.length === 0) return finding;
1986
+ const citedLatest = Math.max(...cited);
1987
+ // The controls the finding names, each judged by its most recent click.
1988
+ const named = [...new Set(clicks.map(entry => entry.name.trim().toLowerCase()))]
1989
+ .filter(name => namesControl(text, name));
1990
+ let hit = null;
1991
+ for (const name of named) {
1992
+ const latest = clicks.filter(entry => entry.name.trim().toLowerCase() === name).at(-1);
1993
+ if (!latest.changed || latest.introducedError) continue;
1994
+ if (!Number.isInteger(latest.observationBefore) || latest.observationBefore < citedLatest) continue;
1995
+ hit = latest;
1996
+ break;
1997
+ }
1998
+ if (!hit) return finding;
1999
+ const to = DOWNGRADE[finding.severity];
2000
+ const note = `the action log shows ${hit.name.trim()} clicked at step ${hit.action} with the screen changing`;
2001
+ const description = String(finding.description || '');
2002
+ downgraded.push({ id: finding.id, from: finding.severity, to, control: hit.name.trim(), action: hit.action, note });
2003
+ return { ...finding, severity: to, description: description ? `${description}\n\nDowngraded by the runner: ${note}.` : `Downgraded by the runner: ${note}.` };
2004
+ });
2005
+ return { report: downgraded.length > 0 ? { ...report, findings } : report, downgraded };
2006
+ }
2007
+
2008
+ /** How report.md lists the findings the runner downgraded. */
2009
+ export const DOWNGRADED_HEADING = '## Downgraded by the runner (the action log or its own screenshot contradicts the claim)';
2010
+
2011
+ /**
2012
+ * report.md agreeing with report.json about downgraded findings: on every
2013
+ * line that names a downgraded finding's id, its old severity word becomes
2014
+ * the new one, and a section lists each downgrade with the runner's note,
2015
+ * replacing one written before.
2016
+ *
2017
+ * @param {string} markdown
2018
+ * @param {Array<{id: string, from: string, to: string, note: string}>} downgraded
2019
+ * @returns {string}
2020
+ */
2021
+ export function rewriteDowngradedMarkdown(markdown, downgraded) {
2022
+ if (!Array.isArray(downgraded) || downgraded.length === 0) return markdown;
2023
+ const text = String(markdown || '');
2024
+ const at = text.indexOf(`\n${DOWNGRADED_HEADING}`);
2025
+ let body = (at === -1 ? (text.startsWith(DOWNGRADED_HEADING) ? '' : text) : text.slice(0, at)).replace(/\s+$/, '');
2026
+ const recase = (word, to) => (word === word.toUpperCase() ? to.toUpperCase() : word[0] === word[0].toUpperCase() ? to[0].toUpperCase() + to.slice(1) : to);
2027
+ const escape = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2028
+ const namesId = (line, id) => new RegExp(`(^|[^A-Za-z0-9])${escape(id)}(?![0-9])`).test(line);
2029
+ // The finding a "Severity:" line belongs to: the one named by the nearest
2030
+ // heading or list item above it. A heading naming none ends it.
2031
+ let current = null;
2032
+ body = body.split('\n').map(line => {
2033
+ let out = line;
2034
+ const named = downgraded.find(entry => entry.id && namesId(line, String(entry.id)));
2035
+ if (named) current = named;
2036
+ else if (/^#{1,6} /.test(line) || /^\s*(?:[-*+]|\d+[.)])\s+\S*\b[A-Z]\d{2,}\b/.test(line)) current = null;
2037
+ for (const entry of downgraded) {
2038
+ const id = String(entry.id || '');
2039
+ if (!id) continue;
2040
+ // Only the severity slot the writer labels: right after the id in
2041
+ // parentheses or brackets ("F001 (major)", "**F001 [Major, bug]**"), or
2042
+ // after "Severity:" within that finding. A title that happens to
2043
+ // contain the word stays as written.
2044
+ if (namesId(out, id)) {
2045
+ out = out.replace(new RegExp(`(${escape(id)}\\**\\s*[([]\\s*)(${entry.from})\\b`, 'i'), (match, lead, word) => `${lead}${recase(word, entry.to)}`);
2046
+ }
2047
+ if (current === entry) {
2048
+ out = out.replace(new RegExp(`(severity\\**\\s*:\\s*\\**\\s*)(${entry.from})\\b`, 'gi'), (match, lead, word) => `${lead}${recase(word, entry.to)}`);
2049
+ }
2050
+ }
2051
+ return out;
2052
+ }).join('\n');
2053
+ const section = [
2054
+ DOWNGRADED_HEADING,
2055
+ ...downgraded.map(entry => `- ${entry.id}: ${entry.from} to ${entry.to}, because ${entry.note}.`)
2056
+ ].join('\n');
2057
+ return `${body}\n\n${section}\n`;
2058
+ }
2059
+
1428
2060
  export function capNetworkOnlyFindings(report, { notes = [], screenshots = [] } = {}) {
1429
2061
  if (!report || !Array.isArray(report.findings)) return report;
1430
2062
  const byShot = new Map();