@thebattlearena/corner 0.10.1-beta.78 → 0.10.1-beta.80
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 +69 -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 +46 -2
- package/ring/scripts/config.mjs +24 -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 +180 -7
- package/ring/scripts/mask-guard.mjs +33 -0
- package/ring/scripts/relay.mjs +127 -0
- package/ring/scripts/review-blocks.mjs +232 -0
- package/ring/scripts/tech-plan-log.mjs +34 -2
- package/ring/skills/no-surrender/SKILL.md +5 -0
- package/ring/skills/referee/SKILL.md +24 -3
package/ring/scripts/brief.mjs
CHANGED
|
@@ -42,6 +42,10 @@ import {
|
|
|
42
42
|
ringHome,
|
|
43
43
|
RING_ROOT,
|
|
44
44
|
} from './config.mjs';
|
|
45
|
+
import { recallSection } from './engram.mjs';
|
|
46
|
+
import { conceptsSinceRelay, relayDue } from './relay.mjs';
|
|
47
|
+
import { reviewTurn } from './review-blocks.mjs';
|
|
48
|
+
import { setPlanState } from './tech-plan-log.mjs';
|
|
45
49
|
import { loadRules } from './rule-ids.mjs';
|
|
46
50
|
|
|
47
51
|
const TEMPLATES = join(RING_ROOT, 'skills', 'referee', 'templates');
|
|
@@ -162,9 +166,23 @@ function boutVars({ dir, state, config }, values) {
|
|
|
162
166
|
};
|
|
163
167
|
}
|
|
164
168
|
|
|
169
|
+
/** The open rebounds the human's review sent back, as a section of the brief (none: nothing). */
|
|
170
|
+
function humanReview(dir) {
|
|
171
|
+
const path = join(dir, 'rebounds.md');
|
|
172
|
+
const open = (existsSync(path) ? readFileSync(path, 'utf8') : '')
|
|
173
|
+
.split('\n')
|
|
174
|
+
.filter((line) => /^- \[ \] .*Revisión humana/.test(line));
|
|
175
|
+
return open.length
|
|
176
|
+
? [
|
|
177
|
+
'## Sent back by the human review (PR Review Lab; fix these first, the Tape shows what failed)',
|
|
178
|
+
open.join('\n'),
|
|
179
|
+
]
|
|
180
|
+
: [];
|
|
181
|
+
}
|
|
182
|
+
|
|
165
183
|
const real = (path) => (existsSync(path) ? realpathSync(path) : path);
|
|
166
184
|
|
|
167
|
-
function generate(kind, [pr, ...argv]) {
|
|
185
|
+
async function generate(kind, [pr, ...argv]) {
|
|
168
186
|
const { values } = flags(argv);
|
|
169
187
|
const shape = SHAPES[kind]?.[values.mode];
|
|
170
188
|
if (!pr || !shape)
|
|
@@ -172,6 +190,21 @@ function generate(kind, [pr, ...argv]) {
|
|
|
172
190
|
`usage: ${kind} <pr> --mode ${Object.keys(SHAPES[kind]).join('|')} --vars <file.json>`,
|
|
173
191
|
);
|
|
174
192
|
const bout = boutOf(pr);
|
|
193
|
+
if (kind === 'wrestler' && relayDue(bout.state, bout.config.relayEvery))
|
|
194
|
+
throw new Error(
|
|
195
|
+
`a relay is due (${conceptsSinceRelay(bout.state)} concepts since the last one): rewrite the card's «How to resume», run judges-card.mjs relay ${pr}, then brief the next wrestler`,
|
|
196
|
+
);
|
|
197
|
+
if (kind === 'wrestler') {
|
|
198
|
+
// The human's verdicts from PR Review Lab first: a fail comes back as a rebound for this brief.
|
|
199
|
+
const turn = reviewTurn(
|
|
200
|
+
bout.dir,
|
|
201
|
+
bout.config.humanReviewEvery,
|
|
202
|
+
(n, to, ref) => setPlanState(bout.dir, n, to, ref),
|
|
203
|
+
);
|
|
204
|
+
if (turn.applied.length)
|
|
205
|
+
console.error(`human review applied: ${turn.applied.join(' · ')}`);
|
|
206
|
+
if (turn.due) throw new Error(turn.due);
|
|
207
|
+
}
|
|
175
208
|
const own = values.vars ? JSON.parse(readFileSync(values.vars, 'utf8')) : {};
|
|
176
209
|
const roots = [
|
|
177
210
|
projectDir(),
|
|
@@ -198,6 +231,15 @@ function generate(kind, [pr, ...argv]) {
|
|
|
198
231
|
files,
|
|
199
232
|
);
|
|
200
233
|
const rules = rulesOf(bout.config);
|
|
234
|
+
// What earlier bouts of the repo learned on these files, from Engram when it answers: context
|
|
235
|
+
// after the decisions and before the rules, never in their place (a cut-off resumes as it was).
|
|
236
|
+
const learned = shape.rules
|
|
237
|
+
? await recallSection(bout.config, {
|
|
238
|
+
slug: repoSlug(projectDir()),
|
|
239
|
+
pr,
|
|
240
|
+
files,
|
|
241
|
+
})
|
|
242
|
+
: null;
|
|
201
243
|
// The task goes first and the rules last, as the reference: a brief that opens with the rules
|
|
202
244
|
// reads as a checklist, and a 2026-11 lab on #1212 found fewer real problems that way.
|
|
203
245
|
const body = [
|
|
@@ -205,6 +247,8 @@ function generate(kind, [pr, ...argv]) {
|
|
|
205
247
|
'## Decisions already taken (excerpt: every foul, the ones on these files, the last 20)',
|
|
206
248
|
decisions,
|
|
207
249
|
`The whole ledger: \`${decisionsPath}\`.`,
|
|
250
|
+
...humanReview(bout.dir),
|
|
251
|
+
...(learned ? [learned] : []),
|
|
208
252
|
...(shape.rules ? ['---', rules.block] : []),
|
|
209
253
|
].join('\n\n');
|
|
210
254
|
const briefs = join(bout.dir, 'briefs');
|
|
@@ -264,7 +308,7 @@ if (isMain(import.meta.url)) {
|
|
|
264
308
|
throw new Error(
|
|
265
309
|
'usage: brief.mjs wrestler|cornerman <pr> --mode … | verify <brief.md>',
|
|
266
310
|
);
|
|
267
|
-
generate(command, rest);
|
|
311
|
+
await generate(command, rest);
|
|
268
312
|
} catch (error) {
|
|
269
313
|
console.error(`brief: ${error.message}`);
|
|
270
314
|
process.exit(1);
|
package/ring/scripts/config.mjs
CHANGED
|
@@ -142,6 +142,30 @@ 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
|
+
// The human review by blocks (review-blocks.mjs, PR Review Lab's bout mode): once this many
|
|
160
|
+
// blocks of the plan (points with a reviewed range) wait on the judge, brief.mjs briefs no
|
|
161
|
+
// wrestler until the human signs a verdict on that cut. null: no pause.
|
|
162
|
+
humanReviewEvery: 2,
|
|
163
|
+
// Engram (github.com/Gentleman-Programming/engram), the memory of what earlier bouts learned:
|
|
164
|
+
// fouls, the judge's verdicts, accepted precedents, X0 findings and each bout's status. Never the
|
|
165
|
+
// rules. The scripts sync and read it when something answers at this URL (null: off). The
|
|
166
|
+
// project defaults to the repo name, as Engram names it.
|
|
167
|
+
engramUrl: 'http://127.0.0.1:7437',
|
|
168
|
+
engramProject: null,
|
|
145
169
|
// The model and effort of each ring agent, passed on every launch. Who writes runs on the
|
|
146
170
|
// cheaper model at high effort; who reviews keeps the strongest one (what slipped in bout #1223
|
|
147
171
|
// 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
|
+
}
|