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
@@ -0,0 +1,79 @@
1
+ // The panel's language. English is the panel's own and the default; Turkish is a choice the viewer makes.
2
+ //
3
+ // The English sentence is the key. t() returns it unchanged unless Turkish is chosen and the dictionary holds it,
4
+ // so a sentence nobody has translated yet still reads, in English, rather than as a key or a blank. Whether every
5
+ // sentence HAS a translation is not left to that fallback: the Studio gate reads each t('…') in web/ and index.html
6
+ // and fails on a missing, extra or mismatched entry.
7
+ //
8
+ // The language is read once, when this module is first evaluated. Every module that renders text imports this one,
9
+ // so it is settled before any of them builds a label, including the word maps they build at load. Switching reloads
10
+ // the page for that reason: a label built in one language is not rebuilt in the other by anything less.
11
+ import { TR } from './i18n-tr.js';
12
+
13
+ export const LANG_KEY = 'crewforth-studio-lang';
14
+ const known = (v) => (v === 'en' || v === 'tr' ? v : null);
15
+
16
+ function saved() {
17
+ // Through window, not globalThis: Node answers a bare localStorage with a warning, and the gate imports this.
18
+ try { return known(globalThis.window?.localStorage?.getItem(LANG_KEY)); } catch { return null; /* blocked storage: English */ }
19
+ }
20
+
21
+ let lang = saved() ?? 'en';
22
+
23
+ export const currentLang = () => lang;
24
+ /** For the gate: the page itself switches by reloading, never by this. */
25
+ export function setLang(l) { lang = known(l) ?? 'en'; }
26
+
27
+ /** The sentence in the chosen language, with {name} filled from vars. */
28
+ export function t(en, vars) {
29
+ const s = lang === 'tr' && Object.hasOwn(TR, en) ? TR[en] : en;
30
+ return vars ? s.replace(/\{(\w+)\}/g, (m, k) => (Object.hasOwn(vars, k) ? String(vars[k]) : m)) : s;
31
+ }
32
+
33
+ /**
34
+ * A sentence with elements in it ("Runs in <code>…</code> on branch <code>…</code>."): the translated sentence cut at
35
+ * its {slots}, each slot replaced by the node given for it, so the words around them can move with the language.
36
+ */
37
+ export function tParts(en, slots) {
38
+ return t(en).split(/(\{\w+\})/).filter(Boolean)
39
+ .map((p) => { const m = /^\{(\w+)\}$/.exec(p); return m && Object.hasOwn(slots, m[1]) ? slots[m[1]] : p; });
40
+ }
41
+
42
+ /** The locale numbers and clock times are written in. */
43
+ export const numLocale = () => (lang === 'tr' ? 'tr-TR' : 'en-US');
44
+
45
+ const ATTRS = ['aria-label', 'title', 'placeholder'];
46
+
47
+ /** index.html is written in English; this puts its words in the chosen language. A no-op in English. */
48
+ export function translateDom(root) {
49
+ root.ownerDocument?.documentElement?.setAttribute('lang', lang);
50
+ if (lang === 'en') return;
51
+ for (const e of root.querySelectorAll('*')) {
52
+ for (const a of ATTRS) {
53
+ const v = e.getAttribute(a);
54
+ if (v && Object.hasOwn(TR, v)) e.setAttribute(a, TR[v]);
55
+ }
56
+ for (const n of e.childNodes) {
57
+ if (n.nodeType !== 3) continue;
58
+ const s = n.nodeValue.trim();
59
+ if (s && Object.hasOwn(TR, s)) n.nodeValue = n.nodeValue.replace(s, TR[s]);
60
+ }
61
+ }
62
+ }
63
+
64
+ /**
65
+ * The language button. It names the language it switches TO, in that language, so a viewer who cannot read the
66
+ * current one can still find the way back. The choice is remembered per browser; storage that refuses costs the
67
+ * memory, and the page then reloads into the default.
68
+ */
69
+ export function initLang(button, ls, reload) {
70
+ const to = lang === 'tr' ? 'en' : 'tr';
71
+ button.textContent = to.toUpperCase();
72
+ const say = to === 'tr' ? 'Türkçe' : 'English';
73
+ button.setAttribute('aria-label', say);
74
+ button.title = say;
75
+ button.addEventListener('click', () => {
76
+ try { ls.setItem(LANG_KEY, to); } catch { /* blocked storage: nothing to remember it in */ }
77
+ reload();
78
+ });
79
+ }
@@ -30,6 +30,7 @@
30
30
  <button id="theme" class="btn icon ghost" type="button" aria-label="Switch theme">
31
31
  <svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M13 9.5A5.5 5.5 0 0 1 6.5 3a5.5 5.5 0 1 0 6.5 6.5z"/></svg>
32
32
  </button>
33
+ <button id="lang" class="btn icon ghost lang-btn" type="button" aria-label="Switch language"></button>
33
34
  <button id="fullscreen" class="btn icon ghost" type="button" title="Full screen (f)" aria-label="Full screen">
34
35
  <svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 6V3h3M10 3h3v3M13 10v3h-3M6 13H3v-3"/></svg>
35
36
  </button>
@@ -4,8 +4,9 @@
4
4
  // skills it applied, who delegated to it. The rule they share is the panel's own: a value that was not read is
5
5
  // written "Not measured", with why — never as 0 and never as an empty list.
6
6
  import { roleName, modelName, modelFamily, modelOf } from './names.js';
7
+ import { t } from './i18n.js';
7
8
 
8
- const NOT_MEASURED = 'Not measured';
9
+ const NOT_MEASURED = t('Not measured');
9
10
 
10
11
  export function fmtTokens(t) {
11
12
  if (t == null) return null;
@@ -17,9 +18,9 @@ export function fmtTokens(t) {
17
18
  export function fmtDuration(ms) {
18
19
  if (ms == null || !Number.isFinite(ms)) return null;
19
20
  const s = Math.max(0, Math.round(ms / 1000));
20
- if (s < 60) return `${s}s`;
21
+ if (s < 60) return t('{n}s', { n: s });
21
22
  const m = Math.floor(s / 60);
22
- return m < 60 ? `${m}m ${s % 60}s` : `${Math.floor(m / 60)}h ${m % 60}m`;
23
+ return m < 60 ? t('{m}m {s}s', { m, s: s % 60 }) : t('{h}h {m}m', { h: Math.floor(m / 60), m: m % 60 });
23
24
  }
24
25
 
25
26
  /** How long the agent has been at it: the recorded duration when it has ended, the clock since it started while
@@ -37,21 +38,21 @@ export function elapsed(n, nowMs) {
37
38
  export function tiles(n, nowMs) {
38
39
  const el = elapsed(n, nowMs);
39
40
  return [
40
- { label: 'Tool calls', value: String(n.toolCount ?? 0), measured: n.toolCount != null },
41
+ { label: t('Tool calls'), value: String(n.toolCount ?? 0), measured: n.toolCount != null },
41
42
  {
42
- label: 'Tokens',
43
+ label: t('Tokens'),
43
44
  value: n.tokens == null ? NOT_MEASURED : fmtTokens(n.tokens),
44
45
  measured: n.tokens != null,
45
- why: n.tokens == null ? 'the transcript carries no usage record for this agent yet' : null,
46
+ why: n.tokens == null ? t('the transcript carries no usage record for this agent yet') : null,
46
47
  },
47
48
  {
48
- label: 'Elapsed',
49
+ label: t('Elapsed'),
49
50
  value: el == null ? NOT_MEASURED : fmtDuration(el),
50
51
  measured: el != null,
51
- why: el == null ? 'no start time was recorded' : null,
52
+ why: el == null ? t('no start time was recorded') : null,
52
53
  },
53
- { label: 'Errors', value: String(n.errors ?? 0), measured: n.errors != null, bad: (n.errors ?? 0) > 0 },
54
- ].map((t) => (t.measured ? t : { ...t, value: NOT_MEASURED, why: t.why ?? 'not in the transcript' }));
54
+ { label: t('Errors'), value: String(n.errors ?? 0), measured: n.errors != null, bad: (n.errors ?? 0) > 0 },
55
+ ].map((x) => (x.measured ? x : { ...x, value: NOT_MEASURED, why: x.why ?? t('not in the transcript') }));
55
56
  }
56
57
 
57
58
  /** "8m 20s · started 14:02" */
@@ -61,7 +62,7 @@ export function timeLine(n, nowMs) {
61
62
  if (el != null) parts.push(fmtDuration(el));
62
63
  if (n.startedAt != null) {
63
64
  const d = new Date(n.startedAt);
64
- parts.push(`started ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`);
65
+ parts.push(t('started {time}', { time: `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` }));
65
66
  }
66
67
  return parts.join(' · ');
67
68
  }
@@ -74,8 +75,8 @@ export function rightNow(n, detail) {
74
75
  const live = n.status === 'running' || n.status === 'starting';
75
76
  const last = detail?.timeline?.length ? detail.timeline[detail.timeline.length - 1] : null;
76
77
  const tool = last?.name ?? n.lastTool ?? null;
77
- if (!tool) return { heading: live ? 'Right now' : 'Last tool', tool: null, text: live ? 'No tool call yet' : 'No tool was called' };
78
- return { heading: live ? 'Right now' : 'Last tool', tool, text: last?.label ?? '' };
78
+ if (!tool) return { heading: live ? t('Right now') : t('Last tool'), tool: null, text: live ? t('No tool call yet') : t('No tool was called') };
79
+ return { heading: live ? t('Right now') : t('Last tool'), tool, text: last?.label ?? '' };
79
80
  }
80
81
 
81
82
  /** The skills the agent invoked, in the order it first did, each once. */
@@ -112,23 +113,25 @@ export function modelLines(n, nodes) {
112
113
  if (n?.kind !== 'agent') return [];
113
114
  const m = modelOf(n);
114
115
  const lines = [
115
- { label: 'Ran on', text: n.model ? `${m.ran} \u00b7 ${n.model}` : 'not recorded' },
116
- { label: 'Asked for', text: n.modelAsked ? `${n.modelAsked}${m.differs ? ' \u2014 differs from what ran' : ''}` : 'no model named (the agent\'s default)' },
116
+ { label: t('Ran on'), text: n.model ? `${m.ran} \u00b7 ${n.model}` : t('not recorded') },
117
+ { label: t('Asked for'), text: n.modelAsked ? `${n.modelAsked}${m.differs ? ` \u2014 ${t('differs from what ran')}` : ''}` : t('no model named (the agent\'s default)') },
117
118
  ];
118
119
  // The family is the word the choice is made in (haiku, sonnet, opus); a model with none keeps its own name.
119
- const short = (model) => (model ? (modelFamily(model) ?? modelName(model).toLowerCase()) : 'no model');
120
+ const short = (model) => (model ? (modelFamily(model) ?? modelName(model).toLowerCase()) : t('no model'));
120
121
  if (n.firstTry) {
121
122
  lines.push({
122
- label: 'Model gate',
123
- text: `first asked: ${short(n.firstTry.asked)} \u2192 refused${n.firstTry.floor ? ` (floor: ${n.firstTry.floor})` : ''} \u2192 ${short(n.modelAsked ?? n.model)}`,
123
+ label: t('Model gate'),
124
+ text: n.firstTry.floor
125
+ ? t('first asked: {asked} \u2192 refused (floor: {floor}) \u2192 {ran}', { asked: short(n.firstTry.asked), floor: n.firstTry.floor, ran: short(n.modelAsked ?? n.model) })
126
+ : t('first asked: {asked} \u2192 refused \u2192 {ran}', { asked: short(n.firstTry.asked), ran: short(n.modelAsked ?? n.model) }),
124
127
  note: n.firstTry.text,
125
128
  });
126
129
  }
127
130
  const tagOf = (id) => { const o = (nodes ?? []).find((x) => x.id === id); return o?.order != null ? ` (#${o.order})` : ''; };
128
131
  if (n.escalatedFrom) {
129
- lines.push({ label: 'Escalated', text: `escalated: ${short(n.escalatedFrom.from)} \u2192 ${short(n.escalatedFrom.to)}, after${tagOf(n.escalatedFrom.id)} closed "confidence: low"`, goto: n.escalatedFrom.id });
132
+ lines.push({ label: t('Escalated'), text: t('escalated: {from} \u2192 {to}, after{tag} closed "confidence: low"', { from: short(n.escalatedFrom.from), to: short(n.escalatedFrom.to), tag: tagOf(n.escalatedFrom.id) }), goto: n.escalatedFrom.id });
130
133
  }
131
- if (n.escalatedTo) lines.push({ label: 'Escalated', text: `closed "confidence: low"; repeated one model up${tagOf(n.escalatedTo)}`, goto: n.escalatedTo });
134
+ if (n.escalatedTo) lines.push({ label: t('Escalated'), text: t('closed "confidence: low"; repeated one model up{tag}', { tag: tagOf(n.escalatedTo) }), goto: n.escalatedTo });
132
135
  return lines;
133
136
  }
134
137
 
@@ -136,8 +139,8 @@ export function modelLines(n, nodes) {
136
139
  export function delegatedBy(n, nodes) {
137
140
  const p = (nodes ?? []).find((x) => x.id === n.parentId) ?? null;
138
141
  if (!p) return null;
139
- if (p.kind === 'session') return { id: p.id, text: `Session${p.gitBranch ? ` · ${p.gitBranch}` : ''}` };
140
- if (p.kind === 'workflow') return { id: p.id, text: `Workflow run · ${p.workflowId ?? p.id}` };
142
+ if (p.kind === 'session') return { id: p.id, text: `${t('Session')}${p.gitBranch ? ` · ${p.gitBranch}` : ''}` };
143
+ if (p.kind === 'workflow') return { id: p.id, text: `${t('Workflow run')} · ${p.workflowId ?? p.id}` };
141
144
  return { id: p.id, text: p.agentType ? roleName(p.agentType) : p.id };
142
145
  }
143
146
 
@@ -147,9 +150,9 @@ export function reportOf(n, detail) {
147
150
  const live = n.status === 'running' || n.status === 'starting';
148
151
  return {
149
152
  present: false,
150
- text: live ? 'Not reported yet — the agent is still running.' : 'Not reported — the transcript holds no closing text.',
153
+ text: live ? t('Not reported yet — the agent is still running.') : t('Not reported — the transcript holds no closing text.'),
151
154
  };
152
155
  }
153
156
 
154
157
  export const TABS = ['overview', 'conversation', 'gates', 'stats'];
155
- export const TAB_WORD = { overview: 'Overview', conversation: 'Conversation', gates: 'Gates', stats: 'Stats' };
158
+ export const TAB_WORD = { overview: t('Overview'), conversation: t('Conversation'), gates: t('Gates'), stats: t('Stats') };
@@ -5,15 +5,16 @@
5
5
  // gets a section of its own, under its own name, after them.
6
6
  import { stateOf } from './graph-plan.js';
7
7
  import { typeOf, shownType } from './names.js';
8
+ import { t } from './i18n.js';
8
9
 
9
10
  /** "8m", "1h 4m", "40s": how long, in as little room as a row has. */
10
11
  export function short(ms) {
11
12
  if (ms == null || !Number.isFinite(ms) || ms < 0) return null;
12
13
  const s = Math.floor(ms / 1000);
13
- if (s < 60) return `${s}s`;
14
+ if (s < 60) return t('{n}s', { n: s });
14
15
  const m = Math.floor(s / 60);
15
- if (m < 60) return `${m}m`;
16
- return `${Math.floor(m / 60)}h ${m % 60}m`;
16
+ if (m < 60) return t('{n}m', { n: m });
17
+ return t('{h}h {m}m', { h: Math.floor(m / 60), m: m % 60 });
17
18
  }
18
19
 
19
20
  const when = (n) => n.endedAt ?? n.updatedAt ?? n.startedAt ?? 0;
@@ -23,9 +24,9 @@ function rowOf(n, now) {
23
24
  const state = stateOf(n.status);
24
25
  if (state === 'failed') {
25
26
  const bits = [];
26
- if (n.workflow) bits.push(`in ${n.workflow}`);
27
+ if (n.workflow) bits.push(t('in {name}', { name: n.workflow }));
27
28
  // The count the transcript gave. An agent can fail without a tool error; then there is none to report.
28
- if (n.errors) bits.push(`${n.errors} ${n.errors === 1 ? 'error' : 'errors'}`);
29
+ if (n.errors) bits.push(n.errors === 1 ? t('{n} error', { n: 1 }) : t('{n} errors', { n: n.errors }));
29
30
  if (n.status !== 'failed') bits.push(n.status);
30
31
  return { id: n.id, node: n, type: shownType(n), real: typeOf(n), line: bits.join(' · ') || (n.description ?? ''), aside: null };
31
32
  }
@@ -37,7 +38,7 @@ function rowOf(n, now) {
37
38
  real: typeOf(n),
38
39
  line: n.description ?? '',
39
40
  // How long it has been going. With no start recorded there is nothing to count from, and nothing is shown.
40
- aside: state === 'waking' ? 'starting' : live && n.startedAt != null ? short(now - n.startedAt) : null,
41
+ aside: state === 'waking' ? t('starting') : live && n.startedAt != null ? short(now - n.startedAt) : null,
41
42
  };
42
43
  }
43
44
 
@@ -56,16 +57,16 @@ export function sections(nodes, { queue = [], current = null, folded = new Map()
56
57
 
57
58
  // Needs you: every request waiting, this session's first. Each is a card that carries its own answers.
58
59
  const cards = [...queue].sort((a, b) => Number(b.sessionId === current) - Number(a.sessionId === current) || (a.askedAt ?? 0) - (b.askedAt ?? 0));
59
- if (cards.length) out.push({ key: 'needs', title: 'Needs you', count: cards.length, tone: 'waiting', cards, rows: [], folded: false, foldable: false });
60
+ if (cards.length) out.push({ key: 'needs', title: t('Needs you'), count: cards.length, tone: 'waiting', cards, rows: [], folded: false, foldable: false });
60
61
 
61
62
  const by = (pred, sort) => agents.filter(pred).sort(sort).map((n) => rowOf(n, now));
62
63
  const newest = (a, b) => when(b) - when(a);
63
64
  const oldest = (a, b) => (a.startedAt ?? Infinity) - (b.startedAt ?? Infinity) || String(a.id).localeCompare(String(b.id));
64
65
 
65
66
  const fixed = [
66
- { key: 'failed', title: 'Failed', tone: 'fail', rows: by((n) => stateOf(n.status) === 'failed', newest), foldedByDefault: false },
67
- { key: 'running', title: 'Running', tone: 'busy', rows: by((n) => ['live', 'waking'].includes(stateOf(n.status)), oldest), foldedByDefault: false },
68
- { key: 'done', title: 'Done', tone: 'good', rows: by((n) => stateOf(n.status) === 'done', newest), foldedByDefault: true },
67
+ { key: 'failed', title: t('Failed'), tone: 'fail', rows: by((n) => stateOf(n.status) === 'failed', newest), foldedByDefault: false },
68
+ { key: 'running', title: t('Running'), tone: 'busy', rows: by((n) => ['live', 'waking'].includes(stateOf(n.status)), oldest), foldedByDefault: false },
69
+ { key: 'done', title: t('Done'), tone: 'good', rows: by((n) => stateOf(n.status) === 'done', newest), foldedByDefault: true },
69
70
  ];
70
71
 
71
72
  // Everything else, by the status the transcript wrote. `ended` and `stale` have words here; anything the panel
@@ -78,7 +79,7 @@ export function sections(nodes, { queue = [], current = null, folded = new Map()
78
79
  if (!rest.has(key)) rest.set(key, []);
79
80
  rest.get(key).push(n);
80
81
  }
81
- const WORD = { ended: 'Ended', stale: 'Stale' };
82
+ const WORD = { ended: t('Ended'), stale: t('Stale') };
82
83
  for (const [status, members] of [...rest].sort((a, b) => a[0].localeCompare(b[0]))) {
83
84
  fixed.push({
84
85
  key: `status:${status}`, title: WORD[status] ?? status, tone: 'none', known: status in WORD,
@@ -100,9 +101,9 @@ export function sections(nodes, { queue = [], current = null, folded = new Map()
100
101
 
101
102
  /** What a request card says under the agent's name. */
102
103
  export function wants(item) {
103
- if (item.ask?.kind === 'questions') return 'asks';
104
- if (item.ask?.kind === 'plan') return 'wants its plan approved';
105
- return `wants to run ${item.toolName}`;
104
+ if (item.ask?.kind === 'questions') return t('asks');
105
+ if (item.ask?.kind === 'plan') return t('wants its plan approved');
106
+ return t('wants to run {tool}', { tool: item.toolName });
106
107
  }
107
108
 
108
109
  /** Which view a window opens on when the viewer has not chosen one: the List on a phone, the graph elsewhere. */
@@ -113,5 +114,5 @@ export function defaultView(stored, phone) {
113
114
 
114
115
  /** The graph and the Timeline are drawn for a wide window. On a phone they still open, and say so. */
115
116
  export function narrowWarning(view, phone) {
116
- return phone && view !== 'list' ? 'Best on a wider screen' : null;
117
+ return phone && view !== 'list' ? t('Best on a wider screen') : null;
117
118
  }
@@ -7,6 +7,7 @@ import { Press } from './press.js';
7
7
  import { hoverOf, modelOf } from './names.js';
8
8
  import { remaining, asker, allowSessionLabel, VERDICTS } from './approvals.js';
9
9
  import { AskForms } from './ask.js';
10
+ import { t } from './i18n.js';
10
11
 
11
12
  function mk(tag, cls, text) {
12
13
  const n = document.createElement(tag);
@@ -81,8 +82,8 @@ export class List {
81
82
  this.lastSections = list;
82
83
  if (!list.length) {
83
84
  const empty = mk('div', 'ls-empty');
84
- empty.append(mk('strong', null, 'No agents yet'),
85
- mk('span', null, 'This session has not delegated to a subagent. When it does, each one is listed here.'));
85
+ empty.append(mk('strong', null, t('No agents yet')),
86
+ mk('span', null, t('This session has not delegated to a subagent. When it does, each one is listed here.')));
86
87
  this.root.replaceChildren(empty);
87
88
  return;
88
89
  }
@@ -104,7 +105,7 @@ export class List {
104
105
  head.type = 'button';
105
106
  head.dataset.key = `sec:${s.key}`;
106
107
  head.setAttribute('aria-expanded', String(!s.folded));
107
- head.append(mk('span', 'row-fill'), mk('span', 'sub', s.folded ? 'show' : 'hide'));
108
+ head.append(mk('span', 'row-fill'), mk('span', 'sub', s.folded ? t('show') : t('hide')));
108
109
  head.addEventListener('click', () => { this.folded.set(s.key, !s.folded); this.render(); this.hooks.onFold?.(); });
109
110
  }
110
111
  sec.append(head);
@@ -122,13 +123,13 @@ export class List {
122
123
  const r = remaining(item, now);
123
124
  const clock = mk('span', 'ls-clock', r.known ? `${r.left}s` : '?');
124
125
  clock.setAttribute('role', 'timer');
125
- clock.setAttribute('aria-label', r.known ? `Auto-deny in ${r.left} seconds` : 'Time left not measured');
126
+ clock.setAttribute('aria-label', r.known ? t('Auto-deny in {n} seconds', { n: r.left }) : t('Time left not measured'));
126
127
  head.append(who, mk('span', 'row-fill'), clock);
127
128
  card.append(head);
128
129
  if (item.detail && !item.ask) card.append(mk('pre', 'ls-cmd', item.detail));
129
130
  // A request from another session says which, and goes there.
130
131
  if (item.sessionId !== this.current) {
131
- const where = mk('button', 'ls-where', `in ${item.sessionName}`);
132
+ const where = mk('button', 'ls-where', t('in {name}', { name: item.sessionName }));
132
133
  where.type = 'button';
133
134
  where.addEventListener('click', () => this.hooks.onLocate?.(item));
134
135
  card.append(where);
@@ -16,6 +16,8 @@
16
16
  //
17
17
  // Offline is not a longer silence. It is the last request failing: a refused connection answers at once, so there
18
18
  // is nothing to wait for.
19
+ import { t } from './i18n.js';
20
+
19
21
  export const STALE_AFTER_MS = 10000;
20
22
 
21
23
  /**
@@ -28,16 +30,16 @@ export function liveness(nowMs, lastOkMs, lastFailed) {
28
30
  if (lastFailed) {
29
31
  return {
30
32
  state: 'offline',
31
- word: 'Offline',
33
+ word: t('Offline'),
32
34
  tone: 'fail',
33
- detail: lastOkMs === null ? 'the server has not answered' : `last update ${clock(lastOkMs)}`,
35
+ detail: lastOkMs === null ? t('the server has not answered') : t('last update {time}', { time: clock(lastOkMs) }),
34
36
  };
35
37
  }
36
- if (lastOkMs === null) return { state: 'connecting', word: 'Connecting', tone: null, detail: '' };
38
+ if (lastOkMs === null) return { state: 'connecting', word: t('Connecting'), tone: null, detail: '' };
37
39
  const silence = Math.max(0, nowMs - lastOkMs);
38
- const said = `${Math.round(silence / 1000)}s ago`;
39
- if (silence > STALE_AFTER_MS) return { state: 'stale', word: 'Stale', tone: 'waiting', detail: said };
40
- return { state: 'live', word: 'Live', tone: 'good', detail: said };
40
+ const said = t('{n}s ago', { n: Math.round(silence / 1000) });
41
+ if (silence > STALE_AFTER_MS) return { state: 'stale', word: t('Stale'), tone: 'waiting', detail: said };
42
+ return { state: 'live', word: t('Live'), tone: 'good', detail: said };
41
43
  }
42
44
 
43
45
  /**
@@ -53,8 +55,8 @@ export function offlineNote(state, lastOkMs, nextTryMs, nowMs) {
53
55
  if (state !== 'offline') return null;
54
56
  const left = nextTryMs == null ? null : Math.max(0, Math.ceil((nextTryMs - nowMs) / 1000));
55
57
  return {
56
- text: lastOkMs === null ? 'The server has not answered yet, so there is nothing to show.' : `Showing the last update from ${clock(lastOkMs)}.`,
57
- retry: left == null ? 'Reconnecting' : left === 0 ? 'Reconnecting now' : `Reconnecting in ${left}s`,
58
+ text: lastOkMs === null ? t('The server has not answered yet, so there is nothing to show.') : t('Showing the last update from {time}.', { time: clock(lastOkMs) }),
59
+ retry: left == null ? t('Reconnecting') : left === 0 ? t('Reconnecting now') : t('Reconnecting in {n}s', { n: left }),
58
60
  stale: lastOkMs !== null,
59
61
  };
60
62
  }
@@ -72,7 +74,7 @@ function clock(ms) {
72
74
  * changed since it started; a server with no `code` at all predates the field, and is older than this page.
73
75
  * Returns the sentence to show, or null.
74
76
  */
75
- export const RESTART_NOTE = 'Studio was updated after this server started, so it is running the old code. Restart it: stop it where it runs (Ctrl-C), then /crew-studio.';
77
+ export const RESTART_NOTE = t('Studio was updated after this server started, so it is running the old code. Restart it: stop it where it runs (Ctrl-C), then /crew-studio.');
76
78
  export function staleServerNote(health) {
77
79
  if (!health || typeof health !== 'object') return null;
78
80
  if (!health.code || typeof health.code !== 'object') return RESTART_NOTE;
@@ -7,20 +7,22 @@
7
7
 
8
8
  // The layers /crew-map may write (eval/lib/crew-map/narrative.mjs LAYERS), set between the bands the scan alone
9
9
  // gives, so a map with no layer is drawn in the same order as before.
10
+ import { t } from './i18n.js';
11
+
10
12
  export const LAYER_KEYS = ['ui', 'api', 'service', 'data', 'infra', 'tool', 'library'];
11
13
  export const BANDS = [
12
- ['ui', 'UI'],
13
- ['clients', 'Clients'],
14
- ['api', 'API'],
15
- ['entry', 'Entry points'],
16
- ['service', 'Service'],
17
- ['core', 'Core'],
18
- ['data', 'Data'],
19
- ['infra', 'Infrastructure'],
20
- ['library', 'Library'],
21
- ['tool', 'Tools'],
22
- ['external', 'External'],
23
- ['unplaced', 'Unplaced'],
14
+ ['ui', t('UI')],
15
+ ['clients', t('Clients')],
16
+ ['api', t('API')],
17
+ ['entry', t('Entry points')],
18
+ ['service', t('Service')],
19
+ ['core', t('Core')],
20
+ ['data', t('Data')],
21
+ ['infra', t('Infrastructure')],
22
+ ['library', t('Library')],
23
+ ['tool', t('Tools')],
24
+ ['external', t('External')],
25
+ ['unplaced', t('Unplaced')],
24
26
  ];
25
27
 
26
28
  const RESOURCE = { db: 'db', cache: 'cache', queue: 'queue', auth: 'auth', 'http-api': 'api' };
@@ -181,7 +183,7 @@ export function findingsOf(doc, { showHidden = false, compare = null } = {}) {
181
183
  for (const c of compare?.clients ?? []) {
182
184
  for (const m of c.missing) {
183
185
  all.push({
184
- id: `cmp:${c.id}:${m.method} ${m.path}`, rule: 'compare-missing', title: `${m.method} ${m.path} is called by ${c.name} and not provided here`,
186
+ id: `cmp:${c.id}:${m.method} ${m.path}`, rule: 'compare-missing', title: t('{endpoint} is called by {client} and not provided here', { endpoint: `${m.method} ${m.path}`, client: c.name }),
185
187
  proven: true, hidden: false, hiddenReason: null, where: m.evidence?.[0] ? `${m.evidence[0].file}:${m.evidence[0].line}` : '', modules: [`cmp:${c.id}`],
186
188
  evidence: m.evidence ?? [], from: compare.label,
187
189
  });
@@ -204,13 +206,15 @@ export function findingCounts(findings) {
204
206
  return out;
205
207
  }
206
208
 
209
+ const endpoints = (n) => (n === 1 ? t('1 endpoint') : t('{n} endpoints', { n }));
210
+
207
211
  /** What a card says under its name. */
208
212
  export function cardSub(node, doc) {
209
- if (node.kind === 'client') return `${node.from} · ${node.calls.length} ${node.calls.length === 1 ? 'call' : 'calls'} here`;
210
- if (node.kind === 'service') return node.image ? `container · ${node.image}` : 'container';
213
+ if (node.kind === 'client') return `${node.from} · ${node.calls.length === 1 ? t('1 call here') : t('{n} calls here', { n: node.calls.length })}`;
214
+ if (node.kind === 'service') return node.image ? `${t('container')} · ${node.image}` : t('container');
211
215
  const provided = (doc.provides?.endpoints ?? []).filter((e) => e.module === node.id).length;
212
216
  const tech = (node.tech ?? []).slice(0, 2).map((t) => t.replace(/@.*$/, '')).join(' · ');
213
- return [tech || node.stack, provided ? `${provided} ${provided === 1 ? 'endpoint' : 'endpoints'}` : null].filter(Boolean).join(' · ');
217
+ return [tech || node.stack, provided ? endpoints(provided) : null].filter(Boolean).join(' · ');
214
218
  }
215
219
 
216
220
  /**
@@ -220,7 +224,7 @@ export function cardSub(node, doc) {
220
224
  export const CARD_CHIPS = 3;
221
225
  export function cardOf(node, doc) {
222
226
  const provided = (doc.provides?.endpoints ?? []).filter((e) => e.module === node.id).length;
223
- const kind = [node.narrative?.domain ?? node.stack ?? null, provided ? `${provided} ${provided === 1 ? 'endpoint' : 'endpoints'}` : null].filter(Boolean).join(' · ');
227
+ const kind = [node.narrative?.domain ?? node.stack ?? null, provided ? endpoints(provided) : null].filter(Boolean).join(' · ');
224
228
  const tech = (node.tech ?? []).map((t) => t.replace(/@.*$/, ''));
225
229
  return { kind, chips: tech.slice(0, CARD_CHIPS), more: Math.max(0, tech.length - CARD_CHIPS), outside: outsideOf(node, doc) };
226
230
  }
@@ -264,10 +268,10 @@ export function summaryOf(doc, findings) {
264
268
  const n = (k) => (doc.nodes ?? []).filter((x) => x.kind === k).length;
265
269
  const eps = (doc.provides?.endpoints?.length ?? 0) + (doc.consumes?.endpoints?.length ?? 0);
266
270
  return [
267
- `${n('module')} ${n('module') === 1 ? 'module' : 'modules'}`,
268
- `${n('external')} external`,
269
- `${eps} ${eps === 1 ? 'endpoint' : 'endpoints'}`,
270
- `${findings.shown.length} ${findings.shown.length === 1 ? 'finding' : 'findings'}`,
271
+ n('module') === 1 ? t('1 module') : t('{n} modules', { n: n('module') }),
272
+ t('{n} external', { n: n('external') }),
273
+ endpoints(eps),
274
+ findings.shown.length === 1 ? t('1 finding') : t('{n} findings', { n: findings.shown.length }),
271
275
  ];
272
276
  }
273
277
 
@@ -277,10 +281,10 @@ export function sourceOf(data) {
277
281
  const behind = data.behind?.count;
278
282
  return {
279
283
  file: data.file,
280
- at: commit ? `scanned at ${commit.slice(0, 7)}` : 'scanned outside git',
281
- behind: typeof behind === 'number' && behind > 0 ? `${behind} ${behind === 1 ? 'commit' : 'commits'} behind` : null,
284
+ at: commit ? t('scanned at {commit}', { commit: commit.slice(0, 7) }) : t('scanned outside git'),
285
+ behind: typeof behind === 'number' && behind > 0 ? (behind === 1 ? t('1 commit behind') : t('{n} commits behind', { n: behind })) : null,
282
286
  behindNote: typeof behind === 'number' ? null : (data.behind?.reason ?? null),
283
- rule: 'Nothing is run; files are only read.',
287
+ rule: t('Nothing is run; files are only read.'),
284
288
  };
285
289
  }
286
290
 
@@ -304,7 +308,7 @@ export function inspectOf(node, doc, edges, findings) {
304
308
  // /crew-map's description: { text, cites }, each cite a file:line of this node's evidence (eval/lib/crew-map/narrative.mjs).
305
309
  narrative: typeof node.narrative?.text === 'string' ? node.narrative.text : null,
306
310
  narrativeCites: Array.isArray(node.narrative?.cites) ? node.narrative.cites.filter((c) => typeof c === 'string') : [],
307
- narrativeNote: 'Not written yet: this map holds no description of the module, only what its files show.',
311
+ narrativeNote: t('Not written yet: this map holds no description of the module, only what its files show.'),
308
312
  // /crew-map's layer and domain, when it wrote them; the band is the layer's (bandOf).
309
313
  layer: LAYER_KEYS.includes(node.narrative?.layer) ? (BANDS.find(([k]) => k === node.narrative.layer)?.[1] ?? null) : null,
310
314
  domain: typeof node.narrative?.domain === 'string' ? node.narrative.domain : null,
@@ -320,7 +324,7 @@ export function inspectOf(node, doc, edges, findings) {
320
324
  testFiles: Array.isArray(node.tests?.files) ? node.tests.files.filter((f) => typeof f === 'string') : [],
321
325
  testTotal: Number.isInteger(node.tests?.total) ? node.tests.total : 0,
322
326
  changed: Number.isInteger(node.commits) && Number.isInteger(doc?.source?.commitsRead) && doc.source.commitsRead > 0
323
- ? `Changed in ${node.commits} of the last ${doc.source.commitsRead} ${doc.source.commitsRead === 1 ? 'commit' : 'commits'}` : null,
327
+ ? (doc.source.commitsRead === 1 ? t('Changed in {n} of the last 1 commit', { n: node.commits }) : t('Changed in {n} of the last {of} commits', { n: node.commits, of: doc.source.commitsRead })) : null,
324
328
  notes: (node.markers?.evidence ?? []).filter((e) => e && typeof e.file === 'string').map((e) => ({ at: `${e.file}:${e.line}`, text: String(e.text ?? '') })),
325
329
  notesTotal: node.markers?.evidenceTotal ?? (node.markers?.evidence ?? []).length,
326
330
  connectsTo: out,
@@ -345,8 +349,8 @@ export function taskOf(f) {
345
349
 
346
350
  /** What the view says when there is no map to draw. */
347
351
  export function emptyNote(data) {
348
- if (!data) return 'Reading the map…';
349
- if (data.measured === false) return data.missing ? `No map yet — ${data.reason}` : `Not read — ${data.reason}`;
352
+ if (!data) return t('Reading the map…');
353
+ if (data.measured === false) return data.missing ? t('No map yet — {reason}', { reason: data.reason }) : t('Not read — {reason}', { reason: data.reason });
350
354
  return null;
351
355
  }
352
356
 
@@ -452,5 +456,5 @@ export function chipsOf(node, doc) {
452
456
  const p = (doc.provides?.endpoints ?? []).filter((e) => e.module === node.id).map((e) => `${e.method} ${e.path}`);
453
457
  const c = (doc.consumes?.endpoints ?? []).filter((e) => e.module === node.id).map((e) => `→ ${e.method} ${e.path}`);
454
458
  const all = [...p, ...c];
455
- return all.length > CHIPS_MAX ? [...all.slice(0, CHIPS_MAX), `+${all.length - CHIPS_MAX} more`] : all;
459
+ return all.length > CHIPS_MAX ? [...all.slice(0, CHIPS_MAX), t('+{n} more', { n: all.length - CHIPS_MAX })] : all;
456
460
  }