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
@@ -8,6 +8,7 @@
8
8
  // (`askedAt`) and what time it is now (`now`) all come from the machine the hook runs on, so a viewer whose clock
9
9
  // differs — a browser on the far end of a tunnel — still counts down to the moment the hook will really give up.
10
10
  import { roleName } from './names.js';
11
+ import { t } from './i18n.js';
11
12
 
12
13
  /** The server's clock, as seen from this page: the difference is measured each time the server says what time it is. */
13
14
  export class ServerClock {
@@ -25,8 +26,8 @@ export class ServerClock {
25
26
 
26
27
  // A plan is approved into one of these; the words are the buttons'.
27
28
  export const PLAN_CHOICES = [
28
- { mode: 'acceptEdits', label: 'Approve \u00b7 accept edits', title: 'Approve the plan and go on in acceptEdits: file edits in the project are accepted without asking; every call is still shown here first.' },
29
- { mode: 'default', label: 'Approve \u00b7 ask each time', title: 'Approve the plan and go on in default: each call is asked about here.' },
29
+ { mode: 'acceptEdits', label: t('Approve \u00b7 accept edits'), title: t('Approve the plan and go on in acceptEdits: file edits in the project are accepted without asking; every call is still shown here first.') },
30
+ { mode: 'default', label: t('Approve \u00b7 ask each time'), title: t('Approve the plan and go on in default: each call is asked about here.') },
30
31
  ];
31
32
 
32
33
  /**
@@ -137,22 +138,22 @@ export function terminalWait(status, session, ownedHere) {
137
138
  const call = session?.openCall ?? null;
138
139
  const who = call?.agentType ? `${roleName(call.agentType)} · ` : '';
139
140
  const what = call ? `${who}${call.toolName}${call.detail ? ` · ${call.detail}` : ''}` : (status.waitingFor ?? null);
140
- return { text: 'Waiting for an answer in the terminal', what };
141
+ return { text: t('Waiting for an answer in the terminal'), what };
141
142
  }
142
143
 
143
144
  export function asker(item) {
144
- return item.agentType ? roleName(item.agentType) : 'Session';
145
+ return item.agentType ? roleName(item.agentType) : t('Session');
145
146
  }
146
147
 
147
148
  export const VERDICTS = {
148
- allow: { label: 'Allow once', said: 'Allowed' },
149
- always: { label: null, said: 'Allowed for this session' },
150
- deny: { label: 'Deny', said: 'Denied' },
149
+ allow: { label: t('Allow once'), said: t('Allowed') },
150
+ always: { label: null, said: t('Allowed for this session') },
151
+ deny: { label: t('Deny'), said: t('Denied') },
151
152
  };
152
153
 
153
154
  /** "Allow Bash this session": the button names the tool it widens the gate for. */
154
155
  export function allowSessionLabel(toolName) {
155
- return `Allow ${toolName} this session`;
156
+ return t('Allow {tool} this session', { tool: toolName });
156
157
  }
157
158
 
158
159
  /**
@@ -186,9 +187,9 @@ export function settled(before, after, decided, serverNowMs, slackMs = 2500) {
186
187
  }
187
188
 
188
189
  export const OUTCOME_WORD = {
189
- allowed: 'Allowed',
190
- 'allowed-session': 'Allowed for this session',
191
- denied: 'Denied',
192
- 'timed-out': 'Timed out — denied',
193
- 'answered-elsewhere': 'Answered elsewhere',
190
+ allowed: t('Allowed'),
191
+ 'allowed-session': t('Allowed for this session'),
192
+ denied: t('Denied'),
193
+ 'timed-out': t('Timed out — denied'),
194
+ 'answered-elsewhere': t('Answered elsewhere'),
194
195
  };
@@ -4,6 +4,7 @@
4
4
  // A form is built once for a request and kept while the request waits. The dock and the List redraw around it every
5
5
  // second; a form rebuilt on each of those would lose what was picked, and what was being typed.
6
6
  import { answersOf, PLAN_CHOICES } from './approvals.js';
7
+ import { t } from './i18n.js';
7
8
 
8
9
  function mk(tag, cls, text) {
9
10
  const n = document.createElement(tag);
@@ -44,7 +45,7 @@ export class AskForms {
44
45
  const picked = new Map(); // question -> Set of labels
45
46
  const typed = new Map(); // question -> the viewer's own text
46
47
  const controls = [];
47
- const send = mk('button', 'btn sm primary', 'Send answers');
48
+ const send = mk('button', 'btn sm primary', t('Send answers'));
48
49
  const refresh = () => { send.disabled = send.dataset.sent === '1' || answersOf(item.ask, picked, typed) === null; };
49
50
 
50
51
  for (const q of item.ask.questions) {
@@ -53,7 +54,7 @@ export class AskForms {
53
54
  const head = mk('div', 'ask-head');
54
55
  if (q.header) head.append(mk('span', 'ask-tag', q.header));
55
56
  head.append(mk('span', 'ask-text', q.question));
56
- if (q.multiSelect) head.append(mk('span', 'sub', 'choose any'));
57
+ if (q.multiSelect) head.append(mk('span', 'sub', t('choose any')));
57
58
  const opts = mk('div', 'ask-opts');
58
59
  opts.setAttribute('role', q.multiSelect ? 'group' : 'radiogroup');
59
60
  const buttons = [];
@@ -80,8 +81,8 @@ export class AskForms {
80
81
  // The model's options do not always hold the answer: the viewer's own words are an answer too.
81
82
  const own = mk('input', 'ask-own');
82
83
  own.type = 'text';
83
- own.placeholder = q.multiSelect ? 'Something else, as well…' : 'Something else…';
84
- own.setAttribute('aria-label', `Your own answer to: ${q.question}`);
84
+ own.placeholder = q.multiSelect ? t('Something else, as well…') : t('Something else…');
85
+ own.setAttribute('aria-label', t('Your own answer to: {question}', { question: q.question }));
85
86
  own.addEventListener('input', () => {
86
87
  typed.set(q.question, own.value);
87
88
  if (!q.multiSelect && own.value.trim()) {
@@ -96,9 +97,9 @@ export class AskForms {
96
97
  }
97
98
 
98
99
  const acts = mk('div', 'ask-acts');
99
- const dismiss = mk('button', 'btn sm', 'Dismiss');
100
+ const dismiss = mk('button', 'btn sm', t('Dismiss'));
100
101
  dismiss.type = 'button';
101
- dismiss.title = 'Refuse the question. The model is told nobody answered and may ask in words instead.';
102
+ dismiss.title = t('Refuse the question. The model is told nobody answered and may ask in words instead.');
102
103
  send.type = 'button';
103
104
  send.addEventListener('click', () => {
104
105
  const answers = answersOf(item.ask, picked, typed);
@@ -114,12 +115,12 @@ export class AskForms {
114
115
 
115
116
  #plan(item) {
116
117
  const el = mk('div', 'ask');
117
- const plan = mk('pre', 'ask-plan', item.ask.plan || 'The plan was not included in the request.');
118
+ const plan = mk('pre', 'ask-plan', item.ask.plan || t('The plan was not included in the request.'));
118
119
  plan.tabIndex = 0;
119
120
  const acts = mk('div', 'ask-acts');
120
- const keep = mk('button', 'btn sm', 'Keep planning');
121
+ const keep = mk('button', 'btn sm', t('Keep planning'));
121
122
  keep.type = 'button';
122
- keep.title = 'Refuse to leave plan mode. The model stays in it and goes on planning.';
123
+ keep.title = t('Refuse to leave plan mode. The model stays in it and goes on planning.');
123
124
  keep.addEventListener('click', () => this.onDecide(item, 'deny', {}));
124
125
  const controls = [keep];
125
126
  acts.append(keep);
@@ -18,6 +18,7 @@ import { Viewport, draggable, swallowClick } from './viewport.js';
18
18
  import { agentStatus } from './nav.js';
19
19
  import { roleName, shownType, hoverOf, originOf, orderTag, modelOf, modelMix, modelFamily, modelBadge } from './names.js';
20
20
  import { fmtCount, fmtCost, COST_NOTE } from './usage.js';
21
+ import { t } from './i18n.js';
21
22
 
22
23
  // How long an arriving wire takes to draw itself toward its new card. Short on purpose: this is a status panel,
23
24
  // and anything a viewer has to wait through is a cost they pay on every spawn.
@@ -139,7 +140,7 @@ export class Canvas {
139
140
 
140
141
  // The whole graph, small. The frame is what the pane shows.
141
142
  this.mapEl = mk('div', 'cv-minimap');
142
- this.mapEl.setAttribute('aria-label', 'Minimap');
143
+ this.mapEl.setAttribute('aria-label', t('Minimap'));
143
144
  this.mapEl.hidden = true;
144
145
  this.mapSvg = document.createElementNS(SVG, 'svg');
145
146
  this.mapSvg.setAttribute('aria-hidden', 'true');
@@ -161,12 +162,12 @@ export class Canvas {
161
162
  /** What the session spent: time, tokens and an estimate of the cost. Closed, it stays closed until asked for. */
162
163
  #spend() {
163
164
  const box = mk('div', 'cv-legend cv-spend');
164
- box.setAttribute('aria-label', 'Session summary');
165
+ box.setAttribute('aria-label', t('Session summary'));
165
166
  const head = mk('div', 'cv-legend-head');
166
- head.append(mk('span', 'cv-label', 'Session'));
167
+ head.append(mk('span', 'cv-label', t('Session')));
167
168
  const close = mk('button', 'cv-x');
168
169
  close.type = 'button';
169
- close.setAttribute('aria-label', 'Close the session summary');
170
+ close.setAttribute('aria-label', t('Close the session summary'));
170
171
  close.innerHTML = MARK.close;
171
172
  close.addEventListener('click', () => this.showSpend(false));
172
173
  head.append(close);
@@ -217,7 +218,7 @@ export class Canvas {
217
218
  const more = Boolean(r.detail?.length);
218
219
  p.label.textContent = r.label;
219
220
  p.value.textContent = r.value ?? '';
220
- p.row.title = more ? `${r.note ?? ''}\nClick for the breakdown` : (r.note ?? '');
221
+ p.row.title = more ? `${r.note ?? ''}\n${t('Click for the breakdown')}` : (r.note ?? '');
221
222
  p.row.classList.toggle('cv-spend-more', more);
222
223
  if (more) p.row.setAttribute('aria-expanded', String(this.spendShown.has(r.label))); else p.row.removeAttribute?.('aria-expanded');
223
224
  p.detail.hidden = !more || !this.spendShown.has(r.label);
@@ -244,12 +245,12 @@ export class Canvas {
244
245
  /** What the lines and the two marks mean. Shown until it is closed, and then not again unless asked for. */
245
246
  #legend() {
246
247
  const box = mk('div', 'cv-legend');
247
- box.setAttribute('aria-label', 'Legend');
248
+ box.setAttribute('aria-label', t('Legend'));
248
249
  const head = mk('div', 'cv-legend-head');
249
- head.append(mk('span', 'cv-label', 'Legend'));
250
+ head.append(mk('span', 'cv-label', t('Legend')));
250
251
  const close = mk('button', 'cv-x');
251
252
  close.type = 'button';
252
- close.setAttribute('aria-label', 'Close the legend');
253
+ close.setAttribute('aria-label', t('Close the legend'));
253
254
  close.innerHTML = MARK.close;
254
255
  close.addEventListener('click', () => this.showLegend(false));
255
256
  head.append(close);
@@ -262,10 +263,10 @@ export class Canvas {
262
263
  return item;
263
264
  };
264
265
  const rows = mk('div', 'cv-legend-grid');
265
- rows.append(line('live', 'working now'), line('waiting', 'needs you'), line('done', 'finished'), line('failed', 'failed'));
266
+ rows.append(line('live', t('working now')), line('waiting', t('needs you')), line('done', t('finished')), line('failed', t('failed')));
266
267
  box.append(rows);
267
268
  // The one wire that is not a call: in the Order view, from an agent to the wave called after it reported.
268
- const inferred = line('inferred', 'after it reported (inferred, not a call)');
269
+ const inferred = line('inferred', t('after it reported (inferred, not a call)'));
269
270
  inferred.title = INFERRED_NOTE;
270
271
  box.append(inferred);
271
272
  const marks = mk('div', 'cv-legend-grid');
@@ -273,7 +274,7 @@ export class Canvas {
273
274
  const crew = mk('span', 'cv-tile cv-tile-sm');
274
275
  crew.innerHTML = MARK.kit;
275
276
  const a = mk('span', 'cv-legend-item');
276
- a.append(this.legendBuiltin, mk('span', null, 'Claude Code agent'));
277
+ a.append(this.legendBuiltin, mk('span', null, t('Claude Code agent')));
277
278
  const b = mk('span', 'cv-legend-item');
278
279
  b.append(crew, mk('span', null, 'Crewforth'));
279
280
  marks.append(a, b);
@@ -524,7 +525,7 @@ export class Canvas {
524
525
  #plan() {
525
526
  return plan(this.#visibleNodes(), {
526
527
  group: this.group, density: this.density, seq: this.seq, open: this.open, pinned: this.pinned, sessionY: this.sessionY,
527
- aside: this.aside, all: this.all, tallest: this.#tallest(),
528
+ aside: this.aside, sides: this.aside, all: this.all, tallest: this.#tallest(),
528
529
  });
529
530
  }
530
531
 
@@ -588,8 +589,8 @@ export class Canvas {
588
589
  this.lastGraph = graph;
589
590
  if (!graph?.nodes?.length) {
590
591
  this.emptyEl.hidden = false;
591
- this.emptyEl.replaceChildren(mk('strong', null, 'No agents yet'),
592
- mk('div', null, 'This session has not delegated to a subagent. The moment it does, a card appears here.'));
592
+ this.emptyEl.replaceChildren(mk('strong', null, t('No agents yet')),
593
+ mk('div', null, t('This session has not delegated to a subagent. The moment it does, a card appears here.')));
593
594
  // els must go with the DOM. Leaving stale elements behind made the next render believe they were still
594
595
  // mounted, so cards never returned.
595
596
  this.nodeLayer.replaceChildren(); this.edgeG.replaceChildren();
@@ -710,7 +711,7 @@ export class Canvas {
710
711
  p.tile.innerHTML = MARK.session;
711
712
  p.pill = this.#pill();
712
713
  const row = mk('div', 'cv-row');
713
- row.append(p.tile, mk('span', 'cv-title', 'Session'), mk('span', 'cv-fill'), p.pill.el);
714
+ row.append(p.tile, mk('span', 'cv-title', t('Session')), mk('span', 'cv-fill'), p.pill.el);
714
715
  p.name = mk('div', 'cv-name cv-indent');
715
716
  p.sub = mk('div', 'cv-sub cv-indent');
716
717
  // What it has running in the background takes a line of its own, when there is any: on the line above it
@@ -786,7 +787,7 @@ export class Canvas {
786
787
  /** The word and the colour an agent's status is drawn in. Waiting on the viewer outranks what the transcript
787
788
  * says, because that is what the agent is doing now. */
788
789
  #statusOf(n) {
789
- if (this.waiting.has(n.id)) return { word: 'Needs you', tone: 'waiting', known: true, state: 'waiting' };
790
+ if (this.waiting.has(n.id)) return { word: t('Needs you'), tone: 'waiting', known: true, state: 'waiting' };
790
791
  const s = agentStatus(n.status);
791
792
  // A status this panel has no word for keeps its own, exactly as the transcript wrote it.
792
793
  return { word: s.known ? cap(s.word) : s.word, tone: s.tone, known: s.known, state: stateOf(n.status) };
@@ -806,9 +807,9 @@ export class Canvas {
806
807
  const type = n?.agentType ?? '';
807
808
  if (this.palette.map?.[type]?.source === 'kit') return { source: 'kit', title: 'Crewforth agent' };
808
809
  if (this.palette.measured === false && type.startsWith('crew-')) {
809
- return { source: 'agent', title: `Agent identity not measured — ${this.palette.reason ?? 'no reason given'}` };
810
+ return { source: 'agent', title: t('Agent identity not measured — {reason}', { reason: this.palette.reason ?? t('no reason given') }) };
810
811
  }
811
- return { source: 'agent', title: 'Claude Code agent', where: originOf(type, this.origins) };
812
+ return { source: 'agent', title: t('Claude Code agent'), where: originOf(type, this.origins) };
812
813
  }
813
814
 
814
815
  #fillTile(tile, n) {
@@ -838,25 +839,25 @@ export class Canvas {
838
839
  p.name.textContent = n.gitBranch || shortPath(n.cwd) || n.sessionId || '';
839
840
  // Cut to the card's width; hovering says it whole.
840
841
  p.name.title = p.name.textContent;
841
- const bits = [`${n.turns ?? 0} ${n.turns === 1 ? 'turn' : 'turns'}`];
842
- if (n.tokens != null) bits.push(`${fmtTokens(n.tokens)} ctx`);
843
- if (n.context?.toolShare != null) bits.push(`${n.context.toolShare}% tool output`);
842
+ const bits = [n.turns === 1 ? t('{n} turn', { n: 1 }) : t('{n} turns', { n: n.turns ?? 0 })];
843
+ if (n.tokens != null) bits.push(t('{n} ctx', { n: fmtTokens(n.tokens) }));
844
+ if (n.context?.toolShare != null) bits.push(t('{n}% tool output', { n: n.context.toolShare }));
844
845
  p.sub.textContent = bits.join(' · ');
845
846
  // The share is of the text since the last compaction, in characters; the compactions are the transcript's own.
846
847
  const lc = n.context?.lastCompact;
847
848
  p.sub.title = [p.sub.textContent,
848
- n.context?.toolShare != null ? `${n.context.toolShare}% of the text in context since the last compaction is tool output (characters, not tokens)` : null,
849
- n.context?.compactions ? `compacted ${n.context.compactions}×${lc?.before != null ? `, last ${lc.trigger ?? ''} ${fmtTokens(lc.before)} → ${fmtTokens(lc.after)}` : ''}` : null].filter(Boolean).join('\n');
849
+ n.context?.toolShare != null ? t('{n}% of the text in context since the last compaction is tool output (characters, not tokens)', { n: n.context.toolShare }) : null,
850
+ n.context?.compactions ? `${t('compacted {n}×', { n: n.context.compactions })}${lc?.before != null ? `, ${t('last {trigger} {before} → {after}', { trigger: lc.trigger ?? '', before: fmtTokens(lc.before), after: fmtTokens(lc.after) })}` : ''}` : null].filter(Boolean).join('\n');
850
851
  // What the session has running that is not an agent: commands sent to the background.
851
852
  p.bg.hidden = !bg;
852
- p.bg.textContent = bg ? `${bg} in background` : '';
853
- p.bg.title = bg ? n.backgroundNow.map((c) => `In the background: ${c.toolName}${c.detail ? ` · ${c.detail}` : ''}`).join('\n') : '';
853
+ p.bg.textContent = bg ? t('{n} in background', { n: bg }) : '';
854
+ p.bg.title = bg ? n.backgroundNow.map((c) => `${t('In the background: {tool}', { tool: c.toolName })}${c.detail ? ` · ${c.detail}` : ''}`).join('\n') : '';
854
855
  const refused = n.refusedCalls ?? [];
855
856
  p.refused.hidden = !refused.length;
856
- p.refused.textContent = refused.length ? `${refused.length} refused by the model gate` : '';
857
- p.refused.title = refused.map((r) => `${r.agent ?? 'an agent'}${r.model ? ` (${r.model})` : ''}: ${r.text}`).join('\n');
858
- el.title = 'Click to inspect this session';
859
- el.setAttribute('aria-label', `Session, ${st?.word ?? 'state not measured'}, ${p.name.textContent}, ${p.sub.textContent}`);
857
+ p.refused.textContent = refused.length ? t('{n} refused by the model gate', { n: refused.length }) : '';
858
+ p.refused.title = refused.map((r) => `${r.agent ?? t('an agent')}${r.model ? ` (${r.model})` : ''}: ${r.text}`).join('\n');
859
+ el.title = t('Click to inspect this session');
860
+ el.setAttribute('aria-label', `${t('Session')}, ${st?.word ?? t('state not measured')}, ${p.name.textContent}, ${p.sub.textContent}`);
860
861
  }
861
862
 
862
863
  #fillAgent(el, n, compact, after = null) {
@@ -892,9 +893,9 @@ export class Canvas {
892
893
  p.model.title = mdl.title;
893
894
  p.model.dataset.differs = String(mdl.differs);
894
895
  if (!compact) { p.facts.textContent = facts; p.facts.title = factsTitle(n); }
895
- p.order.title = tag ? `Called ${orderTag(n, clock)} by the session` : '';
896
+ p.order.title = tag ? t('Called {order} by the session', { order: orderTag(n, clock) }) : '';
896
897
  // In the Order view: what had reported before this wave was called. A reading of the order of events.
897
- el.title = after?.length ? `Called after ${after.join(', ')} reported.\n${INFERRED_NOTE}` : '';
898
+ el.title = after?.length ? `${t('Called after {who} reported.', { who: after.join(', ') })}\n${INFERRED_NOTE}` : '';
898
899
  if (compact) {
899
900
  p.dot.dataset.tone = st.tone ?? 'none';
900
901
  p.word.textContent = st.word;
@@ -923,17 +924,17 @@ export class Canvas {
923
924
  el.dataset.aside = String(Boolean(it.open && !it.boxed));
924
925
  p.name.textContent = it.label;
925
926
  p.name.title = it.real ?? '';
926
- if (it.type === 'run') { p.tile.innerHTML = MARK.run; p.tile.title = 'Workflow run'; }
927
+ if (it.type === 'run') { p.tile.innerHTML = MARK.run; p.tile.title = t('Workflow run'); }
927
928
  else this.#fillTile(p.tile, it.type === 'type' ? { agentType: it.agentType } : (it.of ?? {}));
928
929
  // Several agents of one type: the number is a badge beside the role. Any other group says how many it holds.
929
- const size = it.type === 'type' ? `× ${it.count}` : `${it.members.length} ${it.members.length === 1 ? 'agent' : 'agents'}`;
930
+ const size = it.type === 'type' ? `× ${it.count}` : (it.members.length === 1 ? t('{n} agent', { n: 1 }) : t('{n} agents', { n: it.members.length }));
930
931
  // The group stands where its first member does in the order of the calls.
931
932
  p.sub.textContent = [orderTag(it.first), size].filter(Boolean).join(' · ');
932
933
  p.sub.classList.toggle('cv-times', it.type === 'type');
933
934
  p.fold.innerHTML = it.boxed ? MARK.down : MARK.chevron;
934
935
  // Read aloud, the badge is part of the name.
935
936
  const said = it.type === 'type' ? `${it.label} × ${it.count}` : it.label;
936
- p.fold.setAttribute('aria-label', `${it.open ? 'Fold' : 'Open'} ${said}`);
937
+ p.fold.setAttribute('aria-label', it.open ? t('Fold {name}', { name: said }) : t('Open {name}', { name: said }));
937
938
  p.fold.setAttribute('aria-expanded', String(Boolean(it.open)));
938
939
  // The bar: how the group went, in the order the eye wants it.
939
940
  const order = ['done', 'running', 'starting', 'failed', 'killed', 'stopped', 'stale', 'ended'];
@@ -948,10 +949,10 @@ export class Canvas {
948
949
  // Beside the bar: how the members went — or, at compact density where the card has no line for it, the
949
950
  // models they ran on, with how they went left to the bar and its tooltip.
950
951
  const mix = modelMix(it.members).replace(/, /g, ' \u00b7 ');
951
- if (!it.boxed) { p.count.textContent = it.compact && mix ? `${says} \u00b7 ${mix}` : says; p.count.title = it.compact && mix ? `${says}\nThe models its agents ran on: ${mix}` : ''; }
952
+ if (!it.boxed) { p.count.textContent = it.compact && mix ? `${says} \u00b7 ${mix}` : says; p.count.title = it.compact && mix ? `${says}\n${t('The models its agents ran on: {mix}', { mix })}` : ''; }
952
953
  if (!it.boxed) this.#fillTotals(p, it);
953
- el.title = it.boxed ? '' : (it.open ? 'Click to fold' : `Click to show ${it.members.length} agents`);
954
- el.setAttribute('aria-label', `${said}, ${says}, ${it.open ? 'open' : 'folded'}`);
954
+ el.title = it.boxed ? '' : (it.open ? t('Click to fold') : t('Click to show {n} agents', { n: it.members.length }));
955
+ el.setAttribute('aria-label', `${said}, ${says}, ${it.open ? t('open') : t('folded')}`);
955
956
  }
956
957
  if (!it.boxed) return;
957
958
 
@@ -1018,13 +1019,13 @@ export class Canvas {
1018
1019
  if (it.compact) return;
1019
1020
  p.totals.textContent = [
1020
1021
  s.timed ? fmtDuration(s.durationMs) : null,
1021
- s.counted ? `${fmtCount(s.fresh)} new` : null,
1022
- s.counted ? `${fmtCount(s.cacheRead)} cache` : null,
1022
+ s.counted ? t('{n} new', { n: fmtCount(s.fresh) }) : null,
1023
+ s.counted ? t('{n} cache', { n: fmtCount(s.cacheRead) }) : null,
1023
1024
  s.counted ? fmtCost(s.cost) : null,
1024
1025
  ].filter(Boolean).join(' \u00b7 ');
1025
- p.totals.title = `${it.members.length} calls. Time, new tokens, tokens read from the cache, and cost: ${COST_NOTE}`;
1026
+ p.totals.title = t('{n} calls. Time, new tokens, tokens read from the cache, and cost: {note}', { n: it.members.length, note: COST_NOTE });
1026
1027
  p.mix.textContent = modelMix(it.members);
1027
- p.mix.title = 'The models its agents ran on';
1028
+ p.mix.title = t('The models its agents ran on');
1028
1029
  }
1029
1030
 
1030
1031
  /** What an inferred wire says: on hover that it is an inference, and once per reporter, in words beside it. */
@@ -1073,11 +1074,12 @@ export class Canvas {
1073
1074
  this.edgeG.append(path);
1074
1075
  }
1075
1076
 
1076
- // Out of the right edge of what spawned it, into the left edge of the card. The control points are pushed
1077
- // along the flow so siblings fan out instead of stacking on one line.
1077
+ // Out of the right edge of what spawned it, into the left edge of the card (mirrored on the left side, where
1078
+ // `dir` is -1). The control points are pushed along the flow so siblings fan out instead of stacking on one line.
1078
1079
  const { from, to } = e;
1079
1080
  const c = Math.max(24, Math.abs(to.x - from.x) * 0.55);
1080
- const d = `M ${from.x} ${from.y} C ${from.x + c} ${from.y}, ${to.x - c} ${to.y}, ${to.x} ${to.y}`;
1081
+ const k = c * (e.dir ?? 1);
1082
+ const d = `M ${from.x} ${from.y} C ${from.x + k} ${from.y}, ${to.x - k} ${to.y}, ${to.x} ${to.y}`;
1081
1083
  if (path.d !== d) {
1082
1084
  path.d = d;
1083
1085
  path.setAttribute('d', d);
@@ -1345,14 +1347,14 @@ export class Canvas {
1345
1347
  // What it is doing this moment, for an agent that is doing something.
1346
1348
  if (n.status === 'running' && n.lastTool) bits.push(`\u25b8 ${n.lastTool}`);
1347
1349
  if (n.durationMs != null) bits.push(fmtDuration(n.durationMs));
1348
- if (n.toolCount) bits.push(`${n.toolCount} ${n.toolCount === 1 ? 'call' : 'calls'}`);
1349
- if (n.tokens != null) bits.push(`${fmtTokens(n.tokens)} tokens`);
1350
- if (n.errors) bits.push(`${n.errors} ${n.errors === 1 ? 'error' : 'errors'}`);
1350
+ if (n.toolCount) bits.push(n.toolCount === 1 ? t('{n} call', { n: 1 }) : t('{n} calls', { n: n.toolCount }));
1351
+ if (n.tokens != null) bits.push(t('{n} tokens', { n: fmtTokens(n.tokens) }));
1352
+ if (n.errors) bits.push(n.errors === 1 ? t('{n} error', { n: 1 }) : t('{n} errors', { n: n.errors }));
1351
1353
  this.tipEl.replaceChildren(
1352
1354
  mk('strong', null, shownType(n)),
1353
1355
  mk('div', 'cv-sub cv-real', n.agentType ?? ''),
1354
- mk('div', null, n.description || 'No task recorded'),
1355
- mk('div', 'cv-sub', bits.length ? bits.join(' · ') : 'No numbers yet'),
1356
+ mk('div', null, n.description || t('No task recorded')),
1357
+ mk('div', 'cv-sub', bits.length ? bits.join(' · ') : t('No numbers yet')),
1356
1358
  );
1357
1359
  const host = this.root.getBoundingClientRect();
1358
1360
  const r = el.getBoundingClientRect();
@@ -1417,14 +1419,14 @@ function readPref(key, allowed, fallback) {
1417
1419
  function factsOf(n) {
1418
1420
  return [
1419
1421
  n.durationMs != null ? fmtDuration(n.durationMs) : null,
1420
- n.usage ? `${fmtCount(n.usage.tokens.fresh)} new` : null,
1422
+ n.usage ? t('{n} new', { n: fmtCount(n.usage.tokens.fresh) }) : null,
1421
1423
  n.usage ? fmtCost(n.usage.cost) : null,
1422
1424
  ].filter(Boolean).join(' \u00b7 ');
1423
1425
  }
1424
1426
  function factsTitle(n) {
1425
1427
  if (!n.usage) return '';
1426
- const t = n.usage.tokens;
1427
- return `New tokens ${fmtCount(t.fresh)} (input ${fmtCount(t.input)}, output ${fmtCount(t.output)}, cache write ${fmtCount(t.cacheWrite)}); read from cache ${fmtCount(t.cacheRead)}\n${COST_NOTE}`;
1428
+ const k = n.usage.tokens;
1429
+ return `${t('New tokens {fresh} (input {input}, output {output}, cache write {write}); read from cache {read}', { fresh: fmtCount(k.fresh), input: fmtCount(k.input), output: fmtCount(k.output), write: fmtCount(k.cacheWrite), read: fmtCount(k.cacheRead) })}\n${COST_NOTE}`;
1428
1430
  }
1429
1431
 
1430
1432
  function fmtTokens(t) {
@@ -1436,9 +1438,9 @@ function fmtTokens(t) {
1436
1438
 
1437
1439
  function fmtDuration(ms) {
1438
1440
  const s = Math.round(ms / 1000);
1439
- if (s < 60) return `${s}s`;
1441
+ if (s < 60) return t('{n}s', { n: s });
1440
1442
  const m = Math.floor(s / 60);
1441
- return m < 60 ? `${m}m ${s % 60}s` : `${Math.floor(m / 60)}h ${m % 60}m`;
1443
+ 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 });
1442
1444
  }
1443
1445
 
1444
1446
  function shortPath(p, max = 28) {