@thebattlearena/corner 0.10.1-beta.83 → 0.10.1-beta.85
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 +150 -11
- package/ring/scripts/lanes.mjs +7 -0
- package/ring/scripts/lookout.mjs +17 -1
- package/ring/scripts/mask-guard.mjs +51 -2
- package/ring/scripts/review-blocks.mjs +22 -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 +17 -6
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.85",
|
|
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
|
/**
|
|
@@ -768,10 +872,13 @@ export function readyForTitleDefense(pr, view) {
|
|
|
768
872
|
reason: `could not tell whether PR #${pr} is a draft (${error.message}); title-defense does not start`,
|
|
769
873
|
};
|
|
770
874
|
}
|
|
771
|
-
if (answer?.isDraft === false)
|
|
875
|
+
if (answer?.isDraft === false)
|
|
876
|
+
return { ok: true, reason: null, nodeId: answer.id ?? null };
|
|
772
877
|
if (answer?.isDraft === true)
|
|
773
878
|
return {
|
|
774
879
|
ok: false,
|
|
880
|
+
draft: true,
|
|
881
|
+
nodeId: answer.id ?? null,
|
|
775
882
|
reason: `PR #${pr} is still a draft; title-defense starts when you mark it ready for review`,
|
|
776
883
|
};
|
|
777
884
|
return { ok: false, reason: `gh did not say whether PR #${pr} is a draft` };
|
|
@@ -888,7 +995,9 @@ function titleDefense([pr, ...argv]) {
|
|
|
888
995
|
const clear = argv.includes('--clear');
|
|
889
996
|
const index = argv.indexOf('--until-round');
|
|
890
997
|
if (!pr || (!clear && index === -1))
|
|
891
|
-
throw new Error(
|
|
998
|
+
throw new Error(
|
|
999
|
+
'usage: title-defense <pr> --until-round <k> [--keep-draft] | --clear',
|
|
1000
|
+
);
|
|
892
1001
|
const dir = currentDir(pr);
|
|
893
1002
|
const state = readState(dir);
|
|
894
1003
|
if (state.active !== true)
|
|
@@ -898,14 +1007,43 @@ function titleDefense([pr, ...argv]) {
|
|
|
898
1007
|
const gate = clear
|
|
899
1008
|
? { ok: true }
|
|
900
1009
|
: readyForTitleDefense(pr, () =>
|
|
901
|
-
execFileSync('gh', ['pr', 'view', String(pr), '--json', 'isDraft'], {
|
|
1010
|
+
execFileSync('gh', ['pr', 'view', String(pr), '--json', 'isDraft,id'], {
|
|
902
1011
|
cwd: state.worktree,
|
|
903
1012
|
encoding: 'utf8',
|
|
904
1013
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
905
1014
|
}),
|
|
906
1015
|
);
|
|
907
|
-
|
|
908
|
-
|
|
1016
|
+
// Invoking title-defense is the judge's yes to take the PR out of draft (--keep-draft: not yet).
|
|
1017
|
+
// The script runs gh itself, so mask-guard still refuses `gh pr ready` anywhere else.
|
|
1018
|
+
if (!gate.ok && gate.draft && !argv.includes('--keep-draft')) {
|
|
1019
|
+
execFileSync('gh', ['pr', 'ready', String(pr)], {
|
|
1020
|
+
cwd: state.worktree,
|
|
1021
|
+
encoding: 'utf8',
|
|
1022
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1023
|
+
});
|
|
1024
|
+
appendFileSync(
|
|
1025
|
+
join(dir, 'decisions.md'),
|
|
1026
|
+
`- ${new Date().toISOString()} · ${nextDecisionId(dir)} · judge · the judge invoked title-defense: PR #${pr} is out of draft\n`,
|
|
1027
|
+
);
|
|
1028
|
+
console.log(
|
|
1029
|
+
`PR #${pr} marked ready for review (the judge invoked title-defense)`,
|
|
1030
|
+
);
|
|
1031
|
+
} else if (!gate.ok)
|
|
1032
|
+
throw new Error(
|
|
1033
|
+
gate.draft ? `${gate.reason} (--keep-draft was passed)` : gate.reason,
|
|
1034
|
+
);
|
|
1035
|
+
const flagged = withTitleDefense(
|
|
1036
|
+
state,
|
|
1037
|
+
clear ? null : Number(argv[index + 1]),
|
|
1038
|
+
);
|
|
1039
|
+
// The PR's GraphQL node id: mask-guard ties a requestReviewsByLogin (`-F id=<node>`) to this bout.
|
|
1040
|
+
const next =
|
|
1041
|
+
clear || !gate.nodeId
|
|
1042
|
+
? flagged
|
|
1043
|
+
: {
|
|
1044
|
+
...flagged,
|
|
1045
|
+
titleDefense: { ...flagged.titleDefense, nodeId: gate.nodeId },
|
|
1046
|
+
};
|
|
909
1047
|
writeState(dir, next);
|
|
910
1048
|
console.log(
|
|
911
1049
|
clear
|
|
@@ -1071,6 +1209,7 @@ function proposal([pr, n, verdict, ...argv]) {
|
|
|
1071
1209
|
|
|
1072
1210
|
const COMMANDS = {
|
|
1073
1211
|
open,
|
|
1212
|
+
reopen,
|
|
1074
1213
|
concept,
|
|
1075
1214
|
relay,
|
|
1076
1215
|
proposal,
|
|
@@ -1093,7 +1232,7 @@ if (isMain(import.meta.url)) {
|
|
|
1093
1232
|
const run = COMMANDS[command];
|
|
1094
1233
|
if (!run) {
|
|
1095
1234
|
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 …',
|
|
1235
|
+
'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
1236
|
);
|
|
1098
1237
|
process.exit(1);
|
|
1099
1238
|
}
|
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();
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { boutArchGate } from './arch-gate.mjs';
|
|
14
14
|
import { boutFormatGate } from './format-gate.mjs';
|
|
15
15
|
import { boutLeases, commitLeaseRefusal, wrestlerBlocks } from './lanes.mjs';
|
|
16
|
+
import { reviewInputs, roundsAwaitingHuman } from './review-blocks.mjs';
|
|
16
17
|
import { execFileSync } from 'node:child_process';
|
|
17
18
|
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
18
19
|
import { homedir } from 'node:os';
|
|
@@ -955,6 +956,19 @@ const requestsCopilotByLogin = (gh) =>
|
|
|
955
956
|
/\brequestReviewsByLogin\b/.test(graphqlText(gh)) &&
|
|
956
957
|
/copilot-pull-request-reviewer/.test(graphqlText(gh));
|
|
957
958
|
|
|
959
|
+
/**
|
|
960
|
+
* The PR node id a GraphQL mutation targets: `-F id=<node>` / `-f id=…` or a `pullRequestId:
|
|
961
|
+
* "<node>"` inside the query. PURE.
|
|
962
|
+
*/
|
|
963
|
+
export function graphqlNodeId(gh) {
|
|
964
|
+
const text = graphqlText(gh);
|
|
965
|
+
return (
|
|
966
|
+
/(?:^|\s)-[fF]\s+(?:id|pullRequestId)=(\S+)/.exec(text)?.[1] ??
|
|
967
|
+
/pullRequestId:\s*"([^"]+)"/.exec(text)?.[1] ??
|
|
968
|
+
null
|
|
969
|
+
);
|
|
970
|
+
}
|
|
971
|
+
|
|
958
972
|
/** `node <…>/judges-card.mjs close <pr>`: the PR it closes, else null. */
|
|
959
973
|
function closesBout(words) {
|
|
960
974
|
const closing =
|
|
@@ -980,9 +994,13 @@ function chronicleBehind(ctx, where, pr) {
|
|
|
980
994
|
}
|
|
981
995
|
const [first] = stale;
|
|
982
996
|
if (!first) return null;
|
|
997
|
+
const unscoped =
|
|
998
|
+
pr === null
|
|
999
|
+
? ` This command could not be tied to one bout, so every active bout of the repo was checked: #${first.pr} is the one behind, not necessarily the PR this command is for.`
|
|
1000
|
+
: '';
|
|
983
1001
|
return {
|
|
984
1002
|
block: true,
|
|
985
|
-
reason: `The play-by-play is behind the bout (records changed after ${first.publishedAt ?? 'a publish that was never stamped'}). Run /ring:play-by-play ${first.pr} and publish to the same link first
|
|
1003
|
+
reason: `The play-by-play is behind the bout (records changed after ${first.publishedAt ?? 'a publish that was never stamped'}). Run /ring:play-by-play ${first.pr} and publish to the same link first.${unscoped}`,
|
|
986
1004
|
};
|
|
987
1005
|
}
|
|
988
1006
|
|
|
@@ -1376,8 +1394,19 @@ function judgeSegment(segment, words, cwd, ctx) {
|
|
|
1376
1394
|
const bell = gh && requestsCopilotByLogin(gh);
|
|
1377
1395
|
const where = { git, gh, dir: cwd };
|
|
1378
1396
|
if ((bell || closing !== null) && ctx.anyBout() && ctx.boutActive(where)) {
|
|
1379
|
-
|
|
1397
|
+
// A GraphQL re-request names its PR by node id: title-defense stored it when it set the flag.
|
|
1398
|
+
const bellPr = bell
|
|
1399
|
+
? (prNumberOf(gh) ?? ctx.prOfNode?.(where, graphqlNodeId(gh)) ?? null)
|
|
1400
|
+
: null;
|
|
1401
|
+
const behind = chronicleBehind(ctx, where, bell ? bellPr : closing);
|
|
1380
1402
|
if (behind) return behind;
|
|
1403
|
+
// Copilot gets no new round until the human validated the last one (its plan block).
|
|
1404
|
+
const unreviewed = bell ? ctx.roundsAwaitingHuman?.(where, bellPr) : [];
|
|
1405
|
+
if (unreviewed?.length)
|
|
1406
|
+
return {
|
|
1407
|
+
block: true,
|
|
1408
|
+
reason: `The human has not validated ${unreviewed.join(', ')} yet: Copilot gets the next round only after the human signs that round in PR Review Lab (a pass). Wait for the verdict (the lookout wakes you), apply it with judges-card.mjs summary <pr>, then re-request.`,
|
|
1409
|
+
};
|
|
1381
1410
|
}
|
|
1382
1411
|
const ambiguous =
|
|
1383
1412
|
git?.sub === 'commit' &&
|
|
@@ -1721,6 +1750,26 @@ function realContext(cwd, agentId = null) {
|
|
|
1721
1750
|
.filter((bout) => bout.stale)
|
|
1722
1751
|
.map(({ pr: stalePr, publishedAt }) => ({ pr: stalePr, publishedAt }));
|
|
1723
1752
|
},
|
|
1753
|
+
// The Copilot round blocks the human has not validated, of the bout of `pr` (every bout when null).
|
|
1754
|
+
roundsAwaitingHuman: (where, pr) => {
|
|
1755
|
+
const slug = slugWhere(where);
|
|
1756
|
+
if (!slug) return [];
|
|
1757
|
+
return boutsOf(slug)
|
|
1758
|
+
.filter(({ state }) => pr === null || state.pr === pr)
|
|
1759
|
+
.flatMap(({ dir }) => {
|
|
1760
|
+
const { plan, log, verdicts } = reviewInputs(dir);
|
|
1761
|
+
return roundsAwaitingHuman(plan, log, verdicts);
|
|
1762
|
+
});
|
|
1763
|
+
},
|
|
1764
|
+
// The bout whose title-defense flag carries this PR node id, or null.
|
|
1765
|
+
prOfNode: (where, nodeId) => {
|
|
1766
|
+
const slug = slugWhere(where);
|
|
1767
|
+
if (!slug || !nodeId) return null;
|
|
1768
|
+
return (
|
|
1769
|
+
boutsOf(slug).find(({ state }) => state.titleDefense?.nodeId === nodeId)
|
|
1770
|
+
?.state.pr ?? null
|
|
1771
|
+
);
|
|
1772
|
+
},
|
|
1724
1773
|
titleDefenseFlags: (where) => {
|
|
1725
1774
|
const slug = slugWhere(where);
|
|
1726
1775
|
if (!slug) return [];
|
|
@@ -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)),
|
|
@@ -282,6 +287,19 @@ export function lookaheadRefusal({ globs, waitingFiles, running }) {
|
|
|
282
287
|
return null;
|
|
283
288
|
}
|
|
284
289
|
|
|
290
|
+
/**
|
|
291
|
+
* The Copilot rounds of title-defense the human has not validated yet: a block «Copilot R<k>…»
|
|
292
|
+
* still «en curso», or waiting on the judge with no later verdict. Copilot is not asked for another
|
|
293
|
+
* round until they are empty. PURE.
|
|
294
|
+
*/
|
|
295
|
+
export function roundsAwaitingHuman(plan, log, verdicts) {
|
|
296
|
+
const isRound = (item) => /^Copilot R\d+/i.test(item.title);
|
|
297
|
+
const waiting = blocksAwaitingReview(plan, log, verdicts).filter(isRound);
|
|
298
|
+
return [...runningBlocks(plan).filter(isRound), ...waiting].map(
|
|
299
|
+
(item) => `B${item.n} «${item.title}»`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
285
303
|
/** The blocks «en curso» right now. PURE. */
|
|
286
304
|
export const runningBlocks = (plan) =>
|
|
287
305
|
parsePlan(plan)
|
|
@@ -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),
|
|
@@ -91,7 +94,15 @@ list, judges card, `decisions.md` and `rebounds.md`, and the same `mask-guard` r
|
|
|
91
94
|
result is a new rebound; a fix is not done until the reproduction says so (PR26). If it cannot
|
|
92
95
|
run (environment down, scenario missing), the thread stays open and is listed for the human in
|
|
93
96
|
the judges card: never resolve it on unit tests alone.
|
|
94
|
-
6. **A round-bell for the fixes** (§2.6), one per concept.
|
|
97
|
+
6. **A round-bell for the fixes** (§2.6), one per concept. **The round is a block of the plan**,
|
|
98
|
+
so the human reviews it in PR Review Lab like any other: before the wrestler starts, `judges-card.mjs
|
|
99
|
+
plan <pr> add "Defensa del título" "Copilot R<k>: <one line of what it fixes>"`, then `plan <pr>
|
|
100
|
+
<n> "en curso" --minutes <m> --priority 1` (with `--files` when blocks run side by side), and
|
|
101
|
+
`plan <pr> <n> "espera al juez"` once its range is approved. The human's verdict applies as for
|
|
102
|
+
any block: a fail comes back as the rebound of the next round. A round with nothing to fix adds
|
|
103
|
+
no block. **Copilot gets the next round only after the human passed this one**: mask-guard
|
|
104
|
+
refuses the re-request while a «Copilot R<k>» block is running or waits on the judge; the lookout
|
|
105
|
+
wakes you when the verdict lands.
|
|
95
106
|
7. **CI red**: diagnose before touching code: `gh run view <id> --log-failed`. Tell a PR failure
|
|
96
107
|
from a gate failure (coverage of an untested file; a size ceiling, which goes to the human).
|
|
97
108
|
Never "fix" a gate by weakening it. A PR failure goes through steps 3 to 6 like a finding.
|