crewforth 3.5.0-rc.1 → 3.5.0-rc.2

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 (41) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +1 -1
  3. package/VERSION +1 -1
  4. package/kit/CLAUDE.md +1 -1
  5. package/kit/eval/lib/statusline.sh +22 -5
  6. package/kit/eval/lib/todo.awk +167 -0
  7. package/kit/eval/lib/todo.sh +120 -0
  8. package/kit/eval/smoke-test.sh +60 -2
  9. package/kit/hooks/prompt-approval.sh +5 -5
  10. package/kit/skills/crew-plan/SKILL.md +2 -1
  11. package/kit/skills/crew-todo/SKILL.md +31 -0
  12. package/kit/skills/spec-planning/SKILL.md +9 -2
  13. package/kit/studio/web/agent-spend.js +32 -0
  14. package/kit/studio/web/app.js +245 -236
  15. package/kit/studio/web/approvals.js +14 -13
  16. package/kit/studio/web/ask.js +10 -9
  17. package/kit/studio/web/canvas.js +56 -54
  18. package/kit/studio/web/chat.js +41 -41
  19. package/kit/studio/web/convo.js +21 -18
  20. package/kit/studio/web/dock.js +20 -19
  21. package/kit/studio/web/graph-plan.js +62 -22
  22. package/kit/studio/web/i18n-tr.js +760 -0
  23. package/kit/studio/web/i18n.js +79 -0
  24. package/kit/studio/web/index.html +1 -0
  25. package/kit/studio/web/inspect.js +27 -24
  26. package/kit/studio/web/list-plan.js +16 -15
  27. package/kit/studio/web/list.js +6 -5
  28. package/kit/studio/web/liveness.js +11 -9
  29. package/kit/studio/web/map-plan.js +33 -29
  30. package/kit/studio/web/map.js +74 -73
  31. package/kit/studio/web/models-plan.js +47 -42
  32. package/kit/studio/web/models.js +21 -20
  33. package/kit/studio/web/names.js +13 -10
  34. package/kit/studio/web/nav.js +34 -33
  35. package/kit/studio/web/newsession.js +25 -24
  36. package/kit/studio/web/style.css +2 -0
  37. package/kit/studio/web/theme.js +5 -2
  38. package/kit/studio/web/timeline-plan.js +19 -18
  39. package/kit/studio/web/timeline.js +23 -22
  40. package/kit/studio/web/usage.js +36 -32
  41. package/package.json +1 -1
@@ -13,6 +13,7 @@ import {
13
13
  emptyNote, layoutOf, routesOf, chipsOf, inferredOnly, groupsOf, foldView, placed, BANDS, cardOf, tourOf, searchOf, resourceWord,
14
14
  } from './map-plan.js';
15
15
  import { Viewport, draggable, swallowClick } from './viewport.js';
16
+ import { t } from './i18n.js';
16
17
 
17
18
  const SVG = 'http://www.w3.org/2000/svg';
18
19
  export const LAYOUT_KEY = 'crewforth-studio-map-layout:';
@@ -31,9 +32,9 @@ function svg(tag, attrs = {}) {
31
32
  const BAND_ORDER = Object.fromEntries(BANDS.map(([k], i) => [k, i]));
32
33
  const BAND_WORD = Object.fromEntries(BANDS);
33
34
 
34
- const LAYERS = [['modules', 'Modules'], ['external', 'External'], ['endpoints', 'Endpoints'], ['findings', 'Findings']];
35
+ const LAYERS = [['modules', t('Modules')], ['external', t('External')], ['endpoints', t('Endpoints')], ['findings', t('Findings')]];
35
36
  // How a line between two modules is named on the map.
36
- const EDGE_WORD = { references: 'uses', calls: 'calls', builds: 'builds' };
37
+ const EDGE_WORD = { references: t('uses'), calls: t('calls'), builds: t('builds') };
37
38
 
38
39
  export class MapView {
39
40
  /**
@@ -76,9 +77,9 @@ export class MapView {
76
77
  });
77
78
  this.below = mk('div', 'mp-below');
78
79
  this.list = mk('section', 'mp-modlist');
79
- this.list.setAttribute('aria-label', 'Modules');
80
+ this.list.setAttribute('aria-label', t('Modules'));
80
81
  this.side = mk('aside', 'mp-inspect');
81
- this.side.setAttribute('aria-label', 'Selected node');
82
+ this.side.setAttribute('aria-label', t('Selected node'));
82
83
  const main = mk('div', 'mp-main');
83
84
  main.append(this.pane, this.below, this.list);
84
85
  this.body = mk('div', 'mp-body');
@@ -162,7 +163,7 @@ export class MapView {
162
163
  const note = emptyNote(this.data);
163
164
  if (note) {
164
165
  this.empty.replaceChildren(mk('p', null, note));
165
- if (this.data?.missing) this.empty.append(mk('p', 'sub', 'Studio only reads the map: nothing is scanned from the panel.'));
166
+ if (this.data?.missing) this.empty.append(mk('p', 'sub', t('Studio only reads the map: nothing is scanned from the panel.')));
166
167
  this.root.replaceChildren(this.empty);
167
168
  return;
168
169
  }
@@ -222,9 +223,9 @@ export class MapView {
222
223
  const frame = mk('div', 'mp-group');
223
224
  frame.dataset.group = g.id;
224
225
  frame.style.left = `${x}px`; frame.style.top = `${y}px`; frame.style.width = `${w}px`; frame.style.height = `${h}px`;
225
- const fold = mk('button', 'mp-group-fold', `${g.folder}/ · fold`);
226
+ const fold = mk('button', 'mp-group-fold', `${g.folder}/ · ${t('fold')}`);
226
227
  fold.type = 'button';
227
- fold.title = `Fold the ${g.members.length} modules under ${g.folder}/ into one card`;
228
+ fold.title = t('Fold the {n} modules under {folder}/ into one card', { n: g.members.length, folder: g.folder });
228
229
  fold.addEventListener('click', () => this.toggleGroup(g.id));
229
230
  frame.append(fold);
230
231
  parts.push(frame);
@@ -244,7 +245,7 @@ export class MapView {
244
245
  if (EDGE_WORD[e.kind] && !e.calls) tags.push(mk('span', 'mp-badge', EDGE_WORD[e.kind]));
245
246
  if (e.line === 'model') { const m = mk('span', 'mp-pill', 'model'); m.dataset.tone = 'model'; m.title = 'Proposed by the model, not shown by a file'; tags.push(m); }
246
247
  if (e.calls) {
247
- tags.push(mk('span', 'mp-pill', `${e.calls} ${e.calls === 1 ? 'call' : 'calls'}`));
248
+ tags.push(mk('span', 'mp-pill', e.calls === 1 ? t('{n} call', { n: 1 }) : t('{n} calls', { n: e.calls })));
248
249
  if (e.missing) { const m = mk('span', 'mp-pill', `${e.missing} missing`); m.dataset.tone = 'missing'; tags.push(m); }
249
250
  }
250
251
  if (tags.length) {
@@ -272,14 +273,14 @@ export class MapView {
272
273
 
273
274
  #head(doc, findings) {
274
275
  const head = mk('header', 'mp-head');
275
- head.append(mk('h2', 'mp-title', 'Map'), mk('span', 'row-fill'));
276
+ head.append(mk('h2', 'mp-title', t('Map')), mk('span', 'row-fill'));
276
277
  for (const s of summaryOf(doc, findings)) head.append(mk('span', 'mp-pill', s));
277
278
  return head;
278
279
  }
279
280
 
280
281
  #toolbar(d) {
281
282
  const bar = mk('div', 'mp-toolbar');
282
- bar.append(mk('span', 'lbl', 'Layers'));
283
+ bar.append(mk('span', 'lbl', t('Layers')));
283
284
  for (const [k, label] of LAYERS) {
284
285
  const b = mk('button', 'mp-tog', label);
285
286
  b.type = 'button';
@@ -290,8 +291,8 @@ export class MapView {
290
291
  bar.append(mk('span', 'row-fill'));
291
292
  const search = mk('input', 'mp-search');
292
293
  search.type = 'search';
293
- search.placeholder = 'Search files and modules';
294
- search.setAttribute('aria-label', 'Search files and modules');
294
+ search.placeholder = t('Search files and modules');
295
+ search.setAttribute('aria-label', t('Search files and modules'));
295
296
  search.value = this.query;
296
297
  search.addEventListener('input', () => { this.query = search.value; this.render(); this.top.querySelector('.mp-search')?.focus(); });
297
298
  search.addEventListener('keydown', (e) => {
@@ -301,16 +302,16 @@ export class MapView {
301
302
  });
302
303
  bar.append(search);
303
304
  if (tourOf(d.doc).length) {
304
- const t = mk('button', 'mp-tog', 'Tour');
305
- t.type = 'button';
306
- t.setAttribute('aria-pressed', String(this.tourAt >= 0));
307
- t.title = 'The stops /crew-map wrote for someone new to the project, in order';
308
- t.addEventListener('click', () => this.tour(this.tourAt >= 0 ? -1 : 0));
309
- bar.append(t);
305
+ const tb = mk('button', 'mp-tog', t('Tour'));
306
+ tb.type = 'button';
307
+ tb.setAttribute('aria-pressed', String(this.tourAt >= 0));
308
+ tb.title = t('The stops /crew-map wrote for someone new to the project, in order');
309
+ tb.addEventListener('click', () => this.tour(this.tourAt >= 0 ? -1 : 0));
310
+ bar.append(tb);
310
311
  }
311
312
  const pick = mk('select', 'mp-compare');
312
- pick.setAttribute('aria-label', 'Compare with');
313
- const none = mk('option', null, d.others?.length ? 'Compare with…' : 'Compare with: no other project has a map');
313
+ pick.setAttribute('aria-label', t('Compare with'));
314
+ const none = mk('option', null, d.others?.length ? t('Compare with…') : t('Compare with: no other project has a map'));
314
315
  none.value = '';
315
316
  pick.append(none);
316
317
  for (const o of d.others ?? []) { const opt = mk('option', null, o.label); opt.value = o.cwd; pick.append(opt); }
@@ -318,20 +319,20 @@ export class MapView {
318
319
  pick.disabled = !d.others?.length;
319
320
  pick.addEventListener('change', () => this.hooks.onCompare?.(pick.value || null));
320
321
  const legend = mk('div', 'mp-legend');
321
- const lb = mk('button', 'btn sm ghost', 'Legend');
322
+ const lb = mk('button', 'btn sm ghost', t('Legend'));
322
323
  lb.type = 'button';
323
324
  lb.setAttribute('aria-expanded', String(this.legendOpen));
324
325
  lb.addEventListener('click', () => { this.legendOpen = !this.legendOpen; this.render(); });
325
326
  legend.append(lb);
326
327
  if (this.legendOpen) {
327
328
  const lg = mk('div', 'mp-legend-box');
328
- for (const [kind, text] of [['evidence', 'A file proves this line'], ['inferred', 'Inferred, not proven'], ['model', 'Proposed by the model'], ['lit', 'A line of the selected node']]) {
329
+ for (const [kind, text] of [['evidence', t('A file proves this line')], ['inferred', t('Inferred, not proven')], ['model', t('Proposed by the model')], ['lit', t('A line of the selected node')]]) {
329
330
  const row = mk('div', 'mp-legend-row');
330
331
  const s = mk('span', 'mp-legend-line'); s.dataset.kind = kind;
331
332
  row.append(s, mk('span', 'sub', text));
332
333
  lg.append(row);
333
334
  }
334
- for (const [kind, text] of [['solid', 'A resource a file shows'], ['dashed', 'A resource only inferred']]) {
335
+ for (const [kind, text] of [['solid', t('A resource a file shows')], ['dashed', t('A resource only inferred')]]) {
335
336
  const row = mk('div', 'mp-legend-row');
336
337
  const s = mk('span', 'mp-legend-card'); s.dataset.kind = kind;
337
338
  row.append(s, mk('span', 'sub', text));
@@ -349,7 +350,7 @@ export class MapView {
349
350
  strip.append(mk('code', 'mp-file', s.file), mk('span', 'sub', `· ${s.at}`));
350
351
  if (s.behind) { const b = mk('span', 'mp-pill', s.behind); b.dataset.tone = 'behind'; strip.append(b); }
351
352
  if (s.behindNote) strip.append(mk('span', 'sub', s.behindNote));
352
- if (d.compare?.reason) strip.append(mk('span', 'sub', `Compare: ${d.compare.reason}`));
353
+ if (d.compare?.reason) strip.append(mk('span', 'sub', t('Compare: {reason}', { reason: d.compare.reason })));
353
354
  strip.append(mk('span', 'row-fill'), mk('span', 'sub', s.rule));
354
355
  return strip;
355
356
  }
@@ -372,7 +373,7 @@ export class MapView {
372
373
  const name = mk('span', 'mp-name', n.name ?? n.id);
373
374
  name.title = n.name ?? n.id;
374
375
  top.append(name);
375
- if (count) { const f = mk('span', 'mp-fb', String(count)); f.title = `${count} ${count === 1 ? 'finding' : 'findings'}`; top.append(f); }
376
+ if (count) { const f = mk('span', 'mp-fb', String(count)); f.title = count === 1 ? t('1 finding') : t('{n} findings', { n: count }); top.append(f); }
376
377
  top.append(mk('span', 'row-fill'));
377
378
  if (n.kind === 'group') { card.append(top, mk('span', 'sub', `${n.members.length} modules · click to open`)); }
378
379
  else if (n.kind !== 'module') { card.append(top, mk('span', 'sub', cardSub(n, doc))); }
@@ -387,7 +388,7 @@ export class MapView {
387
388
  card.append(row);
388
389
  }
389
390
  if (this.layers.external) {
390
- const fold = mk('span', `mp-fold${c.outside.length ? '' : ' none'}`, c.outside.length ? `▸ ${c.outside.join(' · ')}` : 'no outside calls');
391
+ const fold = mk('span', `mp-fold${c.outside.length ? '' : ' none'}`, c.outside.length ? `▸ ${c.outside.join(' · ')}` : t('no outside calls'));
391
392
  if (c.outside.length) fold.title = c.outside.join('\n');
392
393
  card.append(fold);
393
394
  }
@@ -408,26 +409,26 @@ export class MapView {
408
409
 
409
410
  #findings(f) {
410
411
  const box = mk('section', 'mp-findings');
411
- box.setAttribute('aria-label', 'Findings');
412
+ box.setAttribute('aria-label', t('Findings'));
412
413
  const h = mk('div', 'mp-row');
413
- h.append(mk('span', 'lbl', `Findings · ${f.shown.length}`), mk('span', 'sub', `${f.proven} proven by a file · ${f.shown.length - f.proven} inferred`), mk('span', 'row-fill'));
414
+ h.append(mk('span', 'lbl', `${t('Findings')} · ${f.shown.length}`), mk('span', 'sub', t('{n} proven by a file · {m} inferred', { n: f.proven, m: f.shown.length - f.proven })), mk('span', 'row-fill'));
414
415
  if (f.hidden) {
415
- const t = mk('button', 'btn sm ghost', this.showHidden ? `Hide ${f.hidden} hidden by default` : `Show ${f.hidden} hidden by default`);
416
- t.type = 'button';
417
- t.title = 'Rules wrong too often to show unasked. Their findings are kept, not deleted.';
418
- t.addEventListener('click', () => { this.showHidden = !this.showHidden; this.render(); });
419
- h.append(t);
416
+ const tb = mk('button', 'btn sm ghost', this.showHidden ? t('Hide {n} hidden by default', { n: f.hidden }) : t('Show {n} hidden by default', { n: f.hidden }));
417
+ tb.type = 'button';
418
+ tb.title = t('Rules wrong too often to show unasked. Their findings are kept, not deleted.');
419
+ tb.addEventListener('click', () => { this.showHidden = !this.showHidden; this.render(); });
420
+ h.append(tb);
420
421
  }
421
- h.append(mk('span', 'sub mp-hint', 'Click a row to show it on the map'));
422
+ h.append(mk('span', 'sub mp-hint', t('Click a row to show it on the map')));
422
423
  box.append(h);
423
- if (!f.shown.length) box.append(mk('p', 'sub', f.total ? 'Every finding of this map is hidden by default.' : 'No finding in this map.'));
424
+ if (!f.shown.length) box.append(mk('p', 'sub', f.total ? t('Every finding of this map is hidden by default.') : t('No finding in this map.')));
424
425
  const list = mk('div', 'mp-flist');
425
426
  for (const x of f.shown) {
426
427
  const row = mk('button', 'mp-frow');
427
428
  row.type = 'button';
428
429
  const dot = mk('span', 'mp-dot'); dot.dataset.proven = String(x.proven);
429
- row.append(dot, mk('span', 'mp-ftitle', x.title), mk('span', 'mp-fwhere', x.where), mk('span', 'row-fill'), mk('span', 'mp-badge', x.proven ? 'proven' : 'inferred'));
430
- if (x.hidden) row.title = x.hiddenReason ?? 'Hidden by default';
430
+ row.append(dot, mk('span', 'mp-ftitle', x.title), mk('span', 'mp-fwhere', x.where), mk('span', 'row-fill'), mk('span', 'mp-badge', x.proven ? t('proven') : t('inferred')));
431
+ if (x.hidden) row.title = x.hiddenReason ?? t('Hidden by default');
431
432
  row.addEventListener('click', () => { if (x.modules[0]) this.select(x.modules[0], { reveal: true }); });
432
433
  list.append(row);
433
434
  }
@@ -447,7 +448,7 @@ export class MapView {
447
448
  r.addEventListener('click', () => this.select(n.id));
448
449
  return r;
449
450
  });
450
- this.list.replaceChildren(mk('span', 'lbl', `Modules · ${rows.length}`), ...items);
451
+ this.list.replaceChildren(mk('span', 'lbl', `${t('Modules')} · ${rows.length}`), ...items);
451
452
  }
452
453
 
453
454
  #inspector(node, doc, edges, findings) {
@@ -456,82 +457,82 @@ export class MapView {
456
457
  // The project's overview, when /crew-map wrote one: what it says, and the lines it rests on.
457
458
  const ov = doc?.overview;
458
459
  if (typeof ov?.text === 'string' && ov.text.trim()) {
459
- parts.push(mk('span', 'lbl', 'Overview'), mk('p', 'mp-overview', ov.text));
460
+ parts.push(mk('span', 'lbl', t('Overview')), mk('p', 'mp-overview', ov.text));
460
461
  if (Array.isArray(ov.cites) && ov.cites.length) parts.push(mk('p', 'sub mp-cites', `From ${ov.cites.filter((c) => typeof c === 'string').join(', ')}`));
461
462
  }
462
- parts.push(mk('p', 'sub', 'Select a module to see what it connects to and what was found in it. Esc lets it go.'));
463
+ parts.push(mk('p', 'sub', t('Select a module to see what it connects to and what was found in it. Esc lets it go.')));
463
464
  } else {
464
465
  const v = inspectOf(node, doc, edges, findings);
465
466
  const top = mk('div', 'mp-row');
466
467
  const close = mk('button', 'btn icon sm ghost', '×');
467
- close.type = 'button'; close.setAttribute('aria-label', 'Close inspector');
468
+ close.type = 'button'; close.setAttribute('aria-label', t('Close inspector'));
468
469
  close.addEventListener('click', () => this.select(null));
469
470
  top.append(mk('span', 'mp-name', v.title), mk('span', 'row-fill'), close);
470
471
  parts.push(top);
471
472
  if (v.layer || v.domain) {
472
473
  const pills = mk('div', 'mp-row');
473
- if (v.layer) pills.append(mk('span', 'mp-pill', `Layer · ${v.layer}`));
474
- if (v.domain) pills.append(mk('span', 'mp-pill', `Domain · ${v.domain}`));
474
+ if (v.layer) pills.append(mk('span', 'mp-pill', `${t('Layer')} · ${v.layer}`));
475
+ if (v.domain) pills.append(mk('span', 'mp-pill', `${t('Domain')} · ${v.domain}`));
475
476
  parts.push(pills);
476
477
  }
477
478
  if (v.path) parts.push(mk('code', 'sub', v.path));
478
479
  if (v.chips.length) { const c = mk('div', 'mp-chips'); for (const t of v.chips) c.append(mk('span', 'mp-chip', t)); parts.push(c); }
479
480
  const tabs = mk('nav', 'mp-tabs');
480
- for (const [k, label] of [['overview', 'Overview'], ['connections', `Connections ${v.connections.out.length + v.connections.in.length}`], ['findings', `Findings ${v.findings.length}`]]) {
481
- const t = mk('button', 'mp-tab', label);
482
- t.type = 'button';
483
- t.setAttribute('aria-selected', String(this.tab === k));
484
- t.addEventListener('click', () => { this.tab = k; this.render(); });
485
- tabs.append(t);
481
+ for (const [k, label] of [['overview', t('Overview')], ['connections', `${t('Connections')} ${v.connections.out.length + v.connections.in.length}`], ['findings', `${t('Findings')} ${v.findings.length}`]]) {
482
+ const tb = mk('button', 'mp-tab', label);
483
+ tb.type = 'button';
484
+ tb.setAttribute('aria-selected', String(this.tab === k));
485
+ tb.addEventListener('click', () => { this.tab = k; this.render(); });
486
+ tabs.append(tb);
486
487
  }
487
488
  parts.push(tabs);
488
489
  const sec = mk('div', 'mp-isec');
489
490
  if (this.tab === 'overview') {
490
- sec.append(mk('span', 'lbl', 'What it is for'), mk('p', v.narrative ? null : 'sub', v.narrative ?? v.narrativeNote));
491
- if (v.narrative && v.narrativeCites.length) sec.append(mk('p', 'sub mp-cites', `From ${v.narrativeCites.join(', ')}`));
491
+ sec.append(mk('span', 'lbl', t('What it is for')), mk('p', v.narrative ? null : 'sub', v.narrative ?? v.narrativeNote));
492
+ if (v.narrative && v.narrativeCites.length) sec.append(mk('p', 'sub mp-cites', t('From {files}', { files: v.narrativeCites.join(', ') })));
492
493
  if (v.inside && (v.inside.entry || v.inside.folders.length)) {
493
- sec.append(mk('span', 'lbl', 'Inside'));
494
+ sec.append(mk('span', 'lbl', t('Inside')));
494
495
  if (v.inside.entry) { const r = mk('div', 'mp-conn mp-inside'); r.append(this.#cite(v.inside.entry), mk('span', 'mp-ev', 'entry point')); sec.append(r); }
495
496
  for (const f of v.inside.folders) { const r = mk('div', 'mp-conn mp-inside'); r.append(mk('span', 'mp-file', `${f.folder === '.' ? '(root)' : `${f.folder}/`}`), mk('span', 'mp-ev', `${f.files} ${f.files === 1 ? 'file' : 'files'}`)); sec.append(r); }
496
- if (v.inside.other) sec.append(mk('p', 'sub', `${v.inside.other} more files in other folders`));
497
- if (v.inside.tests) sec.append(mk('p', 'sub', `${v.inside.tests} test ${v.inside.tests === 1 ? 'file' : 'files'}`));
497
+ if (v.inside.other) sec.append(mk('p', 'sub', t('{n} more files in other folders', { n: v.inside.other })));
498
+ if (v.inside.tests) sec.append(mk('p', 'sub', v.inside.tests === 1 ? t('1 test file') : t('{n} test files', { n: v.inside.tests })));
498
499
  }
499
500
  if (v.changed) sec.append(mk('p', 'sub', v.changed));
500
501
  if (v.testFiles.length) {
501
- sec.append(mk('span', 'lbl', `Tests · ${v.testTotal}`));
502
+ sec.append(mk('span', 'lbl', `${t('Tests')} · ${v.testTotal}`));
502
503
  for (const f of v.testFiles) { const r = mk('div', 'mp-conn mp-inside'); r.append(mk('span', 'mp-file', f)); sec.append(r); }
503
- if (v.testTotal > v.testFiles.length) sec.append(mk('p', 'sub', `${v.testTotal - v.testFiles.length} more in the map file`));
504
+ if (v.testTotal > v.testFiles.length) sec.append(mk('p', 'sub', t('{n} more in the map file', { n: v.testTotal - v.testFiles.length })));
504
505
  }
505
506
  if (v.notes.length) {
506
- sec.append(mk('span', 'lbl', `Notes in the code · ${v.notesTotal}`));
507
+ sec.append(mk('span', 'lbl', `${t('Notes in the code')} · ${v.notesTotal}`));
507
508
  for (const x of v.notes) { const r = mk('div', 'mp-conn mp-inside'); r.append(this.#cite(x.at), mk('span', 'mp-ev', x.text)); sec.append(r); }
508
- if (v.notesTotal > v.notes.length) sec.append(mk('p', 'sub', `${v.notesTotal - v.notes.length} more in the map file`));
509
+ if (v.notesTotal > v.notes.length) sec.append(mk('p', 'sub', t('{n} more in the map file', { n: v.notesTotal - v.notes.length })));
509
510
  }
510
- sec.append(mk('span', 'lbl', 'Connects to'));
511
- if (!v.connectsTo.length) sec.append(mk('p', 'sub', 'Nothing on this map.'));
511
+ sec.append(mk('span', 'lbl', t('Connects to')));
512
+ if (!v.connectsTo.length) sec.append(mk('p', 'sub', t('Nothing on this map.')));
512
513
  for (const c of v.connectsTo) sec.append(this.#conn(c));
513
514
  if (v.evidence.length) {
514
- sec.append(mk('span', 'lbl', `Evidence · ${v.evidenceTotal}`));
515
+ sec.append(mk('span', 'lbl', `${t('Evidence')} · ${v.evidenceTotal}`));
515
516
  for (const e of v.evidence) sec.append(this.#cite(e));
516
- if (v.evidenceTotal > v.evidence.length) sec.append(mk('p', 'sub', `${v.evidenceTotal - v.evidence.length} more in the map file`));
517
+ if (v.evidenceTotal > v.evidence.length) sec.append(mk('p', 'sub', t('{n} more in the map file', { n: v.evidenceTotal - v.evidence.length })));
517
518
  }
518
519
  } else if (this.tab === 'connections') {
519
- if (!v.byKind.length) sec.append(mk('p', 'sub', 'Nothing on this map.'));
520
+ if (!v.byKind.length) sec.append(mk('p', 'sub', t('Nothing on this map.')));
520
521
  for (const [kind, { out, in: into }] of v.byKind) {
521
522
  sec.append(mk('span', 'lbl', `${kind} · ${out.length + into.length}`));
522
523
  for (const c of out) sec.append(this.#conn(c, 'out'));
523
524
  for (const c of into) sec.append(this.#conn(c, 'in'));
524
525
  }
525
526
  } else {
526
- if (!v.findings.length) sec.append(mk('p', 'sub', 'No finding shown for this node.'));
527
+ if (!v.findings.length) sec.append(mk('p', 'sub', t('No finding shown for this node.')));
527
528
  for (const f of v.findings) {
528
529
  const card = mk('div', 'mp-fcard');
529
530
  card.dataset.proven = String(f.proven);
530
531
  const r = mk('div', 'mp-row');
531
- r.append(mk('span', 'mp-ftitle', f.title), mk('span', 'row-fill'), mk('span', 'mp-badge', f.proven ? 'proven' : 'inferred'));
532
- const b = mk('button', 'btn sm', 'Copy as task');
532
+ r.append(mk('span', 'mp-ftitle', f.title), mk('span', 'row-fill'), mk('span', 'mp-badge', f.proven ? t('proven') : t('inferred')));
533
+ const b = mk('button', 'btn sm', t('Copy as task'));
533
534
  b.type = 'button';
534
- b.title = 'Copies the finding as a task to paste into a session. Nothing is run.';
535
+ b.title = t('Copies the finding as a task to paste into a session. Nothing is run.');
535
536
  b.addEventListener('click', () => this.hooks.onCopy?.(taskOf(f), 'task'));
536
537
  card.append(r);
537
538
  if (f.where) card.append(this.#cite(f.where));
@@ -542,8 +543,8 @@ export class MapView {
542
543
  parts.push(sec);
543
544
  }
544
545
  const nm = mk('div', 'mp-notmeasured');
545
- nm.append(mk('span', 'lbl', 'Not measured'));
546
- if (!doc.notMeasured.length) nm.append(mk('p', 'sub', 'Nothing: every file of the project was read.'));
546
+ nm.append(mk('span', 'lbl', t('Not measured')));
547
+ if (!doc.notMeasured.length) nm.append(mk('p', 'sub', t('Nothing: every file of the project was read.')));
547
548
  for (const x of doc.notMeasured) nm.append(mk('p', 'sub', `${x.what} — ${x.reason}`));
548
549
  parts.push(nm);
549
550
  this.side.replaceChildren(...parts);
@@ -555,11 +556,11 @@ export class MapView {
555
556
  if (this.tourAt < 0 || !stops.length) { this.tourBox.hidden = true; return; }
556
557
  const s = stops[Math.min(this.tourAt, stops.length - 1)];
557
558
  const head = mk('div', 'mp-row');
558
- head.append(mk('span', 'lbl', `Tour · ${this.tourAt + 1} of ${stops.length}`), mk('span', 'mp-name', s.name), mk('span', 'row-fill'));
559
+ head.append(mk('span', 'lbl', `${t('Tour')} · ${t('{at} of {n}', { at: this.tourAt + 1, n: stops.length })}`), mk('span', 'mp-name', s.name), mk('span', 'row-fill'));
559
560
  const nav = (label, at, off) => { const b = mk('button', 'btn sm ghost', label); b.type = 'button'; b.disabled = off; b.addEventListener('click', () => this.tour(at)); return b; };
560
- head.append(nav('Back', this.tourAt - 1, this.tourAt === 0), nav(this.tourAt === stops.length - 1 ? 'Done' : 'Next', this.tourAt === stops.length - 1 ? -1 : this.tourAt + 1, false));
561
+ head.append(nav(t('Back'), this.tourAt - 1, this.tourAt === 0), nav(this.tourAt === stops.length - 1 ? t('Done') : t('Next'), this.tourAt === stops.length - 1 ? -1 : this.tourAt + 1, false));
561
562
  const body = [head, mk('p', null, s.text)];
562
- if (s.cites.length) body.push(mk('p', 'sub mp-cites', `From ${s.cites.join(', ')}`));
563
+ if (s.cites.length) body.push(mk('p', 'sub mp-cites', t('From {files}', { files: s.cites.join(', ') })));
563
564
  this.tourBox.replaceChildren(...body);
564
565
  this.tourBox.hidden = false;
565
566
  }
@@ -568,7 +569,7 @@ export class MapView {
568
569
  #cite(text) {
569
570
  const b = mk('button', 'mp-ev mp-cite', text);
570
571
  b.type = 'button';
571
- b.title = 'Copy the path';
572
+ b.title = t('Copy the path');
572
573
  b.addEventListener('click', () => this.hooks.onCopy?.(text, 'path'));
573
574
  return b;
574
575
  }
@@ -5,11 +5,11 @@
5
5
  // apart from the ones that passed or failed.
6
6
  import { roleName, modelName } from './names.js';
7
7
  import { fmtCost, fmtCount } from './usage.js';
8
+ import { t } from './i18n.js';
8
9
 
9
10
  const TIER = { haiku: 'Haiku', sonnet: 'Sonnet', opus: 'Opus', fable: 'Fable' };
10
- export const tierWord = (t) => TIER[t] ?? (t ? String(t) : 'no model');
11
+ export const tierWord = (k) => TIER[k] ?? (k ? String(k) : t('no model'));
11
12
  const pct = (r) => `${Math.round(r * 100)}%`;
12
- const plural = (n, word, many = `${word}s`) => `${n} ${n === 1 ? word : many}`;
13
13
 
14
14
  /**
15
15
  * The task card a crew agent's work starts with: three lines at the top of its task text.
@@ -27,7 +27,7 @@ export function cardOf(prompt) {
27
27
  }
28
28
 
29
29
  const VERIFY_TONE = { pass: 'pass', fail: 'fail' };
30
- const VERIFY_SAYS = { none: 'no verify command', blocked: 'verify not run: the command was not allowed', timeout: 'verify not finished: the command ran out of time' };
30
+ const VERIFY_SAYS = { none: t('no verify command'), blocked: t('verify not run: the command was not allowed'), timeout: t('verify not finished: the command ran out of time') };
31
31
 
32
32
  /** The card as the inspector lists it: the three lines, each kept as it was written. */
33
33
  export function cardLines(card, row) {
@@ -40,11 +40,11 @@ export function cardLines(card, row) {
40
40
 
41
41
  function stepOf(r, again) {
42
42
  const on = r.ranOn ? modelName(r.ranOn) : tierWord(r.model);
43
- const bits = [again ? `Re-run on ${on}` : `Ran on ${on}`];
44
- if (r.fixes > 0) bits.push(`fixed ${r.fixes === 1 ? 'once' : `${r.fixes} times`} inside the agent`);
43
+ const bits = [again ? t('Re-run on {model}', { model: on }) : t('Ran on {model}', { model: on })];
44
+ if (r.fixes > 0) bits.push(r.fixes === 1 ? t('fixed once inside the agent') : t('fixed {n} times inside the agent', { n: r.fixes }));
45
45
  // A verdict is a tag (pass, fail); anything else is said in words, because it is no verdict.
46
46
  const tone = VERIFY_TONE[r.verify] ?? null;
47
- if (!tone) bits.push(VERIFY_SAYS[r.verify] ?? `verify ${r.verify}`);
47
+ if (!tone) bits.push(VERIFY_SAYS[r.verify] ?? t('verify {verdict}', { verdict: r.verify }));
48
48
  return { text: bits.join(' \u00b7 '), verify: tone ? r.verify : null, tone, escalated: again };
49
49
  }
50
50
 
@@ -55,13 +55,13 @@ function stepOf(r, again) {
55
55
  * @returns { risk, riskWhy, steps: [{ text, verify, tone, escalated }], note }
56
56
  */
57
57
  export function workSteps(row) {
58
- if (!row) return { risk: null, riskWhy: null, steps: [], note: 'Not recorded yet: the line is written when the agent stops.' };
58
+ if (!row) return { risk: null, riskWhy: null, steps: [], note: t('Not recorded yet: the line is written when the agent stops.') };
59
59
  const steps = [...(row.before ?? []).map((r) => stepOf(r, false)), stepOf(row, Boolean(row.escalatedFrom))];
60
60
  if (row.escalatedFrom && !(row.before ?? []).length) {
61
- steps.unshift({ text: `An earlier call on ${tierWord(row.escalatedFrom)} did not pass; its line is not in the record`, verify: null, tone: null, escalated: false });
61
+ steps.unshift({ text: t('An earlier call on {model} did not pass; its line is not in the record', { model: tierWord(row.escalatedFrom) }), verify: null, tone: null, escalated: false });
62
62
  }
63
63
  // The reason is the gate's own sentence, kept as it wrote it; a line written before the gate kept one has none.
64
- return { risk: row.risk ?? null, riskWhy: row.risk === 'critical' ? row.riskWhy ?? null : null, steps, note: row.escalatedFrom ? 'A re-run with the same or a lower model is refused by the gate.' : null };
64
+ return { risk: row.risk ?? null, riskWhy: row.risk === 'critical' ? row.riskWhy ?? null : null, steps, note: row.escalatedFrom ? t('A re-run with the same or a lower model is refused by the gate.') : null };
65
65
  }
66
66
 
67
67
  /**
@@ -70,7 +70,7 @@ export function workSteps(row) {
70
70
  */
71
71
  export function outsideRouting(agentType) {
72
72
  if (typeof agentType !== 'string' || !agentType || agentType.startsWith('crew-')) return null;
73
- return 'Outside model routing: not a crew agent, so it has no floor and Crewforth keeps no outcome line for it. The gate only holds the expensive end: a call that asks for opus or fable, or names no model and would take the session\u2019s, is held at sonnet unless its task carries an above: <why> line or opens with a card naming critical work. An agent whose own definition names a model keeps it.';
73
+ return t('Outside model routing: not a crew agent, so it has no floor and Crewforth keeps no outcome line for it. The gate only holds the expensive end: a call that asks for opus or fable, or names no model and would take the session\u2019s, is held at sonnet unless its task carries an above: <why> line or opens with a card naming critical work. An agent whose own definition names a model keeps it.');
74
74
  }
75
75
 
76
76
  /**
@@ -80,12 +80,12 @@ export function outsideRouting(agentType) {
80
80
  */
81
81
  export function floorWords(floor) {
82
82
  if (!floor) return { floorTag: null, raised: null, raisedWhy: '' };
83
- const why = `Raised to ${tierWord(floor.model)} automatically: ${floor.notFirstTry} of ${floor.calls ?? '?'} calls one model down did not pass first time`;
83
+ const why = t('Raised to {model} automatically: {n} of {calls} calls one model down did not pass first time', { model: tierWord(floor.model), n: floor.notFirstTry, calls: floor.calls ?? '?' });
84
84
  if (!floor.lowered) return { floorTag: 'raised', raised: tierWord(floor.model), raisedWhy: why };
85
85
  return {
86
86
  floorTag: 'lowered',
87
87
  raised: null,
88
- raisedWhy: `${why}. You lowered it to ${tierWord(floor.lowered.to)} on ${String(floor.lowered.ts).slice(0, 10)}; its outcomes are counted afresh from then.`,
88
+ raisedWhy: `${why}. ${t('You lowered it to {model} on {date}; its outcomes are counted afresh from then.', { model: tierWord(floor.lowered.to), date: String(floor.lowered.ts).slice(0, 10) })}`,
89
89
  };
90
90
  }
91
91
 
@@ -95,15 +95,15 @@ export function floorWords(floor) {
95
95
  * ("\u2265$0.75 \u00b7 2/7"), because a sum of two calls must not be read as the cost of seven. None: a dash.
96
96
  */
97
97
  export function costCell(cost, runs) {
98
- if (!cost) return { cost: '\u2026', costNote: 'Reading what this class cost\u2026', partial: false, advisor: null };
99
- if (!cost.costed) return { cost: '\u2014', costNote: 'No agent of this class has a transcript on this machine that could be priced', partial: false, advisor: null };
98
+ if (!cost) return { cost: '\u2026', costNote: t('Reading what this class cost\u2026'), partial: false, advisor: null };
99
+ if (!cost.costed) return { cost: '\u2014', costNote: t('No agent of this class has a transcript on this machine that could be priced'), partial: false, advisor: null };
100
100
  const usd = fmtCost(cost.cost);
101
101
  const advisor = advisorWords(cost);
102
102
  const tail = advisor ? `\n${advisor}` : '';
103
- if (cost.costed >= runs) return { cost: usd, costNote: `All ${runs} ${runs === 1 ? 'task' : 'tasks'} priced, at API list price. An estimate: what a subscription is charged is not known here.${tail}`, partial: false, advisor };
103
+ if (cost.costed >= runs) return { cost: usd, costNote: `${runs === 1 ? t('The 1 task priced, at API list price. An estimate: what a subscription is charged is not known here.') : t('All {n} tasks priced, at API list price. An estimate: what a subscription is charged is not known here.', { n: runs })}${tail}`, partial: false, advisor };
104
104
  return {
105
105
  cost: `\u2265${usd.replace(/^~/, '')} \u00b7 ${cost.costed}/${runs}`,
106
- costNote: `At least this much: ${cost.costed} of ${runs} tasks could be priced; the others have no transcript on this machine. At API list price.${tail}`,
106
+ costNote: `${t('At least this much: {n} of {runs} tasks could be priced; the others have no transcript on this machine. At API list price.', { n: cost.costed, runs })}${tail}`,
107
107
  partial: true,
108
108
  advisor,
109
109
  };
@@ -116,17 +116,18 @@ export function costCell(cost, runs) {
116
116
  export function advisorWords(cost) {
117
117
  const n = cost?.advisorCalls ?? 0;
118
118
  if (!(n > 0)) return null;
119
- const share = typeof cost.advisorCost === 'number' && cost.advisorCost > 0 ? `${fmtCost(cost.advisorCost)} of it` : 'its cost not priced';
120
- return `Advisor: ${n} ${n === 1 ? 'call' : 'calls'}, ${share}. A second model the agents consulted, at that model's rates; included above.`;
119
+ const share = typeof cost.advisorCost === 'number' && cost.advisorCost > 0 ? t('{cost} of it', { cost: fmtCost(cost.advisorCost) }) : t('its cost not priced');
120
+ return n === 1 ? t('Advisor: 1 call, {share}. A second model the agents consulted, at that model\'s rates; included above.', { share })
121
+ : t('Advisor: {n} calls, {share}. A second model the agents consulted, at that model\'s rates; included above.', { n, share });
121
122
  }
122
123
 
123
124
  /** One class as the view's row: who and what kind of change, how risky, what ran it, and how it went. */
124
125
  export function classRow(c, cost = null) {
125
- const models = Object.entries(c.models ?? {}).sort((a, b) => b[1].runs - a[1].runs).map(([t, m]) => `${tierWord(t)} ${m.runs}`).join(' \u00b7 ');
126
+ const models = Object.entries(c.models ?? {}).sort((a, b) => b[1].runs - a[1].runs).map(([k, m]) => `${tierWord(k)} ${m.runs}`).join(' \u00b7 ');
126
127
  const apart = [
127
- c.notVerified?.none ? `${c.notVerified.none} with no verify command` : null,
128
- c.notVerified?.blocked ? `${c.notVerified.blocked} not run` : null,
129
- c.notVerified?.timeout ? `${c.notVerified.timeout} timed out` : null,
128
+ c.notVerified?.none ? t('{n} with no verify command', { n: c.notVerified.none }) : null,
129
+ c.notVerified?.blocked ? t('{n} not run', { n: c.notVerified.blocked }) : null,
130
+ c.notVerified?.timeout ? t('{n} timed out', { n: c.notVerified.timeout }) : null,
130
131
  ].filter(Boolean);
131
132
  return {
132
133
  key: c.key,
@@ -141,10 +142,10 @@ export function classRow(c, cost = null) {
141
142
  tasks: String(c.runs),
142
143
  // The rate and what it is a rate of: "83%" of five calls is not "83%" of five hundred.
143
144
  firstTry: c.firstTryRate === null ? '\u2014' : pct(c.firstTryRate),
144
- firstTryOf: c.firstTryRate === null ? 'No call of this class got a verdict from its verify command' : `${c.firstTry} of ${c.verified} verified calls passed the first time`,
145
+ firstTryOf: c.firstTryRate === null ? t('No call of this class got a verdict from its verify command') : t('{n} of {of} verified calls passed the first time', { n: c.firstTry, of: c.verified }),
145
146
  rate: c.firstTryRate,
146
147
  notVerified: apart.join(' \u00b7 '),
147
- after: [c.fixed ? `${c.fixed} fixed by the agent` : null, c.failed ? `${c.failed} failed` : null].filter(Boolean).join(' \u00b7 '),
148
+ after: [c.fixed ? t('{n} fixed by the agent', { n: c.fixed }) : null, c.failed ? t('{n} failed', { n: c.failed }) : null].filter(Boolean).join(' \u00b7 '),
148
149
  escalated: String(c.escalated ?? 0),
149
150
  ...costCell(cost, c.runs),
150
151
  };
@@ -164,8 +165,8 @@ export function lowerCommand(s) {
164
165
  export function suggestionText(s) {
165
166
  return {
166
167
  title: `${roleName(s.agent)} \u00b7 ${s.change}`,
167
- says: `passed ${s.passed} of ${s.passed} on ${tierWord(s.from)}: try ${tierWord(s.to)} for this class?`,
168
- how: 'Yours to decide: send the command in your own session. Studio does not write it.',
168
+ says: t('passed {n} of {n} on {from}: try {to} for this class?', { n: s.passed, from: tierWord(s.from), to: tierWord(s.to) }),
169
+ how: t('Yours to decide: send the command in your own session. Studio does not write it.'),
169
170
  command: lowerCommand(s),
170
171
  };
171
172
  }
@@ -179,43 +180,47 @@ export function suggestionText(s) {
179
180
  */
180
181
  export function aboveText(a, cost = null) {
181
182
  const to = tierWord(a.to);
182
- const ran = Object.entries(a.models ?? {}).sort((x, y) => y[1] - x[1]).map(([t, n]) => `${n} on ${tierWord(t)}`).join(', ');
183
+ const ran = Object.entries(a.models ?? {}).sort((x, y) => y[1] - x[1]).map(([k, n]) => t('{n} on {model}', { n, model: tierWord(k) })).join(', ');
183
184
  let costs = null;
184
185
  if (cost?.costed) {
185
186
  const some = cost.costed < cost.runs;
186
187
  const usd = (v) => (some ? `\u2265${fmtCost(v).replace(/^~/, '')}` : fmtCost(v));
187
- costs = `${usd(cost.cost)} as run; the same tokens at ${to}'s price ${usd(cost.atNormal)}${some ? ` (${cost.costed} of ${cost.runs} priced)` : ''}. API list price; another model would not spend the same tokens.`;
188
+ costs = some
189
+ ? t('{cost} as run; the same tokens at {model}\'s price {normal} ({n} of {runs} priced). API list price; another model would not spend the same tokens.', { cost: usd(cost.cost), model: to, normal: usd(cost.atNormal), n: cost.costed, runs: cost.runs })
190
+ : t('{cost} as run; the same tokens at {model}\'s price {normal}. API list price; another model would not spend the same tokens.', { cost: usd(cost.cost), model: to, normal: usd(cost.atNormal) });
188
191
  }
189
192
  return {
190
193
  title: `${roleName(a.agent)} \u00b7 ${a.change}`,
191
- says: `normal risk, ${plural(a.runs, 'task')} above ${to}: ${ran}.`,
194
+ says: a.runs === 1 ? t('normal risk, 1 task above {model}: {ran}.', { model: to, ran }) : t('normal risk, {n} tasks above {model}: {ran}.', { n: a.runs, model: to, ran }),
192
195
  verdict: a.passed
193
- ? `${to} is enough for this class: its latest ${a.passed} verified calls on ${to} all passed the first time.`
194
- : `Whether ${to} would have done is not measured: this class does not have ${a.need ?? 'enough'} verified calls on ${to} that all passed the first time.`,
196
+ ? t('{model} is enough for this class: its latest {n} verified calls on {model} all passed the first time.', { model: to, n: a.passed })
197
+ : a.need != null
198
+ ? t('Whether {model} would have done is not measured: this class does not have {n} verified calls on {model} that all passed the first time.', { model: to, n: a.need })
199
+ : t('Whether {model} would have done is not measured: this class does not have enough verified calls on {model} that all passed the first time.', { model: to }),
195
200
  measured: Boolean(a.passed),
196
201
  costs,
197
202
  };
198
203
  }
199
204
 
200
- export const ABOVE_HEAD = 'Above the table \u00b7 nothing is blocked';
201
- export const ABOVE_RULE = 'The gate holds a floor, not a ceiling: which model runs normal work is yours to choose.';
205
+ export const ABOVE_HEAD = t('Above the table \u00b7 nothing is blocked');
206
+ export const ABOVE_RULE = t('The gate holds a floor, not a ceiling: which model runs normal work is yours to choose.');
202
207
 
203
208
  /** What the view says when there is nothing to list. */
204
209
  export function emptyNote(data) {
205
- if (!data) return 'Reading the record…';
206
- if (data.measured === false) return `Not measured — ${data.reason}`;
207
- if (!data.classes?.length) return 'The record is there and has no line yet.';
210
+ if (!data) return t('Reading the record…');
211
+ if (data.measured === false) return t('Not measured — {reason}', { reason: data.reason });
212
+ if (!data.classes?.length) return t('The record is there and has no line yet.');
208
213
  return null;
209
214
  }
210
215
 
211
- export const SOURCE = 'Source: .claude/state/model-outcomes.tsv';
212
- export const LOWER_RULE = 'Lowering a floor is never automatic. Raising it is.';
216
+ export const SOURCE = t('Source: .claude/state/model-outcomes.tsv');
217
+ export const LOWER_RULE = t('Lowering a floor is never automatic. Raising it is.');
213
218
 
214
219
  /** What was read for cost, for the header's tooltip; null before it has been. */
215
- export const costLine = (costs) => (costs?.measured ? `${fmtCount(costs.sessionsRead ?? 0)} ${costs.sessionsRead === 1 ? 'session' : 'sessions'} read for cost` : null);
220
+ export const costLine = (costs) => (costs?.measured ? (costs.sessionsRead === 1 ? t('1 session read for cost') : t('{n} sessions read for cost', { n: fmtCount(costs.sessionsRead ?? 0) })) : null);
216
221
 
217
222
  export const totalsLine = (data) => [
218
- `${plural(data.rows ?? 0, 'task')} in ${plural(data.classes?.length ?? 0, 'class', 'classes')}`,
219
- data.dropped ? `${data.dropped} ${data.dropped === 1 ? 'line' : 'lines'} not read (${Object.entries(data.droppedWhy ?? {}).map(([why, n]) => `${n}: ${why}`).join('; ') || 'no reason kept'})` : null,
220
- data.truncated ? 'only the end of a long record was read' : null,
223
+ `${(data.rows ?? 0) === 1 ? t('1 task') : t('{n} tasks', { n: data.rows ?? 0 })} ${(data.classes?.length ?? 0) === 1 ? t('in 1 class') : t('in {n} classes', { n: data.classes?.length ?? 0 })}`,
224
+ data.dropped ? `${data.dropped === 1 ? t('1 line not read') : t('{n} lines not read', { n: data.dropped })} (${Object.entries(data.droppedWhy ?? {}).map(([why, n]) => `${n}: ${why}`).join('; ') || t('no reason kept')})` : null,
225
+ data.truncated ? t('only the end of a long record was read') : null,
221
226
  ].filter(Boolean).join(' · ');