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.
@@ -0,0 +1,90 @@
1
+ /**
2
+ * One name for "the language" across every tool that takes ONE language.
3
+ *
4
+ * The tools grew their own names for it: `code` (get_language,
5
+ * language_overview, forge_discover, forge_init), `target`
6
+ * (get_metric_reliability), `query` (search_languages) — while list_queue,
7
+ * list_contests and every prompt say `language`. An agent that carried
8
+ * `language` from one tool to the next (a persona did:
9
+ * get_metric_reliability {"language": "crk"}) got MCP error -32602,
10
+ * "expected string, received undefined at target": the SDK drops the
11
+ * argument it does not know, then misses the one it requires.
12
+ *
13
+ * Every one-language tool now ALSO accepts `language`. The original name keeps
14
+ * working, so no existing caller breaks; both names are optional in the schema
15
+ * and the handler insists on exactly one language — a missing language or two
16
+ * different ones come back as a tool error that names both arguments.
17
+ */
18
+
19
+ import { z } from 'zod';
20
+
21
+ /**
22
+ * The schema for the `language` alias of a tool's own one-language argument.
23
+ *
24
+ * @param {string} primary The tool's original argument name (`code`, `target`, `query`).
25
+ * @returns {import('zod').ZodTypeAny}
26
+ */
27
+ export function languageAlias(primary) {
28
+ return z.string().optional()
29
+ .describe(`Same as \`${primary}\`: every tool that takes one language also accepts it as \`language\`. Pass one of the two.`);
30
+ }
31
+
32
+ /**
33
+ * Resolve a one-language tool's argument from its original name or the
34
+ * `language` alias.
35
+ *
36
+ * @param {object} args The parsed arguments.
37
+ * @param {string} primary The tool's original argument name.
38
+ * @param {string} tool The tool name (for the error text).
39
+ * @returns {{value: string}|{error: string}}
40
+ */
41
+ export function resolveLanguageArg(args, primary, tool) {
42
+ const norm = (v) => (typeof v === 'string' ? v.trim() : '');
43
+ const own = norm(args?.[primary]);
44
+ const alias = norm(args?.language);
45
+ if (own && alias && own.toLowerCase() !== alias.toLowerCase()) {
46
+ return {
47
+ error: `${tool}: \`${primary}\` ("${own}") and \`language\` ("${alias}") name different `
48
+ + 'languages. They are the same argument: pass one of them.',
49
+ };
50
+ }
51
+ const value = own || alias;
52
+ if (!value) {
53
+ return {
54
+ error: `${tool} needs a language: pass \`language\` (or \`${primary}\`, its original name), `
55
+ + 'e.g. {"language": "crk"}.',
56
+ };
57
+ }
58
+ return { value };
59
+ }
60
+
61
+ /** The tool-error result for a refused argument set. */
62
+ export function argError(text) {
63
+ return { content: [{ type: 'text', text }], isError: true };
64
+ }
65
+
66
+ /**
67
+ * Quote ONE argument for a POSIX shell, so a printed command can be pasted
68
+ * into a terminal as is: safe characters pass through, anything else is
69
+ * single-quoted (a ' inside becomes '\''). The MCP printed
70
+ * `--target-lang Ayta (variety not yet confirmed)` unquoted — not pasteable
71
+ * (Round 9 hospital persona). The argv itself is executed without a shell.
72
+ *
73
+ * @param {string|number} arg
74
+ * @returns {string}
75
+ */
76
+ export function shellQuote(arg) {
77
+ const s = String(arg);
78
+ if (s === '') return "''";
79
+ return /^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, "'\\''")}'`;
80
+ }
81
+
82
+ /**
83
+ * A command line for display: every argument shell-quoted, space-joined.
84
+ *
85
+ * @param {Array<string|number>} argv
86
+ * @returns {string}
87
+ */
88
+ export function shellJoin(argv) {
89
+ return argv.map(shellQuote).join(' ');
90
+ }
@@ -0,0 +1,474 @@
1
+ /**
2
+ * Contest tools — READ-ONLY views of Champollion contests (list_contests,
3
+ * get_contest).
4
+ *
5
+ * A contest is sovereign hosting (founder ruling 2026-09-06): an entry is a
6
+ * method handed to the organizer's air-gapped node and run there on a sealed
7
+ * set. Entering, submitting and ranking are human-authorized flows in the
8
+ * `mt-eval contest …` CLI — never an MCP tool. These tools only READ what an
9
+ * anonymous visitor may read, through the same public anon key and RLS the
10
+ * website uses:
11
+ *
12
+ * contests public + private rows (team contests are hidden)
13
+ * contest_phases public schedule (074)
14
+ * contest_submissions entries of PUBLIC contests — but its `submitted_by`
15
+ * column is the entrant's sign-in email, so it is never
16
+ * selected; only the display label is
17
+ * shared_tasks public editions (047)
18
+ * contest_deferred_results NO anon policy — withheld results are
19
+ * invisible here by design, and the answer says so
20
+ *
21
+ * Every read is bounded (per-request timeout + an overall deadline), the
22
+ * pattern the queue tools adopted after 0.1.0's timeouts. An empty answer
23
+ * from RLS is reported as "nothing visible to an anonymous reader", never as
24
+ * "nothing exists".
25
+ */
26
+
27
+ import { createHash } from 'node:crypto';
28
+ import { count } from './plural.js';
29
+
30
+ const SUPABASE_URL =
31
+ process.env.CHAMPOLLION_SUPABASE_URL || 'https://sjdomynysdljkbemupqa.supabase.co';
32
+ const SUPABASE_ANON_KEY =
33
+ process.env.CHAMPOLLION_SUPABASE_ANON_KEY || 'sb_publishable_bV6CFNFnzxhQI0wlBx2J0A_5Vm5gFBp';
34
+
35
+ const REQUEST_TIMEOUT_MS = 15_000;
36
+ const DEADLINE_MS = 40_000;
37
+ const LIST_CAP = 200;
38
+
39
+ /**
40
+ * Contest metadata keys that are PROMISES to participants, frozen by the
41
+ * database once a contest has entries. MIRROR of
42
+ * arena/mt_eval_harness/contest_policy.py FROZEN_PROMISE_KEYS (the Python
43
+ * SSOT, itself parity-tested against migrations 074/076); test/contests.test.js
44
+ * parses that file and fails if the two drift.
45
+ */
46
+ export const FROZEN_PROMISE_KEYS = Object.freeze([
47
+ 'tie_test', 'alpha', 'n_resamples', 'seed', 'require_description',
48
+ 'prize_terms', 'test_suites', 'anonymize_until_close', 'results_visibility',
49
+ 'allowed_tracks', 'open_weight_only', 'sealed_holdout_set_id',
50
+ 'metric_signature', 'harness_version', 'declared_power',
51
+ ]);
52
+
53
+ /** Contest columns an agent may see. `created_by` (an email) is never read. */
54
+ const CONTEST_SELECT = [
55
+ 'id', 'name', 'description', 'corpus_id', 'language_pair', 'visibility',
56
+ 'status', 'use_context', 'lane', 'metadata', 'created_at', 'shared_task_id',
57
+ 'intake_open',
58
+ ].join(',');
59
+
60
+ /** Entry columns. `submitted_by` (the entrant's sign-in email) is never read. */
61
+ const ENTRY_SELECT = [
62
+ 'run_card_id', 'team', 'submitter_label', 'submitted_at', 'is_primary',
63
+ 'track', 'phase', 'method_release_url',
64
+ ].join(',');
65
+
66
+ /** run_cards columns behind a contest's primary metric (aggregates only). */
67
+ const METRIC_COLUMN = {
68
+ chrf_plus_plus: 'chrf_plus_plus',
69
+ bleu: 'corpus_bleu',
70
+ comet_score: 'comet_score',
71
+ // A contest created before scoring standard/1 may still rank on it; it is
72
+ // shown under its retired name, never as a quality verdict.
73
+ composite: 'composite_score',
74
+ ter: 'ter',
75
+ };
76
+
77
+ /**
78
+ * Scoring standard/1: a contest's primary_metric defaults to chrF++, and a
79
+ * contest ranking is shown under its metric's display name.
80
+ */
81
+ export const DEFAULT_PRIMARY_METRIC = 'chrf_plus_plus';
82
+ export const METRIC_LABEL = {
83
+ chrf_plus_plus: 'chrF++',
84
+ bleu: 'BLEU',
85
+ comet_score: 'COMET',
86
+ ter: 'TER (lower is better)',
87
+ composite: 'legacy composite (retired)',
88
+ };
89
+ const metricLabel = (m) => METRIC_LABEL[m] ?? m;
90
+
91
+ function headers() {
92
+ return {
93
+ apikey: SUPABASE_ANON_KEY,
94
+ Authorization: `Bearer ${SUPABASE_ANON_KEY}`,
95
+ Accept: 'application/json',
96
+ };
97
+ }
98
+
99
+ /** One bounded anon GET; throws a readable error (never a raw TypeError). */
100
+ async function getJson(fetchImpl, path, deadline) {
101
+ const remaining = deadline - Date.now();
102
+ if (remaining <= 0) throw new Error('contest read deadline exceeded');
103
+ let resp;
104
+ try {
105
+ resp = await fetchImpl(`${SUPABASE_URL}/rest/v1/${path}`, {
106
+ headers: headers(),
107
+ signal: AbortSignal.timeout(Math.min(REQUEST_TIMEOUT_MS, remaining)),
108
+ });
109
+ } catch (err) {
110
+ throw new Error(`could not reach the public contest tables (${err.name === 'TimeoutError' ? 'timed out' : err.message})`);
111
+ }
112
+ const text = await resp.text();
113
+ if (!resp.ok) throw new Error(`HTTP ${resp.status} reading ${path.split('?')[0]}: ${text.slice(0, 160)}`);
114
+ let data;
115
+ try {
116
+ data = JSON.parse(text);
117
+ } catch {
118
+ throw new Error(`${path.split('?')[0]} did not return JSON`);
119
+ }
120
+ if (!Array.isArray(data)) throw new Error(`${path.split('?')[0]} did not return a row list`);
121
+ return data;
122
+ }
123
+
124
+ /** Keep only filter-safe characters in a user-supplied value (ids and slugs
125
+ * use letters, digits, `_ . : -`; pairs use `>`). Commas and parentheses —
126
+ * PostgREST list/grouping syntax — never survive. */
127
+ function clean(v) {
128
+ return String(v ?? '').replace(/[^\w.:>-]/g, '').trim();
129
+ }
130
+
131
+ /** Does a contest's "src>tgt" pair involve `code` on either side? */
132
+ function pairInvolves(pair, code) {
133
+ if (!code) return true;
134
+ const sides = String(pair || '').toLowerCase().split('>').map((s) => s.trim());
135
+ return sides.includes(code.toLowerCase());
136
+ }
137
+
138
+ /** A canonical-JSON SHA-256 of the contest's declared promises. */
139
+ export function promisesDigest(promises) {
140
+ const canon = (v) => {
141
+ if (Array.isArray(v)) return `[${v.map(canon).join(',')}]`;
142
+ if (v && typeof v === 'object') {
143
+ return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canon(v[k])}`).join(',')}}`;
144
+ }
145
+ return JSON.stringify(v);
146
+ };
147
+ return createHash('sha256').update(canon(promises)).digest('hex');
148
+ }
149
+
150
+ /** The promise block of a contest's metadata (+ its primary metric). */
151
+ export function extractPromises(metadata) {
152
+ const md = metadata && typeof metadata === 'object' ? metadata : {};
153
+ const declared = {};
154
+ if (md.primary_metric !== undefined) declared.primary_metric = md.primary_metric;
155
+ for (const k of FROZEN_PROMISE_KEYS) if (md[k] !== undefined) declared[k] = md[k];
156
+ return declared;
157
+ }
158
+
159
+ /**
160
+ * List contests visible to an anonymous reader.
161
+ *
162
+ * @param {object} [opts]
163
+ * @param {'open'|'closed'|'archived'|'all'} [opts.status='all']
164
+ * @param {string} [opts.language] ISO 639-3 code on either side of the pair
165
+ * @param {number} [opts.limit=20]
166
+ * @param {Function} [opts.fetchImpl]
167
+ */
168
+ export async function listContests({ status = 'all', language, limit = 20, fetchImpl = fetch } = {}) {
169
+ const deadline = Date.now() + DEADLINE_MS;
170
+ const q = new URLSearchParams({
171
+ select: CONTEST_SELECT,
172
+ order: 'created_at.desc',
173
+ limit: String(LIST_CAP),
174
+ });
175
+ if (status && status !== 'all') q.set('status', `eq.${clean(status)}`);
176
+ const [rows, tasks] = await Promise.all([
177
+ getJson(fetchImpl, `contests?${q}`, deadline),
178
+ getJson(fetchImpl, 'shared_tasks?select=shared_task_id,name,organizer,year,status,report_url&order=year.desc&limit=50', deadline)
179
+ .catch((err) => ({ error: err.message })),
180
+ ]);
181
+ const code = language ? clean(language).toLowerCase() : null;
182
+ const matching = rows.filter((r) => pairInvolves(r.language_pair, code));
183
+ return {
184
+ contests: matching.slice(0, limit).map((r) => ({
185
+ id: r.id,
186
+ name: r.name,
187
+ pair: r.language_pair,
188
+ status: r.status,
189
+ lane: r.lane,
190
+ useContext: r.use_context,
191
+ visibility: r.visibility,
192
+ intakeOpen: r.intake_open ?? null,
193
+ // Undeclared ⇒ the default (chrF++), and the answer says it is the default.
194
+ primaryMetric: r.metadata?.primary_metric ?? DEFAULT_PRIMARY_METRIC,
195
+ primaryMetricDeclared: r.metadata?.primary_metric != null,
196
+ resultsVisibility: r.metadata?.results_visibility ?? null,
197
+ sharedTask: r.shared_task_id ?? null,
198
+ createdAt: r.created_at ?? null,
199
+ })),
200
+ total: matching.length,
201
+ scannedCap: rows.length >= LIST_CAP ? LIST_CAP : null,
202
+ sharedTasks: Array.isArray(tasks) ? tasks : [],
203
+ sharedTasksError: Array.isArray(tasks) ? null : tasks.error,
204
+ filters: { status, language: code },
205
+ };
206
+ }
207
+
208
+ /**
209
+ * One contest: phases, declared promises (+ digest), visibility rules, and
210
+ * the public ranking when the contest has made one visible.
211
+ */
212
+ export async function getContest(id, { fetchImpl = fetch, now = Date.now() } = {}) {
213
+ const deadline = Date.now() + DEADLINE_MS;
214
+ const cid = clean(id);
215
+ if (!cid) return { status: 'bad-request', note: 'Pass a contest id (from list_contests).' };
216
+
217
+ const rows = await getJson(fetchImpl,
218
+ `contests?select=${CONTEST_SELECT}&id=eq.${encodeURIComponent(cid)}&limit=1`, deadline);
219
+ if (rows.length === 0) {
220
+ return {
221
+ status: 'not-visible',
222
+ note: `No contest "${cid}" is visible to an anonymous reader. Either it `
223
+ + 'does not exist, or it is a TEAM contest (team contests are not '
224
+ + 'anon-readable — sign in with `mt-eval auth login` and use '
225
+ + '`mt-eval contest list` to see contests you belong to). Use '
226
+ + 'list_contests to see every public contest.',
227
+ };
228
+ }
229
+ const c = rows[0];
230
+ const md = c.metadata && typeof c.metadata === 'object' ? c.metadata : {};
231
+
232
+ const enc = encodeURIComponent(cid);
233
+ const [phases, entries, task] = await Promise.all([
234
+ getJson(fetchImpl,
235
+ `contest_phases?select=name,starts_at,ends_at,max_submissions,max_submissions_per_day&contest_id=eq.${enc}&order=starts_at.asc`,
236
+ deadline).catch((err) => ({ error: err.message })),
237
+ getJson(fetchImpl,
238
+ `contest_submissions?select=${ENTRY_SELECT}&contest_id=eq.${enc}&order=submitted_at.asc&limit=500`,
239
+ deadline).catch((err) => ({ error: err.message })),
240
+ c.shared_task_id
241
+ ? getJson(fetchImpl,
242
+ `shared_tasks?select=shared_task_id,name,organizer,year,status,report_url,report_generated_at&shared_task_id=eq.${encodeURIComponent(c.shared_task_id)}&limit=1`,
243
+ deadline).then((r) => r[0] ?? null).catch(() => null)
244
+ : Promise.resolve(null),
245
+ ]);
246
+
247
+ const promises = extractPromises(md);
248
+ const resultsVisibility = md.results_visibility ?? null;
249
+ const closed = c.status === 'closed' || c.status === 'archived';
250
+ const anonymize = md.anonymize_until_close === true && !closed;
251
+
252
+ // Phases: which window covers `now` (computed here from the public schedule;
253
+ // the database's contest_active_phase() is the authority at submission time).
254
+ const phaseList = Array.isArray(phases) ? phases.map((p) => {
255
+ const s = Date.parse(p.starts_at);
256
+ const e = Date.parse(p.ends_at);
257
+ return {
258
+ name: p.name, startsAt: p.starts_at, endsAt: p.ends_at,
259
+ maxSubmissions: p.max_submissions ?? null,
260
+ maxPerDay: p.max_submissions_per_day ?? null,
261
+ active: Number.isFinite(s) && Number.isFinite(e) && now >= s && now < e,
262
+ };
263
+ }) : [];
264
+
265
+ const entryList = Array.isArray(entries) ? entries : [];
266
+ let ranking = null;
267
+ let rankingNote;
268
+ if (closed && md.final_ranking && typeof md.final_ranking === 'object') {
269
+ const fr = md.final_ranking;
270
+ ranking = {
271
+ kind: 'final',
272
+ metric: fr.metric ?? md.primary_metric ?? null,
273
+ method: fr.ranking_method ?? null,
274
+ entries: (Array.isArray(fr.entries) ? fr.entries : []).map((e) => ({
275
+ rank: e.rank ?? null,
276
+ tieGroup: e.tie_group ?? null,
277
+ // contest_rank's entry shape: submitter_label (never an email),
278
+ // else the per-contest pseudonym, else the team.
279
+ label: e.submitter_label ?? e.pseudonym ?? e.team ?? null,
280
+ model: e.model_slug ?? null,
281
+ score: e.primary?.value ?? null,
282
+ ci: e.primary && (e.primary.ci_lower != null || e.primary.ci_upper != null)
283
+ ? [e.primary.ci_lower ?? null, e.primary.ci_upper ?? null] : null,
284
+ runCardId: e.run_card_id ?? null,
285
+ primary: e.is_primary ?? null,
286
+ track: e.track ?? null,
287
+ })),
288
+ };
289
+ rankingNote = 'The FINAL ranking, frozen by the database at close (tie groups come from the declared significance test).';
290
+ } else if (closed) {
291
+ rankingNote = 'The contest is closed but carries no final_ranking — nothing official to show.';
292
+ } else if (resultsVisibility === 'hidden_until_close') {
293
+ rankingNote = 'Results are HIDDEN until close (results_visibility = hidden_until_close). '
294
+ + 'Scored entries wait in contest_deferred_results, which has no anonymous read '
295
+ + 'policy — nobody outside the organizer sees a score before the contest closes.';
296
+ } else if (entryList.length) {
297
+ // Interim view: the entries' published aggregate scores. NOT a ranking —
298
+ // no tie test, no trust policy; the official ranking is frozen at close.
299
+ const metric = md.primary_metric ?? DEFAULT_PRIMARY_METRIC;
300
+ const col = METRIC_COLUMN[metric];
301
+ // chrF++ travels with its 95% CI (scoring standard/1).
302
+ const ciCols = col === 'chrf_plus_plus' ? ',chrf_ci_lower,chrf_ci_upper' : '';
303
+ const ids = entryList.map((e) => clean(e.run_card_id)).filter(Boolean);
304
+ let cards = [];
305
+ if (col && ids.length) {
306
+ try {
307
+ cards = await getJson(fetchImpl,
308
+ `run_cards?select=id,model_slug,trust,${col}${ciCols}&id=in.(${ids.map(encodeURIComponent).join(',')})&trust=neq.disqualified`,
309
+ deadline);
310
+ } catch (err) {
311
+ rankingNote = `Interim scores unavailable (${err.message}).`;
312
+ }
313
+ }
314
+ const byId = new Map(cards.map((r) => [r.id, r]));
315
+ ranking = {
316
+ kind: 'interim',
317
+ metric,
318
+ entries: entryList.map((e) => {
319
+ const card = byId.get(e.run_card_id);
320
+ return {
321
+ rank: null,
322
+ label: anonymize ? null : (e.submitter_label ?? e.team ?? null),
323
+ model: card?.model_slug ?? null,
324
+ score: col && card ? (card[col] ?? null) : null,
325
+ ci: ciCols && card && card.chrf_ci_lower != null && card.chrf_ci_upper != null
326
+ ? [card.chrf_ci_lower, card.chrf_ci_upper] : null,
327
+ runCardId: e.run_card_id,
328
+ primary: e.is_primary ?? null,
329
+ track: e.track ?? null,
330
+ trust: card?.trust ?? null,
331
+ };
332
+ }),
333
+ };
334
+ rankingNote = rankingNote ?? ('INTERIM scores of the published entries — not a ranking (no tie test, '
335
+ + 'no trust policy applied). The official ranking is computed and frozen at close by '
336
+ + '`mt-eval contest close`.'
337
+ + (col ? '' : ` The primary metric "${metric}" is not a run_cards column, so scores are omitted.`)
338
+ + (anonymize ? ' Entrant labels are hidden until close (anonymize_until_close).' : ''));
339
+ } else {
340
+ rankingNote = 'No entries are visible yet.';
341
+ }
342
+
343
+ return {
344
+ status: 'ok',
345
+ contest: {
346
+ id: c.id,
347
+ name: c.name,
348
+ description: c.description || '',
349
+ pair: c.language_pair,
350
+ corpusId: c.corpus_id,
351
+ status: c.status,
352
+ lane: c.lane,
353
+ useContext: c.use_context,
354
+ visibility: c.visibility,
355
+ intakeOpen: c.intake_open ?? null,
356
+ createdAt: c.created_at,
357
+ closedAt: md.closed_at ?? null,
358
+ },
359
+ sharedTask: task,
360
+ phases: phaseList,
361
+ phasesError: Array.isArray(phases) ? null : phases.error,
362
+ promises,
363
+ promisesDigest: Object.keys(promises).length ? promisesDigest(promises) : null,
364
+ resultsVisibility,
365
+ entryCount: entryList.length,
366
+ entriesError: Array.isArray(entries) ? null : entries.error,
367
+ ranking,
368
+ rankingNote,
369
+ };
370
+ }
371
+
372
+ // ---------------------------------------------------------------------------
373
+ // Rendering
374
+ // ---------------------------------------------------------------------------
375
+
376
+ const ENTRY_DOCS = 'https://champollion.dev/docs/network/sovereignty/run-a-sovereign-contest';
377
+
378
+ /** Text for a listContests() answer. */
379
+ export function formatContestList(r) {
380
+ const scope = [r.filters.status !== 'all' && `status ${r.filters.status}`, r.filters.language && `language ${r.filters.language}`]
381
+ .filter(Boolean).join(', ');
382
+ const out = [];
383
+ if (r.total === 0) {
384
+ out.push(`No contests${scope ? ` (${scope})` : ''} are visible to an anonymous reader.`);
385
+ out.push('That covers public and private contests; team contests are never anon-readable. '
386
+ + 'An empty list is the honest state of the public network, not an error.');
387
+ } else {
388
+ out.push(`${count(r.total, 'contest')}${scope ? ` for ${scope}` : ''} visible to an anonymous reader`
389
+ + `${r.scannedCap ? ` (first ${r.scannedCap} scanned)` : ''}:`);
390
+ for (const c of r.contests) {
391
+ out.push(` ${c.id} — ${c.name} [${c.pair}] status ${c.status}, ${c.lane} lane, ${c.useContext}`
392
+ + `, ranks on ${metricLabel(c.primaryMetric)}${c.primaryMetricDeclared ? '' : ' (the default; none declared)'}`
393
+ + `${c.resultsVisibility === 'hidden_until_close' ? ', results hidden until close' : ''}`
394
+ + `${c.intakeOpen === true ? ', intake OPEN' : c.intakeOpen === false ? ', intake closed' : ''}`);
395
+ }
396
+ if (r.total > r.contests.length) out.push(` … showing ${r.contests.length} of ${r.total} (raise limit)`);
397
+ }
398
+ if (r.sharedTasks.length) {
399
+ out.push('');
400
+ out.push('Shared-task editions:');
401
+ for (const t of r.sharedTasks) {
402
+ out.push(` ${t.shared_task_id} — ${t.name} (${t.organizer}, ${t.year}) ${t.status}${t.report_url ? ` · report ${t.report_url}` : ''}`);
403
+ }
404
+ } else if (r.sharedTasksError) {
405
+ out.push(`(shared-task editions unavailable: ${r.sharedTasksError})`);
406
+ }
407
+ out.push('');
408
+ out.push('Next: get_contest { "id": "<id>" } for phases, declared terms and results. '
409
+ + `Entering is a human-authorized CLI flow (\`mt-eval contest qualify\`, then submit-model / submit-method) — ${ENTRY_DOCS}`);
410
+ return out.join('\n');
411
+ }
412
+
413
+ /** Text for a getContest() answer. */
414
+ export function formatContest(r) {
415
+ if (r.status !== 'ok') return r.note;
416
+ const c = r.contest;
417
+ const out = [];
418
+ out.push(`# ${c.name} (${c.id})`);
419
+ out.push(`Pair ${c.pair} · corpus ${c.corpusId} · status ${c.status} · ${c.lane} lane · ${c.useContext} · ${c.visibility}`
420
+ + `${c.intakeOpen === true ? ' · intake OPEN' : c.intakeOpen === false ? ' · intake closed' : ''}`);
421
+ if (c.description) out.push(c.description.slice(0, 600));
422
+ if (r.sharedTask) {
423
+ out.push(`Shared task: ${r.sharedTask.name} (${r.sharedTask.organizer}, ${r.sharedTask.year})`
424
+ + `${r.sharedTask.report_url ? ` — report ${r.sharedTask.report_url}` : ''}`);
425
+ }
426
+ out.push('');
427
+ if (r.phases.length) {
428
+ out.push('Phases (the deadline is enforced by the database at the method door):');
429
+ for (const p of r.phases) {
430
+ const caps = [p.maxSubmissions != null && `max ${p.maxSubmissions}`, p.maxPerDay != null && `${p.maxPerDay}/day`]
431
+ .filter(Boolean).join(', ');
432
+ out.push(` ${p.active ? '▶' : ' '} ${p.name}: ${p.startsAt} → ${p.endsAt}${caps ? ` (${caps})` : ''}`);
433
+ }
434
+ } else if (r.phasesError) {
435
+ out.push(`Phases: unavailable (${r.phasesError})`);
436
+ } else {
437
+ out.push('Phases: none declared (an un-phased contest uses the sealed set\'s daily request limit).');
438
+ }
439
+ out.push('');
440
+ const keys = Object.keys(r.promises);
441
+ if (keys.length) {
442
+ out.push('Declared terms (PROMISES — frozen by the database once the contest has entries):');
443
+ for (const k of keys) {
444
+ const v = r.promises[k];
445
+ out.push(` ${k}: ${typeof v === 'object' ? JSON.stringify(v) : String(v)}`.slice(0, 400));
446
+ }
447
+ out.push(` terms digest: sha256 ${r.promisesDigest} — computed here over the canonical JSON of the keys above (not a value stored by the database); re-read and compare to detect a change.`);
448
+ } else {
449
+ out.push('Declared terms: none recorded on this contest.');
450
+ }
451
+ out.push(`Results visibility: ${r.resultsVisibility ?? 'not declared — read as "immediate" (a contest that recorded no value promised no withholding)'}`);
452
+ out.push('');
453
+ out.push(`Entries visible to an anonymous reader: ${r.entryCount}${r.entriesError ? ` (could not read entries: ${r.entriesError})` : ''}`);
454
+ out.push(r.rankingNote);
455
+ if (r.ranking && r.ranking.entries.length) {
456
+ for (const e of r.ranking.entries.slice(0, 50)) {
457
+ const bits = [
458
+ r.ranking.kind === 'final' ? `#${e.rank ?? '?'}${e.tieGroup != null ? ` (tie group ${e.tieGroup})` : ''}` : '•',
459
+ e.label ?? (r.ranking.kind === 'interim' ? '(label hidden or unset)' : '(no label)'),
460
+ e.model,
461
+ // "chrF++ 47.5 [45.9, 49.0]" — the metric by name, the CI beside it.
462
+ e.score != null ? `${metricLabel(r.ranking.metric)} ${e.score}${e.ci ? ` [${e.ci[0] ?? '?'}, ${e.ci[1] ?? '?'}]` : ''}` : null,
463
+ e.primary === false ? 'contrastive' : null,
464
+ e.track,
465
+ e.trust ? `trust ${e.trust}` : null,
466
+ e.runCardId ? `run ${e.runCardId}` : null,
467
+ ].filter(Boolean);
468
+ out.push(` ${bits.join(' ')}`);
469
+ }
470
+ }
471
+ out.push('');
472
+ out.push(`Entering: human-authorized CLI only (\`mt-eval contest qualify\` → submit-model / submit-method). ${ENTRY_DOCS}`);
473
+ return out.join('\n');
474
+ }