ravensight-playtest 0.5.2 → 0.6.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 (96) hide show
  1. package/README.md +249 -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 +92 -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 +165 -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 +578 -68
  22. package/src/commands/init.js +19 -3
  23. package/src/commands/profile.js +25 -6
  24. package/src/commands/replay.js +204 -0
  25. package/src/commands/resume.js +7 -0
  26. package/src/commands/run.js +200 -24
  27. package/src/commands/template.js +70 -0
  28. package/src/commands/validate.js +414 -0
  29. package/src/commands/validateHosted.js +292 -0
  30. package/src/create/scaffold.js +192 -0
  31. package/src/run/acceptance.js +212 -0
  32. package/src/run/args.js +18 -1
  33. package/src/run/checks.js +705 -0
  34. package/src/run/dimension.js +95 -0
  35. package/src/run/drivers/godot-observation.js +59 -11
  36. package/src/run/drivers/godot-project.js +201 -31
  37. package/src/run/drivers/godot.js +532 -45
  38. package/src/run/drivers/web.js +18 -0
  39. package/src/run/evidence.js +170 -0
  40. package/src/run/featureLoop.js +288 -0
  41. package/src/run/fixLoop.js +82 -12
  42. package/src/run/git.js +110 -20
  43. package/src/run/json.js +65 -9
  44. package/src/run/labsOutcome.js +136 -0
  45. package/src/run/liveWork.js +103 -0
  46. package/src/run/model.js +105 -5
  47. package/src/run/openPr.js +107 -0
  48. package/src/run/paths.js +4 -1
  49. package/src/run/pendingRegister.js +81 -0
  50. package/src/run/personaLoop.js +111 -4
  51. package/src/run/profile.js +68 -7
  52. package/src/run/replay.js +236 -0
  53. package/src/run/repoTools.js +227 -27
  54. package/src/run/report.js +7 -1
  55. package/src/run/resume.js +19 -1
  56. package/src/run/secretScan.js +35 -0
  57. package/src/run/settle.js +41 -0
  58. package/src/run/tools.js +33 -4
  59. package/src/run/trace.js +124 -0
  60. package/src/simulation/godotPack.js +112 -0
  61. package/src/ui/index.js +61 -9
  62. package/src/upload/allowlist.js +6 -2
  63. package/src/upload/index.js +5 -4
  64. package/templates/godot-2d/README.md +36 -0
  65. package/templates/godot-2d/addons/ravensight/LICENSE +21 -0
  66. package/templates/godot-2d/addons/ravensight/Ravensight.gd +1823 -0
  67. package/templates/godot-2d/export_presets.cfg +27 -0
  68. package/templates/godot-2d/gitignore +10 -0
  69. package/templates/godot-2d/project.godot +58 -0
  70. package/templates/godot-2d/scenes/main.tscn +67 -0
  71. package/templates/godot-2d/scripts/autoload/game_config.gd +83 -0
  72. package/templates/godot-2d/scripts/autoload/game_state.gd +44 -0
  73. package/templates/godot-2d/scripts/autoload/telemetry.gd +37 -0
  74. package/templates/godot-2d/scripts/main.gd +32 -0
  75. package/templates/godot-2d/scripts/player.gd +10 -0
  76. package/templates/godot-2d/scripts/state_hook.gd +16 -0
  77. package/templates/godot-2d/template.json +14 -0
  78. package/templates/godot-2d/tools/inject_release_key.sh +19 -0
  79. package/templates/godot-2d/tools/selftest.gd +70 -0
  80. package/templates/godot-3d/README.md +49 -0
  81. package/templates/godot-3d/addons/ravensight/LICENSE +21 -0
  82. package/templates/godot-3d/addons/ravensight/Ravensight.gd +1823 -0
  83. package/templates/godot-3d/export_presets.cfg +27 -0
  84. package/templates/godot-3d/gitignore +10 -0
  85. package/templates/godot-3d/project.godot +56 -0
  86. package/templates/godot-3d/scenes/main.tscn +154 -0
  87. package/templates/godot-3d/scripts/autoload/game_config.gd +83 -0
  88. package/templates/godot-3d/scripts/autoload/game_state.gd +44 -0
  89. package/templates/godot-3d/scripts/autoload/telemetry.gd +37 -0
  90. package/templates/godot-3d/scripts/beacon.gd +20 -0
  91. package/templates/godot-3d/scripts/main.gd +49 -0
  92. package/templates/godot-3d/scripts/player.gd +19 -0
  93. package/templates/godot-3d/scripts/state_hook.gd +25 -0
  94. package/templates/godot-3d/template.json +16 -0
  95. package/templates/godot-3d/tools/inject_release_key.sh +19 -0
  96. package/templates/godot-3d/tools/selftest.gd +77 -0
@@ -0,0 +1,845 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { randomBytes, randomUUID } from 'node:crypto';
3
+ import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { promisify } from 'node:util';
6
+
7
+ import { prepareAssets } from '../assets/index.js';
8
+ import { capabilityLive } from '../run/acceptance.js';
9
+ import { checkResultText, createCheckRunner, withRunCheck } from '../run/checks.js';
10
+ import { getDeps } from '../run/deps.js';
11
+ import { DIMENSION_3D, dimensionGate, isThreeDRoot, mainSceneRoot } from '../run/dimension.js';
12
+ import { GodotDriver } from '../run/drivers/godot.js';
13
+ import { inputControls, isGodotProject } from '../run/drivers/godot-project.js';
14
+ import EXIT from '../run/exit.js';
15
+ import {
16
+ applyFeature, eventsInChange, featureEvidence, featurePlanContent, featureSystem, localFeatureSummary,
17
+ MAX_FEATURE_ACTIONS, maxWritesForSize, planFeature, scenarioBrief, summariseFeature
18
+ } from '../run/featureLoop.js';
19
+ import { abandonBranch, createGit, preflight, startBranch } from '../run/git.js';
20
+ import { createHeartbeat } from '../run/heartbeat.js';
21
+ import { createModelClient } from '../run/model.js';
22
+ import { openPullRequest, shellQuote } from '../run/openPr.js';
23
+ import { jobDir, runDir as runDirFor, RUN_FILES } from '../run/paths.js';
24
+ import { checkoutRef, cleanCommit, replayTrace } from '../run/replay.js';
25
+ import { createRepoTools, PROFILE_TOOLS, WRITE_TOOLS } from '../run/repoTools.js';
26
+ import { recordPrOpened } from '../run/labsOutcome.js';
27
+ import { settleFailureLine } from '../run/settle.js';
28
+ import { withEvidenceIntact } from '../run/evidence.js';
29
+ import { assertNoSecrets, withoutQuotedSecrets } from '../run/secretScan.js';
30
+ import * as pendingRegister from '../run/pendingRegister.js';
31
+ import * as stateFile from '../run/state.js';
32
+ import { seedFrom } from '../run/trace.js';
33
+ import { createUsageLedger, writeUsageFile } from '../run/usage.js';
34
+ import { unknownPersonaMessage, unknownPersonas } from '../runDefaults.js';
35
+ import { loadLocalBrief } from '../briefLocal.js';
36
+ import { changesetDetails, projectRelative, runGateChecks } from './fix.js';
37
+ import { createInterruptHandler, explainRegisterRefusal, exitCodeFor, formatCents, godotRefusal, playJob, resolvePersonas, resolveSetup, startBalanceLine, startBalanceShort } from './run.js';
38
+ import { replayReport, replaySentence } from './replay.js';
39
+ import { bootProblem } from './validate.js';
40
+
41
+ const execFileAsync = promisify(execFile);
42
+
43
+ /**
44
+ * `ravensight-playtest feature`: Build a feature (Labs beta).
45
+ *
46
+ * Given a brief, it asks the server for a specification, one AI unit, and stops:
47
+ * a person reads, edits and approves that in the dashboard before any code is
48
+ * bought. Given an approved specification's id, it buys the job and builds it on
49
+ * a new branch, on this machine:
50
+ *
51
+ * plan -> apply (write tools and named checks, 40 files) -> the checks ->
52
+ * a persona plays each of the spec's test scenarios -> summarize
53
+ *
54
+ * with at most two repairs, started by a failed check or a failed scenario. The
55
+ * specification the server froze at register is the only definition of done.
56
+ *
57
+ * Whether it was verified is the server's answer from its own run rows; this
58
+ * command prints it and never words it more strongly. It never pushes, except
59
+ * `--open-pr` after a verified feature and a yes at a prompt.
60
+ */
61
+
62
+ const CAPABILITY = 'feature';
63
+ export const FEATURE_MODULES = Object.freeze(['feature']);
64
+ export const PACK_MODULES = Object.freeze(['feature', 'persona_playtest']);
65
+ export const CHANGESET_FILE = 'changeset.json';
66
+ export const MAX_REPAIRS = 2;
67
+ export const REPAIR_MAX_ACTIONS = 30;
68
+ const SPEC_ID = /^[a-f0-9]{24}$/i;
69
+ const ALPHA_3D_LINE = '3D Alpha: a proof of concept on one template. It is priced as any feature is, a real model on 3D is unmeasured, and a persona plays with keys and input actions, never mouse look.';
70
+ const LABS_OFF = 'Build a feature is part of Ravensight Labs (beta), and it is not on for this game. Turn Labs on from the Labs page in the dashboard. Nothing was charged.';
71
+
72
+ /**
73
+ * @param {string} target a brief, or an approved specification's id
74
+ * @param {Object} flags
75
+ * @param {Object} [injected] test seam
76
+ * @returns {Promise<number>} an exit code
77
+ */
78
+ export async function featureCommand(target, flags = {}, injected = {}) {
79
+ const deps = injected.deps || (await getDeps());
80
+ const log = injected.log || (deps.ui && deps.ui.info ? message => deps.ui.info(message) : message => console.log(message));
81
+ try {
82
+ const value = String(target || '').trim();
83
+ if (!value) {
84
+ log('Say what to build, in quotes, or name an approved specification\'s id.');
85
+ return EXIT.ENVIRONMENT;
86
+ }
87
+ const options = {
88
+ apiUrl: flags.apiUrl, repo: flags.repoRoot, game: flags.game, yes: flags.yes, headless: flags.headless === true,
89
+ godotPath: flags.godotPath, personas: flags.personas, uploadTrace: true, skipAggregate: true, concurrency: 1, verbose: flags.verbose
90
+ };
91
+ const setup = await resolveSetup(deps, options, log);
92
+ if (setup.code !== undefined) return setup.code;
93
+
94
+ let labs = null;
95
+ try { labs = await setup.api.labs.get(setup.gameId); } catch { labs = null; }
96
+ if (!capabilityLive(labs, CAPABILITY)) {
97
+ log(LABS_OFF);
98
+ return EXIT.ENVIRONMENT;
99
+ }
100
+ return SPEC_ID.test(value)
101
+ ? await build(value, flags, options, setup, injected, deps, log)
102
+ : await writeSpec(value, flags, setup, injected, deps, log);
103
+ } catch (error) {
104
+ const code = exitCodeFor(error);
105
+ if (code === EXIT.FAILED) throw error;
106
+ log(String(error && error.message ? error.message : error));
107
+ return code;
108
+ }
109
+ }
110
+
111
+ /** Ask for a specification and stop. A person approves it in the dashboard. */
112
+ async function writeSpec(brief, flags, setup, injected, deps, log) {
113
+ const { api, gameId, repoRoot, context } = setup;
114
+ // Free, before the unit: a specification is written for the kind of game this is.
115
+ const projectDir = path.resolve(repoRoot || '.', flags.godotProject || context?.config?.godot_project || '.');
116
+ const { refusal, dimension } = await dimensionGate({ projectDir, alpha3d: Boolean(flags.alpha3d), name: 'Build a feature' });
117
+ if (refusal) {
118
+ log(`${refusal} Nothing was charged.`);
119
+ return EXIT.ENVIRONMENT;
120
+ }
121
+ if (dimension === DIMENSION_3D) log(ALPHA_3D_LINE);
122
+ const offer = 'Writing a specification is one AI unit. A brief bigger than a large feature is declined and costs nothing.';
123
+ log(offer);
124
+ const confirm = injected.confirm || (deps.ui && deps.ui.confirm);
125
+ if (!flags.yes && confirm && !await confirm('Write the specification?', { yes: flags.yes })) {
126
+ log('Nothing was charged.');
127
+ return EXIT.CANCELED;
128
+ }
129
+ let spec;
130
+ try {
131
+ ({ spec } = await api.labs.featureSpecs.create(gameId, brief, dimension === DIMENSION_3D ? { dimension } : {}));
132
+ } catch (error) {
133
+ if (error && /^feature_(too_large|out_of_scope|not_2d)$/.test(String(error.code))) {
134
+ log(`Declined: ${error.message} Nothing was charged.`);
135
+ return EXIT.ENVIRONMENT;
136
+ }
137
+ throw error;
138
+ }
139
+ log(`Specification ${spec.spec_id}: ${spec.title} (${spec.size}).`);
140
+ for (const line of specLines(spec.spec)) log(line);
141
+ log('Read it, edit it and approve it on the Labs page in the dashboard. Then build it with:');
142
+ log(` npx ravensight-playtest feature ${spec.spec_id}${dimension === DIMENSION_3D ? ' --alpha-3d' : ''}`);
143
+ if (flags.json) (deps.ui && deps.ui.json ? deps.ui.json : value => console.log(JSON.stringify(value)))(spec);
144
+ return EXIT.OK;
145
+ }
146
+
147
+ function specLines(spec) {
148
+ return [
149
+ ` requirements: ${spec.requirements.length}, acceptance criteria: ${spec.acceptance_criteria.length}, proposed files: ${spec.proposed_files.length}`,
150
+ ` test scenarios: ${spec.test_scenarios.map(scenario => scenario.title).join('; ')}`,
151
+ ...(spec.placeholders.length ? [` placeholders: ${spec.placeholders.join('; ')}`] : [])
152
+ ];
153
+ }
154
+
155
+ /**
156
+ * Why a project is not one Build a feature can work on at launch: it has to be a
157
+ * 2D Godot project. Reads the main scene's root node, which is what decides what
158
+ * a player sees. A project with no readable main scene is let through, because
159
+ * the boot check that follows answers that one properly.
160
+ *
161
+ * @param {string} projectDir
162
+ * @returns {Promise<string|null>} the refusal, or null
163
+ */
164
+ export async function notTwoD(projectDir) {
165
+ if (!await isGodotProject(projectDir)) return 'There is no Godot project here, and Build a feature works on Godot projects.';
166
+ const root = await mainSceneRoot(projectDir);
167
+ return isThreeDRoot(root)
168
+ ? `The main scene's root is a ${root}. Build a feature works on 2D Godot projects for now.`
169
+ : null;
170
+ }
171
+
172
+ export function featureBranchName(title, suffix = () => randomBytes(3).toString('hex')) {
173
+ const slug = String(title || 'feature').toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'feature';
174
+ return `ravensight/feature-${slug}-${suffix()}`;
175
+ }
176
+
177
+ /**
178
+ * The whole job: everything up to the charge refuses for free. Exported for
179
+ * `create`, whose every milestone is one of these against its own scaffold, with
180
+ * `flags.prototype` naming the plan and the whole budget confirmed up front.
181
+ */
182
+ export async function buildFeature(specId, flags, options, setup, injected, deps, log) {
183
+ return build(specId, flags, options, setup, injected, deps, log);
184
+ }
185
+
186
+ async function build(specId, flags, options, setup, injected, deps, log) {
187
+ const { context, api, gameId, apiUrl, repoRoot, cliVersion } = setup;
188
+
189
+ let loaded;
190
+ try {
191
+ loaded = await api.labs.featureSpecs.get(gameId, specId);
192
+ } catch (error) {
193
+ if (error && error.status === 404) {
194
+ log(`There is no specification ${specId} for this game. Nothing was charged.`);
195
+ return EXIT.ENVIRONMENT;
196
+ }
197
+ throw error;
198
+ }
199
+ const specRow = loaded.spec;
200
+ if (specRow.status !== 'approved') {
201
+ log(`${specRow.title} has not been approved. Read it and approve it on the Labs page in the dashboard first. Nothing was charged.`);
202
+ return EXIT.ENVIRONMENT;
203
+ }
204
+
205
+ const projectDir = path.resolve(repoRoot, flags.godotProject || context.config?.godot_project || '.');
206
+ const subdir = path.relative(repoRoot, projectDir);
207
+ if (subdir.startsWith('..')) {
208
+ log('The Godot project has to be inside this repository. Nothing was charged.');
209
+ return EXIT.ENVIRONMENT;
210
+ }
211
+ if (!await isGodotProject(projectDir)) {
212
+ log('There is no project.godot here. Pass --godot-project with the project\'s directory. Nothing was charged.');
213
+ return EXIT.ENVIRONMENT;
214
+ }
215
+ const { refusal, dimension } = await dimensionGate({ projectDir, alpha3d: Boolean(flags.alpha3d), name: 'Build a feature' });
216
+ if (refusal) {
217
+ log(`${refusal} Nothing was charged.`);
218
+ return EXIT.ENVIRONMENT;
219
+ }
220
+ // A specification was written for 2D or for 3D, and the two read differently.
221
+ if ((specRow.dimension || '2d') !== dimension) {
222
+ log(`This specification was written for a ${(specRow.dimension || '2d').toUpperCase()} game and this project is ${dimension.toUpperCase()}. Write one for this project. Nothing was charged.`);
223
+ return EXIT.ENVIRONMENT;
224
+ }
225
+ if (dimension === DIMENSION_3D) log(ALPHA_3D_LINE);
226
+
227
+ const noGodot = await godotRefusal({ driverName: 'godot_driver', godotPath: flags.godotPath, projectDir, injected: { detectGodot: injected.detectGodot, driverFactory: injected.driverFactory || injected.checkRunner || injected.playJob } });
228
+ if (noGodot) {
229
+ log(noGodot);
230
+ return EXIT.ENVIRONMENT;
231
+ }
232
+
233
+ // Free, before a price: a folder that was named has to be usable.
234
+ const assets = await prepareAssets({ repoRoot, projectDir, folder: flags.assetsDir || context.config?.assets_dir, dimension });
235
+ if (!assets.ok) {
236
+ log(`${assets.reason} Nothing was charged.`);
237
+ return EXIT.ENVIRONMENT;
238
+ }
239
+
240
+ // The base has to be a clean commit, because the branch and the diff are the
241
+ // deliverable, and a tree with other work in it would blur what this job did.
242
+ const baseCommit = await (injected.cleanCommit || cleanCommit)(repoRoot);
243
+ if (!baseCommit) {
244
+ log('This working tree has uncommitted changes. Commit or stash them first, so the branch holds only this feature. Nothing was charged.');
245
+ return EXIT.ENVIRONMENT;
246
+ }
247
+ const pendingScope = { kind: 'feature', gameId, target: `${specId}:${flags.prototype || ''}` };
248
+ const pendingRequest = await pendingRegister.load(repoRoot, pendingScope);
249
+ const branch = pendingRequest ? pendingRequest.branch : flags.branch || featureBranchName(specRow.spec.title);
250
+ const git = injected.git || createGit({ root: repoRoot });
251
+ const checked = await preflight({ git, branch, allowDirty: false, create: false, reuseBranch: Boolean(pendingRequest) });
252
+ if (!checked.ok) {
253
+ log(`${checked.reason} Nothing was charged.`);
254
+ return EXIT.ENVIRONMENT;
255
+ }
256
+ if (pendingRequest) {
257
+ log(`The last attempt at this feature got no clear answer, so this run asks about that same request on ${branch}. If it was charged, this answers with that job.`);
258
+ checked.from = pendingRequest.from;
259
+ }
260
+
261
+ const pack = await deps.packs.get({ api, gameId, modules: PACK_MODULES });
262
+ const skill = (pack.skills || []).find(entry => entry.module === 'feature');
263
+ if (!skill || !(pack.skills || []).some(entry => entry.module === 'persona_playtest')) {
264
+ log('The content pack does not have this module. Update the server and CLI together. Nothing was charged.');
265
+ return EXIT.ENVIRONMENT;
266
+ }
267
+ const brief = await deps.brief.get({ api, gameId });
268
+ if (!brief || brief.status !== 'complete') {
269
+ log('The scenarios are played by personas, which need a complete expectations brief. Run "ravensight-playtest brief" first. Nothing was charged.');
270
+ return EXIT.ENVIRONMENT;
271
+ }
272
+ const scenarioCount = specRow.spec.test_scenarios.length;
273
+ const personaSlugs = resolvePersonas({ personas: flags.personas }, context.config, pack).slice(0, scenarioCount);
274
+ const unknown = unknownPersonas(personaSlugs, pack.personas || []);
275
+ if (unknown.length > 0) {
276
+ log(`${unknownPersonaMessage(unknown, pack.personas || [])} Nothing was registered or charged.`);
277
+ return EXIT.ENVIRONMENT;
278
+ }
279
+ const personas = personaSlugs.map(slug => (pack.personas || []).find(persona => persona.slug === slug));
280
+
281
+ // Free, before a price: a project that does not start today is not one a job
282
+ // can be judged on. Extracted to a temp directory, so this checkout is untouched.
283
+ log('Checking that the project imports and starts. This costs nothing.');
284
+ const makeProbe = injected.makeDriver || (driverOptions => new GodotDriver(driverOptions));
285
+ let baseCheckout = null;
286
+ try {
287
+ baseCheckout = await (injected.checkoutRef || checkoutRef)({ repoRoot, ref: baseCommit, projectSubdir: subdir, godotPath: flags.godotPath });
288
+ const problem = await bootProblem(baseCheckout, { godotPath: flags.godotPath, makeDriver: makeProbe, headless: true });
289
+ if (problem) {
290
+ log(`This commit does not start: ${problem}`);
291
+ log('Nothing was charged.');
292
+ return EXIT.FAILED;
293
+ }
294
+ } catch (error) {
295
+ log(`${String(error && error.message)} Nothing was charged.`);
296
+ return EXIT.ENVIRONMENT;
297
+ } finally {
298
+ await baseCheckout?.cleanup();
299
+ }
300
+
301
+ // A stop that came while the free checks ran: nothing has been charged, so it stays that way.
302
+ if (flags.shouldStop && flags.shouldStop()) {
303
+ log('Stopped before anything was charged.');
304
+ return EXIT.CANCELED;
305
+ }
306
+ const featureRef = { spec_id: specId, ...(flags.prototype ? { prototype: flags.prototype } : {}) };
307
+ const estimate = await api.jobs.estimate(gameId, { modules: [...FEATURE_MODULES], personas: personaSlugs, feature: featureRef });
308
+ const priceCents = Number(estimate.estimate_cents);
309
+ if (!Number.isFinite(priceCents) || priceCents <= 0 || !Array.isArray(estimate.breakdown)) {
310
+ log('This server did not quote this job. Update the server and CLI together. Nothing was charged.');
311
+ return EXIT.ENVIRONMENT;
312
+ }
313
+ const lines = estimate.breakdown.map(line => `${line.count} x ${line.unit} at ${formatCents(line.unit_cents)}`).join(', ');
314
+ log(`${specRow.spec.title} (${specRow.size}): ${formatCents(priceCents)} for the whole plan (${lines}). Balance: ${formatCents(estimate.balance_cents)}.`);
315
+ log(`It writes at most ${maxWritesForSize(specRow.size)} files. The whole plan comes back if nothing changes or the change fails its checks, up to three times in any 30 days per account once a job has used model time; past that the feature's own price is kept, and scenario runs that did not play still come back. It is kept once a change is delivered, verified or not. The checks and the scenarios run the edited project on this machine with a reduced environment.`);
316
+ const floorLine = startBalanceLine(estimate);
317
+ if (floorLine) log(floorLine);
318
+ if (estimate.ok === false && floorLine) {
319
+ log(startBalanceShort(estimate));
320
+ return EXIT.ENVIRONMENT;
321
+ }
322
+ if (estimate.ok === false) {
323
+ log(`That is more than the balance covers, and the whole plan has to be covered. Top up at ${estimate.topup_url || 'ravensight.io'} and run this again. Nothing was charged.`);
324
+ return EXIT.ENVIRONMENT;
325
+ }
326
+ const confirm = injected.confirm || (deps.ui && deps.ui.confirm);
327
+ if (!flags.yes && confirm && !await confirm(`Build this for ${formatCents(priceCents)}?`, { yes: flags.yes })) {
328
+ log('Nothing was charged and nothing was changed.');
329
+ return EXIT.CANCELED;
330
+ }
331
+
332
+ const started = pendingRequest ? await pendingRegister.returnToBranch({ git, branch }) : await startBranch({ git, branch });
333
+ if (!started.ok) {
334
+ log(started.reason);
335
+ return EXIT.ENVIRONMENT;
336
+ }
337
+ log(`branch ${branch}, from ${checked.head.slice(0, 8)}.`);
338
+
339
+ const idempotencyKey = pendingRequest ? pendingRequest.idempotencyKey : randomUUID();
340
+ let registered;
341
+ try {
342
+ await pendingRegister.save(repoRoot, pendingScope, { idempotencyKey, branch, from: checked.from, head: checked.head });
343
+ registered = await api.jobs.register(gameId, {
344
+ modules: [...FEATURE_MODULES],
345
+ personas: personaSlugs,
346
+ feature: featureRef,
347
+ driver: 'godot_driver',
348
+ cli_version: cliVersion,
349
+ confirm_price_cents: priceCents,
350
+ brief_version: brief.version ?? null,
351
+ build: { kind: 'godot_project', ref: subdir || '.' },
352
+ repo: { commit: checked.head, dirty: false, branch },
353
+ pack_versions: { pack: pack.pack_version }
354
+ }, { idempotencyKey });
355
+ } catch (error) {
356
+ // Nobody knows whether a job exists, so the key and the branch are the way back to it.
357
+ if (pendingRegister.outcomeUnknown(error)) {
358
+ log(pendingRegister.unknownOutcomeMessage('this feature', branch));
359
+ return EXIT.ENVIRONMENT;
360
+ }
361
+ // Every other way out before a job exists has to leave the developer where they were.
362
+ await pendingRegister.clear(repoRoot, pendingScope);
363
+ await abandonBranch({ git, branch, from: checked.from, head: checked.head });
364
+ if (error && (['labs_not_enabled', 'funding_required', 'feature_spec_not_approved'].includes(error.code) || String(error.code).startsWith('prototype_'))) {
365
+ log(`${String(error.message)}${error.body?.topup_url ? ` Top up at ${error.body.topup_url}.` : ''}`);
366
+ return EXIT.ENVIRONMENT;
367
+ }
368
+ const handled = explainRegisterRefusal(error, log);
369
+ if (handled !== null) return handled;
370
+ throw error;
371
+ }
372
+
373
+ await pendingRegister.clear(repoRoot, pendingScope);
374
+
375
+ const jobId = registered.job_id;
376
+ const frozen = registered.feature;
377
+ const featureRun = (registered.runs || []).find(entry => entry.module === 'feature');
378
+ const personaRuns = (registered.runs || []).filter(entry => entry.module === 'persona_playtest');
379
+ const scenarioRuns = personaRuns.filter(entry => Number.isInteger(entry.feature_scenario));
380
+ const spareRuns = personaRuns.filter(entry => !Number.isInteger(entry.feature_scenario));
381
+
382
+ await mkdir(jobDir(repoRoot, jobId), { recursive: true });
383
+ const journal = stateFile.newState({
384
+ jobId, gameId, idempotencyKey, cliVersion,
385
+ packVersion: pack.pack_version,
386
+ briefVersion: brief.version ?? null,
387
+ modules: [...FEATURE_MODULES],
388
+ driver: 'godot_driver',
389
+ target: subdir || '.',
390
+ priceCents: registered.price && registered.price.price_cents,
391
+ options: { branch, baseCommit: checked.head, specId, labs: 'feature', uploadTrace: true, dimension }
392
+ });
393
+ for (const entry of registered.runs || []) {
394
+ journal.runs[entry.run_id] = stateFile.newRunEntry({ runId: entry.run_id, module: entry.module, persona: entry.persona });
395
+ }
396
+ await stateFile.save(repoRoot, journal);
397
+ log(`job ${jobId}: ${formatCents(registered.price && registered.price.price_cents)} charged. ${jobDir(repoRoot, jobId)}`);
398
+
399
+ const skipAll = async (runs, reason) => {
400
+ for (const entry of runs) {
401
+ try {
402
+ await api.runs.transition(gameId, jobId, entry.run_id, 'skipped', { reason });
403
+ } catch (error) {
404
+ log(`run ${entry.run_id} could not be marked skipped (${String(error && error.message)}).`);
405
+ }
406
+ }
407
+ };
408
+ const settle = async reason => {
409
+ let finished = null;
410
+ try {
411
+ finished = await api.jobs.finish(gameId, jobId, { reason });
412
+ log(`job ${jobId} ${finished.job ? finished.job.state : 'finished'}: refunded ${formatCents(finished.refunded_cents)}.`);
413
+ } catch (error) {
414
+ log(settleFailureLine(jobId, error));
415
+ }
416
+ journal.finished = true;
417
+ await stateFile.save(repoRoot, journal).catch(() => {});
418
+ return finished;
419
+ };
420
+ // WHAT CAME BACK, FROM THE SERVER'S ANSWER. A job that would be refunded may not be:
421
+ // the account's limit can keep the unit. So this reads the figure and the reason
422
+ // and never assumes either. A settle that failed already said so, and claims nothing.
423
+ const settleAndSay = async reason => {
424
+ const finished = await settle(reason);
425
+ if (!finished) return;
426
+ const cents = Number(finished.refunded_cents) || 0;
427
+ log(cents > 0
428
+ ? `${formatCents(cents)} of the ${formatCents(registered.price && registered.price.price_cents)} charged came back.`
429
+ : `Nothing came back of the ${formatCents(registered.price && registered.price.price_cents)} charged.`);
430
+ if (finished.refund_limited_note) log(finished.refund_limited_note);
431
+ const closed = await api.jobs.feature(gameId, jobId).catch(() => null);
432
+ if (closed && closed.refund_limited_note) log(closed.refund_limited_note);
433
+ };
434
+ const complete = async body => {
435
+ try {
436
+ await api.runs.complete(gameId, jobId, featureRun.run_id, body);
437
+ } catch (error) {
438
+ log(`the feature run could not be completed (${String(error && error.message)}); the hourly sweep will settle it.`);
439
+ }
440
+ };
441
+
442
+ if (!frozen || !frozen.spec || !featureRun) {
443
+ log('The server registered this job without its frozen specification, so nothing can be judged. The job is being given back. Update the server and CLI together.');
444
+ await skipAll(registered.runs || [], 'the job has no frozen specification');
445
+ await settle('the job has no frozen specification');
446
+ return EXIT.FAILED;
447
+ }
448
+ const spec = frozen.spec;
449
+
450
+ const directory = runDirFor(repoRoot, jobId, featureRun.run_id);
451
+ await mkdir(directory, { recursive: true });
452
+ const createClient = injected.createClient || createModelClient;
453
+ const client = createClient({ apiUrl, token: injected.modelToken || api.token });
454
+ const ledger = createUsageLedger();
455
+ const heartbeat = createHeartbeat({
456
+ api, gameId, jobId, runId: featureRun.run_id,
457
+ onCancel: () => log('the job was cancelled; stopping.'),
458
+ onUsage: usage => ledger.recordServerUsage(usage),
459
+ onError: () => {}
460
+ });
461
+ const inFlight = new Map();
462
+ let interrupt = createInterruptHandler({
463
+ api, gameId, jobId, repoRoot, journal, inFlight, log,
464
+ message: 'finishing the turn in flight, then stopping. A feature job cannot be resumed, so what did not happen is being given back.',
465
+ injected
466
+ });
467
+ const stopped = () => interrupt.signalled || heartbeat.canceled || Boolean(flags.shouldStop && flags.shouldStop());
468
+ const abandon = async why => {
469
+ heartbeat.stop();
470
+ inFlight.delete(featureRun.run_id);
471
+ await skipAll(personaRuns.filter(entry => !journal.runs[entry.run_id]?.completed), why);
472
+ await complete({ state: 'failed', reason: 'error' });
473
+ await settleAndSay(why);
474
+ log(`The branch ${branch} holds whatever was written. Nothing was committed or pushed.`);
475
+ };
476
+
477
+ try {
478
+ await api.runs.transition(gameId, jobId, featureRun.run_id, 'launching', { reason: '' });
479
+ await api.runs.transition(gameId, jobId, featureRun.run_id, 'playing', { reason: '' });
480
+ heartbeat.start();
481
+ inFlight.set(featureRun.run_id, { state: 'playing' });
482
+
483
+ // From the size frozen on the job: the server refuses a changeset above it, so
484
+ // a write past it has to be refused here, where the model can read why.
485
+ const maxWrites = maxWritesForSize(frozen.size);
486
+ const system = featureSystem({ skill: skill.content, jobId, branch, baseCommit: checked.head, maxWrites, dimension });
487
+ const codeBrief = pack.code_brief || null;
488
+ const { plan } = await planFeature({
489
+ client, jobId, runId: featureRun.run_id, system,
490
+ repo: assets.guardRepo(createRepoTools({ root: repoRoot, tools: PROFILE_TOOLS })),
491
+ content: featurePlanContent({ spec, codeBrief, assetLines: assets.planLines }),
492
+ ledger, log
493
+ });
494
+ if (plan.hypothesis) log(`plan: ${plan.hypothesis}`);
495
+
496
+ const maxActions = Math.min(Number.isInteger(flags.maxActions) && flags.maxActions > 0 ? flags.maxActions : MAX_FEATURE_ACTIONS, MAX_FEATURE_ACTIONS);
497
+ const checks = createCheckRunner({
498
+ projectDir,
499
+ godotPath: flags.godotPath || null,
500
+ allowTests: Boolean(flags.allowTests),
501
+ allowToolScripts: Boolean(flags.allowToolScripts),
502
+ changedFiles: async () => projectRelative(await git.diffStat(), repoRoot, projectDir),
503
+ ...(injected.checkRunner ? injected.checkRunner : {})
504
+ });
505
+ const applyRepo = assets.guardRepo(createRepoTools({ root: repoRoot, tools: WRITE_TOOLS, maxWrites }));
506
+ const assetChecks = assets.bindChecks({
507
+ checks,
508
+ changedFiles: async () => projectRelative(await git.diffStat(), repoRoot, projectDir),
509
+ godotPath: flags.godotPath || null,
510
+ allowTests: Boolean(flags.allowTests),
511
+ allowToolScripts: Boolean(flags.allowToolScripts),
512
+ ...(injected.checkRunner ? injected.checkRunner : {})
513
+ });
514
+ const applyArgs = {
515
+ client, jobId, runId: featureRun.run_id, repoRoot, system, plan, codeBrief, ledger, log, spec,
516
+ assetInstructions: assets.instructions,
517
+ dimension,
518
+ onProgress: progress => {
519
+ if (flags.verbose) log(` ${progress.tool} ${progress.target || ''} (${progress.resultChars} chars${progress.isError ? ', error' : ''}${progress.repeat ? ', repeat' : ''})`);
520
+ appendFile(path.join(directory, 'transcript.jsonl'), `${JSON.stringify({ at: new Date().toISOString(), ...progress })}\n`).catch(() => {});
521
+ },
522
+ shouldStop: stopped
523
+ };
524
+
525
+ let applied = await applyFeature({ ...applyArgs, maxActions, repo: assetChecks.withChecks(withRunCheck(applyRepo, checks)) });
526
+ if (stopped() || applied.stop === 'interrupted') {
527
+ await abandon('the feature job was interrupted');
528
+ return EXIT.CANCELED;
529
+ }
530
+
531
+ // NOTHING CHANGED: there is nothing to review, so the whole unit comes back.
532
+ const changed = await git.diffStat();
533
+ if (changed.length === 0) {
534
+ heartbeat.stop();
535
+ inFlight.delete(featureRun.run_id);
536
+ log(`Nothing was changed, so there is nothing to review. The branch ${branch} is empty; delete it or stay on it.`);
537
+ await skipAll(personaRuns, 'nothing was changed');
538
+ await complete({ state: 'failed', reason: 'no_change' });
539
+ await settleAndSay('nothing was changed');
540
+ return EXIT.OK;
541
+ }
542
+
543
+ // Checks after every apply turn set. A failed check starts a repair; passing
544
+ // checks start the scenarios; a failed scenario starts a repair while a spare
545
+ // run is left to judge it on. At most MAX_REPAIRS of either, and nothing here
546
+ // can change what the spec says.
547
+ let repairs = 0;
548
+ let checksBlock = false;
549
+ const pendingScenarios = new Map(scenarioRuns.map(entry => [entry.feature_scenario, entry]));
550
+ const spares = [...spareRuns];
551
+ const staleNotes = [];
552
+ const staleScenarios = new Set();
553
+ let played = false;
554
+ let report = null;
555
+ const runGoAhead = () => repairs < MAX_REPAIRS && applied.stop !== 'budget' && !stopped();
556
+
557
+ for (;;) {
558
+ const gate = await assetChecks.gate(await runGateChecks(checks, log), log);
559
+ // A script that does not parse or a project that does not import is not a
560
+ // change anyone can review, so it blocks the upload. A failing project test
561
+ // is a repair while one is left, and after that a risk the report names:
562
+ // the scenarios still get played.
563
+ checksBlock = gate.importFailed || gate.assetsBlock || gate.failed.some(result => result.name === 'godot_script_check');
564
+ let repairReason = null;
565
+ let repairDetail = '';
566
+ if (gate.failed.length > 0) {
567
+ repairReason = gate.failed.some(result => result.status === 'check_refused')
568
+ ? 'the checks were refused because of what the change edited'
569
+ : `the ${gate.failed.map(result => result.name).join(' and ')} check${gate.failed.length > 1 ? 's' : ''} failed`;
570
+ repairDetail = gate.failed.map(checkResultText).join('\n\n');
571
+ }
572
+ if (!(repairReason && runGoAhead()) && !checksBlock) {
573
+ // Scenarios that passed before this repair: their trace replays free
574
+ // against the new code. A full match means the answer still stands;
575
+ // anything else and it cannot be claimed for this code.
576
+ if (played) {
577
+ const stale = await rejudgePassed({ report, api, gameId, jobId, repoRoot, projectDir, godotPath: flags.godotPath, replayer: injected.replayer || replayTrace, log });
578
+ for (const item of stale) {
579
+ staleScenarios.add(item.index);
580
+ staleNotes.push(`Scenario "${item.title}" passed before a later repair, and its recorded inputs no longer replay the same on the final code, so it does not count as passed.`);
581
+ }
582
+ }
583
+ const toPlay = [];
584
+ if (!played) {
585
+ toPlay.push(...scenarioRuns.map(entry => ({ entry, index: entry.feature_scenario, spare: false })));
586
+ } else {
587
+ for (const answer of report.scenarios.filter(item => !item.passed && item.played)) {
588
+ const entry = spares.shift();
589
+ if (!entry) break;
590
+ toPlay.push({ entry, index: answer.index, spare: true });
591
+ }
592
+ }
593
+ if (toPlay.length > 0) {
594
+ // playJob owns the signal while it plays; ours comes back after it.
595
+ interrupt.release();
596
+ const result = await playScenarios({
597
+ toPlay, deps, api, apiUrl, gameId, jobId, repoRoot, pack, personas, projectDir, options, journal, spec, brief,
598
+ commit: checked.head, config: context.config, injected, log
599
+ });
600
+ interrupt = createInterruptHandler({
601
+ api, gameId, jobId, repoRoot, journal, inFlight, log,
602
+ message: 'finishing the turn in flight, then stopping. A feature job cannot be resumed, so what did not happen is being given back.',
603
+ injected
604
+ });
605
+ for (const { index } of toPlay) { pendingScenarios.delete(index); staleScenarios.delete(index); }
606
+ played = true;
607
+ if (result === EXIT.CANCELED) {
608
+ await abandon('the feature job was interrupted');
609
+ return EXIT.CANCELED;
610
+ }
611
+ }
612
+ report = await api.jobs.feature(gameId, jobId);
613
+ const failed = report.scenarios.filter(item => item.played && !item.passed);
614
+ repairReason = null;
615
+ if (failed.length > 0 && spares.length > 0) {
616
+ repairReason = `${failed.length} test scenario${failed.length > 1 ? 's' : ''} did not pass`;
617
+ repairDetail = await scenarioFailureText({ failed, repoRoot, jobId });
618
+ }
619
+ }
620
+ if (!repairReason || !runGoAhead()) break;
621
+ repairs += 1;
622
+ log(`repair ${repairs} of ${MAX_REPAIRS}: ${repairReason}.`);
623
+ applied = await applyFeature({
624
+ ...applyArgs,
625
+ maxActions: Math.min(REPAIR_MAX_ACTIONS, maxActions),
626
+ repair: { attempt: repairs, of: MAX_REPAIRS, reason: repairReason, detail: repairDetail },
627
+ repo: assetChecks.withChecks(withRunCheck(applyRepo, checks))
628
+ });
629
+ if (stopped()) break;
630
+ }
631
+
632
+ if (stopped()) {
633
+ await abandon('the feature job was interrupted during a repair');
634
+ return EXIT.CANCELED;
635
+ }
636
+ await skipAll(spares, 'no repair needed it');
637
+ for (const entry of pendingScenarios.values()) await skipAll([entry], checksBlock ? 'the change did not pass its checks' : 'not played');
638
+
639
+ const finalChanged = await git.diffStat();
640
+ const files = finalChanged.slice(0, maxWrites);
641
+ const placeholders = assets.placeholders(plan, spec);
642
+ const assetReport = await assets.report({ runDir: directory, changed: projectRelative(finalChanged, repoRoot, projectDir), placeholders, log });
643
+ const events = await eventsInChange({ spec, files, repoRoot });
644
+ const budgetTouched = applied.stop === 'budget' || applied.stop === 'budget_warning';
645
+
646
+ if (checksBlock) {
647
+ heartbeat.stop();
648
+ inFlight.delete(featureRun.run_id);
649
+ const body = featureEvidence({
650
+ prBody: `# ${spec.title}: not uploaded\n\nThis change does not pass its checks.`,
651
+ summarySource: 'local', spec, report: null, files, checks: checks.results, repairs, maxRepairs: MAX_REPAIRS, placeholders, events, staleNotes, budgetTouched, assetLines: assetReport.reportLines
652
+ });
653
+ await writeFile(path.join(directory, RUN_FILES.reportMd), body, 'utf8').catch(() => {});
654
+ log(`This change does not pass its checks 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.`);
655
+ await complete({ state: 'failed', reason: 'check_failed' });
656
+ await settleAndSay('the change did not pass its checks');
657
+ return EXIT.FAILED;
658
+ }
659
+
660
+ report = await api.jobs.feature(gameId, jobId).catch(() => report);
661
+ const fallback = localFeatureSummary({ spec, plan, files, branch });
662
+ const summary = applied.stop === 'budget'
663
+ ? { ...fallback, source: 'local' }
664
+ : await summariseFeature({ client, jobId, runId: featureRun.run_id, system, spec, plan, files, branch, fallback, ledger, log });
665
+ const finalReport = withStale(report, staleScenarios);
666
+ const prBody = withEvidenceIntact(description => featureEvidence({
667
+ prBody: description, summarySource: summary.source, spec, report: finalReport, files, checks: checks.results,
668
+ repairs, maxRepairs: MAX_REPAIRS, placeholders, events, staleNotes, budgetTouched, assetLines: assetReport.reportLines
669
+ }), summary.prBody);
670
+ const changeset = withoutQuotedSecrets({
671
+ schema_version: '1.0',
672
+ spec_id: specId,
673
+ branch,
674
+ base_commit: checked.head,
675
+ files,
676
+ checks: checks.results.slice(-60).map(result => ({ name: result.name, status: result.status })),
677
+ repairs,
678
+ placeholders,
679
+ ...(staleScenarios.size ? { stale_scenarios: [...staleScenarios] } : {}),
680
+ pr_title: summary.prTitle,
681
+ pr_body: prBody
682
+ }, log);
683
+ const reportPath = await writeChangeset(directory, changeset);
684
+
685
+ await heartbeat.beat();
686
+ heartbeat.stop();
687
+ inFlight.delete(featureRun.run_id);
688
+ await writeUsageFile(directory, ledger);
689
+ await api.runs.transition(gameId, jobId, featureRun.run_id, 'reporting', { reason: '' });
690
+ await api.runs.transition(gameId, jobId, featureRun.run_id, 'uploading', { reason: '' });
691
+ let accepted = true;
692
+ try {
693
+ await deps.finalizeRun(directory, {
694
+ api, gameId, jobId, runId: featureRun.run_id,
695
+ allowedPaths: [CHANGESET_FILE, RUN_FILES.reportMd, RUN_FILES.usageJson],
696
+ body: { state: 'succeeded', changeset }
697
+ });
698
+ await stateFile.patchRun(repoRoot, journal, featureRun.run_id, { state: 'succeeded', uploaded: true, completed: true, report_written: true });
699
+ } catch (error) {
700
+ accepted = false;
701
+ log(`The changeset could not be uploaded (${String(error && error.message)}). It is in ${directory} and "ravensight-playtest upload ${jobId}" can retry it.`);
702
+ for (const detail of changesetDetails(error)) log(` ${detail}`);
703
+ }
704
+ await settle('');
705
+
706
+ const closed = await api.jobs.feature(gameId, jobId).catch(() => finalReport);
707
+ log(`branch ${branch}`);
708
+ for (const file of files) log(` ${file.path} +${file.additions} -${file.deletions}`);
709
+ if (finalChanged.length > files.length) log(`${finalChanged.length} files changed and a ${frozen.size} feature's changeset carries at most ${maxWrites}. The branch has all of them.`);
710
+ if (closed) log(closed.verification_note);
711
+ if (closed && closed.refund_limited_note) log(closed.refund_limited_note);
712
+ for (const item of placeholders) log(`placeholder: ${item}`);
713
+ if (budgetTouched) log('The job stopped early because its model budget was spent or nearly spent, so read the diff carefully: it may be half a feature.');
714
+
715
+ let opened = null;
716
+ if (flags.openPr) {
717
+ if (!accepted || !closed || closed.verification.status !== 'verified') {
718
+ log(`--open-pr opens a pull request only for a verified feature, and this one reads ${closed ? closed.verification.status.replace('_', ' ') : 'unknown'}. Nothing was pushed.`);
719
+ opened = { ok: false };
720
+ } else {
721
+ const opener = injected.openPullRequest || openPullRequest;
722
+ opened = await opener({
723
+ repoRoot, branch, paths: finalChanged.map(file => file.path).filter(file => applyRepo.written.includes(file)), title: changeset.pr_title, bodyFile: reportPath,
724
+ confirm: injected.confirmPr || injected.confirm || (deps.ui && deps.ui.confirm), log
725
+ });
726
+ if (opened.ok) await recordPrOpened({ api, gameId, repoRoot, journal, url: opened.url });
727
+ }
728
+ }
729
+ if ((!opened || !opened.ok) && !flags.prototype) {
730
+ log('Nothing was committed and nothing was pushed. Read the diff, commit it, then:');
731
+ log(` gh pr create --title ${shellQuote(changeset.pr_title)} --body-file ${shellQuote(reportPath)}`);
732
+ }
733
+ if (flags.json) (deps.ui && deps.ui.json ? deps.ui.json : value => console.log(JSON.stringify(value)))({ job_id: jobId, ...closed });
734
+ if (applied.stop === 'budget') return EXIT.BUDGET;
735
+ return opened && !opened.ok ? EXIT.ENVIRONMENT : EXIT.OK;
736
+ } catch (error) {
737
+ heartbeat.stop();
738
+ const code = exitCodeFor(error);
739
+ log(`the feature job failed: ${String(error && error.message)}`);
740
+ await abandon('the feature job failed');
741
+ return code;
742
+ } finally {
743
+ interrupt.release();
744
+ }
745
+ }
746
+
747
+ /** One persona run per scenario, against this working tree, on the seams validate uses. */
748
+ async function playScenarios({ toPlay, deps, api, apiUrl, gameId, jobId, repoRoot, pack, personas, projectDir, options, journal, spec, brief, commit, config, injected, log }) {
749
+ const controls = await inputControls(projectDir);
750
+ const runs = toPlay.map(({ entry, index, spare }) => ({
751
+ ...entry,
752
+ target: projectDir,
753
+ brief: scenarioBrief(brief, spec.test_scenarios[index], spec, controls),
754
+ // The same seed for a scenario however many times it is played, so a re-play
755
+ // after a repair differs only by what the repair changed.
756
+ seed: seedFrom(`${jobId}:${index}`),
757
+ trace_header: { commit, side: 'head', pair: index },
758
+ ...(spare ? { report_scenario: index } : {})
759
+ }));
760
+ for (const { index } of toPlay) log(`scenario: ${spec.test_scenarios[index].title}`);
761
+ let localBrief = {};
762
+ try {
763
+ localBrief = await loadLocalBrief({ repoRoot });
764
+ } catch (error) {
765
+ log(`${String(error && error.message)} The scenarios go ahead without expected_noise and test_account.`);
766
+ }
767
+ return (injected.playJob || playJob)({
768
+ deps, api, apiUrl, gameId, jobId, repoRoot, runs, pack, personas,
769
+ driverName: 'godot_driver', target: projectDir, options, journal, config, brief, localBrief, injected, log, finish: false
770
+ });
771
+ }
772
+
773
+ /**
774
+ * Re-judge scenarios that passed before a repair, for free, by replaying their
775
+ * traces on the code as it now stands. Answers those that can no longer be
776
+ * claimed. Never fails the job: no trace or a replay that breaks reads as stale.
777
+ *
778
+ * @returns {Promise<Array<{index: number, title: string}>>}
779
+ */
780
+ export async function rejudgePassed({ report, api, gameId, jobId, repoRoot, projectDir, godotPath, replayer = replayTrace, log = () => {} }) {
781
+ const stale = [];
782
+ const sha = await headSha(repoRoot);
783
+ for (const answer of (report ? report.scenarios : []).filter(item => item.passed && item.run_id)) {
784
+ const directory = runDirFor(repoRoot, jobId, answer.run_id);
785
+ let result = null;
786
+ try {
787
+ const text = await readFile(path.join(directory, 'trace.jsonl'), 'utf8');
788
+ result = await replayer({ text, projectDir, godotPath, runDir: path.join(directory, 'trace-replay') });
789
+ log(`scenario "${answer.title}": ${replaySentence(result)}`);
790
+ if (sha) await api.runs.recordReplay(gameId, jobId, answer.run_id, replayReport(result, { ref: 'feature-branch', sha })).catch(() => {});
791
+ } catch (error) {
792
+ log(`scenario "${answer.title}": its trace could not be replayed (${String(error && error.message)}).`);
793
+ }
794
+ if (!result || result.matched !== true || result.fully_replayed !== true) stale.push({ index: answer.index, title: answer.title });
795
+ }
796
+ return stale;
797
+ }
798
+
799
+ async function headSha(repoRoot) {
800
+ try {
801
+ const { stdout } = await execFileAsync('git', ['-C', repoRoot, 'rev-parse', 'HEAD']);
802
+ return stdout.trim();
803
+ } catch {
804
+ return null;
805
+ }
806
+ }
807
+
808
+ /** The repair's failure text: which scenarios, why, and what the persona filed. */
809
+ export async function scenarioFailureText({ failed, repoRoot, jobId }) {
810
+ const parts = [];
811
+ for (const answer of failed) {
812
+ const lines = [`Scenario "${answer.title}" did not pass: ${answer.reason}. Expected: ${answer.expected}`];
813
+ try {
814
+ const body = JSON.parse(await readFile(path.join(runDirFor(repoRoot, jobId, answer.run_id), RUN_FILES.reportJson), 'utf8'));
815
+ for (const finding of (body.findings || []).slice(0, 5)) lines.push(`- ${finding.severity}: ${finding.title}. ${String(finding.description || '').slice(0, 400)}`);
816
+ } catch {
817
+ // No report on disk: the reason above is what there is.
818
+ }
819
+ parts.push(lines.join('\n'));
820
+ }
821
+ return parts.join('\n\n');
822
+ }
823
+
824
+ /** The report with scenarios this command could not stand behind marked as such. */
825
+ function withStale(report, stale) {
826
+ if (!report || stale.size === 0) return report;
827
+ const scenarios = report.scenarios.map(item => (stale.has(item.index) ? { ...item, passed: false, reason: 'passed before a later repair, and its trace no longer replays the same' } : item));
828
+ const passed = scenarios.filter(item => item.passed).length;
829
+ const status = passed === scenarios.length ? 'verified' : (passed > 0 ? 'partly_verified' : 'not_verified');
830
+ return { ...report, scenarios, verification: { status, passed, total: scenarios.length } };
831
+ }
832
+
833
+ /** `changeset.json` and `report.md`, scanned first, as a fix's are. */
834
+ export async function writeChangeset(directory, changeset) {
835
+ const json = `${JSON.stringify(changeset, null, 2)}\n`;
836
+ const markdown = `${String(changeset.pr_body || '').trimEnd()}\n`;
837
+ assertNoSecrets(CHANGESET_FILE, json);
838
+ assertNoSecrets(RUN_FILES.reportMd, markdown);
839
+ await writeFile(path.join(directory, CHANGESET_FILE), json, 'utf8');
840
+ const reportPath = path.join(directory, RUN_FILES.reportMd);
841
+ await writeFile(reportPath, markdown, 'utf8');
842
+ return reportPath;
843
+ }
844
+
845
+ export default featureCommand;