ravensight-playtest 0.2.9 → 0.3.1

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,3 +1,4 @@
1
+ import { rewriteMarkdown } from './dropped.js';
1
2
  import { selectAction, JEV_CONTROLLER_PROMPT } from './actionSelector.js';
2
3
  import { mkdir, writeFile } from 'node:fs/promises';
3
4
  import path from 'node:path';
@@ -69,6 +70,8 @@ const ABOVE_MINOR = Object.freeze(['blocker', 'major']);
69
70
  export const NETWORK_CAP_NOTE = 'Severity capped at minor by the runner: the only evidence is automatic network notes, and a failed request is a finding above minor only when the persona saw the game break because of it.';
70
71
 
71
72
  /** A persona note kind as a report finding category. */
73
+ // `waiting` has no category on purpose: a "waiting for X to finish" note is
74
+ // context, and nothing turns it into a finding.
72
75
  export const NOTE_CATEGORY = Object.freeze({ bug: 'bug', confusing: 'ux', suggestion: 'ux', good: 'ux', reaction: 'ux' });
73
76
 
74
77
  /** Stop injecting the "nothing changed" hint after this many identical screens. */
@@ -77,8 +80,16 @@ export const STUCK_HINT_AFTER = 5;
77
80
  /** Give up entirely after this many identical screens: the persona is wedged. */
78
81
  export const STUCK_LIMIT = 12;
79
82
 
80
- /** Turns allowed after a budget warning, to land the report. */
81
- export const WRAP_UP_TURNS = 3;
83
+ /**
84
+ * Act turns allowed after a budget warning: none. The proxy reserves each
85
+ * call's pessimistic projection (the whole request at the full input rate,
86
+ * plus the output ceiling) before it sends it, so every act turn after the
87
+ * warning ate into the 20 percent the report step needs, and in the Texas run
88
+ * three of them left too little for the report's own reservation: the report
89
+ * call was refused with 402 and the run ended at cost_cap with no report.
90
+ * The warned turn's own actions are still played; then the report is written.
91
+ */
92
+ export const WRAP_UP_TURNS = 0;
82
93
 
83
94
  /** Token ceiling asked for per act turn. The proxy clamps it to the step's own. */
84
95
  export const ACT_MAX_TOKENS = 1024;
@@ -330,9 +341,11 @@ async function playLoop({
330
341
  keepObservations,
331
342
  heartbeat,
332
343
  onProgress = () => {},
344
+ onBudgetWarning = null,
333
345
  log = () => {}
334
346
  }) {
335
- const system = buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions });
347
+ const system = buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions, runId });
348
+ const identity = runIdentity({ persona, runId });
336
349
  if (controller === 'jev') system.push({ type: 'text', text: JEV_CONTROLLER_PROMPT });
337
350
  const availableTools = driver.supportedActions
338
351
  ? [...PLAY_TOOLS, ...(driver.supportedActions.includes('swipe') ? [SWIPE_TOOL] : [])].filter(tool => !ACTION_TOOLS.includes(tool.name) || driver.supportedActions.includes(tool.name))
@@ -350,6 +363,9 @@ async function playLoop({
350
363
  }
351
364
  })) : availableTools;
352
365
  const recentActions = [];
366
+ // The observation and action lines of the transcript, kept for the report
367
+ // step's "Actions you took" list.
368
+ const actionRecords = [];
353
369
  let latestObservation = '';
354
370
  let visualObservation = false;
355
371
  const messages = [];
@@ -361,7 +377,6 @@ async function playLoop({
361
377
  let budgetExceeded = false;
362
378
  let canceled = false;
363
379
  let unchanged = 0;
364
- let wrapUpLeft = Infinity;
365
380
  const maxTurns = maxActions * 3 + 20;
366
381
 
367
382
  // Notes waiting for the next observation, which is the screen they are
@@ -387,6 +402,7 @@ async function playLoop({
387
402
  latestObservation = first.text;
388
403
  visualObservation = Boolean(first.screenshotBase64);
389
404
  transcript.write({ kind: 'observation', step: first.step, text: first.text, url: first.url });
405
+ actionRecords.push({ kind: 'observation', text: first.text });
390
406
  const landing = await safeScreenshot(driver, 'landing', screenshots, transcript, observations.length);
391
407
  messages.push({
392
408
  role: 'user',
@@ -405,13 +421,9 @@ async function playLoop({
405
421
  quitDetail = 'the job was cancelled';
406
422
  break;
407
423
  }
408
- if (wrapUpLeft <= 0) {
409
- quitReason = 'budget_exhausted';
410
- break;
411
- }
412
-
413
424
  turns += 1;
414
425
  let answer;
426
+ const askedAt = Date.now();
415
427
  try {
416
428
  answer = await callWithImageRetry({
417
429
  client,
@@ -444,30 +456,28 @@ async function playLoop({
444
456
  throw error;
445
457
  }
446
458
 
447
- ledger.record({ model: answer.model, usage: answer.usage });
459
+ ledger.record({ model: answer.model, usage: answer.usage, ms: Date.now() - askedAt });
448
460
  // The warn turn is a turn the developer paid for, so its tool calls are
449
461
  // executed like any other and the wrap-up notice rides along on that
450
462
  // turn's tool_result. Two things this avoids: discarding a paid action,
451
463
  // and pushing a second consecutive `user` message, which the Messages API
452
464
  // refuses when the previous turn ended in a tool_use.
453
- let wrapUpNotice = null;
454
- if (answer.budget === 'warn' && !budgetWarned) {
465
+ const warnedNow = answer.budget === 'warn' && !budgetWarned;
466
+ if (warnedNow) {
455
467
  budgetWarned = true;
456
- wrapUpLeft = WRAP_UP_TURNS;
457
468
  // Surfaced, not swallowed: this is the developer's money and the one
458
469
  // moment where telling them costs nothing and saying nothing costs a
459
- // report.
460
- log(`budget warning: this run has used at least 80 percent of its model budget. Wrapping up after ${WRAP_UP_TURNS} more turns so the report still gets written.`);
461
- wrapUpNotice = `You are nearly out of budget for this session. Take a final screenshot if you need one, then stop calling tools so the report can be written. You have ${WRAP_UP_TURNS} turns left.`;
470
+ // report. Recorded before anything else can fail, so the journal and
471
+ // the transcript both say the run was warned.
472
+ log('budget warning: this run has used at least 80 percent of its model budget. Playing this turn\'s actions, then stopping so the rest of the budget goes to the report.');
462
473
  transcript.write({ kind: 'budget_warning', wrap_up_turns: WRAP_UP_TURNS });
474
+ if (typeof onBudgetWarning === 'function') await onBudgetWarning();
463
475
  }
464
- if (budgetWarned) wrapUpLeft -= 1;
465
476
 
466
477
  if (answer.toolUses.length === 0) {
467
478
  // No tool call is how a persona stops: there is no quit tool in the
468
- // step's allowlist, and adding one would be refused by the proxy. A warn
469
- // on such a turn needs no notice either, since the loop ends here.
470
- quitReason = budgetWarned && wrapUpNotice ? 'budget_exhausted' : 'persona_quit';
479
+ // step's allowlist, and adding one would be refused by the proxy.
480
+ quitReason = warnedNow ? 'budget_exhausted' : 'persona_quit';
471
481
  quitDetail = answer.text.slice(0, 400);
472
482
  transcript.write({ kind: 'quit', reason: quitReason, text: answer.text });
473
483
  break;
@@ -484,15 +494,26 @@ async function playLoop({
484
494
  }
485
495
  transcript.write({ kind: 'action_selection', source: selection.source || 'jev', status: selection.status, confidence: selection.confidence, selected_tool_id: selection.toolId, reason: selection.reason });
486
496
  if (selection.status !== 'selected') { quitReason = 'persona_quit'; quitDetail = 'Ravensight Playtester stopped for review: ' + selection.reason; break; }
487
- if (selection.budget === 'warn') { budgetWarned = true; wrapUpLeft = Math.min(wrapUpLeft, WRAP_UP_TURNS); }
497
+ if (selection.budget === 'warn' && !budgetWarned) {
498
+ budgetWarned = true;
499
+ log('budget warning: this run has used at least 80 percent of its model budget. Playing this turn\'s actions, then stopping so the rest of the budget goes to the report.');
500
+ transcript.write({ kind: 'budget_warning', wrap_up_turns: WRAP_UP_TURNS });
501
+ if (typeof onBudgetWarning === 'function') await onBudgetWarning();
502
+ }
488
503
  }
489
504
  messages.push({ role: 'assistant', content: assistantContent(answer) });
490
505
  const results = [];
506
+ // Every ref in this turn was read off the screen before it. Once one of
507
+ // its actions changes the screen, a later ref may name something else.
508
+ let screenChangedThisTurn = false;
491
509
  for (const toolUse of answer.toolUses) {
492
510
  if (selection && toolUse.id !== selection.toolId) { results.push({ type: 'tool_result', tool_use_id: toolUse.id, content: 'Alternative not selected. No action executed.' }); continue; }
511
+ const notesBefore = notes.length;
493
512
  const executed = await executeTool({
494
513
  driver,
495
514
  toolUse,
515
+ staleRefs: screenChangedThisTurn,
516
+ identity,
496
517
  onNote: note => {
497
518
  // The note is nested rather than spread: its own `kind`
498
519
  // (bug, confusing, good, suggestion, reaction) would otherwise
@@ -508,7 +529,8 @@ async function playLoop({
508
529
  });
509
530
  results.push(executed.result);
510
531
  if (executed.spentAction) actions += 1;
511
- transcript.write({
532
+ if (executed.changed && ACTION_TOOLS.includes(toolUse.name)) screenChangedThisTurn = true;
533
+ const actionLine = {
512
534
  kind: 'action',
513
535
  tool: toolUse.name,
514
536
  input: redactToolInput(toolUse),
@@ -517,14 +539,27 @@ async function playLoop({
517
539
  error: executed.error,
518
540
  changed: executed.changed,
519
541
  actions
520
- });
542
+ };
543
+ transcript.write(actionLine);
544
+ actionRecords.push(actionLine);
545
+ if (executed.driverError) {
546
+ // The driver failed, not the game: a target that matched twice,
547
+ // locator code, a ref not on screen, a timeout on an unchanged page.
548
+ // Recorded as its own kind, and the observation and any note written
549
+ // on this call are marked, so the report step can be told (and the
550
+ // validator can check) that none of it is evidence of a game bug.
551
+ for (const note of notes.slice(notesBefore)) note.driver_error = true;
552
+ transcript.write({ kind: 'driver_error', tool: toolUse.name, error: executed.error, observation: observations.length + (executed.observation ? 1 : 0) });
553
+ }
521
554
  if (executed.observation) {
522
- observed(executed.observation);
555
+ const entry = observed(executed.observation);
556
+ if (executed.driverError) entry.driver_error = String(executed.error || 'driver error').slice(0, 200);
523
557
  latestObservation = executed.observation.text;
524
558
  visualObservation = Boolean(executed.observation.screenshotBase64);
525
559
  recentActions.push(`${toolUse.name}: ${executed.changed ? 'screen changed' : 'no observed change'}`);
526
560
  if (recentActions.length > 10) recentActions.shift();
527
561
  transcript.write({ kind: 'observation', step: executed.observation.step, text: executed.observation.text, url: executed.observation.url });
562
+ actionRecords.push({ kind: 'observation', text: executed.observation.text });
528
563
  // Only an input to the game can tell us anything about being stuck. A
529
564
  // `snapshot` or a `screenshot` never changes the screen by design, so
530
565
  // counting those would have a persona that looks twice before acting
@@ -554,15 +589,20 @@ async function playLoop({
554
589
  const shot = await safeScreenshot(driver, 'console-error', screenshots, transcript, observations.length);
555
590
  for (const entry of consoleErrors) {
556
591
  const kind = RESOURCE_4XX.test(String(entry.text)) ? 'network' : 'bug';
557
- const note = { kind, text: `${entry.type}: ${entry.text}`, automatic: true, ...(shot ? { screenshot: shot.relativePath } : {}) };
592
+ const note = {
593
+ kind,
594
+ text: `${entry.type}: ${entry.text}`,
595
+ automatic: true,
596
+ ...(entry.url ? { url: String(entry.url).slice(0, 200) } : {}),
597
+ ...(entry.url && Number.isInteger(entry.status) ? { status: entry.status } : {}),
598
+ ...(shot ? { screenshot: shot.relativePath } : {})
599
+ };
558
600
  noted(note);
559
601
  transcript.write({ kind: 'note', note });
560
602
  }
561
603
  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}.` : ''}`);
562
604
  }
563
605
 
564
- if (wrapUpNotice) asides.push(wrapUpNotice);
565
-
566
606
  const wedged = unchanged >= STUCK_LIMIT;
567
607
  if (!wedged && unchanged > 0 && unchanged % STUCK_HINT_AFTER === 0) {
568
608
  transcript.write({ kind: 'stuck', unchanged, fatal: false });
@@ -584,6 +624,12 @@ async function playLoop({
584
624
  }
585
625
 
586
626
  if (heartbeat) await heartbeat.beat({ actionsTaken: actions, checkpointStep: turns });
627
+
628
+ if (budgetWarned) {
629
+ quitReason = 'budget_exhausted';
630
+ quitDetail = 'stopped at the 80 percent budget warning so the report could be written';
631
+ break;
632
+ }
587
633
  }
588
634
 
589
635
  if (actions >= maxActions && quitReason === 'budget_exhausted') {
@@ -601,7 +647,8 @@ async function playLoop({
601
647
  budgetExceeded,
602
648
  canceled,
603
649
  imageBudget,
604
- finalScreenshot: final ? final.relativePath : null
650
+ finalScreenshot: final ? final.relativePath : null,
651
+ actionLog: actionLogFrom(actionRecords)
605
652
  };
606
653
  }
607
654
 
@@ -631,7 +678,7 @@ async function reportPhase({
631
678
  endedAt,
632
679
  log
633
680
  }) {
634
- const system = buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions: play.actionsTaken });
681
+ const system = buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions: play.actionsTaken, runId });
635
682
  const messages = [{
636
683
  role: 'user',
637
684
  content: [{
@@ -645,10 +692,20 @@ async function reportPhase({
645
692
  notes.length > 0 ? notes.map((note, i) => noteLine(note, i)).join('\n') : '(none)',
646
693
  '',
647
694
  '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
+ '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.',
696
+ '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.',
648
697
  '',
649
698
  'Observations the persona was shown, by index:',
650
699
  observationLines(observations),
651
700
  '',
701
+ 'Actions you took (every action that went through, numbered by action; "screen changed" means the screen after it differed from the screen before):',
702
+ actionLogLines(play.actionLog),
703
+ '',
704
+ '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.',
705
+ '',
706
+ '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):',
707
+ networkNoteLines(notes),
708
+ '',
652
709
  '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"}.',
653
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.',
654
711
  '',
@@ -674,6 +731,8 @@ async function reportPhase({
674
731
  let jsonText = null;
675
732
  let repaired = false;
676
733
  let continued = false;
734
+ let droppedByRunner = [];
735
+ let downgradedByRunner = [];
677
736
 
678
737
  // One repair at most when the schema or the evidence refs refuse what was
679
738
  // written: each turn is a metered question, and the dogfood gate asks for
@@ -682,19 +741,56 @@ async function reportPhase({
682
741
  // gets one plain "now write report.json" that does not spend the repair.
683
742
  // In 0.2.8 it did, and the report.json that came back had its five
684
743
  // screenshot refs under "path" with no turn left to fix them.
744
+ // A warned run has at most a fifth of its budget left, and the proxy
745
+ // reserves each call's worst case before sending it. So the report step
746
+ // never escalates to a dearer tier after a warning, and a 402 on a report
747
+ // turn gets one retry with the output ceiling cut to what the refusal says
748
+ // still fits.
749
+ const escalate = shouldEscalate(persona) && !play.budgetWarned;
750
+ let maxTokens = REPORT_MAX_TOKENS;
751
+ let shrunk = false;
752
+ let lastCandidate = null;
685
753
  for (let attempt = 1; ; attempt += 1) {
686
- const answer = await callStepWithRetry({
687
- client,
688
- jobId,
689
- runId,
690
- step: STEPS.personaReport,
691
- system,
692
- messages,
693
- tools: REPORT_TOOLS,
694
- maxTokens: REPORT_MAX_TOKENS,
695
- escalate: shouldEscalate(persona)
696
- });
697
- ledger.record({ model: answer.model, usage: answer.usage });
754
+ let answer;
755
+ const askedAt = Date.now();
756
+ try {
757
+ answer = await callStepWithRetry({
758
+ client,
759
+ jobId,
760
+ runId,
761
+ step: STEPS.personaReport,
762
+ system,
763
+ messages,
764
+ tools: REPORT_TOOLS,
765
+ maxTokens,
766
+ escalate
767
+ });
768
+ } catch (error) {
769
+ if (!(error instanceof ProxyError && error.isBudget)) throw error;
770
+ const bodyChars = JSON.stringify({ messages, system, tools: REPORT_TOOLS, max_tokens: maxTokens, stream: true }).length;
771
+ const smaller = shrunk ? null : reportTokensThatFit(error, maxTokens, bodyChars);
772
+ if (!smaller && !shrunk && !lastCandidate) {
773
+ log(hasBudgetFigures(error)
774
+ ? `even the smallest report (${REPORT_MIN_TOKENS} output tokens) does not fit the remaining model budget once the request itself is paid for, so the report is written from the notes instead.`
775
+ : 'the report turn was refused for budget with no budget figures in the refusal, so there is nothing to size a retry from; the report is written from the notes instead.');
776
+ }
777
+ if (smaller) {
778
+ shrunk = true;
779
+ transcript.write({ kind: 'report_budget_retry', attempt, max_tokens: smaller, figures: error.figures || {} });
780
+ log(`the report turn did not fit the remaining model budget; asking again with an output ceiling of ${smaller} tokens.`);
781
+ maxTokens = smaller;
782
+ continue;
783
+ }
784
+ // A report already written on an earlier turn is the report, with its
785
+ // lint, rather than a partial one rebuilt from the notes.
786
+ if (lastCandidate) {
787
+ report = lastCandidate;
788
+ log(`the model budget ran out on a follow-up report turn; keeping the report.json already written (${lint.length} problem(s)).`);
789
+ break;
790
+ }
791
+ throw error;
792
+ }
793
+ ledger.record({ model: answer.model, usage: answer.usage, ms: Date.now() - askedAt });
698
794
  transcript.write({ kind: 'report_turn', attempt, stop_reason: answer.stopReason, files: answer.toolUses.map(t => t.input && t.input.path) });
699
795
 
700
796
  const files = new Map();
@@ -737,7 +833,22 @@ async function reportPhase({
737
833
  if (normalised && normalised.changed > 0) {
738
834
  transcript.write({ kind: 'report_evidence_normalised', attempt, entries: normalised.changed });
739
835
  }
740
- const candidate = normalised ? capNetworkOnlyFindings(stampReport(normalised.report, facts), { notes, screenshots }) : null;
836
+ const capped = normalised ? capNetworkOnlyFindings(stampReport(normalised.report, facts), { notes, screenshots }) : null;
837
+ const runnerDrop = capped ? dropRunnerRejectedFindings(capped, { notes, observations }) : { report: null, dropped: [] };
838
+ for (const entry of runnerDrop.dropped) {
839
+ transcript.write({ kind: 'finding_dropped', id: entry.finding.id, severity: entry.finding.severity, title: entry.finding.title, reason: entry.reason });
840
+ }
841
+ droppedByRunner = runnerDrop.dropped;
842
+ const contradicted = runnerDrop.report ? downgradeContradictedFindings(runnerDrop.report, play.actionLog) : { report: null, downgraded: [] };
843
+ const candidate = contradicted.report;
844
+ for (const entry of contradicted.downgraded) {
845
+ transcript.write({ kind: 'finding_downgraded', id: entry.id, from: entry.from, to: entry.to, control: entry.control, action: entry.action });
846
+ }
847
+ downgradedByRunner = contradicted.downgraded;
848
+ if (contradicted.downgraded.length > 0) {
849
+ 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(', ')}`);
850
+ }
851
+ if (candidate) lastCandidate = candidate;
741
852
  const validation = candidate ? validator.validate(candidate) : { ok: false, details: [{ instancePath: '/', message: parseError || 'report.json was not written' }] };
742
853
  const evidenceProblems = candidate ? await checkEvidence(candidate, runDirectory) : [];
743
854
  // The server lints the same four sections at `complete` and records the
@@ -798,6 +909,12 @@ async function reportPhase({
798
909
  if (!markdown) {
799
910
  markdown = renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason: 'The report step wrote report.json but no report.md.' });
800
911
  }
912
+ if (droppedByRunner.length > 0) {
913
+ markdown = rewriteMarkdown(markdown, droppedByRunner.map(entry => entry.finding), report.findings || [], RUNNER_DROPPED_SECTION);
914
+ log(`dropped ${droppedByRunner.length} finding(s) the runner's evidence rules refuse: ${droppedByRunner.map(entry => `${entry.finding.id} (${entry.reason})`).join(', ')}`);
915
+ }
916
+ // The uploaded pair agree: report.md says the severity report.json does.
917
+ if (downgradedByRunner.length > 0) markdown = rewriteDowngradedMarkdown(markdown, downgradedByRunner);
801
918
  const generatedAt = new Date().toISOString();
802
919
  const facts = reportFacts({ persona, driverName, target, play });
803
920
  markdown = stampGeneratedAt(markdown, generatedAt, facts);
@@ -808,6 +925,72 @@ async function reportPhase({
808
925
  return { report, markdown, lint };
809
926
  }
810
927
 
928
+ /** The smallest output ceiling worth retrying a report turn with. */
929
+ export const REPORT_MIN_TOKENS = 1536;
930
+
931
+ /** Whether a 402 carried the three figures a retry is sized from. */
932
+ export function hasBudgetFigures(error) {
933
+ const figures = (error && error.figures) || {};
934
+ return ['usd_used', 'usd_max', 'projected_usd'].every(key => Number.isFinite(Number(figures[key])) && figures[key] !== null && figures[key] !== '');
935
+ }
936
+
937
+ /** Characters per input token, for estimating the input share of a projection. */
938
+ export const CHARS_PER_INPUT_TOKEN = 3.5;
939
+
940
+ /**
941
+ * Output price per token over input price per token, assumed when the 402
942
+ * does not split its projection. Prices are server side only; this ratio is
943
+ * only used to divide the server's own projected figure into two parts.
944
+ */
945
+ export const ASSUMED_OUTPUT_PRICE_RATIO = 5;
946
+
947
+ /**
948
+ * The output ceiling a refused report turn could fit in, or null when even
949
+ * the minimum report cannot fit.
950
+ *
951
+ * The proxy's projection prices the input and the output ceiling together,
952
+ * and only the output ceiling can move, so the input's share is taken out of
953
+ * the room first: from the 402's own split (`projected: { input_cents,
954
+ * output_cents }`) when the server sends one, otherwise estimated from the
955
+ * request's size (characters / 3.5 at an input price a fifth of the output
956
+ * price). What is left of the room is then shared out over the output, with
957
+ * a tenth of headroom.
958
+ *
959
+ * @param {ProxyError} error
960
+ * @param {number} current the output ceiling that was refused
961
+ * @param {number} [bodyChars] the refused request's JSON size
962
+ * @returns {number|null}
963
+ */
964
+ export function reportTokensThatFit(error, current, bodyChars = 0) {
965
+ const figures = (error && error.figures) || {};
966
+ const used = Number(figures.usd_used);
967
+ const max = Number(figures.usd_max);
968
+ const projected = Number(figures.projected_usd);
969
+ if (![used, max, projected].every(Number.isFinite) || projected <= 0 || current <= 0) return null;
970
+ const room = max - used;
971
+ if (room <= 0 || room >= projected) return null;
972
+ const split = figures.projected && typeof figures.projected === 'object' ? figures.projected : null;
973
+ let inputUsd;
974
+ let outputUsd;
975
+ if (split && Number.isFinite(Number(split.input_cents)) && Number.isFinite(Number(split.output_cents))) {
976
+ inputUsd = Number(split.input_cents) / 100;
977
+ outputUsd = Number(split.output_cents) / 100;
978
+ } else {
979
+ // The request's JSON size counts any base64 screenshot as text, which
980
+ // overstates the input (an image is billed far below its base64 length
981
+ // as text). Overstating the input understates what the output can have,
982
+ // so the estimate errs on the safe side: a retry it allows should fit.
983
+ const inputTokens = Math.max(0, Number(bodyChars) || 0) / CHARS_PER_INPUT_TOKEN;
984
+ const perInputToken = projected / (inputTokens + ASSUMED_OUTPUT_PRICE_RATIO * current);
985
+ inputUsd = perInputToken * inputTokens;
986
+ outputUsd = projected - inputUsd;
987
+ }
988
+ const outputRoom = room - inputUsd;
989
+ if (outputRoom <= 0 || outputUsd <= 0) return null;
990
+ const tokens = Math.floor(current * (outputRoom / outputUsd) * 0.9);
991
+ return tokens >= REPORT_MIN_TOKENS ? tokens : null;
992
+ }
993
+
811
994
  /**
812
995
  * `callStepWithRetry`, plus the one retry that is about our own request
813
996
  * rather than the server's mood: `too_many_images` means this step allows
@@ -841,7 +1024,8 @@ async function callWithImageRetry({ imageBudget, keepObservations, messages, onI
841
1024
  * product where the same few thousand tokens are re-sent tens of times. The
842
1025
  * proxy allowlists `system` and passes the blocks through unchanged.
843
1026
  */
844
- export function buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions }) {
1027
+ export function buildSystem({ skill, persona, brief, intentNotes, driverName, target, maxActions, runId = null }) {
1028
+ const identity = runIdentity({ persona, runId });
845
1029
  return [
846
1030
  { type: 'text', text: skill },
847
1031
  { type: 'text', text: `<persona_file>\n${persona.body}\n</persona_file>` },
@@ -852,13 +1036,55 @@ export function buildSystem({ skill, persona, brief, intentNotes, driverName, ta
852
1036
  `target: ${target}`,
853
1037
  `max_actions: ${maxActions}`,
854
1038
  `<expectations_brief>\n${briefForPrompt(brief)}\n</expectations_brief>`,
855
- `<design_intent_notes>\n${intentNotesForPrompt(brief, intentNotes)}\n</design_intent_notes>`
1039
+ `<design_intent_notes>\n${intentNotesForPrompt(brief, intentNotes)}\n</design_intent_notes>`,
1040
+ ...(identity ? [`<run_identity>\n${runIdentityForPrompt(identity)}\n</run_identity>`] : [])
856
1041
  ].join('\n'),
857
1042
  cache_control: { type: 'ephemeral' }
858
1043
  }
859
1044
  ];
860
1045
  }
861
1046
 
1047
+ /**
1048
+ * The run id cut to something short enough for an email or a display name:
1049
+ * the last eight letters and digits after the `pt_run_` prefix.
1050
+ *
1051
+ * @param {string} runId
1052
+ * @returns {string}
1053
+ */
1054
+ export function shortRunId(runId) {
1055
+ const compact = String(runId == null ? '' : runId).replace(/^pt_run_/, '').toLowerCase().replace(/[^a-z0-9]/g, '');
1056
+ return compact.slice(-8);
1057
+ }
1058
+
1059
+ /**
1060
+ * A fresh identity for every run, so a sign-up form never sees an email an
1061
+ * earlier run already registered. The Texas round lost actions to a 409 on
1062
+ * sign-up from a reused address. `example.test` is a reserved name that never
1063
+ * delivers mail.
1064
+ *
1065
+ * @param {{persona: {slug?: string, name?: string}, runId: string}} args
1066
+ * @returns {{email: string, displayName: string, short: string}|null}
1067
+ */
1068
+ export function runIdentity({ persona, runId }) {
1069
+ const short = shortRunId(runId);
1070
+ if (!short) return null;
1071
+ const slug = String((persona && persona.slug) || 'player').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'player';
1072
+ const name = String((persona && (persona.name || persona.slug)) || 'Player').replace(/\s+/g, ' ').trim().slice(0, 40) || 'Player';
1073
+ return { email: `persona-${slug}-${short}@example.test`, displayName: `${name} ${short}`, short };
1074
+ }
1075
+
1076
+ /**
1077
+ * @param {{email: string, displayName: string}} identity
1078
+ * @returns {string}
1079
+ */
1080
+ export function runIdentityForPrompt(identity) {
1081
+ return [
1082
+ `email: ${identity.email}`,
1083
+ `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.'
1085
+ ].join('\n');
1086
+ }
1087
+
862
1088
  /**
863
1089
  * The brief as the persona skill expects it: the `fields` object the
864
1090
  * expectations-brief schema describes, with the version alongside so a report
@@ -980,6 +1206,7 @@ async function finish(outcome, ledger, runDirectory, transcript, heartbeat) {
980
1206
  }
981
1207
  }
982
1208
  outcome.usage = ledger.toReportUsage();
1209
+ outcome.duration = ledger.duration();
983
1210
  await writeUsageFile(runDirectory, ledger);
984
1211
  transcript.write({ kind: 'end', state: outcome.state, quit_reason: outcome.quitReason, actions: outcome.actionsTaken });
985
1212
  await transcript.close();
@@ -995,6 +1222,7 @@ async function finish(outcome, ledger, runDirectory, transcript, heartbeat) {
995
1222
  export function renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason }) {
996
1223
  const good = notes.filter(note => note.kind === 'good');
997
1224
  const bad = notes.filter(note => note.kind === 'bug' || note.kind === 'confusing');
1225
+ const network = notes.filter(note => note.automatic && note.url);
998
1226
  const list = entries => (entries.length > 0
999
1227
  ? entries.map(note => `- **${note.kind}${note.severity ? `/${note.severity}` : ''}**: ${note.text}`).join('\n')
1000
1228
  : '- Nothing was logged.');
@@ -1022,6 +1250,7 @@ export function renderFallbackMarkdown({ persona, driverName, target, notes, scr
1022
1250
  '## Suggested next step',
1023
1251
  'Run this persona again once the reason above is resolved.',
1024
1252
  '',
1253
+ ...(network.length > 0 ? ['## Network notes', network.map(note => `- ${note.status ? `${note.status} ` : ''}${note.url}`).join('\n'), ''] : []),
1025
1254
  '## Evidence',
1026
1255
  screenshots.length > 0 ? screenshots.map(shot => `- ${shot.relativePath}`).join('\n') : '- (none)',
1027
1256
  ''
@@ -1068,6 +1297,8 @@ export function notesToFindings(notes, screenshots) {
1068
1297
  for (const note of notes) {
1069
1298
  const category = NOTE_CATEGORY[note.kind];
1070
1299
  if (!category || !note.text) continue;
1300
+ // A blocker or major written on a driver error is the runner's failure.
1301
+ if (note.driver_error && ABOVE_MINOR.includes(note.severity)) continue;
1071
1302
  const text = String(note.text);
1072
1303
  const shot = nearestScreenshot(note, screenshots);
1073
1304
  findings.push({
@@ -1145,6 +1376,7 @@ async function writePartialMarkdown({ runDirectory, persona, driverName, target,
1145
1376
  export function noteLine(note, i) {
1146
1377
  const tags = [
1147
1378
  note.automatic ? 'automatic' : null,
1379
+ note.driver_error ? 'written on a driver error' : null,
1148
1380
  Number.isInteger(note.observation) && note.observation > 0 ? `seen at observation ${note.observation}` : null,
1149
1381
  note.screenshot ? `screenshot ${note.screenshot}` : null
1150
1382
  ].filter(Boolean);
@@ -1182,7 +1414,7 @@ export const MAX_OBSERVATIONS_IN_REPORT = 200;
1182
1414
  function observationLines(observations) {
1183
1415
  if (!observations || observations.length === 0) return '(none)';
1184
1416
  const shown = observations.slice(-MAX_OBSERVATIONS_IN_REPORT);
1185
- const lines = shown.map(entry => `observation ${entry.index}: step ${entry.step}${entry.url ? ` ${entry.url}` : ''}${entry.title ? ` "${entry.title}"` : ''}`);
1417
+ const lines = shown.map(entry => `observation ${entry.index}: step ${entry.step}${entry.url ? ` ${entry.url}` : ''}${entry.title ? ` "${entry.title}"` : ''}${entry.driver_error ? ` [driver error: ${entry.driver_error}]` : ''}`);
1186
1418
  if (shown.length < observations.length) lines.unshift(`(the first ${observations.length - shown.length} are left out)`);
1187
1419
  return lines.join('\n');
1188
1420
  }
@@ -1215,6 +1447,332 @@ export function observationTitle(observation) {
1215
1447
  * @param {{notes: Array<Object>, screenshots: Array<Object>}} context
1216
1448
  * @returns {Object} the report, with capped findings rewritten
1217
1449
  */
1450
+ /** How report.md lists what the runner dropped. */
1451
+ 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.',
1454
+ emptyNote: '- Nothing here that the runner kept. See the dropped findings below.'
1455
+ });
1456
+
1457
+ /**
1458
+ * The findings the runner refuses before anything is uploaded, and the
1459
+ * report without them.
1460
+ *
1461
+ * - A blocker or major whose transcript evidence (observations and notes it
1462
+ * cites) all points at driver errors. Screenshots do not rescue it: the
1463
+ * Texas run filed "the season loop is fully blocked" from three strict mode
1464
+ * failures, with a screenshot of the screen working.
1465
+ * - Any finding whose transcript evidence is only waiting notes.
1466
+ *
1467
+ * A finding with no transcript evidence is left to the other checks.
1468
+ *
1469
+ * @param {Object} report
1470
+ * @param {{notes?: Array, observations?: Array}} context
1471
+ * @returns {{report: Object, dropped: Array<{finding: Object, reason: string}>}}
1472
+ */
1473
+ export function dropRunnerRejectedFindings(report, { notes = [], observations = [] } = {}) {
1474
+ if (!report || !Array.isArray(report.findings)) return { report, dropped: [] };
1475
+ const byIndex = new Map(observations.map(entry => [entry.index, entry]));
1476
+ const verdict = finding => {
1477
+ const cited = [];
1478
+ for (const item of Array.isArray(finding.evidence) ? finding.evidence : []) {
1479
+ if (!item || item.type !== 'transcript_step') continue;
1480
+ const ref = String(item.ref || '');
1481
+ const observation = ref.match(/^\s*observation\s*#?\s*(\d+)\s*$/i);
1482
+ const note = ref.match(/^\s*note\s*#?\s*(\d+)\s*$/i);
1483
+ if (observation) {
1484
+ const entry = byIndex.get(Number(observation[1]));
1485
+ cited.push(entry && entry.driver_error ? 'driver' : 'other');
1486
+ } else if (note) {
1487
+ const entry = notes[Number(note[1]) - 1];
1488
+ cited.push(entry && entry.kind === 'waiting' ? 'waiting' : entry && entry.driver_error ? 'driver' : 'other');
1489
+ } else {
1490
+ cited.push('other');
1491
+ }
1492
+ }
1493
+ if (cited.length === 0) return null;
1494
+ if (ABOVE_MINOR.includes(finding.severity) && cited.every(kind => kind === 'driver')) return 'driver errors only';
1495
+ 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';
1497
+ return null;
1498
+ };
1499
+ const dropped = [];
1500
+ const findings = [];
1501
+ for (const finding of report.findings) {
1502
+ const reason = finding ? verdict(finding) : null;
1503
+ if (reason) dropped.push({ finding, reason });
1504
+ else findings.push(finding);
1505
+ }
1506
+ return { report: dropped.length > 0 ? { ...report, findings } : report, dropped };
1507
+ }
1508
+
1509
+ /** The most actions the report prompt lists; the newest are kept. */
1510
+ export const MAX_ACTIONS_IN_REPORT = 60;
1511
+
1512
+ /** The longest typed text an action line repeats. */
1513
+ const MAX_TYPED_CHARS = 40;
1514
+
1515
+ /**
1516
+ * The names of the refs a compacted snapshot shows: `- button "Start" [e12]`
1517
+ * gives e12 -> button "Start".
1518
+ *
1519
+ * @param {string} text
1520
+ * @returns {Map<string, {role: string, name: string}>}
1521
+ */
1522
+ export function refNamesFrom(text) {
1523
+ const names = new Map();
1524
+ for (const line of String(text == null ? '' : text).split('\n')) {
1525
+ const match = line.match(/^\s*- ([a-z]+) "([^"]*)"(?: \[[^\]]*\])*? \[(e\d+)\]/);
1526
+ if (match && match[2]) names.set(match[3], { role: match[1], name: match[2] });
1527
+ }
1528
+ return names;
1529
+ }
1530
+
1531
+ /**
1532
+ * Every action that went through, from the transcript's observation and
1533
+ * action lines, with the name of what it targeted when the screen before it
1534
+ * showed one.
1535
+ *
1536
+ * @param {Array<Object>} records transcript lines of kind observation and action, in order
1537
+ * @returns {Array<{action: number, kind: string, target: string, name: string|null, ref: string|null, changed: boolean}>}
1538
+ */
1539
+ export function actionLogFrom(records) {
1540
+ const log = [];
1541
+ let names = new Map();
1542
+ // Observations are numbered as the report prompt numbers them: one per
1543
+ // observation line, in order.
1544
+ let observation = 0;
1545
+ let previousText = '';
1546
+ let pending = null;
1547
+ for (const record of Array.isArray(records) ? records : []) {
1548
+ if (!record) continue;
1549
+ if (record.kind === 'observation') {
1550
+ observation += 1;
1551
+ const text = String(record.text == null ? '' : record.text);
1552
+ if (pending) {
1553
+ pending.observationAfter = observation;
1554
+ pending.introducedError = introducesError(previousText, text);
1555
+ pending = null;
1556
+ }
1557
+ previousText = text;
1558
+ const next = refNamesFrom(text);
1559
+ // An unchanged marker or a canvas shows no refs; the last names stand.
1560
+ if (next.size > 0) names = next;
1561
+ continue;
1562
+ }
1563
+ if (record.kind !== 'action') continue;
1564
+ pending = null;
1565
+ if (!record.ok || !ACTION_TOOLS.includes(record.tool)) continue;
1566
+ const input = record.input || {};
1567
+ const ref = typeof input.ref === 'string' && input.ref.trim() ? input.ref.trim() : null;
1568
+ const known = ref ? names.get(ref) : null;
1569
+ let name = known ? known.name : null;
1570
+ let target;
1571
+ if (record.tool === 'key') target = `key ${String(input.key || '')}`;
1572
+ else if (record.tool === 'navigate') target = String(input.url || '');
1573
+ else if (record.tool === 'swipe') target = `from (${input.x},${input.y}) to (${input.toX},${input.toY})`;
1574
+ else {
1575
+ const named = typeof input.target === 'string' && input.target.trim() ? input.target.trim() : null;
1576
+ if (!name && named) {
1577
+ const roleAndName = named.match(/^[a-z]+\s+"([^"]*)"$/);
1578
+ name = roleAndName ? roleAndName[1] : named;
1579
+ }
1580
+ 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');
1581
+ if (record.tool === 'type') {
1582
+ const text = String(input.text == null ? '' : input.text);
1583
+ 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' : ''}`;
1584
+ } else {
1585
+ target = where;
1586
+ }
1587
+ }
1588
+ 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 };
1589
+ log.push(entry);
1590
+ pending = entry;
1591
+ }
1592
+ return log;
1593
+ }
1594
+
1595
+ /** A line that reads as an error: an alert or a dialog, or error wording. */
1596
+ const ERROR_LINE = /^\s*- (?:alert|alertdialog|dialog)\b|\b(?:error|errors|failed|invalid)\b/i;
1597
+
1598
+ /**
1599
+ * Whether the screen after an action shows an alert, a dialog or error
1600
+ * wording that the screen before it did not: a change, but not the one the
1601
+ * persona wanted.
1602
+ *
1603
+ * @param {string} before
1604
+ * @param {string} after
1605
+ * @returns {boolean}
1606
+ */
1607
+ export function introducesError(before, after) {
1608
+ const seen = new Set(String(before || '').split('\n').map(line => line.trim()));
1609
+ return String(after || '').split('\n').some(line => !seen.has(line.trim()) && ERROR_LINE.test(line));
1610
+ }
1611
+
1612
+ /**
1613
+ * The action log as the report prompt's numbered list, bounded to the newest
1614
+ * {@link MAX_ACTIONS_IN_REPORT}.
1615
+ *
1616
+ * @param {Array<Object>} log
1617
+ * @returns {string}
1618
+ */
1619
+ export function actionLogLines(log) {
1620
+ if (!Array.isArray(log) || log.length === 0) return '(none)';
1621
+ const shown = log.slice(-MAX_ACTIONS_IN_REPORT);
1622
+ const lines = shown.map(entry => `${entry.action}. ${entry.kind} ${entry.target}: ${entry.changed ? 'screen changed' : 'no change'}`);
1623
+ if (shown.length < log.length) lines.unshift(`(the first ${log.length - shown.length} are left out)`);
1624
+ return lines.join('\n');
1625
+ }
1626
+
1627
+ /**
1628
+ * The failed requests the runner recorded, one line each, for the report
1629
+ * prompt's network notes.
1630
+ *
1631
+ * @param {Array<Object>} notes
1632
+ * @returns {string}
1633
+ */
1634
+ export function networkNoteLines(notes) {
1635
+ const lines = [];
1636
+ (notes || []).forEach((note, index) => {
1637
+ if (!note || !note.automatic || !note.url) return;
1638
+ lines.push(`- note ${index + 1}: ${note.status ? `${note.status} ` : ''}${note.url}`);
1639
+ });
1640
+ return lines.length > 0 ? lines.join('\n') : '(none)';
1641
+ }
1642
+
1643
+ /**
1644
+ * The "nothing happens" family: wording that says a control was used and
1645
+ * produced nothing. Deliberately narrow; "cannot" or "fails" can be true of a
1646
+ * control the persona clicked.
1647
+ */
1648
+ const NOTHING_HAPPENED = /\b(?:did nothing|does nothing|never kicked off|never started|no response|nothing happened)\b/i;
1649
+
1650
+ /** One step down the severity scale. Cosmetic and praise are never touched. */
1651
+ const DOWNGRADE = Object.freeze({ blocker: 'major', major: 'minor', minor: 'cosmetic' });
1652
+
1653
+ /**
1654
+ * Whether a text names a control as a whole word, ignoring case: "Play"
1655
+ * matches "press Play" and never "Players".
1656
+ *
1657
+ * @param {string} text
1658
+ * @param {string} name
1659
+ * @returns {boolean}
1660
+ */
1661
+ export function namesControl(text, name) {
1662
+ const value = String(name || '').trim();
1663
+ if (value.length < 3) return false;
1664
+ const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1665
+ return new RegExp(`(?<![\\p{L}\\p{N}])${escaped}(?![\\p{L}\\p{N}])`, 'iu').test(String(text || ''));
1666
+ }
1667
+
1668
+ /**
1669
+ * Downgrade, never drop, a finding the action log contradicts. All of these
1670
+ * have to hold, or the severity is left alone:
1671
+ *
1672
+ * - its title or description is in the "nothing happens" family ("did
1673
+ * nothing", "does nothing", "never kicked off", "never started", "no
1674
+ * response", "nothing happened") and names a control, whole word, of at
1675
+ * least three characters;
1676
+ * - the most recent click on that control changed the screen, and the screen
1677
+ * after it gained no alert, dialog or error wording;
1678
+ * - that click happened at or after the latest observation the finding cites
1679
+ * (a finding citing no observation is left alone).
1680
+ *
1681
+ * The Texas round's report said the season was never kicked off, with the
1682
+ * click on KICK OFF THE SEASON and a changed screen in its own transcript.
1683
+ *
1684
+ * @param {Object} report
1685
+ * @param {Array<Object>} log from {@link actionLogFrom}
1686
+ * @returns {{report: Object, downgraded: Array<{id: string, from: string, to: string, control: string, action: number, note: string}>}}
1687
+ */
1688
+ export function downgradeContradictedFindings(report, log) {
1689
+ if (!report || !Array.isArray(report.findings) || !Array.isArray(log) || log.length === 0) return { report, downgraded: [] };
1690
+ const clicks = log.filter(entry => entry.kind === 'click' && entry.name && entry.name.trim().length >= 3);
1691
+ if (clicks.length === 0) return { report, downgraded: [] };
1692
+ const downgraded = [];
1693
+ const findings = report.findings.map(finding => {
1694
+ if (!finding || !DOWNGRADE[finding.severity]) return finding;
1695
+ const text = `${finding.title || ''}\n${finding.description || ''}`;
1696
+ if (!NOTHING_HAPPENED.test(text)) return finding;
1697
+ const cited = (Array.isArray(finding.evidence) ? finding.evidence : [])
1698
+ .map(item => (item && item.type === 'transcript_step' ? String(item.ref || '').match(/^\s*observation\s*#?\s*(\d+)\s*$/i) : null))
1699
+ .filter(Boolean)
1700
+ .map(match => Number(match[1]));
1701
+ if (cited.length === 0) return finding;
1702
+ const citedLatest = Math.max(...cited);
1703
+ // The controls the finding names, each judged by its most recent click.
1704
+ const named = [...new Set(clicks.map(entry => entry.name.trim().toLowerCase()))]
1705
+ .filter(name => namesControl(text, name));
1706
+ let hit = null;
1707
+ for (const name of named) {
1708
+ const latest = clicks.filter(entry => entry.name.trim().toLowerCase() === name).at(-1);
1709
+ if (!latest.changed || latest.introducedError) continue;
1710
+ if (!Number.isInteger(latest.observationBefore) || latest.observationBefore < citedLatest) continue;
1711
+ hit = latest;
1712
+ break;
1713
+ }
1714
+ if (!hit) return finding;
1715
+ const to = DOWNGRADE[finding.severity];
1716
+ const note = `the action log shows ${hit.name.trim()} clicked at step ${hit.action} with the screen changing`;
1717
+ const description = String(finding.description || '');
1718
+ downgraded.push({ id: finding.id, from: finding.severity, to, control: hit.name.trim(), action: hit.action, note });
1719
+ return { ...finding, severity: to, description: description ? `${description}\n\nDowngraded by the runner: ${note}.` : `Downgraded by the runner: ${note}.` };
1720
+ });
1721
+ return { report: downgraded.length > 0 ? { ...report, findings } : report, downgraded };
1722
+ }
1723
+
1724
+ /** How report.md lists the findings the runner downgraded. */
1725
+ export const DOWNGRADED_HEADING = '## Downgraded by the runner (the action log contradicts the claim)';
1726
+
1727
+ /**
1728
+ * report.md agreeing with report.json about downgraded findings: on every
1729
+ * line that names a downgraded finding's id, its old severity word becomes
1730
+ * the new one, and a section lists each downgrade with the runner's note,
1731
+ * replacing one written before.
1732
+ *
1733
+ * @param {string} markdown
1734
+ * @param {Array<{id: string, from: string, to: string, note: string}>} downgraded
1735
+ * @returns {string}
1736
+ */
1737
+ export function rewriteDowngradedMarkdown(markdown, downgraded) {
1738
+ if (!Array.isArray(downgraded) || downgraded.length === 0) return markdown;
1739
+ const text = String(markdown || '');
1740
+ const at = text.indexOf(`\n${DOWNGRADED_HEADING}`);
1741
+ let body = (at === -1 ? (text.startsWith(DOWNGRADED_HEADING) ? '' : text) : text.slice(0, at)).replace(/\s+$/, '');
1742
+ const recase = (word, to) => (word === word.toUpperCase() ? to.toUpperCase() : word[0] === word[0].toUpperCase() ? to[0].toUpperCase() + to.slice(1) : to);
1743
+ const escape = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1744
+ const namesId = (line, id) => new RegExp(`(^|[^A-Za-z0-9])${escape(id)}(?![0-9])`).test(line);
1745
+ // The finding a "Severity:" line belongs to: the one named by the nearest
1746
+ // heading or list item above it. A heading naming none ends it.
1747
+ let current = null;
1748
+ body = body.split('\n').map(line => {
1749
+ let out = line;
1750
+ const named = downgraded.find(entry => entry.id && namesId(line, String(entry.id)));
1751
+ if (named) current = named;
1752
+ else if (/^#{1,6} /.test(line) || /^\s*(?:[-*+]|\d+[.)])\s+\S*\b[A-Z]\d{2,}\b/.test(line)) current = null;
1753
+ for (const entry of downgraded) {
1754
+ const id = String(entry.id || '');
1755
+ if (!id) continue;
1756
+ // Only the severity slot the writer labels: right after the id in
1757
+ // parentheses or brackets ("F001 (major)", "**F001 [Major, bug]**"), or
1758
+ // after "Severity:" within that finding. A title that happens to
1759
+ // contain the word stays as written.
1760
+ if (namesId(out, id)) {
1761
+ out = out.replace(new RegExp(`(${escape(id)}\\**\\s*[([]\\s*)(${entry.from})\\b`, 'i'), (match, lead, word) => `${lead}${recase(word, entry.to)}`);
1762
+ }
1763
+ if (current === entry) {
1764
+ out = out.replace(new RegExp(`(severity\\**\\s*:\\s*\\**\\s*)(${entry.from})\\b`, 'gi'), (match, lead, word) => `${lead}${recase(word, entry.to)}`);
1765
+ }
1766
+ }
1767
+ return out;
1768
+ }).join('\n');
1769
+ const section = [
1770
+ DOWNGRADED_HEADING,
1771
+ ...downgraded.map(entry => `- ${entry.id}: ${entry.from} to ${entry.to}, because ${entry.note}.`)
1772
+ ].join('\n');
1773
+ return `${body}\n\n${section}\n`;
1774
+ }
1775
+
1218
1776
  export function capNetworkOnlyFindings(report, { notes = [], screenshots = [] } = {}) {
1219
1777
  if (!report || !Array.isArray(report.findings)) return report;
1220
1778
  const byShot = new Map();