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

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` 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
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.83",
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,84 @@ 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) {
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
+ running: runningBlocks(plan),
245
+ });
246
+ if (refusal) throw new Error(`${turn.due}. ${refusal}`);
247
+ return [
248
+ '## Working ahead of the human review (the cut under review must not move)',
249
+ `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.`,
250
+ ].join('\n\n');
251
+ }
252
+
169
253
  /** The open rebounds the human's review sent back, as a section of the brief (none: nothing). */
170
254
  function humanReview(dir) {
171
255
  const path = join(dir, 'rebounds.md');
@@ -180,6 +264,20 @@ function humanReview(dir) {
180
264
  : [];
181
265
  }
182
266
 
267
+ /**
268
+ * The files the bout changed so far (merge base with its base..head): what Engram searches on when
269
+ * the brief's own range is empty, as a wrestler starting a new concept has no commit yet.
270
+ */
271
+ function boutFiles({ worktree, base }) {
272
+ try {
273
+ return git(worktree, ['diff', '--name-only', `origin/${base}...HEAD`])
274
+ .split('\n')
275
+ .filter(Boolean);
276
+ } catch {
277
+ return [];
278
+ }
279
+ }
280
+
183
281
  const real = (path) => (existsSync(path) ? realpathSync(path) : path);
184
282
 
185
283
  async function generate(kind, [pr, ...argv]) {
@@ -194,6 +292,7 @@ async function generate(kind, [pr, ...argv]) {
194
292
  throw new Error(
195
293
  `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
294
  );
295
+ let ahead = null;
197
296
  if (kind === 'wrestler') {
198
297
  // The human's verdicts from PR Review Lab first: a fail comes back as a rebound for this brief.
199
298
  const turn = reviewTurn(
@@ -203,7 +302,7 @@ async function generate(kind, [pr, ...argv]) {
203
302
  );
204
303
  if (turn.applied.length)
205
304
  console.error(`human review applied: ${turn.applied.join(' · ')}`);
206
- if (turn.due) throw new Error(turn.due);
305
+ if (turn.due) ahead = workAhead(bout, values.lookahead, turn);
207
306
  }
208
307
  const own = values.vars ? JSON.parse(readFileSync(values.vars, 'utf8')) : {};
209
308
  const roots = [
@@ -237,17 +336,19 @@ async function generate(kind, [pr, ...argv]) {
237
336
  ? await recallSection(bout.config, {
238
337
  slug: repoSlug(projectDir()),
239
338
  pr,
240
- files,
339
+ files: files.length ? files : boutFiles(bout.state),
241
340
  })
242
341
  : null;
243
342
  // The task goes first and the rules last, as the reference: a brief that opens with the rules
244
343
  // reads as a checklist, and a 2026-11 lab on #1212 found fewer real problems that way.
245
344
  const body = [
246
345
  text.trim(),
247
- '## Decisions already taken (excerpt: every foul, the ones on these files, the last 20)',
346
+ '## Decisions already taken (excerpt: every foul, every ruling of the judge, the ones on these files, the last 20)',
248
347
  decisions,
249
348
  `The whole ledger: \`${decisionsPath}\`.`,
250
349
  ...humanReview(bout.dir),
350
+ ...(ahead ? [ahead] : []),
351
+ ...(kind === 'wrestler' ? laneSection(bout, values.block) : []),
251
352
  ...(learned ? [learned] : []),
252
353
  ...(shape.rules ? ['---', rules.block] : []),
253
354
  ].join('\n\n');
@@ -262,6 +363,9 @@ async function generate(kind, [pr, ...argv]) {
262
363
  mode: values.mode,
263
364
  pr,
264
365
  n,
366
+ ...(kind === 'wrestler' && values.block
367
+ ? { block: Number(values.block) }
368
+ : {}),
265
369
  rules: shape.rules ? rules.hash : 'none',
266
370
  };
267
371
  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,12 @@ 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). 1: one at a time, as always.
165
+ parallelBlocks: 1,
166
+ // Files the host regenerates on its own (a pre-commit that writes docs): no block holds them, and
167
+ // the referee regenerates them when a wave of parallel blocks closes. Globs.
168
+ generatedPaths: [],
163
169
  // Engram (github.com/Gentleman-Programming/engram), the memory of what earlier bouts learned:
164
170
  // fouls, the judge's verdicts, accepted precedents, X0 findings and each bout's status. Never the
165
171
  // rules. The scripts sync and read it when something answers at this URL (null: off). The
@@ -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';
@@ -317,10 +324,18 @@ async function summary([pr]) {
317
324
  const review = reviewTurn(dir, config.humanReviewEvery, (n, to, ref) =>
318
325
  setPlanState(dir, n, to, ref),
319
326
  );
327
+ const returned = openHumanReturns(dir);
320
328
  console.log(
321
329
  [
322
330
  '',
323
331
  '## Human review by blocks (PR Review Lab, bout mode)',
332
+ ...(returned.length
333
+ ? [
334
+ `RETURNED BY THE HUMAN, rebound still open (${returned.length}):`,
335
+ ...returned,
336
+ TAPE_HINT,
337
+ ]
338
+ : []),
324
339
  review.applied.length
325
340
  ? `verdicts applied now: ${review.applied.join(' · ')}`
326
341
  : 'no new verdicts',
@@ -601,6 +616,7 @@ function plan([pr, target, ...rest]) {
601
616
  estimate: estimateFacts({
602
617
  priority: value('--priority'),
603
618
  minutes: value('--minutes'),
619
+ files: parseGlobs(value('--files')),
604
620
  }),
605
621
  });
606
622
  return void console.log(
@@ -616,17 +632,24 @@ function plan([pr, target, ...rest]) {
616
632
  contract: false,
617
633
  venue: readState(dir).venue ?? DEFAULT_VENUE,
618
634
  });
619
- const inline = [value('--minutes'), value('--priority')].some(
620
- (given) => given != null,
621
- );
635
+ const inline = [
636
+ value('--minutes'),
637
+ value('--priority'),
638
+ value('--files'),
639
+ ].some((given) => given != null);
622
640
  const changed = setPlanState(dir, target, state, value('--ref'), undefined, {
623
641
  range: value('--range'),
624
642
  estimate: inline
625
643
  ? estimateFacts({
626
644
  priority: value('--priority'),
627
645
  minutes: value('--minutes'),
646
+ files: parseGlobs(value('--files')),
628
647
  })
629
648
  : null,
649
+ lanes: {
650
+ parallel: config.parallelBlocks,
651
+ generated: config.generatedPaths,
652
+ },
630
653
  // Under the human review by blocks a block starts with its estimate and priority.
631
654
  requireEstimate: Boolean(config.humanReviewEvery),
632
655
  });
@@ -958,14 +981,19 @@ function playByPlayUrl([pr, url]) {
958
981
  * concept goes to a fresh wrestler; every `relayEvery` concepts the referee relays first.
959
982
  */
960
983
  function concept([pr, name, ...argv]) {
961
- const { range } = options(argv);
984
+ const { range, block } = options(argv);
962
985
  const dir = currentDir(pr);
963
986
  const state = readState(dir);
964
987
  const next = withConceptClosed(state, {
965
988
  name,
966
989
  range,
967
990
  at: new Date().toISOString(),
968
- wrestlers: wrestlerIds(liveLines(dir)),
991
+ // With --block only that block's wrestlers retire: another block may still be running.
992
+ wrestlers: block
993
+ ? [...wrestlerBlocks(dir)]
994
+ .filter(([, n]) => n === Number(block))
995
+ .map(([agent]) => agent)
996
+ : wrestlerIds(wrestlerLines(dir)),
969
997
  });
970
998
  writeState(dir, next);
971
999
  const { config } = loadConfig(projectDir(), {
@@ -0,0 +1,139 @@
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
+ }) {
55
+ const others = running.filter((lease) => lease.n !== n);
56
+ if (!parallel || parallel <= 1 || !others.length) return null;
57
+ if (others.length >= parallel)
58
+ return `${others.length} blocks are already running (parallelBlocks ${parallel}): ${others.map((lease) => `B${lease.n}`).join(', ')}. Start B${n} when one closes`;
59
+ if (!files?.length)
60
+ 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
+ const isGenerated = matcher(generated);
62
+ const mine = matcher(files);
63
+ const clashes = others.flatMap((lease) => {
64
+ const theirs = matcher(lease.files);
65
+ return tracked
66
+ .filter((file) => !isGenerated(file) && mine(file) && theirs(file))
67
+ .map((file) => `${file} (B${lease.n})`);
68
+ });
69
+ if (clashes.length)
70
+ 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`;
71
+ return null;
72
+ }
73
+
74
+ /**
75
+ * Why a commit may not go in, or null: a staged file held by a running block other than the one
76
+ * whose wrestler commits. Unknown committer: a commit may not mix files of two blocks. PURE.
77
+ */
78
+ export function commitLeaseRefusal({ staged, running, block, generated }) {
79
+ if (running.length < 2) return null;
80
+ const isGenerated = matcher(generated);
81
+ const owners = staged
82
+ .filter((file) => !isGenerated(file))
83
+ .flatMap((file) =>
84
+ running
85
+ .filter((lease) => matcher(lease.files)(file))
86
+ .map((lease) => ({ file, n: lease.n })),
87
+ );
88
+ const foreign = block
89
+ ? owners.filter((owner) => owner.n !== block)
90
+ : owners.filter((owner) => owner.n !== owners[0]?.n);
91
+ if (!foreign.length) return null;
92
+ 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.`;
93
+ }
94
+
95
+ const jsonl = (path) =>
96
+ existsSync(path) ? parsePlanLog(readFileSync(path, 'utf8')) : [];
97
+
98
+ /**
99
+ * The block each wrestler works on: its agent id (live.jsonl) → the Agent launch (tool_use_id) →
100
+ * the brief brief-guard saw → that brief's `block` in briefs/index.jsonl. PURE apart from reads.
101
+ */
102
+ export function wrestlerBlocks(dir) {
103
+ const briefs = new Map(
104
+ jsonl(join(dir, 'briefs', 'index.jsonl'))
105
+ .filter((brief) => brief.kind === 'wrestler' && brief.block)
106
+ .map((brief) => [
107
+ `wrestler-${brief.mode}-${brief.n}.md`,
108
+ Number(brief.block),
109
+ ]),
110
+ );
111
+ const launches = new Map(
112
+ jsonl(join(dir, 'brief-guard.jsonl'))
113
+ .filter((entry) => entry.toolUseId && entry.path)
114
+ .map((entry) => [
115
+ entry.toolUseId,
116
+ briefs.get(entry.path.split('/').at(-1)),
117
+ ]),
118
+ );
119
+ const blocks = new Map();
120
+ for (const line of wrestlerLines(dir)) {
121
+ try {
122
+ const event = JSON.parse(line);
123
+ const agent = /^agent (\S+)$/.exec(event.note ?? '')?.[1];
124
+ const block = launches.get(event.id);
125
+ if (event.actor === 'luchador' && agent && block)
126
+ blocks.set(agent, block);
127
+ } catch {
128
+ // A torn line is skipped.
129
+ }
130
+ }
131
+ return blocks;
132
+ }
133
+
134
+ /** The running blocks of a bout dir, read now. */
135
+ export function boutLeases(dir) {
136
+ const read = (name) =>
137
+ existsSync(join(dir, name)) ? readFileSync(join(dir, name), 'utf8') : '';
138
+ return runningLeases(read(PLAN), parsePlanLog(read(PLAN_LOG)));
139
+ }
@@ -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);
@@ -19,11 +19,13 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
19
19
  import { join } from 'node:path';
20
20
 
21
21
  import {
22
+ boutDir,
22
23
  DEFAULTS,
23
24
  isMain,
24
25
  loadConfig,
25
26
  projectDir,
26
27
  readState,
28
+ repoSlug,
27
29
  writeState,
28
30
  } from './config.mjs';
29
31
  import { counts, readLedger, reviewDeclared } from './findings.mjs';
@@ -332,7 +334,11 @@ function approve(dir, ref, { formatUnverified = null } = {}) {
332
334
  }
333
335
 
334
336
  if (isMain(import.meta.url)) {
335
- const [command, dir, ...rest] = process.argv.slice(2);
337
+ const [command, target, ...rest] = process.argv.slice(2);
338
+ // A bout dir, or the PR number every other ring script takes.
339
+ const dir = /^\d+$/.test(target ?? '')
340
+ ? boutDir(repoSlug(projectDir()), target)
341
+ : target;
336
342
  const intervalIndex = rest.indexOf('--interval');
337
343
  const interval = intervalIndex === -1 ? 60 : Number(rest[intervalIndex + 1]);
338
344
  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 {
@@ -245,3 +262,28 @@ export function reviewTurn(dir, every, setState) {
245
262
  const waiting = blocksAwaitingReview(plan, log, verdicts);
246
263
  return { applied, waiting, due: reviewDue(waiting, every) };
247
264
  }
265
+
266
+ /**
267
+ * Why a wrestler may not work ahead while the human reviews a cut, or null. Ahead means one block
268
+ * at a time (nothing else «en curso») on files declared up front that the cut under review does
269
+ * not touch, so the human's verdict on the cut never lands on code that moved. PURE.
270
+ */
271
+ export function lookaheadRefusal({ globs, waitingFiles, running }) {
272
+ if (!globs.length)
273
+ return 'a block ahead of the review declares its files: --lookahead "<glob>,<glob>"';
274
+ if (running.length)
275
+ return `only one block works ahead of the review: B${running[0].n} «${running[0].title}» is already «en curso»`;
276
+ const matchers = globs.map((glob) => globToRegExp(glob));
277
+ const overlap = waitingFiles.filter((file) =>
278
+ matchers.some((matcher) => matcher.test(file)),
279
+ );
280
+ if (overlap.length)
281
+ 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`;
282
+ return null;
283
+ }
284
+
285
+ /** The blocks «en curso» right now. PURE. */
286
+ export const runningBlocks = (plan) =>
287
+ parsePlan(plan)
288
+ .groups.flatMap((group) => group.items)
289
+ .filter((item) => item.state === 'en curso');
@@ -12,6 +12,7 @@ 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,
@@ -39,7 +40,7 @@ export function setPlanState(
39
40
  state,
40
41
  ref = null,
41
42
  at = new Date().toISOString(),
42
- { range = null, estimate = null, requireEstimate = false } = {},
43
+ { range = null, estimate = null, requireEstimate = false, lanes = null } = {},
43
44
  ) {
44
45
  const file = join(dir, PLAN);
45
46
  const markdown = read(file);
@@ -68,6 +69,21 @@ export function setPlanState(
68
69
  ? startRefusal(facts, next, item.n)
69
70
  : null;
70
71
  if (refusal) throw new Error(refusal);
72
+ const laneRefusal =
73
+ lanes && next === 'en curso' && next !== item.state
74
+ ? startLaneRefusal({
75
+ n: item.n,
76
+ files: facts.files,
77
+ running: runningLeases(
78
+ markdown,
79
+ parsePlanLog(read(join(dir, PLAN_LOG))),
80
+ ),
81
+ parallel: lanes.parallel,
82
+ tracked: trackedFiles(worktree),
83
+ generated: lanes.generated,
84
+ })
85
+ : null;
86
+ if (laneRefusal) throw new Error(laneRefusal);
71
87
  writeFileSync(file, withPlanState(markdown, item, next));
72
88
  appendFileSync(
73
89
  join(dir, PLAN_LOG),
@@ -76,6 +92,21 @@ export function setPlanState(
76
92
  return { ...item, from: item.state, to: next, facts };
77
93
  }
78
94
 
95
+ /** The files git tracks in the worktree (none when it is gone). */
96
+ function trackedFiles(worktree) {
97
+ try {
98
+ return execFileSync('git', ['-C', worktree, 'ls-files'], {
99
+ encoding: 'utf8',
100
+ stdio: ['ignore', 'pipe', 'ignore'],
101
+ maxBuffer: 64 * 1024 * 1024,
102
+ })
103
+ .split('\n')
104
+ .filter(Boolean);
105
+ } catch {
106
+ return [];
107
+ }
108
+ }
109
+
79
110
  /** The worktree's head, or null when it is gone. */
80
111
  function worktreeHead(worktree) {
81
112
  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,24 @@ 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` > 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
230
+ other nor share a contract (props, a hook's return, i18n keys, a helper): a file lock does not
231
+ see that, you do. Start each with its files: `judges-card.mjs plan <pr> <n> "en curso"
232
+ --minutes <m> --priority <p> --files "<glob>,<glob>"`; the script refuses a start whose files a
233
+ running block holds. Brief each with `--block <n>` (its brief lists its files and the other
234
+ wrestler's). mask-guard refuses a commit that stages another block's file; a wrestler that needs
235
+ one stops and asks you: decide who keeps it (narrow one block's `--files` with `plan <pr>
236
+ estimate <n> --files …`). Close each with `judges-card.mjs concept <pr> "<name>" --range <A>..<B>
237
+ --block <n>`: only that block's wrestler retires. The review cap counts both wrestlers' commits:
238
+ review the shared range as usual. **When the wave closes** (both blocks wait on the judge),
239
+ regenerate the `generatedPaths` files once and commit them yourself, then run the typecheck and
240
+ the tests of both blocks on the shared tip before the human's cut.
223
241
  If the wrestler claimed something ("does not change counter X"), hand the claim to the
224
242
  cornerman to verify against the base. The cornerman always checks the format of the range
225
243
  (`format-gate.mjs check`); an unclean file is a finding like any other, fixed by the wrestler
@@ -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}}`