champollion-mcp-server 0.1.0 → 0.2.0
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/CHANGELOG.md +468 -0
- package/README.md +135 -82
- package/bin/server.js +4 -3
- package/instructions.md +188 -232
- package/package.json +9 -5
- package/src/index.js +1364 -297
- package/src/tools/args.js +90 -0
- package/src/tools/contests.js +474 -0
- package/src/tools/corpora.js +423 -0
- package/src/tools/forge.js +1076 -78
- package/src/tools/harness-fst.js +404 -0
- package/src/tools/harness.js +1918 -188
- package/src/tools/job-supervisor.js +96 -0
- package/src/tools/jobs.js +408 -0
- package/src/tools/language-card.js +697 -0
- package/src/tools/languages.js +1361 -42
- package/src/tools/metrics-plan.js +287 -0
- package/src/tools/output-trim.js +177 -0
- package/src/tools/overview.js +423 -0
- package/src/tools/plan-notes.js +203 -0
- package/src/tools/plural.js +14 -0
- package/src/tools/publish-preview.js +191 -0
- package/src/tools/publish-report.js +382 -0
- package/src/tools/queue.js +413 -82
- package/src/tools/register-corpus-hint.js +77 -0
- package/src/tools/releasable.js +80 -0
- package/src/tools/reliability.js +114 -21
- package/src/tools/results.js +187 -20
- package/src/tools/run-plan.js +608 -0
- package/src/tools/state.js +40 -0
- package/src/tools/training.js +76 -18
- package/src/tools/translate.js +1612 -77
package/src/tools/forge.js
CHANGED
|
@@ -1,140 +1,1138 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* forge_* — drive nmt-forge (the
|
|
3
|
-
*
|
|
2
|
+
* forge_* — drive nmt-forge (the NMT training suite) from an agent, one
|
|
3
|
+
* guarded step at a time.
|
|
4
4
|
*
|
|
5
5
|
* get_training_guardrails answers "what are the rules?"; these tools answer
|
|
6
6
|
* "where am I and what do I run next?" against a real workspace. Every tool
|
|
7
|
-
* shells out to the forge CLI with `--json`
|
|
8
|
-
*
|
|
9
|
-
* the
|
|
7
|
+
* shells out to the forge CLI with `--json` (nmt-forge ≥ 0.2.0: exactly one
|
|
8
|
+
* JSON document on stdout, chatter on stderr — forge/docs/JSON_OUTPUT.md) and
|
|
9
|
+
* relays the structured result. forge's refusals come back as
|
|
10
|
+
* `{"error": {type, guard, message, why, fix, …}}` + exit 2 and are relayed
|
|
11
|
+
* as a tool error with what/why/fix — a refusal is the product, the guard
|
|
12
|
+
* doing its job — never as raw stderr or a traceback.
|
|
10
13
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
14
|
+
* Two forge commands are deliberately NOT tools, because they outlive any MCP
|
|
15
|
+
* call: `nmt-forge run` (training — minutes on a CPU for the default preset,
|
|
16
|
+
* hours on a GPU) and `nmt-forge serve` (a long-running HTTP server). Both
|
|
17
|
+
* are terminal steps; forge_status hands back their exact commands.
|
|
18
|
+
* forge_export (score the test battery once + package the model) and
|
|
19
|
+
* forge_evaluate (score only) close the loop after a run.
|
|
15
20
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
21
|
+
* WHERE FORGE COMES FROM — first hit wins, every miss is recorded:
|
|
22
|
+
* 1. NMT_FORGE_BIN an explicit console script (override)
|
|
23
|
+
* 2. CHAMPOLLION_FORGE_DIR a Champollion clone's forge/ directory, run as
|
|
24
|
+
* `python -m nmt_forge.cli` with PYTHONPATH set.
|
|
25
|
+
* Set-but-wrong is an ERROR, never skipped past.
|
|
26
|
+
* 3. the monorepo sibling mcp-server/../forge (development checkouts)
|
|
27
|
+
* 4. `nmt-forge` on PATH what `python3 -m pip install nmt-forge` puts there
|
|
28
|
+
* 5. the active Python `python -m nmt_forge.cli` when nmt_forge is
|
|
29
|
+
* importable (python3 -m pip install into a venv whose
|
|
30
|
+
* scripts dir is not on PATH)
|
|
31
|
+
* None → an actionable error naming the install command, not a traceback.
|
|
20
32
|
*/
|
|
21
33
|
|
|
22
34
|
import { spawn } from 'node:child_process';
|
|
23
|
-
import {
|
|
35
|
+
import { accessSync, constants, existsSync, statSync } from 'node:fs';
|
|
36
|
+
import { delimiter, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
24
37
|
import { fileURLToPath } from 'node:url';
|
|
25
38
|
|
|
26
39
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
27
40
|
// src/tools/forge.js → mcp-server root is two levels up
|
|
28
41
|
const MCP_ROOT = resolve(HERE, '../..');
|
|
29
42
|
|
|
30
|
-
/**
|
|
43
|
+
/** How to install forge — one wording, used by every error path. */
|
|
44
|
+
export const FORGE_INSTALL_HINT = 'Install nmt-forge (0.2.0 or later): `python3 -m pip install nmt-forge` '
|
|
45
|
+
+ "(add the training backend with `python3 -m pip install 'nmt-forge[hf]'`; scoring uses "
|
|
46
|
+
+ 'the eval harness, which nmt-forge depends on — `python3 -m pip install mt-eval-harness`). '
|
|
47
|
+
+ 'Or point CHAMPOLLION_FORGE_DIR at the forge/ directory of a Champollion clone '
|
|
48
|
+
+ '(https://github.com/gamedaysuits/Champollion).';
|
|
49
|
+
|
|
50
|
+
/** Absolute path to the forge/ package directory a clone/monorepo would use. */
|
|
31
51
|
export function forgeDir() {
|
|
32
52
|
return process.env.CHAMPOLLION_FORGE_DIR || resolve(MCP_ROOT, '../forge');
|
|
33
53
|
}
|
|
34
54
|
|
|
55
|
+
/** First executable named `cmd` on PATH, or null. No shell. */
|
|
56
|
+
export function whichSync(cmd, env = process.env) {
|
|
57
|
+
const exts = process.platform === 'win32'
|
|
58
|
+
? (env.PATHEXT || '.EXE;.CMD;.BAT').split(';')
|
|
59
|
+
: [''];
|
|
60
|
+
for (const dir of String(env.PATH || '').split(delimiter)) {
|
|
61
|
+
if (!dir) continue;
|
|
62
|
+
for (const ext of exts) {
|
|
63
|
+
const p = join(dir, cmd + ext);
|
|
64
|
+
try {
|
|
65
|
+
accessSync(p, constants.X_OK);
|
|
66
|
+
if (statSync(p).isFile()) return p;
|
|
67
|
+
} catch { /* not here */ }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Can `python` import nmt_forge (a pip install)? Async, bounded, no shell. */
|
|
74
|
+
function probeImport(python, env) {
|
|
75
|
+
return new Promise((done) => {
|
|
76
|
+
let proc;
|
|
77
|
+
try {
|
|
78
|
+
proc = spawn(python, ['-c', 'import nmt_forge'], {
|
|
79
|
+
stdio: ['ignore', 'ignore', 'ignore'], env, timeout: 15_000,
|
|
80
|
+
});
|
|
81
|
+
} catch {
|
|
82
|
+
done(false);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
proc.on('close', (code) => done(code === 0));
|
|
86
|
+
proc.on('error', () => done(false));
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Resolve how to launch forge on THIS machine (see the module docstring).
|
|
92
|
+
* Injectable for tests; never throws.
|
|
93
|
+
*
|
|
94
|
+
* @returns {Promise<{kind: 'bin'|'dir'|'module'|'missing', cmd?: string,
|
|
95
|
+
* dir?: string, python?: string, how: string, tried: string[]}>}
|
|
96
|
+
*/
|
|
97
|
+
export async function resolveForgeLauncher({
|
|
98
|
+
env = process.env,
|
|
99
|
+
exists = existsSync,
|
|
100
|
+
which = (cmd) => whichSync(cmd, env),
|
|
101
|
+
canImport = probeImport,
|
|
102
|
+
siblingDir = resolve(MCP_ROOT, '../forge'),
|
|
103
|
+
} = {}) {
|
|
104
|
+
const tried = [];
|
|
105
|
+
const python = env.PYTHON_BIN || 'python3';
|
|
106
|
+
|
|
107
|
+
if (env.NMT_FORGE_BIN) {
|
|
108
|
+
return { kind: 'bin', cmd: env.NMT_FORGE_BIN, how: 'NMT_FORGE_BIN', tried };
|
|
109
|
+
}
|
|
110
|
+
if (env.CHAMPOLLION_FORGE_DIR) {
|
|
111
|
+
const dir = env.CHAMPOLLION_FORGE_DIR;
|
|
112
|
+
if (exists(join(dir, 'nmt_forge'))) {
|
|
113
|
+
return { kind: 'dir', dir, python, how: `CHAMPOLLION_FORGE_DIR (${dir})`, tried };
|
|
114
|
+
}
|
|
115
|
+
// The user pointed somewhere specific — obey or fail, never fall past it.
|
|
116
|
+
return {
|
|
117
|
+
kind: 'missing',
|
|
118
|
+
how: 'CHAMPOLLION_FORGE_DIR',
|
|
119
|
+
tried: [`CHAMPOLLION_FORGE_DIR=${dir} (no nmt_forge/ package there)`],
|
|
120
|
+
error: `CHAMPOLLION_FORGE_DIR points at ${dir}, which has no nmt_forge/ `
|
|
121
|
+
+ 'package. Point it at the forge/ directory of a Champollion clone, or '
|
|
122
|
+
+ 'unset it to use a pip install (`python3 -m pip install nmt-forge`).',
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
tried.push('NMT_FORGE_BIN (unset)', 'CHAMPOLLION_FORGE_DIR (unset)');
|
|
126
|
+
|
|
127
|
+
if (exists(join(siblingDir, 'nmt_forge'))) {
|
|
128
|
+
return { kind: 'dir', dir: siblingDir, python, how: 'the monorepo checkout', tried };
|
|
129
|
+
}
|
|
130
|
+
tried.push(`${siblingDir} (no monorepo forge/ here)`);
|
|
131
|
+
|
|
132
|
+
const onPath = which('nmt-forge');
|
|
133
|
+
if (onPath) return { kind: 'bin', cmd: onPath, how: '`nmt-forge` on PATH (pip install)', tried };
|
|
134
|
+
tried.push('`nmt-forge` on PATH (not found)');
|
|
135
|
+
|
|
136
|
+
if (await canImport(python, env)) {
|
|
137
|
+
return { kind: 'module', python, how: `\`${python} -m nmt_forge.cli\` (python3 -m pip install in the active Python)`, tried };
|
|
138
|
+
}
|
|
139
|
+
tried.push(`\`${python} -c "import nmt_forge"\` (not importable)`);
|
|
140
|
+
|
|
141
|
+
return { kind: 'missing', how: 'none', tried, error: `nmt-forge is not installed here. ${FORGE_INSTALL_HINT}` };
|
|
142
|
+
}
|
|
143
|
+
|
|
35
144
|
/**
|
|
36
145
|
* Build the argv + env to invoke a forge subcommand — PURE, unit-testable.
|
|
37
146
|
*
|
|
38
147
|
* The global `--workspace` flag precedes the subcommand (argparse layout).
|
|
39
|
-
* Callers pass already-validated positionals/flags in `subArgs`.
|
|
148
|
+
* Callers pass already-validated positionals/flags in `subArgs`. Without a
|
|
149
|
+
* resolved `launcher`, the env-only default applies (NMT_FORGE_BIN, else the
|
|
150
|
+
* clone/monorepo directory) — the pre-0.2 behavior.
|
|
151
|
+
*
|
|
152
|
+
* `projectDir` is the directory forge runs FROM. forge's own advice is
|
|
153
|
+
* `cd <project> && nmt-forge …`: config.json's paths (`data/split/…`, the
|
|
154
|
+
* `.forge` workspace) and every relative path argument resolve against the
|
|
155
|
+
* working directory, so a server started elsewhere must run forge there.
|
|
40
156
|
*
|
|
41
157
|
* @param {string[]} subArgs subcommand + its args, e.g. ['status', '--json']
|
|
42
|
-
* @param {{workspace?: string}} [opts]
|
|
158
|
+
* @param {{workspace?: string, launcher?: object, projectDir?: string}} [opts]
|
|
43
159
|
* @returns {{cmd: string, args: string[], env: object, cwd: string}}
|
|
44
160
|
*/
|
|
45
|
-
export function buildForgeInvocation(subArgs, { workspace } = {}) {
|
|
161
|
+
export function buildForgeInvocation(subArgs, { workspace, launcher, projectDir } = {}) {
|
|
46
162
|
const ws = workspace || process.env.CHAMPOLLION_FORGE_WORKSPACE || '.forge';
|
|
47
163
|
const globals = ['--workspace', ws];
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
164
|
+
const cwd = projectDir ? resolve(projectDir) : process.cwd();
|
|
165
|
+
const l = launcher || (process.env.NMT_FORGE_BIN
|
|
166
|
+
? { kind: 'bin', cmd: process.env.NMT_FORGE_BIN }
|
|
167
|
+
: { kind: 'dir', dir: forgeDir(), python: process.env.PYTHON_BIN || 'python3' });
|
|
168
|
+
|
|
169
|
+
if (l.kind === 'bin') {
|
|
170
|
+
// a relative script path must not change meaning when cwd moves
|
|
171
|
+
const cmd = /[\\/]/.test(l.cmd) ? resolve(l.cmd) : l.cmd;
|
|
172
|
+
return { cmd, args: [...globals, ...subArgs], env: { ...process.env }, cwd };
|
|
56
173
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
: dir,
|
|
65
|
-
},
|
|
66
|
-
cwd: process.cwd(),
|
|
67
|
-
};
|
|
174
|
+
const python = l.python || process.env.PYTHON_BIN || 'python3';
|
|
175
|
+
const env = { ...process.env };
|
|
176
|
+
if (l.kind === 'dir') {
|
|
177
|
+
const dir = resolve(l.dir);
|
|
178
|
+
env.PYTHONPATH = process.env.PYTHONPATH ? `${dir}${delimiter}${process.env.PYTHONPATH}` : dir;
|
|
179
|
+
}
|
|
180
|
+
return { cmd: python, args: ['-m', 'nmt_forge.cli', ...globals, ...subArgs], env, cwd };
|
|
68
181
|
}
|
|
69
182
|
|
|
183
|
+
/** Translate the common cold-start failures (no JSON answer) into the fix. */
|
|
184
|
+
export function explainForgeFailure(stderr) {
|
|
185
|
+
const hints = [];
|
|
186
|
+
if (/No module named '?nmt_forge'?/.test(stderr)) {
|
|
187
|
+
hints.push(`[forge] nmt_forge is not importable. ${FORGE_INSTALL_HINT}`);
|
|
188
|
+
}
|
|
189
|
+
if (/No module named '?mt_eval_harness'?/.test(stderr)) {
|
|
190
|
+
hints.push('[forge] the eval harness is missing — forge scores ONLY through it: `python3 -m pip install mt-eval-harness`.');
|
|
191
|
+
}
|
|
192
|
+
if (/No module named '?(torch|transformers|peft|accelerate)'?/.test(stderr)) {
|
|
193
|
+
hints.push("[forge] the training backend is missing: `python3 -m pip install 'nmt-forge[hf]'`.");
|
|
194
|
+
}
|
|
195
|
+
if (/unrecognized arguments:.*--json/.test(stderr)) {
|
|
196
|
+
hints.push('[forge] this nmt-forge predates 0.2.0 (the subcommand has no --json). These tools '
|
|
197
|
+
+ 'need nmt-forge 0.2.0 or later: `python3 -m pip install -U nmt-forge`.');
|
|
198
|
+
}
|
|
199
|
+
if (/language-cards directory not found/i.test(stderr)) {
|
|
200
|
+
hints.push('[forge] this nmt-forge predates 0.2.0, which resolves language cards on its own '
|
|
201
|
+
+ '(a card directory → a checkout → the public card index, cached). Upgrade: '
|
|
202
|
+
+ '`python3 -m pip install -U nmt-forge`. Or set CHAMPOLLION_CARDS_DIR to a directory of '
|
|
203
|
+
+ '<code>.json cards (`champollion network card <code> --json > cards/<code>.json`). '
|
|
204
|
+
+ 'Meanwhile get_language / language_overview answer the same question.');
|
|
205
|
+
}
|
|
206
|
+
return hints;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Per-call bound for bookkeeping commands (status, split, prereg, …), ms. */
|
|
210
|
+
export const FORGE_TIMEOUT_MS = 120_000;
|
|
211
|
+
/**
|
|
212
|
+
* Per-call bound for the scoring commands (evaluate, export), ms. They decode
|
|
213
|
+
* the whole test battery with the trained model: seconds for the CPU preset
|
|
214
|
+
* on a few hundred rows, much longer for a large model on a CPU. Past this
|
|
215
|
+
* bound the tool stops forge and names the terminal command instead.
|
|
216
|
+
*/
|
|
217
|
+
export const FORGE_SCORING_TIMEOUT_MS = 600_000;
|
|
218
|
+
/** After a timeout's SIGINT, how long forge gets to clean up before SIGKILL, ms. */
|
|
219
|
+
export const FORGE_KILL_GRACE_MS = 10_000;
|
|
220
|
+
|
|
70
221
|
/**
|
|
71
222
|
* Spawn forge, capture output. No shell. stdin ignored (MCP stdio parent's
|
|
72
|
-
* stdin is the JSON-RPC stream — a child must never read it).
|
|
223
|
+
* stdin is the JSON-RPC stream — a child must never read it). Bounded: past
|
|
224
|
+
* `timeout` forge gets SIGINT — Python turns it into KeyboardInterrupt, so
|
|
225
|
+
* forge's own cleanup runs (a failed export removes its half-written
|
|
226
|
+
* directory) — and SIGKILL if it is still alive FORGE_KILL_GRACE_MS later.
|
|
73
227
|
*
|
|
74
228
|
* @param {string[]} subArgs
|
|
75
|
-
* @param {{workspace?: string, timeout?: number}} [opts]
|
|
76
|
-
* @returns {Promise<{code: number,
|
|
229
|
+
* @param {{workspace?: string, timeout?: number, launcher?: object, projectDir?: string}} [opts]
|
|
230
|
+
* @returns {Promise<{code: number, signal: string|null, timedOut: boolean,
|
|
231
|
+
* stdout: string, stderr: string, launcher: object}>}
|
|
77
232
|
*/
|
|
78
|
-
export function runForge(subArgs, {
|
|
79
|
-
|
|
233
|
+
export async function runForge(subArgs, {
|
|
234
|
+
workspace, timeout = FORGE_TIMEOUT_MS, launcher, projectDir,
|
|
235
|
+
} = {}) {
|
|
236
|
+
const l = launcher || await resolveForgeLauncher();
|
|
237
|
+
if (l.kind === 'missing') {
|
|
238
|
+
const err = new Error(l.error);
|
|
239
|
+
err.forgeMissing = true;
|
|
240
|
+
err.tried = l.tried;
|
|
241
|
+
throw err;
|
|
242
|
+
}
|
|
243
|
+
if (projectDir && !isDirectory(projectDir)) {
|
|
244
|
+
throw new Error(`project_dir ${projectDir} is not a directory on this machine. Create the `
|
|
245
|
+
+ 'project first (forge_init with dir set to it), or pass the `project` path forge_init returned.');
|
|
246
|
+
}
|
|
247
|
+
const { cmd, args, env, cwd } = buildForgeInvocation(subArgs, { workspace, launcher: l, projectDir });
|
|
80
248
|
return new Promise((resolvePromise, reject) => {
|
|
81
|
-
const proc = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], env, cwd
|
|
249
|
+
const proc = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], env, cwd });
|
|
82
250
|
let stdout = '';
|
|
83
251
|
let stderr = '';
|
|
252
|
+
let timedOut = false;
|
|
253
|
+
let hardKill = null;
|
|
254
|
+
const timer = setTimeout(() => {
|
|
255
|
+
timedOut = true;
|
|
256
|
+
proc.kill('SIGINT');
|
|
257
|
+
hardKill = setTimeout(() => proc.kill('SIGKILL'), FORGE_KILL_GRACE_MS);
|
|
258
|
+
}, timeout);
|
|
259
|
+
const stop = () => { clearTimeout(timer); clearTimeout(hardKill); };
|
|
84
260
|
proc.stdout.on('data', (d) => { stdout += d; });
|
|
85
261
|
proc.stderr.on('data', (d) => { stderr += d; });
|
|
86
|
-
proc.on('close', (code) => {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
stderr += '\n
|
|
91
|
-
+ 'Champollion monorepo (not on PyPI): clone '
|
|
92
|
-
+ 'https://github.com/gamedaysuits/Champollion and point '
|
|
93
|
-
+ 'CHAMPOLLION_FORGE_DIR at its forge/ directory. Scoring also needs '
|
|
94
|
-
+ 'the eval harness: pip install mt-eval';
|
|
262
|
+
proc.on('close', (code, signal) => {
|
|
263
|
+
stop();
|
|
264
|
+
if ((code ?? 1) !== 0) {
|
|
265
|
+
const hints = explainForgeFailure(stderr);
|
|
266
|
+
if (hints.length) stderr += `\n${hints.join('\n')}`;
|
|
95
267
|
}
|
|
96
|
-
resolvePromise({ code: code ?? 1, stdout, stderr });
|
|
268
|
+
resolvePromise({ code: code ?? 1, signal: signal ?? null, timedOut, stdout, stderr, launcher: l });
|
|
269
|
+
});
|
|
270
|
+
proc.on('error', (err) => {
|
|
271
|
+
stop();
|
|
272
|
+
reject(new Error(`could not launch forge (${cmd}, via ${l.how}): ${err.message}. ${FORGE_INSTALL_HINT}`));
|
|
97
273
|
});
|
|
98
|
-
proc.on('error', (err) => reject(new Error(
|
|
99
|
-
`could not launch forge (${cmd}): ${err.message}. Is forge/ present `
|
|
100
|
-
+ `(set CHAMPOLLION_FORGE_DIR) and python3 available?`)));
|
|
101
274
|
});
|
|
102
275
|
}
|
|
103
276
|
|
|
277
|
+
function isDirectory(p) {
|
|
278
|
+
try {
|
|
279
|
+
return statSync(p).isDirectory();
|
|
280
|
+
} catch {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Shell-quote one argument for a copy-pasteable command line. */
|
|
286
|
+
function shq(arg) {
|
|
287
|
+
const s = String(arg);
|
|
288
|
+
return /^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, "'\\''")}'`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** The command a human would type for this call (what to run in a terminal). */
|
|
292
|
+
export function forgeCommandLine(subArgs, workspace) {
|
|
293
|
+
const ws = workspace || process.env.CHAMPOLLION_FORGE_WORKSPACE || '.forge';
|
|
294
|
+
return ['nmt-forge', '--workspace', ws, ...subArgs].map(shq).join(' ');
|
|
295
|
+
}
|
|
296
|
+
|
|
104
297
|
/**
|
|
105
|
-
*
|
|
106
|
-
* the
|
|
107
|
-
*
|
|
298
|
+
* forge command → the forge_* tool that runs it. `null` = no tool: training
|
|
299
|
+
* (`run`), the HTTP server (`serve`) and the GUI (`monitor`) outlive any MCP
|
|
300
|
+
* call; the rest are expert commands an agent runs in a terminal.
|
|
301
|
+
* Longest match first ("registry add" before "registry").
|
|
108
302
|
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
303
|
+
* Exported: instructions.md states this mapping, and a test checks that
|
|
304
|
+
* every flag forge's advice names on a mapped command is an argument of its
|
|
305
|
+
* tool (Round 9: forge advised `--config-hash`, forge_prereg had no such
|
|
306
|
+
* argument).
|
|
307
|
+
*/
|
|
308
|
+
export const FORGE_COMMAND_TOOLS = [
|
|
309
|
+
['registry add-harness', null], ['registry add', 'forge_register_eval'],
|
|
310
|
+
['prereg template', 'forge_prereg_template'], ['prereg new', 'forge_prereg'],
|
|
311
|
+
['prereg verdict', 'forge_prereg_verdict'], ['prereg check', null],
|
|
312
|
+
['status', 'forge_status'], ['preflight', 'forge_preflight'],
|
|
313
|
+
['discover', 'forge_discover'], ['init', 'forge_init'], ['split', 'forge_split'],
|
|
314
|
+
['leak-audit', 'forge_leak_audit'], ['evaluate', 'forge_evaluate'],
|
|
315
|
+
['export', 'forge_export'], ['lint', 'forge_lint'], ['report', 'forge_report'],
|
|
316
|
+
['compare', 'forge_compare'],
|
|
317
|
+
['run', null], ['serve', null], ['monitor', null], ['score', null],
|
|
318
|
+
['sample', null], ['synth', null], ['verify-split', null],
|
|
319
|
+
];
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* A forge CLI flag → the tool argument that carries it: `--clean-to` →
|
|
323
|
+
* `clean_to`; `--held` / `--missed` → `verdict`. `--json` and `--workspace`
|
|
324
|
+
* are added by every tool itself.
|
|
325
|
+
*/
|
|
326
|
+
export function forgeFlagArg(flag) {
|
|
327
|
+
const f = String(flag).replace(/^--/, '');
|
|
328
|
+
if (f === 'held' || f === 'missed') return 'verdict';
|
|
329
|
+
return f.replace(/-/g, '_');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Flags no tool takes ON PURPOSE (each one's reason): an agent must never
|
|
334
|
+
* be the one to pass them.
|
|
335
|
+
*/
|
|
336
|
+
export const FORGE_FLAGS_NEVER_TOOLS = {
|
|
337
|
+
'--show-text': 'prints a withheld (local-only / sealed) corpus\'s sentences — for a person at the terminal, never an agent',
|
|
338
|
+
'--json': 'every tool adds it',
|
|
339
|
+
'--workspace': 'every tool takes `workspace`',
|
|
340
|
+
'--help': 'not a step',
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Map every `nmt-forge <command>` named in a piece of forge text (an advice
|
|
345
|
+
* line, a fix string) to the tool that runs it, in order of mention, deduped.
|
|
346
|
+
* A command with no tool comes back as "terminal: nmt-forge <command>".
|
|
347
|
+
*
|
|
348
|
+
* @param {string} text
|
|
349
|
+
* @returns {string[]}
|
|
350
|
+
*/
|
|
351
|
+
export function forgeToolsIn(text) {
|
|
352
|
+
const out = [];
|
|
353
|
+
const src = String(text || '');
|
|
354
|
+
for (const m of src.matchAll(/(?<!(?:install|-U)\s+)\bnmt-forge\s+([a-z][a-z-]*)(?:\s+([a-z][a-z-]*))?/g)) {
|
|
355
|
+
// `nmt-forge init --help` / argparse's `nmt-forge init: …` name no step to run
|
|
356
|
+
if (/^(\s*--help|:)/.test(src.slice(m.index + m[0].length))) continue;
|
|
357
|
+
const two = m[2] ? `${m[1]} ${m[2]}` : null;
|
|
358
|
+
const hit = FORGE_COMMAND_TOOLS.find(([c]) => c === two)
|
|
359
|
+
|| FORGE_COMMAND_TOOLS.find(([c]) => c === m[1]);
|
|
360
|
+
const label = hit && hit[1] ? hit[1] : `terminal: nmt-forge ${hit ? hit[0] : m[1]}`;
|
|
361
|
+
if (!out.includes(label)) out.push(label);
|
|
362
|
+
}
|
|
363
|
+
return out;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** A refusal envelope as forge ≥ 0.2.0 prints it under --json. */
|
|
367
|
+
function isErrorEnvelope(v) {
|
|
368
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
369
|
+
&& v.error !== null && typeof v.error === 'object' && !Array.isArray(v.error);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Render forge's `{"error": {type, guard, message, why, fix, how_to_get,
|
|
374
|
+
* text}}` as what/why/fix lines for the agent. Guard refusals carry why/fix
|
|
375
|
+
* as fields (and render them into `text` too); plain ForgeErrors carry them
|
|
376
|
+
* only inline in `text` — both come out once.
|
|
377
|
+
*
|
|
378
|
+
* @param {object} error the envelope's `error` object
|
|
379
|
+
* @param {{exitCode?: number, command?: string}} [ctx]
|
|
380
|
+
* @returns {string}
|
|
381
|
+
*/
|
|
382
|
+
export function formatForgeError(error, { exitCode, command } = {}) {
|
|
383
|
+
const e = error || {};
|
|
384
|
+
const kind = [e.type, e.guard ? `guard: ${e.guard}` : null, exitCode != null ? `exit ${exitCode}` : null]
|
|
385
|
+
.filter(Boolean).join(', ');
|
|
386
|
+
// the part of `text` that is not a rendering of the structured fields
|
|
387
|
+
let head = String(e.text || e.message || e.type || 'forge refused without a message');
|
|
388
|
+
for (const [field, marker] of [['why', '\n why: '], ['fix', '\n fix: '], ['how_to_get', '\n how to get it: ']]) {
|
|
389
|
+
const i = e[field] ? head.indexOf(marker) : -1;
|
|
390
|
+
if (i >= 0) head = head.slice(0, i);
|
|
391
|
+
}
|
|
392
|
+
const lines = [`forge refused (${kind || 'error'})${command ? `: ${command}` : ''}`, `what: ${head.trim()}`];
|
|
393
|
+
if (e.why) lines.push(`why: ${e.why}`);
|
|
394
|
+
if (e.fix) lines.push(`fix: ${e.fix}`);
|
|
395
|
+
if (e.how_to_get) lines.push(`how to get it: ${e.how_to_get}`);
|
|
396
|
+
const tools = forgeToolsIn([head, e.fix, e.how_to_get].filter(Boolean).join('\n'));
|
|
397
|
+
if (tools.length) lines.push(`(as tools: ${tools.join(', ')})`);
|
|
398
|
+
return lines.join('\n');
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Parse forge's stdout as exactly one JSON document. */
|
|
402
|
+
function parseJsonDocument(stdout) {
|
|
403
|
+
const s = String(stdout || '').trim();
|
|
404
|
+
if (!s) return { ok: false };
|
|
405
|
+
try {
|
|
406
|
+
return { ok: true, value: JSON.parse(s) };
|
|
407
|
+
} catch {
|
|
408
|
+
return { ok: false };
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** The last stderr line that says something (not a traceback frame). */
|
|
413
|
+
function lastMessage(stderr) {
|
|
414
|
+
const lines = String(stderr || '').split('\n')
|
|
415
|
+
.filter((l) => l.trim() && !/^\s/.test(l) && !/^Traceback \(most recent call last\)/.test(l)
|
|
416
|
+
&& !/^\[forge\] /.test(l));
|
|
417
|
+
return lines.length ? lines[lines.length - 1].trim().slice(0, 400) : null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** forge exited without a JSON document on stdout — say why, never dump a traceback. */
|
|
421
|
+
function formatNoJson(res, command) {
|
|
422
|
+
if (res.code === 0) {
|
|
423
|
+
const printed = res.stdout.trim();
|
|
424
|
+
return `forge answered in text, not JSON: ${command}\n`
|
|
425
|
+
+ 'These tools need nmt-forge 0.2.0 or later, where every subcommand takes --json. '
|
|
426
|
+
+ 'Upgrade: `python3 -m pip install -U nmt-forge`.'
|
|
427
|
+
+ (printed ? `\nforge printed:\n${printed.slice(0, 2000)}${printed.length > 2000 ? '\n…' : ''}` : '');
|
|
428
|
+
}
|
|
429
|
+
const lines = [`forge failed (exit ${res.code}${res.signal ? `, ${res.signal}` : ''}) without an answer: ${command}`];
|
|
430
|
+
const hints = explainForgeFailure(res.stderr);
|
|
431
|
+
if (hints.length) {
|
|
432
|
+
lines.push(...hints);
|
|
433
|
+
} else {
|
|
434
|
+
const last = lastMessage(res.stderr);
|
|
435
|
+
if (last) lines.push(`forge's last message: ${last}`);
|
|
436
|
+
lines.push('This is not a guard refusal (those come back as what/why/fix): it is an environment '
|
|
437
|
+
+ 'problem or a forge bug. Run the same command in a terminal to see forge\'s full output.');
|
|
438
|
+
}
|
|
439
|
+
return lines.join('\n');
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Run a forge subcommand under `--json` and shape the MCP result.
|
|
444
|
+
*
|
|
445
|
+
* - exit 0 + a JSON payload → `{result, summary?, next?}` (isError false);
|
|
446
|
+
* - `{"error": …}` (a refusal) → what/why/fix text + the envelope as JSON
|
|
447
|
+
* (isError true);
|
|
448
|
+
* - no JSON on stdout → the cold-start fix, or an "upgrade nmt-forge" note
|
|
449
|
+
* when forge answered in text (isError true);
|
|
450
|
+
* - past the bound → forge is stopped and the terminal command is named.
|
|
451
|
+
*
|
|
452
|
+
* @param {string[]} subArgs subcommand + args; `--json` is added when absent
|
|
453
|
+
* @param {object} [opts]
|
|
454
|
+
* @param {string} [opts.workspace]
|
|
455
|
+
* @param {string} [opts.projectDir] the directory forge runs from
|
|
456
|
+
* @param {string|function} [opts.nextHint] text, or (result, exitCode) => text|null
|
|
457
|
+
* @param {function} [opts.summarize] (result, exitCode) => object, returned as `summary`
|
|
458
|
+
* @param {number[]} [opts.okExitCodes] exit codes whose payload is an ANSWER
|
|
459
|
+
* (preflight exits 2 when a gate fails — the failing gates are the answer)
|
|
460
|
+
* @param {number} [opts.timeout]
|
|
461
|
+
* @param {object} [opts.launcher]
|
|
111
462
|
* @returns {Promise<{content: Array, isError: boolean}>}
|
|
112
463
|
*/
|
|
113
|
-
export async function forgeTool(subArgs, {
|
|
464
|
+
export async function forgeTool(subArgs, {
|
|
465
|
+
workspace, projectDir, nextHint, summarize, okExitCodes = [0],
|
|
466
|
+
timeout = FORGE_TIMEOUT_MS, launcher,
|
|
467
|
+
} = {}) {
|
|
468
|
+
const argv = subArgs.includes('--json') ? [...subArgs] : [...subArgs, '--json'];
|
|
469
|
+
const command = forgeCommandLine(argv, workspace);
|
|
470
|
+
const fail = (text) => ({ content: [{ type: 'text', text }], isError: true });
|
|
114
471
|
let res;
|
|
115
472
|
try {
|
|
116
|
-
res = await runForge(
|
|
473
|
+
res = await runForge(argv, { workspace, launcher, timeout, projectDir });
|
|
117
474
|
} catch (err) {
|
|
118
|
-
|
|
475
|
+
const tried = Array.isArray(err.tried) && err.tried.length
|
|
476
|
+
? `\nLooked for forge in: ${err.tried.join('; ')}.` : '';
|
|
477
|
+
return fail(`${err.message}${tried}`);
|
|
119
478
|
}
|
|
120
|
-
if (res.
|
|
121
|
-
|
|
122
|
-
|
|
479
|
+
if (res.timedOut) {
|
|
480
|
+
const bound = timeout >= 1000 ? `${Math.round(timeout / 1000)} s` : `${timeout} ms`;
|
|
481
|
+
return fail(`forge did not finish within ${bound} and was stopped: ${command}\n`
|
|
482
|
+
+ 'This step can legitimately take longer (decoding a test battery with a large model on a CPU). '
|
|
483
|
+
+ `Run it in a terminal instead${projectDir ? ` (from ${projectDir})` : ''}, then call forge_status — `
|
|
484
|
+
+ 'it reads the result from the workspace.');
|
|
485
|
+
}
|
|
486
|
+
const parsed = parseJsonDocument(res.stdout);
|
|
487
|
+
if (parsed.ok && isErrorEnvelope(parsed.value)) {
|
|
123
488
|
return {
|
|
124
|
-
content: [
|
|
489
|
+
content: [
|
|
490
|
+
{ type: 'text', text: formatForgeError(parsed.value.error, { exitCode: res.code, command }) },
|
|
491
|
+
{ type: 'text', text: JSON.stringify({ error: parsed.value.error, exit_code: res.code }, null, 2) },
|
|
492
|
+
],
|
|
125
493
|
isError: true,
|
|
126
494
|
};
|
|
127
495
|
}
|
|
128
|
-
|
|
129
|
-
if (
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
496
|
+
if (!parsed.ok) return fail(formatNoJson(res, command));
|
|
497
|
+
if (!okExitCodes.includes(res.code)) {
|
|
498
|
+
return fail(`forge exited ${res.code} with a result but no error: ${command}\n`
|
|
499
|
+
+ JSON.stringify({ result: parsed.value, exit_code: res.code }, null, 2));
|
|
500
|
+
}
|
|
501
|
+
const out = { result: parsed.value };
|
|
502
|
+
if (res.code !== 0) out.exit_code = res.code;
|
|
503
|
+
const summary = summarize ? summarize(parsed.value, res.code) : null;
|
|
504
|
+
if (summary) out.summary = summary;
|
|
505
|
+
const next = typeof nextHint === 'function' ? nextHint(parsed.value, res.code) : nextHint;
|
|
506
|
+
if (next) out.next = next;
|
|
507
|
+
return { content: [{ type: 'text', text: JSON.stringify(out, null, 2) }], isError: false };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// ---------------------------------------------------------------------------
|
|
511
|
+
// forge_compare: the result travels with its caveats. `nmt-forge compare`
|
|
512
|
+
// printed "winner=all-data" while every test row had a near-twin in
|
|
513
|
+
// all-data's training set (Round 9 school persona); forge now says so in
|
|
514
|
+
// result.caveats, and the summary carries them beside the winner.
|
|
515
|
+
// ---------------------------------------------------------------------------
|
|
516
|
+
|
|
517
|
+
// ---------------------------------------------------------------------------
|
|
518
|
+
// mt-eval's score caveats (Round 13). The harness writes `score_caveats` into
|
|
519
|
+
// the TestReport (a near-constant output, length inflation/deflation, source
|
|
520
|
+
// copies, forge's own near-twin reading echoed back); forge relays the list
|
|
521
|
+
// verbatim on export, status, compare and lint (forge/nmt_forge/
|
|
522
|
+
// harness_caveats.py). The MCP relays forge's list as it came — never
|
|
523
|
+
// reworded, never recomputed — and a MAJOR one goes WITH any score it
|
|
524
|
+
// qualifies (the hospital persona's twin-free model gave one of 9 outputs
|
|
525
|
+
// for 150 sources, and every surface called its chrF++ "the number to
|
|
526
|
+
// quote"). No list (an older harness, or nothing to qualify) → nothing said.
|
|
527
|
+
// ---------------------------------------------------------------------------
|
|
528
|
+
|
|
529
|
+
/** The kind/source of forge's OWN near-twin reading, echoed back by the harness (said elsewhere already). */
|
|
530
|
+
const FORGE_NEAR_TWIN = { kind: 'train_test_near_twin', source: 'nmt-forge' };
|
|
531
|
+
|
|
532
|
+
/** forge's list as it came (objects only), or null when there is none to relay. */
|
|
533
|
+
export function relayCaveats(list) {
|
|
534
|
+
if (!Array.isArray(list)) return null;
|
|
535
|
+
const out = list.filter((c) => c && typeof c === 'object' && !Array.isArray(c));
|
|
536
|
+
return out.length ? out : null;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** The MAJOR caveats a hint must put first — all but forge's own near-twin reading. */
|
|
540
|
+
export function majorCaveats(list) {
|
|
541
|
+
return (relayCaveats(list) || []).filter((c) => c.severity === 'major'
|
|
542
|
+
&& !(c.kind === FORGE_NEAR_TWIN.kind && c.source === FORGE_NEAR_TWIN.source));
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** forge_compare's summary: each lane's result, and the caveats beside it. */
|
|
546
|
+
export function compareSummary(r) {
|
|
547
|
+
if (!r || typeof r !== 'object' || !r.results || typeof r.results !== 'object') return null;
|
|
548
|
+
const results = {};
|
|
549
|
+
for (const [m, x] of Object.entries(r.results)) {
|
|
550
|
+
results[m] = {
|
|
551
|
+
delta: x?.delta ?? null,
|
|
552
|
+
ci: [x?.ci_lower ?? null, x?.ci_upper ?? null],
|
|
553
|
+
p: x?.p_value ?? null,
|
|
554
|
+
significant: x?.significant === true,
|
|
555
|
+
winner: x?.significant ? (x?.winner ?? null) : null,
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
const out = { results };
|
|
559
|
+
const recall = Object.entries(r.near_twin || {})
|
|
560
|
+
.filter(([, v]) => v && v.recall_not_translation === true).map(([k]) => k);
|
|
561
|
+
if (recall.length) out.recall_not_translation = recall;
|
|
562
|
+
const unchecked = Object.entries(r.near_twin || {})
|
|
563
|
+
.filter(([, v]) => !v || v.checked !== true).map(([k]) => k);
|
|
564
|
+
if (unchecked.length) out.near_twins_unchecked = unchecked;
|
|
565
|
+
if (Array.isArray(r.caveats) && r.caveats.length) out.caveats = r.caveats;
|
|
566
|
+
// mt-eval's caveats on each system's outputs, verbatim (Round 13)
|
|
567
|
+
const sc = {};
|
|
568
|
+
for (const [label, list] of Object.entries(r.score_caveats && typeof r.score_caveats === 'object' ? r.score_caveats : {})) {
|
|
569
|
+
const kept = relayCaveats(list);
|
|
570
|
+
if (kept) sc[label] = kept;
|
|
571
|
+
}
|
|
572
|
+
if (Object.keys(sc).length) {
|
|
573
|
+
out.score_caveats = sc;
|
|
574
|
+
const flagged = Object.entries(sc).filter(([, list]) => majorCaveats(list).length).map(([k]) => k);
|
|
575
|
+
if (flagged.length) out.score_caveats_major = flagged;
|
|
576
|
+
}
|
|
577
|
+
return out;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** The next step after forge_compare: relay the caveats WITH the winner. */
|
|
581
|
+
export function compareNextHint(r) {
|
|
582
|
+
const s = compareSummary(r);
|
|
583
|
+
if (!s) return null;
|
|
584
|
+
// a MAJOR harness caveat comes first, whatever else is said (Round 13)
|
|
585
|
+
const first = s.score_caveats_major
|
|
586
|
+
? `relay summary.caveats — mt-eval qualifies ${s.score_caveats_major.join(' and ')}'s outputs `
|
|
587
|
+
+ '(summary.score_caveats, in its words); no score here is quotable without its caveat. '
|
|
588
|
+
: '';
|
|
589
|
+
return first + compareRest(s);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function compareRest(s) {
|
|
593
|
+
if (s.recall_not_translation) {
|
|
594
|
+
return `relay summary.caveats with the result — never the winner alone: ${s.recall_not_translation.join(', ')} `
|
|
595
|
+
+ 'scored on recall of training phrases (most test rows have a near-twin in its training data). '
|
|
596
|
+
+ 'Compare twin-free numbers (each export\'s strict subset in forge_report, or a model trained with '
|
|
597
|
+
+ 'forge_leak_audit drop_test_twins) before anyone says which model translates better.';
|
|
598
|
+
}
|
|
599
|
+
if (s.near_twins_unchecked) {
|
|
600
|
+
return `near-twins were not checked for ${s.near_twins_unchecked.join(', ')} — pass run_a / run_b `
|
|
601
|
+
+ '(that model\'s run-manifest.json) so forge checks its training data, or compare the hypotheses an '
|
|
602
|
+
+ 'export wrote: forge_export\'s result.hypotheses (<export>/evaluation/battery-hyps.jsonl) as hyps_a / '
|
|
603
|
+
+ 'hyps_b — forge then also relays mt-eval\'s caveats on that export\'s outputs. Relay summary.caveats '
|
|
604
|
+
+ 'with the result.';
|
|
605
|
+
}
|
|
606
|
+
return 'relay the result with its CIs and summary.caveats; a difference inside the CI on Δ is not a ranking.';
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// ---------------------------------------------------------------------------
|
|
610
|
+
// forge_lint: the hint follows the findings. With none it used to say "act on
|
|
611
|
+
// the highest-severity finding's lever" anyway (Round 5 hospital persona).
|
|
612
|
+
// ---------------------------------------------------------------------------
|
|
613
|
+
|
|
614
|
+
const LINT_SEVERITY_ORDER = ['high', 'medium', 'info'];
|
|
615
|
+
|
|
616
|
+
/** {high, medium, info} counts of `nmt-forge lint --json` findings. */
|
|
617
|
+
export function lintSeverityCounts(findings) {
|
|
618
|
+
const out = { high: 0, medium: 0, info: 0 };
|
|
619
|
+
for (const f of findings ?? []) {
|
|
620
|
+
if (f && Object.hasOwn(out, f.severity)) out[f.severity] += 1;
|
|
621
|
+
}
|
|
622
|
+
return out;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** forge's rule for mt-eval's own caveat on the scores (Round 13). */
|
|
626
|
+
export const LINT_HARNESS_CAVEAT_RULE = 'R9-harness-score-caveat';
|
|
627
|
+
|
|
628
|
+
/** forge_lint's summary: counts by severity, and every R9 finding's words. */
|
|
629
|
+
export function lintSummary(findings) {
|
|
630
|
+
if (!Array.isArray(findings)) return null;
|
|
631
|
+
const out = { findings: findings.length, by_severity: lintSeverityCounts(findings) };
|
|
632
|
+
const r9 = findings.filter((f) => f?.rule === LINT_HARNESS_CAVEAT_RULE);
|
|
633
|
+
if (r9.length) {
|
|
634
|
+
out.harness_score_caveats = r9.map((f) => ({ severity: f.severity, recommendation: f.recommendation }));
|
|
635
|
+
}
|
|
636
|
+
return out;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** The next step after forge_lint, conditional on what it found. */
|
|
640
|
+
export function lintNextHint(findings) {
|
|
641
|
+
if (!Array.isArray(findings)) return null;
|
|
642
|
+
const r9 = findings.filter((f) => f?.rule === LINT_HARNESS_CAVEAT_RULE);
|
|
643
|
+
const first = r9.length
|
|
644
|
+
? `first relay summary.harness_score_caveats with the scores — mt-eval qualifies them (${LINT_HARNESS_CAVEAT_RULE}, `
|
|
645
|
+
+ `${r9.map((f) => f.severity).join(', ')}); never quote a score without them. `
|
|
646
|
+
: '';
|
|
647
|
+
const rest = lintRest(findings.filter((f) => f?.rule !== LINT_HARNESS_CAVEAT_RULE), r9.length > 0);
|
|
648
|
+
return first + rest;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function lintRest(findings, hadR9) {
|
|
652
|
+
if (findings.length === 0 && hadR9) {
|
|
653
|
+
return 'No other finding: nothing here says which lever to pull.';
|
|
654
|
+
}
|
|
655
|
+
if (findings.length === 0) {
|
|
656
|
+
return 'no findings: the battery shows no weak register this linter can explain. '
|
|
657
|
+
+ 'Read the scores and their CIs (forge_report on the same manifest); nothing here '
|
|
658
|
+
+ 'says which lever to pull, so do not invent one.';
|
|
659
|
+
}
|
|
660
|
+
const rank = (f) => {
|
|
661
|
+
const i = LINT_SEVERITY_ORDER.indexOf(f?.severity);
|
|
662
|
+
return i < 0 ? LINT_SEVERITY_ORDER.length : i;
|
|
663
|
+
};
|
|
664
|
+
const top = [...findings].sort((a, b) => rank(a) - rank(b))[0];
|
|
665
|
+
if (top?.severity === 'info') {
|
|
666
|
+
return `only informational findings (${findings.length}) — nothing to fix; read them `
|
|
667
|
+
+ '(e.g. the measurement caveats) before quoting the scores.';
|
|
668
|
+
}
|
|
669
|
+
return `act on the highest-severity finding: ${top.rule} (${top.severity}) → lever `
|
|
670
|
+
+ `${top.lever}${top.group ? ` for ${top.group}` : ''}; then re-run and re-evaluate.`;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ---------------------------------------------------------------------------
|
|
674
|
+
// forge_discover: two readers, one card. forge reads the card the harness
|
|
675
|
+
// resolves — from a python3 -m pip install with no CLI card nearby, the PUBLIC card
|
|
676
|
+
// index, whose published rows can lag the full card. language_overview
|
|
677
|
+
// reads the champollion CLI's own card through its adapter. Round 10 (school
|
|
678
|
+
// and researcher, third round running): language_overview listed a
|
|
679
|
+
// dictionary and a long grammar for crk while forge_discover said
|
|
680
|
+
// dictionaries [] — two true reports of two different sources, side by side
|
|
681
|
+
// with nothing saying so. The MCP holds both answers: it puts the CLI card's
|
|
682
|
+
// lexical resources beside forge's, each attributed to its source, and never
|
|
683
|
+
// invents a value either source lacks.
|
|
684
|
+
// ---------------------------------------------------------------------------
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* What the CLI's card cites that forge's source did not give it.
|
|
688
|
+
*
|
|
689
|
+
* @param {object} r forge's discover --json result (ResourceReport)
|
|
690
|
+
* @param {object|null} cli getLanguage() result for the same code (status ok), or null
|
|
691
|
+
* @returns {object|null} `{forge_source, cli_card, dictionaries?, documentation?, note}` — null when nothing differs
|
|
692
|
+
*/
|
|
693
|
+
export function discoverCardCrossCheck(r, cli) {
|
|
694
|
+
if (!r || typeof r !== 'object' || !cli || cli.status !== 'ok') return null;
|
|
695
|
+
const R = cli.summary?.resources ?? {};
|
|
696
|
+
const forgeDicts = Array.isArray(r.dictionaries) ? r.dictionaries : [];
|
|
697
|
+
const forgeHasGrammar = (Array.isArray(r.grammars) && r.grammars.length) || Boolean(r.documentation);
|
|
698
|
+
const out = {};
|
|
699
|
+
if (!forgeDicts.length && Array.isArray(R.dictionaries) && R.dictionaries.length) {
|
|
700
|
+
out.dictionaries = R.dictionaries.map((d) => ({
|
|
701
|
+
name: d.name, publisher: d.publisher ?? null, url: d.url ?? null, license: d.license ?? null,
|
|
702
|
+
}));
|
|
703
|
+
}
|
|
704
|
+
if (!forgeHasGrammar && R.documentation) {
|
|
705
|
+
out.documentation = { level: R.documentation.level, source: R.documentation.source };
|
|
706
|
+
}
|
|
707
|
+
if (!Object.keys(out).length) return null;
|
|
708
|
+
const tier = cli.summary?.provenance?.tier ?? cli.tier ?? 'unknown';
|
|
709
|
+
const what = [out.dictionaries && `${out.dictionaries.length} dictionar${out.dictionaries.length === 1 ? 'y' : 'ies'}`,
|
|
710
|
+
out.documentation && `a documentation level (${out.documentation.level})`].filter(Boolean).join(' and ');
|
|
711
|
+
return {
|
|
712
|
+
forge_source: r.card_path ?? null,
|
|
713
|
+
cli_card: tier,
|
|
714
|
+
...out,
|
|
715
|
+
note: `forge read ${r.card_path || 'its card source'}, which lists no ${[
|
|
716
|
+
out.dictionaries && 'dictionary', out.documentation && 'grammar/documentation level'].filter(Boolean).join(' or ')}; `
|
|
717
|
+
+ `the champollion CLI's card (${tier}) cites ${what} — language_overview and get_language show them. `
|
|
718
|
+
+ 'Both are reports of their own source: forge\'s asset ladder (rung 3) counts only what forge read. '
|
|
719
|
+
+ 'To give forge the CLI\'s card: `champollion network card <code> --json > cards/<code>.json`, then '
|
|
720
|
+
+ 'forge_discover / forge_init with cards_dir: "cards".',
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// ---------------------------------------------------------------------------
|
|
725
|
+
// forge_preflight: the next step it names is the ACTUAL next step. Round 10
|
|
726
|
+
// (hospital): every gate passed and the hint still read "fix every gate with
|
|
727
|
+
// ok:false…", which reads as a failure.
|
|
728
|
+
// ---------------------------------------------------------------------------
|
|
729
|
+
|
|
730
|
+
/** What runs a preflighted command once every gate passes. */
|
|
731
|
+
const PREFLIGHT_RUNS = {
|
|
732
|
+
run: (config) => `\`nmt-forge run ${config || 'config.json'}\` in a terminal (training is not an MCP tool: run it in the `
|
|
733
|
+
+ 'background with output to a log, and call forge_status when it exits)',
|
|
734
|
+
evaluate: () => 'forge_evaluate',
|
|
735
|
+
export: () => 'forge_export',
|
|
736
|
+
serve: () => '`nmt-forge serve <export dir>/model` in a terminal',
|
|
737
|
+
score: () => 'forge_evaluate (or `nmt-forge score` in a terminal)',
|
|
738
|
+
split: () => 'forge_split',
|
|
739
|
+
prereg: () => 'forge_prereg_template → forge_prereg',
|
|
740
|
+
'leak-audit': () => 'forge_leak_audit',
|
|
741
|
+
};
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* forge_preflight's `next`: the failing gates' fixes when any gate fails;
|
|
745
|
+
* otherwise the command itself — after the warnings, which pass but must be
|
|
746
|
+
* read first.
|
|
747
|
+
*
|
|
748
|
+
* @param {string} target the preflighted command
|
|
749
|
+
* @param {string|undefined} config the config argument, if any
|
|
750
|
+
* @param {Array<{gate: string, ok: boolean, warning?: boolean}>} gates
|
|
751
|
+
* @returns {string}
|
|
752
|
+
*/
|
|
753
|
+
export function preflightNextHint(target, config, gates) {
|
|
754
|
+
if (!Array.isArray(gates)) return 'forge_status.';
|
|
755
|
+
const failing = gates.filter((g) => !g.ok).map((g) => g.gate);
|
|
756
|
+
if (failing.length) {
|
|
757
|
+
return `fix ${failing.length === 1 ? 'the gate' : `the ${failing.length} gates`} with ok:false using `
|
|
758
|
+
+ `each one's fix string (${failing.join(', ')}), then re-run forge_preflight until summary.passed is true.`;
|
|
759
|
+
}
|
|
760
|
+
const warned = gates.filter((g) => g.ok && g.warning).map((g) => g.gate);
|
|
761
|
+
const run = (PREFLIGHT_RUNS[target] ?? (() => `\`nmt-forge ${target}\``))(config);
|
|
762
|
+
return `every gate passed${warned.length
|
|
763
|
+
? ` — first relay the ${warned.length === 1 ? 'warning' : 'warnings'} (${warned.join(', ')}: ok:true, `
|
|
764
|
+
+ 'warning:true — each detail says what the number will mean) to the user'
|
|
765
|
+
: ''}. Next: ${run}.`;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// ---------------------------------------------------------------------------
|
|
769
|
+
// forge_split: relay forge's near-twin advice — and stop asking once forge
|
|
770
|
+
// has given its verdict. Round 11 (school persona): a corpus whose templates
|
|
771
|
+
// chain can have no twin-free dev set that is a sample of the data; forge
|
|
772
|
+
// said so, and the hint still read "follow it before training" after every
|
|
773
|
+
// re-split, with no stopping point. Advice forge marks settled — the dev
|
|
774
|
+
// twin verdict with `final: true` (no split fixes it), or a registered test
|
|
775
|
+
// set on the two-model route (`two_model`) — is relayed ONCE as a verdict
|
|
776
|
+
// the user decides on, never as a step to repeat.
|
|
777
|
+
// ---------------------------------------------------------------------------
|
|
778
|
+
|
|
779
|
+
const settledAdvice = (f) => f?.verdict?.final === true || Boolean(f?.two_model);
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* forge_split's summary: advice still to act on before training
|
|
783
|
+
* (`near_twin_advice`), forge's settled verdicts (`near_twin_verdicts`), and
|
|
784
|
+
* whether the corpus's templates chain into one group.
|
|
785
|
+
*
|
|
786
|
+
* @param {object} r forge's `split --json` result
|
|
787
|
+
* @returns {object|null}
|
|
788
|
+
*/
|
|
789
|
+
export function splitNearTwinSummary(r) {
|
|
790
|
+
const entries = [...Object.values(r?.near_twin || {}), r?.dev_near_twin]
|
|
791
|
+
.filter((f) => f && typeof f === 'object' && f.advice);
|
|
792
|
+
const open = [...new Set(entries.filter((f) => !settledAdvice(f)).map((f) => f.advice))];
|
|
793
|
+
const settled = [...new Set(entries.filter(settledAdvice).map((f) => f.advice))];
|
|
794
|
+
const chained = r?.near_dupe_carve_check?.chained === true;
|
|
795
|
+
if (!open.length && !settled.length && !chained) return null;
|
|
796
|
+
return {
|
|
797
|
+
...(open.length ? { near_twin_advice: open } : {}),
|
|
798
|
+
...(settled.length ? { near_twin_verdicts: settled } : {}),
|
|
799
|
+
...(chained ? { templates_chain: true } : {}),
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* forge_split's next step.
|
|
805
|
+
*
|
|
806
|
+
* @param {object} r forge's `split --json` result
|
|
807
|
+
* @returns {string}
|
|
808
|
+
*/
|
|
809
|
+
export function splitNextHint(r) {
|
|
810
|
+
const cc = r?.config_check;
|
|
811
|
+
if (cc && cc.ok === false) {
|
|
812
|
+
return `${cc.message} — fix it before training: ${cc.fix}. Then forge_preflight { "target": "run" }.`;
|
|
813
|
+
}
|
|
814
|
+
const sum = splitNearTwinSummary(r);
|
|
815
|
+
if (!sum || (!sum.near_twin_advice && !sum.near_twin_verdicts)) {
|
|
816
|
+
return 'forge_status — you now have a dev set; it names what is left before training (the '
|
|
817
|
+
+ 'preregistrations, one per model, if they are not written yet — then forge_preflight { "target": "run" }).';
|
|
818
|
+
}
|
|
819
|
+
const parts = [];
|
|
820
|
+
if (sum.near_twin_advice) {
|
|
821
|
+
parts.push('relay summary.near_twin_advice to the user and follow it before training — its flags are '
|
|
822
|
+
+ 'forge_split arguments (--near-dupe → near_dupe, --max-group → max_group, --allow-rotate → '
|
|
823
|
+
+ 'allow_rotate)' + (sum.templates_chain && !sum.near_twin_verdicts
|
|
824
|
+
? '; the corpus\'s templates chain into one group, so a near_dupe carve alone cannot separate '
|
|
825
|
+
+ 'them — take the route forge names (result.near_dupe_carve_check)'
|
|
826
|
+
: ''));
|
|
827
|
+
}
|
|
828
|
+
if (sum.near_twin_verdicts) {
|
|
829
|
+
parts.push('relay summary.near_twin_verdicts to the user ONCE: they are forge\'s verdict on this corpus, '
|
|
830
|
+
+ 'not steps to repeat — re-splitting will not change them. The user chooses among the options each '
|
|
831
|
+
+ 'one names (accepting it and training is one); do not call forge_split again for them');
|
|
832
|
+
}
|
|
833
|
+
return `${parts.join('. ')}. Then forge_status.`;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// ---------------------------------------------------------------------------
|
|
837
|
+
// The step order: forge_init's note, its next step, forge_status in state
|
|
838
|
+
// `initialized` and the public guide say ONE order (Round 11 hospital
|
|
839
|
+
// persona: init's note said "carve a split first" while its next step and
|
|
840
|
+
// the guide said register, screen, predict). forge owns it
|
|
841
|
+
// (`scaffold.STEP_ORDER`, as `order` in `init --json` and in status's
|
|
842
|
+
// `advice.order`); these hints render it with the MCP tool for each step.
|
|
843
|
+
// ---------------------------------------------------------------------------
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* forge's step order as one line of MCP tools: `forge_register_eval (register
|
|
847
|
+
* the test set) → … → forge_preflight, then … (check, then train)`. forge is
|
|
848
|
+
* the one source: an nmt-forge whose JSON carries no order (before
|
|
849
|
+
* 2026-10-04) gets NEXT_STEPS.md's step 2 named instead of a copy here.
|
|
850
|
+
*
|
|
851
|
+
* @param {Array<{step: string, what: string, tool?: string}>|undefined} order
|
|
852
|
+
* @returns {string}
|
|
853
|
+
*/
|
|
854
|
+
export function stepOrderText(order) {
|
|
855
|
+
if (!Array.isArray(order) || !order.length) {
|
|
856
|
+
return 'the order NEXT_STEPS.md step 2 gives (register the test set, screen the corpus, the predictions, '
|
|
857
|
+
+ 'baselines, then the split — this nmt-forge does not report its order in --json; upgrade it)';
|
|
858
|
+
}
|
|
859
|
+
return order.map((s) => (s.tool ? `${s.tool} (${s.what})` : s.what)).join(' → ');
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/** What a project with no test set of its own does instead of the first two steps. */
|
|
863
|
+
const NO_OWN_TEST_SET = 'With no test set of their own: forge_split with test > 0 (register: "project") carves '
|
|
864
|
+
+ 'and registers one instead of the first two steps — the predictions still come before any score.';
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* forge_init's next step.
|
|
868
|
+
*
|
|
869
|
+
* @param {object} r forge's `init --json` result
|
|
870
|
+
* @param {string} [dir] the dir argument, when forge's result names no project
|
|
871
|
+
* @returns {string}
|
|
872
|
+
*/
|
|
873
|
+
export function initNextHint(r, dir) {
|
|
874
|
+
return `pass project_dir: ${JSON.stringify(r?.project ?? dir ?? '.')} to every later forge_* call `
|
|
875
|
+
+ '(config.json\'s paths are relative to it). Then, with the user\'s OWN test set kept as a separate '
|
|
876
|
+
+ `file (mark it local-only first), in this order: ${stepOrderText(r?.order)}. ${NO_OWN_TEST_SET} `
|
|
877
|
+
+ 'forge_status names each step as it comes.';
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* forge_status's next step in state `initialized`.
|
|
882
|
+
*
|
|
883
|
+
* @param {object} r forge's `status --json` result
|
|
884
|
+
* @returns {string}
|
|
885
|
+
*/
|
|
886
|
+
export function initializedStatusHint(r) {
|
|
887
|
+
return 'the project is initialized and no eval set is registered yet: get the parallel corpus path from '
|
|
888
|
+
+ 'the user. With their OWN test set kept as a separate file (mark it local-only first), in this order: '
|
|
889
|
+
+ `${stepOrderText(r?.advice?.order)}. ${NO_OWN_TEST_SET} Never call forge_discover / forge_init again.`;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// ---------------------------------------------------------------------------
|
|
893
|
+
// forge_status's `next` in every other state names THE step forge named.
|
|
894
|
+
// Round 12 (school persona): with both trainings finished (ready-to-score)
|
|
895
|
+
// the hint still read "run result.advice.next_command … A terminal: step
|
|
896
|
+
// (training: nmt-forge run config.json …)" — training, at the one point it
|
|
897
|
+
// was done; the export was only in result.advice.next_command. The hint is
|
|
898
|
+
// now built from that command: its tools in order, and the terminal text of
|
|
899
|
+
// only the terminal steps it holds.
|
|
900
|
+
// ---------------------------------------------------------------------------
|
|
901
|
+
|
|
902
|
+
/** What a terminal step of forge's next command is, said once. */
|
|
903
|
+
const TERMINAL_STEP_TEXT = {
|
|
904
|
+
run: '`nmt-forge run <config>` trains in a terminal (not an MCP tool — the default cpu-tiny preset takes '
|
|
905
|
+
+ 'minutes on a CPU): run it in the background with output to a log, and call forge_status when it exits',
|
|
906
|
+
serve: '`nmt-forge serve <export>/model` is a long-running HTTP server: start it in a terminal',
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* forge_status's `next` outside the states with their own hint
|
|
911
|
+
* (initialized, choose-export, exported, missing-preregistration, training).
|
|
912
|
+
*
|
|
913
|
+
* @param {object} r forge's `status --json` result
|
|
914
|
+
* @returns {string}
|
|
915
|
+
*/
|
|
916
|
+
export function statusNextHint(r) {
|
|
917
|
+
const a = r?.advice || {};
|
|
918
|
+
const cmd = String(a.next_command || '').trim();
|
|
919
|
+
if (!cmd) return 'forge_status names no command — read result.advice.why and result.advice.blockers.';
|
|
920
|
+
const tools = forgeToolsIn(cmd);
|
|
921
|
+
const asTools = tools.filter((t) => !t.startsWith('terminal:'));
|
|
922
|
+
const terminal = tools.filter((t) => t.startsWith('terminal:')).map((t) => t.replace(/^terminal: nmt-forge /, ''));
|
|
923
|
+
if (a.state === 'serving') {
|
|
924
|
+
return 'the chosen model is served and answering (result.advice.ready names its URL) — forge has nothing '
|
|
925
|
+
+ 'left to run. What is left is the app\'s: the api method config from DEPLOY.md (in the model '
|
|
926
|
+
+ 'directory), then `npx champollion sync` and `npx champollion verify` in the app, in a terminal '
|
|
927
|
+
+ '(or the translate tool, method api, endpoint <that URL>/translate). A speaker reviews the strings '
|
|
928
|
+
+ 'before anyone relies on them.';
|
|
929
|
+
}
|
|
930
|
+
if (a.state === 'no-dev-set' && /get_training_guardrails/.test(cmd)) {
|
|
931
|
+
// forge names the guardrails ONCE, here, before the split (Round 13):
|
|
932
|
+
// read first, then split — its comment names them after the command.
|
|
933
|
+
// forge's other notes on the split, without the guardrails one said here
|
|
934
|
+
const notes = (cmd.split('#')[1] || '').split(';').map((n) => n.trim())
|
|
935
|
+
.filter((n) => n && !/guardrails/.test(n));
|
|
936
|
+
return `next (state no-dev-set): call get_training_guardrails once (the rules the split, the dev fence and `
|
|
937
|
+
+ 'the leak audit enforce — read them before splitting, and not again), then forge_split for '
|
|
938
|
+
+ `\`${cmd.replace(/\s+#.*$/, '')}\`${notes.length ? ` (forge's notes on it: ${notes.join('; ')})` : ''}. `
|
|
939
|
+
+ 'Then call forge_status again.';
|
|
940
|
+
}
|
|
941
|
+
const lead = a.state === 'ready-to-score'
|
|
942
|
+
? 'training is done — the next step is the export: forge_export with the run_manifest, prereg and out '
|
|
943
|
+
+ `that result.advice.next_command names (\`${cmd}\`); it scores the test set ONCE (prereg-gated) and `
|
|
944
|
+
+ 'packages the model. result.advice.why names any other run to export the same way, each to its own '
|
|
945
|
+
+ 'folder.'
|
|
946
|
+
: `next (state ${a.state || 'unknown'}): \`${cmd}\`${asTools.length || terminal.length
|
|
947
|
+
? ` — as tools: ${tools.join(' → ')}` : ''}.`;
|
|
948
|
+
const term = terminal.map((c) => TERMINAL_STEP_TEXT[c]
|
|
949
|
+
?? `\`nmt-forge ${c}\` runs in a terminal (it has no tool)`);
|
|
950
|
+
return [lead, ...term.map((t) => `${t[0].toUpperCase()}${t.slice(1)}.`), 'Then call forge_status again.']
|
|
951
|
+
.join(' ');
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// ---------------------------------------------------------------------------
|
|
955
|
+
// forge_export's summary, shaped like the other forge tools' (Round 12 school
|
|
956
|
+
// persona: forge_export was the one forge tool whose envelope said
|
|
957
|
+
// `summary: null`). Content-free: the scores with their CIs, the near-twin
|
|
958
|
+
// reading, the twin-free model to quote or the one still to export, the
|
|
959
|
+
// prereg's counts, and what to serve.
|
|
960
|
+
// ---------------------------------------------------------------------------
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* forge_status's `summary.tools`: the tools of the next command in order —
|
|
964
|
+
* with get_training_guardrails first in state no-dev-set, where forge names
|
|
965
|
+
* it (after the command, in a comment) as the step BEFORE the split.
|
|
966
|
+
*/
|
|
967
|
+
export function statusTools(r) {
|
|
968
|
+
const cmd = r?.advice?.next_command;
|
|
969
|
+
const tools = forgeToolsIn(cmd);
|
|
970
|
+
if (r?.advice?.state === 'no-dev-set' && /get_training_guardrails/.test(String(cmd || ''))) {
|
|
971
|
+
return ['get_training_guardrails', ...tools.filter((t) => t !== 'get_training_guardrails')];
|
|
972
|
+
}
|
|
973
|
+
return tools;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* mt-eval's caveats on each export forge_status lists (advice.exports when
|
|
978
|
+
* forge gives them, else the snapshot's), keyed by run — only the exports
|
|
979
|
+
* that carry a list; the major ones flagged. null when none carries one.
|
|
980
|
+
*/
|
|
981
|
+
export function statusExportCaveats(r) {
|
|
982
|
+
const list = Array.isArray(r?.advice?.exports) ? r.advice.exports
|
|
983
|
+
: (Array.isArray(r?.snapshot?.exports) ? r.snapshot.exports : []);
|
|
984
|
+
const out = {};
|
|
985
|
+
const major = [];
|
|
986
|
+
for (const x of list) {
|
|
987
|
+
const kept = relayCaveats(x?.score_caveats);
|
|
988
|
+
if (!kept) continue;
|
|
989
|
+
const key = x.run || x.model_dir || x.dir;
|
|
990
|
+
out[key] = kept;
|
|
991
|
+
if (majorCaveats(kept).length) major.push(key);
|
|
992
|
+
}
|
|
993
|
+
return Object.keys(out).length ? { score_caveats: out, ...(major.length ? { major } : {}) } : null;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/** One score cell as {score, ci}. */
|
|
997
|
+
const scoreCell = (v) => ({ score: v.score, ci: [v.ci_lower ?? null, v.ci_upper ?? null] });
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* forge_export's summary.
|
|
1001
|
+
*
|
|
1002
|
+
* @param {object} r forge's `export --json` result
|
|
1003
|
+
* @returns {object|null}
|
|
1004
|
+
*/
|
|
1005
|
+
export function exportSummary(r) {
|
|
1006
|
+
if (!r || typeof r !== 'object' || !r.export_dir) return null;
|
|
1007
|
+
const out = {
|
|
1008
|
+
run: r.run ?? null,
|
|
1009
|
+
export_dir: r.export_dir,
|
|
1010
|
+
// the only folder to deploy; null for --no-model
|
|
1011
|
+
model_dir: r.model_dir ?? null,
|
|
1012
|
+
evaluated: r.evaluated === true,
|
|
1013
|
+
};
|
|
1014
|
+
if (out.evaluated) {
|
|
1015
|
+
out.test_set = r.dataset_id || r.battery || null;
|
|
1016
|
+
const scores = {};
|
|
1017
|
+
for (const [g, s] of Object.entries(r.test_groups || {})) {
|
|
1018
|
+
for (const [m, v] of Object.entries(s || {})) {
|
|
1019
|
+
if (v && typeof v === 'object' && typeof v.score === 'number') (scores[g] ??= {})[m] = scoreCell(v);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
out.scores = scores;
|
|
1023
|
+
// Scoring standard/1: forge's own headline (chrF++ with its CI and
|
|
1024
|
+
// sacreBLEU signature), never re-derived here.
|
|
1025
|
+
if (r.headline) out.headline = r.headline;
|
|
1026
|
+
}
|
|
1027
|
+
const nt = r.near_twin;
|
|
1028
|
+
if (nt && typeof nt === 'object') {
|
|
1029
|
+
out.near_twin = {
|
|
1030
|
+
checked: nt.checked === true,
|
|
1031
|
+
rows: nt.near_twin_rows ?? null,
|
|
1032
|
+
n: nt.n ?? null,
|
|
1033
|
+
recall_not_translation: nt.recall_not_translation === true,
|
|
1034
|
+
strict: nt.strict && typeof nt.strict.score === 'number' ? scoreCell(nt.strict) : null,
|
|
1035
|
+
};
|
|
1036
|
+
if (nt.advice) out.near_twin_advice = nt.advice;
|
|
1037
|
+
const planned = nt.twin_free_planned;
|
|
1038
|
+
if (planned && typeof planned === 'object') {
|
|
1039
|
+
out.twin_free_planned = {
|
|
1040
|
+
prereg: planned.prereg ?? null,
|
|
1041
|
+
prereg_before_reads: planned.prereg_before_reads ?? null,
|
|
1042
|
+
trained: planned.trained === true,
|
|
1043
|
+
next: planned.next ?? null,
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
// mt-eval's caveats on this score, verbatim (Round 13) — a MAJOR one goes
|
|
1048
|
+
// with the score wherever it is quoted (exportNextHint says so first)
|
|
1049
|
+
const caveats = relayCaveats(r.score_caveats);
|
|
1050
|
+
if (caveats) out.score_caveats = caveats;
|
|
1051
|
+
if (Array.isArray(r.twin_free_siblings) && r.twin_free_siblings.length) {
|
|
1052
|
+
out.twin_free_siblings = r.twin_free_siblings.map((s) => {
|
|
1053
|
+
const sc = relayCaveats(s?.score_caveats);
|
|
1054
|
+
return {
|
|
1055
|
+
run: s.run ?? null, dir: s.model_dir || s.export_dir || null, score: s.score ?? null,
|
|
1056
|
+
...(sc ? { score_caveats: sc } : {}),
|
|
1057
|
+
};
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
// the file forge_compare takes as hyps_a / hyps_b, and forge's command for it
|
|
1061
|
+
if (r.hypotheses) out.hypotheses = r.hypotheses;
|
|
1062
|
+
if (r.compare_hint) out.compare_hint = r.compare_hint;
|
|
1063
|
+
if (r.prereg && typeof r.prereg === 'object') {
|
|
1064
|
+
const counts = {};
|
|
1065
|
+
for (const v of r.prereg.verdicts || []) {
|
|
1066
|
+
const k = v?.human_verdict?.verdict ? `human_${v.human_verdict.verdict}` : v?.verdict;
|
|
1067
|
+
if (k) counts[k] = (counts[k] || 0) + 1;
|
|
137
1068
|
}
|
|
1069
|
+
out.prereg = { id: r.prereg.id, bound_by: r.prereg.bound_by ?? null, verdicts: counts,
|
|
1070
|
+
after_reads: r.prereg.after_reads?.text ?? null };
|
|
138
1071
|
}
|
|
139
|
-
|
|
1072
|
+
if (r.serve) out.serve = r.serve;
|
|
1073
|
+
return out;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/**
|
|
1077
|
+
* forge_export's next step: the twin-free model's own step first when forge
|
|
1078
|
+
* names one, then serving.
|
|
1079
|
+
*
|
|
1080
|
+
* @param {object} r forge's `export --json` result
|
|
1081
|
+
* @param {string} serveText the serve-is-a-terminal-step text
|
|
1082
|
+
* @returns {string}
|
|
1083
|
+
*/
|
|
1084
|
+
export function exportNextHint(r, serveText) {
|
|
1085
|
+
if (!r?.model_dir) {
|
|
1086
|
+
return `${caveatLead(r)}evaluation-only export (no model): read result.test_weighted and the battery report `
|
|
1087
|
+
+ '(forge_report / forge_lint on result.battery_report); there is nothing to serve.';
|
|
1088
|
+
}
|
|
1089
|
+
const planned = r?.near_twin?.twin_free_planned;
|
|
1090
|
+
const twin = planned?.next
|
|
1091
|
+
? `First relay summary.near_twin_advice to the user: the twin-free model of this test set is already `
|
|
1092
|
+
+ `planned${planned.prereg ? ` (preregistration ${planned.prereg})` : ''} — its next step is \`${planned.next}\``
|
|
1093
|
+
+ ` (${forgeToolsIn(planned.next).join(' → ')}); no new preregistration${planned.prereg_before_reads
|
|
1094
|
+
? ' is needed' : ' unless forge says so'}. `
|
|
1095
|
+
: (r?.near_twin?.recall_not_translation
|
|
1096
|
+
? 'First relay summary.near_twin_advice with the score — never this score alone. ' : '');
|
|
1097
|
+
return `${caveatLead(r)}${twin}${serveText} The command: ${r.serve || `nmt-forge serve ${r.export_dir}`}.`;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/** What goes before anything else in forge_export's next step: mt-eval's MAJOR caveats (Round 13). */
|
|
1101
|
+
function caveatLead(r) {
|
|
1102
|
+
const own = majorCaveats(r?.score_caveats).length
|
|
1103
|
+
? 'First relay summary.score_caveats with the score — never quote it without them. ' : '';
|
|
1104
|
+
const sib = (Array.isArray(r?.twin_free_siblings) ? r.twin_free_siblings : [])
|
|
1105
|
+
.some((s) => majorCaveats(s?.score_caveats).length)
|
|
1106
|
+
? 'The twin-free score this export cites carries mt-eval\'s SCORE CAVEAT — relay it with that score '
|
|
1107
|
+
+ '(summary.twin_free_siblings[].score_caveats). '
|
|
1108
|
+
: '';
|
|
1109
|
+
return own + sib;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// ---------------------------------------------------------------------------
|
|
1113
|
+
// forge_register_eval: a relative path is read from project_dir, as every
|
|
1114
|
+
// forge path is (forge runs there). Round 12 (school persona): the agent
|
|
1115
|
+
// passed a path relative to ITS directory, forge looked inside the project,
|
|
1116
|
+
// and the refusal was a bare FileNotFoundError with why/fix null. The MCP
|
|
1117
|
+
// knows both directories, so it names the exact path to pass.
|
|
1118
|
+
// ---------------------------------------------------------------------------
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* The refusal for a forge_register_eval path that is not under project_dir
|
|
1122
|
+
* but IS under this server's working directory, or null (forge's own check
|
|
1123
|
+
* — the resolved path and the fix — covers every other miss).
|
|
1124
|
+
*
|
|
1125
|
+
* @param {{path: string, projectDir?: string, cwd?: string, exists?: function}} a
|
|
1126
|
+
* @returns {string|null}
|
|
1127
|
+
*/
|
|
1128
|
+
export function registerEvalPathError({ path, projectDir, cwd = process.cwd(), exists = existsSync }) {
|
|
1129
|
+
if (!path || isAbsolute(path) || !projectDir) return null;
|
|
1130
|
+
const looked = resolve(cwd, projectDir, path);
|
|
1131
|
+
if (exists(looked)) return null;
|
|
1132
|
+
const here = resolve(cwd, path);
|
|
1133
|
+
if (!exists(here)) return null;
|
|
1134
|
+
const rel = relative(resolve(cwd, projectDir), here);
|
|
1135
|
+
return `forge_register_eval: no file at ${looked} — \`path\` is read relative to project_dir `
|
|
1136
|
+
+ `(${resolve(cwd, projectDir)}), where forge runs, like every forge path. The file is at ${here}: `
|
|
1137
|
+
+ `pass path: ${JSON.stringify(rel)} (relative to project_dir) or ${JSON.stringify(here)}.`;
|
|
140
1138
|
}
|