@thebattlearena/corner 0.10.1-beta.83 → 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 +2 -2
- package/package.json +1 -1
- package/ring/scripts/brief.mjs +5 -3
- package/ring/scripts/config.mjs +3 -2
- package/ring/scripts/findings.mjs +1 -1
- package/ring/scripts/judges-card.mjs +134 -8
- package/ring/scripts/lanes.mjs +7 -0
- package/ring/scripts/lookout.mjs +17 -1
- package/ring/scripts/review-blocks.mjs +9 -4
- package/ring/scripts/tech-plan-log.mjs +8 -0
- package/ring/skills/referee/SKILL.md +7 -3
- package/ring/skills/title-defense/SKILL.md +8 -5
package/README.md
CHANGED
|
@@ -444,8 +444,8 @@ 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. Con `parallelBlocks: 2` dos bloques corren a la vez **en el mismo
|
|
448
|
-
worktree
|
|
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
449
|
arrancar): el script no deja arrancar un bloque con archivos de otro en curso, `mask-guard` rechaza un
|
|
450
450
|
commit con archivos de otro bloque (los de `generatedPaths`, como los docs que regenera un pre-commit,
|
|
451
451
|
no son de nadie: el árbitro los regenera al cerrar la oleada), y `concept … --block <n>` retira solo al
|
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/scripts/brief.mjs
CHANGED
|
@@ -214,7 +214,7 @@ function laneSection(bout, block) {
|
|
|
214
214
|
* cut does not touch (`--lookahead "<globs>"`). Without it, or when it overlaps or another block is
|
|
215
215
|
* running, the pause holds. Returns the brief's section that bounds the work ahead.
|
|
216
216
|
*/
|
|
217
|
-
function workAhead(bout, lookahead, turn) {
|
|
217
|
+
function workAhead(bout, lookahead, turn, block) {
|
|
218
218
|
if (!lookahead)
|
|
219
219
|
throw new Error(
|
|
220
220
|
`${turn.due}. Or work ahead on one block whose files the cut does not touch: --lookahead "<glob>,<glob>"`,
|
|
@@ -241,7 +241,8 @@ function workAhead(bout, lookahead, turn) {
|
|
|
241
241
|
const refusal = lookaheadRefusal({
|
|
242
242
|
globs,
|
|
243
243
|
waitingFiles,
|
|
244
|
-
|
|
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)),
|
|
245
246
|
});
|
|
246
247
|
if (refusal) throw new Error(`${turn.due}. ${refusal}`);
|
|
247
248
|
return [
|
|
@@ -299,10 +300,11 @@ async function generate(kind, [pr, ...argv]) {
|
|
|
299
300
|
bout.dir,
|
|
300
301
|
bout.config.humanReviewEvery,
|
|
301
302
|
(n, to, ref) => setPlanState(bout.dir, n, to, ref),
|
|
303
|
+
bout.config.parallelBlocks ?? 1,
|
|
302
304
|
);
|
|
303
305
|
if (turn.applied.length)
|
|
304
306
|
console.error(`human review applied: ${turn.applied.join(' · ')}`);
|
|
305
|
-
if (turn.due) ahead = workAhead(bout, values.lookahead, turn);
|
|
307
|
+
if (turn.due) ahead = workAhead(bout, values.lookahead, turn, values.block);
|
|
306
308
|
}
|
|
307
309
|
const own = values.vars ? JSON.parse(readFileSync(values.vars, 'utf8')) : {};
|
|
308
310
|
const roots = [
|
package/ring/scripts/config.mjs
CHANGED
|
@@ -161,8 +161,9 @@ export const DEFAULTS = Object.freeze({
|
|
|
161
161
|
// wrestler until the human signs a verdict on that cut. null: no pause.
|
|
162
162
|
humanReviewEvery: 2,
|
|
163
163
|
// Blocks that may run at once in the bout's worktree, each wrestler on the files its block
|
|
164
|
-
// declared (lanes.mjs).
|
|
165
|
-
|
|
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,
|
|
166
167
|
// Files the host regenerates on its own (a pre-commit that writes docs): no block holds them, and
|
|
167
168
|
// the referee regenerates them when a wave of parallel blocks closes. Globs.
|
|
168
169
|
generatedPaths: [],
|
|
@@ -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)) {
|
|
@@ -321,8 +321,11 @@ async function summary([pr]) {
|
|
|
321
321
|
contract: false,
|
|
322
322
|
venue: state.venue ?? DEFAULT_VENUE,
|
|
323
323
|
});
|
|
324
|
-
const review = reviewTurn(
|
|
325
|
-
|
|
324
|
+
const review = reviewTurn(
|
|
325
|
+
dir,
|
|
326
|
+
config.humanReviewEvery,
|
|
327
|
+
(n, to, ref) => setPlanState(dir, n, to, ref),
|
|
328
|
+
config.parallelBlocks ?? 1,
|
|
326
329
|
);
|
|
327
330
|
const returned = openHumanReturns(dir);
|
|
328
331
|
console.log(
|
|
@@ -345,6 +348,23 @@ async function summary([pr]) {
|
|
|
345
348
|
review.due ?? '',
|
|
346
349
|
].join('\n'),
|
|
347
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
|
+
);
|
|
348
368
|
const learned = await refereeRecall(config, {
|
|
349
369
|
slug: repoSlug(projectDir()),
|
|
350
370
|
pr,
|
|
@@ -658,7 +678,8 @@ function plan([pr, target, ...rest]) {
|
|
|
658
678
|
);
|
|
659
679
|
}
|
|
660
680
|
|
|
661
|
-
function log([pr, kind, ...
|
|
681
|
+
function log([pr, kind, ...rest]) {
|
|
682
|
+
let words = rest;
|
|
662
683
|
if (
|
|
663
684
|
['escalation', 'escalation-context', 'answer'].includes(kind) &&
|
|
664
685
|
words.length
|
|
@@ -672,6 +693,26 @@ function log([pr, kind, ...words]) {
|
|
|
672
693
|
const stamp = new Date().toISOString();
|
|
673
694
|
const box = kind === 'rebound' ? '[ ] ' : '';
|
|
674
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
|
+
}
|
|
675
716
|
appendFileSync(
|
|
676
717
|
join(dir, files[kind]),
|
|
677
718
|
`- ${box}${stamp} · ${words.join(' ')}\n`,
|
|
@@ -717,8 +758,20 @@ function list() {
|
|
|
717
758
|
console.log(rows.join('\n') || 'no bouts for this repo');
|
|
718
759
|
}
|
|
719
760
|
|
|
720
|
-
|
|
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]) {
|
|
721
773
|
const dir = currentDir(pr);
|
|
774
|
+
const waiver = options(argv)['without-title-defense'];
|
|
722
775
|
const blocked = auditBlocks(readLedger(dir), { closing: true });
|
|
723
776
|
if (blocked) throw new Error(`bout #${pr} does not close yet: ${blocked}`);
|
|
724
777
|
const proposals = undecidedProposals(
|
|
@@ -733,10 +786,56 @@ function close([pr]) {
|
|
|
733
786
|
if (format.refusal)
|
|
734
787
|
throw new Error(`bout #${pr} does not close yet: ${format.refusal}`);
|
|
735
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
|
+
);
|
|
736
796
|
writeState(dir, { ...state, active: false });
|
|
737
797
|
console.log(`bout #${pr} closed; its files stay in ${dir} as the record.`);
|
|
738
798
|
}
|
|
739
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
|
+
|
|
740
839
|
/**
|
|
741
840
|
* The title-defense flag in a bout state, set or cleared. `setAt` starts its count: mask-guard
|
|
742
841
|
* allows at most `untilRound` Copilot review requests after it. PURE.
|
|
@@ -750,7 +849,12 @@ export function withTitleDefense(
|
|
|
750
849
|
if (untilRound === null) return rest;
|
|
751
850
|
if (!Number.isInteger(untilRound) || untilRound < 1)
|
|
752
851
|
throw new Error('--until-round must be a positive integer');
|
|
753
|
-
|
|
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
|
+
};
|
|
754
858
|
}
|
|
755
859
|
|
|
756
860
|
/**
|
|
@@ -772,6 +876,7 @@ export function readyForTitleDefense(pr, view) {
|
|
|
772
876
|
if (answer?.isDraft === true)
|
|
773
877
|
return {
|
|
774
878
|
ok: false,
|
|
879
|
+
draft: true,
|
|
775
880
|
reason: `PR #${pr} is still a draft; title-defense starts when you mark it ready for review`,
|
|
776
881
|
};
|
|
777
882
|
return { ok: false, reason: `gh did not say whether PR #${pr} is a draft` };
|
|
@@ -888,7 +993,9 @@ function titleDefense([pr, ...argv]) {
|
|
|
888
993
|
const clear = argv.includes('--clear');
|
|
889
994
|
const index = argv.indexOf('--until-round');
|
|
890
995
|
if (!pr || (!clear && index === -1))
|
|
891
|
-
throw new Error(
|
|
996
|
+
throw new Error(
|
|
997
|
+
'usage: title-defense <pr> --until-round <k> [--keep-draft] | --clear',
|
|
998
|
+
);
|
|
892
999
|
const dir = currentDir(pr);
|
|
893
1000
|
const state = readState(dir);
|
|
894
1001
|
if (state.active !== true)
|
|
@@ -904,7 +1011,25 @@ function titleDefense([pr, ...argv]) {
|
|
|
904
1011
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
905
1012
|
}),
|
|
906
1013
|
);
|
|
907
|
-
|
|
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
|
+
);
|
|
908
1033
|
const next = withTitleDefense(state, clear ? null : Number(argv[index + 1]));
|
|
909
1034
|
writeState(dir, next);
|
|
910
1035
|
console.log(
|
|
@@ -1071,6 +1196,7 @@ function proposal([pr, n, verdict, ...argv]) {
|
|
|
1071
1196
|
|
|
1072
1197
|
const COMMANDS = {
|
|
1073
1198
|
open,
|
|
1199
|
+
reopen,
|
|
1074
1200
|
concept,
|
|
1075
1201
|
relay,
|
|
1076
1202
|
proposal,
|
|
@@ -1093,7 +1219,7 @@ if (isMain(import.meta.url)) {
|
|
|
1093
1219
|
const run = COMMANDS[command];
|
|
1094
1220
|
if (!run) {
|
|
1095
1221
|
console.error(
|
|
1096
|
-
'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 …',
|
|
1097
1223
|
);
|
|
1098
1224
|
process.exit(1);
|
|
1099
1225
|
}
|
package/ring/scripts/lanes.mjs
CHANGED
|
@@ -51,11 +51,18 @@ export function startLaneRefusal({
|
|
|
51
51
|
parallel,
|
|
52
52
|
tracked,
|
|
53
53
|
generated,
|
|
54
|
+
waiting = [],
|
|
54
55
|
}) {
|
|
55
56
|
const others = running.filter((lease) => lease.n !== n);
|
|
56
57
|
if (!parallel || parallel <= 1 || !others.length) return null;
|
|
58
|
+
// The human's queue first: with a block waiting on review, one block runs at a time.
|
|
59
|
+
if (waiting.length)
|
|
60
|
+
return `${waiting.map((item) => `B${item.n}`).join(', ')} wait${waiting.length === 1 ? 's' : ''} on the human's review and ${others.map((lease) => `B${lease.n}`).join(', ')} is running: a second block starts only while nothing waits on review. Start B${n} when one of them clears`;
|
|
57
61
|
if (others.length >= parallel)
|
|
58
62
|
return `${others.length} blocks are already running (parallelBlocks ${parallel}): ${others.map((lease) => `B${lease.n}`).join(', ')}. Start B${n} when one closes`;
|
|
63
|
+
const undeclared = others.filter((lease) => !lease.files.length);
|
|
64
|
+
if (undeclared.length)
|
|
65
|
+
return `${undeclared.map((lease) => `B${lease.n}`).join(', ')} runs without declared files, so nothing proves B${n} would not touch them: declare them first (judges-card.mjs plan <pr> estimate ${undeclared[0].n} --files "<globs>")`;
|
|
59
66
|
if (!files?.length)
|
|
60
67
|
return `B${n} starts next to ${others.map((lease) => `B${lease.n}`).join(', ')}: declare the files it will touch with --files "<glob>,<glob>" so no two wrestlers edit the same file`;
|
|
61
68
|
const isGenerated = matcher(generated);
|
package/ring/scripts/lookout.mjs
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
// formatted; see format-gate.mjs)
|
|
10
10
|
// node lookout.mjs backup <boutDir> push the worktree HEAD to the backup branch
|
|
11
11
|
//
|
|
12
|
-
// `watch` polls the wrestler's worktree and EXITS as soon as
|
|
12
|
+
// `watch` polls the wrestler's worktree and EXITS as soon as the human signs a verdict in PR Review
|
|
13
|
+
// Lab that the bout has not applied, or as soon as its HEAD differs from the last
|
|
13
14
|
// reviewed commit in state.json, printing the new commits. Launched with run_in_background, its
|
|
14
15
|
// exit wakes the referee; nothing expires and nothing has to be re-armed by hand. A pidfile makes a
|
|
15
16
|
// new watcher kill the previous one, and both ends of the comparison are FULL hashes: comparing an
|
|
@@ -28,6 +29,7 @@ import {
|
|
|
28
29
|
repoSlug,
|
|
29
30
|
writeState,
|
|
30
31
|
} from './config.mjs';
|
|
32
|
+
import { reviewInputs } from './review-blocks.mjs';
|
|
31
33
|
import { counts, readLedger, reviewDeclared } from './findings.mjs';
|
|
32
34
|
import { acceptUnverified, boutFormatGate } from './format-gate.mjs';
|
|
33
35
|
|
|
@@ -189,6 +191,14 @@ function backupQuietly(state, template) {
|
|
|
189
191
|
}
|
|
190
192
|
}
|
|
191
193
|
|
|
194
|
+
/** The verdicts PR Review Lab wrote that the bout has not applied yet. */
|
|
195
|
+
export function unhandledVerdicts(dir, state) {
|
|
196
|
+
const handled = new Set(state.handledVerdicts ?? []);
|
|
197
|
+
return reviewInputs(dir).verdicts.filter(
|
|
198
|
+
(verdict) => !handled.has(verdict.at),
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
192
202
|
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
193
203
|
|
|
194
204
|
async function watch(dir, intervalSeconds) {
|
|
@@ -205,6 +215,12 @@ async function watch(dir, intervalSeconds) {
|
|
|
205
215
|
const state = readState(dir);
|
|
206
216
|
if (state.active !== true)
|
|
207
217
|
return console.log('lookout: the bout is closed.');
|
|
218
|
+
// The human's verdict wakes the referee too: until the next brief applies it, the bout waits.
|
|
219
|
+
const fresh = unhandledVerdicts(dir, state);
|
|
220
|
+
if (fresh.length)
|
|
221
|
+
return console.log(
|
|
222
|
+
`lookout: the human signed ${fresh.map((verdict) => `${verdict.verdict} ${verdict.blocks.join('+')} (Tape ${verdict.tape})`).join(' · ')} in PR Review Lab: run judges-card.mjs summary ${state.pr} (or brief the next wrestler) to apply it, then go on.`,
|
|
223
|
+
);
|
|
208
224
|
const head = git(state.worktree, ['rev-parse', 'HEAD']);
|
|
209
225
|
if (compareHeads(state.lastReviewed, head).changed) {
|
|
210
226
|
const settings = hostSettings();
|
|
@@ -252,11 +252,16 @@ export function applyVerdicts(dir, state, setState) {
|
|
|
252
252
|
* verdicts, keeps `pauseEvery` in state.json for PR Review Lab, and says whether the human's review
|
|
253
253
|
* is due. `setState(n, state, ref)` moves a block of the plan.
|
|
254
254
|
*/
|
|
255
|
-
export function reviewTurn(dir, every, setState) {
|
|
255
|
+
export function reviewTurn(dir, every, setState, parallel = null) {
|
|
256
256
|
const before = readState(dir);
|
|
257
257
|
const { state, applied } = applyVerdicts(dir, before, setState);
|
|
258
|
-
|
|
259
|
-
|
|
258
|
+
// PR Review Lab reads both from state.json: when the bell rings and how many blocks may run.
|
|
259
|
+
const next = { ...state, pauseEvery: every ?? null, parallel };
|
|
260
|
+
if (
|
|
261
|
+
applied.length ||
|
|
262
|
+
before.pauseEvery !== next.pauseEvery ||
|
|
263
|
+
before.parallel !== next.parallel
|
|
264
|
+
)
|
|
260
265
|
writeState(dir, next);
|
|
261
266
|
const { plan, log, verdicts } = reviewInputs(dir);
|
|
262
267
|
const waiting = blocksAwaitingReview(plan, log, verdicts);
|
|
@@ -272,7 +277,7 @@ export function lookaheadRefusal({ globs, waitingFiles, running }) {
|
|
|
272
277
|
if (!globs.length)
|
|
273
278
|
return 'a block ahead of the review declares its files: --lookahead "<glob>,<glob>"';
|
|
274
279
|
if (running.length)
|
|
275
|
-
return `only one block works ahead of the review: B${running[0].n} «${running[0].title}» is already «en curso
|
|
280
|
+
return `only one block works ahead of the review: B${running[0].n} «${running[0].title}» is already «en curso» (if that is the block of this brief, pass --block ${running[0].n})`;
|
|
276
281
|
const matchers = globs.map((glob) => globToRegExp(glob));
|
|
277
282
|
const overlap = waitingFiles.filter((file) =>
|
|
278
283
|
matchers.some((matcher) => matcher.test(file)),
|
|
@@ -17,7 +17,10 @@ import {
|
|
|
17
17
|
authorEmail,
|
|
18
18
|
blockFacts,
|
|
19
19
|
factsForState,
|
|
20
|
+
blocksAwaitingReview,
|
|
21
|
+
parseVerdicts,
|
|
20
22
|
startRefusal,
|
|
23
|
+
VERDICTS,
|
|
21
24
|
} from './review-blocks.mjs';
|
|
22
25
|
import {
|
|
23
26
|
parsePlan,
|
|
@@ -81,6 +84,11 @@ export function setPlanState(
|
|
|
81
84
|
parallel: lanes.parallel,
|
|
82
85
|
tracked: trackedFiles(worktree),
|
|
83
86
|
generated: lanes.generated,
|
|
87
|
+
waiting: blocksAwaitingReview(
|
|
88
|
+
markdown,
|
|
89
|
+
parsePlanLog(read(join(dir, PLAN_LOG))),
|
|
90
|
+
parseVerdicts(read(join(dir, VERDICTS)) ?? ''),
|
|
91
|
+
),
|
|
84
92
|
})
|
|
85
93
|
: null;
|
|
86
94
|
if (laneRefusal) throw new Error(laneRefusal);
|
|
@@ -225,8 +225,10 @@ way out, and it refuses without a KO. Then the bout runs as always, with three i
|
|
|
225
225
|
"<glob>,<glob>"` (the files that block will touch). brief.mjs refuses it when those globs match a
|
|
226
226
|
file of the cut, or when another block is already «en curso». Its brief says which files it may
|
|
227
227
|
touch; a rebound from the verdict still goes first.
|
|
228
|
-
**Blocks side by side (`parallelBlocks
|
|
229
|
-
|
|
228
|
+
**Blocks side by side (`parallelBlocks`, 2 by default)**: when a block is running and NOTHING
|
|
229
|
+
waits on the human's review, start one more INDEPENDENT block next to it, in the SAME worktree,
|
|
230
|
+
one wrestler each (a long block should not leave the human's queue empty). While any block waits
|
|
231
|
+
on review, one block at a time: the script refuses the second start. Pick blocks that neither depend on each
|
|
230
232
|
other nor share a contract (props, a hook's return, i18n keys, a helper): a file lock does not
|
|
231
233
|
see that, you do. Start each with its files: `judges-card.mjs plan <pr> <n> "en curso"
|
|
232
234
|
--minutes <m> --priority <p> --files "<glob>,<glob>"`; the script refuses a start whose files a
|
|
@@ -509,6 +511,8 @@ logs there; whichever session regenerates the page passes every bout dir of the
|
|
|
509
511
|
|
|
510
512
|
## 4 · `close`
|
|
511
513
|
|
|
514
|
+
**Close comes after title-defense.** The order is: plan complete → the judge tests it in the sandbox → the human marks the PR ready → `/ring:title-defense` → `judges-card.mjs close`. A plan whose blocks the judge all passed is not the end. `close` refuses a bout that never defended its title unless the judge waives it (`--without-title-defense "<what they said>"`). A bout closed too early goes back with `judges-card.mjs reopen <pr> --why "<what the judge said>"`; `resume` on a closed bout asks the judge whether to reopen it.
|
|
515
|
+
|
|
512
516
|
When the PR gets an automated review (GitHub Copilot), converge it with
|
|
513
517
|
`/ring:title-defense <pr> [--max-rounds N]` before or during `close`: it catalogues the findings
|
|
514
518
|
and runs the fixes through this same cycle (§2).
|
|
@@ -559,7 +563,7 @@ and runs the fixes through this same cycle (§2).
|
|
|
559
563
|
|
|
560
564
|
## 5 · `resume [<pr>]`
|
|
561
565
|
|
|
562
|
-
1. Without `<pr>`: `node "$RING/scripts/judges-card.mjs" list`; take the only active bout or ask.
|
|
566
|
+
1. Without `<pr>`: `node "$RING/scripts/judges-card.mjs" list`; take the only active bout or ask. A bout listed as closed is reopened only with the judge's yes: `judges-card.mjs reopen <pr> --why "<what they said>"`.
|
|
563
567
|
2. `node "$RING/scripts/judges-card.mjs" summary <pr>`: state (with the bout's venue),
|
|
564
568
|
unreviewed commits, open rebounds, decisions and the judges card, and, when Engram answers,
|
|
565
569
|
the repo's latest fouls, verdicts and precedents from earlier bouts (do not repeat those fouls;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: title-defense
|
|
3
3
|
description: Converges a PR after an external automated reviewer (GitHub Copilot) has commented. Each Copilot review is a challenger's round; the PR defends its belt until a round brings nothing actionable and CI is green, or the round limit is hit. A thin layer over the referee: it gathers, classifies and catalogues the findings, hands the fixes to the referee's normal cycle, then replies to fixed threads, resolves them and re-requests Copilot. Invoke with `/ring:title-defense <pr> [--max-rounds N]`, "defend the PR against Copilot", "converge the Copilot review".
|
|
4
|
-
argument-hint: "<pr> [--max-rounds N]"
|
|
4
|
+
argument-hint: "<pr> [--max-rounds N] [--keep-draft]"
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# title-defense
|
|
@@ -20,10 +20,13 @@ list, judges card, `decisions.md` and `rebounds.md`, and the same `mask-guard` r
|
|
|
20
20
|
|
|
21
21
|
## 0 · Before the first round
|
|
22
22
|
|
|
23
|
-
1. **
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
1. **Invoking title-defense is the human's yes to take the PR out of draft.** Never run
|
|
24
|
+
`gh pr ready` yourself (mask-guard refuses it): `judges-card.mjs title-defense <pr>
|
|
25
|
+
--until-round <k>` marks a draft PR ready, logs «the judge invoked title-defense» in
|
|
26
|
+
decisions.md and sets the flag. If the human said to keep it a draft (`--keep-draft` in the
|
|
27
|
+
invocation), pass `--keep-draft`: then a draft refuses as before, "PR #<n> is still a draft;
|
|
28
|
+
title-defense starts when you mark it ready for review", and you do nothing else. When gh cannot
|
|
29
|
+
tell whether it is a draft, it refuses too.
|
|
27
30
|
2. `--max-rounds` defaults to 3 (the owner often uses up to 6).
|
|
28
31
|
3. **A bout for this PR**: `node "$RING/scripts/judges-card.mjs" summary <pr>`. If there is none,
|
|
29
32
|
open one with the referee's `start` flow (§1); if it exists, reuse it as in `resume` (§5),
|