@thebattlearena/corner 0.10.1-beta.83 → 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,8 +444,8 @@ 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. Con `parallelBlocks: 2` dos bloques corren a la vez **en el mismo
448
- worktree**, cada luchador sobre los archivos que su bloque declaró (`--files "<globs>"` al estimar o
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
449
  arrancar): el script no deja arrancar un bloque con archivos de otro en curso, `mask-guard` rechaza un
450
450
  commit con archivos de otro bloque (los de `generatedPaths`, como los docs que regenera un pre-commit,
451
451
  no son de nadie: el árbitro los regenera al cerrar la oleada), y `concept … --block <n>` retira solo al
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thebattlearena/corner",
3
- "version": "0.10.1-beta.83",
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": {
@@ -214,7 +214,7 @@ function laneSection(bout, block) {
214
214
  * cut does not touch (`--lookahead "<globs>"`). Without it, or when it overlaps or another block is
215
215
  * running, the pause holds. Returns the brief's section that bounds the work ahead.
216
216
  */
217
- function workAhead(bout, lookahead, turn) {
217
+ function workAhead(bout, lookahead, turn, block) {
218
218
  if (!lookahead)
219
219
  throw new Error(
220
220
  `${turn.due}. Or work ahead on one block whose files the cut does not touch: --lookahead "<glob>,<glob>"`,
@@ -241,7 +241,8 @@ function workAhead(bout, lookahead, turn) {
241
241
  const refusal = lookaheadRefusal({
242
242
  globs,
243
243
  waitingFiles,
244
- running: runningBlocks(plan),
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)),
245
246
  });
246
247
  if (refusal) throw new Error(`${turn.due}. ${refusal}`);
247
248
  return [
@@ -299,10 +300,11 @@ async function generate(kind, [pr, ...argv]) {
299
300
  bout.dir,
300
301
  bout.config.humanReviewEvery,
301
302
  (n, to, ref) => setPlanState(bout.dir, n, to, ref),
303
+ bout.config.parallelBlocks ?? 1,
302
304
  );
303
305
  if (turn.applied.length)
304
306
  console.error(`human review applied: ${turn.applied.join(' · ')}`);
305
- if (turn.due) ahead = workAhead(bout, values.lookahead, turn);
307
+ if (turn.due) ahead = workAhead(bout, values.lookahead, turn, values.block);
306
308
  }
307
309
  const own = values.vars ? JSON.parse(readFileSync(values.vars, 'utf8')) : {};
308
310
  const roots = [
@@ -161,8 +161,9 @@ export const DEFAULTS = Object.freeze({
161
161
  // wrestler until the human signs a verdict on that cut. null: no pause.
162
162
  humanReviewEvery: 2,
163
163
  // Blocks that may run at once in the bout's worktree, each wrestler on the files its block
164
- // declared (lanes.mjs). 1: one at a time, as always.
165
- parallelBlocks: 1,
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,
166
167
  // Files the host regenerates on its own (a pre-commit that writes docs): no block holds them, and
167
168
  // the referee regenerates them when a wave of parallel blocks closes. Globs.
168
169
  generatedPaths: [],
@@ -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)) {
@@ -321,8 +321,11 @@ async function summary([pr]) {
321
321
  contract: false,
322
322
  venue: state.venue ?? DEFAULT_VENUE,
323
323
  });
324
- const review = reviewTurn(dir, config.humanReviewEvery, (n, to, ref) =>
325
- 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,
326
329
  );
327
330
  const returned = openHumanReturns(dir);
328
331
  console.log(
@@ -345,6 +348,23 @@ async function summary([pr]) {
345
348
  review.due ?? '',
346
349
  ].join('\n'),
347
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
+ );
348
368
  const learned = await refereeRecall(config, {
349
369
  slug: repoSlug(projectDir()),
350
370
  pr,
@@ -658,7 +678,8 @@ function plan([pr, target, ...rest]) {
658
678
  );
659
679
  }
660
680
 
661
- function log([pr, kind, ...words]) {
681
+ function log([pr, kind, ...rest]) {
682
+ let words = rest;
662
683
  if (
663
684
  ['escalation', 'escalation-context', 'answer'].includes(kind) &&
664
685
  words.length
@@ -672,6 +693,26 @@ function log([pr, kind, ...words]) {
672
693
  const stamp = new Date().toISOString();
673
694
  const box = kind === 'rebound' ? '[ ] ' : '';
674
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
+ }
675
716
  appendFileSync(
676
717
  join(dir, files[kind]),
677
718
  `- ${box}${stamp} · ${words.join(' ')}\n`,
@@ -717,8 +758,20 @@ function list() {
717
758
  console.log(rows.join('\n') || 'no bouts for this repo');
718
759
  }
719
760
 
720
- 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]) {
721
773
  const dir = currentDir(pr);
774
+ const waiver = options(argv)['without-title-defense'];
722
775
  const blocked = auditBlocks(readLedger(dir), { closing: true });
723
776
  if (blocked) throw new Error(`bout #${pr} does not close yet: ${blocked}`);
724
777
  const proposals = undecidedProposals(
@@ -733,10 +786,56 @@ function close([pr]) {
733
786
  if (format.refusal)
734
787
  throw new Error(`bout #${pr} does not close yet: ${format.refusal}`);
735
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
+ );
736
796
  writeState(dir, { ...state, active: false });
737
797
  console.log(`bout #${pr} closed; its files stay in ${dir} as the record.`);
738
798
  }
739
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
+
740
839
  /**
741
840
  * The title-defense flag in a bout state, set or cleared. `setAt` starts its count: mask-guard
742
841
  * allows at most `untilRound` Copilot review requests after it. PURE.
@@ -750,7 +849,12 @@ export function withTitleDefense(
750
849
  if (untilRound === null) return rest;
751
850
  if (!Number.isInteger(untilRound) || untilRound < 1)
752
851
  throw new Error('--until-round must be a positive integer');
753
- 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
+ };
754
858
  }
755
859
 
756
860
  /**
@@ -772,6 +876,7 @@ export function readyForTitleDefense(pr, view) {
772
876
  if (answer?.isDraft === true)
773
877
  return {
774
878
  ok: false,
879
+ draft: true,
775
880
  reason: `PR #${pr} is still a draft; title-defense starts when you mark it ready for review`,
776
881
  };
777
882
  return { ok: false, reason: `gh did not say whether PR #${pr} is a draft` };
@@ -888,7 +993,9 @@ function titleDefense([pr, ...argv]) {
888
993
  const clear = argv.includes('--clear');
889
994
  const index = argv.indexOf('--until-round');
890
995
  if (!pr || (!clear && index === -1))
891
- 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
+ );
892
999
  const dir = currentDir(pr);
893
1000
  const state = readState(dir);
894
1001
  if (state.active !== true)
@@ -904,7 +1011,25 @@ function titleDefense([pr, ...argv]) {
904
1011
  stdio: ['ignore', 'pipe', 'pipe'],
905
1012
  }),
906
1013
  );
907
- 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
+ );
908
1033
  const next = withTitleDefense(state, clear ? null : Number(argv[index + 1]));
909
1034
  writeState(dir, next);
910
1035
  console.log(
@@ -1071,6 +1196,7 @@ function proposal([pr, n, verdict, ...argv]) {
1071
1196
 
1072
1197
  const COMMANDS = {
1073
1198
  open,
1199
+ reopen,
1074
1200
  concept,
1075
1201
  relay,
1076
1202
  proposal,
@@ -1093,7 +1219,7 @@ if (isMain(import.meta.url)) {
1093
1219
  const run = COMMANDS[command];
1094
1220
  if (!run) {
1095
1221
  console.error(
1096
- '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 …',
1097
1223
  );
1098
1224
  process.exit(1);
1099
1225
  }
@@ -51,11 +51,18 @@ export function startLaneRefusal({
51
51
  parallel,
52
52
  tracked,
53
53
  generated,
54
+ waiting = [],
54
55
  }) {
55
56
  const others = running.filter((lease) => lease.n !== n);
56
57
  if (!parallel || parallel <= 1 || !others.length) return null;
58
+ // The human's queue first: with a block waiting on review, one block runs at a time.
59
+ if (waiting.length)
60
+ return `${waiting.map((item) => `B${item.n}`).join(', ')} wait${waiting.length === 1 ? 's' : ''} on the human's review and ${others.map((lease) => `B${lease.n}`).join(', ')} is running: a second block starts only while nothing waits on review. Start B${n} when one of them clears`;
57
61
  if (others.length >= parallel)
58
62
  return `${others.length} blocks are already running (parallelBlocks ${parallel}): ${others.map((lease) => `B${lease.n}`).join(', ')}. Start B${n} when one closes`;
63
+ const undeclared = others.filter((lease) => !lease.files.length);
64
+ if (undeclared.length)
65
+ return `${undeclared.map((lease) => `B${lease.n}`).join(', ')} runs without declared files, so nothing proves B${n} would not touch them: declare them first (judges-card.mjs plan <pr> estimate ${undeclared[0].n} --files "<globs>")`;
59
66
  if (!files?.length)
60
67
  return `B${n} starts next to ${others.map((lease) => `B${lease.n}`).join(', ')}: declare the files it will touch with --files "<glob>,<glob>" so no two wrestlers edit the same file`;
61
68
  const isGenerated = matcher(generated);
@@ -9,7 +9,8 @@
9
9
  // formatted; see format-gate.mjs)
10
10
  // node lookout.mjs backup <boutDir> push the worktree HEAD to the backup branch
11
11
  //
12
- // `watch` polls the wrestler's worktree and EXITS as soon as its HEAD differs from the last
12
+ // `watch` polls the wrestler's worktree and EXITS as soon as the human signs a verdict in PR Review
13
+ // Lab that the bout has not applied, or as soon as its HEAD differs from the last
13
14
  // reviewed commit in state.json, printing the new commits. Launched with run_in_background, its
14
15
  // exit wakes the referee; nothing expires and nothing has to be re-armed by hand. A pidfile makes a
15
16
  // new watcher kill the previous one, and both ends of the comparison are FULL hashes: comparing an
@@ -28,6 +29,7 @@ import {
28
29
  repoSlug,
29
30
  writeState,
30
31
  } from './config.mjs';
32
+ import { reviewInputs } from './review-blocks.mjs';
31
33
  import { counts, readLedger, reviewDeclared } from './findings.mjs';
32
34
  import { acceptUnverified, boutFormatGate } from './format-gate.mjs';
33
35
 
@@ -189,6 +191,14 @@ function backupQuietly(state, template) {
189
191
  }
190
192
  }
191
193
 
194
+ /** The verdicts PR Review Lab wrote that the bout has not applied yet. */
195
+ export function unhandledVerdicts(dir, state) {
196
+ const handled = new Set(state.handledVerdicts ?? []);
197
+ return reviewInputs(dir).verdicts.filter(
198
+ (verdict) => !handled.has(verdict.at),
199
+ );
200
+ }
201
+
192
202
  const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
193
203
 
194
204
  async function watch(dir, intervalSeconds) {
@@ -205,6 +215,12 @@ async function watch(dir, intervalSeconds) {
205
215
  const state = readState(dir);
206
216
  if (state.active !== true)
207
217
  return console.log('lookout: the bout is closed.');
218
+ // The human's verdict wakes the referee too: until the next brief applies it, the bout waits.
219
+ const fresh = unhandledVerdicts(dir, state);
220
+ if (fresh.length)
221
+ return console.log(
222
+ `lookout: the human signed ${fresh.map((verdict) => `${verdict.verdict} ${verdict.blocks.join('+')} (Tape ${verdict.tape})`).join(' · ')} in PR Review Lab: run judges-card.mjs summary ${state.pr} (or brief the next wrestler) to apply it, then go on.`,
223
+ );
208
224
  const head = git(state.worktree, ['rev-parse', 'HEAD']);
209
225
  if (compareHeads(state.lastReviewed, head).changed) {
210
226
  const settings = hostSettings();
@@ -252,11 +252,16 @@ export function applyVerdicts(dir, state, setState) {
252
252
  * verdicts, keeps `pauseEvery` in state.json for PR Review Lab, and says whether the human's review
253
253
  * is due. `setState(n, state, ref)` moves a block of the plan.
254
254
  */
255
- export function reviewTurn(dir, every, setState) {
255
+ export function reviewTurn(dir, every, setState, parallel = null) {
256
256
  const before = readState(dir);
257
257
  const { state, applied } = applyVerdicts(dir, before, setState);
258
- const next = { ...state, pauseEvery: every ?? null };
259
- if (applied.length || before.pauseEvery !== next.pauseEvery)
258
+ // PR Review Lab reads both from state.json: when the bell rings and how many blocks may run.
259
+ const next = { ...state, pauseEvery: every ?? null, parallel };
260
+ if (
261
+ applied.length ||
262
+ before.pauseEvery !== next.pauseEvery ||
263
+ before.parallel !== next.parallel
264
+ )
260
265
  writeState(dir, next);
261
266
  const { plan, log, verdicts } = reviewInputs(dir);
262
267
  const waiting = blocksAwaitingReview(plan, log, verdicts);
@@ -272,7 +277,7 @@ export function lookaheadRefusal({ globs, waitingFiles, running }) {
272
277
  if (!globs.length)
273
278
  return 'a block ahead of the review declares its files: --lookahead "<glob>,<glob>"';
274
279
  if (running.length)
275
- return `only one block works ahead of the review: B${running[0].n} «${running[0].title}» is already «en curso»`;
280
+ return `only one block works ahead of the review: B${running[0].n} «${running[0].title}» is already «en curso» (if that is the block of this brief, pass --block ${running[0].n})`;
276
281
  const matchers = globs.map((glob) => globToRegExp(glob));
277
282
  const overlap = waitingFiles.filter((file) =>
278
283
  matchers.some((matcher) => matcher.test(file)),
@@ -17,7 +17,10 @@ import {
17
17
  authorEmail,
18
18
  blockFacts,
19
19
  factsForState,
20
+ blocksAwaitingReview,
21
+ parseVerdicts,
20
22
  startRefusal,
23
+ VERDICTS,
21
24
  } from './review-blocks.mjs';
22
25
  import {
23
26
  parsePlan,
@@ -81,6 +84,11 @@ export function setPlanState(
81
84
  parallel: lanes.parallel,
82
85
  tracked: trackedFiles(worktree),
83
86
  generated: lanes.generated,
87
+ waiting: blocksAwaitingReview(
88
+ markdown,
89
+ parsePlanLog(read(join(dir, PLAN_LOG))),
90
+ parseVerdicts(read(join(dir, VERDICTS)) ?? ''),
91
+ ),
84
92
  })
85
93
  : null;
86
94
  if (laneRefusal) throw new Error(laneRefusal);
@@ -225,8 +225,10 @@ way out, and it refuses without a KO. Then the bout runs as always, with three i
225
225
  "<glob>,<glob>"` (the files that block will touch). brief.mjs refuses it when those globs match a
226
226
  file of the cut, or when another block is already «en curso». Its brief says which files it may
227
227
  touch; a rebound from the verdict still goes first.
228
- **Blocks side by side (`parallelBlocks` > 1 in `.claude/ring.json`)**: up to that many blocks
229
- run at once in the SAME worktree, one wrestler each. Pick blocks that neither depend on each
228
+ **Blocks side by side (`parallelBlocks`, 2 by default)**: when a block is running and NOTHING
229
+ waits on the human's review, start one more INDEPENDENT block next to it, in the SAME worktree,
230
+ one wrestler each (a long block should not leave the human's queue empty). While any block waits
231
+ on review, one block at a time: the script refuses the second start. Pick blocks that neither depend on each
230
232
  other nor share a contract (props, a hook's return, i18n keys, a helper): a file lock does not
231
233
  see that, you do. Start each with its files: `judges-card.mjs plan <pr> <n> "en curso"
232
234
  --minutes <m> --priority <p> --files "<glob>,<glob>"`; the script refuses a start whose files a
@@ -509,6 +511,8 @@ logs there; whichever session regenerates the page passes every bout dir of the
509
511
 
510
512
  ## 4 · `close`
511
513
 
514
+ **Close comes after title-defense.** The order is: plan complete → the judge tests it in the sandbox → the human marks the PR ready → `/ring:title-defense` → `judges-card.mjs close`. A plan whose blocks the judge all passed is not the end. `close` refuses a bout that never defended its title unless the judge waives it (`--without-title-defense "<what they said>"`). A bout closed too early goes back with `judges-card.mjs reopen <pr> --why "<what the judge said>"`; `resume` on a closed bout asks the judge whether to reopen it.
515
+
512
516
  When the PR gets an automated review (GitHub Copilot), converge it with
513
517
  `/ring:title-defense <pr> [--max-rounds N]` before or during `close`: it catalogues the findings
514
518
  and runs the fixes through this same cycle (§2).
@@ -559,7 +563,7 @@ and runs the fixes through this same cycle (§2).
559
563
 
560
564
  ## 5 · `resume [<pr>]`
561
565
 
562
- 1. Without `<pr>`: `node "$RING/scripts/judges-card.mjs" list`; take the only active bout or ask.
566
+ 1. Without `<pr>`: `node "$RING/scripts/judges-card.mjs" list`; take the only active bout or ask. A bout listed as closed is reopened only with the judge's yes: `judges-card.mjs reopen <pr> --why "<what they said>"`.
563
567
  2. `node "$RING/scripts/judges-card.mjs" summary <pr>`: state (with the bout's venue),
564
568
  unreviewed commits, open rebounds, decisions and the judges card, and, when Engram answers,
565
569
  the repo's latest fouls, verdicts and precedents from earlier bouts (do not repeat those fouls;
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: title-defense
3
3
  description: Converges a PR after an external automated reviewer (GitHub Copilot) has commented. Each Copilot review is a challenger's round; the PR defends its belt until a round brings nothing actionable and CI is green, or the round limit is hit. A thin layer over the referee: it gathers, classifies and catalogues the findings, hands the fixes to the referee's normal cycle, then replies to fixed threads, resolves them and re-requests Copilot. Invoke with `/ring:title-defense <pr> [--max-rounds N]`, "defend the PR against Copilot", "converge the Copilot review".
4
- argument-hint: "<pr> [--max-rounds N]"
4
+ argument-hint: "<pr> [--max-rounds N] [--keep-draft]"
5
5
  ---
6
6
 
7
7
  # title-defense
@@ -20,10 +20,13 @@ list, judges card, `decisions.md` and `rebounds.md`, and the same `mask-guard` r
20
20
 
21
21
  ## 0 · Before the first round
22
22
 
23
- 1. **Only once the human took the PR out of draft.** `gh pr view <pr> --json isDraft`: if it is
24
- still a draft, answer with one line, "PR #<n> is still a draft; title-defense starts when you
25
- mark it ready for review", and do nothing else (no flag, no Copilot request).
26
- `judges-card.mjs title-defense` refuses on a draft too, and when gh cannot tell.
23
+ 1. **Invoking title-defense is the human's yes to take the PR out of draft.** Never run
24
+ `gh pr ready` yourself (mask-guard refuses it): `judges-card.mjs title-defense <pr>
25
+ --until-round <k>` marks a draft PR ready, logs «the judge invoked title-defense» in
26
+ decisions.md and sets the flag. If the human said to keep it a draft (`--keep-draft` in the
27
+ invocation), pass `--keep-draft`: then a draft refuses as before, "PR #<n> is still a draft;
28
+ title-defense starts when you mark it ready for review", and you do nothing else. When gh cannot
29
+ tell whether it is a draft, it refuses too.
27
30
  2. `--max-rounds` defaults to 3 (the owner often uses up to 6).
28
31
  3. **A bout for this PR**: `node "$RING/scripts/judges-card.mjs" summary <pr>`. If there is none,
29
32
  open one with the referee's `start` flow (§1); if it exists, reuse it as in `resume` (§5),