@thebattlearena/corner 0.10.1-beta.78 → 0.10.1-beta.79

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.
@@ -50,6 +50,17 @@
50
50
  // State lives in ~/.claude/ring/<owner>/<repo>/<pr>/ (RING_HOME overrides the root), never in the
51
51
  // session scratchpad: that one is wiped when the session restarts. state.json stays minimal (worktree, branch, base, lastReviewed,
52
52
  // active, venue); a state without venue is a backend bout, from before venues existed; round-bells are read from GitHub and the tip from git, so they never go stale here.
53
+ import { archConfig, archStatus, SETUP_HINT } from './arch-gate.mjs';
54
+ import {
55
+ conceptsSinceRelay,
56
+ liveLines,
57
+ relayDue,
58
+ undecidedProposals,
59
+ withConceptClosed,
60
+ withProposalDecision,
61
+ wrestlerIds,
62
+ } from './relay.mjs';
63
+ import { resumeSection } from './compact-hook.mjs';
53
64
  import { boutFormatGate } from './format-gate.mjs';
54
65
  import { execFileSync } from 'node:child_process';
55
66
  import {
@@ -103,6 +114,7 @@ import {
103
114
  unloggedEscalationNotice,
104
115
  } from './preparation.mjs';
105
116
  import { handOffWarning, withChronicleUrl } from './sparring-chronicle.mjs';
117
+ import { refereeRecall, syncAfter } from './engram.mjs';
106
118
  import { parsePlan, PLAN } from './tech-plan.mjs';
107
119
  import { addPlanItem, setPlanState } from './tech-plan-log.mjs';
108
120
  import { logPageVerdicts } from './verdicts-log.mjs';
@@ -237,10 +249,24 @@ function open(argv) {
237
249
  dir,
238
250
  }),
239
251
  );
252
+ warnWithoutArchGate(worktree, venue);
240
253
  console.log(dir);
241
254
  }
242
255
 
243
- function summary([pr]) {
256
+ /** A bout in a repo with no architecture gate reviews layers only by eye: say so when it opens. */
257
+ function warnWithoutArchGate(worktree, venue) {
258
+ try {
259
+ const config = archConfig(worktree, venue);
260
+ if (archStatus(worktree, config).configured) return;
261
+ console.error(
262
+ `judges-card: WARNING: this repo has no architecture gate, so layer and dependency rules are reviewed only by eye; ${SETUP_HINT}.`,
263
+ );
264
+ } catch {
265
+ // The warning is advice: a repo the gate cannot read still gets its bout.
266
+ }
267
+ }
268
+
269
+ async function summary([pr]) {
244
270
  const dir = currentDir(pr);
245
271
  const state = readState(dir);
246
272
  const tip = git(state.worktree, ['rev-parse', 'HEAD']);
@@ -283,6 +309,22 @@ function summary([pr]) {
283
309
  read('judges-card.md').trim(),
284
310
  ].join('\n'),
285
311
  );
312
+ const { config } = loadConfig(projectDir(), {
313
+ contract: false,
314
+ venue: state.venue ?? DEFAULT_VENUE,
315
+ });
316
+ const learned = await refereeRecall(config, {
317
+ slug: repoSlug(projectDir()),
318
+ pr,
319
+ });
320
+ if (learned)
321
+ console.log(
322
+ [
323
+ '',
324
+ '## Learned in earlier bouts of this repo (Engram: fouls not to repeat, verdicts, precedents)',
325
+ learned,
326
+ ].join('\n'),
327
+ );
286
328
  }
287
329
 
288
330
  /**
@@ -609,6 +651,11 @@ function close([pr]) {
609
651
  const dir = currentDir(pr);
610
652
  const blocked = auditBlocks(readLedger(dir), { closing: true });
611
653
  if (blocked) throw new Error(`bout #${pr} does not close yet: ${blocked}`);
654
+ const proposals = undecidedProposals(
655
+ readFileSync(join(dir, 'judges-card.md'), 'utf8'),
656
+ );
657
+ if (proposals)
658
+ throw new Error(`bout #${pr} does not close yet: ${proposals}`);
612
659
  const state = readState(dir);
613
660
  const tip = state.lastApproved ?? state.lastReviewed;
614
661
  if (tip) {
@@ -859,8 +906,99 @@ function playByPlayUrl([pr, url]) {
859
906
  );
860
907
  }
861
908
 
909
+ /**
910
+ * A concept closed after its review: recorded, and the wrestlers that carried it retired. The next
911
+ * concept goes to a fresh wrestler; every `relayEvery` concepts the referee relays first.
912
+ */
913
+ function concept([pr, name, ...argv]) {
914
+ const { range } = options(argv);
915
+ const dir = currentDir(pr);
916
+ const state = readState(dir);
917
+ const next = withConceptClosed(state, {
918
+ name,
919
+ range,
920
+ at: new Date().toISOString(),
921
+ wrestlers: wrestlerIds(liveLines(dir)),
922
+ });
923
+ writeState(dir, next);
924
+ const { config } = loadConfig(projectDir(), {
925
+ contract: false,
926
+ venue: state.venue ?? DEFAULT_VENUE,
927
+ });
928
+ const steps = [
929
+ `concept ${next.concepts.length} «${name}» closed (${range}); ${next.retiredWrestlers.length} wrestler(s) retired: do not send them the next concept.`,
930
+ relayDue(next, config.relayEvery)
931
+ ? `Relay first (${conceptsSinceRelay(next)} concepts since the last one): rewrite the card's «How to resume» so a new session or a compaction resumes from it, then judges-card.mjs relay ${pr}. brief.mjs briefs no wrestler until then.`
932
+ : null,
933
+ `Next concept: brief.mjs wrestler ${pr} --mode resume --vars <file with nextConcept>, launched with prompt "ring-brief: <path>".`,
934
+ ];
935
+ console.log(steps.filter(Boolean).join('\n'));
936
+ }
937
+
938
+ /** The referee's relay: the card's «How to resume» is current, written after the last concept. */
939
+ function relay([pr]) {
940
+ const dir = currentDir(pr);
941
+ const state = readState(dir);
942
+ const last = (state.concepts ?? []).at(-1);
943
+ const cardPath = join(dir, 'judges-card.md');
944
+ const resume = resumeSection(readFileSync(cardPath, 'utf8'), 20000);
945
+ const names = last
946
+ ? [
947
+ last.name,
948
+ last.range,
949
+ ...last.range.split('..').filter((sha) => sha.length >= 7),
950
+ ]
951
+ : [];
952
+ if (last && !names.some((name) => name && resume.includes(name)))
953
+ throw new Error(
954
+ `the card's «How to resume» does not mention the last concept («${last.name}», ${last.range}): write where the bout is, what is next and what waits on the human, then relay`,
955
+ );
956
+ if (last && statSync(cardPath).mtimeMs < Date.parse(last.at))
957
+ throw new Error(
958
+ `the card was not touched since concept «${last.name}» closed: rewrite its «How to resume» first`,
959
+ );
960
+ const next = {
961
+ ...state,
962
+ relays: [
963
+ ...(state.relays ?? []),
964
+ {
965
+ at: new Date().toISOString(),
966
+ afterConcepts: (state.concepts ?? []).length,
967
+ },
968
+ ],
969
+ };
970
+ writeState(dir, next);
971
+ console.log(
972
+ `relay recorded after ${(state.concepts ?? []).length} concept(s); the next wrestler can be briefed.`,
973
+ );
974
+ }
975
+
976
+ /** A rulebook proposal of the card, accepted or rejected with its reason. */
977
+ function proposal([pr, n, verdict, ...argv]) {
978
+ const { why } = options(argv);
979
+ const dir = currentDir(pr);
980
+ const cardPath = join(dir, 'judges-card.md');
981
+ const decided = { accept: 'accepted', reject: 'rejected' }[verdict];
982
+ if (!decided)
983
+ throw new Error('usage: proposal <pr> <n> accept|reject --why "<why>"');
984
+ writeFileSync(
985
+ cardPath,
986
+ withProposalDecision(
987
+ readFileSync(cardPath, 'utf8'),
988
+ n,
989
+ decided,
990
+ why,
991
+ new Date().toISOString().slice(0, 10),
992
+ ),
993
+ );
994
+ console.log(`proposal ${n} ${decided}`);
995
+ }
996
+
862
997
  const COMMANDS = {
863
998
  open,
999
+ concept,
1000
+ relay,
1001
+ proposal,
864
1002
  'play-by-play-url': playByPlayUrl,
865
1003
  'page-answers': pageAnswers,
866
1004
  'page-verdicts': pageVerdicts,
@@ -880,12 +1018,15 @@ if (isMain(import.meta.url)) {
880
1018
  const run = COMMANDS[command];
881
1019
  if (!run) {
882
1020
  console.error(
883
- 'Usage: node judges-card.mjs open|summary|log|did|list|path|close|approve-contract|title-defense|play-by-play-url|page-answers|page-verdicts|plan …',
1021
+ '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 …',
884
1022
  );
885
1023
  process.exit(1);
886
1024
  }
887
1025
  try {
888
- run(rest);
1026
+ await run(rest);
1027
+ // What the bout learned goes to Engram after each command that may change it (quiet without it).
1028
+ if (['log', 'concept', 'relay', 'proposal', 'close'].includes(command))
1029
+ await syncAfter(rest[0]);
889
1030
  } catch (error) {
890
1031
  console.error(`judges-card: ${error.message}`);
891
1032
  process.exit(1);
@@ -10,6 +10,7 @@
10
10
  //
11
11
  // Exit 2 with the reason blocks the command. If the guard itself fails while a bout may be active,
12
12
  // it blocks too: a guard that cannot evaluate must not let the command through.
13
+ import { boutArchGate } from './arch-gate.mjs';
13
14
  import { boutFormatGate } from './format-gate.mjs';
14
15
  import { execFileSync } from 'node:child_process';
15
16
  import { existsSync, readFileSync, realpathSync } from 'node:fs';
@@ -881,6 +882,18 @@ const gitRules = [
881
882
  return `The PR branch ${branch} only receives formatted work: ${reason}`;
882
883
  },
883
884
  },
885
+ {
886
+ // Third line: what goes up to the PR branch adds no import a layer may not make (P14).
887
+ when: (git, ctx) =>
888
+ git.sub === 'push' && ctx.archBeforePush(git, pushRefspecs(git, ctx)),
889
+ reason: (git, ctx) => {
890
+ const { branch, reason } = ctx.archBeforePush(
891
+ git,
892
+ pushRefspecs(git, ctx),
893
+ );
894
+ return `The PR branch ${branch} only receives work that respects the repo's layers: ${reason}`;
895
+ },
896
+ },
884
897
  {
885
898
  when: (git, ctx) =>
886
899
  git.sub === 'push' && ctx.findingsBeforePush(git, pushRefspecs(git, ctx)),
@@ -1283,6 +1296,8 @@ function judgeSegment(segment, words, cwd, ctx) {
1283
1296
  ctx.findingsBeforePushAt(context.path(push.dir), refspecs),
1284
1297
  formatBeforePush: (push, refspecs) =>
1285
1298
  ctx.formatBeforePushAt(context.path(push.dir), refspecs),
1299
+ archBeforePush: (push, refspecs) =>
1300
+ ctx.archBeforePushAt(context.path(push.dir), refspecs),
1286
1301
  isBackupBranch: (push, target) =>
1287
1302
  ctx
1288
1303
  .backupBranchesAt(context.path(push.dir))
@@ -1574,6 +1589,24 @@ function realContext(cwd) {
1574
1589
  }
1575
1590
  return null;
1576
1591
  },
1592
+ // The first push to a bout's PR branch whose range adds an import its layers forbid, or null.
1593
+ // A repo with no gate configured passes (judges-card open warned about it).
1594
+ archBeforePushAt: (dir, refspecs) => {
1595
+ const slug = insideGitRepo(dirOf(dir)) ? slugOf(dirOf(dir)) : null;
1596
+ if (!slug) return null;
1597
+ for (const { source, target } of refspecs) {
1598
+ const bout = boutsOf(slug).find(({ state }) => state.branch === target);
1599
+ if (!bout || source === null) continue;
1600
+ const sha = git(dirOf(dir), [
1601
+ 'rev-parse',
1602
+ '--verify',
1603
+ `${source}^{commit}`,
1604
+ ]);
1605
+ const { refusal } = boutArchGate(bout.state, sha);
1606
+ if (refusal) return { branch: target, reason: refusal };
1607
+ }
1608
+ return null;
1609
+ },
1577
1610
  contractApprovals: (where) => {
1578
1611
  const slug = slugWhere(where);
1579
1612
  if (!slug) return {};
@@ -0,0 +1,127 @@
1
+ // The bout's continuity: one fresh wrestler per concept, the referee's relay every few concepts, and
2
+ // no close while a rulebook proposal waits for its decision. Zero-dependency.
3
+ //
4
+ // A wrestler that carried a concept carries its context too: after `judges-card.mjs concept`, the
5
+ // next concept goes to a new wrestler (`brief.mjs wrestler <pr> --mode resume`), and brief-guard
6
+ // warns (or blocks) a SendMessage to a retired one. Every `relayEvery` concepts the referee rewrites
7
+ // the card's «how to resume» and records it (`judges-card.mjs relay`): until then brief.mjs does not
8
+ // brief the next wrestler, so a compaction (the SessionStart hook puts that section back) or a new
9
+ // session always resumes from a card that is current. `judges-card.mjs close` refuses while a
10
+ // proposal of «For the rulebook / precedents» (an X0 included) has no decision.
11
+ import { readFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+
14
+ import { LIVE_LOG } from './live.mjs';
15
+
16
+ export const PROPOSALS = 'For the rulebook / precedents';
17
+ const DECIDED = /→\s*(accepted|rejected)\b/;
18
+
19
+ const sectionOf = (lines, heading) => {
20
+ const start = lines.findIndex((line) => line.startsWith(`## ${heading}`));
21
+ if (start < 0) return null;
22
+ const rest = lines.findIndex((line, i) => i > start && /^##\s/.test(line));
23
+ return { start, end: rest < 0 ? lines.length : rest };
24
+ };
25
+
26
+ /**
27
+ * The proposals of the card's «For the rulebook / precedents» section, in order: each item is a
28
+ * `- ` line with its indented continuation, minus the template's `- <…>` placeholder. PURE.
29
+ */
30
+ export function proposalsOf(card) {
31
+ const lines = String(card ?? '').split('\n');
32
+ const section = sectionOf(lines, PROPOSALS);
33
+ if (!section) return [];
34
+ const items = [];
35
+ let current = null;
36
+ for (let i = section.start + 1; i < section.end; i += 1) {
37
+ const line = lines[i];
38
+ if (line.startsWith('- ')) {
39
+ current = line.startsWith('- <') ? null : { at: i, text: line };
40
+ if (current) items.push(current);
41
+ } else if (current && /^\s+\S/.test(line)) current.text += `\n${line}`;
42
+ else current = null;
43
+ }
44
+ return items.map((item, index) => ({
45
+ n: index + 1,
46
+ line: item.at,
47
+ text: item.text,
48
+ decided: DECIDED.test(item.text),
49
+ }));
50
+ }
51
+
52
+ /** The card with proposal `n` (1-based) decided: `→ accepted|rejected (<at>): <why>`. PURE. */
53
+ export function withProposalDecision(card, n, verdict, why, at) {
54
+ if (!['accepted', 'rejected'].includes(verdict))
55
+ throw new Error('a proposal is accept or reject');
56
+ if (!why?.trim()) throw new Error('a decision on a proposal needs --why');
57
+ const proposal = proposalsOf(card).find((item) => item.n === Number(n));
58
+ if (!proposal) throw new Error(`there is no proposal ${n} in the card`);
59
+ if (proposal.decided)
60
+ throw new Error(`proposal ${n} is already decided: ${proposal.text}`);
61
+ const lines = String(card).split('\n');
62
+ lines[proposal.line] =
63
+ `${lines[proposal.line]} → ${verdict} (${at}): ${why.trim()}`;
64
+ return lines.join('\n');
65
+ }
66
+
67
+ /** Why the bout cannot close for its proposals, or null. PURE. */
68
+ export function undecidedProposals(card) {
69
+ const open = proposalsOf(card).filter((item) => !item.decided);
70
+ if (!open.length) return null;
71
+ return `${open.length} rulebook proposal(s) without a decision: ${open
72
+ .map((item) => `${item.n}. ${item.text.split('\n')[0].slice(2, 120)}`)
73
+ .join(
74
+ ' · ',
75
+ )}. Decide each with judges-card.mjs proposal <pr> <n> accept|reject --why "<why>" (an accepted one goes to the precedents in its own PR)`;
76
+ }
77
+
78
+ /** Concepts closed since the last relay. PURE. */
79
+ export const conceptsSinceRelay = (state) => {
80
+ const concepts = state.concepts ?? [];
81
+ const last = (state.relays ?? []).at(-1);
82
+ return last ? concepts.length - last.afterConcepts : concepts.length;
83
+ };
84
+
85
+ /** Is a relay due before the next wrestler? `every` null or 0 turns it off. PURE. */
86
+ export const relayDue = (state, every) =>
87
+ Boolean(every) && conceptsSinceRelay(state) >= every;
88
+
89
+ /** The wrestler agent ids a bout's live feed has seen (what a concept close retires). PURE. */
90
+ export function wrestlerIds(lines) {
91
+ const ids = new Set();
92
+ for (const line of lines) {
93
+ try {
94
+ const event = JSON.parse(line);
95
+ const id = /^agent (\S+)$/.exec(event.note ?? '')?.[1];
96
+ if (event.actor === 'luchador' && id) ids.add(id);
97
+ } catch {
98
+ // A torn line is skipped.
99
+ }
100
+ }
101
+ return [...ids];
102
+ }
103
+
104
+ /** The state after a concept closes: the concept recorded and its wrestlers retired. PURE. */
105
+ export function withConceptClosed(state, { name, range, at, wrestlers }) {
106
+ if (!name?.trim()) throw new Error('a concept needs its name');
107
+ if (!/^\S+\.\.\S+$/.test(range ?? ''))
108
+ throw new Error('a concept needs its reviewed range: --range <from>..<to>');
109
+ return {
110
+ ...state,
111
+ concepts: [...(state.concepts ?? []), { name: name.trim(), range, at }],
112
+ retiredWrestlers: [
113
+ ...new Set([...(state.retiredWrestlers ?? []), ...wrestlers]),
114
+ ],
115
+ };
116
+ }
117
+
118
+ /** The live lines of a bout, or none. */
119
+ export function liveLines(dir) {
120
+ try {
121
+ return readFileSync(join(dir, LIVE_LOG), 'utf8')
122
+ .split('\n')
123
+ .filter(Boolean);
124
+ } catch {
125
+ return [];
126
+ }
127
+ }
@@ -43,6 +43,11 @@ theirs may be decided by you. It runs on top of an active bout: without one, say
43
43
  - **Keep the cycle**: every range reviewed by a cornerman at effort high, filtered against the
44
44
  base, one rebound package, round-bells opened as concepts close (they wait for the human; that
45
45
  is fine).
46
+ - **A fresh wrestler per concept, and a relay every few**: a long night is where a wrestler's and a
47
+ referee's context grow stale. Close each approved concept with `judges-card.mjs concept`, brief
48
+ the next one to a new wrestler (`--mode resume`), and when `brief.mjs` says a relay is due,
49
+ rewrite the card's «How to resume» and run `judges-card.mjs relay` before going on. When the
50
+ session compacts, the SessionStart hook puts that section back: it is all you keep.
46
51
  - **Red gates are classified**, not silenced: code (rebound to the wrestler), environment (note it
47
52
  and retry once), harness (note it). A coverage gap is fixed with real tests, never with a test
48
53
  that only imports a file (T8). A flag decision and a PR over the host's size ceiling (how to
@@ -201,6 +201,15 @@ way out, and it refuses without a KO. Then the bout runs as always, with three i
201
201
  and `claims`; `designReference` at a UI venue), over ranges already reviewed one by one. It
202
202
  also reads the concept as one piece through the venue's lenses: what no single range shows;
203
203
  - a design review: `--mode batch --files <f1,f2,…>`.
204
+ **A concept ends its wrestler.** When the concept review approves it, run
205
+ `judges-card.mjs concept <pr> "<concept>" --range <first>..<last>`: it records the concept and
206
+ retires the wrestlers that carried it (a long-lived wrestler drags the earlier concepts'
207
+ context and repeats their mistakes). The next concept goes to a fresh wrestler:
208
+ `brief.mjs wrestler <pr> --mode resume --vars <file>` with `nextConcept`; brief-guard warns
209
+ (or, under `briefGuard: "block"`, refuses) a SendMessage to a retired one. Every `relayEvery`
210
+ concepts (4 by default) you relay first: rewrite the card's «How to resume» (where the bout is,
211
+ what is next, what waits on the human) and run `judges-card.mjs relay <pr>`; until then
212
+ `brief.mjs` briefs no wrestler. That is what a compaction or a new session resumes from.
204
213
  If the wrestler claimed something ("does not change counter X"), hand the claim to the
205
214
  cornerman to verify against the base. The cornerman always checks the format of the range
206
215
  (`format-gate.mjs check`); an unclean file is a finding like any other, fixed by the wrestler
@@ -504,8 +513,10 @@ and runs the fixes through this same cycle (§2).
504
513
  who, when), validation (SHA, command, result), what is not verified yet. Show it to the human;
505
514
  apply it with `gh pr edit <pr> --body-file …` only when they say so, then check it with
506
515
  `gh pr view <pr> --json title,body`.
507
- 8. **Precedents**: show the human the proposals in "For the rulebook / precedents". For the ones
508
- they accept, offer a small separate PR that appends them to the host precedents file
516
+ 8. **Precedents**: show the human the proposals in "For the rulebook / precedents" (the `X0`
517
+ findings land there too) and record each answer with
518
+ `judges-card.mjs proposal <pr> <n> accept|reject --why "<what they said>"`: `judges-card.mjs
519
+ close` refuses while one has no decision. For the ones they accept, offer a small separate PR that appends them to the host precedents file
509
520
  (`config.precedentsPath`, or a new `.claude/ring-precedents.md`) with the next HPn ids. Never
510
521
  commit that file on your own.
511
522
  9. **List, never close**: `node "$RING/scripts/round-bell.mjs" list <pr>`, `git worktree list`
@@ -522,7 +533,9 @@ and runs the fixes through this same cycle (§2).
522
533
 
523
534
  1. Without `<pr>`: `node "$RING/scripts/judges-card.mjs" list`; take the only active bout or ask.
524
535
  2. `node "$RING/scripts/judges-card.mjs" summary <pr>`: state (with the bout's venue),
525
- unreviewed commits, open rebounds, decisions and the judges card. Read the rulebook and both precedent files in full again (§0.4).
536
+ unreviewed commits, open rebounds, decisions and the judges card, and, when Engram answers,
537
+ the repo's latest fouls, verdicts and precedents from earlier bouts (do not repeat those fouls;
538
+ they are context, never rules). Read the rulebook and both precedent files in full again (§0.4).
526
539
  3. Review the unreviewed commits first (§2.1–2.4).
527
540
  4. A new session cannot talk to the old wrestler: its agent id does not survive. Relaunch it
528
541
  **without `isolation`**, on the existing worktree, with