ravensight-playtest 0.6.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,9 +1,47 @@
1
1
  # ravensight-playtest
2
2
 
3
- Hosted comparisons use a separate command from the local `run`:
3
+ Hosted playtests run your uploaded build on our machines instead of yours. One
4
+ persona plays it twice, once with Standard and once with Jev, and the dashboard
5
+ gets a report for each and a comparison. They use a separate command from the
6
+ local `run`:
4
7
 
5
8
  ```sh
6
- ravensight-playtest hosted quote --game GAME --build BUILD --profile PROFILE --persona curious-kid --video --transcript --output hosted-quote.json
9
+ ravensight-playtest hosted run --wait
10
+ ravensight-playtest hosted status ha_...
11
+ ravensight-playtest hosted cancel ha_...
12
+ ```
13
+
14
+ `hosted run` uses the game this directory is linked to (or `--game`), picks the
15
+ newest build that passed its static archive check on a ready profile (or the one
16
+ named by `--build`), asks the server for a quote and prints it: each unit and
17
+ its price, the hosted compute for each mode, the total and what is due now from
18
+ prepaid funds, the expiry, and the statements you accept with it (the refund
19
+ rules for a failed build and for cancellation, no network access for the game,
20
+ the separate validation hold). It asks before it charges. `--yes` answers for
21
+ you, a run with no terminal and no `--yes` is a no, and `--json` needs `--yes`.
22
+ A balance that does not meet the quote's funding requirement stops before the
23
+ question, and nothing is charged.
24
+
25
+ | Flag | Effect |
26
+ |---|---|
27
+ | `--build <id>` | Use this build instead of the newest ready one. |
28
+ | `--persona <slug>` | The persona for both runs. Default `curious-kid`. |
29
+ | `--video`, `--transcript` | Save video or transcripts of both sessions in their reports. Priced in the quote. |
30
+ | `--wait` | Stay until the run finishes, printing each phase change, then each mode's outcome. |
31
+ | `--game <id>` | Override the linked game. |
32
+
33
+ The phases are the dashboard's: queued, provisioning, running, capturing,
34
+ analyzing, then report ready, failed or canceled. `hosted status <id>` prints
35
+ the current one (`--wait` to follow it) with each mode's state and findings and
36
+ the dashboard link to the report. With `--wait`, a failed run exits 1 and a
37
+ canceled one exits 10. `hosted cancel <id>` asks first, then requests
38
+ cancellation: work already done follows the accepted policy and unstarted work
39
+ is refunded during settlement.
40
+
41
+ To review a quote before paying, save it and confirm it later:
42
+
43
+ ```sh
44
+ ravensight-playtest hosted quote --game GAME --build BUILD --profile PROFILE --persona curious-kid --video --output hosted-quote.json
7
45
  ravensight-playtest hosted run hosted-quote.json
8
46
  ```
9
47
 
@@ -11,6 +49,7 @@ The quote names the full Standard then Jev price, prepaid amount, evidence
11
49
  options, expiry and refund rules. It charges nothing. `hosted run` confirms
12
50
  that saved quote before one admission request. Its durable journal blocks an
13
51
  automatic second POST after an uncertain outcome; preserve it for reconciliation.
52
+ Both forms of `hosted run` keep that journal under `.ravensight/hosted-runs/`.
14
53
  A charged-blocked acknowledgement does not prove gameplay or a report. This
15
54
  command does not top up funds, change local run/report files or ask for model
16
55
  proxy permission on the CLI credential.
@@ -623,6 +662,14 @@ ravensight-playtest fix fnd_7g3k --no-verify
623
662
  The target is either `<jobId>/<label>`, where the label is the one the report
624
663
  gives the finding, or a bare finding id.
625
664
 
665
+ It can also be a punch list or blue tape item, `li_...`, when the item came
666
+ from a finding or a fresh-eyes finding.
667
+ The fix then runs exactly as it does for that finding, at the same price. After
668
+ the job registers, the job id is recorded on the item. The item stays open
669
+ until you close it. An item that did not come from a finding is refused before
670
+ anything is charged; `feature` is the tool for that item. See
671
+ [Punch list and blue tape](#punch-list-and-blue-tape-labs-beta).
672
+
626
673
  | Flag | Effect |
627
674
  |---|---|
628
675
  | `--game <gameId>` | The game, when this repo is not linked. |
@@ -1209,6 +1256,68 @@ During a feature job the model reads a list of those measurements, never the fil
1209
1256
 
1210
1257
  The report says what was wired in, what the project uses, what is unused and which placeholders are left for you to supply. The names are in `assets-report.md` in the run directory, which stays on your machine.
1211
1258
 
1259
+ ## Punch list and blue tape (Labs beta)
1260
+
1261
+ Adding, closing, promoting and drafting need Ravensight Labs on for the game.
1262
+ The list commands still read when Labs is off; they show an empty list then.
1263
+ The dashboard shows the same two lists.
1264
+
1265
+ The punch list holds what has to be finished before handover. You close the
1266
+ items one at a time. Blue tape holds small annoyances a new player meets in the
1267
+ first minutes. Capture them fast, then promote the ones that matter to the
1268
+ punch list.
1269
+
1270
+ ```sh
1271
+ ravensight-playtest punch list # open items; --status open|done|dropped|all
1272
+ ravensight-playtest punch add "Make the jump button respond on the first press."
1273
+ ravensight-playtest punch done li_9f2c
1274
+ ravensight-playtest punch drop li_9f2c
1275
+ ravensight-playtest punch draft # one AI unit; prints proposals, saves nothing
1276
+ ravensight-playtest punch draft --accept # one AI unit; adds every proposal
1277
+
1278
+ ravensight-playtest tape "The title music is too loud."
1279
+ ravensight-playtest tape list
1280
+ ravensight-playtest tape promote li_4a1b # moves it to the punch list
1281
+ ravensight-playtest tape drop li_4a1b
1282
+
1283
+ ravensight-playtest fix li_9f2c # an item that came from a finding
1284
+ ```
1285
+
1286
+ Reading, adding, closing and promoting are free. `punch draft` proposes items
1287
+ from the game's findings and specifications. It costs one AI unit, and nothing
1288
+ when there is nothing to propose. Without `--accept` it saves nothing. It asks
1289
+ before it spends, so pass `--yes` in a script. A list holds at most 500 open
1290
+ items.
1291
+
1292
+ Each added item gets a free plain-language check, printed as hints under it:
1293
+ long sentences, two instructions in one sentence, passive voice, and words with
1294
+ a simpler choice. The hints follow the rules of Simplified Technical English.
1295
+ They never block an item.
1296
+
1297
+ No command here closes an item for you, including `fix li_...`.
1298
+
1299
+ | Flag | Effect |
1300
+ |---|---|
1301
+ | `--game <gameId>` | The game, when this repo is not linked. |
1302
+ | `--status <status>` | `list` only: `open` (the default), `done`, `dropped` or `all`. |
1303
+ | `--accept` | `punch draft` only: add every proposal to the punch list. |
1304
+ | `--json` | Print the server's answer as JSON. |
1305
+
1306
+ ### Fresh eyes
1307
+
1308
+ A fresh-eyes playtest is a persona run with the `fresh-eyes` persona: a player
1309
+ in their first session who reports only small friction. When the job finishes,
1310
+ each of its findings becomes an open blue tape item.
1311
+
1312
+ ```sh
1313
+ ravensight-playtest personas # check that fresh-eyes is listed for your game
1314
+ ravensight-playtest run --personas fresh-eyes
1315
+ ```
1316
+
1317
+ It is priced as any persona run is. Personas come from the content pack the
1318
+ server sends, so `fresh-eyes` works once the server lists it. No CLI upgrade is
1319
+ needed for it.
1320
+
1212
1321
  ## 3D proof of concept (Labs, Alpha)
1213
1322
 
1214
1323
  Labs beta is Godot 2D. There is one 3D template, for trying Build a feature and Validate changes on a 3D project. That is the whole of the Alpha: `create` does not make a 3D game from a brief and refuses to try, and how well a real model builds 3D has not been measured. It has no switch of its own, needing the same Labs opt-in and capability switches, and no price of its own: the jobs cost what they cost on a 2D project.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ravensight-playtest",
3
- "version": "0.6.0",
3
+ "version": "0.7.2",
4
4
  "description": "Local AI playtests and seeded game simulations with reports in Ravensight.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://ravensight.io/docs/#playtest",
package/src/api/client.js CHANGED
@@ -14,6 +14,15 @@ function gameBase(gameId) {
14
14
  return `/api/v1/games/${encodeURIComponent(gameId)}/playtest`;
15
15
  }
16
16
 
17
+ /** `/api/v1/games/:gameId/labs/lists/:list`, for the punch list and blue tape. */
18
+ function labsListBase(gameId, list) {
19
+ if (typeof gameId !== 'string' || gameId.length === 0) {
20
+ throw new TypeError('gameId is required');
21
+ }
22
+ if (list !== 'punch' && list !== 'tape') throw new TypeError('list must be punch or tape');
23
+ return `/api/v1/games/${encodeURIComponent(gameId)}/labs/lists/${list}`;
24
+ }
25
+
17
26
  /**
18
27
  * The Ravensight playtest API, as one object with one namespace per surface.
19
28
  *
@@ -407,6 +416,25 @@ export function createClient(options = {}) {
407
416
  * free. Editing and approving are the dashboard's, on purpose: this token
408
417
  * cannot do either.
409
418
  */
419
+ /**
420
+ * The punch list and blue tape. `list` is `punch` or `tape`. Reads and
421
+ * plain writes are free; `draft` is one AI unit and saves nothing.
422
+ */
423
+ lists: {
424
+ list: (gameId, list, { status } = {}) =>
425
+ get(labsListBase(gameId, list), { query: status ? { status } : undefined }),
426
+ get: (gameId, list, itemId) =>
427
+ get(`${labsListBase(gameId, list)}/items/${encodeURIComponent(itemId)}`),
428
+ add: (gameId, list, body) =>
429
+ request('POST', labsListBase(gameId, list), { body }).then(answer => answer.body),
430
+ update: (gameId, list, itemId, body) =>
431
+ request('PATCH', `${labsListBase(gameId, list)}/items/${encodeURIComponent(itemId)}`, { body }).then(answer => answer.body),
432
+ promote: (gameId, itemId) =>
433
+ request('POST', `${labsListBase(gameId, 'tape')}/items/${encodeURIComponent(itemId)}/promote`, { body: {} }).then(answer => answer.body),
434
+ draft: gameId =>
435
+ request('POST', `${labsListBase(gameId, 'punch')}/draft`, { body: {} }).then(answer => answer.body)
436
+ },
437
+
410
438
  featureSpecs: {
411
439
  create: (gameId, brief, extra = {}) =>
412
440
  request('POST', `/api/v1/games/${encodeURIComponent(gameId)}/labs/feature-specs`, { body: { brief, ...extra } }).then(answer => answer.body),
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The scopes a CLI token carries, checked before anything is charged.
3
+ *
4
+ * A token's scopes are fixed when it is approved. A persona run needs
5
+ * `playtest:model` to play and `playtest:upload` to hand in its report, and
6
+ * the server only checks each one when the run reaches it: in October a token
7
+ * without `playtest:upload` registered a paid job, played it, and was refused
8
+ * at the upload, so the job came back with no report. `whoami` answers with
9
+ * the token's scopes, so `run` asks first and refuses locally.
10
+ */
11
+
12
+ /** What a persona run (`run`, and `fix`'s verify replay) needs. */
13
+ export const RUN_SCOPES = Object.freeze(['playtest:write', 'playtest:model', 'playtest:upload']);
14
+
15
+ /**
16
+ * The required scopes a token lacks, or null when its scopes are not known
17
+ * (an older server's `whoami` does not list them).
18
+ *
19
+ * @param {unknown} scopes the `scopes` field of a whoami answer
20
+ * @param {ReadonlyArray<string>} [required]
21
+ * @returns {Array<string>|null}
22
+ */
23
+ export function missingScopes(scopes, required = RUN_SCOPES) {
24
+ if (!Array.isArray(scopes)) return null;
25
+ return required.filter(scope => !scopes.includes(scope));
26
+ }
27
+
28
+ /**
29
+ * The refusal for a token that lacks what a run needs.
30
+ *
31
+ * @param {Array<string>} missing
32
+ * @returns {string}
33
+ */
34
+ export function missingScopeMessage(missing) {
35
+ return `This login cannot run a playtest: its token does not have ${missing.join(', ')}. Log in again with "ravensight-playtest login" and approve the full set of playtest permissions. Nothing was registered or charged.`;
36
+ }
package/src/cli.js CHANGED
@@ -153,7 +153,7 @@ function withoutFirst(values, value) {
153
153
  export function buildProgram() {
154
154
  const program = new Command();
155
155
  const slot = { code: ExitCode.OK };
156
- const hosted = common(program.command('hosted').description('quote and admit a server-hosted Standard/Jev comparison'));
156
+ const hosted = common(program.command('hosted').description('run a server-hosted Standard and Jev playtest: quote, run, status and cancel'));
157
157
  hosted.command('quote')
158
158
  .description('save a full hosted quote without charging or admitting a run')
159
159
  .requiredOption('--build <id>', 'an existing validated hosted build')
@@ -163,18 +163,43 @@ export function buildProgram() {
163
163
  .option('--game <id>', 'game id, overriding this repo’s configuration')
164
164
  .addOption(new Option('--standard-provider <name>', 'explicit Standard provider; unavailable choices are refused').choices(['anthropic', 'openai']))
165
165
  .option('--video', 'include hosted gameplay video in this quote')
166
- .option('--transcript', 'include the hosted transcript in this quote')
167
166
  .action(capture(slot, async (options, command) => {
168
167
  const { hostedQuote } = await import('./commands/hosted.js');
169
168
  return hostedQuote(flagsFrom(command.optsWithGlobals(), { build: options.build, profile: options.profile,
170
169
  persona: options.persona, output: options.output, game: options.game, standardProvider: options.standardProvider,
171
- video: options.video, transcript: options.transcript }));
170
+ video: options.video }));
172
171
  }));
173
- hosted.command('run <quote-file>')
174
- .description('confirm the full saved quote and request one hosted admission; never retry an unknown outcome')
175
- .action(capture(slot, async (filename, _options, command) => {
176
- const { hostedRun } = await import('./commands/hosted.js');
177
- return hostedRun(filename, flagsFrom(command.optsWithGlobals()));
172
+ common(hosted.command('run [quote-file]'))
173
+ .description('quote the newest ready build (or --build), confirm the price, and start one hosted Standard and Jev run; with a saved quote file, confirm that quote instead. Never retries an unknown outcome')
174
+ .option('--build <id>', 'a build that passed its static archive check (default: the newest one)')
175
+ .option('--persona <slug>', 'the persona that plays both runs (default: curious-kid)')
176
+ .option('--game <id>', 'game id, overriding this repo’s configuration')
177
+ .option('--video', 'save video of both game sessions in their reports')
178
+ .option('--wait', 'stay until the report is ready, printing each phase change')
179
+ .action(capture(slot, async (filename, options, command) => {
180
+ const { hostedRun, hostedRunDirect } = await import('./commands/hosted.js');
181
+ if (filename) {
182
+ const extra = ['build', 'persona', 'game', 'video', 'wait'].filter(name => options[name] !== undefined);
183
+ if (extra.length) throw new CliError(`A saved quote already fixes its build, persona and options. Drop --${extra.join(', --')}, or run "hosted run" without a quote file.`);
184
+ return hostedRun(filename, flagsFrom(command.optsWithGlobals()));
185
+ }
186
+ return hostedRunDirect(flagsFrom(command.optsWithGlobals(), { build: options.build, persona: options.persona, game: options.game,
187
+ video: options.video, wait: options.wait }));
188
+ }));
189
+ common(hosted.command('status <run-id>'))
190
+ .description('show the phase of one hosted run (queued, provisioning, running, capturing, analyzing, report ready, failed or canceled), each mode and the report link')
191
+ .option('--game <id>', 'game id, overriding this repo’s configuration')
192
+ .option('--wait', 'stay until the run finishes, printing each phase change')
193
+ .action(capture(slot, async (runId, options, command) => {
194
+ const { hostedStatus } = await import('./commands/hosted.js');
195
+ return hostedStatus(runId, flagsFrom(command.optsWithGlobals(), { game: options.game, wait: options.wait }));
196
+ }));
197
+ common(hosted.command('cancel <run-id>'))
198
+ .description('request cancellation of one hosted run; unstarted work is refunded during settlement')
199
+ .option('--game <id>', 'game id, overriding this repo’s configuration')
200
+ .action(capture(slot, async (runId, options, command) => {
201
+ const { hostedCancel } = await import('./commands/hosted.js');
202
+ return hostedCancel(runId, flagsFrom(command.optsWithGlobals(), { game: options.game }));
178
203
  }));
179
204
  const build = common(program.command('build').description('check or upload a self-contained hosted game archive'));
180
205
  build.command('preflight <archive>')
@@ -537,6 +562,74 @@ export function buildProgram() {
537
562
  }));
538
563
  }));
539
564
 
565
+ // Labs lists. Each subcommand takes the shared flags itself, so `--json` and
566
+ // `--game` work after the subcommand name, where people type them.
567
+ const listFlags = command => common(command).option('--game <gameId>', 'the game, when this repo is not linked');
568
+ const statusOption = () => new Option('--status <status>', 'which items to show').choices(['open', 'done', 'dropped', 'all']).default('open');
569
+
570
+ const punch = common(program.command('punch')
571
+ .description('Labs beta: the punch list, what has to be finished before handover, closed one item at a time'));
572
+ listFlags(punch.command('list').description('show the punch list; free'))
573
+ .addOption(statusOption())
574
+ .action(capture(slot, async options => {
575
+ const { punchList } = await import('./commands/punch.js');
576
+ return punchList(flagsFrom(options, { game: options.game, status: options.status }));
577
+ }));
578
+ listFlags(punch.command('add').description('add one item; free, with plain-language hints'))
579
+ .argument('<text>', 'the item, in quotes')
580
+ .action(capture(slot, async (text, options) => {
581
+ const { punchAdd } = await import('./commands/punch.js');
582
+ return punchAdd(text, flagsFrom(options, { game: options.game }));
583
+ }));
584
+ listFlags(punch.command('done').description('close an item as done; free'))
585
+ .argument('<itemId>', 'the item id, li_...')
586
+ .action(capture(slot, async (itemId, options) => {
587
+ const { punchClose } = await import('./commands/punch.js');
588
+ return punchClose(itemId, 'done', flagsFrom(options, { game: options.game }));
589
+ }));
590
+ listFlags(punch.command('drop').description('close an item as dropped; free'))
591
+ .argument('<itemId>', 'the item id, li_...')
592
+ .action(capture(slot, async (itemId, options) => {
593
+ const { punchClose } = await import('./commands/punch.js');
594
+ return punchClose(itemId, 'dropped', flagsFrom(options, { game: options.game }));
595
+ }));
596
+ listFlags(punch.command('draft').description('propose items from this game\'s findings and specs; one AI unit, saves nothing without --accept'))
597
+ .option('--accept', 'add every proposal to the punch list')
598
+ .action(capture(slot, async options => {
599
+ const { punchDraft } = await import('./commands/punch.js');
600
+ return punchDraft(flagsFrom(options, { game: options.game, accept: Boolean(options.accept) }));
601
+ }));
602
+
603
+ const tape = listFlags(program.command('tape')
604
+ .description('Labs beta: blue tape, small first-impression annoyances; tape "<text>" captures one'))
605
+ .argument('[text]', 'the annoyance, in quotes')
606
+ .action(capture(slot, async (text, options, command) => {
607
+ if (!text) {
608
+ command.outputHelp();
609
+ return ExitCode.FAILURE;
610
+ }
611
+ const { tapeAdd } = await import('./commands/tape.js');
612
+ return tapeAdd(text, flagsFrom(options, { game: options.game }));
613
+ }));
614
+ listFlags(tape.command('list').description('show blue tape; free'))
615
+ .addOption(statusOption())
616
+ .action(capture(slot, async options => {
617
+ const { tapeList } = await import('./commands/tape.js');
618
+ return tapeList(flagsFrom(options, { game: options.game, status: options.status }));
619
+ }));
620
+ listFlags(tape.command('promote').description('move an item to the punch list; free'))
621
+ .argument('<itemId>', 'the item id, li_...')
622
+ .action(capture(slot, async (itemId, options) => {
623
+ const { tapePromote } = await import('./commands/tape.js');
624
+ return tapePromote(itemId, flagsFrom(options, { game: options.game }));
625
+ }));
626
+ listFlags(tape.command('drop').description('drop an item; free'))
627
+ .argument('<itemId>', 'the item id, li_...')
628
+ .action(capture(slot, async (itemId, options) => {
629
+ const { tapeDrop } = await import('./commands/tape.js');
630
+ return tapeDrop(itemId, flagsFrom(options, { game: options.game }));
631
+ }));
632
+
540
633
  common(program.command('simulation-replay').description('verify a recorded simulation trace locally; creates no paid job'))
541
634
  .argument('<trace>', 'a selected trace.json written by simulate')
542
635
  .requiredOption('--driver-config <file>', 'local JSON-RPC executable configuration')
@@ -3,6 +3,7 @@ import { ApiError } from '../api/errors.js';
3
3
  import { resolveApiUrl, paths } from '../paths.js';
4
4
  import { resolveToken, looksLikeIngestKey, looksLikeMcpToken } from '../auth/session.js';
5
5
  import { backendName } from '../auth/keychain.js';
6
+ import { missingScopes } from '../auth/scopes.js';
6
7
  import { loadConfig } from '../config.js';
7
8
  import { packs } from '../packs/index.js';
8
9
  import { detectFfmpeg, freeBytes, recommendConcurrency } from '../detect.js';
@@ -421,7 +422,10 @@ export async function runChecks(flags = {}, deps = {}) {
421
422
  facts.login.valid = true;
422
423
  facts.login.games = games.map(game => ({ gameId: game.gameId, name: game.name || null }));
423
424
  const who = whoami.user.email || whoami.user.name || whoami.user.id;
424
- rows.push(row(
425
+ const missing = missingScopes(whoami.scopes);
426
+ if (missing && missing.length > 0) {
427
+ rows.push(row('login', 'fail', `${who}, valid, but the token does not have ${missing.join(', ')}, so a playtest run would be refused`, 'ravensight-playtest login'));
428
+ } else rows.push(row(
425
429
  'login',
426
430
  games.length > 0 ? 'pass' : 'warn',
427
431
  games.length > 0
@@ -363,7 +363,11 @@ export function fakeCore({
363
363
  runs: {
364
364
  async heartbeat(gameId, jobId, runId, options) {
365
365
  record('heartbeat', { runId, options });
366
- return { run: { usage: { total_usd: 0.11 } }, cancel_requested: false };
366
+ // calls: 0, so the fake spend never prices a unit: $0.11 over a few
367
+ // ten-token scripted turns would make the report reserve
368
+ // (../run/budgetReserve.js) stop play after one turn in tests about
369
+ // other things. The reserve has its own tests.
370
+ return { run: { usage: { total_usd: 0.11, calls: 0 } }, cancel_requested: false };
367
371
  },
368
372
  async transition(gameId, jobId, runId, state, options) {
369
373
  record('transition', { runId, state, options });
@@ -13,6 +13,7 @@ import { createHeartbeat } from '../run/heartbeat.js';
13
13
  import { createModelClient } from '../run/model.js';
14
14
  import { jobDir, runDir as runDirFor, RUN_FILES, screenshotsDir } from '../run/paths.js';
15
15
  import { runPersona } from '../run/personaLoop.js';
16
+ import { runModelBudgetUsd } from '../run/budgetReserve.js';
16
17
  import { replayTrace } from '../run/replay.js';
17
18
  import { loadTrace, replayReport, replaySentence } from './replay.js';
18
19
  import { createRepoTools, PROFILE_TOOLS, WRITE_TOOLS } from '../run/repoTools.js';
@@ -29,6 +30,8 @@ import * as stateFile from '../run/state.js';
29
30
  import { createUsageLedger, writeUsageFile } from '../run/usage.js';
30
31
  import { createInterruptHandler, deliver, driverOptions, exitCodeFor, explainRegisterRefusal, formatCents, godotRefusal, resolveSetup, resolveTarget, skillFor, usableLocalBrief, startBalanceLine, startBalanceShort } from './run.js';
31
32
  import { loadLocalBrief } from '../briefLocal.js';
33
+ import { findListItem } from './lists.js';
34
+ import { jobLink } from '../dashboard.js';
32
35
 
33
36
  const execFileAsync = promisify(execFile);
34
37
 
@@ -212,9 +215,9 @@ async function fix(target, flags, injected, deps, log) {
212
215
  for (const line of unknownOptionNotes(unknown)) log(line);
213
216
  return EXIT.ENVIRONMENT;
214
217
  }
215
- const parsed = parseTarget(target);
218
+ let parsed = parseTarget(target);
216
219
  if (!parsed) {
217
- log('Name the finding to fix: either <jobId>/<label>, like pt_job_1/F002, or a finding id.');
220
+ log('Name the finding to fix: either <jobId>/<label>, like pt_job_1/F002, a finding id, or a punch list item, li_...');
218
221
  return EXIT.ENVIRONMENT;
219
222
  }
220
223
 
@@ -222,6 +225,20 @@ async function fix(target, flags, injected, deps, log) {
222
225
  if (setup.code !== undefined) return setup.code;
223
226
  const { context, api, gameId, apiUrl, repoRoot, cliVersion } = setup;
224
227
 
228
+ // A punch list item that came from a finding is fixed as that finding. The
229
+ // item only lends its finding's id, and later records the job; it is never closed here.
230
+ let listItem = null;
231
+ if (isListItemTarget(target)) {
232
+ const resolved = await resolveListItemTarget(api, gameId, String(target).trim());
233
+ if (resolved.refusal) {
234
+ log(resolved.refusal);
235
+ return EXIT.ENVIRONMENT;
236
+ }
237
+ listItem = resolved;
238
+ parsed = parseTarget(resolved.item.source.ref);
239
+ 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.`);
240
+ }
241
+
225
242
  const wantsVerify = options.verify !== false;
226
243
  const driverName = options.driver || (context.config && context.config.driver) || 'playwright_web';
227
244
  const buildTarget = resolveTarget(options, context.config, driverName, repoRoot);
@@ -477,6 +494,7 @@ async function fix(target, flags, injected, deps, log) {
477
494
  // the runs, because the charge has already happened: a developer who cannot
478
495
  // name the job they paid for cannot ask anybody about it either.
479
496
  log(`job ${jobId}: ${formatCents(registered.price && registered.price.price_cents)} charged. ${jobDir(repoRoot, jobId)}`);
497
+ if (listItem) await recordFixJob({ api, gameId, listItem, jobId, link: (registered.links && registered.links.dashboard) || jobLink(gameId, jobId, apiUrl), log });
480
498
 
481
499
  if (!fixRun) {
482
500
  log('The server registered the job without a fix run, so there is nothing to spend a model turn from.');
@@ -931,6 +949,56 @@ async function fix(target, flags, injected, deps, log) {
931
949
  * @param {{jobId: string|null, label: string|null, findingId: string|null}} target
932
950
  * @returns {Promise<Object|null>}
933
951
  */
952
+ /** The list item sources whose ref is a finding a fix can replay. */
953
+ export const FIXABLE_SOURCES = Object.freeze(['finding', 'fresh_eyes']);
954
+
955
+ /** Whether a fix target names a Labs list item rather than a finding. */
956
+ export function isListItemTarget(target) {
957
+ return String(target || '').trim().startsWith('li_');
958
+ }
959
+
960
+ /**
961
+ * Find a list item and check it came from a finding. Only a finding has the
962
+ * persona, the repro steps and the replay a fix needs; anything else is new
963
+ * work, which is what `feature` builds.
964
+ *
965
+ * @param {Object} api
966
+ * @param {string} gameId
967
+ * @param {string} itemId
968
+ * @returns {Promise<{item: Object, list: string}|{refusal: string}>}
969
+ */
970
+ export async function resolveListItemTarget(api, gameId, itemId) {
971
+ if (!api.labs || !api.labs.lists) {
972
+ return { refusal: 'This server has no punch list. Fix the finding by its id instead.' };
973
+ }
974
+ const found = await findListItem(api, gameId, itemId);
975
+ if (!found) {
976
+ 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.` };
977
+ }
978
+ const source = found.item.source || {};
979
+ // A fresh-eyes item's ref is a finding's targetId too.
980
+ if (!FIXABLE_SOURCES.includes(source.kind) || !source.ref) {
981
+ return {
982
+ refusal: `${itemId} did not come from a finding, so fix has nothing to replay. Use "ravensight-playtest feature" for that item. Nothing was charged.`
983
+ };
984
+ }
985
+ return found;
986
+ }
987
+
988
+ /**
989
+ * Record the fix job on the list item. Best effort: the job is already paid
990
+ * for, and a list that cannot be written must not stop the fix.
991
+ */
992
+ async function recordFixJob({ api, gameId, listItem, jobId, link, log }) {
993
+ const itemId = listItem.item.item_id;
994
+ try {
995
+ await api.labs.lists.update(gameId, listItem.list, itemId, { fix_job_id: jobId });
996
+ log(`Recorded job ${jobId} on ${itemId}: ${link}. The item stays open until you close it.`);
997
+ } catch (error) {
998
+ log(`warning: could not record job ${jobId} on ${itemId} (${error && error.message ? error.message : error}). The fix carries on.`);
999
+ }
1000
+ }
1001
+
934
1002
  export async function resolveFinding(api, gameId, target) {
935
1003
  const answer = await api.jobs.findings(gameId, target.jobId ? { job: target.jobId } : {});
936
1004
  const list = (answer && answer.findings) || [];
@@ -1135,6 +1203,7 @@ export async function runVerify({
1135
1203
  outcome = await runPersona({
1136
1204
  client,
1137
1205
  api,
1206
+ modelBudgetUsd: runModelBudgetUsd(entry),
1138
1207
  gameId,
1139
1208
  jobId,
1140
1209
  runId: entry.run_id,