@thebattlearena/corner 0.10.1-beta.82 → 0.10.1-beta.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -444,7 +444,12 @@ y al pasar a «espera al juez» el fin y el rango (`--range A..B` lo fija a mano
444
444
  `plan.jsonl`, que es lo que lee el modo «Pelea del ring» de PR Review Lab. Cuando `humanReviewEvery`
445
445
  bloques (2 por default, `null` lo apaga) esperan al juez, suena la campana en PR Review Lab y
446
446
  `brief.mjs` no le arma instrucciones al siguiente luchador hasta que el humano revise ese corte en un
447
- solo diff y firme un veredicto con Tape. PR Review Lab lo escribe en `<combate>/review-verdicts.jsonl`
447
+ solo diff y firme un veredicto con Tape. Con `parallelBlocks: 2` (el default) dos bloques corren a la vez **en el mismo
448
+ worktree** mientras nada espere la revisión del humano (con algo esperando, uno a la vez), cada luchador sobre los archivos que su bloque declaró (`--files "<globs>"` al estimar o
449
+ arrancar): el script no deja arrancar un bloque con archivos de otro en curso, `mask-guard` rechaza un
450
+ commit con archivos de otro bloque (los de `generatedPaths`, como los docs que regenera un pre-commit,
451
+ no son de nadie: el árbitro los regenera al cerrar la oleada), y `concept … --block <n>` retira solo al
452
+ luchador de ese bloque. Los tests corren al terminar el bloque, sobre lo que tocó. Y mientras el humano revisa un corte, un luchador puede adelantar UN bloque más cuyos archivos no toque el corte: `brief.mjs wrestler … --lookahead "<globs>"`. PR Review Lab lo escribe en `<combate>/review-verdicts.jsonl`
448
453
  y el ring lo lee en el siguiente brief o `summary`: si pasa, los bloques quedan en «hecho»; si no
449
454
  pasa, vuelven a «en curso» con el feedback y el Tape como rebote, que el brief del siguiente luchador
450
455
  trae al principio. Cada veredicto queda en `decisions.md` como decisión del juez (y en Engram).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thebattlearena/corner",
3
- "version": "0.10.1-beta.82",
3
+ "version": "0.10.1-beta.84",
4
4
  "description": "Corner: tu esquina de Battle Arena en Claude Code (el plugin `battlearena`). Hooks de tracking, objetivo del turno, plan de batalla /battlearena:conquer, Ringside (el panel de tu día), el ring y la radio. Zero-dependency.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -79,6 +79,11 @@ numbered list of behavior invariants.
79
79
  outside tests. One used only by its own spec, or by nobody, is a finding (dead code).
80
80
  - **Comments.** Narrating, stale or now-wrong comments in every file the assignment covers,
81
81
  **including files the range did not edit** when the brief names them.
82
+ - **The same question asked again (PR31).** Grep the concept for the same condition (a null
83
+ check, an optional, a `?.`, a status test) asked for the same reason in several places: three
84
+ or more is a finding, with where it should be settled once.
85
+ - **Guards against a provider's events (P16).** A guard against an event a provider's SDK sends
86
+ cites the SDK code that can send it (`node_modules/<sdk>/…:line`); without it, it is a finding.
82
87
  - **Commit messages.** Each message in the range (`git -C <wt> log --format=%B <A>..<B>`) must
83
88
  not carry bout-internal ids (D12, P7, HP3, PR5…): they mean nothing in the repo's history.
84
89
  5. Return: a table per file (findings with rule ids), a table per invariant (holds / broken, with
@@ -37,7 +37,7 @@ export function rulesBlock({ sources, entries }) {
37
37
  }
38
38
 
39
39
  /**
40
- * The decisions a brief carries: every foul (what must not be repeated), the ones whose text names
40
+ * The decisions a brief carries: every foul (what must not be repeated), every ruling of the judge, the ones whose text names
41
41
  * a file of the range, and the last `last`; in ledger order, each once. PURE.
42
42
  */
43
43
  export function decisionsExcerpt(
@@ -54,6 +54,8 @@ export function decisionsExcerpt(
54
54
  (line) =>
55
55
  tail.has(line) ||
56
56
  /\bFOUL\b/.test(line) ||
57
+ // What the human ruled stays in every brief of the bout, like a foul.
58
+ / · judge · /.test(line) ||
57
59
  names.some((name) => line.includes(name)),
58
60
  );
59
61
  return kept.length ? kept.join('\n') : '(no decisions yet)';
@@ -168,7 +168,7 @@ export function guard(input) {
168
168
  const problem = problemOf({ ...brief, check, role, pr: state.pr });
169
169
  appendFileSync(
170
170
  join(bout, GUARD_LOG),
171
- `${JSON.stringify({ at: new Date().toISOString(), role, mode, path: brief.path, ok: !problem, problem })}\n`,
171
+ `${JSON.stringify({ at: new Date().toISOString(), role, mode, path: brief.path, toolUseId: input.tool_use_id ?? null, ok: !problem, problem })}\n`,
172
172
  );
173
173
  return answer({
174
174
  input: input.tool_input,
@@ -44,7 +44,13 @@ import {
44
44
  } from './config.mjs';
45
45
  import { recallSection } from './engram.mjs';
46
46
  import { conceptsSinceRelay, relayDue } from './relay.mjs';
47
- import { reviewTurn } from './review-blocks.mjs';
47
+ import { boutLeases } from './lanes.mjs';
48
+ import {
49
+ lookaheadRefusal,
50
+ reviewInputs,
51
+ reviewTurn,
52
+ runningBlocks,
53
+ } from './review-blocks.mjs';
48
54
  import { setPlanState } from './tech-plan-log.mjs';
49
55
  import { loadRules } from './rule-ids.mjs';
50
56
 
@@ -166,6 +172,85 @@ function boutVars({ dir, state, config }, values) {
166
172
  };
167
173
  }
168
174
 
175
+ /**
176
+ * The wrestler's block and the files it holds, next to the files other running blocks hold (none:
177
+ * one block at a time, nothing to add). With parallelBlocks > 1 and another block running, the
178
+ * brief names its block (--block <n>).
179
+ */
180
+ function laneSection(bout, block) {
181
+ const running = boutLeases(bout.dir);
182
+ if (!block) {
183
+ if ((bout.config.parallelBlocks ?? 1) > 1 && running.length > 1)
184
+ throw new Error(
185
+ `${running.length} blocks are running (${running.map((lease) => `B${lease.n}`).join(', ')}): name this wrestler's block with --block <n>`,
186
+ );
187
+ return [];
188
+ }
189
+ const mine = running.find((lease) => lease.n === Number(block));
190
+ if (!mine)
191
+ throw new Error(
192
+ `B${block} is not «en curso»: start it with judges-card.mjs plan <pr> ${block} "en curso" --files "<globs>" first`,
193
+ );
194
+ const others = running.filter((lease) => lease.n !== mine.n);
195
+ const list = (files) =>
196
+ files.length
197
+ ? files.map((glob) => `\`${glob}\``).join(', ')
198
+ : '(none declared)';
199
+ return [
200
+ `## Your block: B${mine.n} «${mine.title}»`,
201
+ [
202
+ `You touch only files matching ${list(mine.files)}.`,
203
+ others.length
204
+ ? `Other wrestlers work in this same worktree right now: ${others.map((lease) => `B${lease.n} holds ${list(lease.files)}`).join('; ')}. Never edit, stage or commit their files; mask-guard refuses such a commit. If you need one of them, stop and tell me: I move it between blocks.`
205
+ : 'No other block is running now.',
206
+ `Stage by explicit paths only, never \`git add -A\`, \`git stash\`, \`checkout\`, \`reset\` or \`rebase\` (they move the other wrestler's work too).`,
207
+ `Gates once, when the block is done, not after each edit: the tests of the files you touched, and the typecheck reading only the errors in your files (another block's work in progress may not compile yet).${(bout.config.generatedPaths ?? []).length ? ` Generated files (${list(bout.config.generatedPaths)}) are nobody's: leave them unstaged; I regenerate them when the wave closes.` : ''}`,
208
+ ].join('\n\n'),
209
+ ];
210
+ }
211
+
212
+ /**
213
+ * While the human reviews a cut, one wrestler may work ahead on a block whose declared files the
214
+ * cut does not touch (`--lookahead "<globs>"`). Without it, or when it overlaps or another block is
215
+ * running, the pause holds. Returns the brief's section that bounds the work ahead.
216
+ */
217
+ function workAhead(bout, lookahead, turn, block) {
218
+ if (!lookahead)
219
+ throw new Error(
220
+ `${turn.due}. Or work ahead on one block whose files the cut does not touch: --lookahead "<glob>,<glob>"`,
221
+ );
222
+ const globs = String(lookahead)
223
+ .split(',')
224
+ .map((glob) => glob.trim())
225
+ .filter(Boolean);
226
+ const { plan, log } = reviewInputs(bout.dir);
227
+ const waitingFiles = [
228
+ ...new Set(
229
+ turn.waiting.flatMap((item) => {
230
+ const range = log
231
+ .filter((line) => line.n === item.n && line.range)
232
+ .at(-1)?.range;
233
+ return range
234
+ ? git(bout.state.worktree, ['diff', '--name-only', range])
235
+ .split('\n')
236
+ .filter(Boolean)
237
+ : [];
238
+ }),
239
+ ),
240
+ ];
241
+ const refusal = lookaheadRefusal({
242
+ globs,
243
+ waitingFiles,
244
+ // The block this brief is for may already be «en curso»: it is not another block ahead.
245
+ running: runningBlocks(plan).filter((item) => item.n !== Number(block)),
246
+ });
247
+ if (refusal) throw new Error(`${turn.due}. ${refusal}`);
248
+ return [
249
+ '## Working ahead of the human review (the cut under review must not move)',
250
+ `The human is reviewing ${turn.waiting.map((item) => `B${item.n}`).join(' + ')}. You work ahead on one block and touch only files matching: ${globs.map((glob) => `\`${glob}\``).join(', ')}. Never edit a file of the cut under review (${waitingFiles.map((file) => `\`${file}\``).join(', ') || 'none'}); if you need one, stop and tell me. If the verdict sends the cut back, its rebound goes first.`,
251
+ ].join('\n\n');
252
+ }
253
+
169
254
  /** The open rebounds the human's review sent back, as a section of the brief (none: nothing). */
170
255
  function humanReview(dir) {
171
256
  const path = join(dir, 'rebounds.md');
@@ -180,6 +265,20 @@ function humanReview(dir) {
180
265
  : [];
181
266
  }
182
267
 
268
+ /**
269
+ * The files the bout changed so far (merge base with its base..head): what Engram searches on when
270
+ * the brief's own range is empty, as a wrestler starting a new concept has no commit yet.
271
+ */
272
+ function boutFiles({ worktree, base }) {
273
+ try {
274
+ return git(worktree, ['diff', '--name-only', `origin/${base}...HEAD`])
275
+ .split('\n')
276
+ .filter(Boolean);
277
+ } catch {
278
+ return [];
279
+ }
280
+ }
281
+
183
282
  const real = (path) => (existsSync(path) ? realpathSync(path) : path);
184
283
 
185
284
  async function generate(kind, [pr, ...argv]) {
@@ -194,16 +293,18 @@ async function generate(kind, [pr, ...argv]) {
194
293
  throw new Error(
195
294
  `a relay is due (${conceptsSinceRelay(bout.state)} concepts since the last one): rewrite the card's «How to resume», run judges-card.mjs relay ${pr}, then brief the next wrestler`,
196
295
  );
296
+ let ahead = null;
197
297
  if (kind === 'wrestler') {
198
298
  // The human's verdicts from PR Review Lab first: a fail comes back as a rebound for this brief.
199
299
  const turn = reviewTurn(
200
300
  bout.dir,
201
301
  bout.config.humanReviewEvery,
202
302
  (n, to, ref) => setPlanState(bout.dir, n, to, ref),
303
+ bout.config.parallelBlocks ?? 1,
203
304
  );
204
305
  if (turn.applied.length)
205
306
  console.error(`human review applied: ${turn.applied.join(' · ')}`);
206
- if (turn.due) throw new Error(turn.due);
307
+ if (turn.due) ahead = workAhead(bout, values.lookahead, turn, values.block);
207
308
  }
208
309
  const own = values.vars ? JSON.parse(readFileSync(values.vars, 'utf8')) : {};
209
310
  const roots = [
@@ -237,17 +338,19 @@ async function generate(kind, [pr, ...argv]) {
237
338
  ? await recallSection(bout.config, {
238
339
  slug: repoSlug(projectDir()),
239
340
  pr,
240
- files,
341
+ files: files.length ? files : boutFiles(bout.state),
241
342
  })
242
343
  : null;
243
344
  // The task goes first and the rules last, as the reference: a brief that opens with the rules
244
345
  // reads as a checklist, and a 2026-11 lab on #1212 found fewer real problems that way.
245
346
  const body = [
246
347
  text.trim(),
247
- '## Decisions already taken (excerpt: every foul, the ones on these files, the last 20)',
348
+ '## Decisions already taken (excerpt: every foul, every ruling of the judge, the ones on these files, the last 20)',
248
349
  decisions,
249
350
  `The whole ledger: \`${decisionsPath}\`.`,
250
351
  ...humanReview(bout.dir),
352
+ ...(ahead ? [ahead] : []),
353
+ ...(kind === 'wrestler' ? laneSection(bout, values.block) : []),
251
354
  ...(learned ? [learned] : []),
252
355
  ...(shape.rules ? ['---', rules.block] : []),
253
356
  ].join('\n\n');
@@ -262,6 +365,9 @@ async function generate(kind, [pr, ...argv]) {
262
365
  mode: values.mode,
263
366
  pr,
264
367
  n,
368
+ ...(kind === 'wrestler' && values.block
369
+ ? { block: Number(values.block) }
370
+ : {}),
265
371
  rules: shape.rules ? rules.hash : 'none',
266
372
  };
267
373
  const file = join(briefs, `${kind}-${values.mode}-${n}.md`);
@@ -16,6 +16,7 @@ import {
16
16
  RING_ROOT,
17
17
  } from './config.mjs';
18
18
  import { findBout, sessionRegistered } from './live.mjs';
19
+ import { openHumanReturns, TAPE_HINT } from './review-blocks.mjs';
19
20
  import { checklistLines, loadRules } from './rule-ids.mjs';
20
21
 
21
22
  /** Claude Code's cap for SessionStart output, with room to spare. */
@@ -55,10 +56,25 @@ export function foulLines(decisions, max = 3000) {
55
56
  }
56
57
 
57
58
  /** The text a compacted referee gets back. PURE. */
58
- export function compactContext({ pr, dir, checklist, fouls, resume }) {
59
+ export function compactContext({
60
+ pr,
61
+ dir,
62
+ checklist,
63
+ fouls,
64
+ resume,
65
+ returned = [],
66
+ }) {
59
67
  const text = [
60
68
  `# Ring: you referee bout #${pr} (context restored after a compaction)`,
61
69
  '',
70
+ ...(returned.length
71
+ ? [
72
+ '## RETURNED BY THE HUMAN (rebound still open: this goes first)',
73
+ ...returned,
74
+ TAPE_HINT,
75
+ '',
76
+ ]
77
+ : []),
62
78
  `Reread before acting: the judges card \`${join(dir, 'judges-card.md')}\` and the ledger \`${join(dir, 'decisions.md')}\`. Every wrestler and cornerman is launched with a brief from \`node ${RING_ROOT}/scripts/brief.mjs\` and \`prompt: "ring-brief: <path>"\`; never a brief written by hand. Every finding cites its rule id (or X0).`,
63
79
  '',
64
80
  '## Architecture checklist (the full rules go in every brief)',
@@ -90,6 +106,7 @@ export function restore(input) {
90
106
  checklist: checklistLines(loadRules(config).entries).join('\n'),
91
107
  fouls: foulLines(read(join(dir, 'decisions.md'))),
92
108
  resume: resumeSection(read(join(dir, 'judges-card.md'))),
109
+ returned: openHumanReturns(dir),
93
110
  });
94
111
  }
95
112
 
@@ -160,6 +160,13 @@ export const DEFAULTS = Object.freeze({
160
160
  // blocks of the plan (points with a reviewed range) wait on the judge, brief.mjs briefs no
161
161
  // wrestler until the human signs a verdict on that cut. null: no pause.
162
162
  humanReviewEvery: 2,
163
+ // Blocks that may run at once in the bout's worktree, each wrestler on the files its block
164
+ // declared (lanes.mjs). A second block starts only while nothing waits on the human's review and
165
+ // its files do not touch the running one's. 1: one at a time.
166
+ parallelBlocks: 2,
167
+ // Files the host regenerates on its own (a pre-commit that writes docs): no block holds them, and
168
+ // the referee regenerates them when a wave of parallel blocks closes. Globs.
169
+ generatedPaths: [],
163
170
  // Engram (github.com/Gentleman-Programming/engram), the memory of what earlier bouts learned:
164
171
  // fouls, the judge's verdicts, accepted precedents, X0 findings and each bout's status. Never the
165
172
  // rules. The scripts sync and read it when something answers at this URL (null: off). The
@@ -553,7 +553,7 @@ function settleAlone(pr, argv, kind) {
553
553
  for (const finding of settled)
554
554
  appendFileSync(
555
555
  join(dir, 'decisions.md'),
556
- `- ${at} · ${values.id ?? finding.id} · ${judge ? 'judge' : 'referee'} · cornerman finding ${finding.id} ${verb}: ${reason}${judge ? ` (the human: ${judge})` : ''} · «${finding.text}»\n`,
556
+ `- ${at} · ${finding.id} · ${judge ? 'judge' : 'referee'} · cornerman finding ${finding.id} ${verb}${values.id ? ` per ${values.id}` : ''}: ${reason}${judge ? ` (the human: ${judge})` : ''} · «${finding.text}»\n`,
557
557
  );
558
558
  const cardPath = join(dir, 'judges-card.md');
559
559
  if (existsSync(cardPath)) {
@@ -643,6 +643,20 @@ const COMMANDS = {
643
643
  audit,
644
644
  };
645
645
 
646
+ /** Each subcommand's usage, printed with its error. */
647
+ const USAGE = {
648
+ add: 'add <pr> --source range|concept|batch|preferences --range <A>..<B> --file <report> [--up-to <sha>] [--rule <id>]',
649
+ clean: 'clean <pr> --range <from>..<to>',
650
+ applied: 'applied <pr> F<n>[,…] --sha <commit>',
651
+ discard:
652
+ 'discard <pr> F<n>[,…] --id D<k> --reason "<why>" [--judge "<what the human said>"]',
653
+ ticket:
654
+ 'ticket <pr> F<n>[,…] --ticket <id|"to file"> --reason "<why>" [--judge "<what the human said>"]',
655
+ escalate: 'escalate <pr> F<n>[,…] --reason "<why>"',
656
+ shown: 'shown <pr> F<n>[,…] --in "<where the human saw it>"',
657
+ audit: 'audit <pr> [--full]',
658
+ };
659
+
646
660
  if (isMain(import.meta.url)) {
647
661
  const [command, ...rest] = process.argv.slice(2);
648
662
  const run = COMMANDS[command];
@@ -658,6 +672,8 @@ if (isMain(import.meta.url)) {
658
672
  if (command !== 'audit') await syncAfter(rest[0]);
659
673
  } catch (error) {
660
674
  console.error(`findings: ${error.message}`);
675
+ if (USAGE[command])
676
+ console.error(`usage: node findings.mjs ${USAGE[command]}`);
661
677
  process.exit(1);
662
678
  }
663
679
  }
@@ -59,6 +59,7 @@ import {
59
59
  withConceptClosed,
60
60
  withProposalDecision,
61
61
  wrestlerIds,
62
+ wrestlerLines,
62
63
  } from './relay.mjs';
63
64
  import { resumeSection } from './compact-hook.mjs';
64
65
  import { boutFormatGate } from './format-gate.mjs';
@@ -115,7 +116,13 @@ import {
115
116
  } from './preparation.mjs';
116
117
  import { handOffWarning, withChronicleUrl } from './sparring-chronicle.mjs';
117
118
  import { refereeRecall, syncAfter } from './engram.mjs';
118
- import { estimateFacts, reviewTurn } from './review-blocks.mjs';
119
+ import { parseGlobs, wrestlerBlocks } from './lanes.mjs';
120
+ import {
121
+ estimateFacts,
122
+ openHumanReturns,
123
+ reviewTurn,
124
+ TAPE_HINT,
125
+ } from './review-blocks.mjs';
119
126
  import { parsePlan, PLAN, planItem } from './tech-plan.mjs';
120
127
  import { addPlanItem, setPlanState } from './tech-plan-log.mjs';
121
128
  import { logPageVerdicts } from './verdicts-log.mjs';
@@ -314,13 +321,24 @@ async function summary([pr]) {
314
321
  contract: false,
315
322
  venue: state.venue ?? DEFAULT_VENUE,
316
323
  });
317
- const review = reviewTurn(dir, config.humanReviewEvery, (n, to, ref) =>
318
- setPlanState(dir, n, to, ref),
324
+ const review = reviewTurn(
325
+ dir,
326
+ config.humanReviewEvery,
327
+ (n, to, ref) => setPlanState(dir, n, to, ref),
328
+ config.parallelBlocks ?? 1,
319
329
  );
330
+ const returned = openHumanReturns(dir);
320
331
  console.log(
321
332
  [
322
333
  '',
323
334
  '## Human review by blocks (PR Review Lab, bout mode)',
335
+ ...(returned.length
336
+ ? [
337
+ `RETURNED BY THE HUMAN, rebound still open (${returned.length}):`,
338
+ ...returned,
339
+ TAPE_HINT,
340
+ ]
341
+ : []),
324
342
  review.applied.length
325
343
  ? `verdicts applied now: ${review.applied.join(' · ')}`
326
344
  : 'no new verdicts',
@@ -330,6 +348,23 @@ async function summary([pr]) {
330
348
  review.due ?? '',
331
349
  ].join('\n'),
332
350
  );
351
+ const points = existsSync(join(dir, PLAN))
352
+ ? parsePlan(readFileSync(join(dir, PLAN), 'utf8')).groups.flatMap(
353
+ (group) => group.items,
354
+ )
355
+ : [];
356
+ if (
357
+ points.length &&
358
+ points.every((item) => item.state === 'hecho') &&
359
+ titleDefenseRefusal(readState(dir), null)
360
+ )
361
+ console.log(
362
+ [
363
+ '',
364
+ '## Next in the bout’s life (the plan is complete)',
365
+ 'The judge tests it in the sandbox → the human marks the PR ready → /ring:title-defense → judges-card.mjs close. Do not close before the defense unless the judge waives it (--without-title-defense "<what they said>").',
366
+ ].join('\n'),
367
+ );
333
368
  const learned = await refereeRecall(config, {
334
369
  slug: repoSlug(projectDir()),
335
370
  pr,
@@ -601,6 +636,7 @@ function plan([pr, target, ...rest]) {
601
636
  estimate: estimateFacts({
602
637
  priority: value('--priority'),
603
638
  minutes: value('--minutes'),
639
+ files: parseGlobs(value('--files')),
604
640
  }),
605
641
  });
606
642
  return void console.log(
@@ -616,17 +652,24 @@ function plan([pr, target, ...rest]) {
616
652
  contract: false,
617
653
  venue: readState(dir).venue ?? DEFAULT_VENUE,
618
654
  });
619
- const inline = [value('--minutes'), value('--priority')].some(
620
- (given) => given != null,
621
- );
655
+ const inline = [
656
+ value('--minutes'),
657
+ value('--priority'),
658
+ value('--files'),
659
+ ].some((given) => given != null);
622
660
  const changed = setPlanState(dir, target, state, value('--ref'), undefined, {
623
661
  range: value('--range'),
624
662
  estimate: inline
625
663
  ? estimateFacts({
626
664
  priority: value('--priority'),
627
665
  minutes: value('--minutes'),
666
+ files: parseGlobs(value('--files')),
628
667
  })
629
668
  : null,
669
+ lanes: {
670
+ parallel: config.parallelBlocks,
671
+ generated: config.generatedPaths,
672
+ },
630
673
  // Under the human review by blocks a block starts with its estimate and priority.
631
674
  requireEstimate: Boolean(config.humanReviewEvery),
632
675
  });
@@ -635,7 +678,8 @@ function plan([pr, target, ...rest]) {
635
678
  );
636
679
  }
637
680
 
638
- function log([pr, kind, ...words]) {
681
+ function log([pr, kind, ...rest]) {
682
+ let words = rest;
639
683
  if (
640
684
  ['escalation', 'escalation-context', 'answer'].includes(kind) &&
641
685
  words.length
@@ -649,6 +693,26 @@ function log([pr, kind, ...words]) {
649
693
  const stamp = new Date().toISOString();
650
694
  const box = kind === 'rebound' ? '[ ] ' : '';
651
695
  const dir = currentDir(pr);
696
+ if (kind === 'decision') {
697
+ const path = join(dir, 'decisions.md');
698
+ const taken = new Set(
699
+ [
700
+ ...(existsSync(path) ? readFileSync(path, 'utf8') : '').matchAll(
701
+ / · (D\d+) · /g,
702
+ ),
703
+ ].map((m) => m[1]),
704
+ );
705
+ const fixed = withFreeDecisionId(
706
+ words.join(' '),
707
+ taken,
708
+ nextDecisionId(dir),
709
+ );
710
+ if (fixed.renumbered)
711
+ console.error(
712
+ `${fixed.renumbered[0]} is taken in decisions.md: logged as ${fixed.renumbered[1]}`,
713
+ );
714
+ words = [fixed.text];
715
+ }
652
716
  appendFileSync(
653
717
  join(dir, files[kind]),
654
718
  `- ${box}${stamp} · ${words.join(' ')}\n`,
@@ -694,8 +758,20 @@ function list() {
694
758
  console.log(rows.join('\n') || 'no bouts for this repo');
695
759
  }
696
760
 
697
- function close([pr]) {
761
+ /**
762
+ * Why a bout may not close for its title defense yet, or null: the bout ends after the human's tests
763
+ * in the sandbox, the PR marked ready and title-defense against Copilot, unless the judge waives it
764
+ * (`--without-title-defense "<what the human said>"`, which goes to decisions.md). PURE.
765
+ */
766
+ export function titleDefenseRefusal(state, waiver) {
767
+ if (state.titleDefense || state.titleDefenseStarted || waiver?.trim())
768
+ return null;
769
+ return 'it never defended its title. The bout ends with: plan complete → the judge tests it in the sandbox → the human marks the PR ready → /ring:title-defense → close. If the judge does not want the defense, close with --without-title-defense "<what the human said>"';
770
+ }
771
+
772
+ function close([pr, ...argv]) {
698
773
  const dir = currentDir(pr);
774
+ const waiver = options(argv)['without-title-defense'];
699
775
  const blocked = auditBlocks(readLedger(dir), { closing: true });
700
776
  if (blocked) throw new Error(`bout #${pr} does not close yet: ${blocked}`);
701
777
  const proposals = undecidedProposals(
@@ -710,10 +786,56 @@ function close([pr]) {
710
786
  if (format.refusal)
711
787
  throw new Error(`bout #${pr} does not close yet: ${format.refusal}`);
712
788
  }
789
+ const defense = titleDefenseRefusal(state, waiver);
790
+ if (defense) throw new Error(`bout #${pr} does not close yet: ${defense}`);
791
+ if (waiver?.trim())
792
+ appendFileSync(
793
+ join(dir, 'decisions.md'),
794
+ `- ${new Date().toISOString()} · ${nextDecisionId(dir)} · judge · closed without title-defense: ${waiver.trim()}\n`,
795
+ );
713
796
  writeState(dir, { ...state, active: false });
714
797
  console.log(`bout #${pr} closed; its files stay in ${dir} as the record.`);
715
798
  }
716
799
 
800
+ /** The next free D<n> of the bout's decisions.md. */
801
+ function nextDecisionId(dir) {
802
+ const path = join(dir, 'decisions.md');
803
+ const ids = existsSync(path)
804
+ ? [...readFileSync(path, 'utf8').matchAll(/ · D(\d+) · /g)].map((m) =>
805
+ Number(m[1]),
806
+ )
807
+ : [];
808
+ return `D${Math.max(0, ...ids) + 1}`;
809
+ }
810
+
811
+ /**
812
+ * The decision line as the referee wrote it, with its D<n> made unique: a D<n> already taken in
813
+ * the ledger (a judge's verdict wrote it meanwhile) becomes the next free one. PURE.
814
+ */
815
+ export function withFreeDecisionId(text, taken, next) {
816
+ const id = /^(D\d+)(\s*·)/.exec(text)?.[1];
817
+ if (!id || !taken.has(id)) return { text, renumbered: null };
818
+ return { text: text.replace(id, next), renumbered: [id, next] };
819
+ }
820
+
821
+ /** A closed bout back to work, with the judge's reason in decisions.md. */
822
+ function reopen([pr, ...argv]) {
823
+ const { why } = options(argv);
824
+ if (!why?.trim())
825
+ throw new Error('usage: reopen <pr> --why "<what the human said>"');
826
+ const dir = currentDir(pr);
827
+ const state = readState(dir);
828
+ if (state.active) throw new Error(`bout #${pr} is already active`);
829
+ appendFileSync(
830
+ join(dir, 'decisions.md'),
831
+ `- ${new Date().toISOString()} · ${nextDecisionId(dir)} · judge · bout reopened: ${why.trim()}\n`,
832
+ );
833
+ writeState(dir, { ...state, active: true });
834
+ console.log(
835
+ `bout #${pr} reopened; resume it with /ring:referee resume ${pr}.`,
836
+ );
837
+ }
838
+
717
839
  /**
718
840
  * The title-defense flag in a bout state, set or cleared. `setAt` starts its count: mask-guard
719
841
  * allows at most `untilRound` Copilot review requests after it. PURE.
@@ -727,7 +849,12 @@ export function withTitleDefense(
727
849
  if (untilRound === null) return rest;
728
850
  if (!Number.isInteger(untilRound) || untilRound < 1)
729
851
  throw new Error('--until-round must be a positive integer');
730
- return { ...rest, titleDefense: { pr: state.pr, untilRound, setAt } };
852
+ // titleDefenseStarted outlives the flag: close asks whether the PR ever defended its title.
853
+ return {
854
+ ...rest,
855
+ titleDefense: { pr: state.pr, untilRound, setAt },
856
+ titleDefenseStarted: rest.titleDefenseStarted ?? setAt,
857
+ };
731
858
  }
732
859
 
733
860
  /**
@@ -749,6 +876,7 @@ export function readyForTitleDefense(pr, view) {
749
876
  if (answer?.isDraft === true)
750
877
  return {
751
878
  ok: false,
879
+ draft: true,
752
880
  reason: `PR #${pr} is still a draft; title-defense starts when you mark it ready for review`,
753
881
  };
754
882
  return { ok: false, reason: `gh did not say whether PR #${pr} is a draft` };
@@ -865,7 +993,9 @@ function titleDefense([pr, ...argv]) {
865
993
  const clear = argv.includes('--clear');
866
994
  const index = argv.indexOf('--until-round');
867
995
  if (!pr || (!clear && index === -1))
868
- throw new Error('usage: title-defense <pr> --until-round <k> | --clear');
996
+ throw new Error(
997
+ 'usage: title-defense <pr> --until-round <k> [--keep-draft] | --clear',
998
+ );
869
999
  const dir = currentDir(pr);
870
1000
  const state = readState(dir);
871
1001
  if (state.active !== true)
@@ -881,7 +1011,25 @@ function titleDefense([pr, ...argv]) {
881
1011
  stdio: ['ignore', 'pipe', 'pipe'],
882
1012
  }),
883
1013
  );
884
- if (!gate.ok) throw new Error(gate.reason);
1014
+ // Invoking title-defense is the judge's yes to take the PR out of draft (--keep-draft: not yet).
1015
+ // The script runs gh itself, so mask-guard still refuses `gh pr ready` anywhere else.
1016
+ if (!gate.ok && gate.draft && !argv.includes('--keep-draft')) {
1017
+ execFileSync('gh', ['pr', 'ready', String(pr)], {
1018
+ cwd: state.worktree,
1019
+ encoding: 'utf8',
1020
+ stdio: ['ignore', 'pipe', 'pipe'],
1021
+ });
1022
+ appendFileSync(
1023
+ join(dir, 'decisions.md'),
1024
+ `- ${new Date().toISOString()} · ${nextDecisionId(dir)} · judge · the judge invoked title-defense: PR #${pr} is out of draft\n`,
1025
+ );
1026
+ console.log(
1027
+ `PR #${pr} marked ready for review (the judge invoked title-defense)`,
1028
+ );
1029
+ } else if (!gate.ok)
1030
+ throw new Error(
1031
+ gate.draft ? `${gate.reason} (--keep-draft was passed)` : gate.reason,
1032
+ );
885
1033
  const next = withTitleDefense(state, clear ? null : Number(argv[index + 1]));
886
1034
  writeState(dir, next);
887
1035
  console.log(
@@ -958,14 +1106,19 @@ function playByPlayUrl([pr, url]) {
958
1106
  * concept goes to a fresh wrestler; every `relayEvery` concepts the referee relays first.
959
1107
  */
960
1108
  function concept([pr, name, ...argv]) {
961
- const { range } = options(argv);
1109
+ const { range, block } = options(argv);
962
1110
  const dir = currentDir(pr);
963
1111
  const state = readState(dir);
964
1112
  const next = withConceptClosed(state, {
965
1113
  name,
966
1114
  range,
967
1115
  at: new Date().toISOString(),
968
- wrestlers: wrestlerIds(liveLines(dir)),
1116
+ // With --block only that block's wrestlers retire: another block may still be running.
1117
+ wrestlers: block
1118
+ ? [...wrestlerBlocks(dir)]
1119
+ .filter(([, n]) => n === Number(block))
1120
+ .map(([agent]) => agent)
1121
+ : wrestlerIds(wrestlerLines(dir)),
969
1122
  });
970
1123
  writeState(dir, next);
971
1124
  const { config } = loadConfig(projectDir(), {
@@ -1043,6 +1196,7 @@ function proposal([pr, n, verdict, ...argv]) {
1043
1196
 
1044
1197
  const COMMANDS = {
1045
1198
  open,
1199
+ reopen,
1046
1200
  concept,
1047
1201
  relay,
1048
1202
  proposal,
@@ -1065,7 +1219,7 @@ if (isMain(import.meta.url)) {
1065
1219
  const run = COMMANDS[command];
1066
1220
  if (!run) {
1067
1221
  console.error(
1068
- 'Usage: node judges-card.mjs open|summary|log|did|list|path|close|concept|relay|proposal|approve-contract|title-defense|play-by-play-url|page-answers|page-verdicts|plan …',
1222
+ 'Usage: node judges-card.mjs open|reopen|summary|log|did|list|path|close|concept|relay|proposal|approve-contract|title-defense|play-by-play-url|page-answers|page-verdicts|plan …',
1069
1223
  );
1070
1224
  process.exit(1);
1071
1225
  }