@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 +6 -1
- package/package.json +1 -1
- package/ring/agents/cornerman.md +5 -0
- package/ring/scripts/brief-build.mjs +3 -1
- package/ring/scripts/brief-guard.mjs +1 -1
- package/ring/scripts/brief.mjs +108 -4
- package/ring/scripts/compact-hook.mjs +18 -1
- package/ring/scripts/config.mjs +6 -0
- package/ring/scripts/findings.mjs +16 -0
- package/ring/scripts/judges-card.mjs +34 -6
- package/ring/scripts/lanes.mjs +139 -0
- package/ring/scripts/live.mjs +9 -1
- package/ring/scripts/lookout.mjs +7 -1
- package/ring/scripts/mask-guard.mjs +28 -3
- package/ring/scripts/one-at-a-time.mjs +14 -3
- package/ring/scripts/relay.mjs +13 -1
- package/ring/scripts/review-blocks.mjs +43 -1
- package/ring/scripts/tech-plan-log.mjs +32 -1
- package/ring/skills/referee/SKILL.md +20 -2
- package/ring/skills/referee/references/precedents.md +6 -0
- package/ring/skills/referee/templates/wrestler-brief.md +3 -1
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.
|
|
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.
|
|
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": {
|
package/ring/agents/cornerman.md
CHANGED
|
@@ -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,
|
package/ring/scripts/brief.mjs
CHANGED
|
@@ -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 {
|
|
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)
|
|
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({
|
|
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
|
|
package/ring/scripts/config.mjs
CHANGED
|
@@ -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 {
|
|
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 = [
|
|
620
|
-
(
|
|
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:
|
|
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
|
+
}
|
package/ring/scripts/live.mjs
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/ring/scripts/lookout.mjs
CHANGED
|
@@ -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,
|
|
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
|
|
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(
|
|
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
|
-
|
|
215
|
-
|
|
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, {
|
package/ring/scripts/relay.mjs
CHANGED
|
@@ -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
|
-
|
|
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}}`
|