@qaping/cli 0.1.5 → 0.3.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/bin/qaping CHANGED
@@ -11,7 +11,11 @@
11
11
  // qaping setup [claude-code|cursor|codex] [--force] one-command onboarding
12
12
  // qaping remove|uninstall [--client <c>] sweep ONLY qaping's state
13
13
  // qaping publish-build <game.zip> --platform windows|macos
14
+ // qaping builds [rm <slug>] what is hosted, and free a slot
14
15
  // qaping wait <ping_id> [--timeout <seconds>]
16
+ // qaping results <ping_id> one passive fetch, no blocking
17
+ // qaping rounds [add|rm] the parked rounds this repo is owed
18
+ // qaping issues <game> the game's issue board, read-only
15
19
  // qaping whoami | version | help
16
20
  "use strict";
17
21
  const fs = require("fs");
@@ -47,9 +51,12 @@ function resolveKitDir() {
47
51
  // writes it to ~/.claude/rules/qaping.md and ~/.cursor/rules/qaping.mdc.
48
52
  const RULE_BODY = `This machine has qaping: automatic QA for games — real human playtesters play the developer's builds on their own hardware and report back, and the coding agent runs the whole loop.
49
53
  Whenever the user asks to set up QA for a game, run QA on a patch, check whether a change broke anything, or playtest a game or build, load the qaping skill and follow it — it owns authoring/maintaining QA-PLAN.md in the game repo and the per-patch run.
50
- Builds ship with \`qaping publish-build <game.zip> --platform windows|macos\`; the printed /b/<slug> URL is what a round is filed against (store-delivered games file with their Steam/TestFlight/App Store URL instead).
51
- The MCP tools are qaping_playtest (file a round with real human playtesters), qaping_results (fetch a finished round, free) and qaping_wait (continue a pending round on pending, spawn a background task that loops qaping_wait and keep working; foreground polling is the fallback, and never report pending as the answer or end with an unbacked promise to check later).
52
- Playtests are duration-billed at 2 credits per minute of play per playtester state the estimated cost before filing. Windows rounds return a recording and no transcript; web rounds return answers only (no recording, no transcript) — never promise evidence a platform does not ship.
54
+ Builds ship with \`qaping publish-build <game.zip> --platform windows|macos\`; the printed /b/<slug> URL is what a round is filed against (store-delivered games file with their Steam/TestFlight/App Store URL instead). Hosted builds are kept until you delete them (pass \`--expires-in-hours <n>\` to have one reaped instead) and capped per account — if a publish is refused for that cap, \`qaping builds\` lists them and \`qaping builds rm <slug>\` frees a slot; free one and carry on rather than stopping to ask, taking the ones marked as never-finished uploads first and never deleting a build a round you filed is still running against.
55
+ The MCP tools are qaping_playtest (file a round with real human playtesters), qaping_results (fetch a round, free), qaping_wait (continue a pending round) and qaping_issues (the game's issue board file a bug, claim a fix, close or reopen; free).
56
+ Bugs live on that board, not in chat or in a report nobody re-reads: file EVERY playtest with \`game\` set to the QA plan's game_slug, so the game's open and claimed-fixed issues ride the round as the playtester's checklist. You can only ever CLAIM a fix (qaping_issues action:'fixed', with the exact build URL the next round will play, claimed BEFORE that round is filed) — only a playtester answering "Gone" on a later round marks one verified, and a "Still here" on a claimed fix regresses it. \`qaping issues <game>\` reads the same board from the shell.
57
+ Decide at filing time how the round ends: a short round waits in place — the moment filing returns pending, spawn a background task that loops qaping_wait and keep working (foreground polling is the fallback); a long or overnight round is PARKED — file it with an explicit long deadline_seconds, record it immediately with \`qaping rounds add <ping_id>\` (it writes qa-open-rounds.json beside QA-PLAN.md), tell the user exactly what is pending, and end the turn; a later session collects it (\`qaping rounds\` lists them with live status, qaping_results fetches one).
58
+ A recorded parked round is an honest pending end — never report pending as the answer without one, and never end with an unbacked promise to check later.
59
+ Playtests are duration-billed at 2 credits per minute of play per playtester — state the estimated cost before filing. Windows rounds return a recording, and a transcript only when the tester recorded with our recorder app (read transcript_status on each result — a Game Bar session has no transcript); web rounds return answers only (no recording, no transcript) — never promise evidence a session does not ship.
53
60
  QA memory: whenever the developer mentions — in ANY conversation, not just QA runs — something that should always be tested, a fragile area, or a QA lesson learned, offer to record it as a check (or a note on an existing check) in QA-PLAN.md so it is tested from then on.
54
61
  `;
55
62
 
@@ -217,6 +224,15 @@ function cmdPublishBuild(argv) {
217
224
  .main(argv.slice(1), { brandCommand: "qaping publish-build", nextStepToolName: "qaping_playtest" });
218
225
  }
219
226
 
227
+ // The management half of publish-build: an account holds a small number of
228
+ // hosted builds at once, and until this existed a QA loop that hit that cap
229
+ // could not even name the builds holding the slots — it stopped and asked a
230
+ // human. Same brand seam: every remedy this prints says `qaping`.
231
+ function cmdBuilds(argv) {
232
+ return require(path.join(resolveKitDir(), "harness", "builds.js"))
233
+ .main(argv.slice(1), { brandCommand: "qaping builds" });
234
+ }
235
+
220
236
  function cmdPublish(argv) {
221
237
  // WEB games: their "build" is a static directory, hosted (not zipped) — the
222
238
  // kit's hosted-draft publisher, brand-seamed like publish-build above. The
@@ -225,14 +241,580 @@ function cmdPublish(argv) {
225
241
  .main(argv.slice(1), { brandCommand: "qaping publish" });
226
242
  }
227
243
 
244
+ // ─── parked rounds: the state a LATER session collects from ─────────────────
245
+ //
246
+ // A playtest legitimately runs for hours, so an agent that files one has two
247
+ // honest endings: wait in place (the background qaping_wait chain), or PARK —
248
+ // file with an explicit long deadline, record the round, end the turn. This
249
+ // file is that record: OPEN state, mutable, one entry per round still owed,
250
+ // sitting beside QA-PLAN.md at the game repo root. It is NOT qa-rounds.jsonl —
251
+ // that ledger is append-only history written AFTER results land, and the two
252
+ // are never merged. Signed URLs never go in here; they expire in days and the
253
+ // record has to outlive the session that wrote it.
254
+ const ROUNDS_FILE = "qa-open-rounds.json";
255
+ // A repo owed more open rounds than this is a runaway loop, not a QA plan. The
256
+ // listing spends one passive fetch per round, so this is also the ceiling on
257
+ // what `qaping rounds` costs in requests.
258
+ const MAX_OPEN_ROUNDS = 20;
259
+ const PING_ID_RE = /^[0-9a-f-]{36}$/i;
260
+ // The issue board's game slug. Mirrors lib/qa-issues.ts's GAME_SLUG_RE and
261
+ // migration 066's column check exactly — a slug this refuses is a slug the
262
+ // service refuses, and finding that out before the network is the point.
263
+ const GAME_SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
264
+ const DEFAULT_APP_URL = "https://pingfusi.com";
265
+
266
+ const ROUNDS_USAGE = `usage:
267
+ qaping rounds [--json] [--offline] [--file <path>]
268
+ qaping rounds add <ping_id> [--platform windows|macos|ios] [--build <url>]
269
+ [--minutes N] [--deadline-seconds N]
270
+ [--game <slug>] [--checks a,b,c]
271
+ [--note "<patch>"] [--json]
272
+ qaping rounds rm <ping_id> [--json]`;
273
+
274
+ function flagValue(argv, name) {
275
+ const i = argv.indexOf(name);
276
+ return i >= 0 ? argv[i + 1] : undefined;
277
+ }
278
+
279
+ function intFlag(argv, name) {
280
+ const raw = flagValue(argv, name);
281
+ if (raw === undefined) return null;
282
+ const n = Number.parseInt(raw, 10);
283
+ return Number.isFinite(n) && n > 0 ? n : null;
284
+ }
285
+
286
+ function roundsFilePath(argv) {
287
+ const override = flagValue(argv, "--file");
288
+ return override ? path.resolve(override) : path.join(process.cwd(), ROUNDS_FILE);
289
+ }
290
+
291
+ // Tolerant on read, strict on shape: an entry without a ping_id is not a round
292
+ // anyone can collect, so it is dropped rather than rendered as a mystery.
293
+ //
294
+ // A bare top-level ARRAY is normalized, not refused: the skill and the mount
295
+ // prose both sanction hand-writing this file, and a list of records is the
296
+ // likeliest thing a hand writes. ANY OTHER shape is a loud failure — reading an
297
+ // unrecognized document as "no rounds" makes the next `rounds add` overwrite
298
+ // records nobody ever saw, which is data loss dressed as a fresh start.
299
+ function parseRounds(raw) {
300
+ let data;
301
+ try { data = JSON.parse(raw); }
302
+ catch { throw new Error(`${ROUNDS_FILE} is not valid JSON — fix or delete it`); }
303
+ const rounds = Array.isArray(data) ? data
304
+ : (data && Array.isArray(data.rounds) ? data.rounds : null);
305
+ if (!rounds) {
306
+ throw new Error(`${ROUNDS_FILE} is valid JSON but not a rounds file — expected {"rounds": [ … ]} (a bare array of records is also read). Refusing to overwrite it; fix or delete it.`);
307
+ }
308
+ return { rounds: rounds.filter((r) => r && typeof r.ping_id === "string") };
309
+ }
310
+
311
+ /** null when the file does not exist — "no rounds recorded here" is not an error. */
312
+ function readRoundsFile(file) {
313
+ let raw;
314
+ try { raw = fs.readFileSync(file, "utf8"); }
315
+ catch { return null; }
316
+ return parseRounds(raw);
317
+ }
318
+
319
+ // Write via tmp-then-rename: rename(2) is atomic on POSIX, so a reader (or a
320
+ // crash) sees the whole old file or the whole new one, never a truncated one.
321
+ function writeRoundsFile(file, state) {
322
+ const tmp = `${file}.${process.pid}.tmp`;
323
+ fs.writeFileSync(tmp, JSON.stringify({ rounds: state.rounds }, null, 2) + "\n");
324
+ try { fs.renameSync(tmp, file); }
325
+ catch (e) { try { fs.unlinkSync(tmp); } catch { /* best effort */ } throw e; }
326
+ }
327
+
328
+ // ── the write lock ──────────────────────────────────────────────────────────
329
+ //
330
+ // Every mutation is a read-modify-write, and the two callers that run
331
+ // unattended — an agent recording a fresh round while a background poller
332
+ // removes a collected one — race. Unlocked, the loser's record is simply gone.
333
+ // A lockfile (O_EXCL create, the one atomic primitive every filesystem gives)
334
+ // serializes them; dependency-free, because a QA repo must not grow one.
335
+ const LOCK_RETRIES = 10;
336
+ const LOCK_RETRY_MS = 50;
337
+ const LOCK_STALE_MS = 5000;
338
+
339
+ /** Blocking sleep with no dependency and no busy-spin (allowed on Node's main thread). */
340
+ function sleepSync(ms) {
341
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
342
+ }
343
+
344
+ function acquireRoundsLock(file) {
345
+ const lock = `${file}.lock`;
346
+ for (let attempt = 0; attempt < LOCK_RETRIES; attempt++) {
347
+ try { return fs.openSync(lock, "wx"); }
348
+ catch (e) {
349
+ if (!e || e.code !== "EEXIST") throw e;
350
+ // A killed writer must not wedge the repo forever — an old lock is stolen,
351
+ // loudly, because a stolen lock is also how a real concurrent write is lost.
352
+ let age = 0;
353
+ try { age = Date.now() - fs.statSync(lock).mtimeMs; } catch { age = 0; }
354
+ if (age > LOCK_STALE_MS) {
355
+ console.error(`⚠ stale lock on ${ROUNDS_FILE} (${Math.round(age / 1000)}s old) — taking it over`);
356
+ try { fs.unlinkSync(lock); } catch { /* someone else got there first */ }
357
+ continue;
358
+ }
359
+ sleepSync(LOCK_RETRY_MS);
360
+ }
361
+ }
362
+ throw new Error(`${ROUNDS_FILE} is locked by another qaping process — nothing was written; try again in a moment`);
363
+ }
364
+
365
+ function releaseRoundsLock(file, fd) {
366
+ try { fs.closeSync(fd); } catch { /* best effort */ }
367
+ try { fs.unlinkSync(`${file}.lock`); } catch { /* best effort */ }
368
+ }
369
+
370
+ /**
371
+ * The ONE read-modify-write path. `mutate(state)` returns `{ state, …extras }`;
372
+ * a null/absent `state` means "decided not to write" (and never truncates).
373
+ * Both the read and the write happen inside the lock — the point of the lock.
374
+ */
375
+ function mutateRoundsFile(file, mutate) {
376
+ const fd = acquireRoundsLock(file);
377
+ try {
378
+ const out = mutate(readRoundsFile(file) || { rounds: [] }) || {};
379
+ if (out.state) writeRoundsFile(file, out.state);
380
+ return out;
381
+ } finally {
382
+ releaseRoundsLock(file, fd);
383
+ }
384
+ }
385
+
386
+ function upsertRound(state, record) {
387
+ const rounds = state.rounds.slice();
388
+ const i = rounds.findIndex((r) => r.ping_id === record.ping_id);
389
+ if (i >= 0) rounds[i] = record; else rounds.push(record);
390
+ return { rounds };
391
+ }
392
+
393
+ function removeRound(state, pingId) {
394
+ const rounds = state.rounds.filter((r) => r.ping_id !== pingId);
395
+ return { state: { rounds }, removed: rounds.length !== state.rounds.length };
396
+ }
397
+
398
+ // How long the round stays offered to NEW players: the explicit deadline the
399
+ // filing set, counted from when it was filed. Absent either fact there is
400
+ // nothing honest to print, so nothing is printed.
401
+ function visibleUntil(record) {
402
+ const filed = Date.parse(record.filed_at || "");
403
+ if (!Number.isFinite(filed) || !record.deadline_seconds) return null;
404
+ return new Date(filed + record.deadline_seconds * 1000).toISOString();
405
+ }
406
+
407
+ function roundStatusLine(record, live) {
408
+ const status = live.status || "pending";
409
+ if (status === "complete") return `READY — collect now: qaping results ${record.ping_id}`;
410
+ if (status === "pending") {
411
+ const seen = `${live.n_received ?? 0}/${live.n_target ?? "?"}`;
412
+ const until = visibleUntil(record);
413
+ return `still pending (${seen})${until ? ` — visible until ${until}` : ""}; keep waiting: qaping wait ${record.ping_id}`;
414
+ }
415
+ // One round's own failure, not the run's: the rest of the listing is real.
416
+ if (status === "unavailable") {
417
+ return `status unavailable (${live.error || "the service refused this id"}) — the other rounds are unaffected; retry: qaping results ${record.ping_id}`;
418
+ }
419
+ // Results are asker-scoped server-side, so a round filed under a DIFFERENT
420
+ // login reads exactly like one that never existed. Never prescribe deleting
421
+ // the only record of a round on that evidence.
422
+ if (status === "not_found") {
423
+ return `not visible from this login (wrong account?) or expired — verify before \`qaping rounds rm ${record.ping_id}\` (\`qaping whoami\` shows which login this is)`;
424
+ }
425
+ return `expired or unreadable (${status}) — report it, then: qaping rounds rm ${record.ping_id}`;
426
+ }
427
+
428
+ /**
429
+ * Pure render of the listing. entries: [{ record, live }], live null when unfetched.
430
+ * opts.total is the TRUE recorded count — entries may be a capped slice of it, and
431
+ * a count line that reports the slice as the total is a lie a later session acts on.
432
+ */
433
+ function renderRounds(entries, opts) {
434
+ const o = opts || {};
435
+ const total = Number.isFinite(o.total) ? o.total : entries.length;
436
+ const notice = o.notice || (o.offline ? "(live status unavailable — offline)" : null);
437
+ const lines = [total > entries.length
438
+ ? `${total} open round(s) recorded in ${ROUNDS_FILE} — showing live status for first ${entries.length} of ${total}:`
439
+ : `${total} open round(s) recorded in ${ROUNDS_FILE}:`];
440
+ if (notice) lines.push(notice);
441
+ for (const { record, live } of entries) {
442
+ const facts = [
443
+ record.platform || null,
444
+ record.est_minutes ? `${record.est_minutes} min` : null,
445
+ record.filed_at ? `filed ${record.filed_at}` : null,
446
+ ].filter(Boolean).join(" · ");
447
+ lines.push("");
448
+ lines.push(` ${record.ping_id}${facts ? ` — ${facts}` : ""}`);
449
+ if (record.build) lines.push(` build: ${record.build}`);
450
+ // Which board's issues rode this round — the later session that collects
451
+ // it needs the slug to read what moved (`qaping issues <game>`).
452
+ if (record.game) lines.push(` game: ${record.game}`);
453
+ if (Array.isArray(record.checks) && record.checks.length) lines.push(` checks: ${record.checks.join(", ")}`);
454
+ if (record.note) lines.push(` patch: ${record.note}`);
455
+ if (live) lines.push(` ${roundStatusLine(record, live)}`);
456
+ }
457
+ return lines.join("\n");
458
+ }
459
+
460
+ // The app base these passive fetches use. `qaping wait` rides the vendored
461
+ // installer, which honors two kit-side env names besides this brand's own — so
462
+ // resolving a different base here would make two verbs disagree about which
463
+ // service a staging machine talks to. Same order as the vendor's. The second
464
+ // name is spelled from parts on purpose: it is a wire-only env name, and the
465
+ // reviewer-side brand never appears literally on a dev-facing surface (this
466
+ // package's own leak scan is what enforces that).
467
+ const LEGACY_APP_URL_ENVS = [["PING", "HUMANS", "_APP_URL"].join(""), "PINGFUSI_APP_URL"];
468
+
469
+ function resolveAppUrl() {
470
+ const fromEnv = LEGACY_APP_URL_ENVS.map((name) => process.env[name]).find(Boolean);
471
+ return (qapingWrapper().appUrl || fromEnv || DEFAULT_APP_URL).replace(/\/+$/, "");
472
+ }
473
+
474
+ // wire.js's resolveToken reads ~/.claude.json under the WIRE CONTRACT's MCP
475
+ // server keys — a list that predates this brand and does not contain `qaping`.
476
+ // A machine whose only login is the qaping MCP entry (written by the client's
477
+ // own config, with no credentials stash) therefore reads as logged out. This
478
+ // mirrors the vendored installer's resolveLocalToken minimally, bin-local, so
479
+ // the kit stays untouched: the qaping key first, then the contract's own order
480
+ // — which supplies the legacy names without spelling a retired brand here.
481
+ function resolveTokenFromClientConfigs(kitDir) {
482
+ const keys = ["qaping"];
483
+ try {
484
+ const contract = require(path.join(kitDir, "packages", "core", "wire-contract.gen.js"));
485
+ for (const k of contract.MCP_SERVER_KEYS || []) if (!keys.includes(k)) keys.push(k);
486
+ } catch { keys.push("pingfusi"); }
487
+ const home = os.homedir();
488
+ const desktop = process.platform === "darwin"
489
+ ? path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json")
490
+ : process.platform === "win32"
491
+ ? path.join(process.env.APPDATA || path.join(home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json")
492
+ : path.join(home, ".config", "Claude", "claude_desktop_config.json");
493
+ for (const p of [path.join(home, ".claude.json"), desktop, path.join(home, ".cursor", "mcp.json")]) {
494
+ try {
495
+ const servers = (JSON.parse(fs.readFileSync(p, "utf8")) || {}).mcpServers || {};
496
+ for (const key of keys) {
497
+ const headers = (servers[key] || {}).headers || {};
498
+ const m = /Bearer\s+(\S+)/.exec(headers.Authorization || headers.authorization || "");
499
+ if (m) return m[1];
500
+ }
501
+ } catch { /* absent or unreadable config: just not a login source */ }
502
+ }
503
+ return null;
504
+ }
505
+
506
+ // One passive tools/call against the qaping mount, for ANY tool it registers —
507
+ // the same transport and the same JSON-or-SSE parse the kit's `wait` leg uses,
508
+ // with the mount read from the ONE wrapper object every other command is driven
509
+ // by. Passive: it never renews a round's lease, which is exactly why a parked
510
+ // round needs its own explicit deadline. Failures are TYPED for the caller:
511
+ // `authRejected` and `perRound` separate "this login is dead" from "the service
512
+ // refused this one call".
513
+ async function callQapingTool(name, args) {
514
+ const w = qapingWrapper();
515
+ const kitDir = resolveKitDir();
516
+ const { resolveToken } = require(path.join(kitDir, "packages", "core", "wire.js"));
517
+ const token = resolveToken() || resolveTokenFromClientConfigs(kitDir);
518
+ if (!token) throw new Error("no qaping login on this machine — run `qaping setup` first");
519
+ let res;
520
+ try {
521
+ res = await fetch(`${resolveAppUrl()}${w.mcpPath}`, {
522
+ method: "POST",
523
+ headers: {
524
+ "content-type": "application/json",
525
+ accept: "application/json, text/event-stream",
526
+ authorization: `Bearer ${token}`,
527
+ },
528
+ body: JSON.stringify({
529
+ jsonrpc: "2.0",
530
+ id: 1,
531
+ method: "tools/call",
532
+ params: { name, arguments: args },
533
+ }),
534
+ signal: AbortSignal.timeout(20_000),
535
+ });
536
+ } catch (e) {
537
+ throw new Error(`could not reach the qaping service (${(e && e.message) || e})`);
538
+ }
539
+ // A rejected login is a fact about the RUN, not about the round: the body is
540
+ // plain text, so without this it parses as garbage and reads as "offline",
541
+ // sending the developer to debug their network instead of their login.
542
+ if (res.status === 401 || res.status === 403) {
543
+ const e = new Error(`qaping login rejected (HTTP ${res.status}) — run \`qaping setup\` to re-link this machine`);
544
+ e.authRejected = true;
545
+ throw e;
546
+ }
547
+ const raw = await res.text();
548
+ const m = raw.match(/data: (.*)/);
549
+ let payload;
550
+ try { payload = JSON.parse(m ? m[1] : raw); }
551
+ catch { throw new Error(`unreadable response from the qaping service (HTTP ${res.status})`); }
552
+ if (payload.error) {
553
+ // The service answered — it just refused THIS id (a bad ping_id is -32602).
554
+ // Marked so a listing can degrade one row instead of the whole pass.
555
+ const e = new Error(payload.error.message || "MCP error");
556
+ e.perRound = true;
557
+ throw e;
558
+ }
559
+ const result = payload.result || {};
560
+ const structured = result.structuredContent || {};
561
+ return {
562
+ status: structured.status || "pending",
563
+ structured,
564
+ result,
565
+ // A tool that refuses inside a successful JSON-RPC result (the board at
566
+ // its cap, a seq that is not there) answers with isError + the same text.
567
+ isError: result.isError === true,
568
+ text: (result.content && result.content[0] && result.content[0].text) || "",
569
+ };
570
+ }
571
+
572
+ /** The results tool, by name — every caller below reads the same shape. */
573
+ function callResultsTool(pingId) {
574
+ return callQapingTool("qaping_results", { ping_id: pingId });
575
+ }
576
+
577
+ async function cmdResults(argv) {
578
+ const json = argv.includes("--json");
579
+ // Flags in any position: `qaping results --json <id>` is the natural thing to
580
+ // type, and reading argv[1] positionally made it a usage error.
581
+ const pingId = argv.slice(1).find((a) => !a.startsWith("--"));
582
+ if (!pingId || !PING_ID_RE.test(pingId)) {
583
+ console.error("usage: qaping results <ping_id> [--json]");
584
+ // 1, never 2: exit 2 is THIS command's "still pending" signal (a background
585
+ // harness reads it and waits), so a typo must not answer "pending". Same
586
+ // code the vendored `wait` exits on its own usage error.
587
+ process.exit(1);
588
+ }
589
+ const r = await callResultsTool(pingId);
590
+ if (r.status === "not_found") {
591
+ // Results are asker-scoped server-side, so this is nearly always a
592
+ // wrong-account token rather than a wrong id — say which.
593
+ console.error(`✗ no round ${pingId} on this account — results are asker-scoped: \`qaping results\` only reads rounds filed with the same qaping login (\`qaping whoami\` shows which one this is).`);
594
+ process.exit(1);
595
+ }
596
+ const payload = Object.keys(r.structured).length ? r.structured : r.result;
597
+ console.log(json ? JSON.stringify(payload, null, 2) : (r.text || JSON.stringify(payload, null, 2)));
598
+ if (r.status === "pending") {
599
+ // stderr, so --json stdout stays a parseable document.
600
+ console.error(`still pending — \`qaping wait ${pingId}\` to block, or leave it recorded and check later.`);
601
+ process.exit(2);
602
+ }
603
+ process.exit(0);
604
+ }
605
+
606
+ const ISSUES_USAGE = "usage: qaping issues <game> [--json] [--status open|fixed|verified|closed]";
607
+ const ISSUE_STATUSES = ["open", "fixed", "verified", "closed"];
608
+
609
+ /** argv after the command, minus flags and the values they consume — so a
610
+ * slug that happens to equal a flag's value is still the positional. */
611
+ function positionalArgs(argv, valueFlags) {
612
+ const out = [];
613
+ for (let i = 1; i < argv.length; i++) {
614
+ if (argv[i].startsWith("--")) {
615
+ if (valueFlags.includes(argv[i])) i++;
616
+ continue;
617
+ }
618
+ out.push(argv[i]);
619
+ }
620
+ return out;
621
+ }
622
+
623
+ /**
624
+ * The game's issue board, read-only. The agent moves issues through the MCP
625
+ * tool; this is the human's window on the same board — and it prints the
626
+ * SERVICE's own rendering rather than a second one, so the board a developer
627
+ * reads in the shell and the board an agent reads mid-run cannot disagree.
628
+ */
629
+ async function cmdIssues(argv) {
630
+ const json = argv.includes("--json");
631
+ const game = positionalArgs(argv, ["--status"])[0];
632
+ if (!game || !GAME_SLUG_RE.test(game)) {
633
+ console.error(`✗ not a game slug: ${game || "(missing)"} — lowercase letters, digits and hyphens.\n${ISSUES_USAGE}`);
634
+ // 1, like `results`: a typo must never reach the network, and 2 is
635
+ // reserved by the round verbs for "still pending".
636
+ process.exit(1);
637
+ }
638
+ const status = flagValue(argv, "--status");
639
+ if (status !== undefined && !ISSUE_STATUSES.includes(status)) {
640
+ console.error(`✗ --status must be one of ${ISSUE_STATUSES.join(", ")}.\n${ISSUES_USAGE}`);
641
+ process.exit(1);
642
+ }
643
+ const args = { game, action: "list" };
644
+ if (status) args.status_filter = status;
645
+ const r = await callQapingTool("qaping_issues", args);
646
+ console.log(json
647
+ ? JSON.stringify(Object.keys(r.structured).length ? r.structured : r.result, null, 2)
648
+ : (r.text || JSON.stringify(r.result, null, 2)));
649
+ process.exit(r.isError ? 1 : 0);
650
+ }
651
+
652
+ async function cmdRoundsList(argv) {
653
+ const json = argv.includes("--json");
654
+ const state = readRoundsFile(roundsFilePath(argv));
655
+ if (!state || state.rounds.length === 0) {
656
+ console.log(json ? JSON.stringify({ rounds: [], total: 0, truncated: false, notice: null }, null, 2)
657
+ : `no open rounds recorded here (${ROUNDS_FILE})`);
658
+ process.exit(0);
659
+ }
660
+ // The cap bounds the FETCHES (one passive call each), not the truth: the
661
+ // count line and the JSON both report every recorded round, and say so when
662
+ // only the first N carry live status.
663
+ const total = state.rounds.length;
664
+ const rounds = state.rounds.slice(0, MAX_OPEN_ROUNDS);
665
+ const offlineFlag = argv.includes("--offline");
666
+ let notice = offlineFlag ? "(live status unavailable — offline)" : null;
667
+ let live = new Map();
668
+ if (!offlineFlag) {
669
+ for (const record of rounds) {
670
+ try {
671
+ const r = await callResultsTool(record.ping_id);
672
+ live.set(record.ping_id, {
673
+ status: r.status,
674
+ n_received: r.structured.n_received ?? null,
675
+ n_target: r.structured.n_target ?? null,
676
+ });
677
+ } catch (e) {
678
+ if (e && e.perRound) {
679
+ // The service answered and refused this id — that is this round's
680
+ // status, not the run's. The rest of the listing still gets fetched.
681
+ live.set(record.ping_id, { status: "unavailable", error: e.message });
682
+ continue;
683
+ }
684
+ // Login and transport fail for the whole pass; name WHICH, because the
685
+ // remedies are nothing alike.
686
+ notice = e && e.authRejected
687
+ ? "(live status unavailable — login rejected: run `qaping setup`)"
688
+ : `(live status unavailable — ${(e && e.message) || "offline"})`;
689
+ live = new Map();
690
+ break;
691
+ }
692
+ }
693
+ }
694
+ const entries = rounds.map((record) => ({ record, live: live.get(record.ping_id) || null }));
695
+ console.log(json
696
+ ? JSON.stringify({
697
+ rounds: entries.map((e) => ({ ...e.record, live: e.live })),
698
+ total,
699
+ truncated: total > rounds.length,
700
+ notice,
701
+ }, null, 2)
702
+ : renderRounds(entries, { total, notice }));
703
+ process.exit(0);
704
+ }
705
+
706
+ function cmdRoundsAdd(argv) {
707
+ const json = argv.includes("--json");
708
+ const pingId = argv[2];
709
+ if (!pingId || pingId.startsWith("--") || !PING_ID_RE.test(pingId)) {
710
+ console.error(`✗ not a ping id: ${pingId || "(missing)"}\n${ROUNDS_USAGE}`);
711
+ process.exit(2);
712
+ }
713
+ const platform = flagValue(argv, "--platform");
714
+ if (platform && !["windows", "macos", "ios"].includes(platform)) {
715
+ console.error(`✗ --platform must be windows, macos or ios — a web round has none, so omit it.\n${ROUNDS_USAGE}`);
716
+ process.exit(2);
717
+ }
718
+ // Validated here, not just recorded: a slug the service would refuse is a
719
+ // board the collecting session could never read.
720
+ const game = flagValue(argv, "--game");
721
+ if (game !== undefined && !GAME_SLUG_RE.test(game)) {
722
+ console.error(`✗ --game must be a slug: lowercase letters, digits and hyphens (the QA plan's game_slug).\n${ROUNDS_USAGE}`);
723
+ process.exit(2);
724
+ }
725
+ const file = roundsFilePath(argv);
726
+ const minutes = intFlag(argv, "--minutes");
727
+ const deadline = intFlag(argv, "--deadline-seconds");
728
+ const checks = (flagValue(argv, "--checks") || "").split(",").map((s) => s.trim()).filter(Boolean);
729
+ const note = flagValue(argv, "--note");
730
+ const build = flagValue(argv, "--build");
731
+ const patch = {};
732
+ if (platform) patch.platform = platform;
733
+ if (game) patch.game = game;
734
+ if (build) patch.build = build;
735
+ if (minutes != null) patch.est_minutes = minutes;
736
+ if (deadline != null) patch.deadline_seconds = deadline;
737
+ if (checks.length) patch.checks = checks;
738
+ if (note) patch.note = note;
739
+ // Read and write inside ONE lock: a background poller's `rounds rm` landing
740
+ // between this read and this write used to drop whichever record lost.
741
+ const { record, count, updated } = mutateRoundsFile(file, (state) => {
742
+ const existing = state.rounds.find((r) => r.ping_id === pingId);
743
+ // Re-adding the same id CORRECTS a record, never duplicates it — and keeps
744
+ // the original filed_at, which is what the visibility window counts from.
745
+ const rec = {
746
+ ...(existing || {}),
747
+ ping_id: pingId,
748
+ filed_at: (existing && existing.filed_at) || new Date().toISOString(),
749
+ ...patch,
750
+ };
751
+ const next = upsertRound(state, rec);
752
+ return { state: next, record: rec, count: next.rounds.length, updated: !!existing };
753
+ });
754
+ if (json) {
755
+ console.log(JSON.stringify({ file, recorded: record, rounds: count }, null, 2));
756
+ process.exit(0);
757
+ }
758
+ console.log(`✓ recorded round ${pingId} in ${ROUNDS_FILE}${updated ? " (updated)" : ""}`);
759
+ console.log(` collect it later: \`qaping rounds\` lists what this repo is owed; \`qaping results ${pingId}\` fetches this one.`);
760
+ console.log(` ${ROUNDS_FILE} is open state, not history — add it to .gitignore.`);
761
+ if (count > MAX_OPEN_ROUNDS) {
762
+ console.log(` ⚠ ${count} rounds recorded — \`qaping rounds\` fetches live status for the first ${MAX_OPEN_ROUNDS}. Collect some.`);
763
+ }
764
+ process.exit(0);
765
+ }
766
+
767
+ function cmdRoundsRm(argv) {
768
+ const json = argv.includes("--json");
769
+ const pingId = argv[2];
770
+ if (!pingId || pingId.startsWith("--")) {
771
+ console.error(`✗ qaping rounds rm needs a ping id.\n${ROUNDS_USAGE}`);
772
+ process.exit(2);
773
+ }
774
+ const file = roundsFilePath(argv);
775
+ // Same lock as `add`: this is the verb a background poller runs while the
776
+ // agent that filed the next round is recording it.
777
+ const { removed, count } = mutateRoundsFile(file, (state) => {
778
+ const { state: next, removed: didRemove } = removeRound(state, pingId);
779
+ // No write at all when nothing matched — a no-op must not rewrite the file.
780
+ return { state: didRemove ? next : null, removed: didRemove, count: next.rounds.length };
781
+ });
782
+ if (!removed) {
783
+ console.error(`✗ no round ${pingId} recorded in ${ROUNDS_FILE}`);
784
+ process.exit(1);
785
+ }
786
+ console.log(json ? JSON.stringify({ removed: pingId, rounds: count }, null, 2)
787
+ : `✓ removed ${pingId} from ${ROUNDS_FILE} (${count} open round(s) left)`);
788
+ process.exit(0);
789
+ }
790
+
791
+ async function cmdRounds(argv) {
792
+ const sub = argv[1];
793
+ if (sub === "add") return cmdRoundsAdd(argv);
794
+ if (sub === "rm" || sub === "delete" || sub === "remove") return cmdRoundsRm(argv);
795
+ if (sub === "--help" || sub === "-h") { console.log(ROUNDS_USAGE); process.exit(0); }
796
+ if (sub && !sub.startsWith("--")) {
797
+ console.error(`✗ unknown: qaping rounds ${sub}\n${ROUNDS_USAGE}`);
798
+ process.exit(2);
799
+ }
800
+ return cmdRoundsList(argv);
801
+ }
802
+
228
803
  const HELP = `qaping — automatic QA for your game: real human playtesters, driven by your coding agent
229
804
 
230
805
  usage:
231
806
  qaping setup [claude-code|cursor|codex] [--force]
232
807
  qaping remove [--client <c>] remove qaping's MCP entries, skill and rule
233
808
  qaping publish-build <game.zip> --platform windows|macos
809
+ qaping builds the hosted builds you hold (oldest first)
810
+ qaping builds rm <slug> delete one now, freeing its slot
234
811
  qaping publish <built-dir> host a WEB game's built output (prints the URL to file with)
235
812
  qaping wait <ping_id> [--timeout <seconds>]
813
+ qaping results <ping_id> one passive fetch — exit 0 news, 2 still pending, 1 otherwise
814
+ qaping rounds the parked rounds this repo is owed, with live status
815
+ qaping rounds add <ping_id> … record a parked round so a later session collects it
816
+ qaping rounds rm <ping_id> forget one, once it is collected or expired
817
+ qaping issues <game> the game's issue board (read-only; the agent moves it)
236
818
  qaping whoami
237
819
  qaping version
238
820
 
@@ -245,23 +827,38 @@ function route(cmd) {
245
827
  if (cmd === "remove" || cmd === "uninstall") return "remove";
246
828
  if (cmd === "wait" || cmd === "whoami") return "vendor";
247
829
  if (cmd === "publish-build") return "publish-build";
830
+ if (cmd === "builds") return "builds";
248
831
  if (cmd === "publish") return "publish";
832
+ if (cmd === "rounds") return "rounds";
833
+ if (cmd === "results") return "results";
834
+ if (cmd === "issues") return "issues";
249
835
  return "unknown";
250
836
  }
251
837
 
838
+ // The async commands' rejection handler: a clean one-line message, never a raw
839
+ // stack — same contract as the sync catch in main() below.
840
+ function die(e) {
841
+ console.error(`✗ ${(e && e.message) || e}`);
842
+ process.exit(1);
843
+ }
844
+
252
845
  function main() {
253
846
  const argv = process.argv.slice(2);
254
847
  try {
255
848
  switch (route(argv[0])) {
256
849
  case "version": return console.log(require("../package.json").version);
257
850
  case "help": return console.log(HELP);
258
- case "setup": return void cmdSetup(argv).catch((e) => { console.error(`✗ ${(e && e.message) || e}`); process.exit(1); });
851
+ case "setup": return void cmdSetup(argv).catch(die);
259
852
  case "remove": return cmdRemove(argv);
260
853
  // `wait` also names the wait tool the qaping mount registers — the
261
854
  // vendored default (the stock mount's wait) is not on /api/mcp/qaping.
262
855
  case "vendor": return process.exit(spawnVendor(argv[0] === "wait" ? [...argv, "--wait-tool", "qaping_wait"] : argv));
263
856
  case "publish-build": return void cmdPublishBuild(argv);
857
+ case "builds": return void cmdBuilds(argv);
264
858
  case "publish": return void cmdPublish(argv);
859
+ case "rounds": return void cmdRounds(argv).catch(die);
860
+ case "results": return void cmdResults(argv).catch(die);
861
+ case "issues": return void cmdIssues(argv).catch(die);
265
862
  default:
266
863
  console.error(HELP);
267
864
  process.exit(1);
@@ -275,4 +872,8 @@ function main() {
275
872
  }
276
873
 
277
874
  if (require.main === module) main();
278
- module.exports = { route, resolveKitDir, qapingWrapper, vendorFlags, isSourceCheckout, RULE_BODY, HELP };
875
+ module.exports = {
876
+ route, resolveKitDir, qapingWrapper, vendorFlags, isSourceCheckout, RULE_BODY, HELP,
877
+ ROUNDS_FILE, MAX_OPEN_ROUNDS, GAME_SLUG_RE,
878
+ parseRounds, upsertRound, removeRound, visibleUntil, renderRounds,
879
+ };