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
@@ -16,6 +16,14 @@ const FEED = process.env.CREW_UPDATE_URL
16
16
  const FEED_TTL_MS = 60 * 60 * 1000; // the hook uses a day; a panel refreshes hourly
17
17
  const FETCH_TIMEOUT_MS = 8000;
18
18
 
19
+ // How an install made of files is updated: the command, as the project's own update notice words it. It lives
20
+ // here and travels in the payload so that the panel never types a command of its own.
21
+ //
22
+ // It is given ONLY to a project that has `.claude/VERSION`. A plugin install leaves no file in the project, so
23
+ // from here it cannot be told from a project without Crewforth — and it does not update through npx. Such a
24
+ // project gets null, and the panel shows the `/crew-update` hint instead of a command it would have to invent.
25
+ const FILE_INSTALL_UPDATE = 'npx crewforth@latest update --here';
26
+
19
27
  let latestCache = null; // { at, value }
20
28
  let inflight = null;
21
29
 
@@ -120,7 +128,12 @@ export async function kitStatus(cwd, latest) {
120
128
  // the other is a dead path.
121
129
  let present = false;
122
130
  try { await fsp.stat(cwd); present = true; } catch { present = false; }
123
- return { installed: false, dirExists: present, reason: present ? 'Crewforth not installed' : 'directory no longer exists' };
131
+ return {
132
+ installed: false,
133
+ dirExists: present,
134
+ reason: present ? 'Crewforth not installed' : 'directory no longer exists',
135
+ updateCommand: null,
136
+ };
124
137
  }
125
138
 
126
139
  const conf = {};
@@ -137,6 +150,7 @@ export async function kitStatus(cwd, latest) {
137
150
  version,
138
151
  stack: conf.stack ?? null,
139
152
  installer: conf.installer ?? null,
153
+ updateCommand: FILE_INSTALL_UPDATE,
140
154
  };
141
155
 
142
156
  if (!latest?.measured) {
@@ -155,4 +169,4 @@ export async function kitStatus(cwd, latest) {
155
169
  return out;
156
170
  }
157
171
 
158
- export const _internals = { sane, FEED };
172
+ export const _internals = { sane, FEED, FILE_INSTALL_UPDATE };
@@ -39,7 +39,7 @@ export function spoolFor(sessionId) {
39
39
  * are never read, written, or merged. Returns null when the hook is missing, so
40
40
  * the caller can say "no gate" instead of quietly running without one.
41
41
  */
42
- export function prepare(sessionId) {
42
+ export function prepare(sessionId, mode = null) {
43
43
  if (!fs.existsSync(HOOK)) return null;
44
44
 
45
45
  const spool = spoolFor(sessionId);
@@ -57,7 +57,10 @@ export function prepare(sessionId) {
57
57
  // Named, not left to the default: on Windows without a detected Git Bash, Claude Code runs a hook through
58
58
  // PowerShell, which cannot read the VAR=… prefix — the gate would fail open.
59
59
  shell: 'bash',
60
- command: `CREW_GATE_WAIT=${HOOK_WAIT_S} bash ${JSON.stringify(HOOK)} ${JSON.stringify(spool)}`,
60
+ // The mode goes to the hook so that an allowance given in the panel can be an approval the harness
61
+ // honours — in the modes that may write, and never in plan. A mode that is not a plain word is not
62
+ // passed at all, and the hook then approves nothing.
63
+ command: `CREW_GATE_WAIT=${HOOK_WAIT_S} ${gateMode(mode)}bash ${JSON.stringify(HOOK)} ${JSON.stringify(spool)}`,
61
64
  timeout: HARNESS_TIMEOUT_S,
62
65
  }],
63
66
  }],
@@ -67,6 +70,10 @@ export function prepare(sessionId) {
67
70
  return { settingsPath, spool, waitSeconds: HOOK_WAIT_S };
68
71
  }
69
72
 
73
+ function gateMode(mode) {
74
+ return typeof mode === 'string' && /^[A-Za-z]+$/.test(mode) ? `CREW_GATE_MODE=${mode} ` : '';
75
+ }
76
+
70
77
  /** One pending request, as the panel needs to show it. */
71
78
  function readRequest(spool, file) {
72
79
  const toolUseId = file.replace(/\.json$/, '');
@@ -85,6 +92,10 @@ function readRequest(spool, file) {
85
92
  detail: input.command ?? input.file_path ?? input.pattern ?? input.query ?? input.description ?? null,
86
93
  input,
87
94
  cwd: payload.cwd ?? null,
95
+ // Who asked. Claude Code puts these in the hook's input only when the call comes from inside a subagent, so
96
+ // their absence means the session itself asked — it is not a missing answer.
97
+ agentId: typeof payload.agent_id === 'string' && /^[A-Za-z0-9_-]+$/.test(payload.agent_id) ? payload.agent_id : null,
98
+ agentType: typeof payload.agent_type === 'string' ? payload.agent_type.slice(0, 120) : null,
88
99
  askedAt: (() => {
89
100
  try { return fs.statSync(path.join(spool, 'req', file)).mtimeMs; } catch { return Date.now(); }
90
101
  })(),
@@ -133,6 +144,70 @@ export function decide(sessionId, toolUseId, verdict) {
133
144
  return { ok: true, verdict, toolName: req?.toolName ?? null };
134
145
  }
135
146
 
147
+ /**
148
+ * Take back an "allow this tool for the session". The hook looks for the tool's file on every call, so the next
149
+ * call of that tool is asked about again. Returns whether there was anything to take back.
150
+ */
151
+ export function revoke(sessionId, tool) {
152
+ if (typeof tool !== 'string' || !/^[A-Za-z0-9_-]+$/.test(tool)) return { ok: false, reason: 'bad tool name' };
153
+ const file = path.join(spoolFor(sessionId), 'always', tool);
154
+ try {
155
+ fs.rmSync(file);
156
+ return { ok: true, tool, revoked: true };
157
+ } catch (e) {
158
+ if (e?.code === 'ENOENT') return { ok: true, tool, revoked: false };
159
+ return { ok: false, reason: String(e?.message ?? e) };
160
+ }
161
+ }
162
+
163
+ // How many answered requests a session remembers. In memory only: a server that restarts starts the record
164
+ // again, and the page says from when the record runs.
165
+ export const APPROVAL_LOG_MAX = 200;
166
+ // A request that vanished this close to the hook's own deadline, with no answer recorded, timed out. The hook
167
+ // polls and so does the watcher; this is their combined slack.
168
+ const TIMEOUT_SLACK_MS = 2500;
169
+ const VERDICT_OUTCOME = { allow: 'allowed', always: 'allowed-session', deny: 'denied' };
170
+
171
+ /**
172
+ * Fold the requests waiting now into a session's record of approvals.
173
+ *
174
+ * A request not seen before is opened with the moment it was asked. One that was open and is no longer waiting
175
+ * is closed with the moment that was noticed and with what became of it: the answer the panel recorded, a
176
+ * timeout if nobody answered and the hook's deadline had come, and otherwise "unanswered" — the hook went away
177
+ * without one, as it does when the session is stopped. Nothing is guessed into an answer.
178
+ *
179
+ * @param log the record so far, oldest first; changed in place and returned
180
+ * @param pending what `pending()` returns now
181
+ * @param decisions Map toolUseId -> verdict the panel recorded
182
+ */
183
+ export function logApprovals(log, pending, decisions, now, waitSeconds) {
184
+ const waiting = new Set(pending.map((r) => r.toolUseId));
185
+ const open = new Set(log.filter((e) => e.endedAt === null).map((e) => e.toolUseId));
186
+ for (const e of log) {
187
+ if (e.endedAt !== null || waiting.has(e.toolUseId)) continue;
188
+ e.endedAt = now;
189
+ const verdict = decisions?.get(e.toolUseId);
190
+ if (verdict && VERDICT_OUTCOME[verdict]) e.outcome = VERDICT_OUTCOME[verdict];
191
+ else if (typeof waitSeconds === 'number' && now >= e.askedAt + waitSeconds * 1000 - TIMEOUT_SLACK_MS) e.outcome = 'timed-out';
192
+ else e.outcome = 'unanswered';
193
+ decisions?.delete(e.toolUseId);
194
+ }
195
+ for (const r of pending) {
196
+ if (open.has(r.toolUseId)) continue;
197
+ log.push({
198
+ toolUseId: r.toolUseId, toolName: r.toolName, agentId: r.agentId ?? null, agentType: r.agentType ?? null,
199
+ askedAt: r.askedAt, endedAt: null, outcome: null,
200
+ });
201
+ }
202
+ // The oldest closed entries go first; a request still waiting is never dropped.
203
+ while (log.length > APPROVAL_LOG_MAX) {
204
+ const i = log.findIndex((e) => e.endedAt !== null);
205
+ if (i === -1) break;
206
+ log.splice(i, 1);
207
+ }
208
+ return log;
209
+ }
210
+
136
211
  /** Watch a session's spool and call back whenever the pending set changes. */
137
212
  export function watch(sessionId, onChange) {
138
213
  let last = '';
@@ -68,15 +68,25 @@ export async function allProjectDirs() {
68
68
  // Reading them means scanning the transcript, and the sidebar refreshes on a
69
69
  // timer over every session in the project — so only the tail is read (the
70
70
  // latest record wins anyway) and the result is cached against size+mtime.
71
+ //
72
+ // The branch comes out of the same read. Every record carries `gitBranch`, and the last one in the tail is the
73
+ // branch the session is on now — a session can change branch, and the navigator names where it is, not where it
74
+ // began. A transcript with no such field, or an empty one (not a repository), yields null: the caller shows
75
+ // nothing rather than a branch nobody read.
71
76
  const TITLE_TAIL_BYTES = 131072;
72
- const titleCache = new Map(); // file -> { sig, title }
77
+ const titleCache = new Map(); // file -> { sig, title, branch }
73
78
 
74
79
  export async function sessionTitle(file, size, mtimeMs) {
80
+ return (await sessionTail(file, size, mtimeMs)).title;
81
+ }
82
+
83
+ export async function sessionTail(file, size, mtimeMs) {
75
84
  const sig = `${size}:${mtimeMs}`;
76
85
  const hit = titleCache.get(file);
77
- if (hit && hit.sig === sig) return hit.title;
86
+ if (hit && hit.sig === sig) return { title: hit.title, branch: hit.branch };
78
87
 
79
88
  let title = null;
89
+ let branch = null;
80
90
  let fh;
81
91
  try {
82
92
  fh = await fsp.open(file, 'r');
@@ -100,15 +110,24 @@ export async function sessionTitle(file, size, mtimeMs) {
100
110
  }
101
111
  }
102
112
  title = found.custom ?? found.agent ?? found.ai ?? null;
113
+
114
+ // In a record the key is followed by a bare quote; inside a quoted message every quote is escaped, so text
115
+ // that merely mentions the field cannot match.
116
+ let last = null;
117
+ for (const m of text.matchAll(/"gitBranch"\s*:\s*"((?:[^"\\]|\\.)*)"/g)) last = m[1];
118
+ if (last) {
119
+ try { branch = JSON.parse(`"${last}"`) || null; } catch { branch = null; }
120
+ }
103
121
  } catch {
104
122
  title = null;
123
+ branch = null;
105
124
  } finally {
106
125
  if (fh !== undefined) try { await fh.close(); } catch { /* already gone */ }
107
126
  }
108
127
 
109
128
  if (titleCache.size > 500) titleCache.clear();
110
- titleCache.set(file, { sig, title });
111
- return title;
129
+ titleCache.set(file, { sig, title, branch });
130
+ return { title, branch };
112
131
  }
113
132
 
114
133
  // The working directory a session ran in. The encoded folder name cannot be
@@ -162,13 +181,16 @@ export async function listSessions(dir) {
162
181
  try { st = await fsp.stat(file); } catch { return null; }
163
182
 
164
183
  const subagentsDir = path.join(dir, sessionId, 'subagents');
165
- const [agentCount, title, cwd] = await Promise.all([
184
+ const [agentCount, tail, cwd] = await Promise.all([
166
185
  countAgentMetas(subagentsDir),
167
- sessionTitle(file, st.size, st.mtimeMs),
186
+ sessionTail(file, st.size, st.mtimeMs),
168
187
  sessionCwd(file, st.size),
169
188
  ]);
170
189
 
171
- return { sessionId, file, subagentsDir, bytes: st.size, modifiedAt: st.mtimeMs, agentCount, title, cwd };
190
+ return {
191
+ sessionId, file, subagentsDir, bytes: st.size, modifiedAt: st.mtimeMs, agentCount,
192
+ title: tail.title, branch: tail.branch, cwd,
193
+ };
172
194
  }));
173
195
 
174
196
  const out = settled.filter(Boolean);
@@ -222,6 +244,34 @@ export async function agentMetaFiles(subagentsDir, depth = 0) {
222
244
  return out;
223
245
  }
224
246
 
247
+ /**
248
+ * The branch a directory is on NOW, read from its `.git/HEAD` and nowhere else.
249
+ *
250
+ * No process is started: a `git` per project per poll is the cost this file exists to avoid. A worktree's `.git`
251
+ * is a file that points at its real git directory, and that pointer is followed once. A detached HEAD, a
252
+ * directory that is not a repository's root, and a file that cannot be read all answer null: the caller then
253
+ * names no branch, which is different from naming the one a session recorded last week.
254
+ */
255
+ export async function headBranch(cwd) {
256
+ if (!cwd) return null;
257
+ const dot = path.join(cwd, '.git');
258
+ let head;
259
+ try {
260
+ const st = await fsp.stat(dot);
261
+ if (st.isDirectory()) {
262
+ head = await fsp.readFile(path.join(dot, 'HEAD'), 'utf8');
263
+ } else {
264
+ const m = (await fsp.readFile(dot, 'utf8')).match(/^gitdir:\s*(.+?)\s*$/m);
265
+ if (!m) return null;
266
+ head = await fsp.readFile(path.join(path.resolve(cwd, m[1]), 'HEAD'), 'utf8');
267
+ }
268
+ } catch {
269
+ return null;
270
+ }
271
+ const ref = head.match(/^ref:\s*refs\/heads\/(\S+)\s*$/m);
272
+ return ref ? ref[1] : null;
273
+ }
274
+
225
275
  /**
226
276
  * Every session on this machine, grouped by the project it ran in.
227
277
  *
@@ -257,6 +307,9 @@ export async function listProjects({ currentCwd = null, limitPerProject = 60, ki
257
307
  // not deleted: the panel says how many it is holding back rather than
258
308
  // quietly shortening the list.
259
309
  exists: kit?.dirExists ?? (g.cwd ? await existsCached(g.cwd) : false),
310
+ // Where a session started here now would be working. Not the branch a session recorded: that one is on
311
+ // each session row, and may be weeks old.
312
+ branch: await headBranch(g.cwd),
260
313
  total: g.sessions.length,
261
314
  agentTotal: g.sessions.reduce((n, s) => n + s.agentCount, 0),
262
315
  modifiedAt: g.sessions[0]?.modifiedAt ?? 0,
@@ -15,7 +15,7 @@ import { spawn } from 'node:child_process';
15
15
  import { randomUUID } from 'node:crypto';
16
16
  import fs from 'node:fs';
17
17
 
18
- import { prepare, pending, watch, cleanup } from './permissions.js';
18
+ import { prepare, pending, watch, cleanup, alwaysList, logApprovals } from './permissions.js';
19
19
  import { getFleet } from './fleet.js';
20
20
 
21
21
  export const ALLOWED_MODES = ['plan', 'acceptEdits', 'default'];
@@ -49,12 +49,17 @@ class OwnedSession {
49
49
  // The gate is prepared before the child exists, so its very first tool call
50
50
  // is already covered. A missing hook is reported, never worked around: a
51
51
  // panel that silently ran ungated would be worse than one that cannot run.
52
- this.gate = prepare(this.id);
52
+ this.gate = prepare(this.id, this.permissionMode);
53
53
  this.pendingPermissions = [];
54
54
  this.gateEvents = []; // hook lifecycle, newest last
55
+ // What was asked of the viewer in this session, when, and what became of it. Kept from the moment Studio
56
+ // started the session, in memory: it is what the Timeline's waiting periods are drawn from.
57
+ this.approvals = [];
58
+ this.decisions = new Map();
55
59
  this.unwatch = this.gate
56
60
  ? watch(this.id, (reqs) => {
57
61
  this.pendingPermissions = reqs;
62
+ logApprovals(this.approvals, reqs, this.decisions, Date.now(), this.gate.waitSeconds);
58
63
  this.#emit({ type: 'permissions', pending: reqs });
59
64
  })
60
65
  : null;
@@ -181,6 +186,9 @@ class OwnedSession {
181
186
  }
182
187
  }
183
188
 
189
+ /** The panel recorded an answer to a request. Remembered until the request is seen to have gone. */
190
+ noteDecision(toolUseId, verdict) { this.decisions.set(toolUseId, verdict); }
191
+
184
192
  /** Replay from `after`, then follow. Returns an unsubscribe function. */
185
193
  subscribe(fn, after = 0) {
186
194
  for (const ev of this.events) if (ev.seq > after) fn(ev);
@@ -235,6 +243,12 @@ class OwnedSession {
235
243
  gated: Boolean(this.gate),
236
244
  gateWaitSeconds: this.gate?.waitSeconds ?? null,
237
245
  pendingPermissions: this.pendingPermissions,
246
+ // Tools the viewer allowed for the rest of this session, so they can be listed and taken back.
247
+ alwaysAllowed: this.gate ? alwaysList(this.id) : [],
248
+ approvals: this.approvals,
249
+ // This machine's clock. A countdown is `askedAt + wait - now`, and all three have to come from one clock:
250
+ // a viewer on another machine has a different one.
251
+ now: Date.now(),
238
252
  gateEvents: this.gateEvents.slice(-40),
239
253
  };
240
254
  }