@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
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Several blocks at once in ONE worktree: each block «en curso» holds the files it declared
|
|
2
|
+
// (`--files "<globs>"` at estimate or start), and only that block's wrestler commits them. The
|
|
3
|
+
// referee grants them all when the block starts (the start is refused when another running block
|
|
4
|
+
// holds one of them, or when `parallelBlocks` are already running); a wrestler that needs a file of
|
|
5
|
+
// another block stops and asks the referee, never the other wrestler. mask-guard refuses a commit
|
|
6
|
+
// that stages a file another running block holds. Files the host regenerates (`generatedPaths`,
|
|
7
|
+
// e.g. docs a pre-commit writes) are nobody's: the referee regenerates them when the wave closes.
|
|
8
|
+
// Closing a concept with `--block <n>` retires only that block's wrestlers. Zero-dependency.
|
|
9
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { globToRegExp } from './contract.mjs';
|
|
13
|
+
import { wrestlerLines } from './relay.mjs';
|
|
14
|
+
import { parsePlan, parsePlanLog, PLAN, PLAN_LOG } from './tech-plan.mjs';
|
|
15
|
+
|
|
16
|
+
/** `"a/**, b/*.ts"` as globs. PURE. */
|
|
17
|
+
export const parseGlobs = (text) =>
|
|
18
|
+
String(text ?? '')
|
|
19
|
+
.split(',')
|
|
20
|
+
.map((glob) => glob.trim())
|
|
21
|
+
.filter(Boolean);
|
|
22
|
+
|
|
23
|
+
const matcher = (globs) => {
|
|
24
|
+
const patterns = (globs ?? []).map((glob) => globToRegExp(glob));
|
|
25
|
+
return (file) => patterns.some((pattern) => pattern.test(file));
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** The blocks «en curso» with the files each holds (its last `files` in plan.jsonl). PURE. */
|
|
29
|
+
export function runningLeases(plan, log) {
|
|
30
|
+
return parsePlan(plan)
|
|
31
|
+
.groups.flatMap((group) => group.items)
|
|
32
|
+
.filter((item) => item.state === 'en curso')
|
|
33
|
+
.map((item) => ({
|
|
34
|
+
n: item.n,
|
|
35
|
+
title: item.title,
|
|
36
|
+
files:
|
|
37
|
+
log.filter((line) => line.n === item.n && line.files).at(-1)?.files ??
|
|
38
|
+
[],
|
|
39
|
+
}));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Why block `n` may not start next to the blocks already running, or null. With `parallel` 1 (the
|
|
44
|
+
* default) nothing changes. Otherwise it needs its files, a free slot, and no tracked file that a
|
|
45
|
+
* running block holds too (generated files are nobody's). PURE.
|
|
46
|
+
*/
|
|
47
|
+
export function startLaneRefusal({
|
|
48
|
+
n,
|
|
49
|
+
files,
|
|
50
|
+
running,
|
|
51
|
+
parallel,
|
|
52
|
+
tracked,
|
|
53
|
+
generated,
|
|
54
|
+
waiting = [],
|
|
55
|
+
}) {
|
|
56
|
+
const others = running.filter((lease) => lease.n !== n);
|
|
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`;
|
|
61
|
+
if (others.length >= parallel)
|
|
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>")`;
|
|
66
|
+
if (!files?.length)
|
|
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`;
|
|
68
|
+
const isGenerated = matcher(generated);
|
|
69
|
+
const mine = matcher(files);
|
|
70
|
+
const clashes = others.flatMap((lease) => {
|
|
71
|
+
const theirs = matcher(lease.files);
|
|
72
|
+
return tracked
|
|
73
|
+
.filter((file) => !isGenerated(file) && mine(file) && theirs(file))
|
|
74
|
+
.map((file) => `${file} (B${lease.n})`);
|
|
75
|
+
});
|
|
76
|
+
if (clashes.length)
|
|
77
|
+
return `B${n} would share files with a running block: ${clashes.slice(0, 8).join(', ')}${clashes.length > 8 ? ', …' : ''}. Narrow its --files, or start another block`;
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Why a commit may not go in, or null: a staged file held by a running block other than the one
|
|
83
|
+
* whose wrestler commits. Unknown committer: a commit may not mix files of two blocks. PURE.
|
|
84
|
+
*/
|
|
85
|
+
export function commitLeaseRefusal({ staged, running, block, generated }) {
|
|
86
|
+
if (running.length < 2) return null;
|
|
87
|
+
const isGenerated = matcher(generated);
|
|
88
|
+
const owners = staged
|
|
89
|
+
.filter((file) => !isGenerated(file))
|
|
90
|
+
.flatMap((file) =>
|
|
91
|
+
running
|
|
92
|
+
.filter((lease) => matcher(lease.files)(file))
|
|
93
|
+
.map((lease) => ({ file, n: lease.n })),
|
|
94
|
+
);
|
|
95
|
+
const foreign = block
|
|
96
|
+
? owners.filter((owner) => owner.n !== block)
|
|
97
|
+
: owners.filter((owner) => owner.n !== owners[0]?.n);
|
|
98
|
+
if (!foreign.length) return null;
|
|
99
|
+
return `this commit stages files another running block holds: ${foreign.map((owner) => `${owner.file} (B${owner.n})`).join(', ')}. Unstage them (git restore --staged <path>); if your block needs one, stop and ask the referee, who moves it between blocks. Never edit or commit another wrestler's files.`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const jsonl = (path) =>
|
|
103
|
+
existsSync(path) ? parsePlanLog(readFileSync(path, 'utf8')) : [];
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The block each wrestler works on: its agent id (live.jsonl) → the Agent launch (tool_use_id) →
|
|
107
|
+
* the brief brief-guard saw → that brief's `block` in briefs/index.jsonl. PURE apart from reads.
|
|
108
|
+
*/
|
|
109
|
+
export function wrestlerBlocks(dir) {
|
|
110
|
+
const briefs = new Map(
|
|
111
|
+
jsonl(join(dir, 'briefs', 'index.jsonl'))
|
|
112
|
+
.filter((brief) => brief.kind === 'wrestler' && brief.block)
|
|
113
|
+
.map((brief) => [
|
|
114
|
+
`wrestler-${brief.mode}-${brief.n}.md`,
|
|
115
|
+
Number(brief.block),
|
|
116
|
+
]),
|
|
117
|
+
);
|
|
118
|
+
const launches = new Map(
|
|
119
|
+
jsonl(join(dir, 'brief-guard.jsonl'))
|
|
120
|
+
.filter((entry) => entry.toolUseId && entry.path)
|
|
121
|
+
.map((entry) => [
|
|
122
|
+
entry.toolUseId,
|
|
123
|
+
briefs.get(entry.path.split('/').at(-1)),
|
|
124
|
+
]),
|
|
125
|
+
);
|
|
126
|
+
const blocks = new Map();
|
|
127
|
+
for (const line of wrestlerLines(dir)) {
|
|
128
|
+
try {
|
|
129
|
+
const event = JSON.parse(line);
|
|
130
|
+
const agent = /^agent (\S+)$/.exec(event.note ?? '')?.[1];
|
|
131
|
+
const block = launches.get(event.id);
|
|
132
|
+
if (event.actor === 'luchador' && agent && block)
|
|
133
|
+
blocks.set(agent, block);
|
|
134
|
+
} catch {
|
|
135
|
+
// A torn line is skipped.
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return blocks;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The running blocks of a bout dir, read now. */
|
|
142
|
+
export function boutLeases(dir) {
|
|
143
|
+
const read = (name) =>
|
|
144
|
+
existsSync(join(dir, name)) ? readFileSync(join(dir, name), 'utf8') : '';
|
|
145
|
+
return runningLeases(read(PLAN), parsePlanLog(read(PLAN_LOG)));
|
|
146
|
+
}
|
package/ring/scripts/live.mjs
CHANGED
|
@@ -255,9 +255,17 @@ export function appendLive(file, event, keep = KEEP) {
|
|
|
255
255
|
export function record(dir, raw) {
|
|
256
256
|
if (!dir) return false;
|
|
257
257
|
const { event } = validateEvent(raw);
|
|
258
|
-
|
|
258
|
+
if (!event) return false;
|
|
259
|
+
// live.jsonl is trimmed to its last lines; who each wrestler is must outlive the trim (a concept
|
|
260
|
+
// close retires them by these ids, and lanes map them to their block).
|
|
261
|
+
if (event.actor === 'luchador' && /^agent \S+$/.test(event.note ?? ''))
|
|
262
|
+
appendFileSync(join(dir, WRESTLERS), `${JSON.stringify(event)}\n`);
|
|
263
|
+
return appendLive(join(dir, LIVE_LOG), event);
|
|
259
264
|
}
|
|
260
265
|
|
|
266
|
+
/** The launches of the bout's wrestlers (agent ids), kept whole. */
|
|
267
|
+
export const WRESTLERS = 'wrestlers.jsonl';
|
|
268
|
+
|
|
261
269
|
const within = (dir, path) => {
|
|
262
270
|
const inside = relative(resolve(dir), resolve(path));
|
|
263
271
|
return !inside.startsWith('..') && !isAbsolute(inside);
|
package/ring/scripts/lookout.mjs
CHANGED
|
@@ -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
|
|
@@ -19,13 +20,16 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
19
20
|
import { join } from 'node:path';
|
|
20
21
|
|
|
21
22
|
import {
|
|
23
|
+
boutDir,
|
|
22
24
|
DEFAULTS,
|
|
23
25
|
isMain,
|
|
24
26
|
loadConfig,
|
|
25
27
|
projectDir,
|
|
26
28
|
readState,
|
|
29
|
+
repoSlug,
|
|
27
30
|
writeState,
|
|
28
31
|
} from './config.mjs';
|
|
32
|
+
import { reviewInputs } from './review-blocks.mjs';
|
|
29
33
|
import { counts, readLedger, reviewDeclared } from './findings.mjs';
|
|
30
34
|
import { acceptUnverified, boutFormatGate } from './format-gate.mjs';
|
|
31
35
|
|
|
@@ -187,6 +191,14 @@ function backupQuietly(state, template) {
|
|
|
187
191
|
}
|
|
188
192
|
}
|
|
189
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
|
+
|
|
190
202
|
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
|
|
191
203
|
|
|
192
204
|
async function watch(dir, intervalSeconds) {
|
|
@@ -203,6 +215,12 @@ async function watch(dir, intervalSeconds) {
|
|
|
203
215
|
const state = readState(dir);
|
|
204
216
|
if (state.active !== true)
|
|
205
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
|
+
);
|
|
206
224
|
const head = git(state.worktree, ['rev-parse', 'HEAD']);
|
|
207
225
|
if (compareHeads(state.lastReviewed, head).changed) {
|
|
208
226
|
const settings = hostSettings();
|
|
@@ -332,7 +350,11 @@ function approve(dir, ref, { formatUnverified = null } = {}) {
|
|
|
332
350
|
}
|
|
333
351
|
|
|
334
352
|
if (isMain(import.meta.url)) {
|
|
335
|
-
const [command,
|
|
353
|
+
const [command, target, ...rest] = process.argv.slice(2);
|
|
354
|
+
// A bout dir, or the PR number every other ring script takes.
|
|
355
|
+
const dir = /^\d+$/.test(target ?? '')
|
|
356
|
+
? boutDir(repoSlug(projectDir()), target)
|
|
357
|
+
: target;
|
|
336
358
|
const intervalIndex = rest.indexOf('--interval');
|
|
337
359
|
const interval = intervalIndex === -1 ? 60 : Number(rest[intervalIndex + 1]);
|
|
338
360
|
const fail = (error) => {
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// it blocks too: a guard that cannot evaluate must not let the command through.
|
|
13
13
|
import { boutArchGate } from './arch-gate.mjs';
|
|
14
14
|
import { boutFormatGate } from './format-gate.mjs';
|
|
15
|
+
import { boutLeases, commitLeaseRefusal, wrestlerBlocks } from './lanes.mjs';
|
|
15
16
|
import { execFileSync } from 'node:child_process';
|
|
16
17
|
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
17
18
|
import { homedir } from 'node:os';
|
|
@@ -803,6 +804,11 @@ const gitRules = [
|
|
|
803
804
|
reason: (git, ctx) =>
|
|
804
805
|
`This commit changes the API contract, database models or native surface (${ctx.unapprovedContractFiles(git).join(', ')}) without the judge's approval of exactly this content. Serve the preview (contract-preview.mjs), get the judge's yes, record it with judges-card.mjs approve-contract <pr> --files <paths>, then commit. Any edit after approval needs a new one.`,
|
|
805
806
|
},
|
|
807
|
+
{
|
|
808
|
+
// Blocks running side by side in one worktree: a wrestler commits only its block's files.
|
|
809
|
+
when: (git, ctx) => git.sub === 'commit' && ctx.leaseRefusal(git) !== null,
|
|
810
|
+
reason: (git, ctx) => ctx.leaseRefusal(git),
|
|
811
|
+
},
|
|
806
812
|
{
|
|
807
813
|
// An amend adds no commit; everything else waits once the unreviewed range hits the cap.
|
|
808
814
|
when: (git, ctx) =>
|
|
@@ -1067,7 +1073,7 @@ const ghRules = [
|
|
|
1067
1073
|
gh.action === 'create' &&
|
|
1068
1074
|
!hasFlag(gh.args, '--draft', '-d'),
|
|
1069
1075
|
reason: () =>
|
|
1070
|
-
'Every PR
|
|
1076
|
+
'Every PR opened while a bout is active is a draft, even one the human asked for outside the bout: add --draft to gh pr create, and tell the human the link; they mark it ready (gh pr ready <n>, or «Ready for review» on GitHub).',
|
|
1071
1077
|
},
|
|
1072
1078
|
{
|
|
1073
1079
|
when: (gh) =>
|
|
@@ -1290,6 +1296,10 @@ function judgeSegment(segment, words, cwd, ctx) {
|
|
|
1290
1296
|
hasAiTrailer(commitMessage(commit, segment, context)),
|
|
1291
1297
|
commitBoutIds: (commit) => boutIds(commitMessage(commit, segment, context)),
|
|
1292
1298
|
reviewDebt: (commit) => ctx.reviewDebtAt(context.path(commit.dir)),
|
|
1299
|
+
leaseRefusal: (commit) =>
|
|
1300
|
+
ctx.leaseRefusalAt(context.path(commit.dir), () =>
|
|
1301
|
+
ctx.committedFiles(commit, commitsWorkingTree(commit)),
|
|
1302
|
+
),
|
|
1293
1303
|
pushBeyondApproval: (push, refspecs) =>
|
|
1294
1304
|
ctx.pushBeyondApprovalAt(context.path(push.dir), refspecs),
|
|
1295
1305
|
findingsBeforePush: (push, refspecs) =>
|
|
@@ -1410,7 +1420,7 @@ function insideGitRepo(dir) {
|
|
|
1410
1420
|
* The real context of one hook invocation. The host config is loaded lazily, only when a rule
|
|
1411
1421
|
* reads it: a broken .claude/ring.json must not cost every command a parse, nor fail it.
|
|
1412
1422
|
*/
|
|
1413
|
-
function realContext(cwd) {
|
|
1423
|
+
function realContext(cwd, agentId = null) {
|
|
1414
1424
|
const bouts = new Map();
|
|
1415
1425
|
const slugs = new Map();
|
|
1416
1426
|
let config = null;
|
|
@@ -1506,6 +1516,18 @@ function realContext(cwd) {
|
|
|
1506
1516
|
contract ??= loadConfig(projectDir(process.env, cwd)).config;
|
|
1507
1517
|
return contract;
|
|
1508
1518
|
},
|
|
1519
|
+
// A commit staging files another running block holds (parallelBlocks > 1), or null.
|
|
1520
|
+
leaseRefusalAt: (dir, staged) => {
|
|
1521
|
+
if ((loaded().parallelBlocks ?? 1) <= 1) return null;
|
|
1522
|
+
const bout = boutOfDir(dir);
|
|
1523
|
+
if (!bout) return null;
|
|
1524
|
+
return commitLeaseRefusal({
|
|
1525
|
+
staged: staged(),
|
|
1526
|
+
running: boutLeases(bout.dir),
|
|
1527
|
+
block: agentId ? (wrestlerBlocks(bout.dir).get(agentId) ?? null) : null,
|
|
1528
|
+
generated: loaded().generatedPaths,
|
|
1529
|
+
});
|
|
1530
|
+
},
|
|
1509
1531
|
// The unreviewed range of the bout whose worktree holds `dir`, when it is at or over the cap.
|
|
1510
1532
|
reviewDebtAt: (dir) => {
|
|
1511
1533
|
const bout = boutOfDir(dir);
|
|
@@ -1771,7 +1793,10 @@ function block(reason) {
|
|
|
1771
1793
|
function evaluate(payload) {
|
|
1772
1794
|
if (payload?.tool_name !== 'Bash') return;
|
|
1773
1795
|
const command = payload?.tool_input?.command ?? '';
|
|
1774
|
-
const verdict = decide(
|
|
1796
|
+
const verdict = decide(
|
|
1797
|
+
command,
|
|
1798
|
+
realContext(payload?.cwd || process.cwd(), payload?.agent_id ?? null),
|
|
1799
|
+
);
|
|
1775
1800
|
if (verdict.block) return block(verdict.reason);
|
|
1776
1801
|
if (verdict.warnings.length)
|
|
1777
1802
|
process.stdout.write(
|
|
@@ -211,9 +211,20 @@ function reportInvisible(feed, command) {
|
|
|
211
211
|
if (!feed.dir || feed.visible) return () => undefined;
|
|
212
212
|
const id = `invisible:${process.pid}`;
|
|
213
213
|
let shown = false;
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
)
|
|
214
|
+
// Once per bout every few hours, not on every tsc or jest.
|
|
215
|
+
const marker = join(feed.dir, '.no-monitor-warned');
|
|
216
|
+
const fresh = (() => {
|
|
217
|
+
try {
|
|
218
|
+
return Date.now() - statSync(marker).mtimeMs < 6 * 3600_000;
|
|
219
|
+
} catch {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
})();
|
|
223
|
+
if (!fresh) writeFileSync(marker, '');
|
|
224
|
+
if (!fresh)
|
|
225
|
+
console.error(
|
|
226
|
+
`one-at-a-time: no declared monitor shows "${commandLabel(command)}" on the Scorecard; a long run needs one: rerun with --monitor <id>, or declare a monitor whose match.command matches it`,
|
|
227
|
+
);
|
|
217
228
|
const timer = setTimeout(() => {
|
|
218
229
|
shown = true;
|
|
219
230
|
record(feed.dir, {
|
package/ring/scripts/relay.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { readFileSync } from 'node:fs';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
13
|
|
|
14
|
-
import { LIVE_LOG } from './live.mjs';
|
|
14
|
+
import { LIVE_LOG, WRESTLERS } from './live.mjs';
|
|
15
15
|
|
|
16
16
|
export const PROPOSALS = 'For the rulebook / precedents';
|
|
17
17
|
const DECIDED = /→\s*(accepted|rejected)\b/;
|
|
@@ -115,6 +115,18 @@ export function withConceptClosed(state, { name, range, at, wrestlers }) {
|
|
|
115
115
|
};
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
/** The wrestlers' launch lines (wrestlers.jsonl, never trimmed) and the live lines after them. */
|
|
119
|
+
export function wrestlerLines(dir) {
|
|
120
|
+
try {
|
|
121
|
+
return [
|
|
122
|
+
...readFileSync(join(dir, WRESTLERS), 'utf8').split('\n').filter(Boolean),
|
|
123
|
+
...liveLines(dir),
|
|
124
|
+
];
|
|
125
|
+
} catch {
|
|
126
|
+
return liveLines(dir);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
118
130
|
/** The live lines of a bout, or none. */
|
|
119
131
|
export function liveLines(dir) {
|
|
120
132
|
try {
|
|
@@ -10,6 +10,7 @@ import { appendFileSync, existsSync, readFileSync } from 'node:fs';
|
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
|
|
12
12
|
import { readState, writeState } from './config.mjs';
|
|
13
|
+
import { globToRegExp } from './contract.mjs';
|
|
13
14
|
import { parsePlan, parsePlanLog, PLAN, PLAN_LOG } from './tech-plan.mjs';
|
|
14
15
|
|
|
15
16
|
export const VERDICTS = 'review-verdicts.jsonl';
|
|
@@ -25,6 +26,7 @@ const FACTS = [
|
|
|
25
26
|
'startSha',
|
|
26
27
|
'startedAt',
|
|
27
28
|
'endedAt',
|
|
29
|
+
'files',
|
|
28
30
|
];
|
|
29
31
|
|
|
30
32
|
/** The facts of point `n` so far: each field from the last plan.jsonl line that has it. PURE. */
|
|
@@ -86,8 +88,9 @@ export function startRefusal(facts, to, n) {
|
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
/** Checks an estimate: priority 1–4 and whole minutes. PURE. */
|
|
89
|
-
export function estimateFacts({ priority, minutes }) {
|
|
91
|
+
export function estimateFacts({ priority, minutes, files }) {
|
|
90
92
|
const facts = {};
|
|
93
|
+
if (files?.length) facts.files = files;
|
|
91
94
|
if (priority != null) {
|
|
92
95
|
if (![1, 2, 3, 4].includes(Number(priority)))
|
|
93
96
|
throw new Error('--priority is 1 (first) to 4 (last)');
|
|
@@ -168,6 +171,20 @@ export function verdictEffects(verdict) {
|
|
|
168
171
|
|
|
169
172
|
const read = (path) => (existsSync(path) ? readFileSync(path, 'utf8') : '');
|
|
170
173
|
|
|
174
|
+
/**
|
|
175
|
+
* The human's «no pasa» still open as rebounds: until the blocks wait on the judge again, every
|
|
176
|
+
* resume (summary, compaction) shows them first, with their Tape. PURE apart from the read.
|
|
177
|
+
*/
|
|
178
|
+
export function openHumanReturns(dir) {
|
|
179
|
+
return read(join(dir, 'rebounds.md'))
|
|
180
|
+
.split('\n')
|
|
181
|
+
.filter((line) => /^- \[ \] .*Revisión humana/.test(line));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** What the referee does with a returned cut: read its Tape before writing the rebound. */
|
|
185
|
+
export const TAPE_HINT =
|
|
186
|
+
'Read each Tape before writing the rebound (Battle Arena connector: mcp__claude_ai_Battle_Arena__qa_session_overview, then qa_session_timeline on the chapter) and cite its minutes in the rebound. If those tools are not in this session, say so to the human in one line and connect them (/mcp, the claude.ai Battle Arena connector); never design the rebound from the feedback line alone.';
|
|
187
|
+
|
|
171
188
|
/** The author of a block: the git user of the worktree that writes it. */
|
|
172
189
|
export function authorEmail(worktree) {
|
|
173
190
|
try {
|
|
@@ -235,13 +252,43 @@ export function applyVerdicts(dir, state, setState) {
|
|
|
235
252
|
* verdicts, keeps `pauseEvery` in state.json for PR Review Lab, and says whether the human's review
|
|
236
253
|
* is due. `setState(n, state, ref)` moves a block of the plan.
|
|
237
254
|
*/
|
|
238
|
-
export function reviewTurn(dir, every, setState) {
|
|
255
|
+
export function reviewTurn(dir, every, setState, parallel = null) {
|
|
239
256
|
const before = readState(dir);
|
|
240
257
|
const { state, applied } = applyVerdicts(dir, before, setState);
|
|
241
|
-
|
|
242
|
-
|
|
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
|
+
)
|
|
243
265
|
writeState(dir, next);
|
|
244
266
|
const { plan, log, verdicts } = reviewInputs(dir);
|
|
245
267
|
const waiting = blocksAwaitingReview(plan, log, verdicts);
|
|
246
268
|
return { applied, waiting, due: reviewDue(waiting, every) };
|
|
247
269
|
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Why a wrestler may not work ahead while the human reviews a cut, or null. Ahead means one block
|
|
273
|
+
* at a time (nothing else «en curso») on files declared up front that the cut under review does
|
|
274
|
+
* not touch, so the human's verdict on the cut never lands on code that moved. PURE.
|
|
275
|
+
*/
|
|
276
|
+
export function lookaheadRefusal({ globs, waitingFiles, running }) {
|
|
277
|
+
if (!globs.length)
|
|
278
|
+
return 'a block ahead of the review declares its files: --lookahead "<glob>,<glob>"';
|
|
279
|
+
if (running.length)
|
|
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})`;
|
|
281
|
+
const matchers = globs.map((glob) => globToRegExp(glob));
|
|
282
|
+
const overlap = waitingFiles.filter((file) =>
|
|
283
|
+
matchers.some((matcher) => matcher.test(file)),
|
|
284
|
+
);
|
|
285
|
+
if (overlap.length)
|
|
286
|
+
return `the block ahead would touch files of the cut under review: ${overlap.join(', ')}. Pick a block that does not, or wait for the verdict`;
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** The blocks «en curso» right now. PURE. */
|
|
291
|
+
export const runningBlocks = (plan) =>
|
|
292
|
+
parsePlan(plan)
|
|
293
|
+
.groups.flatMap((group) => group.items)
|
|
294
|
+
.filter((item) => item.state === 'en curso');
|
|
@@ -12,11 +12,15 @@ import { join } from 'node:path';
|
|
|
12
12
|
|
|
13
13
|
import { readState } from './config.mjs';
|
|
14
14
|
import { scriptJson } from './contract-endpoints.mjs';
|
|
15
|
+
import { runningLeases, startLaneRefusal } from './lanes.mjs';
|
|
15
16
|
import {
|
|
16
17
|
authorEmail,
|
|
17
18
|
blockFacts,
|
|
18
19
|
factsForState,
|
|
20
|
+
blocksAwaitingReview,
|
|
21
|
+
parseVerdicts,
|
|
19
22
|
startRefusal,
|
|
23
|
+
VERDICTS,
|
|
20
24
|
} from './review-blocks.mjs';
|
|
21
25
|
import {
|
|
22
26
|
parsePlan,
|
|
@@ -39,7 +43,7 @@ export function setPlanState(
|
|
|
39
43
|
state,
|
|
40
44
|
ref = null,
|
|
41
45
|
at = new Date().toISOString(),
|
|
42
|
-
{ range = null, estimate = null, requireEstimate = false } = {},
|
|
46
|
+
{ range = null, estimate = null, requireEstimate = false, lanes = null } = {},
|
|
43
47
|
) {
|
|
44
48
|
const file = join(dir, PLAN);
|
|
45
49
|
const markdown = read(file);
|
|
@@ -68,6 +72,26 @@ export function setPlanState(
|
|
|
68
72
|
? startRefusal(facts, next, item.n)
|
|
69
73
|
: null;
|
|
70
74
|
if (refusal) throw new Error(refusal);
|
|
75
|
+
const laneRefusal =
|
|
76
|
+
lanes && next === 'en curso' && next !== item.state
|
|
77
|
+
? startLaneRefusal({
|
|
78
|
+
n: item.n,
|
|
79
|
+
files: facts.files,
|
|
80
|
+
running: runningLeases(
|
|
81
|
+
markdown,
|
|
82
|
+
parsePlanLog(read(join(dir, PLAN_LOG))),
|
|
83
|
+
),
|
|
84
|
+
parallel: lanes.parallel,
|
|
85
|
+
tracked: trackedFiles(worktree),
|
|
86
|
+
generated: lanes.generated,
|
|
87
|
+
waiting: blocksAwaitingReview(
|
|
88
|
+
markdown,
|
|
89
|
+
parsePlanLog(read(join(dir, PLAN_LOG))),
|
|
90
|
+
parseVerdicts(read(join(dir, VERDICTS)) ?? ''),
|
|
91
|
+
),
|
|
92
|
+
})
|
|
93
|
+
: null;
|
|
94
|
+
if (laneRefusal) throw new Error(laneRefusal);
|
|
71
95
|
writeFileSync(file, withPlanState(markdown, item, next));
|
|
72
96
|
appendFileSync(
|
|
73
97
|
join(dir, PLAN_LOG),
|
|
@@ -76,6 +100,21 @@ export function setPlanState(
|
|
|
76
100
|
return { ...item, from: item.state, to: next, facts };
|
|
77
101
|
}
|
|
78
102
|
|
|
103
|
+
/** The files git tracks in the worktree (none when it is gone). */
|
|
104
|
+
function trackedFiles(worktree) {
|
|
105
|
+
try {
|
|
106
|
+
return execFileSync('git', ['-C', worktree, 'ls-files'], {
|
|
107
|
+
encoding: 'utf8',
|
|
108
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
109
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
110
|
+
})
|
|
111
|
+
.split('\n')
|
|
112
|
+
.filter(Boolean);
|
|
113
|
+
} catch {
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
79
118
|
/** The worktree's head, or null when it is gone. */
|
|
80
119
|
function worktreeHead(worktree) {
|
|
81
120
|
try {
|
|
@@ -199,7 +199,8 @@ way out, and it refuses without a KO. Then the bout runs as always, with three i
|
|
|
199
199
|
- each new range: `brief.mjs cornerman <pr> --mode range --vars <file>` (≤15 lines; the range
|
|
200
200
|
is `lastReviewed..HEAD` unless you pass `--range`; your value: `claims`);
|
|
201
201
|
- when the wrestler closes a concept: `--mode concept` (your values: `invariants`, numbered,
|
|
202
|
-
|
|
202
|
+
always including «no question is asked again downstream for the same reason» (PR31), and
|
|
203
|
+
`claims`; `designReference` at a UI venue), over ranges already reviewed one by one. It
|
|
203
204
|
also reads the concept as one piece through the venue's lenses: what no single range shows;
|
|
204
205
|
- a design review: `--mode batch --files <f1,f2,…>`.
|
|
205
206
|
**A concept ends its wrestler.** When the concept review approves it, run
|
|
@@ -219,7 +220,26 @@ way out, and it refuses without a KO. Then the bout runs as always, with three i
|
|
|
219
220
|
`humanReviewEvery` blocks waiting, brief.mjs stops: the human reviews that cut in PR Review Lab
|
|
220
221
|
(bout mode) and signs a verdict with a Tape. The next brief applies it: a pass closes the
|
|
221
222
|
blocks, a fail comes back as a rebound the next wrestler fixes first. Do not work around the
|
|
222
|
-
pause.
|
|
223
|
+
pause. **Work ahead while the human reviews:** one wrestler may take ONE more block whose files
|
|
224
|
+
the cut does not touch: `brief.mjs wrestler <pr> --mode resume --vars <file> --lookahead
|
|
225
|
+
"<glob>,<glob>"` (the files that block will touch). brief.mjs refuses it when those globs match a
|
|
226
|
+
file of the cut, or when another block is already «en curso». Its brief says which files it may
|
|
227
|
+
touch; a rebound from the verdict still goes first.
|
|
228
|
+
**Blocks side by side (`parallelBlocks`, 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
|
|
232
|
+
other nor share a contract (props, a hook's return, i18n keys, a helper): a file lock does not
|
|
233
|
+
see that, you do. Start each with its files: `judges-card.mjs plan <pr> <n> "en curso"
|
|
234
|
+
--minutes <m> --priority <p> --files "<glob>,<glob>"`; the script refuses a start whose files a
|
|
235
|
+
running block holds. Brief each with `--block <n>` (its brief lists its files and the other
|
|
236
|
+
wrestler's). mask-guard refuses a commit that stages another block's file; a wrestler that needs
|
|
237
|
+
one stops and asks you: decide who keeps it (narrow one block's `--files` with `plan <pr>
|
|
238
|
+
estimate <n> --files …`). Close each with `judges-card.mjs concept <pr> "<name>" --range <A>..<B>
|
|
239
|
+
--block <n>`: only that block's wrestler retires. The review cap counts both wrestlers' commits:
|
|
240
|
+
review the shared range as usual. **When the wave closes** (both blocks wait on the judge),
|
|
241
|
+
regenerate the `generatedPaths` files once and commit them yourself, then run the typecheck and
|
|
242
|
+
the tests of both blocks on the shared tip before the human's cut.
|
|
223
243
|
If the wrestler claimed something ("does not change counter X"), hand the claim to the
|
|
224
244
|
cornerman to verify against the base. The cornerman always checks the format of the range
|
|
225
245
|
(`format-gate.mjs check`); an unclean file is a finding like any other, fixed by the wrestler
|
|
@@ -491,6 +511,8 @@ logs there; whichever session regenerates the page passes every bout dir of the
|
|
|
491
511
|
|
|
492
512
|
## 4 · `close`
|
|
493
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
|
+
|
|
494
516
|
When the PR gets an automated review (GitHub Copilot), converge it with
|
|
495
517
|
`/ring:title-defense <pr> [--max-rounds N]` before or during `close`: it catalogues the findings
|
|
496
518
|
and runs the fixes through this same cycle (§2).
|
|
@@ -541,7 +563,7 @@ and runs the fixes through this same cycle (§2).
|
|
|
541
563
|
|
|
542
564
|
## 5 · `resume [<pr>]`
|
|
543
565
|
|
|
544
|
-
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>"`.
|
|
545
567
|
2. `node "$RING/scripts/judges-card.mjs" summary <pr>`: state (with the bout's venue),
|
|
546
568
|
unreviewed commits, open rebounds, decisions and the judges card, and, when Engram answers,
|
|
547
569
|
the repo's latest fouls, verdicts and precedents from earlier bouts (do not repeat those fouls;
|
|
@@ -47,6 +47,12 @@ _Why: after the first approved slice was cited as the reference, the next one dr
|
|
|
47
47
|
**PR11. Under doubt between two reasonable options, keep current behavior and wire contracts; ticket
|
|
48
48
|
the rest.** _Why: a refactor that also "improves" behavior cannot be validated against the base._
|
|
49
49
|
|
|
50
|
+
**PR31. A null or an undefined that crosses more than one function is settled where it is born.**
|
|
51
|
+
With a default, a value that cannot be missing, or a Null Object; never asked again downstream for
|
|
52
|
+
the same reason. Count how many times the range asks the same question: three or more is a finding.
|
|
53
|
+
_Why: in agents-studio-fe#606 «did the call connect?» was asked six times downstream; each range
|
|
54
|
+
review approved its piece, the human rejected the cut, and the first rebound only moved the cascade._
|
|
55
|
+
|
|
50
56
|
## Process
|
|
51
57
|
|
|
52
58
|
**PR12. Small reviewed slices first, polish later.** No automated review loops or convergence before
|
|
@@ -32,7 +32,9 @@ WAITING ON THE HUMAN (hard list; do not touch): {{waitingOnHuman}}.
|
|
|
32
32
|
Design doubts go to me, never to the human: I decide them (or escalate them) and tell you.
|
|
33
33
|
|
|
34
34
|
Gates (from `.claude/ring.json`, each through one-at-a-time; a long run such as a battery adds
|
|
35
|
-
`--monitor <id>` when its command does not match a declared monitor, so it shows on the Scorecard)
|
|
35
|
+
`--monitor <id>` when its command does not match a declared monitor, so it shows on the Scorecard).
|
|
36
|
+
Run them when the block's work is done, before you report it, on the files you touched; not after
|
|
37
|
+
every edit (a half-written block fails for no reason and holds the machine):
|
|
36
38
|
|
|
37
39
|
- typecheck: `{{gateTypecheck}}`
|
|
38
40
|
- tests of what you touched: `{{gateTest}}`
|
|
@@ -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),
|