@maka/maka-cli 5.65.2 → 5.67.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 (95) hide show
  1. package/bundle/typescript/package.json +1 -1
  2. package/bundle/typescript/src/commands/game/_index.d.ts +1 -0
  3. package/bundle/typescript/src/commands/game/_index.js +1 -0
  4. package/bundle/typescript/src/commands/game/_index.js.map +1 -1
  5. package/bundle/typescript/src/commands/game/sideQuest/commands/backlog.d.ts +124 -0
  6. package/bundle/typescript/src/commands/game/sideQuest/commands/backlog.js +487 -29
  7. package/bundle/typescript/src/commands/game/sideQuest/commands/backlog.js.map +1 -1
  8. package/bundle/typescript/src/commands/game/sideQuest/commands/bluff.d.ts +15 -1
  9. package/bundle/typescript/src/commands/game/sideQuest/commands/bluff.js +61 -4
  10. package/bundle/typescript/src/commands/game/sideQuest/commands/bluff.js.map +1 -1
  11. package/bundle/typescript/src/commands/game/sideQuest/commands/crew.js +4 -1
  12. package/bundle/typescript/src/commands/game/sideQuest/commands/crew.js.map +1 -1
  13. package/bundle/typescript/src/commands/game/sideQuest/commands/note.d.ts +73 -14
  14. package/bundle/typescript/src/commands/game/sideQuest/commands/note.js +150 -40
  15. package/bundle/typescript/src/commands/game/sideQuest/commands/note.js.map +1 -1
  16. package/bundle/typescript/src/commands/game/sideQuest/commands/review.d.ts +39 -0
  17. package/bundle/typescript/src/commands/game/sideQuest/commands/review.js +48 -0
  18. package/bundle/typescript/src/commands/game/sideQuest/commands/review.js.map +1 -0
  19. package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.js +4 -1
  20. package/bundle/typescript/src/commands/game/sideQuest/commands/sheet.js.map +1 -1
  21. package/bundle/typescript/src/commands/game/sideQuest/engine-version.d.ts +1 -1
  22. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +15 -1
  23. package/bundle/typescript/src/commands/game/sideQuest/engine-version.js.map +1 -1
  24. package/bundle/typescript/src/commands/game/sideQuest/factories/repro-scene.d.ts +87 -0
  25. package/bundle/typescript/src/commands/game/sideQuest/factories/repro-scene.js +320 -0
  26. package/bundle/typescript/src/commands/game/sideQuest/factories/repro-scene.js.map +1 -0
  27. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.d.ts +36 -0
  28. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js +116 -3
  29. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-chunks.js.map +1 -1
  30. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.js +8 -1
  31. package/bundle/typescript/src/commands/game/sideQuest/factories/scene-factory.js.map +1 -1
  32. package/bundle/typescript/src/commands/game/sideQuest/game.d.ts +48 -0
  33. package/bundle/typescript/src/commands/game/sideQuest/game.js +112 -13
  34. package/bundle/typescript/src/commands/game/sideQuest/game.js.map +1 -1
  35. package/bundle/typescript/src/commands/game/sideQuest/headless-harness.d.ts +12 -0
  36. package/bundle/typescript/src/commands/game/sideQuest/headless-harness.js +23 -0
  37. package/bundle/typescript/src/commands/game/sideQuest/headless-harness.js.map +1 -1
  38. package/bundle/typescript/src/commands/game/sideQuest/headless.js +4 -1
  39. package/bundle/typescript/src/commands/game/sideQuest/headless.js.map +1 -1
  40. package/bundle/typescript/src/commands/game/sideQuest/models/npc.d.ts +32 -0
  41. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js +122 -3
  42. package/bundle/typescript/src/commands/game/sideQuest/models/npc.js.map +1 -1
  43. package/bundle/typescript/src/commands/game/sideQuest/types/repro.d.ts +106 -0
  44. package/bundle/typescript/src/commands/game/sideQuest/types/repro.js +2 -0
  45. package/bundle/typescript/src/commands/game/sideQuest/types/repro.js.map +1 -0
  46. package/bundle/typescript/src/commands/game/sideQuest/types/seed/device-seed.d.ts +12 -0
  47. package/bundle/typescript/src/commands/game/sideQuest/ui.d.ts +9 -0
  48. package/bundle/typescript/src/commands/game/sideQuest/ui.js.map +1 -1
  49. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog-dump.d.ts +17 -1
  50. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog-dump.js +250 -36
  51. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog-dump.js.map +1 -1
  52. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog-review.d.ts +67 -1
  53. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog-review.js +99 -0
  54. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog-review.js.map +1 -1
  55. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog-vocab.d.ts +85 -0
  56. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog-vocab.js +100 -0
  57. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog-vocab.js.map +1 -0
  58. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog.d.ts +58 -3
  59. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog.js +82 -3
  60. package/bundle/typescript/src/commands/game/sideQuest/utilities/backlog.js.map +1 -1
  61. package/bundle/typescript/src/commands/game/sideQuest/utilities/commit-ref.d.ts +13 -0
  62. package/bundle/typescript/src/commands/game/sideQuest/utilities/commit-ref.js +22 -0
  63. package/bundle/typescript/src/commands/game/sideQuest/utilities/commit-ref.js.map +1 -0
  64. package/bundle/typescript/src/commands/game/sideQuest/utilities/handoff.d.ts +42 -0
  65. package/bundle/typescript/src/commands/game/sideQuest/utilities/handoff.js +57 -0
  66. package/bundle/typescript/src/commands/game/sideQuest/utilities/handoff.js.map +1 -0
  67. package/bundle/typescript/src/commands/game/sideQuest/utilities/identity.d.ts +36 -0
  68. package/bundle/typescript/src/commands/game/sideQuest/utilities/identity.js +48 -0
  69. package/bundle/typescript/src/commands/game/sideQuest/utilities/identity.js.map +1 -0
  70. package/bundle/typescript/src/commands/game/sideQuest/utilities/repro-launch.d.ts +68 -0
  71. package/bundle/typescript/src/commands/game/sideQuest/utilities/repro-launch.js +147 -0
  72. package/bundle/typescript/src/commands/game/sideQuest/utilities/repro-launch.js.map +1 -0
  73. package/bundle/typescript/src/commands/game/sideQuest/utilities/room-grid.js +55 -1
  74. package/bundle/typescript/src/commands/game/sideQuest/utilities/room-grid.js.map +1 -1
  75. package/bundle/typescript/src/commands/game/sideQuest/utilities/room-view.d.ts +27 -1
  76. package/bundle/typescript/src/commands/game/sideQuest/utilities/room-view.js +44 -11
  77. package/bundle/typescript/src/commands/game/sideQuest/utilities/room-view.js.map +1 -1
  78. package/bundle/typescript/src/commands/game/sideQuest/utilities/shared-run.d.ts +47 -2
  79. package/bundle/typescript/src/commands/game/sideQuest/utilities/shared-run.js +76 -8
  80. package/bundle/typescript/src/commands/game/sideQuest/utilities/shared-run.js.map +1 -1
  81. package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.js +39 -0
  82. package/bundle/typescript/src/commands/game/sideQuest/utilities/spots.js.map +1 -1
  83. package/bundle/typescript/src/commands/game/sideQuest/utilities/table-notes.d.ts +30 -0
  84. package/bundle/typescript/src/commands/game/sideQuest/utilities/table-notes.js +46 -0
  85. package/bundle/typescript/src/commands/game/sideQuest/utilities/table-notes.js.map +1 -0
  86. package/bundle/typescript/src/commands/game/sideQuest-backlog.sub.cmd.d.ts +1 -0
  87. package/bundle/typescript/src/commands/game/sideQuest-backlog.sub.cmd.js +46 -0
  88. package/bundle/typescript/src/commands/game/sideQuest-backlog.sub.cmd.js.map +1 -0
  89. package/bundle/typescript/src/commands/game/sideQuest.sub.cmd.d.ts +3 -1
  90. package/bundle/typescript/src/commands/game/sideQuest.sub.cmd.js +269 -42
  91. package/bundle/typescript/src/commands/game/sideQuest.sub.cmd.js.map +1 -1
  92. package/bundle/typescript/src/tools/log/log.class.d.ts +17 -0
  93. package/bundle/typescript/src/tools/log/log.class.js +30 -1
  94. package/bundle/typescript/src/tools/log/log.class.js.map +1 -1
  95. package/package.json +1 -1
@@ -1,11 +1,12 @@
1
1
  import { Command } from './command.js';
2
2
  import { hint } from '../utilities/hints.js';
3
3
  import { authToken } from '../utilities/cloud-saves.js';
4
- import { submitBacklog, fetchBacklog, postBacklogComment, postBacklogSignoff, BACKLOG_STATUSES, } from '../utilities/backlog.js';
4
+ import { submitBacklog, fetchBacklog, postBacklogComment, postBacklogSignoff, postBacklogReject, postBacklogClose, BACKLOG_STATUSES, } from '../utilities/backlog.js';
5
5
  import { noteBacklogOutcome } from '../utilities/playtester.js';
6
6
  import { takeIndex } from '../utilities/fuzzy-match.js';
7
7
  import { optionMark } from '../utilities/log-style.js';
8
- import { loadReviewQueue, reviewSnapshot, reviewItemAt, isValidationRow, markReviewAnswered, } from '../utilities/backlog-review.js';
8
+ import { statusLabel, verdictLabel, boardPrintOrder } from '../utilities/backlog-vocab.js';
9
+ import { loadReviewQueue, reviewSnapshot, reviewItemAt, isValidationRow, reviewDisplayOrder, parseRejection, parseReproRequest, markReviewAnswered, rememberBoard, boardItemAt, boardSize, } from '../utilities/backlog-review.js';
9
10
  /** One line of somebody's report, kept to a skimmable width. */
10
11
  function truncate(text, max) {
11
12
  const flat = text.replace(/\s+/g, ' ').trim();
@@ -36,7 +37,10 @@ export class BacklogCommand extends Command {
36
37
  static description = 'File a play-tester item that somebody acts on -- it goes to maka-cli.com, keeps a link to this session, and comes back checked against the rulebooks. "backlog <what you noticed>" files one; "backlog list [status]" reads the board. For private scribbles, use "note".';
37
38
  static subcommands = [
38
39
  { usage: 'backlog <text>', short: 'File an item against this session.' },
39
- { usage: 'backlog list', short: 'The whole board, newest first.' },
40
+ { usage: 'backlog list', short: 'The whole board, what needs you last.' },
41
+ { usage: 'backlog <n>', short: 'Read board item n in full.' },
42
+ { usage: 'backlog repro <n>', short: 'Play the repro bench for board item n.' },
43
+ { usage: 'backlog close <n> [why]', short: 'Close item n outright -- filed in error, duplicate, no longer real.' },
40
44
  { usage: 'backlog list <status>', short: `Filter: ${BACKLOG_STATUSES.join(', ')}.` },
41
45
  {
42
46
  usage: 'backlog review',
@@ -44,9 +48,9 @@ export class BacklogCommand extends Command {
44
48
  long: 'Items where somebody has asked you something and is waiting: a clarifying question about a report you filed, or a fix that has landed and wants confirming in play. Listed 1..n, oldest first.',
45
49
  },
46
50
  {
47
- usage: 'backlog review <n> <your answer>',
51
+ usage: 'backlog review <n> <your answer> | backlog review <n> reject <what is still wrong>',
48
52
  short: 'Answer item n from that list.',
49
- long: 'Posts your words against item n as an answer. The numbers come from the list you were last shown and do not move when you answer one, so you can work down them. "backlog review <n>" on its own reads that item in full without replying.',
53
+ long: 'Answers item n. On a row asking you to confirm a fix, your words sign it off and close it; "reject <what is still wrong>" instead sends it back to the board as open, with your reason attached. On a row asking you a question, your words are posted as the answer. "backlog review <n>" on its own reads that item in full without replying. The list is re-read and redealt from 1 after every answer.',
50
54
  },
51
55
  ];
52
56
  async execute(args = []) {
@@ -63,6 +67,44 @@ export class BacklogCommand extends Command {
63
67
  if (args.length === 0 || sub === 'list') {
64
68
  return this.list(args[1]);
65
69
  }
70
+ // A BARE NUMBER READS THAT ROW OF THE BOARD, and it has to be caught
71
+ // here for the same reason `list` and `review` are: everything that
72
+ // reaches the bottom of this method FILES AN ITEM. Without this,
73
+ // "backlog 3" -- the obvious thing to type at a numbered list --
74
+ // would quietly file a new report whose entire text is "3".
75
+ //
76
+ // `backlog repro <n>` drops into that row's bench. Both resolve
77
+ // against the BOARD snapshot, never the review queue's: they are
78
+ // different sets, so a number cannot mean a row in both.
79
+ if (/^\d{1,3}$/.test(sub)) {
80
+ return this.readBoardItem(Number(sub));
81
+ }
82
+ if (sub === 'repro') {
83
+ return this.reproFromBoard(args[1]);
84
+ }
85
+ if (sub === 'close') {
86
+ return this.closeFromBoard(args[1], args.slice(2).join(' '));
87
+ }
88
+ // ACCEPT AND REJECT ARE COMMANDS, NOT REPORTS.
89
+ //
90
+ // Claimed here for the same reason `repro` was, and after the same
91
+ // kind of accident: on 2026-08-28 a play-tester in a bench typed
92
+ // "backlog accept" and this method filed a backlog item whose entire
93
+ // text was the word "accept" -- which then went off to be vetted
94
+ // against the rulebooks, came back "needs detail", and sat on the
95
+ // open board as noise (4svHkpnDhWcXDzPsX).
96
+ //
97
+ // The rule this keeps proving: on a verb whose default is to CREATE
98
+ // something, every unclaimed word is data. Any word a player could
99
+ // reasonably read as a command has to be claimed before they can
100
+ // reach for it -- and being in a bench is exactly when "accept" is
101
+ // the obvious thing to type.
102
+ if (sub === 'accept' || sub === 'reject') {
103
+ // Route to the review answer rather than refusing: in a bench this
104
+ // is precisely what they meant, and outside one the review path
105
+ // explains itself far better than an error here could.
106
+ return this.review(args);
107
+ }
66
108
  // "review" is checked BEFORE the fall-through to file(), or
67
109
  // answering an item would file a new one whose text begins with the
68
110
  // word "review" -- the same shape as `backlog open` filing an item
@@ -118,6 +160,23 @@ export class BacklogCommand extends Command {
118
160
  // numbering. This is the ONLY place the queue is refetched.
119
161
  if (args.length === 0)
120
162
  return this.showQueue();
163
+ // YOU ARE ALREADY REVIEWING ONE (player ruling 2026-08-28: "I
164
+ // shouldn't have to pick from the list of review items -- I AM
165
+ // reviewing an item").
166
+ //
167
+ // In a repro bench the session was stood up FOR a specific report,
168
+ // so "backlog review reject <why>" needs no number: asking which
169
+ // item is asking a question the launch already answered. Checked
170
+ // before the index resolver, because "reject" is not a number and
171
+ // takeIndex would refuse it with advice to go and read a list --
172
+ // which is the exact detour being removed.
173
+ //
174
+ // Outside a bench nothing changes: with no bound item there is
175
+ // genuinely more than one thing they could mean, and the list is
176
+ // the honest answer.
177
+ const boundId = this.game?.reproItemId;
178
+ if (boundId)
179
+ return this.answerBound(boundId, args.join(' ').trim());
121
180
  const queue = reviewSnapshot();
122
181
  if (queue.length === 0) {
123
182
  // No snapshot means no list they could have counted on, so we do
@@ -164,6 +223,62 @@ export class BacklogCommand extends Command {
164
223
  // status fallback renderItem uses, so both halves agree about which
165
224
  // row this is.
166
225
  const validation = isValidationRow(item);
226
+ // "repro" IS A COMMAND, NOT AN ANSWER, and it is claimed here --
227
+ // BEFORE the sign-off below -- because everything that reaches the
228
+ // sign-off is treated as the reporter's confirmation.
229
+ //
230
+ // Claimed the hard way. On 2026-08-28 the reporter typed
231
+ // "backlog review 1 repro" at a verb that did not exist yet; it fell
232
+ // through as their answer and closed b9YeNqTNBJuk9dbrk. A sign-off
233
+ // is irreversible from the client -- the reject route refuses any
234
+ // item already carrying one -- so a word had to be reserved the
235
+ // moment it was described to anybody, not the moment it worked.
236
+ //
237
+ // Reserved on BOTH kinds of row. On a question row it would only
238
+ // post a stray comment rather than close anything, but "repro"
239
+ // is no more an answer to a question than it is a confirmation.
240
+ if (parseReproRequest(text)) {
241
+ return this.repro(item, n);
242
+ }
243
+ // "backlog review 1 reject <why>" -- the reporter says NO (user
244
+ // request 2026-08-27). Until this existed the only answer a
245
+ // validation request could take was yes: saying "still broken" left
246
+ // a comment that changed nothing, and to the board that was
247
+ // indistinguishable from saying nothing at all.
248
+ //
249
+ // The keyword is matched only as the FIRST word and only on a
250
+ // validation row, so a report whose text merely contains "reject"
251
+ // cannot bounce a fix by accident. Everything after it is the
252
+ // reason, verbatim.
253
+ const why = validation ? parseRejection(text) : null;
254
+ if (why !== null) {
255
+ // A bare "reject" is refused rather than sent. The reason is the
256
+ // whole value of a rejection -- it is what the next person fixing
257
+ // this has to work from -- and the server demands one anyway.
258
+ if (!why) {
259
+ return `Say what's still wrong: {white-fg}backlog review ${n} reject <what you saw>{/white-fg}`;
260
+ }
261
+ // The INDEXED path is never a bench (a bench answers through
262
+ // answerBound), so the runner name here is a real one.
263
+ const rejOutcome = await postBacklogReject(item.id, why, this.actor?.name);
264
+ noteBacklogOutcome(rejOutcome);
265
+ if (rejOutcome !== 'ok')
266
+ return this.explain(rejOutcome);
267
+ markReviewAnswered(item.id);
268
+ const after = await loadReviewQueue();
269
+ const head = `{red-fg}Rejected item ${n}. It's back on the board as open, with your reason on it.{/red-fg}`;
270
+ if (after.outcome !== 'ok' || !after.items) {
271
+ return `${head}\n{white-fg}(Couldn't re-read the board just now -- "backlog review" when the grid's back.){/white-fg}`;
272
+ }
273
+ if (after.items.length === 0) {
274
+ return `${head}\n{white-fg}Nothing else is waiting on you.{/white-fg}`;
275
+ }
276
+ return [
277
+ head,
278
+ `{white-fg}${after.items.length} still waiting on you:{/white-fg}`,
279
+ ...this.renderRows(after.items),
280
+ ].filter(Boolean).join('\n');
281
+ }
167
282
  const outcome = validation
168
283
  ? await postBacklogSignoff(item.id, text, this.actor?.name)
169
284
  : await postBacklogComment(item.id, text, {
@@ -206,10 +321,33 @@ export class BacklogCommand extends Command {
206
321
  return [
207
322
  head,
208
323
  `{white-fg}${left} still waiting on you:{/white-fg}`,
209
- ...refreshed.items.map((it, i) => this.renderItem(it, i + 1, { full: false })),
210
- hint(`"backlog review <n> <your answer>" -- the numbers have been redealt from 1.`),
324
+ ...this.renderRows(refreshed.items),
325
+ hint(`"backlog review <n> <your answer>" -- the numbers have been redealt from 1. "backlog review <n> repro" plays a bench.`),
211
326
  ].filter(Boolean).join('\n');
212
327
  }
328
+ /**
329
+ * THE ROWS, NUMBERED IN BOARD ORDER AND PRINTED IN REVERSE.
330
+ *
331
+ * User ruling 2026-08-27, twice: "so I see 1. as the first, not 8."
332
+ * and then "REVERSE THE ORDER OF THE REVIEW ITEMS."
333
+ *
334
+ * A terminal scrolls, so the LAST line printed is the one under the
335
+ * player's eye when the list stops. Printing 1..n top-to-bottom put
336
+ * the HIGHEST number closest to the prompt -- the list read as though
337
+ * it started at 8. Reversing the print order puts item 1 last, which
338
+ * is where it is actually read first.
339
+ *
340
+ * THE NUMBERS STAY BOUND TO THEIR ITEMS. They are assigned in the
341
+ * server's order BEFORE the reverse, so `backlog review 1` resolves
342
+ * to the same row it did before and to the same row reviewItemAt
343
+ * returns. Renumbering to match the printed order would have meant
344
+ * the display and the index disagreed -- and an answer posted against
345
+ * the wrong item lands somewhere plausible enough that nobody
346
+ * notices, which is the one failure this queue must not have.
347
+ */
348
+ renderRows(items) {
349
+ return reviewDisplayOrder(items).map(({ item, n }) => this.renderItem(item, n, { full: false }));
350
+ }
213
351
  /** Fetch and render the queue, adopting its order as the numbering. */
214
352
  async showQueue() {
215
353
  const { outcome, items } = await loadReviewQueue();
@@ -220,8 +358,8 @@ export class BacklogCommand extends Command {
220
358
  }
221
359
  return [
222
360
  `{cyan-fg}WAITING ON YOU{/cyan-fg}`,
223
- ...items.map((it, i) => this.renderItem(it, i + 1, { full: false })),
224
- hint(`"backlog review <n> <your answer>" replies. "backlog review <n>" alone reads one in full.`),
361
+ ...this.renderRows(items),
362
+ hint(`"backlog review <n> <your answer>" replies, "reject <what is wrong>" sends it back. "backlog review <n> repro" drops you into its bench.`),
225
363
  ].filter(Boolean).join('\n');
226
364
  }
227
365
  /**
@@ -260,8 +398,292 @@ export class BacklogCommand extends Command {
260
398
  if (validation && it.commits?.length) {
261
399
  rows.push(` {white-fg}fixed by: ${it.commits.slice(0, 3).join(', ')}{/white-fg}`);
262
400
  }
401
+ // A BENCH EXISTS -- said on the row, so it is visible while skimming
402
+ // rather than found by typing "repro" at each one in turn.
403
+ if (it.repro?.steps?.length) {
404
+ rows.push(` {green-fg}bench ready -- "backlog review ${n} repro"{/green-fg}`);
405
+ }
263
406
  return rows.join('\n');
264
407
  }
408
+ /**
409
+ * ANSWERING THE ITEM THIS SESSION IS ABOUT -- no index, because there
410
+ * is only one thing it could mean.
411
+ *
412
+ * backlog review reject <what is still wrong>
413
+ * backlog review accept [what you saw]
414
+ * backlog review <what you saw> (accept, said plainly)
415
+ * backlog review repro (re-read the steps)
416
+ *
417
+ * `accept` is spelled out as a keyword AND left optional, which looks
418
+ * redundant and is not. Bare prose has always signed an item off, and
419
+ * removing that would break the habit of everyone already using it --
420
+ * but a person who has just typed "reject <reason>" reasonably
421
+ * expects "accept <reason>" to exist, and finding that it does not is
422
+ * how somebody ends up typing "accept" alone as their whole
423
+ * confirmation note.
424
+ */
425
+ async answerBound(itemId, text) {
426
+ // WHOSE WORD IS THIS? The account is the identity that matters and
427
+ // the server resolves it from the token -- but the runner NAME is
428
+ // read from the session, and in a bench that is the pregen. Your
429
+ // first playtest recorded six sign-offs as "signed by Testbed", a
430
+ // street name belonging to nobody. Omitted here so the board falls
431
+ // back to the handle: no name is more honest than a fictional one.
432
+ const asRunner = this.game?.isBench ? undefined : this.actor?.name;
433
+ const fresh = await loadReviewQueue();
434
+ if (fresh.outcome !== 'ok' || !fresh.items)
435
+ return this.explain(fresh.outcome);
436
+ const item = fresh.items.find(i => i.id === itemId);
437
+ if (!item) {
438
+ // Already answered, or moved on while the bench was open. Not an
439
+ // error -- say what is true and do not post anything.
440
+ return `This item isn't waiting on you any more -- it may already be answered.${hint(` ("backlog review" shows what is.)`)}`;
441
+ }
442
+ if (text.length === 0)
443
+ return this.renderItem(item, 1, { full: true });
444
+ if (parseReproRequest(text))
445
+ return this.repro(item, 1);
446
+ const validation = isValidationRow(item);
447
+ const why = validation ? parseRejection(text) : null;
448
+ if (why !== null) {
449
+ if (!why)
450
+ return `Say what's still wrong: {white-fg}backlog review reject <what you saw>{/white-fg}`;
451
+ const outcome = await postBacklogReject(item.id, why, asRunner);
452
+ noteBacklogOutcome(outcome);
453
+ if (outcome !== 'ok')
454
+ return this.explain(outcome);
455
+ markReviewAnswered(item.id);
456
+ return `{red-fg}Rejected. It's back on the board as open, with your reason on it.{/red-fg}`
457
+ + this.closeBench('rejected');
458
+ }
459
+ // "accept" is stripped when it leads, so the note is what follows
460
+ // rather than the word itself.
461
+ const note = /^accepts?\b/i.test(text) ? text.replace(/^accepts?\b\s*/i, '').trim() : text;
462
+ const outcome = validation
463
+ ? await postBacklogSignoff(item.id, note || 'Confirmed in play.', asRunner)
464
+ : await postBacklogComment(item.id, note, { kind: 'answer', awaitingReporter: false, runnerName: asRunner });
465
+ noteBacklogOutcome(outcome);
466
+ if (outcome !== 'ok')
467
+ return this.explain(outcome);
468
+ markReviewAnswered(item.id);
469
+ return (validation
470
+ ? `{green-fg}Signed off. That closes it -- your word is what the board was waiting for.{/green-fg}`
471
+ : `{cyan-fg}Answered. It's on the record against your report.{/cyan-fg}`)
472
+ + this.closeBench(validation ? 'signed off' : 'answered');
473
+ }
474
+ /**
475
+ * A BENCH IS DONE WHEN THE ITEM IS ANSWERED (player request
476
+ * 2026-08-28: "after accepting or rejecting, give a 3s timeout and
477
+ * then close the session").
478
+ *
479
+ * It exists to answer one report; once that is posted there is
480
+ * nothing left in it. Three seconds so the confirmation above can
481
+ * actually be read -- closing on the same frame would wipe the only
482
+ * evidence the answer went anywhere.
483
+ *
484
+ * ONLY in a bench, and only after a post that SUCCEEDED. Answering
485
+ * from an ordinary session must never shut the game down: that is
486
+ * somebody's run, not a scratch scene, and the failure would be
487
+ * spectacular.
488
+ */
489
+ /**
490
+ * ASK THE LAUNCHER FOR THE BENCH, then leave so it can run it.
491
+ *
492
+ * Returns the line to print, or undefined when this session cannot
493
+ * hand off -- in which case the caller falls back to simply showing
494
+ * the steps, which is strictly better than an error.
495
+ *
496
+ * REFUSED DURING A SHARED RUN, and this is the guard that matters:
497
+ * quitting to a bench would drop the table and take everyone else's
498
+ * evening with it. The refusal names the route that IS safe from
499
+ * anywhere, because a refusal that does not is just a wall.
500
+ */
501
+ async requestBenchHandoff(item) {
502
+ const game = this.game;
503
+ const dir = game?.sessionDir;
504
+ if (!game || !dir)
505
+ return undefined;
506
+ if (game.remoteSession) {
507
+ return `You're on a shared run -- dropping into a bench would end the table for everyone.`
508
+ + `\n{white-fg}Outside the game: {/white-fg}maka play:shadowrun --repro ${item.id}`;
509
+ }
510
+ try {
511
+ const { writeHandoff } = await import('../utilities/handoff.js');
512
+ writeHandoff(dir, { kind: 'repro', itemId: item.id });
513
+ }
514
+ catch {
515
+ // Could not leave the note -- say nothing about benches and let
516
+ // the caller print the steps instead. Never strand the player.
517
+ return undefined;
518
+ }
519
+ // Exiting 0 is what seals the save (see the quit hook in ui.ts), so
520
+ // the resume the launcher performs picks up exactly here.
521
+ game.closeAfter(3000, `repro handoff for ${item.id}`);
522
+ return [
523
+ `{cyan-fg}Dropping into the bench for this item...{/cyan-fg}`,
524
+ `{white-fg}Your run is saved. You'll come straight back to it when you leave the bench.{/white-fg}`,
525
+ ].join('\n');
526
+ }
527
+ closeBench(reason) {
528
+ if (!this.game?.isBench)
529
+ return '';
530
+ this.game.closeAfter(3000, reason);
531
+ return `\n{white-fg}Closing the bench...{/white-fg}`;
532
+ }
533
+ /**
534
+ * THE BENCH FOR ONE ITEM.
535
+ *
536
+ * Wired as far as it currently goes, and no further: the repro spec
537
+ * lives on the board (server work, not yet deployed), so today this
538
+ * can only report honestly that there is nothing attached. What it
539
+ * must NEVER do is fall through to the sign-off, which is the whole
540
+ * reason it exists this early.
541
+ */
542
+ async repro(item, n) {
543
+ const repro = item.repro;
544
+ // IN A BENCH, "repro" RE-READS. You are already standing in it, so
545
+ // the only useful meaning is "show me the steps again".
546
+ //
547
+ // OUTSIDE ONE, it TAKES YOU THERE -- and it does that by leaving.
548
+ // The scene cannot be swapped underneath a live session: the game
549
+ // holds the terminal through blessed, and a second blessed screen
550
+ // on one terminal is the exact failure the parent-as-janitor rule
551
+ // exists to prevent. So the game seals its save, leaves a note for
552
+ // the launcher, and exits; the launcher plays the bench and brings
553
+ // the same runner back on a resume. Quit -> bench -> back, which is
554
+ // what was asked for, and it needs no new run lifecycle.
555
+ if (repro && repro.steps.length > 0 && !this.game?.isBench) {
556
+ const handoff = await this.requestBenchHandoff(item);
557
+ if (handoff)
558
+ return handoff;
559
+ }
560
+ if (!repro || repro.steps.length === 0) {
561
+ // ABSENCE IS ORDINARY, and must not read as breakage. Plenty of
562
+ // items are about a verb, a menu or a note and have no scene to
563
+ // stand in. Say so, and say the thing that actually matters here:
564
+ // nothing was recorded against the item.
565
+ return [
566
+ `{yellow-fg}No repro bench is attached to item ${n}.{/yellow-fg}`,
567
+ `{white-fg}Not every fix has one -- some are about a verb or a menu rather than a place.{/white-fg}`,
568
+ hint(` Nothing was posted, and the item was NOT signed off.`),
569
+ ].filter(Boolean).join('\n');
570
+ }
571
+ const lines = [`{cyan-fg}REPRO -- item ${n}{/cyan-fg}`];
572
+ if (repro.premise)
573
+ lines.push(`{white-fg}${repro.premise}{/white-fg}`, '');
574
+ // The steps are printed 1..n whatever the queue does, because these
575
+ // are read top-down and typed in order -- the review LIST reverses
576
+ // for a scrolling terminal, and a sequence of instructions is the
577
+ // one thing that must not.
578
+ repro.steps.forEach((step, i) => lines.push(` ${i + 1}. {white-fg}${step}{/white-fg}`));
579
+ if (repro.expect)
580
+ lines.push('', `{white-fg}Passing looks like: ${repro.expect}{/white-fg}`);
581
+ lines.push('', `{white-fg}Play it: {/white-fg}maka play:shadowrun --repro ${item.id}`);
582
+ lines.push(hint(this.game?.isBench
583
+ ? ` "review accept <what you saw>" or "review reject <what is wrong>" answers it -- no number needed.`
584
+ : ` Answer with "backlog review ${n} <what you saw>" when you have looked.`));
585
+ return lines.filter(Boolean).join('\n');
586
+ }
587
+ /**
588
+ * ONE ROW OF THE BOARD, in full.
589
+ *
590
+ * Resolved against the snapshot that was PRINTED, never a fresh
591
+ * fetch -- the same invariant the review queue holds, and for the
592
+ * same reason: a list that reshuffles between being read and being
593
+ * numbered at hands the player a different item than the one they
594
+ * counted. With no snapshot it shows the board rather than guessing
595
+ * which list they had in mind.
596
+ */
597
+ async readBoardItem(n) {
598
+ if (boardSize() === 0) {
599
+ const shown = await this.list();
600
+ return `${shown}\n\n{white-fg}(Pick from the list above -- "backlog <n>".){/white-fg}`;
601
+ }
602
+ const item = boardItemAt(n);
603
+ if (!item)
604
+ return `There's no item ${n}. The board runs 1-${boardSize()}.`;
605
+ const lines = [`${this.statusMark(item.status)} ${item.text}`];
606
+ const who = [item.runnerName, item.reporterHandle].filter(Boolean).join(' / ');
607
+ if (who)
608
+ lines.push(` {white-fg}${who}{/white-fg}`);
609
+ const v = item.vetting;
610
+ if (v?.verdict) {
611
+ lines.push(` ${this.verdictTag(v.verdict)}${v.justification ? ` ${v.justification}` : ''}`);
612
+ }
613
+ for (const c of (v?.citations ?? []).slice(0, 3)) {
614
+ lines.push(` {cyan-fg}${c.book} p.${c.page ?? '?'}${c.section ? ` (${c.section})` : ''}{/cyan-fg}`);
615
+ }
616
+ if (item.commits?.length) {
617
+ lines.push(` {white-fg}fixed by: ${item.commits.map(c => c.slice(0, 8)).join(', ')}{/white-fg}`);
618
+ }
619
+ if (item.repro?.steps?.length) {
620
+ lines.push('', `{green-fg}BENCH -- "backlog repro ${n}" drops you into it{/green-fg}`);
621
+ if (item.repro.premise)
622
+ lines.push(` {white-fg}${item.repro.premise}{/white-fg}`);
623
+ item.repro.steps.forEach((step, i) => lines.push(` ${i + 1}. {white-fg}${step}{/white-fg}`));
624
+ if (item.repro.expect)
625
+ lines.push(` {white-fg}passing looks like: ${item.repro.expect}{/white-fg}`);
626
+ }
627
+ return lines.join('\n');
628
+ }
629
+ /**
630
+ * PLAY ANY BOARD ITEM'S BENCH, not just one awaiting review.
631
+ *
632
+ * The review queue holds only what is waiting on YOU, so once an item
633
+ * is answered its bench became unreachable from in-game -- even
634
+ * though checking a fix a second time, or showing somebody what you
635
+ * saw, are ordinary things to want. The board is the wider set and
636
+ * this is its verb.
637
+ */
638
+ async reproFromBoard(arg) {
639
+ if (boardSize() === 0) {
640
+ const shown = await this.list();
641
+ return `${shown}\n\n{white-fg}(Pick from the list above -- "backlog repro <n>".){/white-fg}`;
642
+ }
643
+ const n = Number(arg);
644
+ const item = Number.isInteger(n) ? boardItemAt(n) : undefined;
645
+ if (!item)
646
+ return `Which one? "backlog repro <n>" -- the board runs 1-${boardSize()}.`;
647
+ if (!item.repro?.steps?.length) {
648
+ return `No bench is attached to that one.${hint(` Not every fix has a place to stand in -- "backlog list" marks the ones that do.`)}`;
649
+ }
650
+ // Same handoff the review path uses: the game cannot swap its own
651
+ // scene, so it leaves a note and the launcher does the rest.
652
+ const handoff = await this.requestBenchHandoff({ id: item._id, text: item.text });
653
+ return handoff ?? `Couldn't hand off to the bench from here.`;
654
+ }
655
+ /**
656
+ * CLOSE A BOARD ROW OUTRIGHT -- no verdict, no evidence, no sign-off.
657
+ *
658
+ * "backlog close <n> [why]". For an item that should never have been
659
+ * filed: a duplicate, a mis-typed command that became a report, or
660
+ * something you have since decided was never real.
661
+ *
662
+ * Deliberately NOT one of the review verbs. Accepting a mistake would
663
+ * record a confirmation nobody made; rejecting it would send
664
+ * imaginary work back to whoever fixed it. This says the third thing.
665
+ */
666
+ async closeFromBoard(arg, why = '') {
667
+ if (boardSize() === 0) {
668
+ const shown = await this.list();
669
+ return `${shown}\n\n{white-fg}(Pick from the list above -- "backlog close <n> [why]".){/white-fg}`;
670
+ }
671
+ const n = Number(arg);
672
+ const item = Number.isInteger(n) ? boardItemAt(n) : undefined;
673
+ if (!item)
674
+ return `Which one? "backlog close <n> [why]" -- the board runs 1-${boardSize()}.`;
675
+ const outcome = await postBacklogClose(item._id, why.trim(), this.game?.isBench ? undefined : this.actor?.name);
676
+ noteBacklogOutcome(outcome);
677
+ if (outcome === 'forbidden') {
678
+ return `That one isn't yours to close -- only the play-tester who filed it, or an admin.`;
679
+ }
680
+ if (outcome !== 'ok')
681
+ return this.explain(outcome);
682
+ // The board is re-read on the next "backlog list"; the numbering
683
+ // belongs to what was printed, so it is not silently redealt here.
684
+ return `{white-fg}Closed: ${truncate(item.text, 60)}{/white-fg}`
685
+ + hint(` It's off the board as "wont-fix", with your reason on the record.`);
686
+ }
265
687
  async list(statusArg) {
266
688
  let status;
267
689
  if (statusArg) {
@@ -285,9 +707,37 @@ export class BacklogCommand extends Command {
285
707
  // "headers like that are too cluttery"). The heading itself stays --
286
708
  // it carries what the panel divider can't say for it.
287
709
  const lines = [`BACKLOG (${items.length}${status ? `, ${status}` : ''})`];
288
- for (const item of items)
289
- lines.push(...this.row(item));
290
- const footer = hint(`"backlog <what you noticed>" files one. Status moves are an admin's call.`);
710
+ // PRINTED LOWEST-PRIORITY FIRST, so the rows that need you land at
711
+ // the BOTTOM, closest to the prompt -- which is where a scrolling
712
+ // panel leaves your eye.
713
+ //
714
+ // The server sorts in-review first, and that is the right order for
715
+ // a CALLER to iterate. Printing it top-down put wont-fix under the
716
+ // cursor and scrolled the urgent rows off the top: reported here in
717
+ // exactly those words ("I see 'wont' items first"), and reported the
718
+ // same way about the out-of-game board an hour earlier -- which I
719
+ // fixed there and did not carry across, so the same list read
720
+ // correctly in one place and backwards in the other.
721
+ //
722
+ // Reversed at the point of PRINT, never in the data, so the
723
+ // server's priority and which end you read first stay separate
724
+ // facts. Same rule as reviewDisplayOrder and as the CLI dump.
725
+ // NUMBERED, so there is something to name. In-game the rows carried
726
+ // neither an id nor a number, which left the board readable and
727
+ // completely inert -- and an id would not have helped, because
728
+ // selecting text out of a blessed panel to paste back into it is
729
+ // not a thing anybody should be asked to do.
730
+ //
731
+ // Numbers are bound in the SERVER's order and only then reversed for
732
+ // printing, exactly as reviewDisplayOrder does: bind first, reverse
733
+ // second, or "3" labels one row and resolves to another.
734
+ rememberBoard(items);
735
+ const numbered = items.map((item, i) => ({ item, n: i + 1 }));
736
+ for (const { item, n } of boardPrintOrder(numbered)) {
737
+ const rows = this.row(item);
738
+ lines.push(`${optionMark(n)}${rows[0].trimStart()}`, ...rows.slice(1));
739
+ }
740
+ const footer = hint(`"backlog <n>" reads one in full, "backlog repro <n>" plays its bench. "backlog <what you noticed>" files one.`);
291
741
  if (footer)
292
742
  lines.push('', footer);
293
743
  return lines.join('\n');
@@ -315,29 +765,37 @@ export class BacklogCommand extends Command {
315
765
  }
316
766
  return out;
317
767
  }
768
+ /** Blessed's ink for a shared tone. Gray is deliberately absent: it
769
+ * reads as absent on this screen (playtest ruling), so 'dim' lands on
770
+ * plain white -- unemphatic without being unreadable. */
771
+ paint(tone, text) {
772
+ const fg = tone === 'good' ? 'green'
773
+ : tone === 'bad' ? 'red'
774
+ : tone === 'warn' ? 'yellow'
775
+ : tone === 'alert' ? 'magenta'
776
+ : tone === 'info' ? 'cyan'
777
+ : 'white';
778
+ return `{${fg}-fg}${text}{/${fg}-fg}`;
779
+ }
780
+ /** The label and its loudness both come from utilities/backlog-vocab,
781
+ * which the out-of-game dump reads too -- one place decides what a
782
+ * status is CALLED and how much it matters, and each surface maps
783
+ * that to its own palette.
784
+ *
785
+ * An in-review row now paints MAGENTA rather than the same cyan as
786
+ * in-progress. It is the one status that is waiting on the reader,
787
+ * and it did not look like it -- the same complaint, in a smaller
788
+ * font, as the board being unreadable out of game. */
318
789
  statusMark(status) {
319
- switch (status) {
320
- case 'complete': return '{green-fg}[done]{/green-fg}';
321
- case 'in-progress': return '{cyan-fg}[wip] {/cyan-fg}';
322
- // Fixed, and waiting on the reporter to confirm it in play. It
323
- // used to fall through to the default and read "[open]" -- an
324
- // item awaiting your word shown as one nobody had touched.
325
- case 'in-review': return '{cyan-fg}[you?] {/cyan-fg}';
326
- case 'wont-fix': return '{cyan-fg}[no] {/cyan-fg}';
327
- default: return '{yellow-fg}[open]{/yellow-fg}';
328
- }
790
+ const { label, tone } = statusLabel(status);
791
+ return this.paint(tone, label);
329
792
  }
330
793
  /** The verdict is about the GAME, not the rulebooks (reframed
331
794
  * 2026-08-27). Asking canon whether a stale map mattered graded every
332
795
  * real defect out of scope and cited a zoo at it. */
333
796
  verdictTag(verdict) {
334
- switch (verdict) {
335
- case 'canon-violation': return `{red-fg}breaks the rules:{/red-fg}`;
336
- case 'bug': return `{red-fg}bug:{/red-fg}`;
337
- case 'enhancement': return `{yellow-fg}wants building:{/yellow-fg}`;
338
- case 'works-as-intended': return `{green-fg}working as meant:{/green-fg}`;
339
- default: return `{cyan-fg}needs detail:{/cyan-fg}`;
340
- }
797
+ const { label, tone } = verdictLabel(verdict);
798
+ return this.paint(tone, `${label}:`);
341
799
  }
342
800
  explain(outcome) {
343
801
  switch (outcome) {