@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.
- package/README.md +57 -1
- package/package.json +2 -2
- package/ring/agents/cornerman.md +7 -0
- package/ring/hooks/hooks.json +1 -1
- package/ring/scripts/arch-gate.mjs +407 -0
- package/ring/scripts/brief-guard.mjs +46 -2
- package/ring/scripts/brief.mjs +18 -2
- package/ring/scripts/config.mjs +20 -0
- package/ring/scripts/engram-memory.mjs +213 -0
- package/ring/scripts/engram.mjs +318 -0
- package/ring/scripts/findings.mjs +37 -4
- package/ring/scripts/judges-card.mjs +144 -3
- package/ring/scripts/mask-guard.mjs +33 -0
- package/ring/scripts/relay.mjs +127 -0
- package/ring/skills/no-surrender/SKILL.md +5 -0
- package/ring/skills/referee/SKILL.md +16 -3
package/ring/scripts/config.mjs
CHANGED
|
@@ -142,6 +142,26 @@ export const DEFAULTS = Object.freeze({
|
|
|
142
142
|
// brief-guard on every Agent launch of a wrestler or cornerman in a bout: 'warn' lets a brief
|
|
143
143
|
// that brief.mjs did not generate through and says so; 'block' refuses it; 'off' does nothing.
|
|
144
144
|
briefGuard: 'warn',
|
|
145
|
+
// The architecture gate (arch-gate.mjs): the repo's layers, each { name, paths (globs),
|
|
146
|
+
// mayImport (layer names), forbid (package regexes) }. Empty: only the repo's dependency-cruiser.
|
|
147
|
+
layers: [],
|
|
148
|
+
// Files the layer check leaves out (null: tests, `*.spec.*`, `*.test.*`, `__tests__/`, `test/`).
|
|
149
|
+
layersIgnore: null,
|
|
150
|
+
// Globs where every NEW code file must fall in a layer (arch-gate flags the ones that do not).
|
|
151
|
+
layersRequired: [],
|
|
152
|
+
// Findings that cite one of these rules are never sent to a ticket or discarded by the referee
|
|
153
|
+
// alone: `findings.mjs ticket|discard` asks for --judge "<what the human said>". A cleanup bout
|
|
154
|
+
// adds the architecture rules (["P14", "R6", "R11", "R12", "R19", "R22"]).
|
|
155
|
+
findingsNeedJudge: ['P14'],
|
|
156
|
+
// Concepts after which the referee relays (rewrites the card's «How to resume» and runs
|
|
157
|
+
// judges-card.mjs relay) before brief.mjs briefs the next wrestler. null: never.
|
|
158
|
+
relayEvery: 4,
|
|
159
|
+
// Engram (github.com/Gentleman-Programming/engram), the memory of what earlier bouts learned:
|
|
160
|
+
// fouls, the judge's verdicts, accepted precedents, X0 findings and each bout's status. Never the
|
|
161
|
+
// rules. The scripts sync and read it when something answers at this URL (null: off). The
|
|
162
|
+
// project defaults to the repo name, as Engram names it.
|
|
163
|
+
engramUrl: 'http://127.0.0.1:7437',
|
|
164
|
+
engramProject: null,
|
|
145
165
|
// The model and effort of each ring agent, passed on every launch. Who writes runs on the
|
|
146
166
|
// cheaper model at high effort; who reviews keeps the strongest one (what slipped in bout #1223
|
|
147
167
|
// slipped in review). A host overrides a role in `.claude/ring.json` (`agents.wrestler.model`).
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// What a bout teaches, as Engram observations, and what a brief reads back. PURE, zero-dependency;
|
|
2
|
+
// the HTTP side is engram.mjs.
|
|
3
|
+
//
|
|
4
|
+
// Engram keeps only what was LEARNED (fouls, the judge's verdicts and answers, accepted precedents,
|
|
5
|
+
// findings no rule covers) and the business context of each bout (where it is, what is next, its
|
|
6
|
+
// plan). The rules never go there: the rulebook, the precedents and the venues stay in files and
|
|
7
|
+
// brief.mjs embeds them in full, every time. What comes back from Engram is context, never a rule.
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
|
|
10
|
+
import { classifyDecision, parseDecisionLine } from './chronicle.mjs';
|
|
11
|
+
import { resumeSection } from './compact-hook.mjs';
|
|
12
|
+
import { proposalsOf } from './relay.mjs';
|
|
13
|
+
import { parsePlan } from './tech-plan.mjs';
|
|
14
|
+
|
|
15
|
+
/** The topic keys the ring upserts on: a bout's status, and each finding no rule covers. */
|
|
16
|
+
export const STATUS_TOPIC = 'ring/status/';
|
|
17
|
+
const X0_TOPIC = 'ring/x0/';
|
|
18
|
+
|
|
19
|
+
/** The `FOUL:` a foul's text opens with; its title already says Foul. */
|
|
20
|
+
const FOUL_MARK = /^\s*(?:FOUL|falta)\s*[:·—-]\s*/i;
|
|
21
|
+
|
|
22
|
+
/** A judge line that only stamps an approval (the contract's file hashes): nothing learned. */
|
|
23
|
+
const STAMP = /^contract approved\b/i;
|
|
24
|
+
|
|
25
|
+
const clip = (text, max) => {
|
|
26
|
+
const flat = String(text ?? '')
|
|
27
|
+
.replace(/\s+/g, ' ')
|
|
28
|
+
.trim();
|
|
29
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** `agents-studio-be#1223`: how every observation names its bout. PURE. */
|
|
33
|
+
export const boutTag = (slug, pr) => `${String(slug).split('/').at(-1)}#${pr}`;
|
|
34
|
+
|
|
35
|
+
/** The judge's and the fouls' lines of decisions.md. PURE. */
|
|
36
|
+
function decisionItems(decisions, bout) {
|
|
37
|
+
return String(decisions ?? '')
|
|
38
|
+
.split('\n')
|
|
39
|
+
.map(parseDecisionLine)
|
|
40
|
+
.filter((entry) => entry?.id && entry.text)
|
|
41
|
+
.map((entry) => ({ entry, kind: classifyDecision(entry) }))
|
|
42
|
+
.filter(({ kind }) => kind !== 'arbitro')
|
|
43
|
+
.filter(({ entry }) => !STAMP.test(entry.text))
|
|
44
|
+
.map(({ entry, kind }) => ({
|
|
45
|
+
type: kind === 'falta' ? 'learning' : 'decision',
|
|
46
|
+
title: `${kind === 'falta' ? 'Foul' : 'Judge'} · ${bout} · ${clip(entry.text.replace(FOUL_MARK, ''), 90)}`,
|
|
47
|
+
content: `${entry.text}\n\n${bout} · ${entry.id} · ${entry.at ?? 'no time'}`,
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The judge's answers to the referee's escalations, with what was asked. PURE. */
|
|
52
|
+
function answerItems(interview, bout) {
|
|
53
|
+
const asked = new Map(
|
|
54
|
+
interview
|
|
55
|
+
.filter((entry) => entry.kind === 'escalation')
|
|
56
|
+
.map((entry) => [entry.id, entry.body]),
|
|
57
|
+
);
|
|
58
|
+
return interview
|
|
59
|
+
.filter((entry) => entry.kind === 'answer' && entry.said)
|
|
60
|
+
.map((entry) => ({
|
|
61
|
+
type: 'decision',
|
|
62
|
+
title: `Judge · ${bout} · ${clip(entry.said, 90)}`,
|
|
63
|
+
content: [
|
|
64
|
+
entry.said,
|
|
65
|
+
`Asked (${entry.to}): ${clip(asked.get(entry.to), 600)}`,
|
|
66
|
+
`${bout} · ${entry.id} · ${entry.judge ?? 'the judge'} · ${entry.at}`,
|
|
67
|
+
].join('\n\n'),
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The rulebook proposals the bout accepted: precedents the next bouts should know. PURE. */
|
|
72
|
+
function precedentItems(card, bout) {
|
|
73
|
+
return proposalsOf(card)
|
|
74
|
+
.filter((proposal) => /→\s*accepted\b/.test(proposal.text))
|
|
75
|
+
.map((proposal) => ({
|
|
76
|
+
type: 'pattern',
|
|
77
|
+
title: `Precedent · ${bout} · ${clip(proposal.text.slice(2), 90)}`,
|
|
78
|
+
content: `${proposal.text.slice(2)}\n\n${bout} · proposal ${proposal.n}`,
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** A finding citing X0 in its rule field (or recorded with rule X0). PURE. */
|
|
83
|
+
const citesX0 = (finding) =>
|
|
84
|
+
finding.rule === 'X0' ||
|
|
85
|
+
/\bX0\b/.test(String(finding.text ?? '').split(' — ')[1] ?? '');
|
|
86
|
+
|
|
87
|
+
/** The findings no rule covers, each upserted on its own topic as its status moves. PURE. */
|
|
88
|
+
function x0Items(ledger, bout, repoPr) {
|
|
89
|
+
return (ledger?.findings ?? []).filter(citesX0).map((finding) => ({
|
|
90
|
+
type: 'discovery',
|
|
91
|
+
title: `No rule covers it · ${bout} · ${clip(finding.text, 90)}`,
|
|
92
|
+
content: [
|
|
93
|
+
finding.text,
|
|
94
|
+
`Status: ${[finding.status, finding.ticket, finding.decision, finding.reason].filter(Boolean).join(' · ')}`,
|
|
95
|
+
`${bout} · ${finding.id} · ${finding.at}`,
|
|
96
|
+
].join('\n\n'),
|
|
97
|
+
topic_key: `${X0_TOPIC}${repoPr}/${finding.id}`,
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The bout's business context: where it is, what is next, its plan and its concepts. PURE. */
|
|
102
|
+
function statusItem({ card, plan, state }, bout, repoPr) {
|
|
103
|
+
const resume = resumeSection(card, 2500);
|
|
104
|
+
const points = parsePlan(plan).groups.flatMap((group) =>
|
|
105
|
+
group.items.map(
|
|
106
|
+
(item) => `- [${item.state}] ${item.title} (${group.name})`,
|
|
107
|
+
),
|
|
108
|
+
);
|
|
109
|
+
const concepts = (state.concepts ?? []).map(
|
|
110
|
+
(concept, i) => `${i + 1}. ${concept.name} (${concept.range})`,
|
|
111
|
+
);
|
|
112
|
+
if (!resume && !points.length && !concepts.length) return [];
|
|
113
|
+
const content = [
|
|
114
|
+
`${state.active === false ? 'Closed' : 'Active'} · branch ${state.branch ?? '?'} on ${state.base ?? '?'}`,
|
|
115
|
+
resume && `How to resume:\n${resume}`,
|
|
116
|
+
points.length && `Plan:\n${points.join('\n')}`,
|
|
117
|
+
concepts.length && `Concepts closed:\n${concepts.join('\n')}`,
|
|
118
|
+
].filter(Boolean);
|
|
119
|
+
return [
|
|
120
|
+
{
|
|
121
|
+
type: 'discovery',
|
|
122
|
+
title: `Status · ${bout} · ${clip(state.branch ?? '', 60)}`,
|
|
123
|
+
content: content.join('\n\n'),
|
|
124
|
+
topic_key: `${STATUS_TOPIC}${repoPr}`,
|
|
125
|
+
},
|
|
126
|
+
];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Everything a bout has to give Engram, from its files as read: decisions.md, interview.jsonl
|
|
131
|
+
* entries, the judges card, findings.json, plan-tecnico.md and state.json. PURE.
|
|
132
|
+
*/
|
|
133
|
+
export function learnedItems({
|
|
134
|
+
slug,
|
|
135
|
+
pr,
|
|
136
|
+
decisions = '',
|
|
137
|
+
interview = [],
|
|
138
|
+
card = '',
|
|
139
|
+
ledger = null,
|
|
140
|
+
plan = '',
|
|
141
|
+
state = {},
|
|
142
|
+
}) {
|
|
143
|
+
const bout = boutTag(slug, pr);
|
|
144
|
+
const repoPr = `${String(slug).split('/').at(-1)}/${pr}`;
|
|
145
|
+
return [
|
|
146
|
+
...decisionItems(decisions, bout),
|
|
147
|
+
...answerItems(interview, bout),
|
|
148
|
+
...precedentItems(card, bout),
|
|
149
|
+
...x0Items(ledger, bout, repoPr),
|
|
150
|
+
...statusItem({ card, plan, state }, bout, repoPr),
|
|
151
|
+
];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The replay key of one save: the same item in the same project is the same operation, so a
|
|
156
|
+
* re-sync never duplicates it. PURE.
|
|
157
|
+
*/
|
|
158
|
+
export const operationId = (project, item) =>
|
|
159
|
+
`ring-${createHash('sha256')
|
|
160
|
+
.update(
|
|
161
|
+
JSON.stringify([
|
|
162
|
+
project,
|
|
163
|
+
item.type,
|
|
164
|
+
item.title,
|
|
165
|
+
item.content,
|
|
166
|
+
item.topic_key ?? '',
|
|
167
|
+
]),
|
|
168
|
+
)
|
|
169
|
+
.digest('hex')
|
|
170
|
+
.slice(0, 40)}`;
|
|
171
|
+
|
|
172
|
+
/** Path words that match everything: what a search on them would return is noise. */
|
|
173
|
+
const NOISE = new Set(
|
|
174
|
+
'apps src lib packages test tests spec specs index main module modules service services controller controllers handler handlers mapper mappers entity entities dto dtos domain application infrastructure typeorm common shared utils types components pages hooks'.split(
|
|
175
|
+
' ',
|
|
176
|
+
),
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
/** The words of the files of a range a search for earlier lessons uses, at most `max`. PURE. */
|
|
180
|
+
export function recallTerms(files, max = 24) {
|
|
181
|
+
const words = files.flatMap((file) =>
|
|
182
|
+
String(file)
|
|
183
|
+
.split('/')
|
|
184
|
+
.slice(-3)
|
|
185
|
+
.join(' ')
|
|
186
|
+
.replace(/\.[a-z]+$/i, '')
|
|
187
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
188
|
+
.split(/[^A-Za-z0-9]+/)
|
|
189
|
+
.map((word) => word.toLowerCase())
|
|
190
|
+
.filter((word) => word.length >= 4 && !NOISE.has(word)),
|
|
191
|
+
);
|
|
192
|
+
return [...new Set(words)].slice(0, max);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The «learned in earlier bouts» lines from Engram's compact search results: the bout's own
|
|
197
|
+
* observations and every bout's status left out (they are the card's job), at most `max`. PURE.
|
|
198
|
+
*/
|
|
199
|
+
export function learnedLines(results, { exclude, max = 8 } = {}) {
|
|
200
|
+
return (results ?? [])
|
|
201
|
+
.filter(
|
|
202
|
+
(result) => !(exclude && result.title?.includes(` · ${exclude} · `)),
|
|
203
|
+
)
|
|
204
|
+
.filter((result) => !result.topic_key?.startsWith(STATUS_TOPIC))
|
|
205
|
+
.slice(0, max)
|
|
206
|
+
.map((result) => {
|
|
207
|
+
// The title is «Kind · bout · start of the text» and the preview the text itself: one of each.
|
|
208
|
+
const [kind, bout] = String(result.title).split(' · ');
|
|
209
|
+
const text =
|
|
210
|
+
result.preview ?? result.title.split(' · ').slice(2).join(' · ');
|
|
211
|
+
return `- [${result.type}] ${kind} · ${bout} — ${clip(text, 280)} (engram #${result.id})`;
|
|
212
|
+
});
|
|
213
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The ring's optional memory across bouts: Engram (github.com/Gentleman-Programming/engram) over
|
|
3
|
+
// its local HTTP API. Zero-dependency; what goes in and what comes out is engram-memory.mjs.
|
|
4
|
+
//
|
|
5
|
+
// node engram.mjs status is Engram there, at which URL, for which project
|
|
6
|
+
// node engram.mjs sync <pr> | --all sends a bout (or every bout of this repo) to Engram
|
|
7
|
+
// node engram.mjs recall <pr> [--files a,b] what a brief of that bout would read back
|
|
8
|
+
//
|
|
9
|
+
// The scripts write and read, never an agent: judges-card.mjs and findings.mjs sync the bout after
|
|
10
|
+
// each command that changes what it learned, brief.mjs reads back the lessons on the files of the
|
|
11
|
+
// range, and `judges-card.mjs summary` the repo's latest fouls and verdicts. Every save carries an
|
|
12
|
+
// operation id derived from its content, so a sync repeats nothing, and the ids already sent are
|
|
13
|
+
// kept per Engram store in `<bout dir>/engram-sent.json`. Engram is optional: without it (`engramUrl: null`, or
|
|
14
|
+
// nothing listening) every call returns at once and the bout goes on exactly as before.
|
|
15
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
boutDir,
|
|
20
|
+
isMain,
|
|
21
|
+
loadConfig,
|
|
22
|
+
projectDir,
|
|
23
|
+
readState,
|
|
24
|
+
repoBouts,
|
|
25
|
+
repoSlug,
|
|
26
|
+
} from './config.mjs';
|
|
27
|
+
import {
|
|
28
|
+
boutTag,
|
|
29
|
+
learnedItems,
|
|
30
|
+
learnedLines,
|
|
31
|
+
operationId,
|
|
32
|
+
recallTerms,
|
|
33
|
+
} from './engram-memory.mjs';
|
|
34
|
+
import { INTERVIEW, parseInterview } from './preparation.mjs';
|
|
35
|
+
import { PLAN } from './tech-plan.mjs';
|
|
36
|
+
|
|
37
|
+
export const SENT = 'engram-sent.json';
|
|
38
|
+
const TIMEOUT_MS = 1500;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Where Engram is for this repo, or null when the host turned it off. RING_ENGRAM_URL overrides the
|
|
42
|
+
* host's URL (`off` turns it off: the ring's own tests never write into the developer's Engram).
|
|
43
|
+
*/
|
|
44
|
+
export function engramSettings(config, slug, env = process.env) {
|
|
45
|
+
const url = env.RING_ENGRAM_URL ?? config.engramUrl;
|
|
46
|
+
if (!url || url === 'off') return null;
|
|
47
|
+
return {
|
|
48
|
+
url: String(url).replace(/\/+$/, ''),
|
|
49
|
+
project: config.engramProject ?? String(slug).split('/').at(-1),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function call(settings, path, { method = 'GET', body } = {}) {
|
|
54
|
+
const response = await fetch(`${settings.url}${path}`, {
|
|
55
|
+
method,
|
|
56
|
+
headers: body ? { 'content-type': 'application/json' } : {},
|
|
57
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
58
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
59
|
+
});
|
|
60
|
+
const text = await response.text();
|
|
61
|
+
try {
|
|
62
|
+
return { status: response.status, json: JSON.parse(text) };
|
|
63
|
+
} catch {
|
|
64
|
+
return { status: response.status, json: null };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The id of the Engram store answering at the URL, or null. Never throws. */
|
|
69
|
+
export async function instanceOf(settings) {
|
|
70
|
+
if (!settings) return null;
|
|
71
|
+
try {
|
|
72
|
+
const { status, json } = await call(settings, '/health');
|
|
73
|
+
if (status !== 200 || json?.service !== 'engram') return null;
|
|
74
|
+
return json.instance_id ?? settings.url;
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Is an Engram server answering at the URL? Never throws. */
|
|
81
|
+
export const reachable = async (settings) =>
|
|
82
|
+
Boolean(await instanceOf(settings));
|
|
83
|
+
|
|
84
|
+
/** The bout's Engram session (created once; an ended one goes on in its continuation). */
|
|
85
|
+
async function openSession(settings, id) {
|
|
86
|
+
const body = { id, project: settings.project, directory: projectDir() };
|
|
87
|
+
const first = await call(settings, '/sessions', { method: 'POST', body });
|
|
88
|
+
const created =
|
|
89
|
+
first.json?.code === 'session_already_ended'
|
|
90
|
+
? await call(settings, '/sessions', {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
body: { ...body, resume: true },
|
|
93
|
+
})
|
|
94
|
+
: first;
|
|
95
|
+
if (created.status >= 300)
|
|
96
|
+
throw new Error(
|
|
97
|
+
`engram refused the session ${id}: ${created.status} ${created.json?.error ?? ''}`,
|
|
98
|
+
);
|
|
99
|
+
return created.json?.id ?? id;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** What a bout dir holds that Engram learns from. */
|
|
103
|
+
function boutFiles(dir) {
|
|
104
|
+
const read = (name) =>
|
|
105
|
+
existsSync(join(dir, name)) ? readFileSync(join(dir, name), 'utf8') : '';
|
|
106
|
+
return {
|
|
107
|
+
decisions: read('decisions.md'),
|
|
108
|
+
interview: parseInterview(read(INTERVIEW)),
|
|
109
|
+
card: read('judges-card.md'),
|
|
110
|
+
// Read as it is, not through findings.mjs: that one syncs to Engram after its commands.
|
|
111
|
+
ledger: existsSync(join(dir, 'findings.json'))
|
|
112
|
+
? JSON.parse(read('findings.json'))
|
|
113
|
+
: null,
|
|
114
|
+
plan: read(PLAN),
|
|
115
|
+
state: readState(dir),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The operation ids sent from a bout, per Engram store (another store has none of them). */
|
|
120
|
+
const readSent = (dir) => {
|
|
121
|
+
try {
|
|
122
|
+
const sent = JSON.parse(readFileSync(join(dir, SENT), 'utf8'));
|
|
123
|
+
return Array.isArray(sent) ? {} : sent;
|
|
124
|
+
} catch {
|
|
125
|
+
return {};
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Sends what a bout learned that Engram does not have yet. A 409 is a save Engram already holds
|
|
131
|
+
* under another payload: it counts as sent. Returns the counts.
|
|
132
|
+
*/
|
|
133
|
+
export async function syncBout(settings, { dir, slug, pr, instance }) {
|
|
134
|
+
const items = learnedItems({ slug, pr, ...boutFiles(dir) });
|
|
135
|
+
const byStore = readSent(dir);
|
|
136
|
+
const sent = new Set(byStore[instance] ?? []);
|
|
137
|
+
const pending = items
|
|
138
|
+
.map((item) => ({ item, op: operationId(settings.project, item) }))
|
|
139
|
+
.filter(({ op }) => !sent.has(op));
|
|
140
|
+
if (!pending.length) return { items: items.length, saved: 0 };
|
|
141
|
+
const session = await openSession(settings, `ring:${boutTag(slug, pr)}`);
|
|
142
|
+
let saved = 0;
|
|
143
|
+
try {
|
|
144
|
+
for (const { item, op } of pending) {
|
|
145
|
+
const { status, json } = await call(settings, '/observations', {
|
|
146
|
+
method: 'POST',
|
|
147
|
+
body: {
|
|
148
|
+
session_id: session,
|
|
149
|
+
project: settings.project,
|
|
150
|
+
scope: 'project',
|
|
151
|
+
tool_name: 'ring',
|
|
152
|
+
capture_prompt: false,
|
|
153
|
+
operation_id: op,
|
|
154
|
+
...item,
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
if (status >= 300 && status !== 409)
|
|
158
|
+
throw new Error(
|
|
159
|
+
`engram refused «${item.title}»: ${status} ${json?.error ?? ''}`,
|
|
160
|
+
);
|
|
161
|
+
sent.add(op);
|
|
162
|
+
saved += status === 409 ? 0 : 1;
|
|
163
|
+
}
|
|
164
|
+
} finally {
|
|
165
|
+
writeFileSync(
|
|
166
|
+
join(dir, SENT),
|
|
167
|
+
`${JSON.stringify({ ...byStore, [instance]: [...sent] }, null, 2)}\n`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return { items: items.length, saved };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Engram's compact search, scoped to the project; [] when there is nothing. */
|
|
174
|
+
async function search(settings, query, { type, limit = 12 } = {}) {
|
|
175
|
+
const params = new URLSearchParams({
|
|
176
|
+
q: query,
|
|
177
|
+
project: settings.project,
|
|
178
|
+
limit: String(limit),
|
|
179
|
+
match_mode: 'any',
|
|
180
|
+
response_format: 'compact',
|
|
181
|
+
...(type ? { type } : {}),
|
|
182
|
+
});
|
|
183
|
+
const { status, json } = await call(settings, `/search?${params}`);
|
|
184
|
+
return status === 200 ? (json?.results ?? []) : [];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The brief's section of lessons from earlier bouts on the files of the range, or null (Engram
|
|
189
|
+
* off, not there, or nothing relevant). Never throws: a brief never waits on Engram.
|
|
190
|
+
*/
|
|
191
|
+
export async function recallSection(config, { slug, pr, files }) {
|
|
192
|
+
const settings = engramSettings(config, slug);
|
|
193
|
+
const terms = recallTerms(files);
|
|
194
|
+
if (!settings || !terms.length) return null;
|
|
195
|
+
try {
|
|
196
|
+
const lines = learnedLines(
|
|
197
|
+
await search(settings, terms.join(' '), { limit: 16 }),
|
|
198
|
+
{ exclude: boutTag(slug, pr) },
|
|
199
|
+
);
|
|
200
|
+
if (!lines.length) return null;
|
|
201
|
+
return [
|
|
202
|
+
'## Learned in earlier bouts of this repo (Engram: context, never a rule; the rules are below)',
|
|
203
|
+
lines.join('\n'),
|
|
204
|
+
].join('\n\n');
|
|
205
|
+
} catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** The repo's latest fouls, verdicts and precedents for the referee's summary, or a notice. */
|
|
211
|
+
export async function refereeRecall(config, { slug, pr }) {
|
|
212
|
+
const settings = engramSettings(config, slug);
|
|
213
|
+
if (!settings) return null;
|
|
214
|
+
if (!(await reachable(settings)))
|
|
215
|
+
return `(Engram is not answering at ${settings.url}: nothing from earlier bouts; the bout goes on without it.)`;
|
|
216
|
+
const results = [
|
|
217
|
+
...(await search(settings, 'Foul', { type: 'learning', limit: 10 })),
|
|
218
|
+
...(await search(settings, 'Judge Precedent', { limit: 10 })),
|
|
219
|
+
];
|
|
220
|
+
const lines = learnedLines(results, { exclude: boutTag(slug, pr), max: 12 });
|
|
221
|
+
return lines.length ? lines.join('\n') : '(nothing learned yet in this repo)';
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The bout synced after a command that changed what it learned. Quiet when Engram is off or not
|
|
226
|
+
* there; one line on stderr when it refused something. Never throws: the command already ran.
|
|
227
|
+
*/
|
|
228
|
+
export async function syncAfter(pr) {
|
|
229
|
+
try {
|
|
230
|
+
const slug = repoSlug(projectDir());
|
|
231
|
+
const dir = boutDir(slug, pr);
|
|
232
|
+
if (!existsSync(join(dir, 'state.json'))) return;
|
|
233
|
+
const { config } = loadConfig(projectDir(), {
|
|
234
|
+
contract: false,
|
|
235
|
+
venue: readState(dir).venue ?? 'backend',
|
|
236
|
+
});
|
|
237
|
+
const settings = engramSettings(config, slug);
|
|
238
|
+
const instance = await instanceOf(settings);
|
|
239
|
+
if (!instance) return;
|
|
240
|
+
await syncBout(settings, { dir, slug, pr, instance });
|
|
241
|
+
} catch (error) {
|
|
242
|
+
console.error(`engram: ${error.message} (the bout's files are intact)`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function settingsHere() {
|
|
247
|
+
const slug = repoSlug(projectDir());
|
|
248
|
+
const { config } = loadConfig(projectDir(), { contract: false });
|
|
249
|
+
return { slug, settings: engramSettings(config, slug), config };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function status() {
|
|
253
|
+
const { settings } = settingsHere();
|
|
254
|
+
if (!settings) return void console.log('engram: off (engramUrl is null)');
|
|
255
|
+
const up = await reachable(settings);
|
|
256
|
+
console.log(
|
|
257
|
+
`engram: ${up ? 'answering' : 'NOT answering'} at ${settings.url} · project ${settings.project}`,
|
|
258
|
+
);
|
|
259
|
+
if (!up) process.exitCode = 2;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function sync([pr]) {
|
|
263
|
+
if (!pr) throw new Error('usage: sync <pr> | --all');
|
|
264
|
+
const { slug, settings } = settingsHere();
|
|
265
|
+
const instance = await instanceOf(settings);
|
|
266
|
+
if (!instance)
|
|
267
|
+
throw new Error(`Engram is not answering at ${settings?.url ?? '(off)'}`);
|
|
268
|
+
const bouts =
|
|
269
|
+
pr === '--all'
|
|
270
|
+
? repoBouts(slug).map(({ dir, state }) => ({ dir, pr: state.pr }))
|
|
271
|
+
: [{ dir: boutDir(slug, pr), pr }];
|
|
272
|
+
for (const bout of bouts) {
|
|
273
|
+
const { items, saved } = await syncBout(settings, {
|
|
274
|
+
...bout,
|
|
275
|
+
slug,
|
|
276
|
+
instance,
|
|
277
|
+
});
|
|
278
|
+
console.log(
|
|
279
|
+
`#${bout.pr}: ${saved} sent, ${items - saved} already there (${items} learned)`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function recall([pr, ...argv]) {
|
|
285
|
+
const { slug, config } = settingsHere();
|
|
286
|
+
const at = argv.indexOf('--files');
|
|
287
|
+
const files =
|
|
288
|
+
at === -1
|
|
289
|
+
? []
|
|
290
|
+
: String(argv[at + 1] ?? '')
|
|
291
|
+
.split(',')
|
|
292
|
+
.filter(Boolean);
|
|
293
|
+
console.log(
|
|
294
|
+
files.length
|
|
295
|
+
? ((await recallSection(config, { slug, pr, files })) ??
|
|
296
|
+
'(nothing relevant)')
|
|
297
|
+
: ((await refereeRecall(config, { slug, pr })) ?? 'engram: off'),
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const COMMANDS = { status, sync, recall };
|
|
302
|
+
|
|
303
|
+
if (isMain(import.meta.url)) {
|
|
304
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
305
|
+
const run = COMMANDS[command];
|
|
306
|
+
if (!run) {
|
|
307
|
+
console.error(
|
|
308
|
+
'Usage: node engram.mjs status | sync <pr>|--all | recall <pr> [--files a,b]',
|
|
309
|
+
);
|
|
310
|
+
process.exit(1);
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
await run(rest);
|
|
314
|
+
} catch (error) {
|
|
315
|
+
console.error(`engram: ${error.message}`);
|
|
316
|
+
process.exit(1);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
// in its second field (`<file:line> — R11 — …`); X0 when none covers it.
|
|
10
10
|
// node findings.mjs clean <pr> --range <from>..<to> the cornerman found nothing in that range
|
|
11
11
|
// node findings.mjs applied <pr> F<n>[,F<m>…] <sha> the wrestler applied it in that commit
|
|
12
|
-
// node findings.mjs discard <pr> F<n>[,…] --id D<k> --reason "<why>"
|
|
13
|
-
// node findings.mjs ticket <pr> F<n>[,…] --ticket <id|"to file"> --reason "<why>"
|
|
12
|
+
// node findings.mjs discard <pr> F<n>[,…] --id D<k> --reason "<why>" [--judge "<what the human said>"]
|
|
13
|
+
// node findings.mjs ticket <pr> F<n>[,…] --ticket <id|"to file"> --reason "<why>" [--judge "…"]
|
|
14
|
+
// (--judge is required when a finding cites a rule of `findingsNeedJudge`, P14 by default)
|
|
14
15
|
// node findings.mjs escalate <pr> F<n>[,…] sent to the human; waits on their answer
|
|
15
16
|
// node findings.mjs shown <pr> F<n>[,…] --in "<where the human saw it>"
|
|
16
17
|
// node findings.mjs audit <pr> [--full] counts; --full writes findings-audit.md
|
|
@@ -41,6 +42,7 @@ import {
|
|
|
41
42
|
readState,
|
|
42
43
|
repoSlug,
|
|
43
44
|
} from './config.mjs';
|
|
45
|
+
import { syncAfter } from './engram.mjs';
|
|
44
46
|
import { record } from './live.mjs';
|
|
45
47
|
import {
|
|
46
48
|
citedRules,
|
|
@@ -499,6 +501,21 @@ function applied([pr, ...argv]) {
|
|
|
499
501
|
console.log(`${ids.join(', ')} applied in ${full.slice(0, 9)}`);
|
|
500
502
|
}
|
|
501
503
|
|
|
504
|
+
/**
|
|
505
|
+
* The findings among `ids` that cite a rule the referee may not settle alone (`findingsNeedJudge`):
|
|
506
|
+
* by the rule the referee recorded or by the rule field of the line. PURE.
|
|
507
|
+
*/
|
|
508
|
+
export function needJudge(findings, ids, rules) {
|
|
509
|
+
const guarded = new Set(rules ?? []);
|
|
510
|
+
return findings.filter(
|
|
511
|
+
(finding) =>
|
|
512
|
+
ids.includes(finding.id) &&
|
|
513
|
+
[finding.rule, ...(citedRules(finding.text) ?? [])].some((rule) =>
|
|
514
|
+
guarded.has(rule),
|
|
515
|
+
),
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
502
519
|
/** Settled by the referee alone: the ledger, decisions.md and the judges card all say so. */
|
|
503
520
|
function settleAlone(pr, argv, kind) {
|
|
504
521
|
const { values, rest } = flags(argv);
|
|
@@ -510,7 +527,21 @@ function settleAlone(pr, argv, kind) {
|
|
|
510
527
|
throw new Error('a discard needs the decision that took it: --id D<k>');
|
|
511
528
|
if (kind === 'ticket' && !values.ticket?.trim())
|
|
512
529
|
throw new Error('a ticket needs its id, or --ticket "to file"');
|
|
513
|
-
const { dir } = bout(pr);
|
|
530
|
+
const { dir, state } = bout(pr);
|
|
531
|
+
const judge = values.judge?.trim();
|
|
532
|
+
const { config } = loadConfig(projectDir(), {
|
|
533
|
+
contract: false,
|
|
534
|
+
venue: state.venue ?? 'backend',
|
|
535
|
+
});
|
|
536
|
+
const guarded = needJudge(
|
|
537
|
+
readLedger(dir).findings,
|
|
538
|
+
ids,
|
|
539
|
+
config.findingsNeedJudge,
|
|
540
|
+
);
|
|
541
|
+
if (guarded.length && !judge)
|
|
542
|
+
throw new Error(
|
|
543
|
+
`${guarded.map((f) => f.id).join(', ')} cite${guarded.length === 1 ? 's' : ''} a rule the referee may not settle alone (${(config.findingsNeedJudge ?? []).join(', ')}): fix it, escalate it, or add --judge "<what the human said>"`,
|
|
544
|
+
);
|
|
514
545
|
const at = now();
|
|
515
546
|
const change =
|
|
516
547
|
kind === 'discard'
|
|
@@ -522,7 +553,7 @@ function settleAlone(pr, argv, kind) {
|
|
|
522
553
|
for (const finding of settled)
|
|
523
554
|
appendFileSync(
|
|
524
555
|
join(dir, 'decisions.md'),
|
|
525
|
-
`- ${at} · ${values.id ?? finding.id} · referee · cornerman finding ${finding.id} ${verb}: ${reason} · «${finding.text}»\n`,
|
|
556
|
+
`- ${at} · ${values.id ?? finding.id} · ${judge ? 'judge' : 'referee'} · cornerman finding ${finding.id} ${verb}: ${reason}${judge ? ` (the human: ${judge})` : ''} · «${finding.text}»\n`,
|
|
526
557
|
);
|
|
527
558
|
const cardPath = join(dir, 'judges-card.md');
|
|
528
559
|
if (existsSync(cardPath)) {
|
|
@@ -623,6 +654,8 @@ if (isMain(import.meta.url)) {
|
|
|
623
654
|
}
|
|
624
655
|
try {
|
|
625
656
|
run(rest);
|
|
657
|
+
// An X0 finding and its status go to Engram (quiet without it).
|
|
658
|
+
if (command !== 'audit') await syncAfter(rest[0]);
|
|
626
659
|
} catch (error) {
|
|
627
660
|
console.error(`findings: ${error.message}`);
|
|
628
661
|
process.exit(1);
|