@thebattlearena/corner 0.10.1-beta.79 → 0.10.1-beta.80

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
@@ -374,7 +374,7 @@ rama. El contrato para el proyecto está en `ring/skills/sparring/references/hos
374
374
  **Config del repo** (`.claude/ring.json`, opcional): `gates` (typecheck, tests por ruta con
375
375
  `{paths}`, lint), `validateCommand`, `rulebook` (por defecto el genérico del plugin),
376
376
  `protectedBranches`, `forbidAiTrailers` (por defecto `true`), `heavyCommands`, `delegation`,
377
- `precedents`, `monitors` (ver «Monitores del repo»), `briefGuard` (`warn`, `block` u `off`), `layers`, `layersIgnore`, `layersRequired` y `findingsNeedJudge` (ver «Gate de arquitectura»), `relayEvery`, `engramUrl` y `engramProject` (ver «Memoria entre peleas»), y `agents` (modelo y esfuerzo por rol; por
377
+ `precedents`, `monitors` (ver «Monitores del repo»), `briefGuard` (`warn`, `block` u `off`), `layers`, `layersIgnore`, `layersRequired` y `findingsNeedJudge` (ver «Gate de arquitectura»), `relayEvery`, `humanReviewEvery` (ver «Revisión humana por bloques»), `engramUrl` y `engramProject` (ver «Memoria entre peleas»), y `agents` (modelo y esfuerzo por rol; por
378
378
  defecto el luchador y el rudo en `sonnet` con esfuerzo `high` y el esquinero en `opus` con `high`,
379
379
  p. ej. `"agents": { "wrestler": { "model": "opus" } }`). Sin el archivo, `ring` usa los gates que
380
380
  detecta en `package.json`, `pyproject.toml`, `go.mod` y el `Makefile`.
@@ -437,6 +437,18 @@ compactarse. Y `judges-card.mjs close` no cierra mientras una propuesta de «For
437
437
  precedents» (los `X0` incluidos) no tenga decisión: `judges-card.mjs proposal <pr> <n>
438
438
  accept|reject --why "<lo que dijo el humano>"`.
439
439
 
440
+ **Revisión humana por bloques (PR Review Lab)**: cada punto del plan técnico es un bloque (`B<n>` es
441
+ el punto n). `judges-card.mjs plan <pr> estimate <n> --minutes <m> [--priority 1-4]` le pone estimado y
442
+ prioridad. Al pasar a «en curso» se anotan el inicio y el autor (el `user.email` de git del worktree),
443
+ y al pasar a «espera al juez» el fin y el rango (`--range A..B` lo fija a mano); todo va en
444
+ `plan.jsonl`, que es lo que lee el modo «Pelea del ring» de PR Review Lab. Cuando `humanReviewEvery`
445
+ bloques (2 por default, `null` lo apaga) esperan al juez, suena la campana en PR Review Lab y
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`
448
+ y el ring lo lee en el siguiente brief o `summary`: si pasa, los bloques quedan en «hecho»; si no
449
+ pasa, vuelven a «en curso» con el feedback y el Tape como rebote, que el brief del siguiente luchador
450
+ trae al principio. Cada veredicto queda en `decisions.md` como decisión del juez (y en Engram).
451
+
440
452
  **Memoria entre peleas (Engram, opcional)**: si en `engramUrl` (por default
441
453
  `http://127.0.0.1:7437`) contesta un [Engram](https://github.com/Gentleman-Programming/engram)
442
454
  (`engram serve`), los scripts le mandan lo que cada pelea aprendió: las faltas, las respuestas y
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thebattlearena/corner",
3
- "version": "0.10.1-beta.79",
3
+ "version": "0.10.1-beta.80",
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": {
@@ -44,6 +44,8 @@ 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';
48
+ import { setPlanState } from './tech-plan-log.mjs';
47
49
  import { loadRules } from './rule-ids.mjs';
48
50
 
49
51
  const TEMPLATES = join(RING_ROOT, 'skills', 'referee', 'templates');
@@ -164,6 +166,20 @@ function boutVars({ dir, state, config }, values) {
164
166
  };
165
167
  }
166
168
 
169
+ /** The open rebounds the human's review sent back, as a section of the brief (none: nothing). */
170
+ function humanReview(dir) {
171
+ const path = join(dir, 'rebounds.md');
172
+ const open = (existsSync(path) ? readFileSync(path, 'utf8') : '')
173
+ .split('\n')
174
+ .filter((line) => /^- \[ \] .*Revisión humana/.test(line));
175
+ return open.length
176
+ ? [
177
+ '## Sent back by the human review (PR Review Lab; fix these first, the Tape shows what failed)',
178
+ open.join('\n'),
179
+ ]
180
+ : [];
181
+ }
182
+
167
183
  const real = (path) => (existsSync(path) ? realpathSync(path) : path);
168
184
 
169
185
  async function generate(kind, [pr, ...argv]) {
@@ -178,6 +194,17 @@ async function generate(kind, [pr, ...argv]) {
178
194
  throw new Error(
179
195
  `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`,
180
196
  );
197
+ if (kind === 'wrestler') {
198
+ // The human's verdicts from PR Review Lab first: a fail comes back as a rebound for this brief.
199
+ const turn = reviewTurn(
200
+ bout.dir,
201
+ bout.config.humanReviewEvery,
202
+ (n, to, ref) => setPlanState(bout.dir, n, to, ref),
203
+ );
204
+ if (turn.applied.length)
205
+ console.error(`human review applied: ${turn.applied.join(' · ')}`);
206
+ if (turn.due) throw new Error(turn.due);
207
+ }
181
208
  const own = values.vars ? JSON.parse(readFileSync(values.vars, 'utf8')) : {};
182
209
  const roots = [
183
210
  projectDir(),
@@ -220,6 +247,7 @@ async function generate(kind, [pr, ...argv]) {
220
247
  '## Decisions already taken (excerpt: every foul, the ones on these files, the last 20)',
221
248
  decisions,
222
249
  `The whole ledger: \`${decisionsPath}\`.`,
250
+ ...humanReview(bout.dir),
223
251
  ...(learned ? [learned] : []),
224
252
  ...(shape.rules ? ['---', rules.block] : []),
225
253
  ].join('\n\n');
@@ -156,6 +156,10 @@ export const DEFAULTS = Object.freeze({
156
156
  // Concepts after which the referee relays (rewrites the card's «How to resume» and runs
157
157
  // judges-card.mjs relay) before brief.mjs briefs the next wrestler. null: never.
158
158
  relayEvery: 4,
159
+ // The human review by blocks (review-blocks.mjs, PR Review Lab's bout mode): once this many
160
+ // blocks of the plan (points with a reviewed range) wait on the judge, brief.mjs briefs no
161
+ // wrestler until the human signs a verdict on that cut. null: no pause.
162
+ humanReviewEvery: 2,
159
163
  // Engram (github.com/Gentleman-Programming/engram), the memory of what earlier bouts learned:
160
164
  // fouls, the judge's verdicts, accepted precedents, X0 findings and each bout's status. Never the
161
165
  // rules. The scripts sync and read it when something answers at this URL (null: off). The
@@ -115,7 +115,8 @@ import {
115
115
  } from './preparation.mjs';
116
116
  import { handOffWarning, withChronicleUrl } from './sparring-chronicle.mjs';
117
117
  import { refereeRecall, syncAfter } from './engram.mjs';
118
- import { parsePlan, PLAN } from './tech-plan.mjs';
118
+ import { estimateFacts, reviewTurn } from './review-blocks.mjs';
119
+ import { parsePlan, PLAN, planItem } from './tech-plan.mjs';
119
120
  import { addPlanItem, setPlanState } from './tech-plan-log.mjs';
120
121
  import { logPageVerdicts } from './verdicts-log.mjs';
121
122
  import { DEFAULT_VENUE } from './venues.mjs';
@@ -313,6 +314,22 @@ async function summary([pr]) {
313
314
  contract: false,
314
315
  venue: state.venue ?? DEFAULT_VENUE,
315
316
  });
317
+ const review = reviewTurn(dir, config.humanReviewEvery, (n, to, ref) =>
318
+ setPlanState(dir, n, to, ref),
319
+ );
320
+ console.log(
321
+ [
322
+ '',
323
+ '## Human review by blocks (PR Review Lab, bout mode)',
324
+ review.applied.length
325
+ ? `verdicts applied now: ${review.applied.join(' · ')}`
326
+ : 'no new verdicts',
327
+ review.waiting.length
328
+ ? `waiting on the judge: ${review.waiting.map((item) => `B${item.n} «${item.title}»`).join(', ')}`
329
+ : 'no block waits on the judge',
330
+ review.due ?? '',
331
+ ].join('\n'),
332
+ );
316
333
  const learned = await refereeRecall(config, {
317
334
  slug: repoSlug(projectDir()),
318
335
  pr,
@@ -577,14 +594,29 @@ function plan([pr, target, ...rest]) {
577
594
  : 'a point with that origin is already in the plan',
578
595
  );
579
596
  }
597
+ if (target === 'estimate') {
598
+ const [n] = rest;
599
+ const point = planItem(parsePlan(readFileSync(join(dir, PLAN), 'utf8')), n);
600
+ const estimated = setPlanState(dir, point.n, point.state, null, undefined, {
601
+ estimate: estimateFacts({
602
+ priority: value('--priority'),
603
+ minutes: value('--minutes'),
604
+ }),
605
+ });
606
+ return void console.log(
607
+ `B${estimated.n} «${estimated.title}»: priority ${estimated.facts.priority ?? '—'} · ${estimated.facts.estimateMin ?? '—'} min`,
608
+ );
609
+ }
580
610
  const [state] = rest;
581
611
  if (!target || !state)
582
612
  throw new Error(
583
- 'usage: plan <pr> <n|title> <pendiente|espera al juez|en curso|hecho> [--ref <sha|round-bell>] | add … | list',
613
+ 'usage: plan <pr> <n|title> <pendiente|espera al juez|en curso|hecho> [--ref <sha|round-bell>] [--range A..B] | estimate <n> --minutes <m> [--priority 1-4] | add … | list',
584
614
  );
585
- const changed = setPlanState(dir, target, state, value('--ref'));
615
+ const changed = setPlanState(dir, target, state, value('--ref'), undefined, {
616
+ range: value('--range'),
617
+ });
586
618
  console.log(
587
- `${changed.n}. ${changed.title}: ${changed.from} → ${changed.to} at ${localStamp(new Date())}`,
619
+ `${changed.n}. ${changed.title}: ${changed.from} → ${changed.to} at ${localStamp(new Date())}${changed.facts.range && changed.to === 'espera al juez' ? ` · range ${changed.facts.range}` : ''}`,
588
620
  );
589
621
  }
590
622
 
@@ -0,0 +1,232 @@
1
+ // The human review by blocks with PR Review Lab: each point of the technical plan is a block (B<n>
2
+ // is point n), with its priority, estimate, author and range in plan.jsonl; every `humanReviewEvery`
3
+ // blocks waiting on the judge, brief.mjs briefs no wrestler until the human reviews that cut in
4
+ // PR Review Lab and signs a verdict with a Tape. PR Review Lab appends the verdicts to
5
+ // `<bout dir>/review-verdicts.jsonl`; the ring reads them: a pass moves the blocks to «hecho», a
6
+ // fail sends them back with the feedback and the Tape as a rebound for the next wrestler.
7
+ // Zero-dependency.
8
+ import { execFileSync } from 'node:child_process';
9
+ import { appendFileSync, existsSync, readFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+
12
+ import { readState, writeState } from './config.mjs';
13
+ import { parsePlan, parsePlanLog, PLAN, PLAN_LOG } from './tech-plan.mjs';
14
+
15
+ export const VERDICTS = 'review-verdicts.jsonl';
16
+ const WAITING = 'espera al juez';
17
+ const TAPE = /^https:\/\/(?:[\w-]+\.)?battlearena\.dev\/t\/[\w-]+$/;
18
+
19
+ /** What PR Review Lab reads of a block, as its last line of plan.jsonl carries it. */
20
+ const FACTS = [
21
+ 'priority',
22
+ 'estimateMin',
23
+ 'email',
24
+ 'range',
25
+ 'startSha',
26
+ 'startedAt',
27
+ 'endedAt',
28
+ ];
29
+
30
+ /** The facts of point `n` so far: each field from the last plan.jsonl line that has it. PURE. */
31
+ export function blockFacts(log, n) {
32
+ return log
33
+ .filter((line) => line.n === n)
34
+ .reduce(
35
+ (facts, line) => ({
36
+ ...facts,
37
+ ...Object.fromEntries(
38
+ FACTS.filter((key) => line[key] != null).map((key) => [
39
+ key,
40
+ line[key],
41
+ ]),
42
+ ),
43
+ }),
44
+ {},
45
+ );
46
+ }
47
+
48
+ /**
49
+ * The facts a state change adds: entering «en curso» stamps the start (the first time: a block sent
50
+ * back keeps its start, so its next review shows the whole block), and «espera al juez» the end and
51
+ * the range from that start to the worktree's head. PURE.
52
+ */
53
+ export function factsForState(previous, to, { at, head, email, range }) {
54
+ if (to === 'en curso')
55
+ return {
56
+ startedAt: previous.startedAt ?? at,
57
+ startSha: previous.startSha ?? head,
58
+ email: previous.email ?? email,
59
+ // Back at work: no end and no range until it waits on the judge again.
60
+ endedAt: null,
61
+ range: null,
62
+ };
63
+ if (to === WAITING) {
64
+ const from = previous.startSha;
65
+ return {
66
+ endedAt: at,
67
+ range: range ?? (from && head ? `${from}..${head}` : null),
68
+ };
69
+ }
70
+ return {};
71
+ }
72
+
73
+ /** Checks an estimate: priority 1–4 and whole minutes. PURE. */
74
+ export function estimateFacts({ priority, minutes }) {
75
+ const facts = {};
76
+ if (priority != null) {
77
+ if (![1, 2, 3, 4].includes(Number(priority)))
78
+ throw new Error('--priority is 1 (first) to 4 (last)');
79
+ facts.priority = Number(priority);
80
+ }
81
+ if (minutes != null) {
82
+ if (!/^\d+$/.test(String(minutes)) || Number(minutes) < 1)
83
+ throw new Error('--minutes is a whole number of minutes');
84
+ facts.estimateMin = Number(minutes);
85
+ }
86
+ if (!Object.keys(facts).length)
87
+ throw new Error(
88
+ 'usage: plan <pr> estimate <n> --minutes <m> [--priority 1-4]',
89
+ );
90
+ return facts;
91
+ }
92
+
93
+ /** The verdicts PR Review Lab signed, in order; a torn line is skipped. PURE. */
94
+ export function parseVerdicts(text) {
95
+ return parsePlanLog(text).filter(
96
+ (verdict) =>
97
+ ['pass', 'fail'].includes(verdict.verdict) &&
98
+ Array.isArray(verdict.blocks) &&
99
+ verdict.at,
100
+ );
101
+ }
102
+
103
+ /** `["B3", "B4"]` (or numbers) as point numbers. PURE. */
104
+ export const blockNumbers = (blocks) =>
105
+ blocks.map((block) => Number(/\d+/.exec(String(block))?.[0])).filter(Boolean);
106
+
107
+ /**
108
+ * The blocks waiting for the human: in «espera al juez», with a range to review, and no verdict
109
+ * signed after the block's last change. PURE.
110
+ */
111
+ export function blocksAwaitingReview(plan, log, verdicts) {
112
+ return parsePlan(plan)
113
+ .groups.flatMap((group) => group.items)
114
+ .filter((item) => item.state === WAITING)
115
+ .filter((item) => blockFacts(log, item.n).range)
116
+ .filter((item) => {
117
+ const last = log.filter((line) => line.n === item.n).at(-1)?.at ?? '';
118
+ return !verdicts.some(
119
+ (verdict) =>
120
+ blockNumbers(verdict.blocks).includes(item.n) && verdict.at > last,
121
+ );
122
+ });
123
+ }
124
+
125
+ /** Why the next wrestler waits for the human's review, or null. PURE. */
126
+ export function reviewDue(waiting, every) {
127
+ if (!every || waiting.length < every) return null;
128
+ return `the human review is due: ${waiting.map((item) => `B${item.n} «${item.title}»`).join(', ')} wait on the judge (humanReviewEvery ${every}). The bell rang in PR Review Lab (bout mode): the human reviews that cut in one diff and signs a verdict with a Tape; brief.mjs reads it and goes on`;
129
+ }
130
+
131
+ /**
132
+ * What a verdict does to the bout: the plan state of each block, the decisions line (the judge's
133
+ * verdict, which Engram learns) and, for a fail, the rebound with the feedback and the Tape. PURE.
134
+ */
135
+ export function verdictEffects(verdict) {
136
+ const blocks = blockNumbers(verdict.blocks);
137
+ const names = blocks.map((n) => `B${n}`).join(' + ');
138
+ const pass = verdict.verdict === 'pass';
139
+ if (!TAPE.test(String(verdict.tape ?? '')) || !verdict.feedback?.trim())
140
+ throw new Error(
141
+ `the verdict of ${names} at ${verdict.at} has no Tape or no feedback: PR Review Lab must not write it without both`,
142
+ );
143
+ const who = verdict.reviewer ? ` (${verdict.reviewer})` : '';
144
+ return {
145
+ blocks,
146
+ state: pass ? 'hecho' : 'en curso',
147
+ decision: `revisión humana de ${names}${who}: ${pass ? 'pasa' : 'no pasa'} · ${verdict.feedback.trim()} · Tape ${verdict.tape} · ${verdict.range ?? ''}`,
148
+ rebound: pass
149
+ ? null
150
+ : `Revisión humana de ${names}${who} no pasa (Tape ${verdict.tape}, rango ${verdict.range ?? 'del corte'}): ${verdict.feedback.trim()}`,
151
+ };
152
+ }
153
+
154
+ const read = (path) => (existsSync(path) ? readFileSync(path, 'utf8') : '');
155
+
156
+ /** The author of a block: the git user of the worktree that writes it. */
157
+ export function authorEmail(worktree) {
158
+ try {
159
+ return (
160
+ execFileSync('git', ['-C', worktree, 'config', 'user.email'], {
161
+ encoding: 'utf8',
162
+ stdio: ['ignore', 'pipe', 'ignore'],
163
+ }).trim() || null
164
+ );
165
+ } catch {
166
+ return null;
167
+ }
168
+ }
169
+
170
+ /** A bout's plan, its log and PR Review Lab's verdicts, as read now. */
171
+ export function reviewInputs(dir) {
172
+ return {
173
+ plan: read(join(dir, PLAN)),
174
+ log: parsePlanLog(read(join(dir, PLAN_LOG))),
175
+ verdicts: parseVerdicts(read(join(dir, VERDICTS))),
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Applies the verdicts the bout has not handled yet (state.handledVerdicts holds their `at`):
181
+ * `setState(n, state, ref)` moves each block, and the decision and the rebound are appended.
182
+ * Returns the state to write and what was applied.
183
+ */
184
+ export function applyVerdicts(dir, state, setState) {
185
+ const handled = new Set(state.handledVerdicts ?? []);
186
+ const fresh = reviewInputs(dir).verdicts.filter(
187
+ (verdict) => !handled.has(verdict.at),
188
+ );
189
+ const nextId = () =>
190
+ Math.max(
191
+ 0,
192
+ ...[...read(join(dir, 'decisions.md')).matchAll(/ · D(\d+) · /g)].map(
193
+ (match) => Number(match[1]),
194
+ ),
195
+ ) + 1;
196
+ const applied = fresh.map((verdict) => {
197
+ const effects = verdictEffects(verdict);
198
+ for (const n of effects.blocks)
199
+ setState(n, effects.state, `review ${verdict.tape}`);
200
+ appendFileSync(
201
+ join(dir, 'decisions.md'),
202
+ `- ${new Date().toISOString()} · D${nextId()} · judge · ${effects.decision}\n`,
203
+ );
204
+ if (effects.rebound)
205
+ appendFileSync(
206
+ join(dir, 'rebounds.md'),
207
+ `- [ ] ${new Date().toISOString()} · ${effects.rebound}\n`,
208
+ );
209
+ handled.add(verdict.at);
210
+ return `${verdict.verdict} ${effects.blocks.map((n) => `B${n}`).join('+')} (${verdict.at})`;
211
+ });
212
+ return {
213
+ state: applied.length ? { ...state, handledVerdicts: [...handled] } : state,
214
+ applied,
215
+ };
216
+ }
217
+
218
+ /**
219
+ * The review turn of a bout, before a wrestler is briefed or the referee resumes: applies the new
220
+ * verdicts, keeps `pauseEvery` in state.json for PR Review Lab, and says whether the human's review
221
+ * is due. `setState(n, state, ref)` moves a block of the plan.
222
+ */
223
+ export function reviewTurn(dir, every, setState) {
224
+ const before = readState(dir);
225
+ const { state, applied } = applyVerdicts(dir, before, setState);
226
+ const next = { ...state, pauseEvery: every ?? null };
227
+ if (applied.length || before.pauseEvery !== next.pauseEvery)
228
+ writeState(dir, next);
229
+ const { plan, log, verdicts } = reviewInputs(dir);
230
+ const waiting = blocksAwaitingReview(plan, log, verdicts);
231
+ return { applied, waiting, due: reviewDue(waiting, every) };
232
+ }
@@ -1,6 +1,7 @@
1
1
  // Reads and writes a bout's technical plan (tech-plan.mjs): `judges-card.mjs plan` changes a
2
2
  // point's state or adds one, `page-verdicts` adds each judge's «se mejora», and `play-by-play.mjs
3
3
  // embed --plan` puts the plan into the chronicle. Zero-dependency.
4
+ import { execFileSync } from 'node:child_process';
4
5
  import {
5
6
  appendFileSync,
6
7
  existsSync,
@@ -11,6 +12,7 @@ import { join } from 'node:path';
11
12
 
12
13
  import { readState } from './config.mjs';
13
14
  import { scriptJson } from './contract-endpoints.mjs';
15
+ import { authorEmail, blockFacts, factsForState } from './review-blocks.mjs';
14
16
  import {
15
17
  parsePlan,
16
18
  parsePlanLog,
@@ -32,18 +34,48 @@ export function setPlanState(
32
34
  state,
33
35
  ref = null,
34
36
  at = new Date().toISOString(),
37
+ { range = null, estimate = null } = {},
35
38
  ) {
36
39
  const file = join(dir, PLAN);
37
40
  const markdown = read(file);
38
41
  if (markdown === null) throw new Error(`no ${PLAN} in ${dir}`);
39
42
  const item = planItem(parsePlan(markdown), target);
40
43
  const next = planState(state);
44
+ // Each line carries every fact of the block so far (PR Review Lab reads the last one): the
45
+ // priority and estimate, and the start, end, author and range a state change stamps.
46
+ const previous = blockFacts(parsePlanLog(read(join(dir, PLAN_LOG))), item.n);
47
+ const { worktree } = readState(dir);
48
+ const facts = {
49
+ ...previous,
50
+ ...(estimate ?? {}),
51
+ // Only a change of state stamps a start or an end: an estimate keeps the block's range.
52
+ ...(next === item.state && !range
53
+ ? {}
54
+ : factsForState(previous, next, {
55
+ at,
56
+ head: worktreeHead(worktree),
57
+ email: authorEmail(worktree),
58
+ range,
59
+ })),
60
+ };
41
61
  writeFileSync(file, withPlanState(markdown, item, next));
42
62
  appendFileSync(
43
63
  join(dir, PLAN_LOG),
44
- `${JSON.stringify({ at, n: item.n, title: item.title, from: item.state, to: next, ref })}\n`,
64
+ `${JSON.stringify({ at, n: item.n, title: item.title, from: item.state, to: next, ref, ...Object.fromEntries(Object.entries(facts).filter(([, value]) => value != null)) })}\n`,
45
65
  );
46
- return { ...item, from: item.state, to: next };
66
+ return { ...item, from: item.state, to: next, facts };
67
+ }
68
+
69
+ /** The worktree's head, or null when it is gone. */
70
+ function worktreeHead(worktree) {
71
+ try {
72
+ return execFileSync('git', ['-C', worktree, 'rev-parse', 'HEAD'], {
73
+ encoding: 'utf8',
74
+ stdio: ['ignore', 'pipe', 'ignore'],
75
+ }).trim();
76
+ } catch {
77
+ return null;
78
+ }
47
79
  }
48
80
 
49
81
  /** Adds a point to the plan (creating the file when it does not exist); returns whether it was new. */
@@ -210,6 +210,14 @@ way out, and it refuses without a KO. Then the bout runs as always, with three i
210
210
  concepts (4 by default) you relay first: rewrite the card's «How to resume» (where the bout is,
211
211
  what is next, what waits on the human) and run `judges-card.mjs relay <pr>`; until then
212
212
  `brief.mjs` briefs no wrestler. That is what a compaction or a new session resumes from.
213
+ **Human review by blocks**: each point of the technical plan is a block. Give each its estimate
214
+ and priority when the plan is made (`judges-card.mjs plan <pr> estimate <n> --minutes <m>
215
+ --priority <1-4>`), move it to «en curso» when its wrestler starts and to «espera al juez» when
216
+ its review passes (that stamps the author, the times and the range PR Review Lab reviews). Every
217
+ `humanReviewEvery` blocks waiting, brief.mjs stops: the human reviews that cut in PR Review Lab
218
+ (bout mode) and signs a verdict with a Tape. The next brief applies it: a pass closes the
219
+ blocks, a fail comes back as a rebound the next wrestler fixes first. Do not work around the
220
+ pause.
213
221
  If the wrestler claimed something ("does not change counter X"), hand the claim to the
214
222
  cornerman to verify against the base. The cornerman always checks the format of the range
215
223
  (`format-gate.mjs check`); an unclean file is a finding like any other, fixed by the wrestler