crewforth 3.0.3 → 3.1.0-rc.1

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 (67) hide show
  1. package/CHANGELOG.md +211 -0
  2. package/README.md +3 -3
  3. package/VERSION +1 -1
  4. package/adopt.sh +86 -34
  5. package/kit/CLAUDE.md +1 -1
  6. package/kit/README.md +1 -1
  7. package/kit/agents/crew-commit-agent.md +7 -3
  8. package/kit/agents/crew-frontend-expert.md +4 -4
  9. package/kit/agents/crew-performance-expert.md +1 -1
  10. package/kit/agents/crew-review-agent.md +1 -1
  11. package/kit/eval/doctor.sh +57 -3
  12. package/kit/eval/golden-routing.txt +13 -0
  13. package/kit/eval/lib/git-bash.sh +11 -2
  14. package/kit/eval/parser-conformance.sh +1 -0
  15. package/kit/eval/routing-eval.sh +7 -3
  16. package/kit/eval/smoke-test.sh +2545 -214
  17. package/kit/hooks/commit-msg +1 -1
  18. package/kit/hooks/context-usage.sh +7 -1
  19. package/kit/hooks/guard-bash.sh +1608 -103
  20. package/kit/hooks/guard-commit-scan.sh +138 -19
  21. package/kit/hooks/guard-powershell.sh +89 -15
  22. package/kit/hooks/guard-write.sh +98 -22
  23. package/kit/hooks/no-bash-guard.ps1 +71 -0
  24. package/kit/hooks/pre-commit +5 -2
  25. package/kit/hooks/prompt-approval.sh +484 -0
  26. package/kit/hooks/route-hint.sh +13 -5
  27. package/kit/hooks/session-rehydrate.sh +18 -0
  28. package/kit/hooks/skill-trust.sh +17 -2
  29. package/kit/owned-blobs.tsv +43 -0
  30. package/kit/settings.json +20 -4
  31. package/kit/skills/a11y/SKILL.md +1 -0
  32. package/kit/skills/crew-doctor/SKILL.md +2 -2
  33. package/kit/skills/frontend/SKILL.md +3 -3
  34. package/kit/skills/frontend-flutter/SKILL.md +94 -0
  35. package/kit/skills/frontend-flutter/references/adaptive-and-accessible.md +55 -0
  36. package/kit/skills/frontend-flutter/references/platform-async-release.md +59 -0
  37. package/kit/skills/frontend-flutter/references/rebuilds-and-performance.md +36 -0
  38. package/kit/studio/server/hooks/studio-gate.sh +35 -1
  39. package/kit/studio/server/index.js +14 -4
  40. package/kit/studio/server/lib/graph.js +64 -3
  41. package/kit/studio/server/lib/kit.js +16 -2
  42. package/kit/studio/server/lib/permissions.js +77 -2
  43. package/kit/studio/server/lib/projects.js +60 -7
  44. package/kit/studio/server/lib/session.js +16 -2
  45. package/kit/studio/web/app.js +1765 -573
  46. package/kit/studio/web/approvals.js +124 -0
  47. package/kit/studio/web/canvas.js +932 -885
  48. package/kit/studio/web/chat.js +322 -145
  49. package/kit/studio/web/convo.js +174 -0
  50. package/kit/studio/web/dock.js +221 -0
  51. package/kit/studio/web/graph-plan.js +457 -0
  52. package/kit/studio/web/icons/builtin.svg +1 -0
  53. package/kit/studio/web/index.html +116 -50
  54. package/kit/studio/web/inspect.js +110 -0
  55. package/kit/studio/web/list-plan.js +112 -0
  56. package/kit/studio/web/list.js +157 -0
  57. package/kit/studio/web/liveness.js +66 -0
  58. package/kit/studio/web/nav.js +237 -0
  59. package/kit/studio/web/newsession.js +235 -0
  60. package/kit/studio/web/press.js +65 -0
  61. package/kit/studio/web/style.css +1281 -1014
  62. package/kit/studio/web/theme.js +43 -0
  63. package/kit/studio/web/timeline-plan.js +291 -0
  64. package/kit/studio/web/timeline.js +400 -0
  65. package/kit/studio/web/tokens.css +131 -0
  66. package/kit/studio/web/toolbar-fit.js +35 -0
  67. package/package.json +5 -4
@@ -0,0 +1,43 @@
1
+ // The theme button. Dark is the panel's own theme; light is what the system asks for or the viewer picks.
2
+ //
3
+ // tokens.css decides what is drawn: an explicit data-theme on the root wins, and without one the system's
4
+ // preference does. So "which theme is showing" is not the attribute alone, and the button has to answer from the
5
+ // same two inputs the stylesheet uses — otherwise the first click on a light system sets "light" and nothing moves.
6
+ //
7
+ // The choice is remembered per browser. Storage that refuses (private mode, a blocked origin) costs the memory and
8
+ // not the click: the theme still switches for this page.
9
+ export const THEME_KEY = 'crewforth-studio-theme';
10
+
11
+ const chosen = (v) => (v === 'light' || v === 'dark' ? v : null);
12
+
13
+ /** What is on screen: the viewer's choice if there is one, the system's otherwise. */
14
+ export function effectiveTheme(choice, systemLight) {
15
+ return chosen(choice) ?? (systemLight ? 'light' : 'dark');
16
+ }
17
+
18
+ /**
19
+ * @param root the element that carries data-theme (documentElement)
20
+ * @param button the control; its label always names the theme it switches TO
21
+ * @param ls a Storage, or anything with getItem/setItem; may throw on either
22
+ * @param systemLight () => boolean, whether the system prefers light right now
23
+ */
24
+ export function initTheme(root, button, ls, systemLight) {
25
+ let saved = null;
26
+ try { saved = chosen(ls.getItem(THEME_KEY)); } catch { /* blocked storage: start from the system's theme */ }
27
+ if (saved) root.dataset.theme = saved;
28
+
29
+ const label = () => {
30
+ const to = effectiveTheme(root.dataset.theme, systemLight()) === 'light' ? 'dark' : 'light';
31
+ button.setAttribute('aria-label', `Switch to ${to} theme`);
32
+ button.title = `Switch to ${to} theme`;
33
+ };
34
+ label();
35
+
36
+ button.addEventListener('click', () => {
37
+ const next = effectiveTheme(root.dataset.theme, systemLight()) === 'light' ? 'dark' : 'light';
38
+ root.dataset.theme = next;
39
+ try { ls.setItem(THEME_KEY, next); } catch { /* blocked storage: switched, not remembered */ }
40
+ label();
41
+ });
42
+ return label;
43
+ }
@@ -0,0 +1,291 @@
1
+ // What the Timeline draws, worked out from the graph's nodes with no page in it.
2
+ //
3
+ // A row is an agent, several agents of one type, a group's header, or the link that shows the rest of a group.
4
+ // A bar is where an agent was in time and what it was doing there. Everything here is a function of the server's
5
+ // data and the viewer's choices, so it can be checked without a browser.
6
+ //
7
+ // Two things the data does not hold are not invented. An agent with no recorded start has no bar. And the only
8
+ // change of state an agent's bar can show is a wait on the viewer, because that is the only one anything
9
+ // records: the server's log of approvals, for sessions started here. An agent that failed is drawn failed for
10
+ // its whole length — the transcript says how it ended, not when it started going wrong.
11
+ import { stateOf, tally } from './graph-plan.js';
12
+
13
+ export const RANGES = [
14
+ { key: '5m', ms: 5 * 60_000 },
15
+ { key: '15m', ms: 15 * 60_000 },
16
+ { key: '1h', ms: 60 * 60_000 },
17
+ { key: 'all', ms: null },
18
+ ];
19
+ export const DEFAULT_RANGE = '15m';
20
+
21
+ // A group with more rows than this shows the first few and a link to the rest.
22
+ export const GROUP_ROWS = { showAllUpTo: 6, shown: 4 };
23
+
24
+ const SESSION = 'session';
25
+ const TONE = { live: 'busy', waking: 'busy', done: 'done', failed: 'fail', quiet: 'quiet', unknown: 'quiet' };
26
+
27
+ const typeOf = (n) => n.agentType ?? 'unknown agent';
28
+ const isLive = (n) => { const s = stateOf(n.status); return s === 'live' || s === 'waking'; };
29
+
30
+ /** Where an agent's bar ends: its recorded end, or now while it is still going. */
31
+ function endOf(n, now) {
32
+ if (isLive(n)) return now;
33
+ return n.endedAt ?? n.updatedAt ?? null;
34
+ }
35
+
36
+ /**
37
+ * One agent's bar, as the pieces it is drawn in.
38
+ *
39
+ * @param approvals the session's approval record (server), or null when Studio does not see them
40
+ * @returns [] when no start was recorded — there is nothing to place
41
+ */
42
+ export function segments(n, approvals, now) {
43
+ if (n.startedAt == null) return [];
44
+ const end = endOf(n, now);
45
+ if (end == null || end < n.startedAt) return [];
46
+ const tone = TONE[stateOf(n.status)] ?? 'quiet';
47
+ const waits = (approvals ?? [])
48
+ .filter((a) => a.agentId === n.id)
49
+ .map((a) => ({ from: Math.max(a.askedAt, n.startedAt), to: Math.min(a.endedAt ?? now, end) }))
50
+ .filter((w) => w.to > w.from)
51
+ .sort((a, b) => a.from - b.from);
52
+
53
+ const out = [];
54
+ let at = n.startedAt;
55
+ for (const w of waits) {
56
+ if (w.from > at) out.push({ from: at, to: w.from, tone });
57
+ const from = Math.max(w.from, at);
58
+ if (w.to > from) out.push({ from, to: w.to, tone: 'wait' });
59
+ at = Math.max(at, w.to);
60
+ }
61
+ if (end > at || !out.length) out.push({ from: at, to: end, tone });
62
+ // The end of a failed agent is marked, on its last piece.
63
+ if (tone === 'fail') out[out.length - 1].failedAt = end;
64
+ for (const s of out) s.id = n.id;
65
+ return out;
66
+ }
67
+
68
+ /** What the right of a row says about one agent. */
69
+ export function agentSub(n, waiting) {
70
+ if (waiting?.has(n.id)) return { text: 'needs you', tone: 'wait' };
71
+ const s = stateOf(n.status);
72
+ if (s === 'failed') return { text: n.status, tone: 'fail' };
73
+ if (s === 'live') return { text: 'running', tone: null };
74
+ if (s === 'waking') return { text: 'starting', tone: null };
75
+ if (s === 'done') return { text: 'done', tone: null };
76
+ // A status this panel has no word for keeps its own.
77
+ return { text: String(n.status ?? 'unknown'), tone: null };
78
+ }
79
+
80
+ /** "12 done · 2 running" for several agents, in the order a reader looks for them. */
81
+ export function countsSub(members, waiting) {
82
+ const need = members.filter((m) => waiting?.has(m.id)).length;
83
+ const counts = tally(members.filter((m) => !waiting?.has(m.id)));
84
+ const parts = [];
85
+ if (need) parts.push(`${need} need you`);
86
+ const word = { running: 'running', starting: 'starting', done: 'done', failed: 'failed', killed: 'killed', stopped: 'stopped', ended: 'ended', stale: 'stale' };
87
+ for (const k of ['failed', 'killed', 'stopped', 'running', 'starting', 'done', 'ended', 'stale']) {
88
+ if (counts[k]) parts.push(`${counts[k]} ${word[k]}`);
89
+ }
90
+ for (const [k, v] of Object.entries(counts)) if (!word[k]) parts.push(`${v} ${k}`);
91
+ return parts.join(' · ');
92
+ }
93
+
94
+ /**
95
+ * Put bars that overlap on different lanes, two at most.
96
+ * Returns lanes as arrays of segments; with one lane the bars are full height, with two they are thin.
97
+ */
98
+ export function lanes(bars) {
99
+ const sorted = bars.slice().sort((a, b) => a.from - b.from);
100
+ const ends = [-Infinity, -Infinity];
101
+ const out = [[], []];
102
+ for (const b of sorted) {
103
+ // The lane that has been free longest; with both busy, the one that frees first.
104
+ const i = ends[0] <= b.from ? 0 : ends[1] <= b.from ? 1 : (ends[0] <= ends[1] ? 0 : 1);
105
+ out[i].push(b);
106
+ ends[i] = Math.max(ends[i], b.to);
107
+ }
108
+ return out[1].length ? out : [out[0]];
109
+ }
110
+
111
+ /** The groups of a session's agents under one grouping, in the order they first started. */
112
+ export function groupsOf(nodes, grouping) {
113
+ const agents = nodes.filter((n) => n.kind === 'agent');
114
+ const byId = new Map(nodes.map((n) => [n.id, n]));
115
+ const key = (n) => {
116
+ if (grouping === 'type') return { id: `type:${typeOf(n)}`, label: typeOf(n) };
117
+ if (grouping === 'parent') {
118
+ const p = byId.get(n.parentId);
119
+ if (!p || p.kind === 'session') return { id: 'direct', label: 'Session · direct' };
120
+ return { id: `parent:${p.id}`, label: p.kind === 'workflow' ? (p.workflowId ?? p.id) : `under ${typeOf(p)}` };
121
+ }
122
+ if (grouping === 'none') return { id: 'all', label: null };
123
+ // `run`: a workflow run is a group; everything the session started itself is another.
124
+ if (n.workflow) return { id: `run:${n.workflow}`, label: String(n.workflow) };
125
+ return { id: 'direct', label: 'Session · direct' };
126
+ };
127
+ const groups = new Map();
128
+ for (const n of agents) {
129
+ const k = key(n);
130
+ if (!groups.has(k.id)) groups.set(k.id, { id: k.id, label: k.label, members: [] });
131
+ groups.get(k.id).members.push(n);
132
+ }
133
+ const first = (g) => Math.min(...g.members.map((m) => m.startedAt ?? Infinity));
134
+ const out = [...groups.values()];
135
+ for (const g of out) g.members.sort((a, b) => (a.startedAt ?? Infinity) - (b.startedAt ?? Infinity) || String(a.id).localeCompare(String(b.id)));
136
+ // What the session did itself comes first; the rest in the order they began.
137
+ out.sort((a, b) => Number(b.id === 'direct') - Number(a.id === 'direct') || first(a) - first(b) || a.id.localeCompare(b.id));
138
+ return out;
139
+ }
140
+
141
+ /**
142
+ * The rows of the Timeline, top to bottom.
143
+ *
144
+ * @param opts.group 'run' | 'type' | 'parent' | 'none'
145
+ * @param opts.folded Set of group ids the viewer folded
146
+ * @param opts.expanded Set of group ids whose "Show N more" was used
147
+ * @param opts.waiting Set of agent ids parked on an approval right now
148
+ * @param opts.approvals the session's approval record, or null
149
+ * @param opts.now the server's clock
150
+ */
151
+ export function rows(nodes, opts = {}) {
152
+ const { group = 'run', folded = new Set(), expanded = new Set(), waiting = new Set(), approvals = null, now = 0 } = opts;
153
+ const out = [];
154
+ const bar = (n) => segments(n, approvals, now);
155
+
156
+ for (const g of groupsOf(nodes, group)) {
157
+ const headed = g.label !== null;
158
+ const isFolded = headed && folded.has(g.id);
159
+ if (headed) {
160
+ out.push({
161
+ kind: 'group',
162
+ id: g.id,
163
+ label: g.label,
164
+ folded: isFolded,
165
+ sub: `${g.members.length} ${g.members.length === 1 ? 'agent' : 'agents'}${countsSub(g.members, waiting) ? ` · ${countsSub(g.members, waiting)}` : ''}`,
166
+ // Folded, the header carries its members' bars, so a group that is out of sight is not out of time.
167
+ lanes: isFolded ? lanes(g.members.flatMap(bar)) : [],
168
+ });
169
+ if (isFolded) continue;
170
+ }
171
+
172
+ // Several agents of one type, next to each other in a group, are one row: the same thing several times.
173
+ // Not under `type` — there the group already is the type — and not under `none`, which means none.
174
+ const merge = group === 'run' || group === 'parent';
175
+ const body = [];
176
+ const seen = new Set();
177
+ for (const n of g.members) {
178
+ if (seen.has(n.id)) continue;
179
+ const same = merge ? g.members.filter((m) => typeOf(m) === typeOf(n)) : [n];
180
+ for (const m of same) seen.add(m.id);
181
+ if (same.length > 1) {
182
+ body.push({
183
+ kind: 'merged', id: `${g.id}|${typeOf(n)}`, group: g.id, label: `${typeOf(n)} × ${same.length}`,
184
+ sub: countsSub(same, waiting), members: same, lanes: lanes(same.flatMap(bar)), indent: headed && g.id !== 'direct',
185
+ });
186
+ } else {
187
+ const sub = agentSub(n, waiting);
188
+ body.push({
189
+ kind: 'agent', id: n.id, group: g.id, node: n, label: typeOf(n), sub: sub.text, tone: sub.tone,
190
+ bars: bar(n), timed: n.startedAt != null, indent: headed && g.id !== 'direct',
191
+ });
192
+ }
193
+ }
194
+
195
+ const cut = headed && body.length > GROUP_ROWS.showAllUpTo && !expanded.has(g.id);
196
+ const shown = cut ? body.slice(0, GROUP_ROWS.shown) : body;
197
+ out.push(...shown);
198
+ if (cut) out.push({ kind: 'more', id: `${g.id}|more`, group: g.id, count: body.length - shown.length, indent: true });
199
+ }
200
+ return out;
201
+ }
202
+
203
+ /** When the session's time starts and ends: its earliest start, and now if anything is still going. */
204
+ export function extent(nodes, now, live) {
205
+ let from = Infinity;
206
+ let to = -Infinity;
207
+ for (const n of nodes) {
208
+ if (n.startedAt != null) from = Math.min(from, n.startedAt);
209
+ const end = n.kind === 'agent' ? endOf(n, now) : (n.endedAt ?? n.updatedAt ?? null);
210
+ if (end != null) to = Math.max(to, end);
211
+ }
212
+ if (live) to = Math.max(to, now);
213
+ if (!Number.isFinite(from) || !Number.isFinite(to)) return null;
214
+ return { from, to: Math.max(to, from + 1000) };
215
+ }
216
+
217
+ /**
218
+ * The stretch of time on screen.
219
+ *
220
+ * Following, it ends at now. Not following, it ends where the viewer left it. `all` is the whole session.
221
+ * A range longer than the session shows the whole session rather than empty time before it began.
222
+ */
223
+ export function windowOf(ext, rangeKey, { follow = true, end = null } = {}) {
224
+ if (!ext) return null;
225
+ const range = RANGES.find((r) => r.key === rangeKey) ?? RANGES.find((r) => r.key === DEFAULT_RANGE);
226
+ const whole = ext.to - ext.from;
227
+ const span = range.ms == null ? whole : Math.min(range.ms, Math.max(whole, 60_000));
228
+ if (range.ms == null || span >= whole) return { from: ext.from, to: ext.from + Math.max(span, whole), span: Math.max(span, whole) };
229
+ let to = follow || end == null ? ext.to : Math.min(Math.max(end, ext.from + span), ext.to);
230
+ return { from: to - span, to, span };
231
+ }
232
+
233
+ /** Where a moment falls across a track `width` pixels wide. */
234
+ export function xOf(t, win, width) {
235
+ return ((t - win.from) / win.span) * width;
236
+ }
237
+
238
+ /** A bar's place on the track, clipped to what is on screen; null when none of it is. */
239
+ export function place(seg, win, width, minW = 2) {
240
+ const from = Math.max(seg.from, win.from);
241
+ const to = Math.min(seg.to, win.to);
242
+ if (to <= from && !(seg.from >= win.from && seg.from <= win.to)) return null;
243
+ const left = xOf(from, win, width);
244
+ return { left, width: Math.max(minW, xOf(Math.max(to, from), win, width) - left), cutLeft: seg.from < win.from, cutRight: seg.to > win.to };
245
+ }
246
+
247
+ const STEPS = [10_000, 30_000, 60_000, 5 * 60_000, 15 * 60_000, 30 * 60_000, 3_600_000, 3 * 3_600_000, 6 * 3_600_000, 12 * 3_600_000, 24 * 3_600_000];
248
+
249
+ /** The marks on the axis: round moments, about one every 120 pixels. */
250
+ export function ticks(win, width) {
251
+ const want = Math.max(2, Math.floor(width / 120));
252
+ const step = STEPS.find((s) => win.span / s <= want) ?? STEPS[STEPS.length - 1];
253
+ const out = [];
254
+ for (let t = Math.ceil(win.from / step) * step; t <= win.to; t += step) out.push({ at: t, seconds: step < 60_000 });
255
+ return out;
256
+ }
257
+
258
+ /** HH:MM, or HH:MM:SS, in the viewer's own time zone. */
259
+ export function clock(ms, seconds = false) {
260
+ const d = new Date(ms);
261
+ const p = (n) => String(n).padStart(2, '0');
262
+ return `${p(d.getHours())}:${p(d.getMinutes())}${seconds ? `:${p(d.getSeconds())}` : ''}`;
263
+ }
264
+
265
+ /** The next range in or out; null at either end. */
266
+ export function stepRange(key, by) {
267
+ const i = RANGES.findIndex((r) => r.key === key);
268
+ const next = RANGES[i + by];
269
+ return next ? next.key : null;
270
+ }
271
+
272
+ /**
273
+ * The line under the rows that says where the waiting periods come from, or that there are none to show.
274
+ * @param owned the session's summary when Studio started it, else null
275
+ */
276
+ export function waitNote(owned) {
277
+ if (!owned) return { measured: false, text: 'Not measured: this session\'s approvals are not seen by Studio.' };
278
+ if (owned.gated === false) return { measured: false, text: 'Not measured: this session runs without the approval gate.' };
279
+ return { measured: true, text: `Waiting periods since Studio started this session at ${clock(owned.startedAt)}.` };
280
+ }
281
+
282
+ /** "14:01 → 14:09 · 8m 12s · 31 tool calls · 57k tokens", with only the parts that were read. */
283
+ export function factsOf(n, now, fmt) {
284
+ const parts = [];
285
+ const end = n.kind === 'agent' ? endOf(n, now) : n.endedAt;
286
+ if (n.startedAt != null) parts.push(isLive(n) || end == null ? `${clock(n.startedAt)} → now` : `${clock(n.startedAt)} → ${clock(end)}`);
287
+ if (n.startedAt != null && end != null) parts.push(fmt.duration(end - n.startedAt));
288
+ if (n.toolCount != null) parts.push(`${n.toolCount} tool ${n.toolCount === 1 ? 'call' : 'calls'}`);
289
+ if (n.tokens != null) parts.push(`${fmt.tokens(n.tokens)} tokens`);
290
+ return parts.join(' · ');
291
+ }