ravensight-playtest 0.5.0 → 0.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ravensight-playtest",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Local AI playtests and seeded game simulations with reports in Ravensight.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://ravensight.io/docs/#playtest",
@@ -870,11 +870,11 @@ export async function deliver({ deps, api, gameId, jobId, runId, directory, outc
870
870
  body
871
871
  });
872
872
  } catch (error) {
873
- // A server older than partial reports refuses one as `invalid_report`.
874
- // The uploads are already done by then, so the run is completed
875
- // without it rather than left open: the notes stay on disk in
876
- // report.json either way.
877
- if (!(body.report && body.report.partial && error && error.status === 422 && error.code === 'invalid_report')) throw error;
873
+ // A server older than partial reports refuses one as `invalid_report`,
874
+ // and any server refuses one with a secret shaped value in it. The
875
+ // uploads are already done by then, so the run is completed without it
876
+ // rather than left open: the notes stay on disk in report.json either way.
877
+ if (!(body.report && body.report.partial && error && error.status === 422 && ['invalid_report', 'secrets_detected'].includes(error.code))) throw error;
878
878
  log(`run ${runId}: the server refused the partial report (${String(error.message)}), so the run is completed without it. The notes are in ${directory}.`);
879
879
  if (delivery) delivery.reportRefused = true;
880
880
  const { report: ignored, report_md: alsoIgnored, ...withoutReport } = body;
@@ -1037,7 +1037,10 @@ async function aggregatePhase({
1037
1037
 
1038
1038
  // The aggregate reads reports off disk, so a resumed job whose earlier runs
1039
1039
  // were played by a previous process is covered too.
1040
- const reports = await loadRunReports(repoRoot, jobId, personaRuns, journal);
1040
+ // A failed run's report explains the failure and its unit is refunded. It is
1041
+ // not a result to summarise, and the server refuses a summary of only those.
1042
+ const reports = (await loadRunReports(repoRoot, jobId, personaRuns, journal))
1043
+ .map(run => (run.state === 'failed' ? { ...run, report: null } : run));
1041
1044
  let aggregate;
1042
1045
  try {
1043
1046
  aggregate = await buildAggregate({
@@ -18,7 +18,7 @@ export async function readBridgeConfig(file) {
18
18
  }
19
19
 
20
20
  export function createStdioDriver({ spawn: spawnImpl = spawn, timeoutMs = 30000 } = {}) {
21
- let child, buffer = '', nextId = 0, runDirectory, step = 0, shots = 0, lastHash;
21
+ let child, childState, buffer = '', nextId = 0, runDirectory, step = 0, shots = 0, lastHash;
22
22
  const pending = new Map();
23
23
  const fail = message => {
24
24
  for (const entry of pending.values()) { clearTimeout(entry.timer); entry.reject(new DriverError(message, { fatal: true })); }
@@ -52,6 +52,9 @@ export function createStdioDriver({ spawn: spawnImpl = spawn, timeoutMs = 30000
52
52
  const config = await readBridgeConfig(target);
53
53
  runDirectory = runDir;
54
54
  child = spawnImpl(config.command, config.args || [], { cwd: config.cwd || path.dirname(path.resolve(target)), shell: false, stdio: ['pipe', 'pipe', 'pipe'] });
55
+ const state = { process: child, closed: false, stopping: null };
56
+ state.close = new Promise(resolve => child.once('close', () => { state.closed = true; resolve(); }));
57
+ childState = state;
55
58
  child.stdout.setEncoding('utf8');
56
59
  child.stdout.on('data', chunk => {
57
60
  buffer += chunk;
@@ -92,20 +95,33 @@ export function createStdioDriver({ spawn: spawnImpl = spawn, timeoutMs = 30000
92
95
  async screenshot(reason) {
93
96
  const image = await request('screenshot');
94
97
  if (typeof image?.pngBase64 !== 'string' || !image.pngBase64.startsWith('iVBORw0KGgo')) throw new DriverError('This bridge did not return a PNG screenshot.');
95
- const relativePath = path.join('screenshots', screenshotName(++shots, reason));
98
+ const relativePath = path.posix.join('screenshots', screenshotName(++shots, reason));
96
99
  const file = path.join(runDirectory, relativePath);
97
100
  await fs.writeFile(file, Buffer.from(image.pngBase64, 'base64'));
98
101
  return { path: file, relativePath };
99
102
  },
100
103
  async consoleErrors() { return []; },
101
104
  async stop() {
102
- if (child && child.exitCode === null && !child.killed) {
103
- child.kill('SIGTERM');
104
- const process = child;
105
- const timer = setTimeout(() => { if (process.exitCode === null) process.kill('SIGKILL'); }, 1000);
106
- timer.unref();
107
- }
108
105
  fail('Game bridge stopped.');
106
+ const state = childState;
107
+ if (state && !state.stopping) {
108
+ state.stopping = (async () => {
109
+ if (state.closed) return;
110
+ const process = state.process;
111
+ let deadline;
112
+ const force = setTimeout(() => { if (!state.closed) process.kill('SIGKILL'); }, 1000);
113
+ try {
114
+ const timedOut = new Promise((resolve, reject) => {
115
+ deadline = setTimeout(() => reject(new DriverError('Game bridge did not close after stopping.', { fatal: true })), 5000);
116
+ });
117
+ if (process.exitCode === null && process.signalCode === null && !process.killed) process.kill('SIGTERM');
118
+ // Windows can retain the bridge's cwd until its pipes close, even
119
+ // after exit. Concurrent/repeated stops share the same close wait.
120
+ await Promise.race([state.close, timedOut]);
121
+ } finally { clearTimeout(force); clearTimeout(deadline); }
122
+ })();
123
+ }
124
+ if (state) await state.stopping;
109
125
  return { videoPath: null };
110
126
  }
111
127
  };
@@ -1706,7 +1706,9 @@ export function createWebDriver({ importer, headless = true, playtest = null, pl
1706
1706
  async screenshot(name) {
1707
1707
  const current = requirePage();
1708
1708
  shots += 1;
1709
- const relativePath = path.join('screenshots', screenshotName(shots, name));
1709
+ // Forward slashes on every platform: this is the evidence ref the server
1710
+ // matches against upload paths.
1711
+ const relativePath = path.posix.join('screenshots', screenshotName(shots, name));
1710
1712
  const absolute = path.join(runDirectory, relativePath);
1711
1713
  await mkdir(path.dirname(absolute), { recursive: true });
1712
1714
  await current.screenshot({ path: absolute, type: 'png', timeout: ACTION_TIMEOUT_MS });
@@ -61,7 +61,7 @@ export function createWebDriverAdapter({name='desktop_driver',fetch:fetchImpl=gl
61
61
  }catch(error){return {ok:false,changed:false,error:error instanceof DriverError?error.message:'Device input failed.'};}
62
62
  finally {if(session)await request('DELETE',route('/actions')).catch(()=>{});}
63
63
  },
64
- async screenshot(reason){const relativePath=path.join('screenshots',screenshotName(++shots,reason));const file=path.join(runDirectory,relativePath);await fs.writeFile(file,Buffer.from(await frame(),'base64'));return {path:file,relativePath,reason};},
64
+ async screenshot(reason){const relativePath=path.posix.join('screenshots',screenshotName(++shots,reason));const file=path.join(runDirectory,relativePath);await fs.writeFile(file,Buffer.from(await frame(),'base64'));return {path:file,relativePath,reason};},
65
65
  async consoleErrors(){return [];},
66
66
  async stop(){if(session){const current=route('');session=null;await request('DELETE',current);}return {videoPath:null};}
67
67
  };return driver;
@@ -7,7 +7,7 @@ import { withRollingCacheBreakpoint } from './fixLoop.js';
7
7
  import { callStepWithRetry, ProxyError, STEPS } from './model.js';
8
8
  import { RUN_FILES } from './paths.js';
9
9
  import { appendNormalisedMarkdown, checkEvidence, createReportValidator, lintMarkdownSections, normaliseEnums, normaliseEvidence, repairInstruction, REQUIRED_MARKDOWN_SECTIONS, stampReport, writeReportFiles } from './report.js';
10
- import { assertNoSecrets } from './secretScan.js';
10
+ import { assertNoSecrets, scanForSecrets } 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';
@@ -35,14 +35,17 @@ import { MIN_PASSWORD_CHARS } from '../briefLocal.js';
35
35
  * - `X-Playtest-Budget: warn` at 80 percent of the run budget starts a wrap
36
36
  * up, so the report (which is the product) gets written while there is
37
37
  * still budget to write it with.
38
- * - A 402 means the money is gone. Nothing further is asked of the model,
39
- * including the report: a partial `report.md` and `report.json` are
40
- * written locally from the notes (`partial: true`, each note an
38
+ * - An act 402 stops gameplay. If its authoritative figures show room, the
39
+ * cheaper report phase may still fit. Otherwise partial `report.md` and
40
+ * `report.json` files are written locally from the notes (`partial: true`, each note an
41
41
  * unreviewed finding), `complete` is sent with `state: budget_exceeded`
42
42
  * and that report, and the process exits 2. The notes are the one thing
43
43
  * the run bought before the money ran out, so they are not thrown away.
44
44
  */
45
45
 
46
+ /** Why a report is partial when the money ran out. */
47
+ const BUDGET_PARTIAL_REASON = 'The run reached its model budget before the report could be completed. What the persona saw is below, and report.json carries the notes as unreviewed findings.';
48
+
46
49
  /**
47
50
  * Order across notes and screenshots, so a partial report can attach the
48
51
  * screenshot nearest to each note. Module wide and only ever compared within
@@ -161,6 +164,9 @@ export async function runPersona(args) {
161
164
  // what the screen looked like next and a finding can cite where a failure
162
165
  // is visible.
163
166
  const observations = [];
167
+ // What the persona did, kept out here so a report can still say so when the
168
+ // play loop dies part way through.
169
+ const actionRecords = [];
164
170
  let imageBudget = maxImages;
165
171
 
166
172
  await mkdir(path.join(runDirectory, 'screenshots'), { recursive: true });
@@ -221,6 +227,7 @@ export async function runPersona(args) {
221
227
  notes,
222
228
  screenshots,
223
229
  observations,
230
+ actionRecords,
224
231
  imageBudget,
225
232
  keepObservations,
226
233
  onImageBudget: next => {
@@ -241,20 +248,12 @@ export async function runPersona(args) {
241
248
  return finish(outcome, ledger, runDirectory, transcript, heartbeat);
242
249
  }
243
250
 
244
- if (play.budgetExceeded) {
251
+ if (play.budgetExceeded && !play.reportBudgetAvailable) {
245
252
  outcome.state = 'budget_exceeded';
246
253
  outcome.quitReason = 'cost_cap';
247
- // Nothing more is asked of the model: there is no budget left to ask
248
- // with, and a refused call would just be another 402.
249
- // A report that cannot be written (a secret shaped note, a full disk)
250
- // must not turn an out of budget run into a failed one.
251
- try {
252
- await writePartialReport({ outcome, runDirectory, persona, driverName, target, notes, screenshots, play, skill, ledger, runId, jobId, startedAt, endedAt });
253
- } catch (writeError) {
254
- outcome.report = null;
255
- outcome.reportMarkdown = null;
256
- log(`the partial report could not be written: ${String(writeError && writeError.message)}`);
257
- }
254
+ // No positive remaining budget was established by the refusal.
255
+ // Preserve the notes without authorizing another model call.
256
+ await writeSomeReport({ outcome, runDirectory, persona, driverName, target, notes, screenshots, actionRecords, play, skill, ledger, runId, jobId, startedAt, endedAt, log });
258
257
  return finish(outcome, ledger, runDirectory, transcript, heartbeat);
259
258
  }
260
259
 
@@ -290,8 +289,8 @@ export async function runPersona(args) {
290
289
  outcome.report = written.report;
291
290
  outcome.reportMarkdown = written.markdown;
292
291
  outcome.reportLint = written.lint;
293
- outcome.state = 'succeeded';
294
- outcome.quitReason = written.report.quit_reason;
292
+ outcome.state = play.budgetExceeded ? 'budget_exceeded' : 'succeeded';
293
+ outcome.quitReason = play.budgetExceeded ? 'cost_cap' : written.report.quit_reason;
295
294
  outcome.quitDetail = written.report.quit_detail || '';
296
295
  return finish(outcome, ledger, runDirectory, transcript, heartbeat);
297
296
  } catch (error) {
@@ -301,20 +300,37 @@ export async function runPersona(args) {
301
300
  outcome.quitReason = 'cost_cap';
302
301
  // The money ran out in the report step itself: the notes are still
303
302
  // worth keeping, by the same rule as a 402 during play.
304
- try {
305
- const play = { actionsTaken: outcome.actionsTaken, quitReason: 'cost_cap', quitDetail: String(error.message || '') };
306
- await writePartialReport({ outcome, runDirectory, persona, driverName, target, notes, screenshots, play, skill, ledger, runId, jobId, startedAt, endedAt: new Date().toISOString() });
307
- } catch (writeError) {
308
- log(`the partial report could not be written: ${String(writeError && writeError.message)}`);
309
- }
303
+ const play = { actionsTaken: outcome.actionsTaken, quitReason: 'cost_cap', quitDetail: String(error.message || '') };
304
+ await writeSomeReport({ outcome, runDirectory, persona, driverName, target, notes, screenshots, actionRecords, play, skill, ledger, runId, jobId, startedAt, endedAt: new Date().toISOString(), log, preservedMarkdown: error.completedReportMarkdown });
310
305
  } else if (error instanceof ProxyError && error.isClosed) {
311
306
  outcome.state = 'canceled';
312
307
  outcome.quitReason = 'error';
313
308
  } else {
314
309
  outcome.state = 'failed';
315
310
  outcome.quitReason = 'error';
311
+ // A run that broke still reports what it saw and why it stopped. The
312
+ // state is still `failed`, so the unit is still refunded; the report
313
+ // costs no model call. An abort is the person stopping it, not a failure.
314
+ if (!args.signal?.aborted) {
315
+ const detail = safeErrorDetail(error, localBrief);
316
+ // The play loop's count only reaches `outcome` when it returns, and this
317
+ // may be the play loop dying.
318
+ const actionsTaken = Math.max(outcome.actionsTaken || 0, actionLogFrom(actionRecords).length);
319
+ const play = { actionsTaken, quitReason: 'error', quitDetail: detail };
320
+ await writeSomeReport({
321
+ outcome, runDirectory, persona, driverName, target, notes, screenshots, actionRecords, play, skill, ledger, runId, jobId, startedAt, endedAt: new Date().toISOString(), log,
322
+ quitReason: 'error',
323
+ // The notes stay in report.md and out of report.json. Nothing reviewed
324
+ // them, they may be symptoms of the crash, and what report.json holds
325
+ // becomes findings, analyst memory and graph nodes on the server.
326
+ findings: [],
327
+ reason: `The run stopped on an error before its report was written: ${detail}`,
328
+ bareReason: 'The run stopped on an error before its report was written.'
329
+ });
330
+ }
316
331
  }
317
- outcome.quitDetail = String(error && error.message ? error.message : error);
332
+ // Sent to the server as the run's reason, so it is as safe as the report.
333
+ outcome.quitDetail = safeErrorDetail(error, localBrief);
318
334
  transcript.write({ kind: 'error', message: outcome.quitDetail });
319
335
  await finish(outcome, ledger, runDirectory, transcript, heartbeat);
320
336
  throw Object.assign(error, { outcome });
@@ -327,6 +343,9 @@ export async function runPersona(args) {
327
343
  * @returns {Promise<Object>}
328
344
  */
329
345
  async function playLoop({
346
+ // The observation and action lines of the transcript, kept for the report
347
+ // step's "Actions you took" list and for a partial report.
348
+ actionRecords = [],
330
349
  api,
331
350
  controller = 'standard',
332
351
  signal: argsSignal,
@@ -375,9 +394,6 @@ async function playLoop({
375
394
  }
376
395
  })) : availableTools;
377
396
  const recentActions = [];
378
- // The observation and action lines of the transcript, kept for the report
379
- // step's "Actions you took" list.
380
- const actionRecords = [];
381
397
  let latestObservation = '';
382
398
  let visualObservation = false;
383
399
  const messages = [];
@@ -387,6 +403,7 @@ async function playLoop({
387
403
  let quitDetail = '';
388
404
  let budgetWarned = false;
389
405
  let budgetExceeded = false;
406
+ let reportBudgetAvailable = false;
390
407
  let canceled = false;
391
408
  let unchanged = 0;
392
409
  const maxTurns = maxActions * 3 + 20;
@@ -458,6 +475,7 @@ async function playLoop({
458
475
  } catch (error) {
459
476
  if (error instanceof ProxyError && error.isBudget) {
460
477
  budgetExceeded = true;
478
+ reportBudgetAvailable = hasPositiveRemainingBudget(error);
461
479
  quitReason = 'cost_cap';
462
480
  quitDetail = error.message;
463
481
  break;
@@ -666,6 +684,7 @@ async function playLoop({
666
684
  quitDetail,
667
685
  budgetWarned,
668
686
  budgetExceeded,
687
+ reportBudgetAvailable,
669
688
  canceled,
670
689
  imageBudget,
671
690
  finalScreenshot: final ? final.relativePath : null,
@@ -779,7 +798,7 @@ async function reportPhase({
779
798
  // never escalates to a dearer tier after a warning, and a 402 on a report
780
799
  // turn gets one retry with the output ceiling cut to what the refusal says
781
800
  // still fits.
782
- const escalate = shouldEscalate(persona) && !play.budgetWarned;
801
+ const escalate = shouldEscalate(persona) && !play.budgetWarned && !play.budgetExceeded;
783
802
  let maxTokens = REPORT_MAX_TOKENS;
784
803
  let shrunk = false;
785
804
  let lastCandidate = null;
@@ -821,6 +840,14 @@ async function reportPhase({
821
840
  log(`the model budget ran out on a follow-up report turn; keeping the report.json already written (${lint.length} problem(s)).`);
822
841
  break;
823
842
  }
843
+ // A completed markdown write can survive a cut-off JSON write. Keep
844
+ // that draft without promoting its prose into structured findings.
845
+ if (typeof markdown === 'string' && markdown.trim()) {
846
+ const safeMarkdown = scrubSecrets(markdown, secrets);
847
+ if (Buffer.byteLength(safeMarkdown, 'utf8') <= 256 * 1024 && scanForSecrets(safeMarkdown).length === 0) {
848
+ error.completedReportMarkdown = safeMarkdown;
849
+ }
850
+ }
824
851
  throw error;
825
852
  }
826
853
  ledger.record({ model: answer.model, usage: answer.usage, ms: Date.now() - askedAt });
@@ -875,7 +902,10 @@ async function reportPhase({
875
902
  if (enumFixed.changes.length > 0) {
876
903
  transcript.write({ kind: 'report_values_normalised', attempt, changes: enumFixed.changes.map(change => ({ id: change.id, field: change.field, from: change.from, to: change.to })) });
877
904
  }
878
- const capped = normalised ? capNetworkOnlyFindings(stampReport(enumFixed.report, facts), { notes, screenshots }) : null;
905
+ const reportCandidate = play.budgetExceeded
906
+ ? { ...enumFixed.report, partial: true, quit_reason: 'cost_cap', quit_detail: play.quitDetail }
907
+ : enumFixed.report;
908
+ const capped = normalised ? capNetworkOnlyFindings(stampReport(reportCandidate, facts), { notes, screenshots }) : null;
879
909
  const runnerDrop = capped ? dropRunnerRejectedFindings(capped, { notes, observations }) : { report: null, dropped: [] };
880
910
  for (const entry of runnerDrop.dropped) {
881
911
  transcript.write({ kind: 'finding_dropped', id: entry.finding.id, severity: entry.finding.severity, title: entry.finding.title, reason: entry.reason });
@@ -953,7 +983,7 @@ async function reportPhase({
953
983
  // after the report is settled, so a refusal can cost the check and never
954
984
  // the report.
955
985
  report = scrubSecrets(report, secrets);
956
- if (!shrunk) {
986
+ if (!shrunk && !play.budgetExceeded) {
957
987
  const checked = await checkClaimsAgainstScreenshots({ client, jobId, runId, report, screenshots, runDirectory, ledger, transcript, log });
958
988
  if (checked.downgraded.length > 0) {
959
989
  report = checked.report;
@@ -961,7 +991,7 @@ async function reportPhase({
961
991
  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(', ')}`);
962
992
  }
963
993
  } else {
964
- transcript.write({ kind: 'claim_check_skipped', reason: 'the report turn needed a smaller output ceiling to fit the budget' });
994
+ transcript.write({ kind: 'claim_check_skipped', reason: play.budgetExceeded ? 'gameplay stopped for budget; preserving the report budget' : 'the report turn needed a smaller output ceiling to fit the budget' });
965
995
  }
966
996
 
967
997
  if (!markdown) {
@@ -1122,6 +1152,13 @@ export async function checkClaimsAgainstScreenshots({ client, jobId, runId, repo
1122
1152
  /** The smallest output ceiling worth retrying a report turn with. */
1123
1153
  export const REPORT_MIN_TOKENS = 1536;
1124
1154
 
1155
+ /** A refused act is not proof that a smaller, cheaper report cannot fit. */
1156
+ function hasPositiveRemainingBudget(error) {
1157
+ const { usd_used: used, usd_max: maximum, projected_usd: projected } = error.figures || {};
1158
+ return [used, maximum, projected].every(value => typeof value === 'number' && Number.isFinite(value))
1159
+ && used >= 0 && maximum > used && projected > maximum - used;
1160
+ }
1161
+
1125
1162
  /** Whether a 402 carried the three figures a retry is sized from. */
1126
1163
  export function hasBudgetFigures(error) {
1127
1164
  const figures = (error && error.figures) || {};
@@ -1490,7 +1527,7 @@ async function finish(outcome, ledger, runDirectory, transcript, heartbeat) {
1490
1527
  * screenshot list and says plainly that it is partial, rather than
1491
1528
  * inventing a persona verdict nobody gave.
1492
1529
  */
1493
- export function renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason }) {
1530
+ export function renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, actions = [], play, reason }) {
1494
1531
  const good = notes.filter(note => note.kind === 'good');
1495
1532
  const bad = notes.filter(note => note.kind === 'bug' || note.kind === 'confusing');
1496
1533
  const network = notes.filter(note => note.automatic && note.url);
@@ -1509,6 +1546,7 @@ export function renderFallbackMarkdown({ persona, driverName, target, notes, scr
1509
1546
  '## Why this report is partial',
1510
1547
  reason,
1511
1548
  '',
1549
+ ...(actions.length > 0 ? ['## What the persona did', actionLogLines(actions), ''] : []),
1512
1550
  '## What worked',
1513
1551
  list(good),
1514
1552
  '',
@@ -1582,7 +1620,7 @@ export function notesToFindings(notes, screenshots) {
1582
1620
  description: text,
1583
1621
  repro_steps: [`Seen after ${Number(note.at_action) || 0} action(s) in this run.`],
1584
1622
  evidence: shot
1585
- ? [{ type: 'screenshot', ref: shot.relativePath }]
1623
+ ? [{ type: 'screenshot', ref: String(shot.relativePath).replace(/\\/g, '/') }]
1586
1624
  : [{ type: 'transcript_step', ref: `action ${Number(note.at_action) || 0}` }]
1587
1625
  });
1588
1626
  }
@@ -1596,9 +1634,9 @@ export function notesToFindings(notes, screenshots) {
1596
1634
  * and no persona verdict was recorded; `partial` is what tells a reader not
1597
1635
  * to read them as one.
1598
1636
  */
1599
- async function writePartialReport({ outcome, runDirectory, persona, driverName, target, notes, screenshots, play, skill, ledger, runId, jobId, startedAt, endedAt }) {
1637
+ async function writePartialReport({ outcome, runDirectory, persona, driverName, target, notes, screenshots, actionRecords = [], play, skill, ledger, runId, jobId, startedAt, endedAt, quitReason = 'cost_cap', reason = BUDGET_PARTIAL_REASON, findings = null, preservedMarkdown = null }) {
1600
1638
  const report = stampReport({
1601
- findings: notesToFindings(notes, screenshots),
1639
+ findings: findings || notesToFindings(notes, screenshots),
1602
1640
  sentiment: { fun: 3, clarity: 3, frustration: 3 },
1603
1641
  would_continue: false,
1604
1642
  partial: true
@@ -1613,24 +1651,70 @@ async function writePartialReport({ outcome, runDirectory, persona, driverName,
1613
1651
  actionsTaken: play.actionsTaken,
1614
1652
  usage: ledger.toReportUsage(),
1615
1653
  promptVersion: promptVersionOf(skill) || 'unknown',
1616
- quitReason: 'cost_cap',
1654
+ quitReason,
1617
1655
  quitDetail: play.quitDetail
1618
1656
  });
1619
- const markdown = renderFallbackMarkdown({
1657
+ const fallbackMarkdown = renderFallbackMarkdown({
1620
1658
  persona,
1621
1659
  driverName,
1622
1660
  target,
1623
1661
  notes,
1624
1662
  screenshots,
1663
+ actions: actionLogFrom(actionRecords),
1625
1664
  play,
1626
- 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.'
1665
+ reason
1627
1666
  });
1667
+ const markdown = preservedMarkdown
1668
+ ? ['# Playtest (partial report draft)', '',
1669
+ 'The run reached its model budget before this draft could be completed and checked. It is preserved below for review.',
1670
+ '', preservedMarkdown, '', '---', '', fallbackMarkdown].join('\n')
1671
+ : fallbackMarkdown;
1628
1672
  const stamped = stampGeneratedAt(markdown, new Date().toISOString(), reportFacts({ persona, driverName, target, play }));
1629
1673
  await writeReportFiles({ runDirectory, markdown: stamped, report });
1630
1674
  outcome.report = report;
1631
1675
  outcome.reportMarkdown = stamped;
1632
1676
  }
1633
1677
 
1678
+ /**
1679
+ * An error message fit to leave this machine: the brief's own secrets replaced,
1680
+ * and the whole message withheld if anything else in it is shaped like a secret.
1681
+ */
1682
+ function safeErrorDetail(error, localBrief) {
1683
+ const text = scrubSecrets(String(error && error.message ? error.message : error), secretsOf(localBrief));
1684
+ if (scanForSecrets(text).length > 0) return 'The error message was withheld because it contained something shaped like a secret.';
1685
+ return text.slice(0, 400);
1686
+ }
1687
+
1688
+ /**
1689
+ * A run always ends with a report. If the notes cannot be written (a secret
1690
+ * shaped note, a secret in an error message), the report goes out without them
1691
+ * and says so: the reason and the action count are worth more than nothing.
1692
+ * Only a failure of the disk itself leaves no report.
1693
+ */
1694
+ async function writeSomeReport({ log, bareReason = BUDGET_PARTIAL_REASON, ...report }) {
1695
+ try {
1696
+ await writePartialReport(report);
1697
+ return;
1698
+ } catch (error) {
1699
+ log(`the partial report could not be written with its notes: ${String(error && error.message)}`);
1700
+ }
1701
+ try {
1702
+ await writePartialReport({
1703
+ ...report,
1704
+ preservedMarkdown: null,
1705
+ notes: [],
1706
+ screenshots: [],
1707
+ actionRecords: [],
1708
+ play: { ...report.play, quitDetail: '' },
1709
+ reason: `${bareReason} The notes were left out of this report because they could not be written safely.`
1710
+ });
1711
+ } catch (error) {
1712
+ report.outcome.report = null;
1713
+ report.outcome.reportMarkdown = null;
1714
+ log(`the partial report could not be written: ${String(error && error.message)}`);
1715
+ }
1716
+ }
1717
+
1634
1718
  async function writePartialMarkdown({ runDirectory, persona, driverName, target, notes, screenshots, play, reason }) {
1635
1719
  const markdown = stampGeneratedAt(renderFallbackMarkdown({ persona, driverName, target, notes, screenshots, play, reason }), new Date().toISOString(), reportFacts({ persona, driverName, target, play }));
1636
1720
  assertNoSecrets('report.md', markdown);