ravensight-playtest 0.1.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 (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +380 -0
  3. package/addons/ravensight_driver/driver.gd +836 -0
  4. package/addons/ravensight_driver/export_plugin.gd +51 -0
  5. package/addons/ravensight_driver/plugin.cfg +7 -0
  6. package/addons/ravensight_driver/plugin.gd +36 -0
  7. package/bin/ravensight-playtest.js +31 -0
  8. package/package.json +45 -0
  9. package/src/api/README.md +500 -0
  10. package/src/api/client.js +340 -0
  11. package/src/api/errors.js +115 -0
  12. package/src/api/http.js +194 -0
  13. package/src/api/index.js +107 -0
  14. package/src/auth/deviceCode.js +79 -0
  15. package/src/auth/keychain.js +159 -0
  16. package/src/auth/session.js +128 -0
  17. package/src/cli.js +335 -0
  18. package/src/commands/brief.js +303 -0
  19. package/src/commands/check.js +318 -0
  20. package/src/commands/fakeCore.js +379 -0
  21. package/src/commands/init.js +120 -0
  22. package/src/commands/login.js +90 -0
  23. package/src/commands/logout.js +70 -0
  24. package/src/commands/open.js +125 -0
  25. package/src/commands/profile.js +262 -0
  26. package/src/commands/resume.js +156 -0
  27. package/src/commands/run.js +1015 -0
  28. package/src/commands/upload.js +137 -0
  29. package/src/config.js +100 -0
  30. package/src/dashboard.js +97 -0
  31. package/src/detect.js +77 -0
  32. package/src/errors.js +44 -0
  33. package/src/fsutil.js +77 -0
  34. package/src/godot.js +85 -0
  35. package/src/packs/index.js +191 -0
  36. package/src/paths.js +129 -0
  37. package/src/run/aggregate.js +658 -0
  38. package/src/run/args.js +111 -0
  39. package/src/run/context.js +181 -0
  40. package/src/run/deps.js +184 -0
  41. package/src/run/drivers/driver.js +183 -0
  42. package/src/run/drivers/godot-observation.js +138 -0
  43. package/src/run/drivers/godot-project.js +475 -0
  44. package/src/run/drivers/godot-rpc.js +225 -0
  45. package/src/run/drivers/godot.js +587 -0
  46. package/src/run/drivers/index.js +52 -0
  47. package/src/run/drivers/web.js +385 -0
  48. package/src/run/exit.js +21 -0
  49. package/src/run/heartbeat.js +131 -0
  50. package/src/run/index.js +31 -0
  51. package/src/run/json.js +56 -0
  52. package/src/run/model.js +384 -0
  53. package/src/run/paths.js +88 -0
  54. package/src/run/personaLoop.js +871 -0
  55. package/src/run/profile.js +214 -0
  56. package/src/run/regenerate.js +149 -0
  57. package/src/run/repoTools.js +286 -0
  58. package/src/run/report.js +222 -0
  59. package/src/run/resume.js +272 -0
  60. package/src/run/secretScan.js +171 -0
  61. package/src/run/state.js +198 -0
  62. package/src/run/synthetic.js +206 -0
  63. package/src/run/tools.js +344 -0
  64. package/src/run/transcript.js +93 -0
  65. package/src/run/usage.js +115 -0
  66. package/src/state/index.js +105 -0
  67. package/src/states.js +104 -0
  68. package/src/ui/index.js +195 -0
  69. package/src/upload/allowlist.js +116 -0
  70. package/src/upload/index.js +467 -0
  71. package/src/upload/queue.js +114 -0
  72. package/src/version.js +63 -0
@@ -0,0 +1,51 @@
1
+ # Keeps the driver out of builds that ship to players.
2
+ #
3
+ # The driver already refuses to start in an export template that lacks the
4
+ # "ravensight_driver" feature tag, but a refusing-to-start TCP server is still
5
+ # a TCP server sitting in the binary. This skips the whole addon at export
6
+ # time unless the preset was built with that tag, so the usual release export
7
+ # contains no driver code at all.
8
+ #
9
+ # One file does not simply vanish. A developer who installs the addon in their
10
+ # own project gets the autoload written into project.godot, and project.godot
11
+ # ships in the export. Removing driver.gd outright would leave that autoload
12
+ # pointing at a file that is not there, and the exported game would greet the
13
+ # player with a load error before its first frame. So driver.gd's path keeps a
14
+ # script: a stub that frees itself and does nothing else. Every other file in
15
+ # the addon, this plugin included, is dropped.
16
+ @tool
17
+ extends EditorExportPlugin
18
+
19
+ const FEATURE_TAG := "ravensight_driver"
20
+ const ADDON_PREFIX := "res://addons/ravensight_driver/"
21
+ const DRIVER_PATH := "res://addons/ravensight_driver/driver.gd"
22
+
23
+ const INERT_STUB := """extends Node
24
+
25
+ # The Ravensight playtest driver was stripped from this export. This stub only
26
+ # exists so a self-installed autoload entry still resolves to a real script.
27
+ func _ready() -> void:
28
+ queue_free()
29
+ """
30
+
31
+ var _keep := false
32
+
33
+
34
+ func _get_name() -> String:
35
+ return "RavensightDriverStrip"
36
+
37
+
38
+ func _export_begin(features: PackedStringArray, _is_debug: bool, _path: String, _flags: int) -> void:
39
+ _keep = FEATURE_TAG in features
40
+ if not _keep:
41
+ print("[ravensight-driver] stripping the playtest driver from this export")
42
+
43
+
44
+ func _export_file(path: String, _type: String, _features: PackedStringArray) -> void:
45
+ if _keep:
46
+ return
47
+ if not path.begins_with(ADDON_PREFIX):
48
+ return
49
+ skip()
50
+ if path == DRIVER_PATH:
51
+ add_file(path, INERT_STUB.to_utf8_buffer(), false)
@@ -0,0 +1,7 @@
1
+ [plugin]
2
+
3
+ name="Ravensight Playtest Driver"
4
+ description="Opt-in JSON-RPC driver that lets Ravensight playtest personas observe and control a running Godot game. Inert unless launched with --ravensight-driver or RAVENSIGHT_DRIVER=1, binds 127.0.0.1 only, and is stripped from any export not built with the ravensight_driver feature tag."
5
+ author="Ravensight"
6
+ version="1.0.0"
7
+ script="plugin.gd"
@@ -0,0 +1,36 @@
1
+ # Editor side of the Ravensight Playtest driver.
2
+ #
3
+ # Two jobs, both about making sure the driver cannot reach players:
4
+ # 1. Register (and unregister) the driver autoload, for developers who
5
+ # install the addon in their own project rather than letting the CLI
6
+ # inject it into a temporary copy.
7
+ # 2. Install an export plugin that strips addons/ravensight_driver/ out of
8
+ # every export that was not deliberately built with the custom feature
9
+ # tag "ravensight_driver". A release build therefore does not merely
10
+ # refuse to start the driver, it does not contain it.
11
+ @tool
12
+ extends EditorPlugin
13
+
14
+ const AUTOLOAD_NAME := "RavensightDriver"
15
+ const AUTOLOAD_PATH := "res://addons/ravensight_driver/driver.gd"
16
+
17
+ var _export_plugin: EditorExportPlugin
18
+
19
+
20
+ func _enter_tree() -> void:
21
+ _export_plugin = preload("res://addons/ravensight_driver/export_plugin.gd").new()
22
+ add_export_plugin(_export_plugin)
23
+
24
+
25
+ func _exit_tree() -> void:
26
+ if _export_plugin != null:
27
+ remove_export_plugin(_export_plugin)
28
+ _export_plugin = null
29
+
30
+
31
+ func _enable_plugin() -> void:
32
+ add_autoload_singleton(AUTOLOAD_NAME, AUTOLOAD_PATH)
33
+
34
+
35
+ func _disable_plugin() -> void:
36
+ remove_autoload_singleton(AUTOLOAD_NAME)
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The only place this package calls `process.exit`.
4
+ *
5
+ * Every command answers an exit code instead of exiting, so the whole CLI can
6
+ * be driven from a test without taking the test process down with it. This file
7
+ * is the one boundary where a code becomes an exit, and the one place an
8
+ * unhandled rejection is turned into a message rather than a Node warning.
9
+ */
10
+ import { main, report } from '../src/cli.js';
11
+
12
+ const NODE_MIN_MAJOR = 22;
13
+ const major = Number(process.versions.node.split('.')[0]);
14
+ if (Number.isFinite(major) && major < NODE_MIN_MAJOR) {
15
+ process.stderr.write(
16
+ `ravensight-playtest needs Node ${NODE_MIN_MAJOR} or newer, and this is v${process.versions.node}.\n`
17
+ );
18
+ process.exit(3);
19
+ }
20
+
21
+ process.on('unhandledRejection', error => {
22
+ process.exitCode = report(error);
23
+ });
24
+
25
+ main(process.argv)
26
+ .then(code => {
27
+ process.exitCode = code;
28
+ })
29
+ .catch(error => {
30
+ process.exitCode = report(error);
31
+ });
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "ravensight-playtest",
3
+ "version": "0.1.0",
4
+ "description": "AI personas play your game and file reports to Ravensight.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "ravensight-playtest": "bin/ravensight-playtest.js"
9
+ },
10
+ "engines": {
11
+ "node": ">=22"
12
+ },
13
+ "files": [
14
+ "bin",
15
+ "src",
16
+ "addons",
17
+ "README.md",
18
+ "LICENSE",
19
+ "!src/**/*.test.js",
20
+ "!src/test-helpers",
21
+ "!src/run/__helpers__",
22
+ "!src/run/drivers/testing",
23
+ "!test"
24
+ ],
25
+ "exports": {
26
+ ".": "./src/api/index.js",
27
+ "./api": "./src/api/index.js"
28
+ },
29
+ "scripts": {
30
+ "test": "node --test --test-reporter=spec 'src/**/!(godot-real|web.e2e).test.js'",
31
+ "test:godot": "node --test --test-reporter=spec src/run/drivers/godot-real.test.js",
32
+ "test:e2e": "node --test --test-reporter=spec src/run/drivers/web.e2e.test.js",
33
+ "lint": "node scripts/no-dashes.mjs"
34
+ },
35
+ "dependencies": {
36
+ "@anthropic-ai/sdk": "^0.60.0",
37
+ "ajv": "^8.17.1",
38
+ "ajv-formats": "^3.0.1",
39
+ "commander": "^13.0.0"
40
+ },
41
+ "optionalDependencies": {
42
+ "keytar": "^7.9.0",
43
+ "playwright": "^1.49.0"
44
+ }
45
+ }
@@ -0,0 +1,500 @@
1
+ # The runner-facing interface
2
+
3
+ Everything `src/run/*` (cli-runner) and `src/run/drivers/godot.js` (cli-godot)
4
+ are allowed to import lives behind one module:
5
+
6
+ ```js
7
+ import { createClient, uploadRunDir, packs } from '../api/index.js';
8
+ ```
9
+
10
+ Nothing else in `src/` is a contract. `src/api/http.js`, `src/auth/*`,
11
+ `src/upload/queue.js` and the command modules are implementation detail and
12
+ may change without notice. If you need something that is not listed here,
13
+ ask for it to be added to `src/api/index.js` rather than reaching past it.
14
+
15
+ The barrel is also the package's public entry point (`exports` in
16
+ `package.json`), so an external consumer sees exactly this surface.
17
+
18
+ ## Contents
19
+
20
+ | Export | What it is |
21
+ |---|---|
22
+ | `createClient(options)` | the Ravensight API client |
23
+ | `ApiError`, `isRetryable(error)`, `codeRetryLimit(error)` | the error envelope, and whether a retry could help |
24
+ | `resolveApiUrl()`, `resolveToken(options)` | where the API is, and the credential to use |
25
+ | `handleUnauthorized`, `withAuthHandling` | the one 401 handler every command funnels through |
26
+ | `createContext(options)` | resolve config plus credential plus client in one call |
27
+ | `loadConfig(options)`, `saveConfig(config, options)` | `<repo>/.ravensight/config.json` |
28
+ | `packs` | pack cache: `packs.get()`, `packs.sync()`, `packs.cached()` |
29
+ | `uploadRunDir(runDir, options)` | presign, PUT, record; one run directory |
30
+ | `finalizeRun(runDir, options)` | `uploadRunDir` plus `complete`, with the 422 repair pass |
31
+ | `readState`, `writeState`, `updateState`, `stateFileFor` | the resumable job journal |
32
+ | `paths` | every directory and file this CLI writes |
33
+ | `ui` | spinner, table, status lines, confirmation prompt |
34
+ | `CLI_VERSION`, `compareVersions` | this package's version, sent as `cli_version` |
35
+ | `ExitCode`, `CliError` | the spec 17 exit codes, and the error that carries one |
36
+ | state lists | `MODULES`, `DRIVERS`, `RUN_STATES`, `RUN_TERMINAL_STATES`, `RUN_TRANSITIONS`, `isRunTerminal`, `canTransitionRun`, and the job equivalents |
37
+ | link helpers | `dashboardUrl`, `gameLink`, `jobLink`, `reviewLink`, `briefLink`, `openInBrowser` |
38
+ | `inspectGodot` | the Godot binary and project version, through cli-godot's own detection |
39
+ | machine helpers | `recommendConcurrency`, `freeBytes`, `probe` |
40
+ | allowlist | `contentTypeFor`, `isAllowedPath`, `JOB_UPLOAD_PATHS`, `RETENTION_TAGGED_PATHS`, the three size caps |
41
+ | file helpers | `sha256`, `sha256File`, `readJson`, `writeJson`, `writeFileAtomic`, `stableStringify` |
42
+
43
+ ## createClient
44
+
45
+ ```js
46
+ const api = createClient({
47
+ apiUrl: 'https://api.ravensight.io', // default resolveApiUrl()
48
+ token: 'gt_cli_...', // required for everything but cli.version()
49
+ cliVersion: CLI_VERSION, // default CLI_VERSION
50
+ fetch: globalThis.fetch, // injectable, for tests
51
+ retries: 4, // network and 5xx and 429 retries
52
+ timeoutMs: 30000,
53
+ onRetry: ({ attempt, delayMs, error }) => {}
54
+ });
55
+ ```
56
+
57
+ The client is stateless and cheap. Make one per process, or one per run if
58
+ you want the per-run token on it.
59
+
60
+ ### Read-only properties
61
+
62
+ `api.apiUrl`, `api.token`, `api.cliVersion`.
63
+
64
+ ### The escape hatch
65
+
66
+ ```js
67
+ const { status, headers, body } = await api.request('POST', '/api/v1/...', {
68
+ query: { limit: 10 },
69
+ body: { ... },
70
+ headers: { 'Idempotency-Key': key },
71
+ idempotent: false, // true lets a mutation be retried
72
+ raw: false // true returns body as text, not JSON
73
+ });
74
+ ```
75
+
76
+ Every named method below is a thin wrapper over `request`. A non-2xx answer
77
+ throws `ApiError`; nothing returns an error as a value.
78
+
79
+ ### api.cli
80
+
81
+ | Call | Server route | Answers |
82
+ |---|---|---|
83
+ | `api.cli.version()` | `GET /api/v1/playtest/cli/version` | `{ min_supported, latest, schema_version }`. No token needed. |
84
+ | `api.cli.deviceCode({ client, clientVersion })` | `POST /api/v1/playtest/cli/device-code` | `{ device_code, user_code, verification_uri, expires_in, interval }` |
85
+ | `api.cli.pollToken(deviceCode)` | `POST /api/v1/playtest/cli/token` | `{ token, games, scopes, expires_at }`, or throws `ApiError` with code `authorization_pending` (428), `slow_down` (429), `expired_token` (400) or `access_denied` (403) |
86
+ | `api.cli.whoami()` | `GET /api/v1/playtest/cli/whoami` | `{ user, orgs, games: [{ gameId, name }], scopes, available_cents }` |
87
+ | `api.cli.revoke()` | `DELETE /api/v1/playtest/cli/token` | nothing (204) |
88
+
89
+ ### api.packs
90
+
91
+ Every pack route is cacheable. Pass `etag` to get a 304 instead of a body.
92
+
93
+ ```js
94
+ const { status, etag, body } = await api.packs.bundle({ gameId, modules, etag });
95
+ // status 200 -> body is the bundle; status 304 -> body is null
96
+ ```
97
+
98
+ `api.packs.manifest`, `api.packs.personas({ gameId, etag })`,
99
+ `api.packs.skills({ modules, etag })`, `api.packs.schemas({ etag })`,
100
+ `api.packs.routing({ etag })`, `api.packs.bundle({ gameId, modules, etag })`.
101
+
102
+ Prefer `packs.get()` (below) over these: it does the caching for you.
103
+
104
+ ### api.brief
105
+
106
+ All game-scoped, all under `/api/v1/games/:gameId/playtest`.
107
+
108
+ | Call | Route |
109
+ |---|---|
110
+ | `api.brief.get(gameId)` | `GET /brief` -> `{ brief, rendered }` or `{ brief: null }` |
111
+ | `api.brief.versions(gameId)` | `GET /brief/versions` |
112
+ | `api.brief.version(gameId, version)` | `GET /brief/versions/:version` |
113
+ | `api.brief.put(gameId, { fields, status, parentVersion })` | `PUT /brief` -> `{ brief, rendered }`; 409 `stale_version` carries `current_version` |
114
+ | `api.brief.estimate(gameId, { modules, personas })` | `POST /brief/estimate` -> `{ estimate_cents, breakdown, balance_cents, ok, topup_url }` |
115
+ | `api.brief.check(gameId, completeness)` | `POST /brief/check` -> `{ completeness }` |
116
+
117
+ `balance_cents` and `ok` are `null` when the caller's role has no
118
+ `billing:manage`. That is not an error; show the estimate without a verdict.
119
+
120
+ ### api.jobs
121
+
122
+ | Call | Route |
123
+ |---|---|
124
+ | `api.jobs.estimate(gameId, { modules, personas })` | `POST /estimate`, same body as `brief.estimate` |
125
+ | `api.jobs.register(gameId, body, { idempotencyKey })` | `POST /jobs` |
126
+ | `api.jobs.list(gameId, query)` | `GET /jobs` -> `{ jobs, next_cursor }` |
127
+ | `api.jobs.get(gameId, jobId)` | `GET /jobs/:jobId` -> `{ job }` |
128
+ | `api.jobs.patch(gameId, jobId, { state, reason })` | `PATCH /jobs/:jobId` -> `{ job }` |
129
+ | `api.jobs.cancel(gameId, jobId)` | `POST /jobs/:jobId/cancel` |
130
+ | `api.jobs.finish(gameId, jobId, { state, reason })` | `POST /jobs/:jobId/finish` |
131
+ | `api.jobs.remove(gameId, jobId)` | `DELETE /jobs/:jobId` |
132
+ | `api.jobs.uploads(gameId, jobId, { files })` | `POST /jobs/:jobId/uploads`, job-level keys |
133
+ | `api.jobs.capabilityReport(gameId, jobId, body)` | `POST /jobs/:jobId/capability-report` |
134
+ | `api.jobs.aggregateComplete(gameId, jobId, body)` | `POST /jobs/:jobId/aggregate/complete` |
135
+ | `api.jobs.artifacts(gameId, jobId)` | `GET /jobs/:jobId/artifacts` |
136
+ | `api.jobs.report(gameId, jobId, format)` | `GET /jobs/:jobId/report?format=md\|json`, returns text |
137
+ | `api.jobs.summary(gameId)` | `GET /summary` |
138
+ | `api.jobs.findings(gameId, query)` | `GET /findings` |
139
+
140
+ `register` takes the server's own spelling, so nothing is translated twice:
141
+
142
+ ```js
143
+ await api.jobs.register(gameId, {
144
+ modules: ['persona_playtest'], // required, non-empty
145
+ personas: ['rage-quitter'], // required when modules has persona_playtest
146
+ driver: 'playwright_web', // required
147
+ cli_version: api.cliVersion, // required, added for you if omitted
148
+ confirm_price_cents: 800, // required integer, from the estimate
149
+ build: { kind: 'url', ref: 'http://localhost:8080' },
150
+ repo: { commit: 'abc123', dirty: false },
151
+ max_actions: 120,
152
+ budget: { wall_seconds_max: 1800 },
153
+ pack_versions: { pack: '1.0.0' }
154
+ }, { idempotencyKey });
155
+ ```
156
+
157
+ `Idempotency-Key` is required by the server, so `register` throws
158
+ `TypeError` locally rather than sending a request that cannot succeed. The
159
+ call is marked idempotent, so a dropped response is retried and the server
160
+ replays instead of charging twice.
161
+
162
+ On success: `{ job_id, runs: [{ run_id, module, persona, playtest_token, budget_usd }], price: { price_cents, breakdown }, links: { dashboard }, replayed }`.
163
+
164
+ Refusals worth handling by code:
165
+
166
+ | Status | `error` | What it means |
167
+ |---|---|---|
168
+ | 400 | `idempotency_key_required`, `invalid_job` | fix the request |
169
+ | 402 | `insufficient_balance` | carries `estimate_cents`, `balance_cents`, `shortfall_cents`, `topup_url` |
170
+ | 409 | `price_changed` | carries the new `estimate_cents` and `breakdown`; re-confirm |
171
+ | 409 | `brief_required` | finish the brief first |
172
+ | 409 | `cli_outdated` | carries `min_version` |
173
+ | 409 | `charge_in_progress` | unknown, not refused. Honor `Retry-After` and ask again |
174
+ | 429 | `too_many_active_jobs` | carries `max_active_jobs` |
175
+
176
+ ### api.runs
177
+
178
+ | Call | Route |
179
+ |---|---|
180
+ | `api.runs.list(gameId, jobId)` | `GET /jobs/:jobId/runs` -> `{ runs }` |
181
+ | `api.runs.add(gameId, jobId, personas)` | `POST /jobs/:jobId/runs` |
182
+ | `api.runs.heartbeat(gameId, jobId, runId, opts)` | `PATCH /jobs/:jobId/runs/:runId` with no state |
183
+ | `api.runs.transition(gameId, jobId, runId, state, opts)` | the same PATCH, with a state |
184
+ | `api.runs.uploads(gameId, jobId, runId, { files, includeVideo })` | `POST .../uploads` |
185
+ | `api.runs.complete(gameId, jobId, runId, body)` | `POST .../complete` |
186
+
187
+ `opts` for both heartbeat and transition is
188
+ `{ reason, checkpointStep, actionsTaken, heartbeat }`. Both answer
189
+ `{ run, cancel_requested }`, and `cancel_requested` is the only signal a
190
+ runner gets that it should stop, so read it on every beat.
191
+
192
+ Heartbeat is marked idempotent and is retried. Transition is not: a retried
193
+ transition can lose a race and come back 409 `invalid_transition`, which the
194
+ caller has to see rather than have swallowed.
195
+
196
+ Send a heartbeat at least every 5 seconds. The server marks a run
197
+ `interrupted` after 30 minutes of silence.
198
+
199
+ ## ApiError
200
+
201
+ ```js
202
+ try { await api.jobs.register(...); }
203
+ catch (error) {
204
+ if (error instanceof ApiError) {
205
+ error.status; // 402
206
+ error.code; // 'insufficient_balance', the server's `error` field
207
+ error.message; // the server's `message`, safe to print
208
+ error.details; // [{ instancePath, message, code? }], [] when absent
209
+ error.retryAfter; // seconds, or null
210
+ error.body; // the parsed body, for the extra figures
211
+ error.method; // 'POST'
212
+ error.path; // '/api/v1/games/g1/playtest/jobs'
213
+ }
214
+ }
215
+ ```
216
+
217
+ `isRetryable(error)` is true for a network failure, a timeout, 408, 425, 429
218
+ and 5xx, with two code-aware exceptions, because for these two the status alone
219
+ gives the wrong answer:
220
+
221
+ - **`too_many_active_jobs`** is a 429 and is NOT retried. It means this game
222
+ already has three jobs running, which waiting inside one request cannot
223
+ change. The person needs to be told, not made to wait through four backoffs.
224
+ - **`charge_in_progress`** is a 409 and IS retried, on its own `Retry-After`,
225
+ up to five times, and that allowance is independent of the client's `retries`
226
+ setting. It does not mean refused: another caller holds the charge claim and
227
+ this one cannot tell whether money moved. It becomes a real answer (a replay,
228
+ or a charge) within seconds. A `charge_in_progress` with no `Retry-After` is
229
+ not retried, because then there is no interval the server has blessed.
230
+
231
+ `isRetryable` says nothing about whether retrying is *safe*; that is what the
232
+ `idempotent` flag is for. `codeRetryLimit(error)` exposes the per-code
233
+ allowance.
234
+
235
+ A network failure with no response is an `ApiError` with `status: 0` and
236
+ `code: 'network_error'`.
237
+
238
+ ## Credentials, and what a 401 means
239
+
240
+ `resolveToken({ apiUrl, required })` answers
241
+ `{ token, source, host }`, where `source` is the real origin: `'env'` for
242
+ `RAVENSIGHT_TOKEN`, or `'keychain'` or `'file'` depending on which store
243
+ actually answered.
244
+
245
+ Every command runs its API work inside `withAuthHandling(context, fn)`, which
246
+ funnels a 401 through `handleUnauthorized(context, error)`:
247
+
248
+ - a **stored** token (`tokenSource` is not `'env'`) is DELETED, a line says so,
249
+ and the thrown error is a `CliError` with `ExitCode.AUTH` and a "run login"
250
+ hint. Keeping a revoked token means every later command fails the same way
251
+ and nobody can tell why.
252
+ - a token from **`RAVENSIGHT_TOKEN`** is never deleted, only reported. It is
253
+ probably a CI secret shared with other jobs.
254
+ - anything that is not a 401 (a 403, a 404, a plain `Error`) passes through
255
+ untouched.
256
+
257
+ If you add a command, wrap its body the same way. `createContext` gives you the
258
+ `tokenSource` it needs.
259
+
260
+ ## packs
261
+
262
+ The cache lives in the user cache directory, keyed by API host, verified by
263
+ sha256, refreshed with `If-None-Match`.
264
+
265
+ ```js
266
+ const { bundle, etag, fromCache, fetchedAt, stale } = await packs.get({
267
+ api, gameId, modules: ['persona_playtest'], offline: false
268
+ });
269
+ ```
270
+
271
+ - Online, no cache: fetches and writes the cache.
272
+ - Online, cache present: sends `If-None-Match`; a 304 reuses the cached copy.
273
+ - Offline (or the fetch fails): returns the cached copy with `stale: true`,
274
+ or throws when there is no cache at all.
275
+ - `fetchedAt` is an ISO string. `stale` is true whenever the answer did not
276
+ come from this process's own successful request.
277
+
278
+ `packs.cached({ apiUrl, gameId, modules })` reads the cache without any
279
+ network. `packs.sync({ api, gameId, modules })` forces a fetch and returns
280
+ the same shape.
281
+
282
+ The bundle's own fields are the server's: `pack_version`, `generated_at`,
283
+ `personas`, `skills`, `schemas`, `routing`, `code_brief`, `engine`,
284
+ `intent_notes`, `etag`.
285
+
286
+ ## uploadRunDir and finalizeRun
287
+
288
+ ```js
289
+ const result = await uploadRunDir(runDir, {
290
+ api, gameId, jobId, runId,
291
+ includeVideo: false, // session.webm and session.mp4 are opt-in
292
+ includeTranscript: false, // transcript.jsonl is opt-in
293
+ allowedPaths: null, // narrow the allowlist (see JOB_UPLOAD_PATHS)
294
+ dryRun: false, // list what would be sent, upload nothing
295
+ onFile: ({ path, size, status }) => {}
296
+ });
297
+ // { uploaded: [{path,size,sha256}], skipped: [{path,reason}], bytes, dryRun }
298
+ ```
299
+
300
+ **The server has ONE opt-in flag and it covers BOTH opt-in artifacts.**
301
+ `POST .../uploads` takes `include_video`, and the server's `contentTypeFor`
302
+ skips every `optIn` entry when it is false, which includes `transcript.jsonl`.
303
+ So a transcript-only batch sent with `include_video: false` is refused, and the
304
+ refusal is a 400 on the whole batch rather than on the one path. `uploadRunDir`
305
+ therefore sends `include_video: includeVideo || includeTranscript`. Keep the two
306
+ CLI-side flags separate (they decide what goes in the batch); do not try to
307
+ send two flags to the server.
308
+
309
+ For a job-level upload (no `runId`), pass `allowedPaths: JOB_UPLOAD_PATHS`. The
310
+ server's allowlist is one list for both levels and would happily sign
311
+ `report.json` or `job.json` at the job root; the three artifacts that actually
312
+ belong to a job are `capability-report.json`, `aggregate-report.md` and
313
+ `aggregate-report.json`.
314
+
315
+ `runDir` is a directory on disk. Files are matched against the upload
316
+ allowlist compiled into the CLI (the same list as the server's
317
+ `src/playtest/limits.js`); anything else is reported in `skipped` and never
318
+ sent. Source code is not on the list and is never uploaded.
319
+
320
+ Each file is sha256'd and sized locally, presigned in one request, then PUT
321
+ with the `Content-Type` the server signed and, for `session.webm`,
322
+ `session.mp4` and `transcript.jsonl`, the `x-amz-tagging` value the presign
323
+ response handed back, sent verbatim. Dropping that header is a signature
324
+ mismatch, not an untagged object.
325
+
326
+ A 403 on a PUT means the URL expired: the file is presigned again once and
327
+ retried. Anything else retries with backoff. Every attempt is journalled to
328
+ `<jobDir>/upload-queue.jsonl`, so `ravensight-playtest upload <job>` can
329
+ drain what a crash left behind, and a file whose sha256 is already recorded
330
+ as uploaded is skipped rather than sent twice.
331
+
332
+ ```js
333
+ const { run, dropped_findings, report_lint } = await finalizeRun(runDir, {
334
+ api, gameId, jobId, runId, includeVideo, includeTranscript,
335
+ body: { state: 'succeeded', report, report_md, actions_taken, quit_reason }
336
+ });
337
+ ```
338
+
339
+ `finalizeRun` uploads, then calls `complete`. A 422 `upload_unverified`
340
+ names the failed paths in `details[].instancePath` as `/manifest/<path>`;
341
+ `finalizeRun` re-uploads exactly those and completes once more. The run
342
+ stays non-terminal through that, by design. A second failure throws.
343
+
344
+ 422 `invalid_report` and 422 `secrets_detected` are not repaired here and
345
+ are thrown for the caller to deal with: a report the schema refuses needs
346
+ regenerating, and a secret needs rotating.
347
+
348
+ ## State
349
+
350
+ The resumable journal, one file per job, written atomically (temp file plus
351
+ rename) so a kill mid-write cannot leave a half file.
352
+
353
+ ```js
354
+ await writeState(jobId, { job_id, game_id, ... }, { repoRoot });
355
+ const state = await readState(jobId, { repoRoot }); // null when absent
356
+ await updateState(jobId, s => ({ ...s, runs: {...} }), { repoRoot });
357
+ ```
358
+
359
+ The shape is the runner's to define; cli-core only requires `job_id` and
360
+ `game_id` at the top level, which is what `resume` reads. Keep every value
361
+ JSON-serializable.
362
+
363
+ ## paths
364
+
365
+ ```js
366
+ paths.repoRoot(options) // cwd, or options.repoRoot
367
+ paths.ravensightDir(options) // <repo>/.ravensight
368
+ paths.configFile(options) // <repo>/.ravensight/config.json
369
+ paths.jobDir(jobId, options) // <repo>/.ravensight/jobs/<jobId>
370
+ paths.stateFile(jobId, options)
371
+ paths.queueFile(jobId, options)
372
+ paths.runDir(jobId, runId, options) // .../jobs/<jobId>/runs/<runId>
373
+ paths.userDir() // ~/.ravensight-playtest
374
+ paths.cacheDir(apiUrl) // per-host pack cache
375
+ ```
376
+
377
+ A run directory holds `report.md`, `report.json`, `usage.json`,
378
+ `transcript.jsonl`, `screenshots/NN-slug.png` and `session.webm`. Those
379
+ names are the allowlist's; a different name is not uploadable.
380
+
381
+ ## ui
382
+
383
+ ```js
384
+ ui.info(text); ui.warn(text); ui.error(text); ui.ok(text);
385
+ ui.table(rows, columns); // aligned, no borders
386
+ const spin = ui.spinner('Working'); spin.update('Still working'); spin.stop('Done');
387
+ await ui.confirm('Start the job?', { yes: flags.yes });
388
+ ui.json(value); // stable stringify for --json output
389
+ ```
390
+
391
+ Nothing in `ui` writes to stdout when `RAVENSIGHT_JSON=1` or when
392
+ `ui.setQuiet(true)` has been called, except `ui.json`, so `--json` output
393
+ stays machine-readable. Spinners degrade to one line per update when stdout
394
+ is not a TTY.
395
+
396
+ ## State lists
397
+
398
+ `MODULES`, `DRIVERS`, `JOB_STATES`, `JOB_TERMINAL_STATES`, `RUN_STATES`,
399
+ `RUN_TERMINAL_STATES` and `RUN_TRANSITIONS` are copies of the server's own
400
+ enums, with `isRunTerminal(state)`, `isJobTerminal(state)` and
401
+ `canTransitionRun(from, to)` over them. Use them to refuse a nonsense
402
+ transition locally; the server is still the authority and refuses one it does
403
+ not know.
404
+
405
+ Two of them are load bearing. `interrupted` is NOT a terminal run state: it is
406
+ what the server's sweep marks a run whose heartbeat stopped, and the whole
407
+ point of it is that `resume` can take it back to `launching`. And a job reaches
408
+ a terminal state only through `/finish`, `/cancel` or `DELETE`; a `PATCH` that
409
+ asks for one is refused with 409, because the terminal state and the refund are
410
+ one decision.
411
+
412
+ ## Godot
413
+
414
+ ```js
415
+ const { isProject, projectVersion, binary, matches, source } = await inspectGodot({
416
+ repoRoot, godotPath
417
+ });
418
+ ```
419
+
420
+ This is an adapter, not a second implementation. When
421
+ `src/run/drivers/godot-project.js` is present it calls that module's
422
+ `isGodotProject`, `readProjectEngineVersion` and `detectGodotBinary`, so the
423
+ binary `check` blesses is the one the driver will launch; without it, it falls
424
+ back to a local probe and reports `source: 'fallback'`. Godot is never a
425
+ failure in `check`: a missing one is a warning, because a web build run does
426
+ not need it.
427
+
428
+ ## Exit codes
429
+
430
+ `ExitCode` from spec 17, used by `process.exit` at the top level only:
431
+
432
+ | Name | Value |
433
+ |---|---|
434
+ | `OK` | 0 |
435
+ | `BUDGET_EXCEEDED` | 2 |
436
+ | `ENVIRONMENT` | 3 |
437
+ | `AUTH` | 4 |
438
+ | `MODEL` | 5 |
439
+ | `CANCELED` | 10 |
440
+
441
+ Throw `new CliError(message, ExitCode.AUTH)` from anywhere; `bin/` maps it
442
+ to the code and prints the message without a stack trace.
443
+
444
+ ## What cli-runner must export
445
+
446
+ `src/cli.js` wires three commands to a lazy `import('./run/index.js')`, which
447
+ keeps `check` from parsing the whole runner and turns a partial install into a
448
+ sentence rather than a module-resolution stack trace. `src/run/index.js` exports
449
+ exactly these three names, and `src/cli.test.js` resolves the module and checks
450
+ for all three, so a rename on either side of the seam fails a test rather than a
451
+ paid run.
452
+
453
+ ```js
454
+ /**
455
+ * `ravensight-playtest run`
456
+ * @param {Object} flags - { apiUrl, repoRoot, json, yes, game, argv }
457
+ * `argv` is every unrecognized argument, so the run command owns its own
458
+ * flag parsing (--personas, --driver, --build-url, --max-actions, and so on).
459
+ * @returns {Promise<number>} an ExitCode
460
+ */
461
+ export async function runCommand(flags) {}
462
+
463
+ /**
464
+ * `ravensight-playtest profile`
465
+ * @param {Object} flags - the same shape
466
+ * @returns {Promise<number>} an ExitCode
467
+ */
468
+ export async function profileCommand(flags) {}
469
+
470
+ /**
471
+ * The handoff from `ravensight-playtest resume <jobId>`.
472
+ *
473
+ * cli-core has already: read the local journal, confirmed the job exists on the
474
+ * server, listed its runs, and printed which are finished. It does NOT restart
475
+ * anything, and it never filters the runs it hands over, so deciding which are
476
+ * resumable is yours (use `isRunTerminal` from this module: a `succeeded` run
477
+ * must never be re-run, and `interrupted` is the one that should be).
478
+ *
479
+ * @param {Object} args
480
+ * @param {Object} args.api a createClient instance, already authenticated
481
+ * @param {string} args.gameId
482
+ * @param {string} args.jobId
483
+ * @param {Object} args.job the server's job body, from GET /jobs/:jobId
484
+ * @param {Array} args.runs the server's run bodies, from GET .../runs
485
+ * @param {Object} args.state the local journal, as written by writeState
486
+ * @param {string} args.repoRoot
487
+ * @param {Object} args.flags { apiUrl, repoRoot, json, yes, game, status,
488
+ * restartInterrupted }. `restartInterrupted`
489
+ * is `resume --restart-interrupted`: without
490
+ * it a run past the partial checkpoint floor
491
+ * is finalised from its own transcript, with
492
+ * it the run is replayed and spends its
493
+ * action budget again.
494
+ * @returns {Promise<number>} an ExitCode. 2 for a budget stop, 10 for a cancel.
495
+ */
496
+ export async function resumeJob(args) {}
497
+ ```
498
+
499
+ Both commands must answer an exit code rather than calling `process.exit`:
500
+ `bin/ravensight-playtest.js` is the only place in the package that exits.