champollion 0.3.3 → 0.4.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.
Files changed (142) hide show
  1. package/README.md +52 -37
  2. package/bin/cli.js +53 -5
  3. package/index.js +63 -2
  4. package/lib/api-key.js +17 -4
  5. package/lib/autofix.js +83 -36
  6. package/lib/bridge/method_bridge.py +15 -3
  7. package/lib/cards/reader.js +51 -3
  8. package/lib/cards/remote.js +15 -0
  9. package/lib/cards/search-names.js +178 -0
  10. package/lib/command-help.js +289 -88
  11. package/lib/commands/audit.js +10 -3
  12. package/lib/commands/card.js +583 -226
  13. package/lib/commands/doctor.js +54 -18
  14. package/lib/commands/help.js +37 -32
  15. package/lib/commands/init.js +1689 -87
  16. package/lib/commands/integrity.js +127 -40
  17. package/lib/commands/leaderboard.js +187 -67
  18. package/lib/commands/models.js +9 -2
  19. package/lib/commands/provenance.js +7 -2
  20. package/lib/commands/recommend.js +43 -14
  21. package/lib/commands/register-corpus.js +649 -130
  22. package/lib/commands/seal-corpus.js +1 -1
  23. package/lib/commands/status.js +564 -27
  24. package/lib/commands/submit.js +17 -12
  25. package/lib/commands/sync.js +31 -7
  26. package/lib/commands/tm.js +16 -10
  27. package/lib/commands/verify.js +27 -3
  28. package/lib/commands/wrap.js +63 -5
  29. package/lib/commands/xliff.js +135 -64
  30. package/lib/commercial-eligibility.js +1 -1
  31. package/lib/config.js +196 -14
  32. package/lib/content-estimate.js +96 -0
  33. package/lib/content-refusals.js +270 -0
  34. package/lib/content-review.js +372 -0
  35. package/lib/content-sync.js +1127 -344
  36. package/lib/content.js +94 -7
  37. package/lib/corpus-registration.mjs +197 -38
  38. package/lib/cost-label.js +29 -0
  39. package/lib/cost-report.js +726 -78
  40. package/lib/diff.js +38 -4
  41. package/lib/docusaurus-sync.js +965 -253
  42. package/lib/edit-distance.js +31 -0
  43. package/lib/fallback.js +964 -0
  44. package/lib/file-scope.js +106 -0
  45. package/lib/flatten.js +80 -3
  46. package/lib/flutter-locales.js +124 -0
  47. package/lib/format.js +266 -12
  48. package/lib/hash.js +146 -21
  49. package/lib/icu-structure.js +929 -0
  50. package/lib/integrity.js +223 -75
  51. package/lib/language-pair.js +157 -0
  52. package/lib/lint.js +78 -16
  53. package/lib/local-only-marks.js +106 -0
  54. package/lib/locale-layout.js +1103 -0
  55. package/lib/locale-state.js +571 -0
  56. package/lib/methods/anthropic.js +5 -0
  57. package/lib/methods/apertium.js +6 -3
  58. package/lib/methods/api.js +138 -25
  59. package/lib/methods/base.js +17 -0
  60. package/lib/methods/coaching-data.js +153 -0
  61. package/lib/methods/content-separator.js +43 -0
  62. package/lib/methods/deepl.js +1 -1
  63. package/lib/methods/direct-llm.js +252 -103
  64. package/lib/methods/external.js +146 -63
  65. package/lib/methods/gemini.js +1 -0
  66. package/lib/methods/google-translate.js +1 -0
  67. package/lib/methods/http-utils.js +41 -0
  68. package/lib/methods/libretranslate.js +7 -2
  69. package/lib/methods/llm-coached.js +68 -128
  70. package/lib/methods/llm.js +80 -31
  71. package/lib/methods/local.js +93 -10
  72. package/lib/methods/microsoft-translator.js +1 -2
  73. package/lib/methods/openai.js +4 -2
  74. package/lib/methods/openrouter-client.js +20 -19
  75. package/lib/methods/openrouter-pricing.js +150 -13
  76. package/lib/methods/prompt-methods.js +20 -0
  77. package/lib/methods/provider-pricing.js +42 -1
  78. package/lib/methods/request-capture.js +104 -0
  79. package/lib/methods/tilde.js +1 -1
  80. package/lib/methods/translated.js +1 -2
  81. package/lib/missing-key.js +93 -0
  82. package/lib/models.js +11 -0
  83. package/lib/name-rules.js +32 -0
  84. package/lib/named-keys.js +172 -0
  85. package/lib/no-translate.js +4 -3
  86. package/lib/output.js +160 -19
  87. package/lib/pairs.js +586 -30
  88. package/lib/placeholders.js +394 -0
  89. package/lib/plugins.js +8 -0
  90. package/lib/plural-gap-redo.js +109 -0
  91. package/lib/plurals.js +323 -0
  92. package/lib/po.js +1187 -0
  93. package/lib/public-catalogue.js +74 -0
  94. package/lib/recommend.js +527 -32
  95. package/lib/redo.js +95 -0
  96. package/lib/refusal-category.js +44 -0
  97. package/lib/registers.js +255 -11
  98. package/lib/repair-script.js +20 -13
  99. package/lib/scripts.js +193 -106
  100. package/lib/seal.mjs +6 -5
  101. package/lib/sealed-qualifier.mjs +2 -2
  102. package/lib/segment.js +2 -1
  103. package/lib/seo.js +19 -9
  104. package/lib/serve.js +43 -6
  105. package/lib/shared-output-seed.js +164 -0
  106. package/lib/source-contexts.js +39 -0
  107. package/lib/submit.mjs +57 -5
  108. package/lib/sync.js +2923 -474
  109. package/lib/terminology.js +13 -4
  110. package/lib/tm-evict.js +179 -0
  111. package/lib/tm-seed.js +5 -2
  112. package/lib/tm.js +818 -36
  113. package/lib/translate-pair.js +639 -34
  114. package/lib/translate.js +78 -5
  115. package/lib/types.js +22 -3
  116. package/lib/validate.js +880 -17
  117. package/lib/verify.js +1296 -104
  118. package/lib/watch.js +32 -13
  119. package/lib/xliff.js +44 -3
  120. package/package.json +3 -2
  121. package/shared/CORPORA-CARDS.md +2 -0
  122. package/shared/DATA-SOVEREIGNTY.md +19 -20
  123. package/shared/LANGUAGE-CARD-FIELDS.md +1 -1
  124. package/shared/cards-fallback.json +1 -1
  125. package/shared/catalogue/card-config.json +1 -1
  126. package/shared/curated-orthography-conventions.json +26 -8
  127. package/shared/docent/faq.en.json +14 -16
  128. package/shared/docent/system-prompt.md +17 -19
  129. package/shared/explainers/tc-features.json +15 -15
  130. package/shared/gettext-plural-forms.json +45 -0
  131. package/shared/human-services.json +1 -1
  132. package/shared/method-registry.json +2 -0
  133. package/shared/metric-registry.json +96 -18
  134. package/shared/schemas/champollion-plugin.schema.json +4 -0
  135. package/shared/schemas/corpora-card.schema.json +20 -10
  136. package/shared/schemas/human-services.schema.json +2 -2
  137. package/shared/schemas/language-card.schema.json +1 -1
  138. package/shared/schemas/method-card.schema.json +1 -1
  139. package/shared/schemas/method-index-record.schema.json +67 -0
  140. package/shared/schemas/method-registry.schema.json +4 -0
  141. package/shared/schemas/metric-registry.schema.json +55 -1
  142. package/shared/docent/corpus.json +0 -11333
@@ -7,15 +7,418 @@
7
7
  */
8
8
 
9
9
  import { resolveConfig, autoDetectLanguages } from '../config.js';
10
+ import { discoverLocaleLayout, loadSourceUnits, expectedForTarget, readLocaleFlat, lockKey } from '../locale-layout.js';
11
+ import { readLock } from '../hash.js';
12
+ import { LockState, localeHealth, countReplacedEdits, REPLACED_EDITS_FILENAME, decodeWritten, valueHash } from '../locale-state.js';
13
+ import { originKey } from '../plurals.js';
14
+ import { redoCommand } from '../verify.js';
15
+ import { tmProofTextsFor } from '../tm-evict.js';
10
16
  import { resolvePairs, QUALITY_TIERS } from '../pairs.js';
11
17
  import { loadPlugins, resolvePluginForPair } from '../plugins.js';
12
- import { DEFAULT_REGISTERS, getLanguageCard, getRegisterPresets, DEFAULT_REGISTER_FALLBACK } from '../registers.js';
13
- import { loadTM, tmSize, TM_DIR, TM_FILENAME } from '../tm.js';
18
+ import { DEFAULT_REGISTERS, getLanguageCard, getRegisterPresets, DEFAULT_REGISTER_FALLBACK, summarizeGenderGuidance } from '../registers.js';
19
+ import { loadTM, tmSize, modelsBehindValues, tmMethodKey, canonicalWriterKey, fallbackWriterOf, describeMethodKey, adoptLegacyCoachingKeys, TM_DIR, TM_FILENAME } from '../tm.js';
20
+ import { COACHING_PROMPT_METHODS } from '../methods/prompt-methods.js';
14
21
  import { getMethod } from '../translate.js';
22
+ import { contentStatus } from '../content-sync.js';
23
+ import { PLAIN_LLM_METHODS } from '../methods/llm.js';
15
24
  import { output } from '../output.js';
25
+ import { FALLBACK_MAJORITY_SHARE } from '../fallback.js';
16
26
  import fs from 'node:fs';
17
27
  import path from 'node:path';
18
28
 
29
+ /**
30
+ * Did the config set this pair's quality tier? A pair resolved without one
31
+ * carries the default label in `_defaults` (lib/pairs.js); a pair object
32
+ * without that record (older callers) counts as set unless it is the
33
+ * default, so nothing a person wrote is hidden.
34
+ *
35
+ * @param {object} pairConfig
36
+ * @returns {boolean}
37
+ */
38
+ function qualityTierSet(pairConfig) {
39
+ if (!pairConfig.qualityTier) return false;
40
+ if (pairConfig._defaults instanceof Set) return !pairConfig._defaults.has('qualityTier');
41
+ return pairConfig.qualityTier !== 'standard';
42
+ }
43
+
44
+ /** Methods that run a model the config names (the LLM methods). */
45
+ function runsModel(method) {
46
+ return PLAIN_LLM_METHODS.has(method) || method === 'llm-coached';
47
+ }
48
+
49
+ /**
50
+ * The model a pair (or a fallback) will actually use: direct providers may
51
+ * carry model=null (the method class picks its own default at runtime).
52
+ * null for a method that runs no model the config names — `api` (the model
53
+ * is whatever its endpoint serves) and the MT engines (deepl, google-translate…),
54
+ * which used to be shown "model: auto".
55
+ */
56
+ function displayModelFor(pairConfig) {
57
+ if (!runsModel(pairConfig.method)) return null;
58
+ if (pairConfig.model) return pairConfig.model;
59
+ // llm-coached on a direct provider runs that provider's default model.
60
+ const transport = pairConfig.method === 'llm-coached' && pairConfig.provider && pairConfig.provider !== 'openrouter'
61
+ ? pairConfig.provider
62
+ : pairConfig.method;
63
+ try {
64
+ return getMethod(transport)._getDefaultModel?.() || 'auto';
65
+ } catch {
66
+ return 'auto';
67
+ }
68
+ }
69
+
70
+ /**
71
+ * The header's model/endpoint summary. "Default model" only when a pair
72
+ * actually runs the top-level model (or, with no pairs yet, when the
73
+ * default method runs one): an api-only project printed the built-in
74
+ * OpenRouter default, a model nothing in it uses. The endpoints of `api`
75
+ * pairs are listed instead.
76
+ *
77
+ * @returns {{ defaultModel: string|null, endpoints: string[] }}
78
+ */
79
+ function headerModels(config, pairs) {
80
+ const list = [...pairs.values()];
81
+ const usesDefault = list.length === 0
82
+ ? runsModel(config.defaultMethod || 'llm')
83
+ : list.some(p => displayModelFor(p) === config.model);
84
+ const endpoints = [...new Set(list.filter(p => p.method === 'api' && p.endpoint).map(p => p.endpoint))];
85
+ return { defaultModel: usesDefault ? config.model : null, endpoints };
86
+ }
87
+
88
+ /** Methods that run a model the user picks — its licence is theirs to check. */
89
+ const MODEL_YOU_CHOOSE = new Set(['local', 'api', 'external']);
90
+ const MODEL_YOU_CHOOSE_NOTE = 'runs a model you choose — Champollion cannot check its licence; make sure your use of it is allowed';
91
+
92
+ /**
93
+ * Per target locale: which models produced the values its files hold now
94
+ * (lib/tm.js modelsBehindValues). Empty when there is no cache, or the
95
+ * layout cannot be read — status is a diagnostic and never fails on it.
96
+ *
97
+ * @returns {Map<string, Array<{ model: string, keys: number, current: boolean }>>}
98
+ */
99
+ function modelsInFiles(config, pairs, cwd) {
100
+ const out = new Map();
101
+ if (config.format === 'docusaurus') return out;
102
+ const hasTM = fs.existsSync(path.join(cwd, TM_DIR, TM_FILENAME));
103
+ let layout;
104
+ let units;
105
+ let state;
106
+ try {
107
+ layout = discoverLocaleLayout(config, { cwd });
108
+ units = loadSourceUnits(layout);
109
+ state = new LockState(readLock(cwd).locales);
110
+ } catch {
111
+ return out;
112
+ }
113
+ if (!hasTM && Object.values(state.locales).every(l => Object.keys(l.by || {}).length === 0)) return out;
114
+ const tm = hasTM ? loadTM(cwd) : {};
115
+ // A cache from before coaching was keyed reads as sync reads it (in memory).
116
+ adoptLegacyCoachingKeys(tm, pairs.values());
117
+ for (const [, pairConfig] of pairs) {
118
+ const items = [];
119
+ const localeState = state.peek(pairConfig.target);
120
+ for (const unit of units) {
121
+ let file;
122
+ try { file = layout.fileFor(pairConfig.target, unit.ns); } catch { continue; }
123
+ if (!file || !fs.existsSync(file.path)) continue;
124
+ let target;
125
+ try { target = readLocaleFlat(file); } catch { continue; }
126
+ const { flat: expected, expansion } = expectedForTarget(unit, config.inputLocale, pairConfig.target);
127
+ for (const [key, src] of Object.entries(expected)) {
128
+ if (typeof src !== 'string' || typeof target[key] !== 'string') continue;
129
+ // The lock's record of who wrote the value, while it is still the
130
+ // value on disk (a hand edit since makes it no longer apply).
131
+ const lk = lockKey(layout, unit.ns, key);
132
+ const record = decodeWritten(localeState.written[lk]);
133
+ const by = record && record.value === valueHash(target[key]) ? (localeState.by[lk] || null) : null;
134
+ // A key from before coaching was keyed, for this same setup, reads
135
+ // as today's (lib/tm.js canonicalWriterKey).
136
+ const writtenBy = by ? canonicalWriterKey(tm, pairConfig.target, by) : null;
137
+ items.push({ texts: tmProofTextsFor(key, src, expansion), value: target[key], writtenBy });
138
+ }
139
+ }
140
+ out.set(pairConfig.target, modelsBehindValues(tm, pairConfig, items));
141
+ }
142
+ return out;
143
+ }
144
+
145
+ /**
146
+ * Per target locale (pairs with a fallback): how many values in its files the
147
+ * fallback method wrote — the lock's `by` record naming the fallback's method
148
+ * key, while the value is still the one sync wrote. The files themselves do
149
+ * not say which strings came from the fallback (Round 8, school + hospital
150
+ * personas). Key-value files only; empty when nothing can be read.
151
+ *
152
+ * `earlier`: values the fallback wrote as it was set up BEFORE (another
153
+ * model, register or coaching of it — lib/tm.js fallbackWriterOf), which a
154
+ * redo re-translates (Round 10, school persona: after a coaching file was
155
+ * added to the fallback, nothing showed that its values predate it).
156
+ *
157
+ * `written`: every value sync wrote that is still as it wrote it (any
158
+ * method) — the whole the fallback's share is a part of (Round 11, school
159
+ * persona: status said "8 value(s)" with nothing to measure it against).
160
+ *
161
+ * @returns {Map<string, { count: number, keys: string[], earlier: { count: number, keys: string[], by: string[] }, written: number }>}
162
+ */
163
+ function fallbackValuesInFiles(config, pairs, cwd) {
164
+ const out = new Map();
165
+ if (config.format === 'docusaurus') return out;
166
+ if (![...pairs.values()].some(pc => pc.fallback)) return out;
167
+ let layout;
168
+ let units;
169
+ let state;
170
+ try {
171
+ layout = discoverLocaleLayout(config, { cwd });
172
+ units = loadSourceUnits(layout);
173
+ state = new LockState(readLock(cwd).locales);
174
+ } catch {
175
+ return out;
176
+ }
177
+ const tm = fs.existsSync(path.join(cwd, TM_DIR, TM_FILENAME)) ? loadTM(cwd) : {};
178
+ adoptLegacyCoachingKeys(tm, pairs.values());
179
+ for (const [, pairConfig] of pairs) {
180
+ if (!pairConfig.fallback) continue;
181
+ const localeState = state.peek(pairConfig.target);
182
+ const keys = [];
183
+ const earlier = { count: 0, keys: [], by: [] };
184
+ let written = 0;
185
+ for (const unit of units) {
186
+ let file;
187
+ try { file = layout.fileFor(pairConfig.target, unit.ns); } catch { continue; }
188
+ if (!file || !fs.existsSync(file.path)) continue;
189
+ let target;
190
+ try { target = readLocaleFlat(file); } catch { continue; }
191
+ const { flat: expected } = expectedForTarget(unit, config.inputLocale, pairConfig.target);
192
+ for (const key of Object.keys(expected)) {
193
+ const lk = lockKey(layout, unit.ns, key);
194
+ if (typeof target[key] !== 'string') continue;
195
+ const record = decodeWritten(localeState.written[lk]);
196
+ if (!record || record.value !== valueHash(target[key])) continue;
197
+ // A value sync wrote that is still as written: the share's whole.
198
+ written++;
199
+ const by = localeState.by?.[lk];
200
+ if (!by) continue;
201
+ const writer = fallbackWriterOf(pairConfig, canonicalWriterKey(tm, pairConfig.target, by));
202
+ if (!writer) continue;
203
+ if (writer === 'current') keys.push(lk);
204
+ else {
205
+ earlier.keys.push(lk);
206
+ if (!earlier.by.includes(by)) earlier.by.push(by);
207
+ }
208
+ }
209
+ }
210
+ earlier.count = earlier.keys.length;
211
+ out.set(pairConfig.target, { count: keys.length, keys, earlier, written });
212
+ }
213
+ return out;
214
+ }
215
+
216
+ /**
217
+ * The status line for a locale whose files hold text from a model other than
218
+ * the current one — a mix of two models, or ALL of it from an earlier model
219
+ * (Round 4, Next.js persona: only the mixed case was reported) — or null.
220
+ */
221
+ function mixedModelsLine(pairKey, models, currentModel = null) {
222
+ if (!models || models.length === 0) return null;
223
+ const unify = `champollion sync --pair ${pairKey} --redo all --fresh-on-model-change`;
224
+ const plural = (n) => `${n} key${n === 1 ? '' : 's'}`;
225
+ if (models.length === 1) {
226
+ const only = models[0];
227
+ if (only.current) return null;
228
+ if (only.unknown) {
229
+ return ` model unknown: ${plural(only.keys)} hold text that ${only.candidates.join(' and ')} cached identically, so which of them wrote it `
230
+ + `cannot be told${currentModel ? ` (the current model is ${currentModel})` : ''}. To have the current model translate them: ${unify}`;
231
+ }
232
+ // Both directions: a one-off `sync --model` run wrote them, and the user
233
+ // may want to keep that model (Round 10, Next.js persona).
234
+ return ` earlier model: every translation in the files that can be attributed (${plural(only.keys)}) `
235
+ + `came from ${only.model}${currentModel ? `, not the current model (${currentModel})` : ''}. To have the current model translate them: ${unify}`
236
+ + ` — or, to keep ${only.model}'s text, make it the model: "model": "${only.model}" in champollion.config.json (nothing is sent)`;
237
+ }
238
+ const list = models.map(m => (m.unknown
239
+ ? `model unknown (${plural(m.keys)}: ${m.candidates.join(' and ')} cached the same text)`
240
+ : `${m.model} (${plural(m.keys)}${m.current ? ', current' : ''})`)).join(', ');
241
+ const known = models.filter(m => !m.unknown).length;
242
+ return ` mixed: the files hold text from ${known > 1 ? `${known} models` : 'more than one model'} — ${list}. To have the current model translate the ones an earlier model wrote: ${unify} (what the current model already translated comes from the cache)`;
243
+ }
244
+
245
+ /**
246
+ * Per target locale: keys a redo left pending, translations made from an
247
+ * older source text (out of date), and keys held back because the quality
248
+ * gate refused this method's translation (lib/locale-state.js). Empty for a
249
+ * locale with nothing to say; status never fails on it.
250
+ *
251
+ * @returns {Map<string, { stale: string[], pending: Array<{key: string, reason: string, held: boolean}>, held: string[] }>}
252
+ */
253
+ function localeHealthMap(config, pairs, cwd) {
254
+ const out = new Map();
255
+ if (config.format === 'docusaurus') return out;
256
+ let layout;
257
+ let units;
258
+ let lock;
259
+ try {
260
+ layout = discoverLocaleLayout(config, { cwd });
261
+ units = loadSourceUnits(layout);
262
+ lock = readLock(cwd);
263
+ } catch {
264
+ return out;
265
+ }
266
+ const state = new LockState(lock.locales);
267
+ const tm = fs.existsSync(path.join(cwd, TM_DIR, TM_FILENAME)) ? loadTM(cwd) : null;
268
+ for (const [, pairConfig] of pairs) {
269
+ try {
270
+ out.set(pairConfig.target, localeHealth({
271
+ layout, units, inputLocale: config.inputLocale, code: pairConfig.target,
272
+ localeState: state.peek(pairConfig.target), manifest: lock.source, tm, pairConfig,
273
+ helpers: { expectedForTarget, readLocaleFlat, lockKey, originKey, fallbackPrefix: config.fallbackPrefix },
274
+ }));
275
+ } catch { /* a diagnostic: never fails */ }
276
+ }
277
+ return out;
278
+ }
279
+
280
+ /**
281
+ * One line on a pair's gender guidance, or null when there is none to say.
282
+ * @returns {string|null}
283
+ */
284
+ function describeGenderSetting(pairConfig) {
285
+ const how = '"genderGuidance" in champollion.config.json changes it (false = none, or your own words)';
286
+ if (pairConfig.genderGuidanceSource === 'off') return `gender: no guidance (set off in the config) — ${how}`;
287
+ const text = summarizeGenderGuidance(pairConfig.genderGuidance);
288
+ if (!text) return null;
289
+ const from = pairConfig.genderGuidanceSource === 'config' ? 'from your config' : 'Champollion\'s default for this language';
290
+ return `gender (${from}; LLM methods): ${text} — ${how}`;
291
+ }
292
+
293
+ /** "a, b, c, +2 more" */
294
+ function sampleKeys(keys, n = 5) {
295
+ const shown = keys.slice(0, n).map(k => String(k).replace(/\u0004/g, '\u2404'));
296
+ return `${shown.join(', ')}${keys.length > n ? `, +${keys.length - n} more` : ''}`;
297
+ }
298
+
299
+ /** Status lines for one locale's health (pending / out of date / held back). */
300
+ function healthLines(pairKey, health) {
301
+ if (!health) return [];
302
+ const lines = [];
303
+ const pendingHeld = health.pending.filter(p => p.held);
304
+ const pendingNext = health.pending.filter(p => !p.held);
305
+ if (pendingNext.length > 0) {
306
+ const reasons = [...new Set(pendingNext.map(p => p.reason))].join('`, `');
307
+ lines.push(` pending: ${pendingNext.length} key(s) an earlier \`${reasons}\` could not finish (${sampleKeys(pendingNext.map(p => p.key))}) `
308
+ + '— the next `champollion sync` asks the model for them once more.');
309
+ }
310
+ if (pendingHeld.length > 0) {
311
+ const keys = pendingHeld.map(p => p.key);
312
+ lines.push(` pending, held back: ${keys.length} key(s) a redo could not finish, refused again on the retry (${sampleKeys(keys)}) `
313
+ + `— not re-sent on a plain sync. Ask again: \`${redoCommand(keys.slice(0, 8), { pair: pairKey })}\`, or fill them `
314
+ + '(a "fallback" method, "noTranslate", or by hand).');
315
+ }
316
+ if (health.held.length > 0) {
317
+ lines.push(` held back: ${health.held.length} key(s) the quality gate refused from this method (${sampleKeys(health.held)}) `
318
+ + `— not re-sent on a plain sync. Ask again: \`${redoCommand(health.held.slice(0, 8), { pair: pairKey })}\`, or fill them `
319
+ + '(a "fallback" method, "noTranslate", or by hand).');
320
+ }
321
+ if (health.stale.length > 0) {
322
+ lines.push(` out of date: ${health.stale.length} translation(s) were made from an older source text (${sampleKeys(health.stale)}) `
323
+ + `— \`champollion sync --pair ${pairKey}\` re-translates them${health.held.some(k => health.stale.includes(k)) ? ' (the held-back ones only when named)' : ''}.`);
324
+ }
325
+ return lines;
326
+ }
327
+
328
+ /**
329
+ * Where an OpenAI-compatible method (local, openai) sends its requests, and
330
+ * the setting that chose it — LOCAL_API_BASE in the environment or in .env,
331
+ * or the built-in default (Ollama) — exactly as a connection error names it
332
+ * (Round 7, i18next persona: status did not say which address `local` would
333
+ * call). null for a method with no configurable endpoint.
334
+ *
335
+ * @returns {{ url: string, from: string|null, line: string }|null}
336
+ */
337
+ function resolvedEndpoint(pairConfig, cwd) {
338
+ if (pairConfig.method === 'api') return null; // its endpoint is the pair's own, shown as [API]
339
+ let method;
340
+ try { method = getMethod(pairConfig.method, pairConfig); } catch { return null; }
341
+ if (typeof method?._describeEndpoint !== 'function' || typeof method._resolveApiBaseSource !== 'function') return null;
342
+ let line = null;
343
+ let src = null;
344
+ try {
345
+ line = method._describeEndpoint({ cwd, ...(pairConfig.baseUrl ? { baseUrl: pairConfig.baseUrl } : {}) });
346
+ src = method._resolveApiBaseSource({ cwd, ...(pairConfig.baseUrl ? { baseUrl: pairConfig.baseUrl } : {}) });
347
+ } catch { return null; }
348
+ if (!line || !src?.base) return null;
349
+ return { url: src.base, from: src.from || null, line };
350
+ }
351
+
352
+ /** The content lane for status (null without a contentDir). */
353
+ function contentState(config, pairs, cwd) {
354
+ if (!config.contentDir || config.format === 'docusaurus') return null;
355
+ try {
356
+ return contentStatus(config.contentDir, config.inputLocale, [...pairs.entries()], cwd, { fallbackPrefix: config.fallbackPrefix || '[EN] ' });
357
+ } catch {
358
+ return null;
359
+ }
360
+ }
361
+
362
+ /** Human lines for the content lane: the folder, its files, each locale's state. */
363
+ function contentLines(config, pairs, cwd) {
364
+ const st = contentState(config, pairs, cwd);
365
+ if (!st) return [];
366
+ const lines = [` Content (Markdown): ${st.dir}/ — ${st.files} source file(s), each translated beside it as <name>.<locale>.md\n`];
367
+ const few = (list) => `${list.slice(0, 3).join(', ')}${list.length > 3 ? `, +${list.length - 3} more` : ''}`;
368
+ for (const [code, l] of Object.entries(st.locales)) {
369
+ const parts = [`${l.translated} translated`];
370
+ if (l.outOfDate.length > 0) parts.push(`${l.outOfDate.length} out of date (${few(l.outOfDate)})`);
371
+ if (l.pending.length > 0) parts.push(`${l.pending.length} pending (${few(l.pending)})`);
372
+ if (l.unrecorded.length > 0) parts.push(`${l.unrecorded.length} with no record — kept as they are (${few(l.unrecorded)})`);
373
+ const todo = l.outOfDate.length + l.pending.length;
374
+ lines.push(` ${code}: ${parts.join(', ')}${todo > 0 ? ' — `champollion sync` translates the out-of-date and pending ones' : ''}`);
375
+ }
376
+ lines.push('');
377
+ return lines;
378
+ }
379
+
380
+ /**
381
+ * The coaching a pair (or a fallback) carries, for status: where it comes
382
+ * from, and the fingerprint its cache key holds (the "coaching …" that sync's
383
+ * notes name) — or null when it has none. A method that sends no coaching
384
+ * says so: the file changes nothing there (Round 10, school persona: status
385
+ * did not show the fallback's coaching at all).
386
+ *
387
+ * @returns {{ file: string|null, inline: boolean, fingerprint: string|null, sent: boolean }|null}
388
+ */
389
+ function coachingOf(pairConfig) {
390
+ const text = typeof pairConfig.coachingPrompt === 'string' && pairConfig.coachingPrompt.trim() ? pairConfig.coachingPrompt : null;
391
+ const file = typeof pairConfig.coachingFile === 'string' && pairConfig.coachingFile.trim() ? pairConfig.coachingFile : null;
392
+ if (!text && !file) return null;
393
+ const sent = COACHING_PROMPT_METHODS.has(pairConfig.method);
394
+ const fingerprint = sent ? (tmMethodKey(pairConfig).split('|')[3] || null) : null;
395
+ return { file, inline: !file, fingerprint, sent };
396
+ }
397
+
398
+ /** "coaching: coaching.json (key f430948b)", or the reason it is not sent. */
399
+ function coachingLabel(pairConfig) {
400
+ const c = coachingOf(pairConfig);
401
+ if (!c) return null;
402
+ const what = c.file ? c.file : 'inline coachingPrompt';
403
+ return c.sent
404
+ ? `coaching: ${what}${c.fingerprint ? ` (cache key coaching ${c.fingerprint})` : ''}`
405
+ : `coaching: ${what} — not sent: ${pairConfig.method} takes no coaching`;
406
+ }
407
+
408
+ /** One line describing a pair's fallback, for the human status display. */
409
+ function describeFallback(fallback, cwd, pairConfig = null) {
410
+ const model = displayModelFor(fallback);
411
+ const resolved = resolvedEndpoint(fallback, cwd);
412
+ const where = fallback.method === 'api' && fallback.endpoint ? ` | endpoint: ${fallback.endpoint}`
413
+ : resolved ? ` | endpoint: ${resolved.line}` : '';
414
+ const coaching = coachingLabel(fallback);
415
+ // Its register only when it is not the pair's (a fallback inherits it).
416
+ const register = pairConfig && (fallback.register !== pairConfig.register || fallback.registerPreset !== pairConfig.registerPreset)
417
+ ? ` | register: ${fallback.registerPreset || JSON.stringify(String(fallback.register || '').slice(0, 40))}` : '';
418
+ return ` fallback: ${fallback.method}${model ? ` | model: ${model}` : ''}${where}${coaching ? ` | ${coaching}` : ''}${register}`
419
+ + ' (translates what the method above cannot translate safely)';
420
+ }
421
+
19
422
  // CLI version — read from package.json (the single source of truth), never a
20
423
  // hardcoded literal. Mirrors bin/cli.js's --version read.
21
424
  const { version: CLI_VERSION } = JSON.parse(
@@ -42,7 +445,7 @@ async function run(args, cwd) {
42
445
  }
43
446
  config.resolvedLanguages = languages;
44
447
 
45
- const pairs = resolvePairs(config);
448
+ const pairs = resolvePairs(config, { cwd });
46
449
 
47
450
  // Load installed plugins to enrich status display
48
451
  const plugins = loadPlugins(cwd);
@@ -54,7 +457,19 @@ async function run(args, cwd) {
54
457
  output.raw('\n champollion v3 — Translation Status\n');
55
458
  output.raw(` Input locale: ${config.inputLocale}`);
56
459
  output.raw(` Locales dir: ${config.localesDir}`);
57
- output.raw(` Default model: ${config.model}`);
460
+ // Multi-file layouts say which files make up a language — the question an
461
+ // i18next user asks first. Flat projects print exactly what they did.
462
+ if (config.format !== 'docusaurus') {
463
+ const layout = discoverLocaleLayout(config, { cwd });
464
+ if (layout.kind !== 'flat') {
465
+ output.raw(` Layout: ${layout.kind} — ${layout.display} (${layout.sourceFiles.length} source file(s))`);
466
+ }
467
+ }
468
+ const header = headerModels(config, pairs);
469
+ if (header.defaultModel) output.raw(` Default model: ${header.defaultModel}`);
470
+ if (header.endpoints.length > 0) {
471
+ output.raw(` ${header.endpoints.length === 1 ? 'Endpoint: ' : 'Endpoints: '} ${header.endpoints.join(', ')}`);
472
+ }
58
473
  if (config.temperature != null) {
59
474
  output.raw(` Temperature: ${config.temperature}`);
60
475
  }
@@ -78,6 +493,10 @@ async function run(args, cwd) {
78
493
  } else {
79
494
  output.raw(' TM cache: empty (will populate on first sync)');
80
495
  }
496
+ const replaced = countReplacedEdits(cwd);
497
+ if (replaced > 0) {
498
+ output.raw(` Replaced edits: ${replaced} hand-edited translation(s) a sync replaced, with their wording, in ${REPLACED_EDITS_FILENAME}`);
499
+ }
81
500
  output.raw('');
82
501
 
83
502
  if (pairs.size === 0) {
@@ -85,6 +504,9 @@ async function run(args, cwd) {
85
504
  output.raw(' Run `champollion init` or add languages to your config.\n');
86
505
  } else {
87
506
  output.raw(` Translation Pairs (${pairs.size}):\n`);
507
+ const mixed = modelsInFiles(config, pairs, cwd);
508
+ const health = localeHealthMap(config, pairs, cwd);
509
+ const fromFallback = fallbackValuesInFiles(config, pairs, cwd);
88
510
  for (const [pairKey, pairConfig] of pairs) {
89
511
  const tier = QUALITY_TIERS[pairConfig.qualityTier];
90
512
  const tierLabel = tier ? tier.label : pairConfig.qualityTier;
@@ -102,17 +524,16 @@ async function run(args, cwd) {
102
524
 
103
525
  // Resolve display model — direct providers may have model=null in the
104
526
  // pair config, meaning the method class picks its own default at runtime.
105
- // Show the resolved model so users know what will actually be used.
106
- let displayModel = pairConfig.model;
107
- if (!displayModel && pairConfig.method !== 'api') {
108
- try {
109
- const method = getMethod(pairConfig.method);
110
- displayModel = method._getDefaultModel?.() || 'auto';
111
- } catch { displayModel = 'auto'; }
112
- }
113
- const modelStr = pairConfig.method === 'api' ? '' : ` | model: ${displayModel || 'auto'}`;
527
+ // Show the resolved model so users know what will actually be used; a
528
+ // method with no model of ours (api, the MT engines) shows none.
529
+ const displayModel = displayModelFor(pairConfig);
530
+ const modelStr = displayModel ? ` | model: ${displayModel}` : '';
114
531
 
115
- // Show benchmarks if plugin has them; otherwise show tier as self-reported
532
+ // A plugin's benchmarks, when it publishes them; a quality tier only
533
+ // when the config sets one — and then said for what it is: a label
534
+ // someone chose, not a measurement. "quality: Standard" was printed for
535
+ // every pair, from a default nobody set, with no word on what it meant
536
+ // (Round 14, Next.js persona).
116
537
  let qualityStr = '';
117
538
  if (pairConfig.methodPlugin) {
118
539
  const resolvedPair = resolvePluginForPair(plugins, pairConfig);
@@ -124,12 +545,44 @@ async function run(args, cwd) {
124
545
  if (bm.exact_match_rate) parts.push(`exact ${Math.round(bm.exact_match_rate * 100)}%`);
125
546
  qualityStr = ` | benchmarks: ${parts.join(', ')}`;
126
547
  } else {
127
- qualityStr = ` | quality: ${tierLabel} (self-reported, no benchmarks)`;
548
+ qualityStr = ' | benchmarks: none in the plugin\'s manifest';
128
549
  }
129
- } else {
130
- qualityStr = ` | quality: ${tierLabel}`;
131
550
  }
132
551
  output.raw(` method: ${pairConfig.method}${modelStr}${qualityStr}`);
552
+ if (qualityTierSet(pairConfig)) {
553
+ output.raw(` quality tier: ${tierLabel} — the "qualityTier" your config sets for this pair: a label you chose `
554
+ + `(${Object.keys(QUALITY_TIERS).join(', ')}), not a measurement. Sync translates the same whatever it says; \`serve\` advertises it.`);
555
+ }
556
+ const endpoint = resolvedEndpoint(pairConfig, cwd);
557
+ if (endpoint) output.raw(` endpoint: ${endpoint.line}`);
558
+ const ownCoaching = coachingLabel(pairConfig);
559
+ if (ownCoaching) output.raw(` ${ownCoaching}`);
560
+ if (pairConfig.fallback) {
561
+ output.raw(describeFallback(pairConfig.fallback, cwd, pairConfig));
562
+ const fb = fromFallback.get(pairConfig.target);
563
+ const few = (keys) => keys.slice(0, 5).map(k => String(k).replace(/\u0004/g, '\u2404')).join(', ') + (keys.length > 5 ? `, +${keys.length - 5} more` : '');
564
+ if (fb && fb.count > 0) {
565
+ // The share of what sync wrote, and — above FALLBACK_MAJORITY_SHARE —
566
+ // that what ships is mostly the fallback's (as sync warns).
567
+ const share = fb.written > 0 ? fb.count / fb.written : null;
568
+ output.raw(` from the fallback: ${fb.count} value(s) in the files (${few(fb.keys)})`
569
+ + (share !== null ? ` — ${fb.count} of the ${fb.written} sync wrote (${Math.round(share * 100)}%)` : '')
570
+ + (share !== null && share > FALLBACK_MAJORITY_SHARE
571
+ ? `: most of this locale's text is the fallback's (${pairConfig.fallback.method}), not ${pairConfig.method}'s`
572
+ : ''));
573
+ }
574
+ if (fb && fb.earlier?.count > 0) {
575
+ output.raw(` from the fallback as it was set up before: ${fb.earlier.count} value(s) (${few(fb.earlier.keys)}) — written by `
576
+ + `${fb.earlier.by.map(describeMethodKey).join('; ')}, not by the fallback as it is now (${describeMethodKey(tmMethodKey(pairConfig.fallback))}). `
577
+ + `A change of the fallback re-translates nothing on its own; to re-translate them: champollion sync --pair ${pairKey} --redo all`);
578
+ }
579
+ }
580
+ // Said once by the first sync; here every time it is asked for.
581
+ if (MODEL_YOU_CHOOSE.has(pairConfig.method)) output.raw(` licence: ${MODEL_YOU_CHOOSE_NOTE}`);
582
+ // After a model switch without a full re-translation (Round 3).
583
+ const mixedLine = mixedModelsLine(pairKey, mixed.get(pairConfig.target), displayModel);
584
+ if (mixedLine) output.raw(mixedLine);
585
+ for (const line of healthLines(pairKey, health.get(pairConfig.target))) output.raw(line);
133
586
 
134
587
  // Plugin badge — show name and version (benchmarks already shown above)
135
588
  if (pairConfig.methodPlugin) {
@@ -143,7 +596,14 @@ async function run(args, cwd) {
143
596
 
144
597
  // API badge
145
598
  if (pairConfig.method === 'api') {
146
- output.raw(' [API] Translation runs server-side (IP protected)');
599
+ // Say where it runs: a loopback endpoint (nmt-forge serve, champollion
600
+ // serve) is this machine, not a remote service.
601
+ let host = '';
602
+ try { host = new URL(pairConfig.endpoint || '').hostname; } catch { /* no/invalid endpoint */ }
603
+ const local = ['localhost', '127.0.0.1', '::1', '[::1]'].includes(host);
604
+ output.raw(local
605
+ ? ` [API] Translation runs on this machine (${pairConfig.endpoint})`
606
+ : ` [API] Translation runs on the endpoint's server${pairConfig.endpoint ? ` (${pairConfig.endpoint})` : ''} — the text is sent there`);
147
607
  }
148
608
 
149
609
  // Google Translate badge
@@ -153,6 +613,10 @@ async function run(args, cwd) {
153
613
  }
154
614
  output.raw('');
155
615
 
616
+ // The content lane (contentDir: Markdown pages), beside the key-value
617
+ // lane above — what each locale holds of it.
618
+ for (const line of contentLines(config, pairs, cwd)) output.raw(line);
619
+
156
620
  // Show active registers for each pair with structured card info
157
621
  output.raw(' Registers:\n');
158
622
  for (const [pairKey, pairConfig] of pairs) {
@@ -176,6 +640,11 @@ async function run(args, cwd) {
176
640
  const truncated = regText.length > 55 ? regText.slice(0, 52) + '...' : regText;
177
641
  output.raw(` ${pairConfig.target} (custom)${systemLabel} — "${truncated}"`);
178
642
  }
643
+ // The gender guidance the prompt carries (LLM methods): visible, with
644
+ // where it comes from and how to change it (Round 8, Django persona:
645
+ // the French prompt asked for écriture inclusive and nothing said so).
646
+ const genderLine = describeGenderSetting(pairConfig);
647
+ if (genderLine) output.raw(` ${genderLine}`);
179
648
  }
180
649
  output.raw('');
181
650
  }
@@ -197,19 +666,16 @@ function runJson(config, pairs, plugins, cwd) {
197
666
  }
198
667
 
199
668
  const pairList = [];
669
+ const mixed = modelsInFiles(config, pairs, cwd);
670
+ const health = localeHealthMap(config, pairs, cwd);
671
+ const fromFallback = fallbackValuesInFiles(config, pairs, cwd);
200
672
  for (const [pairKey, pairConfig] of pairs) {
201
673
  const tier = QUALITY_TIERS[pairConfig.qualityTier];
202
674
  const resolvedPair = resolvePluginForPair(plugins, pairConfig);
203
675
 
204
676
  // Same display-model resolution as the human path: direct providers may
205
677
  // have model=null (the method class picks its own default at runtime).
206
- let displayModel = pairConfig.model;
207
- if (!displayModel && pairConfig.method !== 'api') {
208
- try {
209
- const method = getMethod(pairConfig.method);
210
- displayModel = method._getDefaultModel?.() || 'auto';
211
- } catch { displayModel = 'auto'; }
212
- }
678
+ const displayModel = displayModelFor(pairConfig);
213
679
 
214
680
  const presets = getRegisterPresets(pairConfig.target);
215
681
  const matchedPreset = pairConfig.registerPreset
@@ -221,9 +687,20 @@ function runJson(config, pairs, plugins, cwd) {
221
687
  target: pairConfig.target,
222
688
  name: pairConfig.name,
223
689
  method: pairConfig.method,
224
- model: pairConfig.method === 'api' ? null : (displayModel || 'auto'),
690
+ // null for a method that runs no model of ours (api, the MT engines).
691
+ model: displayModel,
692
+ // Where an `api` pair sends its text (null for every other method).
693
+ endpoint: pairConfig.method === 'api' ? (pairConfig.endpoint || null) : null,
694
+ // Its coaching: the file (null for inline text), the fingerprint its
695
+ // cache key holds, and whether the method sends it (null: none).
696
+ coaching: coachingOf(pairConfig),
697
+ // Where an OpenAI-compatible method (local, openai) sends its requests
698
+ // and the setting that chose it ({ url, from }; absent for other methods).
699
+ ...(() => { const r = resolvedEndpoint(pairConfig, cwd); return r ? { requestsGoTo: { url: r.url, from: r.from } } : {}; })(),
225
700
  qualityTier: pairConfig.qualityTier,
226
701
  qualityTierLabel: tier ? tier.label : pairConfig.qualityTier,
702
+ // Whether the config sets it (false: the default label, a claim nobody made).
703
+ qualityTierSet: qualityTierSet(pairConfig),
227
704
  dir: pairConfig.dir || 'ltr',
228
705
  scripts: pairConfig.scripts || null,
229
706
  // Resolved script decision (kept alongside legacy `scripts` for compat)
@@ -241,22 +718,82 @@ function runJson(config, pairs, plugins, cwd) {
241
718
  register: matchedPreset
242
719
  ? { preset: matchedPreset.key, label: matchedPreset.label, isDefault: !!matchedPreset.isDefault }
243
720
  : { preset: null, custom: pairConfig.register || DEFAULT_REGISTER_FALLBACK },
721
+ // The gender guidance LLM prompts carry: catalogue (the default for the
722
+ // language), config (your own), off — and its text.
723
+ genderGuidance: { source: pairConfig.genderGuidanceSource || null, text: pairConfig.genderGuidance || null },
724
+ // Models whose cached output the files hold now (most keys first);
725
+ // more than one = mixed after a model switch.
726
+ modelsInFiles: mixed.get(pairConfig.target) || [],
727
+ // Every attributable value came from a model other than the current one.
728
+ fromEarlierModel: (() => {
729
+ const m = mixed.get(pairConfig.target) || [];
730
+ return m.length === 1 && !m[0].current && !m[0].unknown ? m[0].model : null;
731
+ })(),
732
+ // lib/locale-state.js: keys an unfinished redo left (asked again by the
733
+ // next sync unless held), translations of an older source text, and
734
+ // keys held back after a quality-gate refusal.
735
+ pending: health.get(pairConfig.target)?.pending || [],
736
+ stale: health.get(pairConfig.target)?.stale || [],
737
+ heldBack: health.get(pairConfig.target)?.held || [],
738
+ // A model the user picks: its licence is theirs to check (null otherwise).
739
+ licenceNote: MODEL_YOU_CHOOSE.has(pairConfig.method) ? MODEL_YOU_CHOOSE_NOTE : null,
740
+ // The second method for what this pair's own method cannot translate
741
+ // safely (null when none is configured).
742
+ fallback: pairConfig.fallback
743
+ ? {
744
+ method: pairConfig.fallback.method,
745
+ model: displayModelFor(pairConfig.fallback),
746
+ provider: pairConfig.fallback.provider || null,
747
+ endpoint: pairConfig.fallback.endpoint || null,
748
+ ...(() => { const r = resolvedEndpoint(pairConfig.fallback, cwd); return r ? { requestsGoTo: { url: r.url, from: r.from } } : {}; })(),
749
+ // Its coaching, as for the pair; its register only when not the pair's.
750
+ coaching: coachingOf(pairConfig.fallback),
751
+ ...((pairConfig.fallback.register !== pairConfig.register || pairConfig.fallback.registerPreset !== pairConfig.registerPreset)
752
+ && { register: pairConfig.fallback.registerPreset ? { preset: pairConfig.fallback.registerPreset } : { custom: pairConfig.fallback.register || null } }),
753
+ // Values in the files this fallback wrote (the lock's record), and which.
754
+ ...(fromFallback.has(pairConfig.target) && { valuesInFiles: fromFallback.get(pairConfig.target).count }),
755
+ // Of how many values sync wrote (any method), and the share.
756
+ ...(fromFallback.get(pairConfig.target)?.written > 0 && {
757
+ valuesWritten: fromFallback.get(pairConfig.target).written,
758
+ share: fromFallback.get(pairConfig.target).count / fromFallback.get(pairConfig.target).written,
759
+ }),
760
+ ...(fromFallback.get(pairConfig.target)?.count > 0 && { keys: fromFallback.get(pairConfig.target).keys }),
761
+ // Values it wrote as it was set up before (another model, register
762
+ // or coaching of it): `sync --redo all` re-translates them.
763
+ ...(fromFallback.get(pairConfig.target)?.earlier?.count > 0 && {
764
+ earlierSetup: {
765
+ values: fromFallback.get(pairConfig.target).earlier.count,
766
+ keys: fromFallback.get(pairConfig.target).earlier.keys,
767
+ writtenBy: fromFallback.get(pairConfig.target).earlier.by.map(describeMethodKey),
768
+ },
769
+ }),
770
+ }
771
+ : null,
244
772
  });
245
773
  }
246
774
 
775
+ const header = headerModels(config, pairs);
247
776
  console.log(JSON.stringify({
248
777
  command: 'status',
249
778
  version: CLI_VERSION,
250
779
  inputLocale: config.inputLocale,
251
780
  localesDir: config.localesDir,
252
- defaultModel: config.model,
781
+ // The top-level model — null when no pair runs it (an api-only project).
782
+ defaultModel: header.defaultModel,
783
+ // The endpoints of `api` pairs (what they run is whatever those serve).
784
+ endpoints: header.endpoints,
253
785
  temperature: config.temperature ?? null,
254
786
  configVersion: config.version || '2 (legacy)',
255
787
  format: config.format,
256
788
  contentDir: config.contentDir || null,
257
789
  plugins: plugins.size,
258
790
  tm,
791
+ replacedEdits: { count: countReplacedEdits(cwd), file: REPLACED_EDITS_FILENAME },
259
792
  pairs: pairList,
793
+ // The content lane (contentDir): the folder, its source files, and per
794
+ // locale how many translations are current, out of date, pending, or
795
+ // unrecorded (null without a contentDir).
796
+ content: contentState(config, pairs, cwd),
260
797
  }, null, 2));
261
798
 
262
799
  return 0;