@thebattlearena/corner 0.10.1-beta.82 → 0.10.1-beta.84
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +110 -4
- package/ring/scripts/compact-hook.mjs +18 -1
- package/ring/scripts/config.mjs +7 -0
- package/ring/scripts/findings.mjs +17 -1
- package/ring/scripts/judges-card.mjs +168 -14
- package/ring/scripts/lanes.mjs +146 -0
- package/ring/scripts/live.mjs +9 -1
- package/ring/scripts/lookout.mjs +24 -2
- 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 +51 -4
- package/ring/scripts/tech-plan-log.mjs +40 -1
- package/ring/skills/referee/SKILL.md +25 -3
- package/ring/skills/referee/references/precedents.md +6 -0
- package/ring/skills/referee/templates/wrestler-brief.md +3 -1
- package/ring/skills/title-defense/SKILL.md +8 -5
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` (el default) dos bloques corren a la vez **en el mismo
|
|
448
|
+
worktree** mientras nada espere la revisión del humano (con algo esperando, uno a la vez), cada luchador sobre los archivos que su bloque declaró (`--files "<globs>"` al estimar o
|
|
449
|
+
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.84",
|
|
4
4
|
"description": "Corner: tu esquina de Battle Arena en Claude Code (el plugin `battlearena`). Hooks de tracking, objetivo del turno, plan de batalla /battlearena:conquer, Ringside (el panel de tu día), el ring y la radio. Zero-dependency.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
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,85 @@ 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, block) {
|
|
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
|
+
// The block this brief is for may already be «en curso»: it is not another block ahead.
|
|
245
|
+
running: runningBlocks(plan).filter((item) => item.n !== Number(block)),
|
|
246
|
+
});
|
|
247
|
+
if (refusal) throw new Error(`${turn.due}. ${refusal}`);
|
|
248
|
+
return [
|
|
249
|
+
'## Working ahead of the human review (the cut under review must not move)',
|
|
250
|
+
`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.`,
|
|
251
|
+
].join('\n\n');
|
|
252
|
+
}
|
|
253
|
+
|
|
169
254
|
/** The open rebounds the human's review sent back, as a section of the brief (none: nothing). */
|
|
170
255
|
function humanReview(dir) {
|
|
171
256
|
const path = join(dir, 'rebounds.md');
|
|
@@ -180,6 +265,20 @@ function humanReview(dir) {
|
|
|
180
265
|
: [];
|
|
181
266
|
}
|
|
182
267
|
|
|
268
|
+
/**
|
|
269
|
+
* The files the bout changed so far (merge base with its base..head): what Engram searches on when
|
|
270
|
+
* the brief's own range is empty, as a wrestler starting a new concept has no commit yet.
|
|
271
|
+
*/
|
|
272
|
+
function boutFiles({ worktree, base }) {
|
|
273
|
+
try {
|
|
274
|
+
return git(worktree, ['diff', '--name-only', `origin/${base}...HEAD`])
|
|
275
|
+
.split('\n')
|
|
276
|
+
.filter(Boolean);
|
|
277
|
+
} catch {
|
|
278
|
+
return [];
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
183
282
|
const real = (path) => (existsSync(path) ? realpathSync(path) : path);
|
|
184
283
|
|
|
185
284
|
async function generate(kind, [pr, ...argv]) {
|
|
@@ -194,16 +293,18 @@ async function generate(kind, [pr, ...argv]) {
|
|
|
194
293
|
throw new Error(
|
|
195
294
|
`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
295
|
);
|
|
296
|
+
let ahead = null;
|
|
197
297
|
if (kind === 'wrestler') {
|
|
198
298
|
// The human's verdicts from PR Review Lab first: a fail comes back as a rebound for this brief.
|
|
199
299
|
const turn = reviewTurn(
|
|
200
300
|
bout.dir,
|
|
201
301
|
bout.config.humanReviewEvery,
|
|
202
302
|
(n, to, ref) => setPlanState(bout.dir, n, to, ref),
|
|
303
|
+
bout.config.parallelBlocks ?? 1,
|
|
203
304
|
);
|
|
204
305
|
if (turn.applied.length)
|
|
205
306
|
console.error(`human review applied: ${turn.applied.join(' · ')}`);
|
|
206
|
-
if (turn.due)
|
|
307
|
+
if (turn.due) ahead = workAhead(bout, values.lookahead, turn, values.block);
|
|
207
308
|
}
|
|
208
309
|
const own = values.vars ? JSON.parse(readFileSync(values.vars, 'utf8')) : {};
|
|
209
310
|
const roots = [
|
|
@@ -237,17 +338,19 @@ async function generate(kind, [pr, ...argv]) {
|
|
|
237
338
|
? await recallSection(bout.config, {
|
|
238
339
|
slug: repoSlug(projectDir()),
|
|
239
340
|
pr,
|
|
240
|
-
files,
|
|
341
|
+
files: files.length ? files : boutFiles(bout.state),
|
|
241
342
|
})
|
|
242
343
|
: null;
|
|
243
344
|
// The task goes first and the rules last, as the reference: a brief that opens with the rules
|
|
244
345
|
// reads as a checklist, and a 2026-11 lab on #1212 found fewer real problems that way.
|
|
245
346
|
const body = [
|
|
246
347
|
text.trim(),
|
|
247
|
-
'## Decisions already taken (excerpt: every foul, the ones on these files, the last 20)',
|
|
348
|
+
'## Decisions already taken (excerpt: every foul, every ruling of the judge, the ones on these files, the last 20)',
|
|
248
349
|
decisions,
|
|
249
350
|
`The whole ledger: \`${decisionsPath}\`.`,
|
|
250
351
|
...humanReview(bout.dir),
|
|
352
|
+
...(ahead ? [ahead] : []),
|
|
353
|
+
...(kind === 'wrestler' ? laneSection(bout, values.block) : []),
|
|
251
354
|
...(learned ? [learned] : []),
|
|
252
355
|
...(shape.rules ? ['---', rules.block] : []),
|
|
253
356
|
].join('\n\n');
|
|
@@ -262,6 +365,9 @@ async function generate(kind, [pr, ...argv]) {
|
|
|
262
365
|
mode: values.mode,
|
|
263
366
|
pr,
|
|
264
367
|
n,
|
|
368
|
+
...(kind === 'wrestler' && values.block
|
|
369
|
+
? { block: Number(values.block) }
|
|
370
|
+
: {}),
|
|
265
371
|
rules: shape.rules ? rules.hash : 'none',
|
|
266
372
|
};
|
|
267
373
|
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,13 @@ 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). A second block starts only while nothing waits on the human's review and
|
|
165
|
+
// its files do not touch the running one's. 1: one at a time.
|
|
166
|
+
parallelBlocks: 2,
|
|
167
|
+
// Files the host regenerates on its own (a pre-commit that writes docs): no block holds them, and
|
|
168
|
+
// the referee regenerates them when a wave of parallel blocks closes. Globs.
|
|
169
|
+
generatedPaths: [],
|
|
163
170
|
// Engram (github.com/Gentleman-Programming/engram), the memory of what earlier bouts learned:
|
|
164
171
|
// fouls, the judge's verdicts, accepted precedents, X0 findings and each bout's status. Never the
|
|
165
172
|
// rules. The scripts sync and read it when something answers at this URL (null: off). The
|
|
@@ -553,7 +553,7 @@ function settleAlone(pr, argv, kind) {
|
|
|
553
553
|
for (const finding of settled)
|
|
554
554
|
appendFileSync(
|
|
555
555
|
join(dir, 'decisions.md'),
|
|
556
|
-
`- ${at} · ${
|
|
556
|
+
`- ${at} · ${finding.id} · ${judge ? 'judge' : 'referee'} · cornerman finding ${finding.id} ${verb}${values.id ? ` per ${values.id}` : ''}: ${reason}${judge ? ` (the human: ${judge})` : ''} · «${finding.text}»\n`,
|
|
557
557
|
);
|
|
558
558
|
const cardPath = join(dir, 'judges-card.md');
|
|
559
559
|
if (existsSync(cardPath)) {
|
|
@@ -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';
|
|
@@ -314,13 +321,24 @@ async function summary([pr]) {
|
|
|
314
321
|
contract: false,
|
|
315
322
|
venue: state.venue ?? DEFAULT_VENUE,
|
|
316
323
|
});
|
|
317
|
-
const review = reviewTurn(
|
|
318
|
-
|
|
324
|
+
const review = reviewTurn(
|
|
325
|
+
dir,
|
|
326
|
+
config.humanReviewEvery,
|
|
327
|
+
(n, to, ref) => setPlanState(dir, n, to, ref),
|
|
328
|
+
config.parallelBlocks ?? 1,
|
|
319
329
|
);
|
|
330
|
+
const returned = openHumanReturns(dir);
|
|
320
331
|
console.log(
|
|
321
332
|
[
|
|
322
333
|
'',
|
|
323
334
|
'## Human review by blocks (PR Review Lab, bout mode)',
|
|
335
|
+
...(returned.length
|
|
336
|
+
? [
|
|
337
|
+
`RETURNED BY THE HUMAN, rebound still open (${returned.length}):`,
|
|
338
|
+
...returned,
|
|
339
|
+
TAPE_HINT,
|
|
340
|
+
]
|
|
341
|
+
: []),
|
|
324
342
|
review.applied.length
|
|
325
343
|
? `verdicts applied now: ${review.applied.join(' · ')}`
|
|
326
344
|
: 'no new verdicts',
|
|
@@ -330,6 +348,23 @@ async function summary([pr]) {
|
|
|
330
348
|
review.due ?? '',
|
|
331
349
|
].join('\n'),
|
|
332
350
|
);
|
|
351
|
+
const points = existsSync(join(dir, PLAN))
|
|
352
|
+
? parsePlan(readFileSync(join(dir, PLAN), 'utf8')).groups.flatMap(
|
|
353
|
+
(group) => group.items,
|
|
354
|
+
)
|
|
355
|
+
: [];
|
|
356
|
+
if (
|
|
357
|
+
points.length &&
|
|
358
|
+
points.every((item) => item.state === 'hecho') &&
|
|
359
|
+
titleDefenseRefusal(readState(dir), null)
|
|
360
|
+
)
|
|
361
|
+
console.log(
|
|
362
|
+
[
|
|
363
|
+
'',
|
|
364
|
+
'## Next in the bout’s life (the plan is complete)',
|
|
365
|
+
'The judge tests it in the sandbox → the human marks the PR ready → /ring:title-defense → judges-card.mjs close. Do not close before the defense unless the judge waives it (--without-title-defense "<what they said>").',
|
|
366
|
+
].join('\n'),
|
|
367
|
+
);
|
|
333
368
|
const learned = await refereeRecall(config, {
|
|
334
369
|
slug: repoSlug(projectDir()),
|
|
335
370
|
pr,
|
|
@@ -601,6 +636,7 @@ function plan([pr, target, ...rest]) {
|
|
|
601
636
|
estimate: estimateFacts({
|
|
602
637
|
priority: value('--priority'),
|
|
603
638
|
minutes: value('--minutes'),
|
|
639
|
+
files: parseGlobs(value('--files')),
|
|
604
640
|
}),
|
|
605
641
|
});
|
|
606
642
|
return void console.log(
|
|
@@ -616,17 +652,24 @@ function plan([pr, target, ...rest]) {
|
|
|
616
652
|
contract: false,
|
|
617
653
|
venue: readState(dir).venue ?? DEFAULT_VENUE,
|
|
618
654
|
});
|
|
619
|
-
const inline = [
|
|
620
|
-
(
|
|
621
|
-
|
|
655
|
+
const inline = [
|
|
656
|
+
value('--minutes'),
|
|
657
|
+
value('--priority'),
|
|
658
|
+
value('--files'),
|
|
659
|
+
].some((given) => given != null);
|
|
622
660
|
const changed = setPlanState(dir, target, state, value('--ref'), undefined, {
|
|
623
661
|
range: value('--range'),
|
|
624
662
|
estimate: inline
|
|
625
663
|
? estimateFacts({
|
|
626
664
|
priority: value('--priority'),
|
|
627
665
|
minutes: value('--minutes'),
|
|
666
|
+
files: parseGlobs(value('--files')),
|
|
628
667
|
})
|
|
629
668
|
: null,
|
|
669
|
+
lanes: {
|
|
670
|
+
parallel: config.parallelBlocks,
|
|
671
|
+
generated: config.generatedPaths,
|
|
672
|
+
},
|
|
630
673
|
// Under the human review by blocks a block starts with its estimate and priority.
|
|
631
674
|
requireEstimate: Boolean(config.humanReviewEvery),
|
|
632
675
|
});
|
|
@@ -635,7 +678,8 @@ function plan([pr, target, ...rest]) {
|
|
|
635
678
|
);
|
|
636
679
|
}
|
|
637
680
|
|
|
638
|
-
function log([pr, kind, ...
|
|
681
|
+
function log([pr, kind, ...rest]) {
|
|
682
|
+
let words = rest;
|
|
639
683
|
if (
|
|
640
684
|
['escalation', 'escalation-context', 'answer'].includes(kind) &&
|
|
641
685
|
words.length
|
|
@@ -649,6 +693,26 @@ function log([pr, kind, ...words]) {
|
|
|
649
693
|
const stamp = new Date().toISOString();
|
|
650
694
|
const box = kind === 'rebound' ? '[ ] ' : '';
|
|
651
695
|
const dir = currentDir(pr);
|
|
696
|
+
if (kind === 'decision') {
|
|
697
|
+
const path = join(dir, 'decisions.md');
|
|
698
|
+
const taken = new Set(
|
|
699
|
+
[
|
|
700
|
+
...(existsSync(path) ? readFileSync(path, 'utf8') : '').matchAll(
|
|
701
|
+
/ · (D\d+) · /g,
|
|
702
|
+
),
|
|
703
|
+
].map((m) => m[1]),
|
|
704
|
+
);
|
|
705
|
+
const fixed = withFreeDecisionId(
|
|
706
|
+
words.join(' '),
|
|
707
|
+
taken,
|
|
708
|
+
nextDecisionId(dir),
|
|
709
|
+
);
|
|
710
|
+
if (fixed.renumbered)
|
|
711
|
+
console.error(
|
|
712
|
+
`${fixed.renumbered[0]} is taken in decisions.md: logged as ${fixed.renumbered[1]}`,
|
|
713
|
+
);
|
|
714
|
+
words = [fixed.text];
|
|
715
|
+
}
|
|
652
716
|
appendFileSync(
|
|
653
717
|
join(dir, files[kind]),
|
|
654
718
|
`- ${box}${stamp} · ${words.join(' ')}\n`,
|
|
@@ -694,8 +758,20 @@ function list() {
|
|
|
694
758
|
console.log(rows.join('\n') || 'no bouts for this repo');
|
|
695
759
|
}
|
|
696
760
|
|
|
697
|
-
|
|
761
|
+
/**
|
|
762
|
+
* Why a bout may not close for its title defense yet, or null: the bout ends after the human's tests
|
|
763
|
+
* in the sandbox, the PR marked ready and title-defense against Copilot, unless the judge waives it
|
|
764
|
+
* (`--without-title-defense "<what the human said>"`, which goes to decisions.md). PURE.
|
|
765
|
+
*/
|
|
766
|
+
export function titleDefenseRefusal(state, waiver) {
|
|
767
|
+
if (state.titleDefense || state.titleDefenseStarted || waiver?.trim())
|
|
768
|
+
return null;
|
|
769
|
+
return 'it never defended its title. The bout ends with: plan complete → the judge tests it in the sandbox → the human marks the PR ready → /ring:title-defense → close. If the judge does not want the defense, close with --without-title-defense "<what the human said>"';
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function close([pr, ...argv]) {
|
|
698
773
|
const dir = currentDir(pr);
|
|
774
|
+
const waiver = options(argv)['without-title-defense'];
|
|
699
775
|
const blocked = auditBlocks(readLedger(dir), { closing: true });
|
|
700
776
|
if (blocked) throw new Error(`bout #${pr} does not close yet: ${blocked}`);
|
|
701
777
|
const proposals = undecidedProposals(
|
|
@@ -710,10 +786,56 @@ function close([pr]) {
|
|
|
710
786
|
if (format.refusal)
|
|
711
787
|
throw new Error(`bout #${pr} does not close yet: ${format.refusal}`);
|
|
712
788
|
}
|
|
789
|
+
const defense = titleDefenseRefusal(state, waiver);
|
|
790
|
+
if (defense) throw new Error(`bout #${pr} does not close yet: ${defense}`);
|
|
791
|
+
if (waiver?.trim())
|
|
792
|
+
appendFileSync(
|
|
793
|
+
join(dir, 'decisions.md'),
|
|
794
|
+
`- ${new Date().toISOString()} · ${nextDecisionId(dir)} · judge · closed without title-defense: ${waiver.trim()}\n`,
|
|
795
|
+
);
|
|
713
796
|
writeState(dir, { ...state, active: false });
|
|
714
797
|
console.log(`bout #${pr} closed; its files stay in ${dir} as the record.`);
|
|
715
798
|
}
|
|
716
799
|
|
|
800
|
+
/** The next free D<n> of the bout's decisions.md. */
|
|
801
|
+
function nextDecisionId(dir) {
|
|
802
|
+
const path = join(dir, 'decisions.md');
|
|
803
|
+
const ids = existsSync(path)
|
|
804
|
+
? [...readFileSync(path, 'utf8').matchAll(/ · D(\d+) · /g)].map((m) =>
|
|
805
|
+
Number(m[1]),
|
|
806
|
+
)
|
|
807
|
+
: [];
|
|
808
|
+
return `D${Math.max(0, ...ids) + 1}`;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* The decision line as the referee wrote it, with its D<n> made unique: a D<n> already taken in
|
|
813
|
+
* the ledger (a judge's verdict wrote it meanwhile) becomes the next free one. PURE.
|
|
814
|
+
*/
|
|
815
|
+
export function withFreeDecisionId(text, taken, next) {
|
|
816
|
+
const id = /^(D\d+)(\s*·)/.exec(text)?.[1];
|
|
817
|
+
if (!id || !taken.has(id)) return { text, renumbered: null };
|
|
818
|
+
return { text: text.replace(id, next), renumbered: [id, next] };
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/** A closed bout back to work, with the judge's reason in decisions.md. */
|
|
822
|
+
function reopen([pr, ...argv]) {
|
|
823
|
+
const { why } = options(argv);
|
|
824
|
+
if (!why?.trim())
|
|
825
|
+
throw new Error('usage: reopen <pr> --why "<what the human said>"');
|
|
826
|
+
const dir = currentDir(pr);
|
|
827
|
+
const state = readState(dir);
|
|
828
|
+
if (state.active) throw new Error(`bout #${pr} is already active`);
|
|
829
|
+
appendFileSync(
|
|
830
|
+
join(dir, 'decisions.md'),
|
|
831
|
+
`- ${new Date().toISOString()} · ${nextDecisionId(dir)} · judge · bout reopened: ${why.trim()}\n`,
|
|
832
|
+
);
|
|
833
|
+
writeState(dir, { ...state, active: true });
|
|
834
|
+
console.log(
|
|
835
|
+
`bout #${pr} reopened; resume it with /ring:referee resume ${pr}.`,
|
|
836
|
+
);
|
|
837
|
+
}
|
|
838
|
+
|
|
717
839
|
/**
|
|
718
840
|
* The title-defense flag in a bout state, set or cleared. `setAt` starts its count: mask-guard
|
|
719
841
|
* allows at most `untilRound` Copilot review requests after it. PURE.
|
|
@@ -727,7 +849,12 @@ export function withTitleDefense(
|
|
|
727
849
|
if (untilRound === null) return rest;
|
|
728
850
|
if (!Number.isInteger(untilRound) || untilRound < 1)
|
|
729
851
|
throw new Error('--until-round must be a positive integer');
|
|
730
|
-
|
|
852
|
+
// titleDefenseStarted outlives the flag: close asks whether the PR ever defended its title.
|
|
853
|
+
return {
|
|
854
|
+
...rest,
|
|
855
|
+
titleDefense: { pr: state.pr, untilRound, setAt },
|
|
856
|
+
titleDefenseStarted: rest.titleDefenseStarted ?? setAt,
|
|
857
|
+
};
|
|
731
858
|
}
|
|
732
859
|
|
|
733
860
|
/**
|
|
@@ -749,6 +876,7 @@ export function readyForTitleDefense(pr, view) {
|
|
|
749
876
|
if (answer?.isDraft === true)
|
|
750
877
|
return {
|
|
751
878
|
ok: false,
|
|
879
|
+
draft: true,
|
|
752
880
|
reason: `PR #${pr} is still a draft; title-defense starts when you mark it ready for review`,
|
|
753
881
|
};
|
|
754
882
|
return { ok: false, reason: `gh did not say whether PR #${pr} is a draft` };
|
|
@@ -865,7 +993,9 @@ function titleDefense([pr, ...argv]) {
|
|
|
865
993
|
const clear = argv.includes('--clear');
|
|
866
994
|
const index = argv.indexOf('--until-round');
|
|
867
995
|
if (!pr || (!clear && index === -1))
|
|
868
|
-
throw new Error(
|
|
996
|
+
throw new Error(
|
|
997
|
+
'usage: title-defense <pr> --until-round <k> [--keep-draft] | --clear',
|
|
998
|
+
);
|
|
869
999
|
const dir = currentDir(pr);
|
|
870
1000
|
const state = readState(dir);
|
|
871
1001
|
if (state.active !== true)
|
|
@@ -881,7 +1011,25 @@ function titleDefense([pr, ...argv]) {
|
|
|
881
1011
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
882
1012
|
}),
|
|
883
1013
|
);
|
|
884
|
-
|
|
1014
|
+
// Invoking title-defense is the judge's yes to take the PR out of draft (--keep-draft: not yet).
|
|
1015
|
+
// The script runs gh itself, so mask-guard still refuses `gh pr ready` anywhere else.
|
|
1016
|
+
if (!gate.ok && gate.draft && !argv.includes('--keep-draft')) {
|
|
1017
|
+
execFileSync('gh', ['pr', 'ready', String(pr)], {
|
|
1018
|
+
cwd: state.worktree,
|
|
1019
|
+
encoding: 'utf8',
|
|
1020
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1021
|
+
});
|
|
1022
|
+
appendFileSync(
|
|
1023
|
+
join(dir, 'decisions.md'),
|
|
1024
|
+
`- ${new Date().toISOString()} · ${nextDecisionId(dir)} · judge · the judge invoked title-defense: PR #${pr} is out of draft\n`,
|
|
1025
|
+
);
|
|
1026
|
+
console.log(
|
|
1027
|
+
`PR #${pr} marked ready for review (the judge invoked title-defense)`,
|
|
1028
|
+
);
|
|
1029
|
+
} else if (!gate.ok)
|
|
1030
|
+
throw new Error(
|
|
1031
|
+
gate.draft ? `${gate.reason} (--keep-draft was passed)` : gate.reason,
|
|
1032
|
+
);
|
|
885
1033
|
const next = withTitleDefense(state, clear ? null : Number(argv[index + 1]));
|
|
886
1034
|
writeState(dir, next);
|
|
887
1035
|
console.log(
|
|
@@ -958,14 +1106,19 @@ function playByPlayUrl([pr, url]) {
|
|
|
958
1106
|
* concept goes to a fresh wrestler; every `relayEvery` concepts the referee relays first.
|
|
959
1107
|
*/
|
|
960
1108
|
function concept([pr, name, ...argv]) {
|
|
961
|
-
const { range } = options(argv);
|
|
1109
|
+
const { range, block } = options(argv);
|
|
962
1110
|
const dir = currentDir(pr);
|
|
963
1111
|
const state = readState(dir);
|
|
964
1112
|
const next = withConceptClosed(state, {
|
|
965
1113
|
name,
|
|
966
1114
|
range,
|
|
967
1115
|
at: new Date().toISOString(),
|
|
968
|
-
wrestlers:
|
|
1116
|
+
// With --block only that block's wrestlers retire: another block may still be running.
|
|
1117
|
+
wrestlers: block
|
|
1118
|
+
? [...wrestlerBlocks(dir)]
|
|
1119
|
+
.filter(([, n]) => n === Number(block))
|
|
1120
|
+
.map(([agent]) => agent)
|
|
1121
|
+
: wrestlerIds(wrestlerLines(dir)),
|
|
969
1122
|
});
|
|
970
1123
|
writeState(dir, next);
|
|
971
1124
|
const { config } = loadConfig(projectDir(), {
|
|
@@ -1043,6 +1196,7 @@ function proposal([pr, n, verdict, ...argv]) {
|
|
|
1043
1196
|
|
|
1044
1197
|
const COMMANDS = {
|
|
1045
1198
|
open,
|
|
1199
|
+
reopen,
|
|
1046
1200
|
concept,
|
|
1047
1201
|
relay,
|
|
1048
1202
|
proposal,
|
|
@@ -1065,7 +1219,7 @@ if (isMain(import.meta.url)) {
|
|
|
1065
1219
|
const run = COMMANDS[command];
|
|
1066
1220
|
if (!run) {
|
|
1067
1221
|
console.error(
|
|
1068
|
-
'Usage: node judges-card.mjs open|summary|log|did|list|path|close|concept|relay|proposal|approve-contract|title-defense|play-by-play-url|page-answers|page-verdicts|plan …',
|
|
1222
|
+
'Usage: node judges-card.mjs open|reopen|summary|log|did|list|path|close|concept|relay|proposal|approve-contract|title-defense|play-by-play-url|page-answers|page-verdicts|plan …',
|
|
1069
1223
|
);
|
|
1070
1224
|
process.exit(1);
|
|
1071
1225
|
}
|