@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.
@@ -0,0 +1,146 @@
1
+ // Several blocks at once in ONE worktree: each block «en curso» holds the files it declared
2
+ // (`--files "<globs>"` at estimate or start), and only that block's wrestler commits them. The
3
+ // referee grants them all when the block starts (the start is refused when another running block
4
+ // holds one of them, or when `parallelBlocks` are already running); a wrestler that needs a file of
5
+ // another block stops and asks the referee, never the other wrestler. mask-guard refuses a commit
6
+ // that stages a file another running block holds. Files the host regenerates (`generatedPaths`,
7
+ // e.g. docs a pre-commit writes) are nobody's: the referee regenerates them when the wave closes.
8
+ // Closing a concept with `--block <n>` retires only that block's wrestlers. Zero-dependency.
9
+ import { existsSync, readFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+
12
+ import { globToRegExp } from './contract.mjs';
13
+ import { wrestlerLines } from './relay.mjs';
14
+ import { parsePlan, parsePlanLog, PLAN, PLAN_LOG } from './tech-plan.mjs';
15
+
16
+ /** `"a/**, b/*.ts"` as globs. PURE. */
17
+ export const parseGlobs = (text) =>
18
+ String(text ?? '')
19
+ .split(',')
20
+ .map((glob) => glob.trim())
21
+ .filter(Boolean);
22
+
23
+ const matcher = (globs) => {
24
+ const patterns = (globs ?? []).map((glob) => globToRegExp(glob));
25
+ return (file) => patterns.some((pattern) => pattern.test(file));
26
+ };
27
+
28
+ /** The blocks «en curso» with the files each holds (its last `files` in plan.jsonl). PURE. */
29
+ export function runningLeases(plan, log) {
30
+ return parsePlan(plan)
31
+ .groups.flatMap((group) => group.items)
32
+ .filter((item) => item.state === 'en curso')
33
+ .map((item) => ({
34
+ n: item.n,
35
+ title: item.title,
36
+ files:
37
+ log.filter((line) => line.n === item.n && line.files).at(-1)?.files ??
38
+ [],
39
+ }));
40
+ }
41
+
42
+ /**
43
+ * Why block `n` may not start next to the blocks already running, or null. With `parallel` 1 (the
44
+ * default) nothing changes. Otherwise it needs its files, a free slot, and no tracked file that a
45
+ * running block holds too (generated files are nobody's). PURE.
46
+ */
47
+ export function startLaneRefusal({
48
+ n,
49
+ files,
50
+ running,
51
+ parallel,
52
+ tracked,
53
+ generated,
54
+ waiting = [],
55
+ }) {
56
+ const others = running.filter((lease) => lease.n !== n);
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`;
61
+ if (others.length >= parallel)
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>")`;
66
+ if (!files?.length)
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`;
68
+ const isGenerated = matcher(generated);
69
+ const mine = matcher(files);
70
+ const clashes = others.flatMap((lease) => {
71
+ const theirs = matcher(lease.files);
72
+ return tracked
73
+ .filter((file) => !isGenerated(file) && mine(file) && theirs(file))
74
+ .map((file) => `${file} (B${lease.n})`);
75
+ });
76
+ if (clashes.length)
77
+ return `B${n} would share files with a running block: ${clashes.slice(0, 8).join(', ')}${clashes.length > 8 ? ', …' : ''}. Narrow its --files, or start another block`;
78
+ return null;
79
+ }
80
+
81
+ /**
82
+ * Why a commit may not go in, or null: a staged file held by a running block other than the one
83
+ * whose wrestler commits. Unknown committer: a commit may not mix files of two blocks. PURE.
84
+ */
85
+ export function commitLeaseRefusal({ staged, running, block, generated }) {
86
+ if (running.length < 2) return null;
87
+ const isGenerated = matcher(generated);
88
+ const owners = staged
89
+ .filter((file) => !isGenerated(file))
90
+ .flatMap((file) =>
91
+ running
92
+ .filter((lease) => matcher(lease.files)(file))
93
+ .map((lease) => ({ file, n: lease.n })),
94
+ );
95
+ const foreign = block
96
+ ? owners.filter((owner) => owner.n !== block)
97
+ : owners.filter((owner) => owner.n !== owners[0]?.n);
98
+ if (!foreign.length) return null;
99
+ return `this commit stages files another running block holds: ${foreign.map((owner) => `${owner.file} (B${owner.n})`).join(', ')}. Unstage them (git restore --staged <path>); if your block needs one, stop and ask the referee, who moves it between blocks. Never edit or commit another wrestler's files.`;
100
+ }
101
+
102
+ const jsonl = (path) =>
103
+ existsSync(path) ? parsePlanLog(readFileSync(path, 'utf8')) : [];
104
+
105
+ /**
106
+ * The block each wrestler works on: its agent id (live.jsonl) → the Agent launch (tool_use_id) →
107
+ * the brief brief-guard saw → that brief's `block` in briefs/index.jsonl. PURE apart from reads.
108
+ */
109
+ export function wrestlerBlocks(dir) {
110
+ const briefs = new Map(
111
+ jsonl(join(dir, 'briefs', 'index.jsonl'))
112
+ .filter((brief) => brief.kind === 'wrestler' && brief.block)
113
+ .map((brief) => [
114
+ `wrestler-${brief.mode}-${brief.n}.md`,
115
+ Number(brief.block),
116
+ ]),
117
+ );
118
+ const launches = new Map(
119
+ jsonl(join(dir, 'brief-guard.jsonl'))
120
+ .filter((entry) => entry.toolUseId && entry.path)
121
+ .map((entry) => [
122
+ entry.toolUseId,
123
+ briefs.get(entry.path.split('/').at(-1)),
124
+ ]),
125
+ );
126
+ const blocks = new Map();
127
+ for (const line of wrestlerLines(dir)) {
128
+ try {
129
+ const event = JSON.parse(line);
130
+ const agent = /^agent (\S+)$/.exec(event.note ?? '')?.[1];
131
+ const block = launches.get(event.id);
132
+ if (event.actor === 'luchador' && agent && block)
133
+ blocks.set(agent, block);
134
+ } catch {
135
+ // A torn line is skipped.
136
+ }
137
+ }
138
+ return blocks;
139
+ }
140
+
141
+ /** The running blocks of a bout dir, read now. */
142
+ export function boutLeases(dir) {
143
+ const read = (name) =>
144
+ existsSync(join(dir, name)) ? readFileSync(join(dir, name), 'utf8') : '';
145
+ return runningLeases(read(PLAN), parsePlanLog(read(PLAN_LOG)));
146
+ }
@@ -255,9 +255,17 @@ export function appendLive(file, event, keep = KEEP) {
255
255
  export function record(dir, raw) {
256
256
  if (!dir) return false;
257
257
  const { event } = validateEvent(raw);
258
- return event ? appendLive(join(dir, LIVE_LOG), event) : false;
258
+ if (!event) return false;
259
+ // live.jsonl is trimmed to its last lines; who each wrestler is must outlive the trim (a concept
260
+ // close retires them by these ids, and lanes map them to their block).
261
+ if (event.actor === 'luchador' && /^agent \S+$/.test(event.note ?? ''))
262
+ appendFileSync(join(dir, WRESTLERS), `${JSON.stringify(event)}\n`);
263
+ return appendLive(join(dir, LIVE_LOG), event);
259
264
  }
260
265
 
266
+ /** The launches of the bout's wrestlers (agent ids), kept whole. */
267
+ export const WRESTLERS = 'wrestlers.jsonl';
268
+
261
269
  const within = (dir, path) => {
262
270
  const inside = relative(resolve(dir), resolve(path));
263
271
  return !inside.startsWith('..') && !isAbsolute(inside);
@@ -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
@@ -19,13 +20,16 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
19
20
  import { join } from 'node:path';
20
21
 
21
22
  import {
23
+ boutDir,
22
24
  DEFAULTS,
23
25
  isMain,
24
26
  loadConfig,
25
27
  projectDir,
26
28
  readState,
29
+ repoSlug,
27
30
  writeState,
28
31
  } from './config.mjs';
32
+ import { reviewInputs } from './review-blocks.mjs';
29
33
  import { counts, readLedger, reviewDeclared } from './findings.mjs';
30
34
  import { acceptUnverified, boutFormatGate } from './format-gate.mjs';
31
35
 
@@ -187,6 +191,14 @@ function backupQuietly(state, template) {
187
191
  }
188
192
  }
189
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
+
190
202
  const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
191
203
 
192
204
  async function watch(dir, intervalSeconds) {
@@ -203,6 +215,12 @@ async function watch(dir, intervalSeconds) {
203
215
  const state = readState(dir);
204
216
  if (state.active !== true)
205
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
+ );
206
224
  const head = git(state.worktree, ['rev-parse', 'HEAD']);
207
225
  if (compareHeads(state.lastReviewed, head).changed) {
208
226
  const settings = hostSettings();
@@ -332,7 +350,11 @@ function approve(dir, ref, { formatUnverified = null } = {}) {
332
350
  }
333
351
 
334
352
  if (isMain(import.meta.url)) {
335
- const [command, dir, ...rest] = process.argv.slice(2);
353
+ const [command, target, ...rest] = process.argv.slice(2);
354
+ // A bout dir, or the PR number every other ring script takes.
355
+ const dir = /^\d+$/.test(target ?? '')
356
+ ? boutDir(repoSlug(projectDir()), target)
357
+ : target;
336
358
  const intervalIndex = rest.indexOf('--interval');
337
359
  const interval = intervalIndex === -1 ? 60 : Number(rest[intervalIndex + 1]);
338
360
  const fail = (error) => {
@@ -12,6 +12,7 @@
12
12
  // it blocks too: a guard that cannot evaluate must not let the command through.
13
13
  import { boutArchGate } from './arch-gate.mjs';
14
14
  import { boutFormatGate } from './format-gate.mjs';
15
+ import { boutLeases, commitLeaseRefusal, wrestlerBlocks } from './lanes.mjs';
15
16
  import { execFileSync } from 'node:child_process';
16
17
  import { existsSync, readFileSync, realpathSync } from 'node:fs';
17
18
  import { homedir } from 'node:os';
@@ -803,6 +804,11 @@ const gitRules = [
803
804
  reason: (git, ctx) =>
804
805
  `This commit changes the API contract, database models or native surface (${ctx.unapprovedContractFiles(git).join(', ')}) without the judge's approval of exactly this content. Serve the preview (contract-preview.mjs), get the judge's yes, record it with judges-card.mjs approve-contract <pr> --files <paths>, then commit. Any edit after approval needs a new one.`,
805
806
  },
807
+ {
808
+ // Blocks running side by side in one worktree: a wrestler commits only its block's files.
809
+ when: (git, ctx) => git.sub === 'commit' && ctx.leaseRefusal(git) !== null,
810
+ reason: (git, ctx) => ctx.leaseRefusal(git),
811
+ },
806
812
  {
807
813
  // An amend adds no commit; everything else waits once the unreviewed range hits the cap.
808
814
  when: (git, ctx) =>
@@ -1067,7 +1073,7 @@ const ghRules = [
1067
1073
  gh.action === 'create' &&
1068
1074
  !hasFlag(gh.args, '--draft', '-d'),
1069
1075
  reason: () =>
1070
- 'Every PR ring opens is a draft; only the human marks it ready. Add --draft to gh pr create.',
1076
+ 'Every PR opened while a bout is active is a draft, even one the human asked for outside the bout: add --draft to gh pr create, and tell the human the link; they mark it ready (gh pr ready <n>, or «Ready for review» on GitHub).',
1071
1077
  },
1072
1078
  {
1073
1079
  when: (gh) =>
@@ -1290,6 +1296,10 @@ function judgeSegment(segment, words, cwd, ctx) {
1290
1296
  hasAiTrailer(commitMessage(commit, segment, context)),
1291
1297
  commitBoutIds: (commit) => boutIds(commitMessage(commit, segment, context)),
1292
1298
  reviewDebt: (commit) => ctx.reviewDebtAt(context.path(commit.dir)),
1299
+ leaseRefusal: (commit) =>
1300
+ ctx.leaseRefusalAt(context.path(commit.dir), () =>
1301
+ ctx.committedFiles(commit, commitsWorkingTree(commit)),
1302
+ ),
1293
1303
  pushBeyondApproval: (push, refspecs) =>
1294
1304
  ctx.pushBeyondApprovalAt(context.path(push.dir), refspecs),
1295
1305
  findingsBeforePush: (push, refspecs) =>
@@ -1410,7 +1420,7 @@ function insideGitRepo(dir) {
1410
1420
  * The real context of one hook invocation. The host config is loaded lazily, only when a rule
1411
1421
  * reads it: a broken .claude/ring.json must not cost every command a parse, nor fail it.
1412
1422
  */
1413
- function realContext(cwd) {
1423
+ function realContext(cwd, agentId = null) {
1414
1424
  const bouts = new Map();
1415
1425
  const slugs = new Map();
1416
1426
  let config = null;
@@ -1506,6 +1516,18 @@ function realContext(cwd) {
1506
1516
  contract ??= loadConfig(projectDir(process.env, cwd)).config;
1507
1517
  return contract;
1508
1518
  },
1519
+ // A commit staging files another running block holds (parallelBlocks > 1), or null.
1520
+ leaseRefusalAt: (dir, staged) => {
1521
+ if ((loaded().parallelBlocks ?? 1) <= 1) return null;
1522
+ const bout = boutOfDir(dir);
1523
+ if (!bout) return null;
1524
+ return commitLeaseRefusal({
1525
+ staged: staged(),
1526
+ running: boutLeases(bout.dir),
1527
+ block: agentId ? (wrestlerBlocks(bout.dir).get(agentId) ?? null) : null,
1528
+ generated: loaded().generatedPaths,
1529
+ });
1530
+ },
1509
1531
  // The unreviewed range of the bout whose worktree holds `dir`, when it is at or over the cap.
1510
1532
  reviewDebtAt: (dir) => {
1511
1533
  const bout = boutOfDir(dir);
@@ -1771,7 +1793,10 @@ function block(reason) {
1771
1793
  function evaluate(payload) {
1772
1794
  if (payload?.tool_name !== 'Bash') return;
1773
1795
  const command = payload?.tool_input?.command ?? '';
1774
- const verdict = decide(command, realContext(payload?.cwd || process.cwd()));
1796
+ const verdict = decide(
1797
+ command,
1798
+ realContext(payload?.cwd || process.cwd(), payload?.agent_id ?? null),
1799
+ );
1775
1800
  if (verdict.block) return block(verdict.reason);
1776
1801
  if (verdict.warnings.length)
1777
1802
  process.stdout.write(
@@ -211,9 +211,20 @@ function reportInvisible(feed, command) {
211
211
  if (!feed.dir || feed.visible) return () => undefined;
212
212
  const id = `invisible:${process.pid}`;
213
213
  let shown = false;
214
- console.error(
215
- `one-at-a-time: no declared monitor shows "${commandLabel(command)}" on the Scorecard; a long run needs one: rerun with --monitor <id>, or declare a monitor whose match.command matches it`,
216
- );
214
+ // Once per bout every few hours, not on every tsc or jest.
215
+ const marker = join(feed.dir, '.no-monitor-warned');
216
+ const fresh = (() => {
217
+ try {
218
+ return Date.now() - statSync(marker).mtimeMs < 6 * 3600_000;
219
+ } catch {
220
+ return false;
221
+ }
222
+ })();
223
+ if (!fresh) writeFileSync(marker, '');
224
+ if (!fresh)
225
+ console.error(
226
+ `one-at-a-time: no declared monitor shows "${commandLabel(command)}" on the Scorecard; a long run needs one: rerun with --monitor <id>, or declare a monitor whose match.command matches it`,
227
+ );
217
228
  const timer = setTimeout(() => {
218
229
  shown = true;
219
230
  record(feed.dir, {
@@ -11,7 +11,7 @@
11
11
  import { readFileSync } from 'node:fs';
12
12
  import { join } from 'node:path';
13
13
 
14
- import { LIVE_LOG } from './live.mjs';
14
+ import { LIVE_LOG, WRESTLERS } from './live.mjs';
15
15
 
16
16
  export const PROPOSALS = 'For the rulebook / precedents';
17
17
  const DECIDED = /→\s*(accepted|rejected)\b/;
@@ -115,6 +115,18 @@ export function withConceptClosed(state, { name, range, at, wrestlers }) {
115
115
  };
116
116
  }
117
117
 
118
+ /** The wrestlers' launch lines (wrestlers.jsonl, never trimmed) and the live lines after them. */
119
+ export function wrestlerLines(dir) {
120
+ try {
121
+ return [
122
+ ...readFileSync(join(dir, WRESTLERS), 'utf8').split('\n').filter(Boolean),
123
+ ...liveLines(dir),
124
+ ];
125
+ } catch {
126
+ return liveLines(dir);
127
+ }
128
+ }
129
+
118
130
  /** The live lines of a bout, or none. */
119
131
  export function liveLines(dir) {
120
132
  try {
@@ -10,6 +10,7 @@ import { appendFileSync, existsSync, readFileSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
11
 
12
12
  import { readState, writeState } from './config.mjs';
13
+ import { globToRegExp } from './contract.mjs';
13
14
  import { parsePlan, parsePlanLog, PLAN, PLAN_LOG } from './tech-plan.mjs';
14
15
 
15
16
  export const VERDICTS = 'review-verdicts.jsonl';
@@ -25,6 +26,7 @@ const FACTS = [
25
26
  'startSha',
26
27
  'startedAt',
27
28
  'endedAt',
29
+ 'files',
28
30
  ];
29
31
 
30
32
  /** The facts of point `n` so far: each field from the last plan.jsonl line that has it. PURE. */
@@ -86,8 +88,9 @@ export function startRefusal(facts, to, n) {
86
88
  }
87
89
 
88
90
  /** Checks an estimate: priority 1–4 and whole minutes. PURE. */
89
- export function estimateFacts({ priority, minutes }) {
91
+ export function estimateFacts({ priority, minutes, files }) {
90
92
  const facts = {};
93
+ if (files?.length) facts.files = files;
91
94
  if (priority != null) {
92
95
  if (![1, 2, 3, 4].includes(Number(priority)))
93
96
  throw new Error('--priority is 1 (first) to 4 (last)');
@@ -168,6 +171,20 @@ export function verdictEffects(verdict) {
168
171
 
169
172
  const read = (path) => (existsSync(path) ? readFileSync(path, 'utf8') : '');
170
173
 
174
+ /**
175
+ * The human's «no pasa» still open as rebounds: until the blocks wait on the judge again, every
176
+ * resume (summary, compaction) shows them first, with their Tape. PURE apart from the read.
177
+ */
178
+ export function openHumanReturns(dir) {
179
+ return read(join(dir, 'rebounds.md'))
180
+ .split('\n')
181
+ .filter((line) => /^- \[ \] .*Revisión humana/.test(line));
182
+ }
183
+
184
+ /** What the referee does with a returned cut: read its Tape before writing the rebound. */
185
+ export const TAPE_HINT =
186
+ 'Read each Tape before writing the rebound (Battle Arena connector: mcp__claude_ai_Battle_Arena__qa_session_overview, then qa_session_timeline on the chapter) and cite its minutes in the rebound. If those tools are not in this session, say so to the human in one line and connect them (/mcp, the claude.ai Battle Arena connector); never design the rebound from the feedback line alone.';
187
+
171
188
  /** The author of a block: the git user of the worktree that writes it. */
172
189
  export function authorEmail(worktree) {
173
190
  try {
@@ -235,13 +252,43 @@ export function applyVerdicts(dir, state, setState) {
235
252
  * verdicts, keeps `pauseEvery` in state.json for PR Review Lab, and says whether the human's review
236
253
  * is due. `setState(n, state, ref)` moves a block of the plan.
237
254
  */
238
- export function reviewTurn(dir, every, setState) {
255
+ export function reviewTurn(dir, every, setState, parallel = null) {
239
256
  const before = readState(dir);
240
257
  const { state, applied } = applyVerdicts(dir, before, setState);
241
- const next = { ...state, pauseEvery: every ?? null };
242
- 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
+ )
243
265
  writeState(dir, next);
244
266
  const { plan, log, verdicts } = reviewInputs(dir);
245
267
  const waiting = blocksAwaitingReview(plan, log, verdicts);
246
268
  return { applied, waiting, due: reviewDue(waiting, every) };
247
269
  }
270
+
271
+ /**
272
+ * Why a wrestler may not work ahead while the human reviews a cut, or null. Ahead means one block
273
+ * at a time (nothing else «en curso») on files declared up front that the cut under review does
274
+ * not touch, so the human's verdict on the cut never lands on code that moved. PURE.
275
+ */
276
+ export function lookaheadRefusal({ globs, waitingFiles, running }) {
277
+ if (!globs.length)
278
+ return 'a block ahead of the review declares its files: --lookahead "<glob>,<glob>"';
279
+ if (running.length)
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})`;
281
+ const matchers = globs.map((glob) => globToRegExp(glob));
282
+ const overlap = waitingFiles.filter((file) =>
283
+ matchers.some((matcher) => matcher.test(file)),
284
+ );
285
+ if (overlap.length)
286
+ return `the block ahead would touch files of the cut under review: ${overlap.join(', ')}. Pick a block that does not, or wait for the verdict`;
287
+ return null;
288
+ }
289
+
290
+ /** The blocks «en curso» right now. PURE. */
291
+ export const runningBlocks = (plan) =>
292
+ parsePlan(plan)
293
+ .groups.flatMap((group) => group.items)
294
+ .filter((item) => item.state === 'en curso');
@@ -12,11 +12,15 @@ import { join } from 'node:path';
12
12
 
13
13
  import { readState } from './config.mjs';
14
14
  import { scriptJson } from './contract-endpoints.mjs';
15
+ import { runningLeases, startLaneRefusal } from './lanes.mjs';
15
16
  import {
16
17
  authorEmail,
17
18
  blockFacts,
18
19
  factsForState,
20
+ blocksAwaitingReview,
21
+ parseVerdicts,
19
22
  startRefusal,
23
+ VERDICTS,
20
24
  } from './review-blocks.mjs';
21
25
  import {
22
26
  parsePlan,
@@ -39,7 +43,7 @@ export function setPlanState(
39
43
  state,
40
44
  ref = null,
41
45
  at = new Date().toISOString(),
42
- { range = null, estimate = null, requireEstimate = false } = {},
46
+ { range = null, estimate = null, requireEstimate = false, lanes = null } = {},
43
47
  ) {
44
48
  const file = join(dir, PLAN);
45
49
  const markdown = read(file);
@@ -68,6 +72,26 @@ export function setPlanState(
68
72
  ? startRefusal(facts, next, item.n)
69
73
  : null;
70
74
  if (refusal) throw new Error(refusal);
75
+ const laneRefusal =
76
+ lanes && next === 'en curso' && next !== item.state
77
+ ? startLaneRefusal({
78
+ n: item.n,
79
+ files: facts.files,
80
+ running: runningLeases(
81
+ markdown,
82
+ parsePlanLog(read(join(dir, PLAN_LOG))),
83
+ ),
84
+ parallel: lanes.parallel,
85
+ tracked: trackedFiles(worktree),
86
+ generated: lanes.generated,
87
+ waiting: blocksAwaitingReview(
88
+ markdown,
89
+ parsePlanLog(read(join(dir, PLAN_LOG))),
90
+ parseVerdicts(read(join(dir, VERDICTS)) ?? ''),
91
+ ),
92
+ })
93
+ : null;
94
+ if (laneRefusal) throw new Error(laneRefusal);
71
95
  writeFileSync(file, withPlanState(markdown, item, next));
72
96
  appendFileSync(
73
97
  join(dir, PLAN_LOG),
@@ -76,6 +100,21 @@ export function setPlanState(
76
100
  return { ...item, from: item.state, to: next, facts };
77
101
  }
78
102
 
103
+ /** The files git tracks in the worktree (none when it is gone). */
104
+ function trackedFiles(worktree) {
105
+ try {
106
+ return execFileSync('git', ['-C', worktree, 'ls-files'], {
107
+ encoding: 'utf8',
108
+ stdio: ['ignore', 'pipe', 'ignore'],
109
+ maxBuffer: 64 * 1024 * 1024,
110
+ })
111
+ .split('\n')
112
+ .filter(Boolean);
113
+ } catch {
114
+ return [];
115
+ }
116
+ }
117
+
79
118
  /** The worktree's head, or null when it is gone. */
80
119
  function worktreeHead(worktree) {
81
120
  try {
@@ -199,7 +199,8 @@ way out, and it refuses without a KO. Then the bout runs as always, with three i
199
199
  - each new range: `brief.mjs cornerman <pr> --mode range --vars <file>` (≤15 lines; the range
200
200
  is `lastReviewed..HEAD` unless you pass `--range`; your value: `claims`);
201
201
  - when the wrestler closes a concept: `--mode concept` (your values: `invariants`, numbered,
202
- and `claims`; `designReference` at a UI venue), over ranges already reviewed one by one. It
202
+ always including «no question is asked again downstream for the same reason» (PR31), and
203
+ `claims`; `designReference` at a UI venue), over ranges already reviewed one by one. It
203
204
  also reads the concept as one piece through the venue's lenses: what no single range shows;
204
205
  - a design review: `--mode batch --files <f1,f2,…>`.
205
206
  **A concept ends its wrestler.** When the concept review approves it, run
@@ -219,7 +220,26 @@ way out, and it refuses without a KO. Then the bout runs as always, with three i
219
220
  `humanReviewEvery` blocks waiting, brief.mjs stops: the human reviews that cut in PR Review Lab
220
221
  (bout mode) and signs a verdict with a Tape. The next brief applies it: a pass closes the
221
222
  blocks, a fail comes back as a rebound the next wrestler fixes first. Do not work around the
222
- pause.
223
+ pause. **Work ahead while the human reviews:** one wrestler may take ONE more block whose files
224
+ the cut does not touch: `brief.mjs wrestler <pr> --mode resume --vars <file> --lookahead
225
+ "<glob>,<glob>"` (the files that block will touch). brief.mjs refuses it when those globs match a
226
+ file of the cut, or when another block is already «en curso». Its brief says which files it may
227
+ touch; a rebound from the verdict still goes first.
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
232
+ other nor share a contract (props, a hook's return, i18n keys, a helper): a file lock does not
233
+ see that, you do. Start each with its files: `judges-card.mjs plan <pr> <n> "en curso"
234
+ --minutes <m> --priority <p> --files "<glob>,<glob>"`; the script refuses a start whose files a
235
+ running block holds. Brief each with `--block <n>` (its brief lists its files and the other
236
+ wrestler's). mask-guard refuses a commit that stages another block's file; a wrestler that needs
237
+ one stops and asks you: decide who keeps it (narrow one block's `--files` with `plan <pr>
238
+ estimate <n> --files …`). Close each with `judges-card.mjs concept <pr> "<name>" --range <A>..<B>
239
+ --block <n>`: only that block's wrestler retires. The review cap counts both wrestlers' commits:
240
+ review the shared range as usual. **When the wave closes** (both blocks wait on the judge),
241
+ regenerate the `generatedPaths` files once and commit them yourself, then run the typecheck and
242
+ the tests of both blocks on the shared tip before the human's cut.
223
243
  If the wrestler claimed something ("does not change counter X"), hand the claim to the
224
244
  cornerman to verify against the base. The cornerman always checks the format of the range
225
245
  (`format-gate.mjs check`); an unclean file is a finding like any other, fixed by the wrestler
@@ -491,6 +511,8 @@ logs there; whichever session regenerates the page passes every bout dir of the
491
511
 
492
512
  ## 4 · `close`
493
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
+
494
516
  When the PR gets an automated review (GitHub Copilot), converge it with
495
517
  `/ring:title-defense <pr> [--max-rounds N]` before or during `close`: it catalogues the findings
496
518
  and runs the fixes through this same cycle (§2).
@@ -541,7 +563,7 @@ and runs the fixes through this same cycle (§2).
541
563
 
542
564
  ## 5 · `resume [<pr>]`
543
565
 
544
- 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>"`.
545
567
  2. `node "$RING/scripts/judges-card.mjs" summary <pr>`: state (with the bout's venue),
546
568
  unreviewed commits, open rebounds, decisions and the judges card, and, when Engram answers,
547
569
  the repo's latest fouls, verdicts and precedents from earlier bouts (do not repeat those fouls;
@@ -47,6 +47,12 @@ _Why: after the first approved slice was cited as the reference, the next one dr
47
47
  **PR11. Under doubt between two reasonable options, keep current behavior and wire contracts; ticket
48
48
  the rest.** _Why: a refactor that also "improves" behavior cannot be validated against the base._
49
49
 
50
+ **PR31. A null or an undefined that crosses more than one function is settled where it is born.**
51
+ With a default, a value that cannot be missing, or a Null Object; never asked again downstream for
52
+ the same reason. Count how many times the range asks the same question: three or more is a finding.
53
+ _Why: in agents-studio-fe#606 «did the call connect?» was asked six times downstream; each range
54
+ review approved its piece, the human rejected the cut, and the first rebound only moved the cascade._
55
+
50
56
  ## Process
51
57
 
52
58
  **PR12. Small reviewed slices first, polish later.** No automated review loops or convergence before
@@ -32,7 +32,9 @@ WAITING ON THE HUMAN (hard list; do not touch): {{waitingOnHuman}}.
32
32
  Design doubts go to me, never to the human: I decide them (or escalate them) and tell you.
33
33
 
34
34
  Gates (from `.claude/ring.json`, each through one-at-a-time; a long run such as a battery adds
35
- `--monitor <id>` when its command does not match a declared monitor, so it shows on the Scorecard):
35
+ `--monitor <id>` when its command does not match a declared monitor, so it shows on the Scorecard).
36
+ Run them when the block's work is done, before you report it, on the files you touched; not after
37
+ every edit (a half-written block fails for no reason and holds the machine):
36
38
 
37
39
  - typecheck: `{{gateTypecheck}}`
38
40
  - tests of what you touched: `{{gateTest}}`
@@ -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),