ravensight-playtest 0.5.2 → 0.7.0

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.
Files changed (99) hide show
  1. package/README.md +319 -14
  2. package/addons/ravensight_driver/driver.gd +406 -8
  3. package/addons/ravensight_simulate/simulate.gd +360 -0
  4. package/docs/godot-driver.md +70 -8
  5. package/package.json +4 -3
  6. package/src/api/README.md +4 -1
  7. package/src/api/client.js +120 -3
  8. package/src/api/errors.js +17 -1
  9. package/src/api/http.js +43 -17
  10. package/src/assets/formats.js +267 -0
  11. package/src/assets/index.js +260 -0
  12. package/src/assets/integration.js +429 -0
  13. package/src/assets/inventory.js +337 -0
  14. package/src/assets/measure.js +392 -0
  15. package/src/cli.js +233 -0
  16. package/src/commands/assets.js +62 -0
  17. package/src/commands/check.js +4 -1
  18. package/src/commands/create.js +734 -0
  19. package/src/commands/fakeCore.js +16 -1
  20. package/src/commands/feature.js +845 -0
  21. package/src/commands/fix.js +647 -70
  22. package/src/commands/init.js +19 -3
  23. package/src/commands/lists.js +206 -0
  24. package/src/commands/profile.js +25 -6
  25. package/src/commands/punch.js +116 -0
  26. package/src/commands/replay.js +204 -0
  27. package/src/commands/resume.js +7 -0
  28. package/src/commands/run.js +200 -24
  29. package/src/commands/tape.js +74 -0
  30. package/src/commands/template.js +70 -0
  31. package/src/commands/validate.js +414 -0
  32. package/src/commands/validateHosted.js +292 -0
  33. package/src/create/scaffold.js +192 -0
  34. package/src/run/acceptance.js +212 -0
  35. package/src/run/args.js +18 -1
  36. package/src/run/checks.js +705 -0
  37. package/src/run/dimension.js +95 -0
  38. package/src/run/drivers/godot-observation.js +59 -11
  39. package/src/run/drivers/godot-project.js +201 -31
  40. package/src/run/drivers/godot.js +532 -45
  41. package/src/run/drivers/web.js +18 -0
  42. package/src/run/evidence.js +170 -0
  43. package/src/run/featureLoop.js +288 -0
  44. package/src/run/fixLoop.js +82 -12
  45. package/src/run/git.js +110 -20
  46. package/src/run/json.js +65 -9
  47. package/src/run/labsOutcome.js +136 -0
  48. package/src/run/liveWork.js +103 -0
  49. package/src/run/model.js +105 -5
  50. package/src/run/openPr.js +107 -0
  51. package/src/run/paths.js +4 -1
  52. package/src/run/pendingRegister.js +81 -0
  53. package/src/run/personaLoop.js +111 -4
  54. package/src/run/profile.js +68 -7
  55. package/src/run/replay.js +236 -0
  56. package/src/run/repoTools.js +227 -27
  57. package/src/run/report.js +7 -1
  58. package/src/run/resume.js +19 -1
  59. package/src/run/secretScan.js +35 -0
  60. package/src/run/settle.js +41 -0
  61. package/src/run/tools.js +33 -4
  62. package/src/run/trace.js +124 -0
  63. package/src/simulation/godotPack.js +112 -0
  64. package/src/ui/index.js +61 -9
  65. package/src/upload/allowlist.js +6 -2
  66. package/src/upload/index.js +5 -4
  67. package/templates/godot-2d/README.md +36 -0
  68. package/templates/godot-2d/addons/ravensight/LICENSE +21 -0
  69. package/templates/godot-2d/addons/ravensight/Ravensight.gd +1823 -0
  70. package/templates/godot-2d/export_presets.cfg +27 -0
  71. package/templates/godot-2d/gitignore +10 -0
  72. package/templates/godot-2d/project.godot +58 -0
  73. package/templates/godot-2d/scenes/main.tscn +67 -0
  74. package/templates/godot-2d/scripts/autoload/game_config.gd +83 -0
  75. package/templates/godot-2d/scripts/autoload/game_state.gd +44 -0
  76. package/templates/godot-2d/scripts/autoload/telemetry.gd +37 -0
  77. package/templates/godot-2d/scripts/main.gd +32 -0
  78. package/templates/godot-2d/scripts/player.gd +10 -0
  79. package/templates/godot-2d/scripts/state_hook.gd +16 -0
  80. package/templates/godot-2d/template.json +14 -0
  81. package/templates/godot-2d/tools/inject_release_key.sh +19 -0
  82. package/templates/godot-2d/tools/selftest.gd +70 -0
  83. package/templates/godot-3d/README.md +49 -0
  84. package/templates/godot-3d/addons/ravensight/LICENSE +21 -0
  85. package/templates/godot-3d/addons/ravensight/Ravensight.gd +1823 -0
  86. package/templates/godot-3d/export_presets.cfg +27 -0
  87. package/templates/godot-3d/gitignore +10 -0
  88. package/templates/godot-3d/project.godot +56 -0
  89. package/templates/godot-3d/scenes/main.tscn +154 -0
  90. package/templates/godot-3d/scripts/autoload/game_config.gd +83 -0
  91. package/templates/godot-3d/scripts/autoload/game_state.gd +44 -0
  92. package/templates/godot-3d/scripts/autoload/telemetry.gd +37 -0
  93. package/templates/godot-3d/scripts/beacon.gd +20 -0
  94. package/templates/godot-3d/scripts/main.gd +49 -0
  95. package/templates/godot-3d/scripts/player.gd +19 -0
  96. package/templates/godot-3d/scripts/state_hook.gd +25 -0
  97. package/templates/godot-3d/template.json +16 -0
  98. package/templates/godot-3d/tools/inject_release_key.sh +19 -0
  99. package/templates/godot-3d/tools/selftest.gd +77 -0
@@ -1,4 +1,6 @@
1
+ import { execFile } from 'node:child_process';
1
2
  import { randomUUID } from 'node:crypto';
3
+ import { promisify } from 'node:util';
2
4
  import { appendFile, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
3
5
  import path from 'node:path';
4
6
  import { optionsFrom, unknownOptionNotes } from '../run/args.js';
@@ -6,17 +8,31 @@ import { getDeps } from '../run/deps.js';
6
8
  import { createDriver } from '../run/drivers/index.js';
7
9
  import EXIT from '../run/exit.js';
8
10
  import { applyFix, fixSystem, MAX_PLAN_IMAGES, planContent, planFix, resolveFixMaxActions, summariseFix } from '../run/fixLoop.js';
9
- import { createGit, fixBranchName, preflight, startBranch } from '../run/git.js';
11
+ import { abandonBranch, createGit, fixBranchName, preflight, startBranch } from '../run/git.js';
10
12
  import { createHeartbeat } from '../run/heartbeat.js';
11
13
  import { createModelClient } from '../run/model.js';
12
14
  import { jobDir, runDir as runDirFor, RUN_FILES, screenshotsDir } from '../run/paths.js';
13
15
  import { runPersona } from '../run/personaLoop.js';
14
- import { createRepoTools, PROFILE_TOOLS } from '../run/repoTools.js';
15
- import { assertNoSecrets } from '../run/secretScan.js';
16
+ import { replayTrace } from '../run/replay.js';
17
+ import { loadTrace, replayReport, replaySentence } from './replay.js';
18
+ import { createRepoTools, PROFILE_TOOLS, WRITE_TOOLS } from '../run/repoTools.js';
19
+ import { recordPrOpened } from '../run/labsOutcome.js';
20
+ import { settleFailureLine } from '../run/settle.js';
21
+ import { acceptanceLines, buildAcceptance, fixV2Live, frozenAcceptance, verifyBriefFromAcceptance } from '../run/acceptance.js';
22
+ import { checkResultText, createCheckRunner, detectTestRunner, withRunCheck } from '../run/checks.js';
23
+ import { isGodotProject } from '../run/drivers/godot-project.js';
24
+ import { checksStillFailing, evidenceReport, withEvidenceIntact } from '../run/evidence.js';
25
+ import { openPullRequest, shellQuote } from '../run/openPr.js';
26
+ import { assertNoSecrets, withoutQuotedSecrets } from '../run/secretScan.js';
27
+ import * as pendingRegister from '../run/pendingRegister.js';
16
28
  import * as stateFile from '../run/state.js';
17
29
  import { createUsageLedger, writeUsageFile } from '../run/usage.js';
18
- import { createInterruptHandler, deliver, driverOptions, exitCodeFor, explainRegisterRefusal, formatCents, resolveSetup, resolveTarget, skillFor, usableLocalBrief } from './run.js';
30
+ import { createInterruptHandler, deliver, driverOptions, exitCodeFor, explainRegisterRefusal, formatCents, godotRefusal, resolveSetup, resolveTarget, skillFor, usableLocalBrief, startBalanceLine, startBalanceShort } from './run.js';
19
31
  import { loadLocalBrief } from '../briefLocal.js';
32
+ import { findListItem } from './lists.js';
33
+ import { jobLink } from '../dashboard.js';
34
+
35
+ const execFileAsync = promisify(execFile);
20
36
 
21
37
  /**
22
38
  * `ravensight-playtest fix <target>`: take one finding, change the code on a
@@ -25,10 +41,12 @@ import { loadLocalBrief } from '../briefLocal.js';
25
41
  *
26
42
  * Three rules shape everything else in this file:
27
43
  *
28
- * 1. **It edits, it never publishes.** All git usage lives in
44
+ * 1. **It edits, it never publishes on its own.** Git usage lives in
29
45
  * `src/run/git.js`, which has no commit, no push and no way to reach a
30
46
  * forge. The command ends by printing the pull request command; running it
31
- * is the developer's decision, made with the diff in front of them.
47
+ * is the developer's decision, made with the diff in front of them. The
48
+ * one exception is Fix v2's `--open-pr` (`src/run/openPr.js`), which still
49
+ * asks at its own prompt after a verified fix and never forces a push.
32
50
  * 2. **It buys the answer "no" as cheaply as possible.** The triage call comes
33
51
  * before the first edit, because a finding that is design intent should
34
52
  * cost a refunded unit rather than a branch full of changes nobody wanted.
@@ -65,6 +83,18 @@ export const REPRODUCED_ABOVE = 0.65;
65
83
  /** A changeset carries at most this many files. */
66
84
  export const MAX_CHANGESET_FILES = 20;
67
85
 
86
+ /**
87
+ * Fix v2: at most this many repair turn sets after the first apply, each
88
+ * started by a failed check or a verify that still reproduces the finding.
89
+ */
90
+ export const MAX_REPAIRS = 2;
91
+
92
+ /** A repair turn set's own action ceiling, inside the same run budget. */
93
+ export const REPAIR_MAX_ACTIONS = 20;
94
+
95
+ /** The checks run after every apply turn set, in this order. */
96
+ export const GATE_CHECKS = Object.freeze(['godot_import', 'godot_script_check', 'project_tests']);
97
+
68
98
  /**
69
99
  * @param {string} target `<jobId>/<label>` or a finding id
70
100
  * @param {Object} flags what commander hands over
@@ -96,6 +126,9 @@ export async function fixCommand(target, flags = {}, injected = {}) {
96
126
  export function parseTarget(target) {
97
127
  const value = String(target || '').trim();
98
128
  if (!value) return null;
129
+ // A finding's id is `<runId>/<label>`, so a run id on the left means the whole
130
+ // value names the finding, not a label inside a job.
131
+ if (value.startsWith('pt_run_')) return { jobId: null, label: null, findingId: value };
99
132
  const slash = value.lastIndexOf('/');
100
133
  if (slash > 0 && slash < value.length - 1) {
101
134
  return { jobId: value.slice(0, slash), label: value.slice(slash + 1), findingId: null };
@@ -129,6 +162,24 @@ export function hostedTargetRefusal(driverName, target) {
129
162
  return `${target} is not served from this machine, so a verify run there cannot see a change that only exists in this working tree. Serve the build locally and pass --build-url, or pass --no-verify to fix without verifying.`;
130
163
  }
131
164
 
165
+ /**
166
+ * What became of a fix's unit when its verify could not boot the game, from the
167
+ * server's own answers: the unit comes back up to a limit per account.
168
+ *
169
+ * @param {{finished: Object|null, run: Object|null, priceCents: number}} input
170
+ * @returns {string}
171
+ */
172
+ export function bootRefundLine({ finished, run, priceCents }) {
173
+ const start = 'The game did not boot for the verify run';
174
+ if (run && run.changeset && run.changeset.boot_refund_limited) {
175
+ return `${start}, and this account has used its refunds for that in the last 30 days, so the ${formatCents(priceCents)} is kept.`;
176
+ }
177
+ const cents = finished ? Number(finished.refunded_cents) || 0 : 0;
178
+ if (cents > 0) return `${start}, so the ${formatCents(cents)} comes back.`;
179
+ if (!finished) return `${start}. The job page says what came back once it settles.`;
180
+ return `${start}, and nothing came back of the ${formatCents(priceCents)} charged.`;
181
+ }
182
+
132
183
  /**
133
184
  * The verdict in the words a developer reads.
134
185
  *
@@ -137,6 +188,10 @@ export function hostedTargetRefusal(driverName, target) {
137
188
  */
138
189
  export function verifyWords(verify) {
139
190
  if (!verify) return 'not verified';
191
+ // A verify run whose game never booted played a black screen: whatever the
192
+ // replay's report says, it says nothing about the fix.
193
+ if (verify.failed === 'game_boot_failed') return `not verified, the game did not boot (${verify.detail || 'see godot.log in the verify run directory'})`;
194
+ if (verify.failed) return `not verified, the verify run failed (${verify.detail || verify.failed})`;
140
195
  const score = verify.reproduced;
141
196
  if (typeof score !== 'number' || !Number.isFinite(score)) return 'unknown, the verify run reached no verdict';
142
197
  if (score < FIXED_BELOW) return `fixed (reproduced ${score})`;
@@ -159,9 +214,9 @@ async function fix(target, flags, injected, deps, log) {
159
214
  for (const line of unknownOptionNotes(unknown)) log(line);
160
215
  return EXIT.ENVIRONMENT;
161
216
  }
162
- const parsed = parseTarget(target);
217
+ let parsed = parseTarget(target);
163
218
  if (!parsed) {
164
- log('Name the finding to fix: either <jobId>/<label>, like pt_job_1/F002, or a finding id.');
219
+ log('Name the finding to fix: either <jobId>/<label>, like pt_job_1/F002, a finding id, or a punch list item, li_...');
165
220
  return EXIT.ENVIRONMENT;
166
221
  }
167
222
 
@@ -169,9 +224,23 @@ async function fix(target, flags, injected, deps, log) {
169
224
  if (setup.code !== undefined) return setup.code;
170
225
  const { context, api, gameId, apiUrl, repoRoot, cliVersion } = setup;
171
226
 
227
+ // A punch list item that came from a finding is fixed as that finding. The
228
+ // item only lends its finding's id, and later records the job; it is never closed here.
229
+ let listItem = null;
230
+ if (isListItemTarget(target)) {
231
+ const resolved = await resolveListItemTarget(api, gameId, String(target).trim());
232
+ if (resolved.refusal) {
233
+ log(resolved.refusal);
234
+ return EXIT.ENVIRONMENT;
235
+ }
236
+ listItem = resolved;
237
+ parsed = parseTarget(resolved.item.source.ref);
238
+ log(`${resolved.item.item_id} came from ${resolved.item.source.kind === 'fresh_eyes' ? 'fresh-eyes finding' : 'finding'} ${resolved.item.source.ref}, so this fixes that finding.`);
239
+ }
240
+
172
241
  const wantsVerify = options.verify !== false;
173
242
  const driverName = options.driver || (context.config && context.config.driver) || 'playwright_web';
174
- const buildTarget = resolveTarget(options, context.config, driverName);
243
+ const buildTarget = resolveTarget(options, context.config, driverName, repoRoot);
175
244
 
176
245
  // Said before anything is charged, because it is the difference between a
177
246
  // verify that means something and one that does not.
@@ -185,6 +254,11 @@ async function fix(target, flags, injected, deps, log) {
185
254
  log(refusal);
186
255
  return EXIT.ENVIRONMENT;
187
256
  }
257
+ const noGodot = await godotRefusal({ driverName, godotPath: options.godotPath, projectDir: buildTarget, injected });
258
+ if (noGodot) {
259
+ log(noGodot);
260
+ return EXIT.ENVIRONMENT;
261
+ }
188
262
  if (driverName === 'playwright_web') {
189
263
  log(`The verify run plays ${buildTarget}, which has to serve this working tree. Rebuild and serve the branch before the verify, or its answer is about the old build.`);
190
264
  }
@@ -218,16 +292,29 @@ async function fix(target, flags, injected, deps, log) {
218
292
  // repro steps and the persona's words are written in full.
219
293
  const finding = enrichFinding(listed, evidence.reportJson);
220
294
 
221
- const branch = options.branch || fixBranchName(finding.label || finding.finding_id);
295
+ const pendingScope = { kind: 'fix', gameId, target: finding.finding_id };
296
+ const pendingRequest = options.idempotencyKey ? null : await pendingRegister.load(repoRoot, pendingScope);
297
+ const branch = pendingRequest ? pendingRequest.branch : options.branch || fixBranchName(finding.label || finding.finding_id);
222
298
  const git = injected.git || createGit({ root: repoRoot });
223
299
  // Every refusal now, the one mutation after the price is agreed: a developer
224
300
  // who says no to the charge should not be left on a new branch.
225
- const checked = await preflight({ git, branch, allowDirty: Boolean(options.allowDirty), create: false });
301
+ const checked = await preflight({ git, branch, allowDirty: Boolean(options.allowDirty), create: false, reuseBranch: Boolean(pendingRequest) });
302
+ if (pendingRequest) {
303
+ log(`The last attempt at this fix got no clear answer, so this run asks about that same request on ${branch}. If it was charged, this answers with that job.`);
304
+ checked.from = pendingRequest.from;
305
+ }
226
306
  if (!checked.ok) {
227
307
  log(checked.reason);
228
308
  return EXIT.ENVIRONMENT;
229
309
  }
230
310
 
311
+ // --open-pr commits what the fix changed, and a dirty tree makes that a mixture
312
+ // of two people's work. Refused now, because nothing later can tell them apart.
313
+ if (options.openPr && checked.dirty) {
314
+ log('--open-pr publishes a commit, and this working tree has uncommitted changes that would be mixed into it. Commit or stash them, or leave out --open-pr. Nothing was charged.');
315
+ return EXIT.ENVIRONMENT;
316
+ }
317
+
231
318
  // A fix job asks for exactly one persona, the one who filed the finding: the
232
319
  // server creates the verify run from it, and both zero and two are a 400.
233
320
  const personaSlug = finding.persona || ((pack.personas || [])[0] || {}).slug || null;
@@ -235,7 +322,33 @@ async function fix(target, flags, injected, deps, log) {
235
322
  log('That finding names no persona and the content pack has none, so there is nobody to verify the fix with.');
236
323
  return EXIT.ENVIRONMENT;
237
324
  }
238
- const estimate = await api.jobs.estimate(gameId, { modules: [...FIX_MODULES], personas: [personaSlug] });
325
+ // Fix v2 (Labs beta) only when the server says it is live for this game.
326
+ // Anything else, including a server that has never heard of Labs, is the standard fix.
327
+ const labs = !options.noLabs && (await fixV2Live(api, gameId, {
328
+ onUnfunded: () => log('Fix v2 (Labs beta) needs an account that has added at least $10.00 in top-ups, so this is an ordinary fix.')
329
+ }));
330
+ const acceptance = labs ? buildAcceptance(finding, { persona: personaSlug, reproSteps: reproStepsOf(finding) }) : null;
331
+ if (acceptance) {
332
+ log('Fix v2 (Labs beta) is on for this game: named checks, up to two repairs, and an evidence report.');
333
+ for (const line of acceptanceLines(acceptance)) log(line);
334
+ }
335
+ // Tests are the developer's code run with the model's edits in place, so
336
+ // they run only with --allow-tests or a yes at a terminal, asked now,
337
+ // before anything is charged.
338
+ let allowTests = Boolean(options.allowTests);
339
+ if (labs && !allowTests) {
340
+ const projectDir = await checkProjectDir({ driverName, buildTarget, repoRoot });
341
+ const runner = projectDir ? await detectTestRunner(projectDir) : null;
342
+ const askTests = injected.confirmTests || (process.stdin.isTTY && deps.ui && deps.ui.confirm);
343
+ if (runner && askTests) {
344
+ allowTests = Boolean(await askTests(`This project ships ${runner === 'gut' ? 'GUT' : 'gdUnit4'} tests. Let the fix run them, with its edits in place, on this machine?`, { yes: false }));
345
+ }
346
+ }
347
+ if (options.openPr && !labs) {
348
+ log('--open-pr is part of Fix v2 (Labs beta), which is not on for this game, so it is ignored and the pull request command is printed instead.');
349
+ }
350
+
351
+ const estimate = await api.jobs.estimate(gameId, { modules: [...FIX_MODULES], personas: [personaSlug], ...(acceptance ? { acceptance: true } : {}) });
239
352
  const priceCents = Number.isFinite(Number(estimate.estimate_cents)) ? Number(estimate.estimate_cents) : Number(estimate.price_cents);
240
353
  if (!Number.isFinite(priceCents)) {
241
354
  // Sending `confirm_price_cents: undefined` would be refused as
@@ -244,8 +357,15 @@ async function fix(target, flags, injected, deps, log) {
244
357
  log('The server quoted no price for this fix, so nothing was charged. Run "ravensight-playtest check" and try again.');
245
358
  return EXIT.ENVIRONMENT;
246
359
  }
247
- const offer = `${formatCents(priceCents)} fix attempt (includes one verify run). Balance: ${formatCents(estimate.balance_cents)}.`;
360
+ const offer = `${formatCents(priceCents)} fix attempt (includes one verify run). Balance: ${formatCents(estimate.balance_cents)}.`
361
+ + (labs ? ' The checks and the verify run execute the edited project on this machine with a reduced environment.' : '');
248
362
  log(offer);
363
+ const floorLine = startBalanceLine(estimate);
364
+ if (floorLine) log(floorLine);
365
+ if (estimate.ok === false && floorLine) {
366
+ log(`${startBalanceShort(estimate)} An ordinary fix, with --no-labs, starts on its price alone.`);
367
+ return EXIT.ENVIRONMENT;
368
+ }
249
369
  if (estimate.ok === false) {
250
370
  log(`That is more than the balance covers. Top up at ${estimate.topup_url || 'ravensight.io'} and run this again.`);
251
371
  return EXIT.ENVIRONMENT;
@@ -260,42 +380,86 @@ async function fix(target, flags, injected, deps, log) {
260
380
  }
261
381
  }
262
382
 
263
- const started = await startBranch({ git, branch });
383
+ const started = pendingRequest ? await pendingRegister.returnToBranch({ git, branch }) : await startBranch({ git, branch });
264
384
  if (!started.ok) {
265
385
  log(started.reason);
266
386
  return EXIT.ENVIRONMENT;
267
387
  }
268
388
  log(`branch ${branch}, from ${checked.head.slice(0, 8)}${checked.from ? ` on ${checked.from}` : ''}.`);
269
389
 
270
- const idempotencyKey = options.idempotencyKey || randomUUID();
390
+ // Every way out before a job exists has to leave the developer where they were,
391
+ // unless nobody knows whether a job exists. Then the key and the branch are the way back.
392
+ const noJob = async () => {
393
+ await pendingRegister.clear(repoRoot, pendingScope);
394
+ await abandonBranch({ git, branch, from: checked.from, head: checked.head });
395
+ };
396
+ const remember = key => pendingRegister.save(repoRoot, pendingScope, { idempotencyKey: key, branch, from: checked.from, head: checked.head });
397
+ let idempotencyKey = options.idempotencyKey || (pendingRequest && pendingRequest.idempotencyKey) || randomUUID();
398
+ const registerBody = {
399
+ modules: [...FIX_MODULES],
400
+ personas: [personaSlug],
401
+ finding_id: finding.finding_id,
402
+ driver: driverName,
403
+ cli_version: cliVersion,
404
+ confirm_price_cents: priceCents,
405
+ brief_version: brief ? brief.version ?? null : null,
406
+ build: buildTarget
407
+ ? { kind: driverName === 'playwright_web' ? 'url' : 'godot_project', ref: buildTarget }
408
+ : { kind: 'repo', ref: repoRoot },
409
+ repo: { commit: checked.head, dirty: Boolean(checked.dirty), branch },
410
+ max_actions: Number.isInteger(options.maxActions) ? options.maxActions : null,
411
+ pack_versions: { pack: pack.pack_version },
412
+ ...(acceptance ? { acceptance } : {})
413
+ };
414
+ // A failed register, whichever attempt: `undefined` means it was an exception to rethrow.
415
+ const registerFailed = async error => {
416
+ if (pendingRegister.outcomeUnknown(error)) {
417
+ log(pendingRegister.unknownOutcomeMessage('this fix', branch));
418
+ return EXIT.ENVIRONMENT;
419
+ }
420
+ await noJob();
421
+ const handled = explainRegisterRefusal(error, log);
422
+ return handled !== null ? handled : undefined;
423
+ };
271
424
  let registered;
272
425
  try {
273
- registered = await api.jobs.register(gameId, {
274
- modules: [...FIX_MODULES],
275
- personas: [personaSlug],
276
- finding_id: finding.finding_id,
277
- driver: driverName,
278
- cli_version: cliVersion,
279
- confirm_price_cents: priceCents,
280
- brief_version: brief ? brief.version ?? null : null,
281
- build: buildTarget
282
- ? { kind: driverName === 'playwright_web' ? 'url' : 'godot_project', ref: buildTarget }
283
- : { kind: 'repo', ref: repoRoot },
284
- repo: { commit: checked.head, dirty: Boolean(checked.dirty), branch },
285
- max_actions: Number.isInteger(options.maxActions) ? options.maxActions : null,
286
- pack_versions: { pack: pack.pack_version }
287
- }, { idempotencyKey });
426
+ await remember(idempotencyKey);
427
+ registered = await api.jobs.register(gameId, registerBody, { idempotencyKey });
288
428
  } catch (error) {
289
- const handled = explainRegisterRefusal(error, log);
290
- if (handled !== null) return handled;
291
- throw error;
429
+ if (acceptance && error && ['labs_not_enabled', 'funding_required'].includes(error.code)) {
430
+ // Labs went off, or the funding floor stopped being met, between the status read
431
+ // and the register. Nothing was charged by a refusal, so register the ordinary
432
+ // fix instead, on a fresh key because the body differs.
433
+ log(`The server refused Fix v2 for this game (${error.code}), so this is an ordinary fix.`);
434
+ delete registerBody.acceptance;
435
+ idempotencyKey = randomUUID();
436
+ try {
437
+ await remember(idempotencyKey);
438
+ registered = await api.jobs.register(gameId, registerBody, { idempotencyKey });
439
+ } catch (retryError) {
440
+ const exit = await registerFailed(retryError);
441
+ if (exit !== undefined) return exit;
442
+ throw retryError;
443
+ }
444
+ } else {
445
+ const exit = await registerFailed(error);
446
+ if (exit !== undefined) return exit;
447
+ throw error;
448
+ }
292
449
  }
450
+ await pendingRegister.clear(repoRoot, pendingScope);
293
451
 
294
452
  const jobId = registered.job_id;
295
453
  // Runs are told apart by module and nothing else: the fix run carries
296
454
  // `persona: null`, and only the verify run carries the slug.
297
455
  const fixRun = (registered.runs || []).find(entry => entry.module === 'fix');
298
- const verifyRun = (registered.runs || []).find(entry => entry.module === 'persona_playtest') || null;
456
+ const verifyRuns = (registered.runs || []).filter(entry => entry.module === 'persona_playtest');
457
+ const verifyRun = verifyRuns[0] || null;
458
+ // The contract the server froze. None means the server kept none, and the
459
+ // fix runs as the standard fix does: no checks, no repairs, no evidence report.
460
+ const frozen = acceptance ? frozenAcceptance(registered) : null;
461
+ if (acceptance && !frozen) log('The server did not keep the acceptance contract, so this fix runs without checks or repairs.');
462
+ const v2 = Boolean(frozen);
299
463
 
300
464
  await mkdir(jobDir(repoRoot, jobId), { recursive: true });
301
465
  const journal = stateFile.newState({
@@ -317,7 +481,8 @@ async function fix(target, flags, injected, deps, log) {
317
481
  findingId: finding.finding_id,
318
482
  verify: wantsVerify,
319
483
  allowDirty: Boolean(options.allowDirty),
320
- maxActions: options.maxActions ?? null
484
+ maxActions: options.maxActions ?? null,
485
+ ...(v2 ? { labs: 'fix_v2' } : {})
321
486
  }
322
487
  });
323
488
  for (const entry of registered.runs || []) {
@@ -328,10 +493,11 @@ async function fix(target, flags, injected, deps, log) {
328
493
  // the runs, because the charge has already happened: a developer who cannot
329
494
  // name the job they paid for cannot ask anybody about it either.
330
495
  log(`job ${jobId}: ${formatCents(registered.price && registered.price.price_cents)} charged. ${jobDir(repoRoot, jobId)}`);
496
+ if (listItem) await recordFixJob({ api, gameId, listItem, jobId, link: (registered.links && registered.links.dashboard) || jobLink(gameId, jobId, apiUrl), log });
331
497
 
332
498
  if (!fixRun) {
333
499
  log('The server registered the job without a fix run, so there is nothing to spend a model turn from.');
334
- await skipRun({ api, gameId, jobId, run: verifyRun, reason: 'the job has no fix run', log });
500
+ for (const entry of v2 ? verifyRuns : [verifyRun]) await skipRun({ api, gameId, jobId, run: entry, reason: 'the job has no fix run', log });
335
501
  // Settled rather than abandoned: the job is charged and unplayable, so the
336
502
  // refund is owed now and not in half an hour when the sweep notices.
337
503
  await settle({ api, gameId, jobId, repoRoot, journal, reason: 'the job has no fix run', log });
@@ -354,6 +520,7 @@ async function fix(target, flags, injected, deps, log) {
354
520
  onError: () => {}
355
521
  });
356
522
  const inFlight = new Map();
523
+ const abort = new AbortController();
357
524
  const interrupt = createInterruptHandler({
358
525
  api,
359
526
  gameId,
@@ -366,7 +533,9 @@ async function fix(target, flags, injected, deps, log) {
366
533
  // this job's verify persona against a half-applied edit would answer a
367
534
  // question nobody asked. A fix is abandoned and refunded instead.
368
535
  message: 'finishing the turn in flight, then stopping. A fix cannot be resumed, so this one is being given back.',
369
- injected
536
+ injected,
537
+ // The verify run listens to this: every turn it plays after a Ctrl-C is model spend.
538
+ onInterrupt: () => abort.abort()
370
539
  });
371
540
 
372
541
  try {
@@ -387,10 +556,11 @@ async function fix(target, flags, injected, deps, log) {
387
556
  heartbeat.stop();
388
557
  inFlight.delete(fixRun.run_id);
389
558
  log(`${finding.label || finding.finding_id} reads as design intent, not a defect (confidence ${verdict.confidence}, from ${verdict.source || 'the brief'}), so nothing was edited.`);
559
+ for (const entry of v2 ? verifyRuns : [verifyRun]) await skipRun({ api, gameId, jobId, run: entry, reason: 'the finding is design intent', log });
390
560
  await completeFixRun({ api, gameId, jobId, runId: fixRun.run_id, body: { state: 'failed', reason: 'intended' }, log });
391
- await skipRun({ api, gameId, jobId, run: verifyRun, reason: 'the finding is design intent', log });
392
- await settle({ api, gameId, jobId, repoRoot, journal, reason: 'the finding is design intent', log });
393
- log('The fix unit was refunded. Mark the finding as intended in the dashboard so no later job spends a turn on it.');
561
+ const intended = await settle({ api, gameId, jobId, repoRoot, journal, reason: 'the finding is design intent', log });
562
+ if (Number(intended?.refunded_cents) > 0) log('The fix unit was refunded.');
563
+ log('Mark the finding as intended in the dashboard so no later job spends a turn on it.');
394
564
  return EXIT.OK;
395
565
  }
396
566
 
@@ -401,7 +571,8 @@ async function fix(target, flags, injected, deps, log) {
401
571
  jobId,
402
572
  branch,
403
573
  baseCommit: checked.head,
404
- engine: pack.engine || null
574
+ engine: pack.engine || null,
575
+ ...(v2 ? { runCheck: true } : {})
405
576
  });
406
577
  const codeBrief = pack.code_brief || null;
407
578
  // `graph_context` only exists in a bundle asked for with this job's id: it
@@ -432,14 +603,26 @@ async function fix(target, flags, injected, deps, log) {
432
603
  if (plan.hypothesis) log(`hypothesis: ${plan.hypothesis}`);
433
604
 
434
605
  const maxActions = resolveFixMaxActions(options, pack);
435
- const applied = await applyFix({
606
+ // Fix v2: one repository tool surface for the first apply and every
607
+ // repair, so the per-run write cap counts all of them, plus run_check.
608
+ const checks = v2
609
+ ? createCheckRunner({
610
+ projectDir: await checkProjectDir({ driverName, buildTarget, repoRoot }),
611
+ godotPath: options.godotPath || null,
612
+ allowTests,
613
+ allowToolScripts: Boolean(options.allowToolScripts),
614
+ changedFiles: async () => projectRelative(await git.diffStat(), repoRoot, await checkProjectDir({ driverName, buildTarget, repoRoot })),
615
+ ...(injected.checkRunner ? injected.checkRunner : {})
616
+ })
617
+ : null;
618
+ const applyRepo = v2 ? createRepoTools({ root: repoRoot, tools: WRITE_TOOLS }) : undefined;
619
+ const applyArgs = {
436
620
  client,
437
621
  jobId,
438
622
  runId: fixRun.run_id,
439
623
  repoRoot,
440
624
  system,
441
625
  plan,
442
- maxActions,
443
626
  codeBrief,
444
627
  graphContext,
445
628
  ledger,
@@ -454,6 +637,11 @@ async function fix(target, flags, injected, deps, log) {
454
637
  appendFile(path.join(directory, 'transcript.jsonl'), `${JSON.stringify({ at: new Date().toISOString(), ...progress })}\n`).catch(() => {});
455
638
  },
456
639
  shouldStop: () => interrupt.signalled || heartbeat.canceled
640
+ };
641
+ let applied = await applyFix({
642
+ ...applyArgs,
643
+ maxActions,
644
+ ...(v2 ? { acceptance: frozen, repo: withRunCheck(applyRepo, checks) } : {})
457
645
  });
458
646
 
459
647
  // A Ctrl-C, or the job cancelled from the dashboard. Either way this run is
@@ -465,16 +653,16 @@ async function fix(target, flags, injected, deps, log) {
465
653
  heartbeat.stop();
466
654
  inFlight.delete(fixRun.run_id);
467
655
  const why = interrupt.signalled ? 'interrupted by the developer' : 'cancelled';
656
+ for (const entry of v2 ? verifyRuns : [verifyRun]) await skipRun({ api, gameId, jobId, run: entry, reason: `the fix was ${why}`, log });
468
657
  await completeFixRun({ api, gameId, jobId, runId: fixRun.run_id, body: { state: 'failed', reason: 'error' }, log });
469
- await skipRun({ api, gameId, jobId, run: verifyRun, reason: `the fix was ${why}`, log });
470
658
  try {
471
659
  await stateFile.patchRun(repoRoot, journal, fixRun.run_id, { state: 'failed', quit_reason: 'error' });
472
660
  } catch {
473
661
  // The journal is a convenience here; the server's own run state is the
474
662
  // record, and it has just been told.
475
663
  }
476
- await settle({ api, gameId, jobId, repoRoot, journal, reason: `the fix was ${why}`, log });
477
- log(`The fix was ${why} and cannot be resumed, so the unit was given back. The branch ${branch} holds whatever was written, and nothing was committed or pushed.`);
664
+ const stopped = await settle({ api, gameId, jobId, repoRoot, journal, reason: `the fix was ${why}`, log });
665
+ log(`The fix was ${why} and cannot be resumed${Number(stopped?.refunded_cents) > 0 ? ', so the unit was given back' : ''}. The branch ${branch} holds whatever was written, and nothing was committed or pushed.`);
478
666
  return EXIT.CANCELED;
479
667
  }
480
668
 
@@ -484,10 +672,10 @@ async function fix(target, flags, injected, deps, log) {
484
672
  heartbeat.stop();
485
673
  inFlight.delete(fixRun.run_id);
486
674
  log(`Nothing was changed, so there is nothing to review. The branch ${branch} is empty; delete it or stay on it.`);
675
+ for (const entry of v2 ? verifyRuns : [verifyRun]) await skipRun({ api, gameId, jobId, run: entry, reason: 'nothing was changed', log });
487
676
  await completeFixRun({ api, gameId, jobId, runId: fixRun.run_id, body: { state: 'failed', reason: 'no_change' }, log });
488
- await skipRun({ api, gameId, jobId, run: verifyRun, reason: 'nothing was changed', log });
489
- await settle({ api, gameId, jobId, repoRoot, journal, reason: 'nothing was changed', log });
490
- log('The fix unit was refunded.');
677
+ const unchanged = await settle({ api, gameId, jobId, repoRoot, journal, reason: 'nothing was changed', log });
678
+ if (Number(unchanged?.refunded_cents) > 0) log('The fix unit was refunded.');
491
679
  return EXIT.OK;
492
680
  }
493
681
 
@@ -498,7 +686,69 @@ async function fix(target, flags, injected, deps, log) {
498
686
 
499
687
  // 4. Verify, by replaying the finding against the build.
500
688
  let verified = null;
501
- if (wantsVerify && verifyRun) {
689
+ const verifies = [];
690
+ let repairs = 0;
691
+ // A script that does not parse or a project that does not import is not a
692
+ // change anyone can review, so it blocks the upload and the unit comes back
693
+ // (playtest_fix_check_failed), as it does for a feature. A failing project
694
+ // test ships, and the report says so first.
695
+ let checksBlock = false;
696
+ let usedVerifyRuns = 0;
697
+ if (v2) {
698
+ // Fix v2: checks after every apply turn set, verify once they pass, and
699
+ // at most MAX_REPAIRS repair turn sets started by a failed check or a
700
+ // verify that still reproduces. The contract never changes.
701
+ const verifyArgs = {
702
+ deps, api, client, gameId, jobId, repoRoot, pack, brief, finding, driverName,
703
+ target: buildTarget, options, journal,
704
+ maxActions: Math.min(VERIFY_MAX_ACTIONS, maxActions),
705
+ makeDriver: injected.driverFactory || createDriver,
706
+ signal: abort.signal,
707
+ acceptance: frozen,
708
+ sourceRunId,
709
+ ...(injected.replayer ? { replayer: injected.replayer } : {}),
710
+ log
711
+ };
712
+ for (;;) {
713
+ const gate = await runGateChecks(checks, log);
714
+ checksBlock = gate.importFailed || gate.failed.some(result => result.name === 'godot_script_check');
715
+ const canRepair = repairs < MAX_REPAIRS && applied.stop !== 'budget' && !interrupt.signalled && !heartbeat.canceled;
716
+ let repairReason = null;
717
+ let repairDetail = '';
718
+ if (gate.failed.length > 0) {
719
+ repairReason = gate.failed.some(result => result.status === 'check_refused')
720
+ ? 'the checks were refused because of what the change edited'
721
+ : `the ${gate.failed.map(result => result.name).join(' and ')} check${gate.failed.length > 1 ? 's' : ''} failed`;
722
+ repairDetail = gate.failed.map(checkResultText).join('\n\n');
723
+ } else if (wantsVerify && usedVerifyRuns < verifyRuns.length) {
724
+ verified = await runVerify({ ...verifyArgs, entry: verifyRuns[usedVerifyRuns] });
725
+ usedVerifyRuns += 1;
726
+ verifies.push(verified);
727
+ const score = verified && !verified.failed ? verified.reproduced : null;
728
+ // A repair after verify needs a fresh verify run to judge it; the
729
+ // server registers spares for Fix v2, and without one there is
730
+ // nothing to re-verify with, so no repair is spent.
731
+ if (typeof score === 'number' && score > REPRODUCED_ABOVE && usedVerifyRuns < verifyRuns.length) {
732
+ repairReason = `the verify run still reproduced the finding (${score})`;
733
+ repairDetail = `verify run ${verified.run_id}: ${verifyWords(verified)}. The acceptance contract is unchanged: ${frozen.actual} should become: ${frozen.expected}`;
734
+ }
735
+ }
736
+ if (!repairReason || !canRepair) break;
737
+ repairs += 1;
738
+ log(`repair ${repairs} of ${MAX_REPAIRS}: ${repairReason}.`);
739
+ applied = await applyFix({
740
+ ...applyArgs,
741
+ maxActions: Math.min(REPAIR_MAX_ACTIONS, maxActions),
742
+ acceptance: frozen,
743
+ repair: { attempt: repairs, of: MAX_REPAIRS, reason: repairReason, detail: repairDetail },
744
+ repo: withRunCheck(applyRepo, checks)
745
+ });
746
+ if (interrupt.signalled || heartbeat.canceled) break;
747
+ }
748
+ for (const spare of verifyRuns.slice(usedVerifyRuns)) {
749
+ await skipRun({ api, gameId, jobId, run: spare, reason: checksBlock ? 'the fix did not pass its checks' : (wantsVerify ? 'no repair needed it' : 'skipped with --no-verify'), log });
750
+ }
751
+ } else if (wantsVerify && verifyRun) {
502
752
  verified = await runVerify({
503
753
  deps,
504
754
  api,
@@ -516,14 +766,61 @@ async function fix(target, flags, injected, deps, log) {
516
766
  journal,
517
767
  maxActions: Math.min(VERIFY_MAX_ACTIONS, maxActions),
518
768
  makeDriver: injected.driverFactory || createDriver,
769
+ signal: abort.signal,
519
770
  log
520
771
  });
521
772
  } else {
522
773
  await skipRun({ api, gameId, jobId, run: verifyRun, reason: wantsVerify ? 'no verify run was registered' : 'skipped with --no-verify', log });
523
774
  }
775
+ if (interrupt.signalled || heartbeat.canceled) {
776
+ heartbeat.stop();
777
+ inFlight.delete(fixRun.run_id);
778
+ await completeFixRun({ api, gameId, jobId, runId: fixRun.run_id, body: { state: 'failed', reason: 'error' }, log });
779
+ await settle({ api, gameId, jobId, repoRoot, journal, reason: 'the fix was interrupted', log });
780
+ log(`The fix was interrupted and cannot be resumed, so the unit was given back. The branch ${branch} holds whatever was written.`);
781
+ return EXIT.CANCELED;
782
+ }
783
+
784
+ // A repair may have touched more files than the first apply did.
785
+ const finalChanged = v2 ? await git.diffStat() : changed;
786
+ const finalFiles = v2 ? finalChanged.slice(0, MAX_CHANGESET_FILES) : files;
787
+
788
+ if (v2 && checksBlock) {
789
+ // A fix the project cannot import or parse is not uploaded: there is no
790
+ // review it deserves. The evidence stays local for the developer.
791
+ heartbeat.stop();
792
+ inFlight.delete(fixRun.run_id);
793
+ const body = evidenceReport({
794
+ prBody: `# Fix ${finding.label || finding.finding_id}: not uploaded\n\nThis change does not pass its checks.`,
795
+ summarySource: 'local',
796
+ acceptance: frozen,
797
+ finding,
798
+ sourceRunId,
799
+ plan,
800
+ files: finalFiles,
801
+ checks: checks.results,
802
+ verifies,
803
+ repairs,
804
+ maxRepairs: MAX_REPAIRS,
805
+ wantsVerify,
806
+ thresholds: { fixedBelow: FIXED_BELOW, reproducedAbove: REPRODUCED_ABOVE }
807
+ });
808
+ await writeFile(path.join(directory, RUN_FILES.reportMd), body, 'utf8').catch(() => {});
809
+ const stillFailing = checksStillFailing(checks.results);
810
+ const refusedCheck = checks.results.some(result => stillFailing.includes(result.name) && result.status === 'check_refused');
811
+ const blockedBy = refusedCheck
812
+ ? 'The checks were refused for this change'
813
+ : (stillFailing.includes('godot_import') ? 'The project does not import with this change' : 'This change does not pass its script check');
814
+ log(`${blockedBy} after ${repairs} repair(s), so the changeset was not uploaded. The evidence is in ${path.join(directory, RUN_FILES.reportMd)} and the branch ${branch} holds the edit.`);
815
+ // `check_failed` is the server's own word for this, so the ledger and the
816
+ // dashboard say the fix failed its checks rather than that a run crashed.
817
+ await completeFixRun({ api, gameId, jobId, runId: fixRun.run_id, body: { state: 'failed', reason: 'check_failed' }, log });
818
+ await settle({ api, gameId, jobId, repoRoot, journal, reason: 'the fix did not pass its checks', log });
819
+ return EXIT.FAILED;
820
+ }
524
821
 
525
822
  // 5. The pull request text, from the diff that exists.
526
- const fallback = localSummary({ finding, plan, files, verify: verified, branch, applied });
823
+ const fallback = localSummary({ finding, plan, files: finalFiles, verify: verified, branch, applied });
527
824
  // Both budget stops mean the apply loop left off early rather than because
528
825
  // it was finished, so both carry the caveat. Only the 402 skips the
529
826
  // summarise call, because only the 402 means there is nothing left to
@@ -538,24 +835,48 @@ async function fix(target, flags, injected, deps, log) {
538
835
  system,
539
836
  finding: findingForPrompt(finding),
540
837
  plan,
541
- files,
838
+ files: finalFiles,
542
839
  verify: verified,
840
+ thresholds: { fixedBelow: FIXED_BELOW, reproducedAbove: REPRODUCED_ABOVE },
543
841
  branch,
544
842
  fallback,
545
843
  ledger,
546
844
  log
547
845
  });
548
846
 
549
- const changeset = {
847
+ // Fix v2: the pull request body is the evidence report, which keeps what
848
+ // was measured apart from what a model wrote.
849
+ const prBody = v2
850
+ ? withEvidenceIntact(description => evidenceReport({
851
+ prBody: description,
852
+ summarySource: summary.source,
853
+ acceptance: frozen,
854
+ finding,
855
+ sourceRunId,
856
+ plan,
857
+ files: finalFiles,
858
+ checks: checks.results,
859
+ verifies,
860
+ repairs,
861
+ maxRepairs: MAX_REPAIRS,
862
+ wantsVerify,
863
+ budgetTouched,
864
+ thresholds: { fixedBelow: FIXED_BELOW, reproducedAbove: REPRODUCED_ABOVE }
865
+ }), summary.prBody)
866
+ : summary.prBody;
867
+
868
+ const changeset = withoutQuotedSecrets({
550
869
  schema_version: '1.0',
551
870
  finding_id: finding.finding_id,
552
871
  branch,
553
872
  base_commit: checked.head,
554
- files,
555
- verify: verified ? { run_id: verified.run_id, reproduced: verified.reproduced } : null,
873
+ files: finalFiles,
874
+ // A verify that never played the fix (its game did not boot, or the run
875
+ // died) is no verify at all: the changeset says unverified.
876
+ verify: verified && !verified.failed ? { run_id: verified.run_id, reproduced: verified.reproduced } : null,
556
877
  pr_title: summary.prTitle,
557
- pr_body: summary.prBody
558
- };
878
+ pr_body: prBody
879
+ }, log);
559
880
 
560
881
  await heartbeat.beat();
561
882
  heartbeat.stop();
@@ -584,19 +905,34 @@ async function fix(target, flags, injected, deps, log) {
584
905
  // whole diagnosis: "pr_title too long" is actionable, "422" is not.
585
906
  for (const detail of changesetDetails(error)) log(` ${detail}`);
586
907
  }
587
- await settle({ api, gameId, jobId, repoRoot, journal, reason: '', log });
588
-
589
- report({ changeset, directory, reportPath, verified, options, log, injected, deps, budget: budgetTouched });
908
+ const finished = await settle({ api, gameId, jobId, repoRoot, journal, reason: '', log });
909
+
910
+ if (verified && verified.failed === 'game_boot_failed') {
911
+ // The server refunds a fix whose verify run could not boot
912
+ // (playtest_fix_unverified_boot) only up to the account's limit, so this says
913
+ // what its answer says and never assumes.
914
+ const answer = await api.runs.get(gameId, jobId, fixRun.run_id).catch(() => null);
915
+ log(bootRefundLine({ finished, run: (answer && (answer.run || answer)) || null, priceCents: registered.price && registered.price.price_cents }));
916
+ }
917
+ let opened = null;
918
+ if (v2 && options.openPr) {
919
+ opened = await maybeOpenPr({ repoRoot, branch, changed: finalChanged, written: applyRepo.written, changeset, reportPath, verified, injected, deps, log });
920
+ if (opened.ok) await recordPrOpened({ api, gameId, repoRoot, journal, url: opened.url });
921
+ }
922
+ const stillFailing = v2 ? checksStillFailing(checks.results) : [];
923
+ if (stillFailing.length > 0) log(`checks failed on this change: ${stillFailing.join(', ')}. The verify run did not play it, and the unit is kept because a change was delivered. Read the evidence report before you use it.`);
924
+ if (!opened || !opened.ok) report({ changeset, directory, reportPath, verified, options, log, injected, deps, budget: budgetTouched });
590
925
  // Exit 2 is "the money ran out", which a warning is not: a warning means
591
926
  // the run wrapped up early with budget still in hand.
592
- return applied.stop === 'budget' ? EXIT.BUDGET : EXIT.OK;
927
+ if (applied.stop === 'budget') return EXIT.BUDGET;
928
+ return opened && !opened.ok ? EXIT.ENVIRONMENT : EXIT.OK;
593
929
  } catch (error) {
594
930
  heartbeat.stop();
595
931
  const code = exitCodeFor(error);
596
932
  log(`fix failed: ${String(error && error.message)}`);
597
933
  log(`The branch ${branch} still holds whatever was written. Nothing was committed or pushed.`);
598
- await failFixRun({ api, gameId, jobId, runId: fixRun.run_id, log });
599
934
  await skipRun({ api, gameId, jobId, run: verifyRun, reason: 'the fix failed', log });
935
+ await failFixRun({ api, gameId, jobId, runId: fixRun.run_id, log });
600
936
  await settle({ api, gameId, jobId, repoRoot, journal, reason: 'the fix failed', log });
601
937
  return code;
602
938
  } finally {
@@ -612,6 +948,56 @@ async function fix(target, flags, injected, deps, log) {
612
948
  * @param {{jobId: string|null, label: string|null, findingId: string|null}} target
613
949
  * @returns {Promise<Object|null>}
614
950
  */
951
+ /** The list item sources whose ref is a finding a fix can replay. */
952
+ export const FIXABLE_SOURCES = Object.freeze(['finding', 'fresh_eyes']);
953
+
954
+ /** Whether a fix target names a Labs list item rather than a finding. */
955
+ export function isListItemTarget(target) {
956
+ return String(target || '').trim().startsWith('li_');
957
+ }
958
+
959
+ /**
960
+ * Find a list item and check it came from a finding. Only a finding has the
961
+ * persona, the repro steps and the replay a fix needs; anything else is new
962
+ * work, which is what `feature` builds.
963
+ *
964
+ * @param {Object} api
965
+ * @param {string} gameId
966
+ * @param {string} itemId
967
+ * @returns {Promise<{item: Object, list: string}|{refusal: string}>}
968
+ */
969
+ export async function resolveListItemTarget(api, gameId, itemId) {
970
+ if (!api.labs || !api.labs.lists) {
971
+ return { refusal: 'This server has no punch list. Fix the finding by its id instead.' };
972
+ }
973
+ const found = await findListItem(api, gameId, itemId);
974
+ if (!found) {
975
+ return { refusal: `No item ${itemId} is on this game's punch list or blue tape. Run "ravensight-playtest punch list --status all" to see the ids.` };
976
+ }
977
+ const source = found.item.source || {};
978
+ // A fresh-eyes item's ref is a finding's targetId too.
979
+ if (!FIXABLE_SOURCES.includes(source.kind) || !source.ref) {
980
+ return {
981
+ refusal: `${itemId} did not come from a finding, so fix has nothing to replay. Use "ravensight-playtest feature" for that item. Nothing was charged.`
982
+ };
983
+ }
984
+ return found;
985
+ }
986
+
987
+ /**
988
+ * Record the fix job on the list item. Best effort: the job is already paid
989
+ * for, and a list that cannot be written must not stop the fix.
990
+ */
991
+ async function recordFixJob({ api, gameId, listItem, jobId, link, log }) {
992
+ const itemId = listItem.item.item_id;
993
+ try {
994
+ await api.labs.lists.update(gameId, listItem.list, itemId, { fix_job_id: jobId });
995
+ log(`Recorded job ${jobId} on ${itemId}: ${link}. The item stays open until you close it.`);
996
+ } catch (error) {
997
+ log(`warning: could not record job ${jobId} on ${itemId} (${error && error.message ? error.message : error}). The fix carries on.`);
998
+ }
999
+ }
1000
+
615
1001
  export async function resolveFinding(api, gameId, target) {
616
1002
  const answer = await api.jobs.findings(gameId, target.jobId ? { job: target.jobId } : {});
617
1003
  const list = (answer && answer.findings) || [];
@@ -763,17 +1149,34 @@ export async function runVerify({
763
1149
  journal,
764
1150
  maxActions,
765
1151
  makeDriver,
1152
+ acceptance = null,
1153
+ sourceRunId = null,
1154
+ replayer = replayTrace,
1155
+ signal,
766
1156
  log
767
1157
  }) {
768
1158
  const directory = runDirFor(repoRoot, jobId, entry.run_id);
769
1159
  await mkdir(directory, { recursive: true });
1160
+
1161
+ // FIX V2 REPLAYS THE SOURCE RUN FIRST. It costs no model call, and it can say
1162
+ // one thing for certain: if the game answers identically at every recorded step,
1163
+ // the fix changed nothing on the path that found the problem. It cannot say the
1164
+ // problem is gone, because after a divergence the recorded inputs no longer mean
1165
+ // anything, so a divergence goes to the persona like a run with no trace at all.
1166
+ let divergence = null;
1167
+ if (acceptance && driverName === 'godot_driver') {
1168
+ const replayed = await replaySourceTrace({ api, gameId, jobId, repoRoot, finding, target, godotPath: options.godotPath, directory, sourceRunId, entry, log, replayer });
1169
+ if (replayed.unchanged) return closeVerifyOnUnchanged({ deps, api, gameId, jobId, repoRoot, entry, journal, directory, log, report: replayed.report });
1170
+ divergence = replayed.divergence;
1171
+ }
1172
+
770
1173
  const persona = (pack.personas || []).find(candidate => candidate.slug === entry.persona) || (pack.personas || [])[0];
771
1174
  if (!persona) {
772
1175
  log('The content pack has no persona to verify with, so the fix is reported unverified.');
773
1176
  return null;
774
1177
  }
775
1178
  const headless = driverName === 'godot_driver' ? options.headless === true : options.headed !== true;
776
- const driver = await makeDriver(driverName, driverOptions({ headless, driverName, target, directory, entry, jobId }));
1179
+ const driver = await makeDriver(driverName, driverOptions({ headless, driverName, target, directory, entry, jobId, godotPath: options.godotPath, realtime: options.realtime }));
777
1180
  const ledger = createUsageLedger();
778
1181
  const heartbeat = createHeartbeat({
779
1182
  api,
@@ -810,13 +1213,17 @@ export async function runVerify({
810
1213
  runDirectory: directory,
811
1214
  skill: skillFor(pack, 'persona_playtest'),
812
1215
  // The brief fetched above, as `run` does: the pack does not always carry one.
813
- brief: verifyBrief(brief || pack.brief || null, finding),
1216
+ // Fix v2 builds the verify brief from the frozen contract alone.
1217
+ brief: withDivergence(acceptance
1218
+ ? verifyBriefFromAcceptance(brief || pack.brief || null, acceptance)
1219
+ : verifyBrief(brief || pack.brief || null, finding), divergence),
814
1220
  localBrief,
815
1221
  intentNotes: pack.intent_notes || [],
816
1222
  schemas: pack.schemas || [],
817
1223
  maxActions,
818
1224
  heartbeat,
819
1225
  ledger,
1226
+ signal,
820
1227
  log: message => log(`verify: ${message}`)
821
1228
  });
822
1229
  } catch (error) {
@@ -825,7 +1232,8 @@ export async function runVerify({
825
1232
  await stateFile.patchRun(repoRoot, journal, entry.run_id, { state: failed.state, error: String(error && error.message), quit_reason: failed.quitReason });
826
1233
  await deliver({ deps, api, gameId, jobId, runId: entry.run_id, directory, outcome: failed, options, log, journal, repoRoot });
827
1234
  log(`the verify run failed (${String(error && error.message)}), so the fix is reported unverified.`);
828
- return null;
1235
+ const booted = !(error && (error.code === 'game_boot_failed' || /^game_boot_failed: /.test(String(error.message))));
1236
+ return { run_id: entry.run_id, reproduced: null, source: null, failed: booted ? 'verify_failed' : 'game_boot_failed', detail: String(error && error.message).replace(/^game_boot_failed: /, '').slice(0, 200) };
829
1237
  }
830
1238
  heartbeat.stop();
831
1239
 
@@ -848,10 +1256,100 @@ export async function runVerify({
848
1256
  log(`the verify verdict could not be read (${String(error && error.message)}).`);
849
1257
  }
850
1258
  const verdict = (run && run.verify_verdict) || null;
851
- const reproduced = verdict && Number.isFinite(Number(verdict.reproduced)) ? Number(verdict.reproduced) : null;
1259
+ // Strictly a number. `Number(null)` is 0, and the server answers
1260
+ // `{ reproduced: null, source: 'unavailable' }` when nobody could judge, so a
1261
+ // coercion here reads a judge being down as "fixed (reproduced 0)".
1262
+ const reproduced = verdict && typeof verdict.reproduced === 'number' && Number.isFinite(verdict.reproduced) ? verdict.reproduced : null;
852
1263
  return { run_id: entry.run_id, reproduced, source: (verdict && verdict.source) || null };
853
1264
  }
854
1265
 
1266
+ /**
1267
+ * Replay the source run's trace against the fix, on this working tree. Never
1268
+ * fails the verify: anything short of a full match, including no trace at all,
1269
+ * answers "not unchanged" and the persona decides. The outcome is recorded on the
1270
+ * job, free, when the server takes it.
1271
+ *
1272
+ * @returns {Promise<{unchanged: boolean, divergence: Object|null}>}
1273
+ */
1274
+ export async function replaySourceTrace({ api, gameId, jobId, repoRoot, finding, target, godotPath, directory, sourceRunId, entry, log, replayer = replayTrace }) {
1275
+ const runId = sourceRunId || finding.run_id || null;
1276
+ if (!runId) return { unchanged: false, divergence: null };
1277
+ const trace = await loadTrace({ repoRoot, runId, jobId: finding.job_id || null, api, gameId });
1278
+ if (!trace) return { unchanged: false, divergence: null };
1279
+ let result;
1280
+ try {
1281
+ result = await replayer({ text: trace.text, projectDir: target, godotPath, runDir: path.join(directory, 'trace-replay') });
1282
+ } catch (error) {
1283
+ log(`verify: the source run's trace could not be replayed (${String(error && error.message)}). A persona replays the finding instead.`);
1284
+ return { unchanged: false, divergence: null };
1285
+ }
1286
+ log(`verify: ${replaySentence(result)}`);
1287
+ const sha = await headSha(repoRoot);
1288
+ const report = sha ? replayReport(result, { ref: 'fix-branch', sha }) : null;
1289
+ if (report && entry) {
1290
+ try {
1291
+ await api.runs.recordReplay(gameId, jobId, entry.run_id, report);
1292
+ } catch {
1293
+ // A record is for reading later. The verify does not depend on it.
1294
+ }
1295
+ }
1296
+ // The server decides a verify from this whole report, so a replay that cannot
1297
+ // name its commit cannot close one: a persona replays the finding instead.
1298
+ // The same test as the server's `replayProvesIdentical`: a replay that compared
1299
+ // nothing, or ran no ticks, matched only because it did nothing.
1300
+ const proven = result.matched === true && result.fully_replayed === true && !result.stopped && !result.first_divergence && result.steps_compared > 0 && result.ticks > 0;
1301
+ return { unchanged: Boolean(report) && proven, divergence: result.first_divergence || null, report };
1302
+ }
1303
+
1304
+ async function headSha(repoRoot) {
1305
+ try {
1306
+ const { stdout } = await execFileAsync('git', ['-C', repoRoot, 'rev-parse', 'HEAD']);
1307
+ return stdout.trim();
1308
+ } catch {
1309
+ return null;
1310
+ }
1311
+ }
1312
+
1313
+ /**
1314
+ * The trace matched everywhere, so the verify run is finished without a model.
1315
+ * The verdict comes back from the server's own record of this complete, the same
1316
+ * as a persona verify's, and it says the finding is still there.
1317
+ */
1318
+ export async function closeVerifyOnUnchanged({ deps, api, gameId, jobId, repoRoot, entry, journal, directory, log, report }) {
1319
+ log('verify: every recorded step answered the same, so this fix changed nothing on the path that found the problem. No model was called.');
1320
+ for (const state of ['launching', 'playing', 'reporting', 'uploading']) {
1321
+ await api.runs.transition(gameId, jobId, entry.run_id, state, { reason: '' });
1322
+ }
1323
+ await deps.finalizeRun(directory, {
1324
+ api,
1325
+ gameId,
1326
+ jobId,
1327
+ runId: entry.run_id,
1328
+ body: { state: 'succeeded', actions_taken: 0, quit_reason: 'goal_reached', trace_replay: report }
1329
+ });
1330
+ await stateFile.patchRun(repoRoot, journal, entry.run_id, { state: 'succeeded', completed: true, uploaded: true });
1331
+ const answer = await api.runs.get(gameId, jobId, entry.run_id);
1332
+ const run = (answer && (answer.run || answer)) || null;
1333
+ const verdict = (run && run.verify_verdict) || null;
1334
+ // The same reading as a persona verify's: `Number(null)` is 0, which would read a
1335
+ // judge that could not answer as "fixed (reproduced 0)".
1336
+ const reproduced = verdict && typeof verdict.reproduced === 'number' && Number.isFinite(verdict.reproduced) ? verdict.reproduced : null;
1337
+ return { run_id: entry.run_id, reproduced, source: (verdict && verdict.source) || 'trace_replay' };
1338
+ }
1339
+
1340
+ /** Tell the verify persona where the fix first moved the game, as a starting point. */
1341
+ export function withDivergence(brief, divergence) {
1342
+ if (!divergence || !brief) return brief;
1343
+ const diff = Object.entries(divergence.game_state_diff || {}).slice(0, 5).map(([key, pair]) => `${key}: ${JSON.stringify(pair.expected)} became ${JSON.stringify(pair.actual)}`);
1344
+ return {
1345
+ ...brief,
1346
+ fields: {
1347
+ ...(brief.fields || {}),
1348
+ verify_trace_note: `Replaying the original run against this fix first differed at tick ${divergence.frame ?? 'unknown'}${diff.length ? ` (${diff.join('; ')})` : ''}. That shows the fix changed behaviour there, not that the problem is gone. Check for yourself.`
1349
+ }
1350
+ };
1351
+ }
1352
+
855
1353
  /**
856
1354
  * The verify run's brief: what it is being asked to reproduce, then the brief
857
1355
  * a persona usually plays against. The task comes first because it is the one
@@ -942,11 +1440,13 @@ function report({ changeset, reportPath, verified, options, log, injected, deps,
942
1440
  log(`verify: ${verifyWords(verified)}`);
943
1441
  if (budget) log('The fix stopped early because its model budget was spent or nearly spent, so read the diff carefully: it may be half a change.');
944
1442
  log('Nothing was committed and nothing was pushed. Read the diff, commit it, then:');
945
- log(` gh pr create --title "${String(changeset.pr_title).replace(/"/g, '\\"')}" --body-file ${reportPath}`);
1443
+ log(` gh pr create --title ${shellQuote(changeset.pr_title)} --body-file ${shellQuote(reportPath)}`);
946
1444
  }
947
1445
 
948
1446
  /**
949
- * Complete the fix run, tolerating a refusal. The money is settled by
1447
+ * Complete the fix run, tolerating a refusal. Every caller completes or skips
1448
+ * the verify run(s) first: the server reads the verify run's record when the
1449
+ * fix run completes (a verify that could not boot refunds the unit). The money is settled by
950
1450
  * `finish` either way, and a refused complete must not turn a fix that worked
951
1451
  * into a crash.
952
1452
  */
@@ -977,11 +1477,13 @@ async function skipRun({ api, gameId, jobId, run, reason, log }) {
977
1477
 
978
1478
  /** `finish` settles the job once and issues at most one refund. */
979
1479
  async function settle({ api, gameId, jobId, repoRoot, journal, reason, log }) {
1480
+ let finished = null;
980
1481
  try {
981
- const finished = await api.jobs.finish(gameId, jobId, { reason });
1482
+ finished = await api.jobs.finish(gameId, jobId, { reason });
982
1483
  log(`job ${jobId} ${finished.job ? finished.job.state : 'finished'}: refunded ${formatCents(finished.refunded_cents)}.`);
1484
+ if (finished.refund_limited_note) log(finished.refund_limited_note);
983
1485
  } catch (error) {
984
- log(`the job could not be settled now; the hourly sweep will settle it: ${String(error && error.message)}`);
1486
+ log(settleFailureLine(jobId, error));
985
1487
  }
986
1488
  journal.finished = true;
987
1489
  try {
@@ -990,6 +1492,7 @@ async function settle({ api, gameId, jobId, repoRoot, journal, reason, log }) {
990
1492
  // The journal is a convenience at this point: the server's ledger is the
991
1493
  // record of what was charged and what came back.
992
1494
  }
1495
+ return finished;
993
1496
  }
994
1497
 
995
1498
  /** The finding as the prompt sees it: no ids nobody can act on, no noise. */
@@ -1061,6 +1564,80 @@ export function changesetDetails(error) {
1061
1564
  .filter(Boolean);
1062
1565
  }
1063
1566
 
1567
+ /**
1568
+ * The Godot project the checks copy: the verify target for the Godot driver,
1569
+ * else the repository when it is a Godot project, else none.
1570
+ *
1571
+ * @returns {Promise<string|null>}
1572
+ */
1573
+ export async function checkProjectDir({ driverName, buildTarget, repoRoot }) {
1574
+ if (driverName === 'godot_driver' && buildTarget) return buildTarget;
1575
+ return (await isGodotProject(repoRoot)) ? repoRoot : null;
1576
+ }
1577
+
1578
+ /**
1579
+ * Repository relative paths as paths inside the Godot project, dropping any
1580
+ * outside it.
1581
+ */
1582
+ export function projectRelative(changed, repoRoot, projectDir) {
1583
+ if (!projectDir) return [];
1584
+ return changed
1585
+ .map(file => path.relative(projectDir, path.join(repoRoot, file.path)))
1586
+ .filter(file => file && !file.startsWith('..') && !path.isAbsolute(file));
1587
+ }
1588
+
1589
+ /**
1590
+ * Every gate check, in order. `godot_import` failing is the one that blocks an
1591
+ * upload; the others start a repair and, if they still fail, are listed as
1592
+ * remaining risks.
1593
+ *
1594
+ * @returns {Promise<{failed: Array<Object>, importFailed: boolean}>}
1595
+ */
1596
+ export async function runGateChecks(checks, log = () => {}) {
1597
+ const failed = [];
1598
+ let importFailed = false;
1599
+ for (const name of GATE_CHECKS) {
1600
+ // A refusal names the edits, not the check, so the rest would only repeat it.
1601
+ if (failed.some(result => result.status === 'check_refused')) break;
1602
+ // After a failed import the tests cannot mean anything, but the script
1603
+ // check still can: the import names the scripts that could not load
1604
+ // because of a broken one, the script check names the broken line.
1605
+ if (importFailed && name === 'project_tests') continue;
1606
+ const result = await checks.run(name);
1607
+ log(`check ${name}: ${result.status} (${result.detail})`);
1608
+ if (result.status === 'failed' || result.status === 'check_refused') {
1609
+ failed.push(result);
1610
+ // A refused check blocks the upload exactly as a failed import does:
1611
+ // nothing proved the change imports.
1612
+ if (name === 'godot_import' || result.status === 'check_refused') importFailed = true;
1613
+ }
1614
+ }
1615
+ return { failed, importFailed };
1616
+ }
1617
+
1618
+ /**
1619
+ * `--open-pr` after the fix is uploaded: only for a fix the verify scored as
1620
+ * fixed, and only with the developer's own yes.
1621
+ */
1622
+ export async function maybeOpenPr({ repoRoot, branch, changed, written = null, changeset, reportPath, verified, injected, deps, log }) {
1623
+ if (!verifyWords(verified).startsWith('fixed')) {
1624
+ log(`--open-pr opens a pull request only for a verified fix, and this one reads ${verifyWords(verified)}. Nothing was pushed.`);
1625
+ return { ok: false, reason: 'not_verified' };
1626
+ }
1627
+ const opener = injected.openPullRequest || openPullRequest;
1628
+ return opener({
1629
+ repoRoot,
1630
+ branch,
1631
+ // What the fix wrote, not whatever else is in the tree by now: a run can leave
1632
+ // files behind that are not the change.
1633
+ paths: changed.map(file => file.path).filter(file => !written || written.includes(file)),
1634
+ title: changeset.pr_title,
1635
+ bodyFile: reportPath,
1636
+ confirm: injected.confirmPr || injected.confirm || (deps.ui && deps.ui.confirm),
1637
+ log
1638
+ });
1639
+ }
1640
+
1064
1641
  function isImageName(name) {
1065
1642
  return /\.(png|jpe?g|webp)$/i.test(String(name));
1066
1643
  }