ravensight-playtest 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -47,7 +47,7 @@ In CI, set `RAVENSIGHT_TOKEN` instead and skip `login` entirely.
47
47
 
48
48
  ## Commands
49
49
 
50
- Eleven commands. Flags below are the ones the code accepts, not the ones the
50
+ Twelve commands. Flags below are the ones the code accepts, not the ones the
51
51
  prose elsewhere describes; `--help` on any command prints the same list.
52
52
 
53
53
  Every command takes these four:
@@ -171,6 +171,73 @@ silently ignored: `--budget-usd` and `--quality` (the budget and the model tier
171
171
  are the server's, enforced at the proxy), `--offline-upload` (uploads already
172
172
  queue) and `--build-dir` (serve the build and pass `--build-url`).
173
173
 
174
+ ### fix
175
+
176
+ Takes one finding, attempts the change on a branch of its own, replays the
177
+ finding to see whether the change worked, and prints the pull request command
178
+ for you to run.
179
+
180
+ ```bash
181
+ ravensight-playtest fix pt_job_1/F002 --build-url http://localhost:8080/
182
+ ravensight-playtest fix fnd_7g3k --no-verify
183
+ ```
184
+
185
+ The target is either `<jobId>/<label>`, where the label is the one the report
186
+ gives the finding, or a bare finding id.
187
+
188
+ | Flag | Effect |
189
+ |---|---|
190
+ | `--game <gameId>` | The game, when this repo is not linked. |
191
+ | `--branch <name>` | The branch to create. Defaults to `ravensight/fix-<label>-<6 hex>`. |
192
+ | `--no-verify` | Do not replay the finding afterwards. The changeset then carries no verdict. |
193
+ | `--allow-dirty` | Fix on top of uncommitted changes instead of refusing. Your changes end up in the diff too. |
194
+ | `--max-actions <n>` | Cap the turns the fix may spend. Capped at 60 whatever you ask for. |
195
+ | `--build-url <url>`, `--godot-project <dir>`, `--driver <name>` | Where the verify run plays, resolved exactly as `run` resolves them. |
196
+ | `--json` | Print the changeset instead of the summary. |
197
+
198
+ What happens, in order:
199
+
200
+ 1. The finding and its run report are read, with up to two of its screenshots.
201
+ 2. Git is checked: a directory that is not a repository, or a machine with no
202
+ git, stops here with one sentence, and so does a working tree with
203
+ uncommitted changes unless you passed `--allow-dirty`.
204
+ 3. The price is printed and confirmed. Nothing is charged and no branch is
205
+ created until you say yes.
206
+ 4. The server is asked whether the finding is a bug at all. A finding it reads
207
+ as design intent with confidence 0.7 or more stops the attempt before
208
+ anything is edited.
209
+ 5. The change is planned, then made with a write tool that can only write text
210
+ files inside your repository, at most 200 kB and at most 20 files.
211
+ 6. The verify run replays the finding on the build you pointed at.
212
+ 7. `changeset.json` and `report.md` are written, uploaded and attached to the
213
+ fix run, and the pull request command is printed.
214
+
215
+ **It never pushes and never commits.** There is no `git commit`, no `git push`
216
+ and no forge client anywhere in the command. You are left on the branch with
217
+ the change in your working tree, and the last thing printed is the command to
218
+ open the pull request once you have read the diff.
219
+
220
+ A fix attempt is one unit. It is refunded when the finding turns out to be
221
+ design intent, and refunded when the attempt changes nothing, in both cases
222
+ without a model turn being spent on the edit itself. The verify run is included
223
+ in the price.
224
+
225
+ **A verify can only mean something against a build this machine serves.** The
226
+ change exists in your working tree and nowhere else, so a `--build-url`
227
+ pointing at a hosted build would answer about the last upload instead. Any
228
+ non-local URL is refused unless you pass `--no-verify`, because a confident
229
+ "fixed" from the wrong build is worse than no answer. Rebuild and serve the
230
+ branch locally before the verify, or skip it.
231
+
232
+ The verdict is printed in words: fixed below 0.35, still reproduced above 0.65,
233
+ unknown in between, and "not verified" when there was no verify run.
234
+
235
+ **A fix cannot be resumed.** Ctrl-C, or cancelling the job from the dashboard,
236
+ abandons the attempt and gives the unit back: whatever was written stays on the
237
+ branch for you to read or delete, and nothing is committed. That is different
238
+ from `run`, which `resume` picks up where it left off, because a half-applied
239
+ edit is not a playthrough to carry on with.
240
+
174
241
  ### resume
175
242
 
176
243
  With no job id, lists the jobs this repo has a journal for, newest first. With
@@ -299,9 +366,17 @@ In your repo:
299
366
  session.webm only when you opt in
300
367
  ```
301
368
 
369
+ A `fix` job's run directory holds `changeset.json`, `report.md` (the pull
370
+ request body) and `usage.json`, plus the screenshots it was shown as evidence.
371
+
302
372
  Add `.ravensight/jobs/` to your `.gitignore`; `init` does it for you when you
303
373
  already have one.
304
374
 
375
+ `fix` also leaves a branch on your working copy, checked out, with the change in
376
+ your working tree and nothing committed. It is the only thing in this CLI that
377
+ writes to your source at all, and it never pushes: no commit, no push, no forge
378
+ client exists in it. Delete the branch and you are back where you started.
379
+
305
380
  ## What leaves your machine
306
381
 
307
382
  The dashboard has the full answer, artifact by artifact, at
@@ -314,8 +389,8 @@ which is the page to trust: it is rendered from the server's own allowlist, so
314
389
  it cannot drift from what is actually accepted. `ravensight-playtest open` links
315
390
  to it. The short version:
316
391
 
317
- Uploaded by default: `report.md`, `report.json`, `usage.json`, the aggregate and
318
- capability reports, and `screenshots/`. Uploaded only when you ask:
392
+ Uploaded by default: `report.md`, `report.json`, `usage.json`, `changeset.json`,
393
+ the aggregate and capability reports, and `screenshots/`. Uploaded only when you ask:
319
394
  `transcript.jsonl` (`--upload-transcript`) and `session.webm`
320
395
  (`--upload-video`). **Source code never, at any path.** The upload allowlist has
321
396
  no entry for it, the CLI refuses it locally, and the server refuses anything not
@@ -325,6 +400,15 @@ pins it equal to the server's file when a checkout is on the same machine.
325
400
  Findings cite a file and a line range. The source text behind that line is not
326
401
  uploaded.
327
402
 
403
+ A `fix` uploads `changeset.json` and `report.md`. The changeset carries the
404
+ finding id, the branch name, the commit it branched from, the file paths it
405
+ changed with their added and removed line counts, the verify verdict, and the
406
+ pull request title and body. **Not the diff, and not the file contents.** What
407
+ the model read while working is what goes over the wire as part of its own
408
+ reasoning, exactly as `profile` already does, and the same guards apply: a file
409
+ that trips the secret scan is withheld rather than read, and both files a fix
410
+ writes are scanned again before they are uploaded.
411
+
328
412
  Run `upload --dry-upload` or `run --dry-upload` to see the exact file list and
329
413
  byte count before anything leaves the machine.
330
414
 
package/package.json CHANGED
@@ -1,8 +1,19 @@
1
1
  {
2
2
  "name": "ravensight-playtest",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "AI personas play your game and file reports to Ravensight.",
5
5
  "license": "MIT",
6
+ "homepage": "https://ravensight.io/docs/#playtest",
7
+ "bugs": {
8
+ "email": "support@realityse.com"
9
+ },
10
+ "keywords": [
11
+ "godot",
12
+ "playtest",
13
+ "game-analytics",
14
+ "ai-personas",
15
+ "ravensight"
16
+ ],
6
17
  "type": "module",
7
18
  "bin": {
8
19
  "ravensight-playtest": "bin/ravensight-playtest.js"
package/src/api/client.js CHANGED
@@ -152,9 +152,14 @@ export function createClient(options = {}) {
152
152
  cacheable(`${PACKS_BASE}/skills`, { query: { modules: packModules(modules) }, etag }),
153
153
  schemas: ({ etag } = {}) => cacheable(`${PACKS_BASE}/schemas`, { etag }),
154
154
  routing: ({ etag } = {}) => cacheable(`${PACKS_BASE}/routing`, { etag }),
155
- bundle: ({ gameId, modules, etag } = {}) =>
155
+ /**
156
+ * `jobId` asks for the bundle in the context of one job, which is the only
157
+ * way `graph_context` comes back: without it that field is null, because
158
+ * the graph is assembled from the job's own findings.
159
+ */
160
+ bundle: ({ gameId, modules, jobId, etag } = {}) =>
156
161
  cacheable(`${PACKS_BASE}/bundle`, {
157
- query: { game_id: gameId, modules: packModules(modules) },
162
+ query: { game_id: gameId, modules: packModules(modules), job_id: jobId },
158
163
  etag
159
164
  })
160
165
  };
@@ -243,6 +248,18 @@ export function createClient(options = {}) {
243
248
  request('POST', `${gameBase(gameId)}/jobs/${encodeURIComponent(jobId)}/aggregate/complete`, { body })
244
249
  .then(answer => answer.body),
245
250
 
251
+ /**
252
+ * Ask whether a finding is a bug at all before spending a fix on it.
253
+ *
254
+ * Answers `{ verdict: 'bug' | 'design_intent' | 'content' | null,
255
+ * confidence, source }`. The verdict is the server's, from the brief's
256
+ * design intent notes and whatever the dashboard has been told, which is
257
+ * why it is a route rather than a judgement the CLI makes locally.
258
+ */
259
+ fixTriage: (gameId, jobId, body) =>
260
+ request('POST', `${gameBase(gameId)}/jobs/${encodeURIComponent(jobId)}/fix/triage`, { body })
261
+ .then(answer => answer.body),
262
+
246
263
  artifacts: (gameId, jobId) =>
247
264
  get(`${gameBase(gameId)}/jobs/${encodeURIComponent(jobId)}/artifacts`),
248
265
 
@@ -271,6 +288,12 @@ export function createClient(options = {}) {
271
288
  const runs = {
272
289
  list: (gameId, jobId) => get(`${gameBase(gameId)}/jobs/${encodeURIComponent(jobId)}/runs`),
273
290
 
291
+ /**
292
+ * One run. `fix` reads it for `verify_verdict`, which the server attaches
293
+ * to a verify run once it has completed and which nothing else answers.
294
+ */
295
+ get: (gameId, jobId, runId) => get(runPath(gameId, jobId, runId)),
296
+
274
297
  add: (gameId, jobId, personas) =>
275
298
  request('POST', `${gameBase(gameId)}/jobs/${encodeURIComponent(jobId)}/runs`, {
276
299
  body: { personas }
package/src/cli.js CHANGED
@@ -19,7 +19,8 @@ import { ApiError } from './api/errors.js';
19
19
  /** Where each runner backed command lives, for the message below. */
20
20
  const RUNNER_COMMANDS = {
21
21
  run: 'src/run/index.js',
22
- profile: 'src/run/index.js'
22
+ profile: 'src/run/index.js',
23
+ fix: 'src/run/index.js'
23
24
  };
24
25
 
25
26
  async function loadRunner(name, exportName) {
@@ -108,6 +109,19 @@ function flagsFrom(options, extra = {}) {
108
109
 
109
110
  const list = value => value.split(',').map(part => part.trim()).filter(Boolean);
110
111
 
112
+ /**
113
+ * A copy of `values` with the first occurrence of `value` removed.
114
+ * @param {Array<string>} values
115
+ * @param {string} value
116
+ * @returns {Array<string>}
117
+ */
118
+ function withoutFirst(values, value) {
119
+ const copy = [...values];
120
+ const at = copy.indexOf(value);
121
+ if (at !== -1) copy.splice(at, 1);
122
+ return copy;
123
+ }
124
+
111
125
  /**
112
126
  * Build the program. Exported rather than executed so a test can parse an
113
127
  * argument vector without spawning a process or exiting it.
@@ -241,6 +255,31 @@ export function buildProgram() {
241
255
  return profile(flagsFrom(options, { game: options.game, argv: command.args }));
242
256
  }));
243
257
 
258
+ common(program.command('fix').description('attempt a fix for one finding, on a new branch'))
259
+ .argument('<target>', 'the finding: <jobId>/<label>, or a finding id')
260
+ .option('--game <gameId>', 'the game, when this repo is not linked')
261
+ .option('--branch <name>', 'the branch to create (default ravensight/fix-<label>-<hex>)')
262
+ .option('--no-verify', 'do not replay the finding after the fix')
263
+ .option('--allow-dirty', 'fix on top of uncommitted changes instead of refusing')
264
+ .allowUnknownOption()
265
+ // Unknown options' values land in command.args; without this commander
266
+ // counts them as excess positional arguments and refuses the command.
267
+ .allowExcessArguments()
268
+ .addHelpText('after', RUNNER_HELP)
269
+ .action(capture(slot, async (target, options, command) => {
270
+ const fix = await loadRunner('fix', 'fixCommand');
271
+ return fix(target, flagsFrom(options, {
272
+ game: options.game,
273
+ branch: options.branch,
274
+ verify: options.verify,
275
+ allowDirty: Boolean(options.allowDirty),
276
+ // Commander puts operands first in `command.args` and appends the
277
+ // options it did not recognise, so the target is in there too and only
278
+ // the first copy of it is the operand.
279
+ argv: withoutFirst(command.args, target)
280
+ }));
281
+ }));
282
+
244
283
  common(program.command('run').description('run a playtest'))
245
284
  .option('--game <gameId>', 'the game, when this repo is not linked')
246
285
  .allowUnknownOption()
@@ -16,6 +16,7 @@
16
16
 
17
17
  const SKILL = ['---', 'name: persona-playtest', 'prompt_version: persona-playtest@0.1.0', '---', '# Persona Playtest'].join('\n');
18
18
  const PROFILE_SKILL = ['---', 'name: game-profile', 'prompt_version: game-profile@0.1.0', '---', '# Game Profile'].join('\n');
19
+ const FIX_SKILL = ['---', 'name: fix', 'prompt_version: fix@0.1.0', '---', '# Fix'].join('\n');
19
20
 
20
21
  /** The server's table, verbatim. */
21
22
  export const RUN_TRANSITIONS = Object.freeze({
@@ -102,7 +103,8 @@ export function fakePack(overrides = {}) {
102
103
  ],
103
104
  skills: [
104
105
  { module: 'persona_playtest', name: 'persona-playtest', content: SKILL },
105
- { module: 'game_profile', name: 'game-profile', content: PROFILE_SKILL }
106
+ { module: 'game_profile', name: 'game-profile', content: PROFILE_SKILL },
107
+ { module: 'fix', name: 'fix', content: FIX_SKILL }
106
108
  ],
107
109
  schemas: [
108
110
  { name: 'playtest-report', content: REPORT_SCHEMA },
@@ -186,6 +188,36 @@ export const REPORT_MD = [
186
188
  ''
187
189
  ].join('\n');
188
190
 
191
+ /**
192
+ * The two runs a `fix` job buys: the fix itself, and the persona run that
193
+ * replays the finding afterwards. There is no aggregate run, because a fix
194
+ * job has nothing to aggregate.
195
+ */
196
+ export const FIX_RUNS = Object.freeze([
197
+ { run_id: 'pt_run_fix', module: 'fix', persona: null, playtest_token: 'tok_fix', budget_usd: 1.5 },
198
+ { run_id: 'pt_run_verify', module: 'persona_playtest', persona: 'curious-kid', playtest_token: 'tok_verify', budget_usd: 0.6 }
199
+ ]);
200
+
201
+ /**
202
+ * @param {Object} [overrides]
203
+ * @returns {Object} one finding, the shape `GET /findings` answers with
204
+ */
205
+ export function fakeFinding(overrides = {}) {
206
+ return {
207
+ finding_id: 'fnd_1',
208
+ label: 'F002',
209
+ job_id: 'pt_job_1',
210
+ run_id: 'pt_run_a',
211
+ persona: 'curious-kid',
212
+ title: 'The pause menu cannot be closed',
213
+ severity: 'major',
214
+ kind: 'bug',
215
+ repro_steps: ['press start', 'press escape', 'press escape again'],
216
+ persona_quote: 'I am stuck in this menu and nothing I press gets me out.',
217
+ ...overrides
218
+ };
219
+ }
220
+
189
221
  /**
190
222
  * @param {Object} [options]
191
223
  * @returns {Object} deps, with a `calls` log and a `runStates` map
@@ -198,7 +230,11 @@ export function fakeCore({
198
230
  publish = true,
199
231
  aggregateFinishes = false,
200
232
  brief = fakeBrief(),
201
- pack = fakePack()
233
+ pack = fakePack(),
234
+ findings = [fakeFinding()],
235
+ triage = { verdict: 'bug', confidence: 0.9, source: 'brief' },
236
+ graphContext = { levels: [{ name: 'title', exits: ['level_1'] }] },
237
+ verifyVerdict = { reproduced: 0.1, source: 'judge' }
202
238
  } = {}) {
203
239
  const calls = [];
204
240
  const record = (name, payload) => calls.push({ name, ...payload });
@@ -265,6 +301,21 @@ export function fakeCore({
265
301
  record('uploads', { jobId, body });
266
302
  return { urls: body.files.map(file => ({ path: file.path, put_url: 'https://s3.test/x', tagging: null })) };
267
303
  },
304
+ async findings(gameId, query) {
305
+ record('findings', { gameId, query });
306
+ // The server narrows by `job` when it is given; the fake does the same
307
+ // so a test cannot pass by matching a finding from another job.
308
+ const wanted = query && query.job;
309
+ return { findings: wanted ? findings.filter(entry => entry.job_id === wanted) : findings };
310
+ },
311
+ async fixTriage(gameId, jobId, body) {
312
+ record('fixTriage', { jobId, body });
313
+ return { ...triage };
314
+ },
315
+ async artifacts(gameId, jobId) {
316
+ record('artifacts', { jobId });
317
+ return { artifacts: [] };
318
+ },
268
319
  async aggregateComplete(gameId, jobId, body) {
269
320
  record('aggregateComplete', { jobId, body });
270
321
  return aggregateFinishes
@@ -302,6 +353,15 @@ export function fakeCore({
302
353
  async list(gameId, jobId) {
303
354
  record('list', { jobId });
304
355
  return { runs: plan.map(entry => ({ ...entry, state: runStates.get(entry.run_id), actions_taken: 0 })) };
356
+ },
357
+ async get(gameId, jobId, runId) {
358
+ record('runGet', { jobId, runId });
359
+ const entry = plan.find(candidate => candidate.run_id === runId) || { run_id: runId };
360
+ const state = runStates.get(runId) || 'queued';
361
+ // `verify_verdict` exists only on a completed verify run, which is the
362
+ // one thing the fix command reads this route for.
363
+ const verdict = entry.module === 'persona_playtest' && state === 'succeeded' ? verifyVerdict : undefined;
364
+ return { run: { ...entry, state, ...(verdict === undefined ? {} : { verify_verdict: verdict }) } };
305
365
  }
306
366
  }
307
367
  };
@@ -325,7 +385,12 @@ export function fakeCore({
325
385
  packs: {
326
386
  async get(options) {
327
387
  record('packs', { options });
328
- return pack;
388
+ // `graph_context` is populated only in a bundle asked for with a job
389
+ // id, because the server assembles it from that job's findings. A fake
390
+ // that answered it either way would hide a missing refetch.
391
+ return options && options.jobId
392
+ ? { ...pack, graph_context: pack.graph_context ?? graphContext }
393
+ : { ...pack, graph_context: null };
329
394
  }
330
395
  },
331
396
  async finalizeRun(dir, options) {