@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.
@@ -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);
@@ -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
+ }