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
@@ -7,6 +7,7 @@ import {
7
7
  } from './timeline-plan.js';
8
8
  import { Press } from './press.js';
9
9
  import { shownType, hoverOf, modelOf, modelFamily } from './names.js';
10
+ import { t, numLocale } from './i18n.js';
10
11
 
11
12
 
12
13
  function mk(tag, cls, text) {
@@ -56,7 +57,7 @@ export class Timeline {
56
57
  #build() {
57
58
  this.root.classList.add('tl');
58
59
  this.axis = mk('div', 'tl-axis');
59
- this.axisLabel = mk('div', 'tl-axis-label', 'Agent');
60
+ this.axisLabel = mk('div', 'tl-axis-label', t('Agent'));
60
61
  this.axisTrack = mk('div', 'tl-axis-track');
61
62
  this.axis.append(this.axisLabel, this.axisTrack);
62
63
 
@@ -69,7 +70,7 @@ export class Timeline {
69
70
  this.noteEl = mk('div', 'tl-note');
70
71
  this.drawer = mk('div', 'tl-drawer');
71
72
  this.drawer.setAttribute('role', 'region');
72
- this.drawer.setAttribute('aria-label', 'Selected agent');
73
+ this.drawer.setAttribute('aria-label', t('Selected agent'));
73
74
  this.drawer.hidden = true;
74
75
  this.empty = mk('div', 'tl-empty');
75
76
  this.empty.hidden = true;
@@ -77,7 +78,7 @@ export class Timeline {
77
78
  // no word would read as a session in which nothing happened.
78
79
  this.away = mk('div', 'tl-away');
79
80
  this.away.hidden = true;
80
- const whole = mk('button', 'btn sm', 'Show the whole session');
81
+ const whole = mk('button', 'btn sm', t('Show the whole session'));
81
82
  whole.type = 'button';
82
83
  whole.addEventListener('click', () => { this.range = 'all'; this.render(); this.#changed(); });
83
84
  this.awayText = mk('span', null);
@@ -205,8 +206,8 @@ export class Timeline {
205
206
  this.body.hidden = agents.length === 0;
206
207
  this.axis.hidden = agents.length === 0;
207
208
  if (!agents.length) {
208
- this.empty.replaceChildren(mk('strong', null, 'No agents yet'),
209
- mk('span', null, 'This session has not delegated to a subagent. When it does, each one gets a row here.'));
209
+ this.empty.replaceChildren(mk('strong', null, t('No agents yet')),
210
+ mk('span', null, t('This session has not delegated to a subagent. When it does, each one gets a row here.')));
210
211
  this.noteEl.hidden = true;
211
212
  this.nowLine.hidden = true;
212
213
  this.away.hidden = true;
@@ -243,7 +244,7 @@ export class Timeline {
243
244
  this.nowLine.hidden = !this.live || !win || nowX < 0 || nowX > width + 1;
244
245
  if (!this.nowLine.hidden) {
245
246
  this.nowLine.style.left = `${this.labelWidth() + Math.min(nowX, width - 1)}px`;
246
- this.nowLine.dataset.label = `now ${clock(now)}`;
247
+ this.nowLine.dataset.label = `${t('now')} ${clock(now)}`;
247
248
  }
248
249
 
249
250
  // Bars exist, and none of them is in the stretch on screen.
@@ -253,8 +254,8 @@ export class Timeline {
253
254
  if (!this.away.hidden) {
254
255
  const last = Math.max(...timed.map((n) => n.endedAt ?? n.updatedAt ?? n.startedAt));
255
256
  this.awayText.textContent = last < win.from
256
- ? `Nothing in this stretch of time. The last activity ended at ${clock(last)}.`
257
- : 'Nothing in this stretch of time.';
257
+ ? t('Nothing in this stretch of time. The last activity ended at {time}.', { time: clock(last) })
258
+ : t('Nothing in this stretch of time.');
258
259
  }
259
260
 
260
261
  const note = waitNote(this.owned);
@@ -281,7 +282,7 @@ export class Timeline {
281
282
  b.style.left = `${at.left}px`;
282
283
  b.style.width = `${at.width}px`;
283
284
  const node = this.nodes.find((n) => n.id === seg.id);
284
- b.setAttribute('aria-label', `${node ? shownType(node) : 'agent'}, ${seg.tone === 'wait' ? 'waiting for you' : (node?.status ?? '')}, ${clock(seg.from)} to ${clock(seg.to)}`);
285
+ b.setAttribute('aria-label', t('{who}, {state}, {from} to {to}', { who: node ? shownType(node) : t('agent'), state: seg.tone === 'wait' ? t('waiting for you') : (node?.status ?? ''), from: clock(seg.from), to: clock(seg.to) }));
285
286
  if (node && this.dimmed(node)) b.classList.add('dim');
286
287
  if (seg.id === this.selected) b.classList.add('on');
287
288
  b.addEventListener('click', (e) => { e.stopPropagation(); this.#choose(seg.id); });
@@ -300,7 +301,7 @@ export class Timeline {
300
301
  #row(r, win, width) {
301
302
  if (r.kind === 'more') {
302
303
  const row = mk('div', 'tl-row tl-more');
303
- const b = mk('button', 'tl-more-btn', `Show ${r.count} more in this ${this.group === 'run' ? 'run' : 'group'}`);
304
+ const b = mk('button', 'tl-more-btn', this.group === 'run' ? t('Show {n} more in this run', { n: r.count }) : t('Show {n} more in this group', { n: r.count }));
304
305
  b.type = 'button';
305
306
  b.addEventListener('click', () => { this.expanded.add(r.group); this.render(); });
306
307
  const lab = mk('div', 'tl-lab');
@@ -353,7 +354,7 @@ export class Timeline {
353
354
  row.addEventListener('click', () => this.#choose(r.id));
354
355
  if (r.timed) this.#bars(track, [r.bars], win, width);
355
356
  // No start was recorded, so there is nowhere to put a bar; the row says that instead of drawing one at zero.
356
- else track.append(mk('span', 'tl-untimed', 'no start time recorded'));
357
+ else track.append(mk('span', 'tl-untimed', t('no start time recorded')));
357
358
  if (r.id === this.selected) row.classList.add('on');
358
359
  if (this.dimmed(r.node)) row.classList.add('dim');
359
360
  }
@@ -382,7 +383,7 @@ export class Timeline {
382
383
  const pill = mk('span', 'pill');
383
384
  const dot = mk('span', 'dot');
384
385
  dot.dataset.tone = st.tone ?? 'none';
385
- pill.append(dot, mk('span', null, st.word ?? 'State not measured'));
386
+ pill.append(dot, mk('span', null, st.word ?? t('State not measured')));
386
387
  const dname = mk('span', 'tl-d-name', shownType(n));
387
388
  dname.title = n.agentType ?? '';
388
389
  head.append(dname, pill);
@@ -391,36 +392,36 @@ export class Timeline {
391
392
  left.append(head, dtask, mk('div', 'sub tl-d-facts', factsOf(n, now, this.hooks.fmt)));
392
393
 
393
394
  const mid = mk('div', 'tl-d-error');
394
- mid.append(mk('div', 'isec-h', 'Last error'));
395
+ mid.append(mk('div', 'isec-h', t('Last error')));
395
396
  const detail = this.hooks.detailOf?.(n);
396
397
  if (!detail || detail.loading) {
397
- mid.append(mk('div', 'ihint', 'Reading the agent transcript…'));
398
+ mid.append(mk('div', 'ihint', t('Reading the agent transcript…')));
398
399
  } else if (detail.measured === false) {
399
400
  const hint = mk('div', 'ihint');
400
- hint.append(mk('strong', null, 'Not measured'), mk('div', null, detail.reason || 'no reason given'));
401
+ hint.append(mk('strong', null, t('Not measured')), mk('div', null, detail.reason || t('no reason given')));
401
402
  mid.append(hint);
402
403
  } else if (!detail.lastError) {
403
- mid.append(mk('div', 'ihint', 'None — no tool call in this agent\'s transcript came back as an error.'));
404
+ mid.append(mk('div', 'ihint', t('None — no tool call in this agent\'s transcript came back as an error.')));
404
405
  } else {
405
406
  const e = detail.lastError;
406
- const text = mk('pre', 'tl-d-errtext', e.truncated ? `… ${(e.length - e.text.length).toLocaleString('en-US')} earlier characters not shown\n${e.text}` : (e.text || '(no output)'));
407
+ const text = mk('pre', 'tl-d-errtext', e.truncated ? `${t('… {n} earlier characters not shown', { n: (e.length - e.text.length).toLocaleString(numLocale()) })}\n${e.text}` : (e.text || t('(no output)')));
407
408
  const count = detail.errors ?? 0;
408
- const facts = [`${count} ${count === 1 ? 'error' : 'errors'}`];
409
- if (e.at != null) facts.push(`last at ${clock(e.at, true)}`);
409
+ const facts = [count === 1 ? t('{n} error', { n: 1 }) : t('{n} errors', { n: count })];
410
+ if (e.at != null) facts.push(t('last at {time}', { time: clock(e.at, true) }));
410
411
  if (e.tool) facts.push(e.tool);
411
412
  mid.append(text, mk('div', 'sub', facts.join(' · ')));
412
413
  }
413
414
 
414
415
  const acts = mk('div', 'tl-d-acts');
415
- const show = mk('button', 'btn sm primary', 'Show on graph');
416
+ const show = mk('button', 'btn sm primary', t('Show on graph'));
416
417
  show.type = 'button';
417
418
  show.addEventListener('click', () => this.hooks.onShowOnGraph?.(n));
418
- const open = mk('button', 'btn sm', 'Open conversation');
419
+ const open = mk('button', 'btn sm', t('Open conversation'));
419
420
  open.type = 'button';
420
421
  open.addEventListener('click', () => this.hooks.onOpenConversation?.());
421
422
  const close = mk('button', 'btn icon sm ghost');
422
423
  close.type = 'button';
423
- close.setAttribute('aria-label', 'Close');
424
+ close.setAttribute('aria-label', t('Close'));
424
425
  close.innerHTML = '<svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 4l8 8M12 4l-8 8"/></svg>';
425
426
  close.addEventListener('click', () => this.#choose(n.id));
426
427
  acts.append(show, open, close);
@@ -6,8 +6,9 @@
6
6
  // a dash, not a smaller number.
7
7
 
8
8
  import { modelName } from './names.js';
9
+ import { t, numLocale } from './i18n.js';
9
10
 
10
- export const COST_NOTE = 'Estimated at API list price; on a subscription it is not what you are billed';
11
+ export const COST_NOTE = t('Estimated at API list price; on a subscription it is not what you are billed');
11
12
  export const NO_COST = '—';
12
13
 
13
14
  /** 950 · 12.4k · 3.2M · 1.53B */
@@ -23,7 +24,7 @@ export function fmtCount(n) {
23
24
  export function fmtCost(usd) {
24
25
  if (usd == null || !Number.isFinite(usd)) return NO_COST;
25
26
  if (usd > 0 && usd < 0.01) return '<$0.01';
26
- if (usd >= 1000) return `~$${Math.round(usd).toLocaleString('en-US')}`;
27
+ if (usd >= 1000) return `~$${Math.round(usd).toLocaleString(numLocale())}`;
27
28
  return `~$${usd.toFixed(2)}`;
28
29
  }
29
30
 
@@ -31,12 +32,12 @@ export function fmtCost(usd) {
31
32
  export function fmtSpan(ms) {
32
33
  if (ms == null || !Number.isFinite(ms) || ms < 0) return null;
33
34
  const s = Math.round(ms / 1000);
34
- if (s < 60) return `${s}s`;
35
+ if (s < 60) return t('{n}s', { n: s });
35
36
  const m = Math.floor(s / 60);
36
- if (m < 60) return `${m}m`;
37
+ if (m < 60) return t('{n}m', { n: m });
37
38
  const h = Math.floor(m / 60);
38
- if (h < 24) return `${h}h ${m % 60}m`;
39
- return `${Math.floor(h / 24)}d ${h % 24}h`;
39
+ if (h < 24) return t('{h}h {m}m', { h, m: m % 60 });
40
+ return t('{d}d {h}h', { d: Math.floor(h / 24), h: h % 24 });
40
41
  }
41
42
 
42
43
  /**
@@ -45,23 +46,23 @@ export function fmtSpan(ms) {
45
46
  * @returns { ms, kind: 'worked' | 'elapsed', note } or null
46
47
  */
47
48
  export function timeOf(u) {
48
- if (u?.workedMs) return { ms: u.workedMs, kind: 'worked', note: 'The turns Claude Code timed, added up; a turn still running is not in it yet' };
49
- if (u?.durationMs != null) return { ms: u.durationMs, kind: 'elapsed', note: 'From the first record to the last, pauses included: no turn was timed' };
49
+ if (u?.workedMs) return { ms: u.workedMs, kind: 'worked', note: t('The turns Claude Code timed, added up; a turn still running is not in it yet') };
50
+ if (u?.durationMs != null) return { ms: u.durationMs, kind: 'elapsed', note: t('From the first record to the last, pauses included: no turn was timed') };
50
51
  return null;
51
52
  }
52
53
 
53
54
  /** Why there is no estimate, when there is none. */
54
55
  export function costNote(u) {
55
56
  if (!u?.unpriced?.length) return COST_NOTE;
56
- return `No estimate: the price table has no row for ${u.unpriced.join(', ')}`;
57
+ return t('No estimate: the price table has no row for {models}', { models: u.unpriced.join(', ') });
57
58
  }
58
59
 
59
60
  /** "40s", "4m 40s", "3h 5m": a span that is being counted, so the seconds show while they matter. */
60
61
  export function fmtRunning(ms) {
61
62
  if (ms == null || !Number.isFinite(ms) || ms < 0) return null;
62
63
  const s = Math.floor(ms / 1000);
63
- if (s < 60) return `${s}s`;
64
- if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`;
64
+ if (s < 60) return t('{n}s', { n: s });
65
+ if (s < 3600) return t('{m}m {s}s', { m: Math.floor(s / 60), s: s % 60 });
65
66
  return fmtSpan(ms);
66
67
  }
67
68
 
@@ -71,15 +72,15 @@ export function fmtRunning(ms) {
71
72
  */
72
73
  export function usageLine(u, { live = false, now = null } = {}) {
73
74
  if (!u) return null;
74
- const t = timeOf(u);
75
- const span = live && u.startedAt != null && now != null ? fmtSpan(now - u.startedAt) : (t ? fmtSpan(t.ms) : null);
75
+ const took = timeOf(u);
76
+ const span = live && u.startedAt != null && now != null ? fmtSpan(now - u.startedAt) : (took ? fmtSpan(took.ms) : null);
76
77
  const tokens = typeof u.tokens === 'number' ? u.tokens : u.tokens?.fresh;
77
78
  return [span, tokens ? fmtCount(tokens) : null, tokens ? fmtCost(u.cost) : null].filter(Boolean).join(' · ') || null;
78
79
  }
79
80
 
80
81
  const kinds = (k, cached = true) => [
81
- ['Input', fmtCount(k?.input ?? 0)], ['Output', fmtCount(k?.output ?? 0)], ['Cache write', fmtCount(k?.cacheWrite ?? 0)],
82
- ...(cached ? [['Cache read', fmtCount(k?.cacheRead ?? 0)]] : []),
82
+ [t('Input'), fmtCount(k?.input ?? 0)], [t('Output'), fmtCount(k?.output ?? 0)], [t('Cache write'), fmtCount(k?.cacheWrite ?? 0)],
83
+ ...(cached ? [[t('Cache read'), fmtCount(k?.cacheRead ?? 0)]] : []),
83
84
  ];
84
85
 
85
86
  /**
@@ -91,40 +92,43 @@ const kinds = (k, cached = true) => [
91
92
  * read as millions.
92
93
  *
93
94
  * @param opts.live the session is running: its time is counted from its first record to `opts.now`
95
+ * @param opts.agentRows [name, value] pairs, one per kind of agent (agent-spend.js): what the Agents row opens on
96
+ * instead of the token kinds, as the Cost row opens on its models. Without any, the kinds.
94
97
  */
95
- export function summaryRows(u, { live = false, now = null } = {}) {
98
+ export function summaryRows(u, { live = false, now = null, agentRows = [] } = {}) {
96
99
  if (!u) return [];
97
100
  const k = u.tokens ?? {};
98
101
  const rows = [];
99
- const t = timeOf(u);
102
+ const took = timeOf(u);
100
103
  if (live && u.startedAt != null && now != null) {
101
- rows.push({ label: 'Running for', value: fmtRunning(now - u.startedAt), note: 'From the session\'s first record to now' });
102
- } else if (t) {
103
- rows.push({ label: t.kind === 'worked' ? 'Working time' : 'First to last record', value: fmtSpan(t.ms), note: t.note });
104
- if (t.kind === 'worked' && u.durationMs != null) rows.push({ label: 'First to last record', value: fmtSpan(u.durationMs), note: 'Pauses included; the agents\' transcripts too' });
104
+ rows.push({ label: t('Running for'), value: fmtRunning(now - u.startedAt), note: t('From the session\'s first record to now') });
105
+ } else if (took) {
106
+ rows.push({ label: took.kind === 'worked' ? t('Working time') : t('First to last record'), value: fmtSpan(took.ms), note: took.note });
107
+ if (took.kind === 'worked' && u.durationMs != null) rows.push({ label: t('First to last record'), value: fmtSpan(u.durationMs), note: t('Pauses included; the agents\' transcripts too') });
105
108
  }
106
- rows.push({ label: 'Tokens', value: fmtCount(k.fresh ?? 0), note: 'New tokens: input, output and cache writes. Agents included, each response counted once', detail: kinds(k, false) });
107
- rows.push({ label: 'From cache', value: fmtCount(k.cacheRead ?? 0), note: 'Read back from the prompt cache: the same context, counted on every response that reused it' });
108
- const part = (label, p, note) => {
109
+ rows.push({ label: t('Tokens'), value: fmtCount(k.fresh ?? 0), note: t('New tokens: input, output and cache writes. Agents included, each response counted once'), detail: kinds(k, false) });
110
+ rows.push({ label: t('From cache'), value: fmtCount(k.cacheRead ?? 0), note: t('Read back from the prompt cache: the same context, counted on every response that reused it') });
111
+ const part = (label, p, note, detail = null) => {
109
112
  if (!p?.tokens?.total) return;
110
- rows.push({ label, value: `${fmtCount(p.tokens.fresh)} · ${fmtCost(p.cost)}`, note: `${note}: new tokens and estimated cost\n${costNote(p)}`, detail: kinds(p.tokens) });
113
+ rows.push({ label, value: `${fmtCount(p.tokens.fresh)} · ${fmtCost(p.cost)}`, note: `${t('{what}: new tokens and estimated cost', { what: note })}
114
+ ${costNote(p)}`, detail: detail ?? kinds(p.tokens) });
111
115
  };
112
- part('Session', u.parts?.session, 'The session\'s own transcript');
113
- part('Agents', u.parts?.agents, 'Everything its agents did');
116
+ part(t('Session'), u.parts?.session, t('The session\'s own transcript'));
117
+ part(t('Agents'), u.parts?.agents, t('Everything its agents did'), agentRows.length ? agentRows : null);
114
118
  // The advisor: a second model the main one consulted mid-response. Its tokens are in the rows above already; this
115
119
  // says how much of them it was. No call recorded, no row: a session that never consulted it shows no zero.
116
120
  if (u.advisor?.calls) {
117
121
  const a = u.advisor;
118
122
  const who = Object.keys(u.advisorModels ?? {}).map(modelName).join(', ');
119
123
  rows.push({
120
- label: 'Advisor',
121
- value: `${a.calls} ${a.calls === 1 ? 'call' : 'calls'} \u00b7 ${fmtCount(a.tokens.fresh)} \u00b7 ${fmtCost(a.cost)}`,
122
- note: `Advice the main model asked a second model for${who ? ` (${who})` : ''}, priced at that model's rates. Included in the totals above; the API's own token count leaves it out\n${costNote(a)}`,
124
+ label: t('Advisor'),
125
+ value: `${a.calls === 1 ? t('{n} call', { n: 1 }) : t('{n} calls', { n: a.calls })} \u00b7 ${fmtCount(a.tokens.fresh)} \u00b7 ${fmtCost(a.cost)}`,
126
+ note: `${who ? t('Advice the main model asked a second model for ({who}), priced at that model\'s rates. Included in the totals above; the API\'s own token count leaves it out', { who }) : t('Advice the main model asked a second model for, priced at that model\'s rates. Included in the totals above; the API\'s own token count leaves it out')}\n${costNote(a)}`,
123
127
  detail: kinds(a.tokens),
124
128
  });
125
129
  }
126
130
  // Behind the cost: each model's new tokens and what they come to. A model with no price says so with a dash.
127
- const perModel = Object.entries(u.byModel ?? {}).map(([model, t]) => [modelName(model), `${fmtCount(t.tokens.fresh)} \u00b7 ${fmtCost(t.cost)}`]);
128
- rows.push({ label: 'Cost', value: k.total ? fmtCost(u.cost) : NO_COST, note: costNote(u), detail: perModel.length ? perModel : undefined });
131
+ const perModel = Object.entries(u.byModel ?? {}).map(([model, m]) => [modelName(model), `${fmtCount(m.tokens.fresh)} \u00b7 ${fmtCost(m.cost)}`]);
132
+ rows.push({ label: t('Cost'), value: k.total ? fmtCost(u.cost) : NO_COST, note: costNote(u), detail: perModel.length ? perModel : undefined });
129
133
  return rows;
130
134
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crewforth",
3
- "version": "3.5.0-rc.1",
3
+ "version": "3.5.0-rc.2",
4
4
  "description": "Crewforth — your engineering crew for Claude Code: agents, skills and tool-level gates, in any stack.",
5
5
  "bin": {
6
6
  "crewforth": "bin/cli.js"