crewforth 3.4.0 → 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 (182) hide show
  1. package/CHANGELOG.md +273 -0
  2. package/README.md +3 -2
  3. package/VERSION +1 -1
  4. package/adopt.sh +40 -5
  5. package/bin/cli.js +138 -2
  6. package/kit/AGENT_TEMPLATE.md +15 -0
  7. package/kit/CLAUDE.md +50 -18
  8. package/kit/agents/crew-backend-expert.md +4 -3
  9. package/kit/agents/crew-commit-agent.md +2 -0
  10. package/kit/agents/crew-database-expert.md +2 -1
  11. package/kit/agents/crew-devops-expert.md +1 -0
  12. package/kit/agents/crew-docs-agent.md +3 -1
  13. package/kit/agents/crew-frontend-expert.md +2 -0
  14. package/kit/agents/crew-performance-expert.md +7 -2
  15. package/kit/agents/crew-planner.md +15 -3
  16. package/kit/agents/crew-privacy-agent.md +2 -0
  17. package/kit/agents/crew-review-agent.md +8 -1
  18. package/kit/agents/crew-security-expert.md +4 -3
  19. package/kit/agents/crew-test-expert.md +6 -2
  20. package/kit/eval/doctor.sh +201 -1
  21. package/kit/eval/gate-report.sh +28 -0
  22. package/kit/eval/golden-holdout.txt +2 -0
  23. package/kit/eval/golden-routing.txt +17 -0
  24. package/kit/eval/lib/crew-map/contracts.mjs +77 -0
  25. package/kit/eval/lib/crew-map/crew-map.sh +2 -1
  26. package/kit/eval/lib/crew-map/detect.mjs +2 -1
  27. package/kit/eval/lib/crew-map/narrative.mjs +14 -3
  28. package/kit/eval/lib/crew-map/project.mjs +45 -6
  29. package/kit/eval/lib/crew-map/scan.mjs +153 -14
  30. package/kit/eval/lib/findings/findings.schema.json +11 -1
  31. package/kit/eval/lib/findings/findings.sh +24 -1
  32. package/kit/eval/lib/hook-inject.sh +159 -0
  33. package/kit/eval/lib/lockfiles.sh +71 -0
  34. package/kit/eval/lib/payload-check.sh +29 -0
  35. package/kit/eval/lib/preview.sh +3 -3
  36. package/kit/eval/lib/review-bench/bench.mjs +14 -2
  37. package/kit/eval/lib/review-prepass/prepass.mjs +32 -3
  38. package/kit/eval/lib/settings-json.awk +18 -0
  39. package/kit/eval/lib/statusline.sh +22 -5
  40. package/kit/eval/lib/todo.awk +167 -0
  41. package/kit/eval/lib/todo.sh +120 -0
  42. package/kit/eval/lib/untrusted-text.awk +38 -0
  43. package/kit/eval/scan-skill.sh +67 -20
  44. package/kit/eval/smoke-test.sh +1759 -41
  45. package/kit/eval/utilization.sh +77 -3
  46. package/kit/hooks/agent-outcome.sh +9 -1
  47. package/kit/hooks/board-sync.sh +49 -3
  48. package/kit/hooks/board.sh +8 -8
  49. package/kit/hooks/branch-fresh.sh +101 -0
  50. package/kit/hooks/floor-blocklist.txt +19 -1
  51. package/kit/hooks/guard-bash.sh +92 -8
  52. package/kit/hooks/guard-question.sh +2 -1
  53. package/kit/hooks/loop-guard.sh +445 -0
  54. package/kit/hooks/map-stale.sh +437 -0
  55. package/kit/hooks/pre-commit +48 -7
  56. package/kit/hooks/precompact-snapshot.sh +102 -6
  57. package/kit/hooks/prompt-approval.sh +50 -14
  58. package/kit/hooks/route-hint.sh +36 -7
  59. package/kit/hooks/secret-blocklist.txt +55 -1
  60. package/kit/hooks/session-guard.sh +45 -15
  61. package/kit/hooks/session-rehydrate.sh +34 -11
  62. package/kit/hooks/session-stats.sh +169 -8
  63. package/kit/hooks/session-update-check.sh +5 -1
  64. package/kit/hooks/skill-trust.sh +23 -14
  65. package/kit/hooks/turn-check.sh +190 -0
  66. package/kit/owned-blobs.tsv +4 -4
  67. package/kit/settings.json +64 -0
  68. package/kit/skills/a11y/SKILL.md +50 -3
  69. package/kit/skills/a11y/references/recipes.md +41 -0
  70. package/kit/skills/adr/SKILL.md +14 -2
  71. package/kit/skills/api-design/SKILL.md +6 -1
  72. package/kit/skills/api-design/references/idempotency.md +39 -0
  73. package/kit/skills/brainstorm/SKILL.md +4 -2
  74. package/kit/skills/brainstorm/references/asking.md +47 -0
  75. package/kit/skills/ci-pipeline/SKILL.md +11 -1
  76. package/kit/skills/ci-pipeline/references/agent-job.md +33 -0
  77. package/kit/skills/commit-message/SKILL.md +9 -3
  78. package/kit/skills/confidence-check/SKILL.md +9 -7
  79. package/kit/skills/confidence-check/references/source-check.md +37 -0
  80. package/kit/skills/crew-approve/SKILL.md +3 -0
  81. package/kit/skills/crew-audit/SKILL.md +63 -0
  82. package/kit/skills/crew-board/SKILL.md +2 -2
  83. package/kit/skills/crew-code-review/SKILL.md +27 -4
  84. package/kit/skills/crew-code-review/references/panel-mode.md +9 -9
  85. package/kit/skills/crew-code-review/references/simplify.md +28 -0
  86. package/kit/skills/crew-doctor/SKILL.md +3 -2
  87. package/kit/skills/crew-gates/SKILL.md +3 -0
  88. package/kit/skills/crew-gates/references/enforcers.md +30 -0
  89. package/kit/skills/crew-interview/SKILL.md +42 -0
  90. package/kit/skills/crew-map/SKILL.md +26 -6
  91. package/kit/skills/crew-plan/SKILL.md +10 -2
  92. package/kit/skills/crew-pr/SKILL.md +57 -0
  93. package/kit/skills/crew-pr/references/checks.md +35 -0
  94. package/kit/skills/crew-pr/references/threads.md +23 -0
  95. package/kit/skills/crew-review/SKILL.md +34 -1
  96. package/kit/skills/crew-ship/SKILL.md +5 -2
  97. package/kit/skills/crew-skill/SKILL.md +26 -1
  98. package/kit/skills/crew-todo/SKILL.md +31 -0
  99. package/kit/skills/crew-update/SKILL.md +4 -1
  100. package/kit/skills/db-migration/SKILL.md +15 -0
  101. package/kit/skills/db-migration/references/expand-contract.md +52 -0
  102. package/kit/skills/dependency-audit/SKILL.md +34 -1
  103. package/kit/skills/dependency-audit/scripts/record.sh +37 -0
  104. package/kit/skills/dependency-upgrade/SKILL.md +29 -1
  105. package/kit/skills/deploy/SKILL.md +18 -0
  106. package/kit/skills/deploy/references/topology-a.md +7 -0
  107. package/kit/skills/deploy/references/topology-b.md +2 -2
  108. package/kit/skills/docs-writer/SKILL.md +37 -2
  109. package/kit/skills/eval-grader/SKILL.md +9 -0
  110. package/kit/skills/eval-grader/references/method.md +26 -0
  111. package/kit/skills/frontend/SKILL.md +5 -2
  112. package/kit/skills/frontend/references/from-design.md +29 -0
  113. package/kit/skills/frontend/references/page-metadata.md +17 -0
  114. package/kit/skills/frontend/references/verify-contract.md +18 -1
  115. package/kit/skills/frontend/references/worst-data.md +35 -0
  116. package/kit/skills/frontend-design/SKILL.md +20 -4
  117. package/kit/skills/frontend-design/references/design-review.md +21 -0
  118. package/kit/skills/frontend-design/references/detection.md +44 -0
  119. package/kit/skills/frontend-design/references/motion.md +54 -0
  120. package/kit/skills/frontend-design/references/ui-copy.md +35 -0
  121. package/kit/skills/frontend-rn-expo/SKILL.md +3 -0
  122. package/kit/skills/handoff/SKILL.md +28 -3
  123. package/kit/skills/handoff/references/cold-reader.md +13 -6
  124. package/kit/skills/i18n-integrity/SKILL.md +3 -0
  125. package/kit/skills/iterate/SKILL.md +7 -2
  126. package/kit/skills/mcp-builder/SKILL.md +21 -4
  127. package/kit/skills/mcp-builder/references/building.md +74 -13
  128. package/kit/skills/mcp-builder/references/cli-for-agents.md +26 -0
  129. package/kit/skills/observability/SKILL.md +13 -1
  130. package/kit/skills/performance/SKILL.md +25 -1
  131. package/kit/skills/performance/references/bottlenecks.md +11 -0
  132. package/kit/skills/performance/references/web-vitals.md +31 -0
  133. package/kit/skills/red-team/SKILL.md +25 -3
  134. package/kit/skills/reflect/SKILL.md +7 -2
  135. package/kit/skills/release/SKILL.md +13 -0
  136. package/kit/skills/security-scan/SKILL.md +6 -2
  137. package/kit/skills/security-scan/references/ai-agents.md +16 -0
  138. package/kit/skills/security-scan/references/fronts.md +59 -2
  139. package/kit/skills/security-scan/references/reporting.md +13 -0
  140. package/kit/skills/security-scan/references/verify.md +9 -0
  141. package/kit/skills/sonarqube-check/SKILL.md +32 -2
  142. package/kit/skills/sonarqube-check/references/ratchet.md +32 -0
  143. package/kit/skills/spec-planning/SKILL.md +32 -8
  144. package/kit/skills/systematic-debugging/SKILL.md +15 -7
  145. package/kit/skills/systematic-debugging/references/techniques.md +5 -0
  146. package/kit/skills/teamboard/SKILL.md +3 -3
  147. package/kit/skills/testing/SKILL.md +18 -3
  148. package/kit/skills/testing/references/browser-headless.md +21 -0
  149. package/kit/skills/token-budget/SKILL.md +26 -5
  150. package/kit/skills/worktree/SKILL.md +13 -5
  151. package/kit/studio/server/lib/session.js +23 -2
  152. package/kit/studio/server/lib/worktree-changes.js +64 -0
  153. package/kit/studio/web/agent-spend.js +32 -0
  154. package/kit/studio/web/app.js +245 -236
  155. package/kit/studio/web/approvals.js +14 -13
  156. package/kit/studio/web/ask.js +10 -9
  157. package/kit/studio/web/canvas.js +56 -54
  158. package/kit/studio/web/chat.js +41 -41
  159. package/kit/studio/web/convo.js +33 -16
  160. package/kit/studio/web/dock.js +20 -19
  161. package/kit/studio/web/graph-plan.js +62 -22
  162. package/kit/studio/web/i18n-tr.js +760 -0
  163. package/kit/studio/web/i18n.js +79 -0
  164. package/kit/studio/web/index.html +1 -0
  165. package/kit/studio/web/inspect.js +27 -24
  166. package/kit/studio/web/list-plan.js +16 -15
  167. package/kit/studio/web/list.js +6 -5
  168. package/kit/studio/web/liveness.js +11 -9
  169. package/kit/studio/web/map-plan.js +86 -36
  170. package/kit/studio/web/map.js +85 -72
  171. package/kit/studio/web/models-plan.js +47 -42
  172. package/kit/studio/web/models.js +21 -20
  173. package/kit/studio/web/names.js +13 -10
  174. package/kit/studio/web/nav.js +34 -33
  175. package/kit/studio/web/newsession.js +25 -24
  176. package/kit/studio/web/style.css +2 -0
  177. package/kit/studio/web/theme.js +5 -2
  178. package/kit/studio/web/timeline-plan.js +19 -18
  179. package/kit/studio/web/timeline.js +23 -22
  180. package/kit/studio/web/usage.js +36 -32
  181. package/package.json +5 -4
  182. package/start.sh +11 -2
@@ -10,9 +10,10 @@
10
10
  // nothing; what leaves the page is copied text: a finding as a task, or a file:line.
11
11
  import {
12
12
  nodesOf, edgesOf, edgeKey, litEdges, findingsOf, findingCounts, cardSub, summaryOf, sourceOf, inspectOf, taskOf,
13
- emptyNote, layoutOf, pathOf, chipsOf, inferredOnly, groupsOf, foldView, placed, BANDS, cardOf, tourOf, searchOf, resourceWord,
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,17 +223,18 @@ 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);
231
232
  }
232
233
  const lines = svg('svg', { class: 'mp-lines', width: lay.width, height: lay.height, 'aria-hidden': 'true' });
233
234
  const labels = [];
234
- for (const e of edges) {
235
- const p = pathOf(lay.pos[e.from], lay.pos[e.to]);
235
+ const routes = routesOf(edges, lay.pos);
236
+ for (const [ei, e] of edges.entries()) {
237
+ const p = routes[ei];
236
238
  if (!p) continue;
237
239
  const path = svg('path', { d: p.d, class: 'mp-line' });
238
240
  path.dataset.solid = String(e.solid);
@@ -243,7 +245,7 @@ export class MapView {
243
245
  if (EDGE_WORD[e.kind] && !e.calls) tags.push(mk('span', 'mp-badge', EDGE_WORD[e.kind]));
244
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); }
245
247
  if (e.calls) {
246
- 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 })));
247
249
  if (e.missing) { const m = mk('span', 'mp-pill', `${e.missing} missing`); m.dataset.tone = 'missing'; tags.push(m); }
248
250
  }
249
251
  if (tags.length) {
@@ -271,14 +273,14 @@ export class MapView {
271
273
 
272
274
  #head(doc, findings) {
273
275
  const head = mk('header', 'mp-head');
274
- head.append(mk('h2', 'mp-title', 'Map'), mk('span', 'row-fill'));
276
+ head.append(mk('h2', 'mp-title', t('Map')), mk('span', 'row-fill'));
275
277
  for (const s of summaryOf(doc, findings)) head.append(mk('span', 'mp-pill', s));
276
278
  return head;
277
279
  }
278
280
 
279
281
  #toolbar(d) {
280
282
  const bar = mk('div', 'mp-toolbar');
281
- bar.append(mk('span', 'lbl', 'Layers'));
283
+ bar.append(mk('span', 'lbl', t('Layers')));
282
284
  for (const [k, label] of LAYERS) {
283
285
  const b = mk('button', 'mp-tog', label);
284
286
  b.type = 'button';
@@ -289,8 +291,8 @@ export class MapView {
289
291
  bar.append(mk('span', 'row-fill'));
290
292
  const search = mk('input', 'mp-search');
291
293
  search.type = 'search';
292
- search.placeholder = 'Search files and modules';
293
- 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'));
294
296
  search.value = this.query;
295
297
  search.addEventListener('input', () => { this.query = search.value; this.render(); this.top.querySelector('.mp-search')?.focus(); });
296
298
  search.addEventListener('keydown', (e) => {
@@ -300,16 +302,16 @@ export class MapView {
300
302
  });
301
303
  bar.append(search);
302
304
  if (tourOf(d.doc).length) {
303
- const t = mk('button', 'mp-tog', 'Tour');
304
- t.type = 'button';
305
- t.setAttribute('aria-pressed', String(this.tourAt >= 0));
306
- t.title = 'The stops /crew-map wrote for someone new to the project, in order';
307
- t.addEventListener('click', () => this.tour(this.tourAt >= 0 ? -1 : 0));
308
- 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);
309
311
  }
310
312
  const pick = mk('select', 'mp-compare');
311
- pick.setAttribute('aria-label', 'Compare with');
312
- 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'));
313
315
  none.value = '';
314
316
  pick.append(none);
315
317
  for (const o of d.others ?? []) { const opt = mk('option', null, o.label); opt.value = o.cwd; pick.append(opt); }
@@ -317,20 +319,20 @@ export class MapView {
317
319
  pick.disabled = !d.others?.length;
318
320
  pick.addEventListener('change', () => this.hooks.onCompare?.(pick.value || null));
319
321
  const legend = mk('div', 'mp-legend');
320
- const lb = mk('button', 'btn sm ghost', 'Legend');
322
+ const lb = mk('button', 'btn sm ghost', t('Legend'));
321
323
  lb.type = 'button';
322
324
  lb.setAttribute('aria-expanded', String(this.legendOpen));
323
325
  lb.addEventListener('click', () => { this.legendOpen = !this.legendOpen; this.render(); });
324
326
  legend.append(lb);
325
327
  if (this.legendOpen) {
326
328
  const lg = mk('div', 'mp-legend-box');
327
- 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')]]) {
328
330
  const row = mk('div', 'mp-legend-row');
329
331
  const s = mk('span', 'mp-legend-line'); s.dataset.kind = kind;
330
332
  row.append(s, mk('span', 'sub', text));
331
333
  lg.append(row);
332
334
  }
333
- 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')]]) {
334
336
  const row = mk('div', 'mp-legend-row');
335
337
  const s = mk('span', 'mp-legend-card'); s.dataset.kind = kind;
336
338
  row.append(s, mk('span', 'sub', text));
@@ -348,7 +350,7 @@ export class MapView {
348
350
  strip.append(mk('code', 'mp-file', s.file), mk('span', 'sub', `· ${s.at}`));
349
351
  if (s.behind) { const b = mk('span', 'mp-pill', s.behind); b.dataset.tone = 'behind'; strip.append(b); }
350
352
  if (s.behindNote) strip.append(mk('span', 'sub', s.behindNote));
351
- 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 })));
352
354
  strip.append(mk('span', 'row-fill'), mk('span', 'sub', s.rule));
353
355
  return strip;
354
356
  }
@@ -371,7 +373,7 @@ export class MapView {
371
373
  const name = mk('span', 'mp-name', n.name ?? n.id);
372
374
  name.title = n.name ?? n.id;
373
375
  top.append(name);
374
- 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); }
375
377
  top.append(mk('span', 'row-fill'));
376
378
  if (n.kind === 'group') { card.append(top, mk('span', 'sub', `${n.members.length} modules · click to open`)); }
377
379
  else if (n.kind !== 'module') { card.append(top, mk('span', 'sub', cardSub(n, doc))); }
@@ -386,7 +388,7 @@ export class MapView {
386
388
  card.append(row);
387
389
  }
388
390
  if (this.layers.external) {
389
- 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'));
390
392
  if (c.outside.length) fold.title = c.outside.join('\n');
391
393
  card.append(fold);
392
394
  }
@@ -407,26 +409,26 @@ export class MapView {
407
409
 
408
410
  #findings(f) {
409
411
  const box = mk('section', 'mp-findings');
410
- box.setAttribute('aria-label', 'Findings');
412
+ box.setAttribute('aria-label', t('Findings'));
411
413
  const h = mk('div', 'mp-row');
412
- 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'));
413
415
  if (f.hidden) {
414
- const t = mk('button', 'btn sm ghost', this.showHidden ? `Hide ${f.hidden} hidden by default` : `Show ${f.hidden} hidden by default`);
415
- t.type = 'button';
416
- t.title = 'Rules wrong too often to show unasked. Their findings are kept, not deleted.';
417
- t.addEventListener('click', () => { this.showHidden = !this.showHidden; this.render(); });
418
- 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);
419
421
  }
420
- 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')));
421
423
  box.append(h);
422
- 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.')));
423
425
  const list = mk('div', 'mp-flist');
424
426
  for (const x of f.shown) {
425
427
  const row = mk('button', 'mp-frow');
426
428
  row.type = 'button';
427
429
  const dot = mk('span', 'mp-dot'); dot.dataset.proven = String(x.proven);
428
- 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'));
429
- 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');
430
432
  row.addEventListener('click', () => { if (x.modules[0]) this.select(x.modules[0], { reveal: true }); });
431
433
  list.append(row);
432
434
  }
@@ -446,7 +448,7 @@ export class MapView {
446
448
  r.addEventListener('click', () => this.select(n.id));
447
449
  return r;
448
450
  });
449
- this.list.replaceChildren(mk('span', 'lbl', `Modules · ${rows.length}`), ...items);
451
+ this.list.replaceChildren(mk('span', 'lbl', `${t('Modules')} · ${rows.length}`), ...items);
450
452
  }
451
453
 
452
454
  #inspector(node, doc, edges, findings) {
@@ -455,71 +457,82 @@ export class MapView {
455
457
  // The project's overview, when /crew-map wrote one: what it says, and the lines it rests on.
456
458
  const ov = doc?.overview;
457
459
  if (typeof ov?.text === 'string' && ov.text.trim()) {
458
- 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));
459
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(', ')}`));
460
462
  }
461
- 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.')));
462
464
  } else {
463
465
  const v = inspectOf(node, doc, edges, findings);
464
466
  const top = mk('div', 'mp-row');
465
467
  const close = mk('button', 'btn icon sm ghost', '×');
466
- close.type = 'button'; close.setAttribute('aria-label', 'Close inspector');
468
+ close.type = 'button'; close.setAttribute('aria-label', t('Close inspector'));
467
469
  close.addEventListener('click', () => this.select(null));
468
470
  top.append(mk('span', 'mp-name', v.title), mk('span', 'row-fill'), close);
469
471
  parts.push(top);
470
472
  if (v.layer || v.domain) {
471
473
  const pills = mk('div', 'mp-row');
472
- if (v.layer) pills.append(mk('span', 'mp-pill', `Layer · ${v.layer}`));
473
- 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}`));
474
476
  parts.push(pills);
475
477
  }
476
478
  if (v.path) parts.push(mk('code', 'sub', v.path));
477
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); }
478
480
  const tabs = mk('nav', 'mp-tabs');
479
- for (const [k, label] of [['overview', 'Overview'], ['connections', `Connections ${v.connections.out.length + v.connections.in.length}`], ['findings', `Findings ${v.findings.length}`]]) {
480
- const t = mk('button', 'mp-tab', label);
481
- t.type = 'button';
482
- t.setAttribute('aria-selected', String(this.tab === k));
483
- t.addEventListener('click', () => { this.tab = k; this.render(); });
484
- 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);
485
487
  }
486
488
  parts.push(tabs);
487
489
  const sec = mk('div', 'mp-isec');
488
490
  if (this.tab === 'overview') {
489
- sec.append(mk('span', 'lbl', 'What it is for'), mk('p', v.narrative ? null : 'sub', v.narrative ?? v.narrativeNote));
490
- 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(', ') })));
491
493
  if (v.inside && (v.inside.entry || v.inside.folders.length)) {
492
- sec.append(mk('span', 'lbl', 'Inside'));
494
+ sec.append(mk('span', 'lbl', t('Inside')));
493
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); }
494
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); }
495
- if (v.inside.other) sec.append(mk('p', 'sub', `${v.inside.other} more files in other folders`));
496
- 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 })));
497
499
  }
498
- sec.append(mk('span', 'lbl', 'Connects to'));
499
- if (!v.connectsTo.length) sec.append(mk('p', 'sub', 'Nothing on this map.'));
500
+ if (v.changed) sec.append(mk('p', 'sub', v.changed));
501
+ if (v.testFiles.length) {
502
+ sec.append(mk('span', 'lbl', `${t('Tests')} · ${v.testTotal}`));
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); }
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 })));
505
+ }
506
+ if (v.notes.length) {
507
+ sec.append(mk('span', 'lbl', `${t('Notes in the code')} · ${v.notesTotal}`));
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); }
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 })));
510
+ }
511
+ sec.append(mk('span', 'lbl', t('Connects to')));
512
+ if (!v.connectsTo.length) sec.append(mk('p', 'sub', t('Nothing on this map.')));
500
513
  for (const c of v.connectsTo) sec.append(this.#conn(c));
501
514
  if (v.evidence.length) {
502
- sec.append(mk('span', 'lbl', `Evidence · ${v.evidenceTotal}`));
515
+ sec.append(mk('span', 'lbl', `${t('Evidence')} · ${v.evidenceTotal}`));
503
516
  for (const e of v.evidence) sec.append(this.#cite(e));
504
- 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 })));
505
518
  }
506
519
  } else if (this.tab === 'connections') {
507
- 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.')));
508
521
  for (const [kind, { out, in: into }] of v.byKind) {
509
522
  sec.append(mk('span', 'lbl', `${kind} · ${out.length + into.length}`));
510
523
  for (const c of out) sec.append(this.#conn(c, 'out'));
511
524
  for (const c of into) sec.append(this.#conn(c, 'in'));
512
525
  }
513
526
  } else {
514
- 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.')));
515
528
  for (const f of v.findings) {
516
529
  const card = mk('div', 'mp-fcard');
517
530
  card.dataset.proven = String(f.proven);
518
531
  const r = mk('div', 'mp-row');
519
- r.append(mk('span', 'mp-ftitle', f.title), mk('span', 'row-fill'), mk('span', 'mp-badge', f.proven ? 'proven' : 'inferred'));
520
- 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'));
521
534
  b.type = 'button';
522
- 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.');
523
536
  b.addEventListener('click', () => this.hooks.onCopy?.(taskOf(f), 'task'));
524
537
  card.append(r);
525
538
  if (f.where) card.append(this.#cite(f.where));
@@ -530,8 +543,8 @@ export class MapView {
530
543
  parts.push(sec);
531
544
  }
532
545
  const nm = mk('div', 'mp-notmeasured');
533
- nm.append(mk('span', 'lbl', 'Not measured'));
534
- 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.')));
535
548
  for (const x of doc.notMeasured) nm.append(mk('p', 'sub', `${x.what} — ${x.reason}`));
536
549
  parts.push(nm);
537
550
  this.side.replaceChildren(...parts);
@@ -543,11 +556,11 @@ export class MapView {
543
556
  if (this.tourAt < 0 || !stops.length) { this.tourBox.hidden = true; return; }
544
557
  const s = stops[Math.min(this.tourAt, stops.length - 1)];
545
558
  const head = mk('div', 'mp-row');
546
- 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'));
547
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; };
548
- 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));
549
562
  const body = [head, mk('p', null, s.text)];
550
- 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(', ') })));
551
564
  this.tourBox.replaceChildren(...body);
552
565
  this.tourBox.hidden = false;
553
566
  }
@@ -556,7 +569,7 @@ export class MapView {
556
569
  #cite(text) {
557
570
  const b = mk('button', 'mp-ev mp-cite', text);
558
571
  b.type = 'button';
559
- b.title = 'Copy the path';
572
+ b.title = t('Copy the path');
560
573
  b.addEventListener('click', () => this.hooks.onCopy?.(text, 'path'));
561
574
  return b;
562
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(' · ');