ravensight-playtest 0.2.7 → 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,294 @@
1
+ import { readFile, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { sha256File } from '../fsutil.js';
4
+ import { planRunDir } from '../upload/index.js';
5
+ import { record, STATUS } from '../upload/queue.js';
6
+ import { TRANSCRIPT_PATH, VIDEO_PATHS } from '../upload/allowlist.js';
7
+ import { RUN_FILES } from './paths.js';
8
+ import { fillPlaceholders } from './placeholders.js';
9
+ import { writeReportFiles } from './report.js';
10
+
11
+ /**
12
+ * Findings the server dropped, marked in the local report so it agrees with
13
+ * the dashboard.
14
+ *
15
+ * At `complete` the server keeps a finding only when at least one of its
16
+ * evidence refs resolves to a file the run actually uploaded, and answers
17
+ * with how many it dropped, not which. Without this step the CLI said "the
18
+ * server dropped 1 finding" while report.md and report.json still listed it,
19
+ * major severity and all, and the dashboard showed one fewer.
20
+ *
21
+ * The CLI uploaded the files itself, so it can apply the server's rule to
22
+ * the same list and name them. When its count matches the server's, each
23
+ * one is marked `dropped: true` in report.json and moved in report.md to a
24
+ * section of its own: kept for the developer to read, out of the top issues,
25
+ * the aggregate and the synthetic telemetry. When the counts disagree it
26
+ * does not guess which; it says how many the server dropped and points at
27
+ * the dashboard.
28
+ */
29
+
30
+ /** The section dropped findings are moved to. */
31
+ export const DROPPED_HEADING = '## Dropped by the server (no usable evidence)';
32
+
33
+ /**
34
+ * The uploaded path, or candidate paths, an evidence entry needs. The same
35
+ * mapping as the server's `manifestPathForEvidence`.
36
+ *
37
+ * @param {{type?: string, ref?: string}} evidence
38
+ * @returns {string|Array<string>|null}
39
+ */
40
+ export function evidencePath(evidence) {
41
+ if (!evidence || typeof evidence.type !== 'string') return null;
42
+ const ref = typeof evidence.ref === 'string' ? evidence.ref : '';
43
+ switch (evidence.type) {
44
+ case 'screenshot':
45
+ return ref || null;
46
+ case 'video_ts':
47
+ return [...VIDEO_PATHS];
48
+ case 'transcript_step':
49
+ return TRANSCRIPT_PATH;
50
+ case 'file_line':
51
+ return ref ? ref.split(':')[0] : null;
52
+ default:
53
+ return null;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * The findings none of whose evidence resolves to an uploaded file.
59
+ *
60
+ * @param {Object} report
61
+ * @param {Iterable<string>} uploaded the run relative paths that were uploaded
62
+ * @returns {Array<Object>}
63
+ */
64
+ export function unbackedFindings(report, uploaded) {
65
+ const paths = new Set(uploaded);
66
+ const findings = Array.isArray(report && report.findings) ? report.findings : [];
67
+ return findings.filter(finding => {
68
+ if (!finding || finding.dropped === true) return false;
69
+ const evidence = Array.isArray(finding.evidence) ? finding.evidence : [];
70
+ return !evidence.some(entry => {
71
+ const wanted = evidencePath(entry);
72
+ if (wanted === null) return false;
73
+ return (Array.isArray(wanted) ? wanted : [wanted]).some(candidate => paths.has(candidate));
74
+ });
75
+ });
76
+ }
77
+
78
+ /**
79
+ * report.json with the named findings marked.
80
+ *
81
+ * @param {Object} report
82
+ * @param {Set<string>} ids
83
+ * @returns {Object}
84
+ */
85
+ export function markDroppedFindings(report, ids) {
86
+ return {
87
+ ...report,
88
+ findings: (report.findings || []).map(finding => (finding && ids.has(finding.id) ? { ...finding, dropped: true } : finding))
89
+ };
90
+ }
91
+
92
+ const SECTION = /^## /;
93
+ const LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s+/;
94
+
95
+ /**
96
+ * report.md with the dropped findings out of the summary and in a section of
97
+ * their own.
98
+ *
99
+ * A list item that names a dropped finding id and no kept one is removed
100
+ * wherever it is, which is how "Top issues" names them (`F003 ...`). A
101
+ * section left with nothing under it says so. Then the dropped findings are
102
+ * listed under {@link DROPPED_HEADING}, replacing one written before, so a
103
+ * second reconcile does not stack them.
104
+ *
105
+ * @param {string} markdown
106
+ * @param {Array<Object>} dropped the dropped findings, from report.json
107
+ * @param {Array<Object>} kept the findings the server kept
108
+ * @param {{unidentified?: number}} [opts] a count the CLI could not attribute
109
+ * @returns {string}
110
+ */
111
+ export function rewriteMarkdown(markdown, dropped, kept, { unidentified = 0 } = {}) {
112
+ const lines = withoutDroppedSection(String(markdown || '').replace(/\s+$/, '')).split('\n');
113
+ const droppedIds = dropped.map(finding => String(finding.id)).filter(Boolean);
114
+ const keptIds = kept.map(finding => String(finding.id)).filter(Boolean);
115
+ const names = (line, id) => new RegExp(`(^|[^A-Za-z0-9])${escapeRegExp(id)}(?![0-9])`).test(line);
116
+
117
+ const out = [];
118
+ let section = null;
119
+ let sectionRemoved = 0;
120
+ let sectionKept = 0;
121
+ const closeSection = () => {
122
+ if (section !== null && sectionRemoved > 0 && sectionKept === 0) {
123
+ // Trailing blank lines stay after the note, as they were after the list.
124
+ let blanks = 0;
125
+ while (out.length > 0 && out[out.length - 1].trim() === '') {
126
+ out.pop();
127
+ blanks += 1;
128
+ }
129
+ out.push('- Nothing here that the server kept. See the dropped findings below.');
130
+ for (let i = 0; i < blanks; i += 1) out.push('');
131
+ }
132
+ };
133
+ for (const line of lines) {
134
+ if (SECTION.test(line)) {
135
+ closeSection();
136
+ section = line;
137
+ sectionRemoved = 0;
138
+ sectionKept = 0;
139
+ out.push(line);
140
+ continue;
141
+ }
142
+ if (LIST_ITEM.test(line) && droppedIds.some(id => names(line, id)) && !keptIds.some(id => names(line, id))) {
143
+ sectionRemoved += 1;
144
+ continue;
145
+ }
146
+ if (LIST_ITEM.test(line)) sectionKept += 1;
147
+ out.push(line);
148
+ }
149
+ closeSection();
150
+
151
+ const tail = ['', DROPPED_HEADING];
152
+ if (dropped.length > 0) {
153
+ tail.push(
154
+ 'The server keeps a finding only when at least one of its evidence references points at a file this run uploaded. These did not, so the dashboard does not show them and they are not counted. They are kept here so you can judge them yourself.',
155
+ ''
156
+ );
157
+ for (const finding of dropped) {
158
+ const evidence = (Array.isArray(finding.evidence) ? finding.evidence : [])
159
+ .map(entry => `${entry && entry.type} ${entry && entry.ref}`)
160
+ .join(', ') || 'none';
161
+ // The title is the model's prose, copied here; fill any slot it carries first.
162
+ tail.push(`- ${finding.id} (${finding.severity}, ${finding.category}): ${fillPlaceholders(String(finding.title || ''))}. Evidence cited: ${evidence}.`);
163
+ }
164
+ }
165
+ if (unidentified > 0) {
166
+ tail.push(
167
+ `The server dropped ${unidentified} finding(s) with no usable evidence, and this report could not tell which, so every finding above is still listed and counted here. The dashboard shows the findings the server kept.`
168
+ );
169
+ }
170
+ return `${out.join('\n')}\n${tail.join('\n')}\n`;
171
+ }
172
+
173
+ /**
174
+ * Remove a dropped section written by an earlier reconcile.
175
+ *
176
+ * @param {string} markdown
177
+ * @returns {string}
178
+ */
179
+ function withoutDroppedSection(markdown) {
180
+ const lines = markdown.split('\n');
181
+ const start = lines.findIndex(line => line.trim() === DROPPED_HEADING);
182
+ if (start === -1) return markdown;
183
+ let end = lines.findIndex((line, index) => index > start && SECTION.test(line));
184
+ if (end === -1) end = lines.length;
185
+ return [...lines.slice(0, start), ...lines.slice(end)].join('\n').replace(/\s+$/, '');
186
+ }
187
+
188
+ function escapeRegExp(value) {
189
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
190
+ }
191
+
192
+ /**
193
+ * Mark the findings the server dropped in a run directory's report files.
194
+ *
195
+ * The server's own list (`dropped_finding_ids`) is used when it sends one.
196
+ * Without it the CLI applies the server's evidence rule to its upload plan,
197
+ * and marks only when that count matches the server's. Findings marked by an
198
+ * earlier reconcile count towards the server's number, so running it twice
199
+ * changes nothing.
200
+ *
201
+ * Rewriting the two files changes their sha256, which would make `upload
202
+ * <job>` send them again over the validated artifacts. So each new hash is
203
+ * recorded in the job's upload journal as a local edit, which the journal
204
+ * treats as already delivered.
205
+ *
206
+ * @param {Object} args
207
+ * @param {string} args.directory the run directory
208
+ * @param {number} args.droppedCount `dropped_findings` from the complete answer
209
+ * @param {Array<string>} [args.droppedIds] `dropped_finding_ids` from the complete answer, when the server sends it
210
+ * @param {{uploadVideo?: boolean, uploadTranscript?: boolean}} [args.options] what was uploaded
211
+ * @param {{jobId: string, runId: string, repoRoot?: string}} [args.journal] where to record the local edit
212
+ * @param {(dir: string, opts: Object) => Promise<{files: Array<{path: string}>}>} [args.plan] test seam
213
+ * @returns {Promise<{report: Object, markdown: string, identified: boolean, dropped: Array<string>, source: string}|null>}
214
+ * null when there was nothing to mark or no report on disk
215
+ */
216
+ export async function reconcileDroppedFindings({ directory, droppedCount, droppedIds = null, options = {}, journal = null, plan = planRunDir }) {
217
+ const serverIds = Array.isArray(droppedIds) ? droppedIds.map(String).filter(Boolean) : null;
218
+ const count = serverIds && serverIds.length > 0 ? serverIds.length : Number(droppedCount) || 0;
219
+ if (count <= 0) return null;
220
+ let report;
221
+ let markdown;
222
+ try {
223
+ report = JSON.parse(await readFile(path.join(directory, RUN_FILES.reportJson), 'utf8'));
224
+ markdown = await readFile(path.join(directory, RUN_FILES.reportMd), 'utf8');
225
+ } catch {
226
+ return null;
227
+ }
228
+ if (!report || !Array.isArray(report.findings)) return null;
229
+
230
+ const already = report.findings.filter(finding => finding && finding.dropped === true).map(finding => finding.id);
231
+ let ids;
232
+ let identified;
233
+ let source;
234
+ if (serverIds && serverIds.length > 0) {
235
+ const present = new Set(report.findings.filter(Boolean).map(finding => finding.id));
236
+ ids = new Set(serverIds.filter(id => present.has(id)));
237
+ identified = ids.size === serverIds.length;
238
+ source = 'server';
239
+ } else {
240
+ const planned = await plan(directory, { video: Boolean(options.uploadVideo), transcript: Boolean(options.uploadTranscript) });
241
+ const unbacked = unbackedFindings(report, planned.files.map(file => file.path)).map(finding => finding.id);
242
+ identified = already.length + unbacked.length === count;
243
+ ids = new Set(identified ? [...already, ...unbacked] : already);
244
+ source = 'plan';
245
+ }
246
+ const marked = markDroppedFindings(report, ids);
247
+ const dropped = marked.findings.filter(finding => finding && finding.dropped === true);
248
+ const kept = marked.findings.filter(finding => !(finding && finding.dropped === true));
249
+ const rewritten = rewriteMarkdown(markdown, dropped, kept, { unidentified: identified ? 0 : count - dropped.length });
250
+ await writeReportFiles({ runDirectory: directory, markdown: rewritten, report: marked });
251
+ await recordLocalEdits(directory, journal);
252
+ return { report: marked, markdown: rewritten, identified, dropped: [...ids], source };
253
+ }
254
+
255
+ /**
256
+ * Record the rewritten report files' hashes as local edits, so the upload
257
+ * journal treats them as delivered rather than owed.
258
+ *
259
+ * @param {string} directory
260
+ * @param {{jobId: string, runId: string, repoRoot?: string}|null} journal
261
+ */
262
+ async function recordLocalEdits(directory, journal) {
263
+ const where = journal && journal.jobId && journal.repoRoot ? journal : journalFromDirectory(directory) || (journal && journal.jobId ? journal : null);
264
+ if (!where) return;
265
+ for (const name of [RUN_FILES.reportMd, RUN_FILES.reportJson]) {
266
+ const file = path.join(directory, name);
267
+ const info = await stat(file);
268
+ await record(where.jobId, {
269
+ kind: 'run',
270
+ run_id: where.runId,
271
+ path: name,
272
+ sha256: await sha256File(file),
273
+ size: info.size,
274
+ status: STATUS.LOCAL_EDIT,
275
+ detail: 'dropped findings marked after complete; the delivered copy stays the record'
276
+ }, where.repoRoot ? { repoRoot: where.repoRoot } : {});
277
+ }
278
+ }
279
+
280
+ /**
281
+ * The job, run and repository a run directory belongs to, from its own path
282
+ * (`<repo>/.ravensight/jobs/<jobId>/runs/<runId>`), or null for any other.
283
+ *
284
+ * @param {string} directory
285
+ * @returns {{jobId: string, runId: string, repoRoot: string}|null}
286
+ */
287
+ export function journalFromDirectory(directory) {
288
+ const parts = path.resolve(directory).split(path.sep);
289
+ const n = parts.length;
290
+ if (n < 6 || parts[n - 2] !== 'runs' || parts[n - 4] !== 'jobs' || parts[n - 5] !== '.ravensight') return null;
291
+ return { jobId: parts[n - 3], runId: parts[n - 1], repoRoot: parts.slice(0, n - 5).join(path.sep) || path.sep };
292
+ }
293
+
294
+ export default { reconcileDroppedFindings, unbackedFindings, markDroppedFindings, rewriteMarkdown, evidencePath, DROPPED_HEADING };
@@ -58,6 +58,11 @@ export function createHeartbeat({
58
58
  let canceled = false;
59
59
  let failures = 0;
60
60
  let inFlight = false;
61
+ // Set by stop(). A beat already on the wire when the run ends must not
62
+ // write its progress after the run's final state: in 0.2.8 one landed
63
+ // after the outcome and put a finished run back to `playing` in the
64
+ // journal.
65
+ let stopped = false;
61
66
  // The progress the loop last reported. The interval beat carries it too,
62
67
  // rather than resetting the server's and the journal's idea of the
63
68
  // checkpoint to zero every five seconds.
@@ -78,7 +83,7 @@ export function createHeartbeat({
78
83
  try {
79
84
  const answer = await api.runs.heartbeat(gameId, jobId, runId, { heartbeat: true, ...progress });
80
85
  failures = 0;
81
- if (onBeat) await onBeat({ ...progress });
86
+ if (onBeat && !stopped) await onBeat({ ...progress });
82
87
  if (answer && answer.run && answer.run.usage && onUsage) onUsage(answer.run.usage);
83
88
  if (answer && answer.cancel_requested && !canceled) {
84
89
  canceled = true;
@@ -111,6 +116,7 @@ export function createHeartbeat({
111
116
  },
112
117
 
113
118
  start() {
119
+ stopped = false;
114
120
  if (timer) return this;
115
121
  timer = setIntervalImpl(() => {
116
122
  beat().catch(() => {});
@@ -121,6 +127,7 @@ export function createHeartbeat({
121
127
  },
122
128
 
123
129
  stop() {
130
+ stopped = true;
124
131
  if (timer) clearIntervalImpl(timer);
125
132
  timer = null;
126
133
  return this;
@@ -2,15 +2,15 @@ import { selectAction, JEV_CONTROLLER_PROMPT } from './actionSelector.js';
2
2
  import { mkdir, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { prepareMessages, MAX_IMAGES_IN_CONTEXT, KEEP_OBSERVATIONS, shrinkImages } from './context.js';
5
- import { observationHash } from './drivers/driver.js';
6
5
  import { withRollingCacheBreakpoint } from './fixLoop.js';
7
6
  import { callStepWithRetry, ProxyError, STEPS } from './model.js';
8
7
  import { RUN_FILES } from './paths.js';
9
- import { checkEvidence, createReportValidator, lintMarkdownSections, repairInstruction, REQUIRED_MARKDOWN_SECTIONS, stampReport, writeReportFiles } from './report.js';
8
+ import { checkEvidence, createReportValidator, lintMarkdownSections, normaliseEvidence, repairInstruction, REQUIRED_MARKDOWN_SECTIONS, stampReport, writeReportFiles } from './report.js';
10
9
  import { assertNoSecrets } from './secretScan.js';
11
10
  import { ACTION_TOOLS, PLAY_TOOLS, SWIPE_TOOL, REPORT_TOOLS, executeTool } from './tools.js';
12
11
  import { createTranscript } from './transcript.js';
13
12
  import { createUsageLedger, writeUsageFile } from './usage.js';
13
+ import { fillPlaceholders, fillReportPlaceholders } from './placeholders.js';
14
14
 
15
15
  /**
16
16
  * One persona's playthrough: PLAYING then REPORTING (spec 03).
@@ -387,7 +387,7 @@ async function playLoop({
387
387
  latestObservation = first.text;
388
388
  visualObservation = Boolean(first.screenshotBase64);
389
389
  transcript.write({ kind: 'observation', step: first.step, text: first.text, url: first.url });
390
- const landing = await safeScreenshot(driver, 'landing', screenshots, transcript);
390
+ const landing = await safeScreenshot(driver, 'landing', screenshots, transcript, observations.length);
391
391
  messages.push({
392
392
  role: 'user',
393
393
  content: [
@@ -502,7 +502,7 @@ async function playLoop({
502
502
  transcript.write({ kind: 'note', tool: toolUse.name, note });
503
503
  },
504
504
  onScreenshot: shot => {
505
- screenshots.push({ ...shot, order: nextOrder() });
505
+ screenshots.push({ ...shot, observation: observations.length, order: nextOrder() });
506
506
  transcript.write({ kind: 'screenshot', path: shot.relativePath, reason: shot.reason });
507
507
  }
508
508
  });
@@ -551,7 +551,7 @@ async function playLoop({
551
551
  // with the persona's view of the screen in front of it. A 4xx on a
552
552
  // resource is a `network` note, which the report step reads as context
553
553
  // rather than as a bug (a missing favicon is not a broken game).
554
- const shot = await safeScreenshot(driver, 'console-error', screenshots, transcript);
554
+ const shot = await safeScreenshot(driver, 'console-error', screenshots, transcript, observations.length);
555
555
  for (const entry of consoleErrors) {
556
556
  const kind = RESOURCE_4XX.test(String(entry.text)) ? 'network' : 'bug';
557
557
  const note = { kind, text: `${entry.type}: ${entry.text}`, automatic: true, ...(shot ? { screenshot: shot.relativePath } : {}) };
@@ -591,7 +591,7 @@ async function playLoop({
591
591
  quitDetail = `the action budget of ${maxActions} ran out`;
592
592
  }
593
593
 
594
- const final = await safeScreenshot(driver, 'final', screenshots, transcript);
594
+ const final = await safeScreenshot(driver, 'final', screenshots, transcript, observations.length);
595
595
  return {
596
596
  actionsTaken: actions,
597
597
  turns,
@@ -650,11 +650,15 @@ async function reportPhase({
650
650
  observationLines(observations),
651
651
  '',
652
652
  '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
+ '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.',
653
654
  '',
654
- 'Never write calendar dates or times in the prose of report.md or report.json. The runner stamps when the report was generated.',
655
+ '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".',
656
+ '',
657
+ 'Never write calendar dates or times in the prose of report.md or report.json. The runner stamps when the report was generated, so leave the template\'s Date out rather than writing a placeholder for it.',
658
+ 'Replace every <...> slot in the template with real content. Never copy a <...> placeholder into the report.',
655
659
  '',
656
660
  'Screenshots available as evidence (use these exact paths in evidence refs):',
657
- screenshots.length > 0 ? screenshots.map(shot => `- ${shot.relativePath}${shot.reason ? ` (${shot.reason})` : ''}`).join('\n') : '(none)',
661
+ screenshots.length > 0 ? screenshots.map(screenshotLine).join('\n') : '(none)',
658
662
  '',
659
663
  `report.md must contain these section headings, spelled exactly: ${REQUIRED_MARKDOWN_SECTIONS.map(section => `"${section}"`).join(', ')}.`,
660
664
  'Write report.md first, then report.json. Leave usage as zeros; the runner fills it in.'
@@ -665,12 +669,20 @@ async function reportPhase({
665
669
  let report = null;
666
670
  let markdown = null;
667
671
  let lint = [];
668
-
669
- // Two attempts at most: the first, and one repair if the schema or the
670
- // evidence refs refuse it. Each attempt is a metered question, and the
671
- // dogfood gate asks for "first or the repair attempt", not an
672
- // open ended loop.
673
- for (let attempt = 1; attempt <= 2; attempt += 1) {
672
+ // The newest report.json the model wrote, across turns: a repair turn that
673
+ // rewrites only report.md keeps the report.json it already wrote.
674
+ let jsonText = null;
675
+ let repaired = false;
676
+ let continued = false;
677
+
678
+ // One repair at most when the schema or the evidence refs refuse what was
679
+ // written: each turn is a metered question, and the dogfood gate asks for
680
+ // "first or the repair attempt", not an open ended loop. A turn that wrote
681
+ // report.md and stopped before report.json is not a refused report, so it
682
+ // gets one plain "now write report.json" that does not spend the repair.
683
+ // In 0.2.8 it did, and the report.json that came back had its five
684
+ // screenshot refs under "path" with no turn left to fix them.
685
+ for (let attempt = 1; ; attempt += 1) {
674
686
  const answer = await callStepWithRetry({
675
687
  client,
676
688
  jobId,
@@ -693,12 +705,13 @@ async function reportPhase({
693
705
  files.set(name, String(toolUse.input.content || ''));
694
706
  }
695
707
  if (files.has(RUN_FILES.reportMd)) markdown = files.get(RUN_FILES.reportMd);
708
+ if (files.has(RUN_FILES.reportJson)) jsonText = files.get(RUN_FILES.reportJson);
696
709
 
697
710
  let parsed = null;
698
711
  let parseError = null;
699
- if (files.has(RUN_FILES.reportJson)) {
712
+ if (jsonText !== null) {
700
713
  try {
701
- parsed = JSON.parse(files.get(RUN_FILES.reportJson));
714
+ parsed = JSON.parse(jsonText);
702
715
  } catch (error) {
703
716
  parseError = String(error && error.message);
704
717
  }
@@ -718,7 +731,13 @@ async function reportPhase({
718
731
  quitReason: play.quitReason,
719
732
  quitDetail: play.quitDetail
720
733
  };
721
- const candidate = parsed ? capNetworkOnlyFindings(stampReport(parsed, facts), { notes, screenshots }) : null;
734
+ // An evidence path under the wrong key is moved to `ref` before anything
735
+ // judges the report; see normaliseEvidence.
736
+ const normalised = parsed ? normaliseEvidence(parsed, runDirectory) : null;
737
+ if (normalised && normalised.changed > 0) {
738
+ transcript.write({ kind: 'report_evidence_normalised', attempt, entries: normalised.changed });
739
+ }
740
+ const candidate = normalised ? capNetworkOnlyFindings(stampReport(normalised.report, facts), { notes, screenshots }) : null;
722
741
  const validation = candidate ? validator.validate(candidate) : { ok: false, details: [{ instancePath: '/', message: parseError || 'report.json was not written' }] };
723
742
  const evidenceProblems = candidate ? await checkEvidence(candidate, runDirectory) : [];
724
743
  // The server lints the same four sections at `complete` and records the
@@ -740,31 +759,50 @@ async function reportPhase({
740
759
  ...evidenceProblems,
741
760
  ...missingSections.map(section => `report.md has no "${section}" section`)
742
761
  ];
743
- if (attempt === 2) {
762
+
763
+ // Every tool_use the next request echoes back needs its tool_result, or
764
+ // the API refuses the request. A write cut off at the token limit is
765
+ // malformed: it was not saved, and it is answered so.
766
+ const toolResults = answer.toolUses.map(toolUse => (toolUse.malformed
767
+ ? { type: 'tool_result', tool_use_id: toolUse.id, is_error: true, content: [{ type: 'text', text: CUT_OFF_WRITE }] }
768
+ : { type: 'tool_result', tool_use_id: toolUse.id, content: [{ type: 'text', text: 'saved' }] }));
769
+
770
+ if (jsonText === null && files.has(RUN_FILES.reportMd) && !continued) {
771
+ continued = true;
772
+ messages.push({ role: 'assistant', content: assistantContent(answer) });
773
+ messages.push({ role: 'user', content: toolResults.concat([{ type: 'text', text: CONTINUE_WITH_JSON }]) });
774
+ continue;
775
+ }
776
+
777
+ if (repaired) {
744
778
  if (!candidate) throw new Error(`the report step did not produce a valid report.json: ${lint.join('; ')}`);
745
- // Second attempt still imperfect: keep it, record the lint, and let
779
+ // Still imperfect after the repair: keep it, record the lint, and let
746
780
  // the server have the final word. It validates the same schema and
747
781
  // drops unevidenced findings itself, so this is a report with known
748
- // problems rather than no report at all.
782
+ // problems rather than no report at all. Every problem on its own line,
783
+ // before anything is uploaded, so a refusal at complete is never the
784
+ // first the developer hears of it.
749
785
  report = candidate;
750
- log(`report.json still has problems after one repair: ${lint.join('; ')}`);
786
+ log(`report.json still has problems after one repair (${lint.length}); the server will refuse a report that fails its schema:\n${lint.map(line => ` - ${line}`).join('\n')}`);
751
787
  break;
752
788
  }
753
789
 
790
+ repaired = true;
754
791
  messages.push({ role: 'assistant', content: assistantContent(answer) });
755
792
  messages.push({
756
793
  role: 'user',
757
- content: answer.toolUses
758
- .filter(toolUse => !toolUse.malformed)
759
- .map(toolUse => ({ type: 'tool_result', tool_use_id: toolUse.id, content: [{ type: 'text', text: 'saved' }] }))
760
- .concat([{ type: 'text', text: repairInstruction(validation.details, evidenceProblems, missingSections) }])
794
+ content: toolResults.concat([{ type: 'text', text: repairInstruction(validation.details, evidenceProblems, missingSections) }])
761
795
  });
762
796
  }
763
797
 
764
798
  if (!markdown) {
765
799
  markdown = renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason: 'The report step wrote report.json but no report.md.' });
766
800
  }
767
- markdown = stampGeneratedAt(markdown, new Date().toISOString());
801
+ const generatedAt = new Date().toISOString();
802
+ const facts = reportFacts({ persona, driverName, target, play });
803
+ markdown = stampGeneratedAt(markdown, generatedAt, facts);
804
+ // The same slots in the prose the dashboard shows from report.json.
805
+ report = fillReportPlaceholders(report, { generatedAt, ...facts });
768
806
 
769
807
  await writeReportFiles({ runDirectory, markdown, report });
770
808
  return { report, markdown, lint };
@@ -895,10 +933,10 @@ function redactToolInput(toolUse) {
895
933
  return rest;
896
934
  }
897
935
 
898
- async function safeScreenshot(driver, name, screenshots, transcript) {
936
+ async function safeScreenshot(driver, name, screenshots, transcript, observation = null) {
899
937
  try {
900
938
  const shot = await driver.screenshot(name);
901
- screenshots.push({ ...shot, reason: name, order: nextOrder() });
939
+ screenshots.push({ ...shot, reason: name, ...(Number.isInteger(observation) ? { observation } : {}), order: nextOrder() });
902
940
  transcript.write({ kind: 'screenshot', path: shot.relativePath, reason: name });
903
941
  return shot;
904
942
  } catch (error) {
@@ -1083,14 +1121,14 @@ async function writePartialReport({ outcome, runDirectory, persona, driverName,
1083
1121
  play,
1084
1122
  reason: 'The run reached its model budget before the report step. What the persona saw is below, and report.json carries the notes as unreviewed findings.'
1085
1123
  });
1086
- const stamped = stampGeneratedAt(markdown, new Date().toISOString());
1124
+ const stamped = stampGeneratedAt(markdown, new Date().toISOString(), reportFacts({ persona, driverName, target, play }));
1087
1125
  await writeReportFiles({ runDirectory, markdown: stamped, report });
1088
1126
  outcome.report = report;
1089
1127
  outcome.reportMarkdown = stamped;
1090
1128
  }
1091
1129
 
1092
1130
  async function writePartialMarkdown({ runDirectory, persona, driverName, target, notes, screenshots, play, reason }) {
1093
- const markdown = stampGeneratedAt(renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason }), new Date().toISOString());
1131
+ const markdown = stampGeneratedAt(renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason }), new Date().toISOString(), reportFacts({ persona, driverName, target, play }));
1094
1132
  assertNoSecrets('report.md', markdown);
1095
1133
  await writeFile(path.join(runDirectory, RUN_FILES.reportMd), markdown, 'utf8');
1096
1134
  }
@@ -1116,6 +1154,28 @@ export function noteLine(note, i) {
1116
1154
  return `${i + 1}. [${note.kind}${note.severity ? `/${note.severity}` : ''}] ${note.text}${tags.length > 0 ? ` (${tags.join(', ')})` : ''}${after}`;
1117
1155
  }
1118
1156
 
1157
+ /** The tool_result for a write_file call that was cut off before its input was complete. */
1158
+ export const CUT_OFF_WRITE = 'That write_file call was cut off before it finished, so nothing was saved. Write that file again, shorter if it has to be.';
1159
+
1160
+ /** The one follow-up a report turn gets for writing report.md and stopping. */
1161
+ export const CONTINUE_WITH_JSON = 'report.md is saved. Now write report.json with write_file. Every evidence entry is exactly {"type": ..., "ref": ...}; a screenshot is {"type": "screenshot", "ref": "screenshots/04-example.png"}.';
1162
+
1163
+ /**
1164
+ * A screenshot as one line of the report prompt: its path, the observation
1165
+ * it followed (what a blocker or major has to be checked against) and why
1166
+ * it was taken.
1167
+ *
1168
+ * @param {Object} shot
1169
+ * @returns {string}
1170
+ */
1171
+ export function screenshotLine(shot) {
1172
+ const tags = [
1173
+ Number.isInteger(shot.observation) && shot.observation > 0 ? `after observation ${shot.observation}` : null,
1174
+ shot.reason ? String(shot.reason) : null
1175
+ ].filter(Boolean);
1176
+ return `- ${shot.relativePath}${tags.length > 0 ? ` (${tags.join('; ')})` : ''}`;
1177
+ }
1178
+
1119
1179
  /** The most observations listed in the report prompt; the newest are kept. */
1120
1180
  export const MAX_OBSERVATIONS_IN_REPORT = 200;
1121
1181
 
@@ -1216,13 +1276,32 @@ export function capNetworkOnlyFindings(report, { notes = [], screenshots = [] }
1216
1276
  * @param {string} generatedAt an ISO timestamp
1217
1277
  * @returns {string}
1218
1278
  */
1219
- export function stampGeneratedAt(markdown, generatedAt) {
1279
+ export function stampGeneratedAt(markdown, generatedAt, facts = {}) {
1220
1280
  const line = `**Generated at:** ${generatedAt}`;
1221
1281
  const lines = String(markdown || '').split('\n').filter(entry => !/^\*\*Generated at:\*\* /.test(entry));
1222
1282
  const heading = lines.findIndex(entry => /^# /.test(entry));
1223
- if (heading === -1) return [line, '', ...lines].join('\n');
1224
- lines.splice(heading + 1, 0, line);
1225
- return lines.join('\n');
1283
+ if (heading === -1) lines.unshift(line, '');
1284
+ else lines.splice(heading + 1, 0, line);
1285
+ // The template's own slots (`**Date:** <date>`) are filled from what the
1286
+ // runner knows, because the prompt forbids the model a calendar date and a
1287
+ // model that obeys both writes the placeholder itself.
1288
+ return fillPlaceholders(lines.join('\n'), { generatedAt, ...facts });
1289
+ }
1290
+
1291
+ /**
1292
+ * What the runner knows about a run, for the template slots a report left
1293
+ * unfilled.
1294
+ *
1295
+ * @returns {{driver: string, target: string, persona: string, actionsTaken: number, quitReason: string}}
1296
+ */
1297
+ function reportFacts({ persona, driverName, target, play }) {
1298
+ return {
1299
+ driver: driverName,
1300
+ target,
1301
+ persona: persona ? persona.name || persona.slug : undefined,
1302
+ actionsTaken: play ? play.actionsTaken : undefined,
1303
+ quitReason: play ? play.quitReason : undefined
1304
+ };
1226
1305
  }
1227
1306
 
1228
1307
  /**