crewforth 3.1.1 → 3.2.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 (78) hide show
  1. package/CHANGELOG.md +171 -0
  2. package/README.md +4 -3
  3. package/VERSION +1 -1
  4. package/adopt.sh +56 -5
  5. package/kit/AGENT_TEMPLATE.md +2 -1
  6. package/kit/CLAUDE.md +14 -13
  7. package/kit/agents/crew-commit-agent.md +7 -2
  8. package/kit/agents/crew-docs-agent.md +71 -0
  9. package/kit/agents/crew-planner.md +2 -0
  10. package/kit/agents/crew-refuter.md +77 -0
  11. package/kit/agents/crew-security-expert.md +3 -1
  12. package/kit/agents/crew-session-manager.md +11 -9
  13. package/kit/eval/doctor.sh +117 -6
  14. package/kit/eval/golden-routing.txt +9 -0
  15. package/kit/eval/lib/crew-map/crew-map.sh +26 -0
  16. package/kit/eval/lib/crew-map/detect.mjs +238 -0
  17. package/kit/eval/lib/crew-map/narrative.mjs +98 -0
  18. package/kit/eval/lib/crew-map/parse.mjs +305 -0
  19. package/kit/eval/lib/crew-map/project.mjs +163 -0
  20. package/kit/eval/lib/crew-map/rules/names.json +38 -0
  21. package/kit/eval/lib/crew-map/rules/packages.json +142 -0
  22. package/kit/eval/lib/crew-map/rules/python-stdlib.json +197 -0
  23. package/kit/eval/lib/crew-map/scan.mjs +214 -0
  24. package/kit/eval/lib/crew-map/stacks.mjs +441 -0
  25. package/kit/eval/lib/crew-map/surface.mjs +457 -0
  26. package/kit/eval/lib/findings/findings.mjs +410 -0
  27. package/kit/eval/lib/findings/findings.schema.json +122 -0
  28. package/kit/eval/lib/findings/findings.sh +29 -0
  29. package/kit/eval/lib/settings-default.sh +36 -0
  30. package/kit/eval/lib/settings-json.awk +15 -4
  31. package/kit/eval/lib/star.sh +23 -7
  32. package/kit/eval/lib/statusline.settings.json +6 -0
  33. package/kit/eval/lib/statusline.sh +100 -0
  34. package/kit/eval/lib/version.sh +41 -0
  35. package/kit/eval/smoke-test.sh +576 -41
  36. package/kit/hooks/agent-outcome.sh +26 -12
  37. package/kit/hooks/guard-agent-model.sh +109 -9
  38. package/kit/hooks/guard-bash.sh +240 -182
  39. package/kit/hooks/guard-question.sh +73 -0
  40. package/kit/hooks/guard-write.sh +21 -0
  41. package/kit/hooks/prompt-approval.sh +320 -103
  42. package/kit/hooks/refuter-ledger.sh +80 -0
  43. package/kit/hooks/session-update-check.sh +9 -8
  44. package/kit/owned-blobs.tsv +4 -4
  45. package/kit/settings.json +33 -0
  46. package/kit/skills/crew-approve/SKILL.md +7 -0
  47. package/kit/skills/crew-doctor/SKILL.md +5 -1
  48. package/kit/skills/crew-handoff/SKILL.md +3 -2
  49. package/kit/skills/crew-map/SKILL.md +44 -0
  50. package/kit/skills/crew-review/SKILL.md +2 -0
  51. package/kit/skills/crew-update/SKILL.md +1 -1
  52. package/kit/skills/incident-runbook/SKILL.md +2 -2
  53. package/kit/skills/security-scan/references/verify.md +6 -3
  54. package/kit/studio/README.md +12 -0
  55. package/kit/studio/server/index.js +125 -11
  56. package/kit/studio/server/lib/code-stamp.js +35 -0
  57. package/kit/studio/server/lib/crew-map.js +113 -0
  58. package/kit/studio/server/lib/model-outcomes.js +130 -15
  59. package/kit/studio/server/lib/owned-stream.js +56 -0
  60. package/kit/studio/server/lib/pricing.js +3 -0
  61. package/kit/studio/server/lib/projects.js +11 -4
  62. package/kit/studio/server/lib/remote.js +99 -0
  63. package/kit/studio/server/lib/usage.js +74 -14
  64. package/kit/studio/web/app.js +223 -15
  65. package/kit/studio/web/canvas.js +44 -120
  66. package/kit/studio/web/chat.js +82 -17
  67. package/kit/studio/web/graph-plan.js +10 -4
  68. package/kit/studio/web/index.html +10 -5
  69. package/kit/studio/web/liveness.js +14 -0
  70. package/kit/studio/web/map-plan.js +341 -0
  71. package/kit/studio/web/map.js +503 -0
  72. package/kit/studio/web/models-plan.js +61 -7
  73. package/kit/studio/web/models.js +19 -3
  74. package/kit/studio/web/style.css +131 -0
  75. package/kit/studio/web/usage.js +12 -0
  76. package/kit/studio/web/viewport.js +160 -0
  77. package/package.json +6 -5
  78. package/start.sh +10 -0
@@ -0,0 +1,35 @@
1
+ // code-stamp.js — is the code this server runs still the code on disk?
2
+ //
3
+ // A server keeps the code it started with. An update replaces the files on disk, the page is served fresh from
4
+ // them, and the two disagree: measured in a real project, a server started before 3.2.0 answered the new Map
5
+ // view's /api/map with 404, which the view showed as "Not read — HTTP 404". The stamp is the size and mtime of
6
+ // every file of the server, taken at start and again when asked; when they differ, the page says to restart.
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+
10
+ /** Size and mtime of every .js/.mjs/.sh file under `dir` (the server's own code), as one string. */
11
+ export function codeStamp(dir) {
12
+ const parts = [];
13
+ const walk = (d) => {
14
+ let entries;
15
+ try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
16
+ for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
17
+ const p = path.join(d, e.name);
18
+ if (e.isDirectory()) { if (e.name !== 'node_modules') walk(p); continue; }
19
+ if (!/\.(m?js|sh)$/.test(e.name)) continue;
20
+ try { const st = fs.statSync(p); parts.push(`${path.relative(dir, p)}:${st.size}:${Math.round(st.mtimeMs)}`); } catch { /* gone */ }
21
+ }
22
+ };
23
+ walk(dir);
24
+ return parts.join('|');
25
+ }
26
+
27
+ /** Stamp the code now, and answer later whether it has changed on disk (re-read at most every `everyMs`). */
28
+ export function stampAtStart(dir, { everyMs = 5000, now = () => Date.now() } = {}) {
29
+ const boot = codeStamp(dir);
30
+ let last = boot; let at = now();
31
+ return () => {
32
+ if (now() - at >= everyMs) { last = codeStamp(dir); at = now(); }
33
+ return { changed: last !== boot };
34
+ };
35
+ }
@@ -0,0 +1,113 @@
1
+ // What Crewforth's crew map says about a project, for the Map view.
2
+ //
3
+ // The map is `.crewforth/crew-map.json`, written by Crewforth's scanner (kit/eval/lib/crew-map/scan.mjs): the
4
+ // project's modules, the external resources it touches, the endpoints it provides and calls, and the findings
5
+ // between them, every one citing file:line. This file reads it and runs nothing that writes: no scan is started
6
+ // from here, and a map that is not on disk is said to be missing. The one process it starts is `git rev-list
7
+ // --count`, to say how far the project has moved since the commit the map was scanned at.
8
+ //
9
+ // Compare with: another project's map is read the same way. The scanner already says which of a project's calls
10
+ // no endpoint in that project provides (they go somewhere else); those are the calls compared here. One the other
11
+ // project provides is a match, one it does not is missing.
12
+
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { execFile } from 'node:child_process';
16
+
17
+ export const MAP_FILE = path.join('.crewforth', 'crew-map.json');
18
+ // How a project writes its map, said wherever a map is missing. The one place it is written: the view and the
19
+ // page show what the server sends.
20
+ // /crew-map in Claude Code: the scan, then the descriptions, in either edition (the plugin has no .claude/eval).
21
+ export const WRITER = '/crew-map in Claude Code';
22
+ export const SCHEMA = /^crewforth\.crew-map\/1$/;
23
+ const MAX_BYTES = 8 * 1024 * 1024;
24
+ const GIT_TIMEOUT_MS = 5000;
25
+
26
+ const maps = new Map(); // cwd -> { sig, value }
27
+ export const _stats = { parsed: 0 }; // how many times a map was really read: the claims count it
28
+
29
+ /**
30
+ * The map of one project, read once and kept until the file changes (its time or its size).
31
+ * @returns { measured: true, file, bytes, doc } or { measured: false, reason }
32
+ */
33
+ export function readMap(cwd) {
34
+ const file = path.join(cwd, MAP_FILE);
35
+ let st;
36
+ try { st = fs.statSync(file); } catch {
37
+ return { measured: false, missing: true, reason: `no ${MAP_FILE.replaceAll('\\', '/')} in this project. Write one there with: ${WRITER}` };
38
+ }
39
+ const sig = `${st.mtimeMs}:${st.size}`;
40
+ const hit = maps.get(cwd);
41
+ if (hit && hit.sig === sig) return hit.value;
42
+ const value = parseMap(file, st.size);
43
+ _stats.parsed += 1;
44
+ if (maps.size > 50) maps.clear();
45
+ maps.set(cwd, { sig, value });
46
+ return value;
47
+ }
48
+
49
+ function parseMap(file, size) {
50
+ const shown = MAP_FILE.replaceAll('\\', '/');
51
+ // Read whole or not at all: half a map would draw modules without the edges that belong to them.
52
+ if (size > MAX_BYTES) return { measured: false, reason: `${shown} is ${size} bytes, over the ${MAX_BYTES} Studio reads` };
53
+ let doc;
54
+ try { doc = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) { return { measured: false, reason: `${shown} is not JSON (${e.message.slice(0, 80)})` }; }
55
+ if (!SCHEMA.test(String(doc?.schema))) return { measured: false, reason: `${shown} says schema ${JSON.stringify(doc?.schema ?? null)}, and Studio reads crewforth.crew-map/1` };
56
+ for (const k of ['nodes', 'edges', 'findings', 'notMeasured']) {
57
+ if (!Array.isArray(doc[k])) return { measured: false, reason: `${shown} has no ${k} list` };
58
+ }
59
+ return { measured: true, file: shown, bytes: size, doc };
60
+ }
61
+
62
+ /** What the navigator says of a project's map: null with none, else how many findings it shows unasked. */
63
+ export function mapSummary(cwd) {
64
+ const m = readMap(cwd);
65
+ if (!m.measured) return m.missing ? null : { findings: null, reason: m.reason };
66
+ return { findings: m.doc.findings.filter((f) => !f?.data?.defaultHidden).length };
67
+ }
68
+
69
+ /**
70
+ * How many commits HEAD is past the commit the map was scanned at: { count } or { count: null, reason }.
71
+ * Only a commit id is handed to git, so nothing in the file can become an option.
72
+ */
73
+ export function behindOf(cwd, commit) {
74
+ if (typeof commit !== 'string' || !/^[0-9a-f]{7,64}$/.test(commit)) return Promise.resolve({ count: null, reason: 'the map names no commit' });
75
+ return new Promise((resolve) => {
76
+ execFile('git', ['rev-list', '--count', `${commit}..HEAD`], { cwd, timeout: GIT_TIMEOUT_MS, windowsHide: true }, (err, stdout) => {
77
+ const n = Number.parseInt(String(stdout).trim(), 10);
78
+ if (err || !Number.isFinite(n)) resolve({ count: null, reason: 'git could not say (the commit may not be in this clone)' });
79
+ else resolve({ count: n });
80
+ });
81
+ });
82
+ }
83
+
84
+ /** One endpoint as it is matched: the method, and the path with each parameter written as one `*`. */
85
+ export function endpointKey(e) {
86
+ const p = String(e?.path ?? '').split('?')[0].replace(/\/+$/, '') || '/';
87
+ const segs = p.split('/').map((s) => (/^(:\w+|\{[^}]*\}|<[^>]*>|\*)$/.test(s) ? '*' : s));
88
+ return `${String(e?.method ?? '').toUpperCase()} ${segs.join('/')}`;
89
+ }
90
+
91
+ /**
92
+ * Another project's calls into this one. `doc` is this project's map, `other` the other project's. A call of the
93
+ * other project is compared when no endpoint of its own provides it (the scanner's own endpoint-missing). Each
94
+ * calling module of the other project is one client: what it calls here that this project provides (`calls`,
95
+ * each with `to`, the module of this project that provides it),
96
+ * and what else it calls that its own project does not provide either (`missing`). A module none of whose calls
97
+ * this project provides is not a client of it, and is not listed: its calls go to a third place, and naming them
98
+ * missing here would say something the two maps do not.
99
+ */
100
+ export function compareMaps(doc, other) {
101
+ const own = new Set((other?.provides?.endpoints ?? []).map(endpointKey));
102
+ const here = new Map((doc?.provides?.endpoints ?? []).map((e) => [endpointKey(e), e.module]));
103
+ const names = new Map((other?.nodes ?? []).filter((n) => n.kind === 'module').map((n) => [n.id, n]));
104
+ const by = new Map();
105
+ for (const e of other?.consumes?.endpoints ?? []) {
106
+ const k = endpointKey(e);
107
+ if (own.has(k)) continue;
108
+ const c = by.get(e.module) ?? by.set(e.module, { id: e.module, name: names.get(e.module)?.name ?? e.module, tech: names.get(e.module)?.tech ?? [], calls: [], missing: [] }).get(e.module);
109
+ const call = { method: e.method, path: e.path, evidence: (e.evidence ?? []).slice(0, 1) };
110
+ if (here.has(k)) c.calls.push({ ...call, to: here.get(k) }); else c.missing.push(call);
111
+ }
112
+ return [...by.values()].filter((c) => c.calls.length).sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
113
+ }
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // Crewforth writes one line per finished crew agent to `.claude/state/model-outcomes.tsv` (hooks/agent-outcome.sh):
4
4
  //
5
- // ts agent_id agent change risk card model verify fixes escalated_from ran_on confidence
5
+ // ts agent_id agent change risk card model verify fixes escalated_from ran_on confidence [risk_why]
6
6
  //
7
7
  // Twelve columns, tab-separated, a header on the first line, "-" for an empty value, and any column added later
8
8
  // is added at the end. This file reads that and adds nothing to it: a class's first-try success is counted from
@@ -19,11 +19,18 @@
19
19
  // failed too often. Lowering such a hold is the user's to approve, with a Crewforth command in their own session:
20
20
  // Studio shows which holds could be lowered (`suggestionsOf`) and writes nothing. A panel's button is a request
21
21
  // any process of the same user can send, so it cannot stand for the user.
22
+ //
23
+ // The gate holds a floor and no ceiling: normal work may run on a model above the one the table in CLAUDE.md asks
24
+ // for it. Studio shows that work (`aboveTableOf`) and blocks nothing. That the table's model would have done is
25
+ // said only where this record shows it: the class's latest LOOSEN_AFTER verified calls on that model all passed the
26
+ // first time, the same test a hold is offered for lowering by.
22
27
 
23
28
  import fs from 'node:fs';
24
29
  import path from 'node:path';
25
30
 
26
31
  export const COLUMNS = ['ts', 'agent_id', 'agent', 'change', 'risk', 'card', 'model', 'verify', 'fixes', 'escalated_from', 'ran_on', 'confidence'];
32
+ // Added at the end later, so a line written before them is still whole without them.
33
+ export const LATER_COLUMNS = ['risk_why'];
27
34
  const VERIFIED = new Set(['pass', 'fail']);
28
35
  // What the hooks write, and nothing else is a row. The words are hooks/guard-agent-model.sh's and
29
36
  // hooks/agent-outcome.sh's; a word they do not use is a line this file cannot read, not a new kind of work.
@@ -36,6 +43,14 @@ const MAX_BYTES = 4 * 1024 * 1024; // the record is read whole; past this o
36
43
  // A starting point; it has not been measured.
37
44
  export const LOOSEN_AFTER = 10;
38
45
  const TRAIL_MAX = 6;
46
+ // What the table in CLAUDE.md asks of normal work, at most. Above it is a choice the gate lets the caller make.
47
+ export const NORMAL_TIER = 'sonnet';
48
+ // The floor hooks/guard-agent-model.sh puts under an agent whatever its card says (its `case "$st"`).
49
+ export const AGENT_FLOORS = {
50
+ 'crew-security-expert': 'opus',
51
+ 'crew-privacy-agent': 'sonnet', 'crew-review-agent': 'sonnet', 'crew-planner': 'sonnet', 'crew-database-expert': 'sonnet',
52
+ };
53
+ const rankOf = (t) => TIERS.indexOf(t) + 1; // 1..4, 0 for no tier: the gate's _am_rank
39
54
 
40
55
  const stateDir = (cwd) => path.join(cwd, '.claude', 'state');
41
56
  const dash = (v) => (v === '-' || v === '' || v == null ? null : v);
@@ -72,6 +87,8 @@ export function parseOutcomes(text) {
72
87
  line: i, ts: f[0], at, agentId: f[1], agent: f[2], change: f[3], risk: f[4], card: dash(f[5]),
73
88
  model: dash(f[6]), verify: f[7], fixes: Number.parseInt(f[8], 10), escalatedFrom: dash(f[9]), ranOn: dash(f[10]),
74
89
  confidence: dash(f[11]),
90
+ // Written by the gate when it called the work critical; a line from before it kept the reason has no column.
91
+ riskWhy: dash(f[12]),
75
92
  });
76
93
  }
77
94
  return out;
@@ -102,9 +119,53 @@ export function parseLoosened(text) {
102
119
  return out;
103
120
  }
104
121
 
122
+ /**
123
+ * The `floor <agent> <change> <risk> <model>` lines of the project's .claude/crew-model-rules; `*` matches any.
124
+ * Read as hooks/guard-agent-model.sh reads them (`_am_rules`): a comment is cut at #, the model is found in the word.
125
+ */
126
+ export function parseRuleFloors(text) {
127
+ const out = [];
128
+ for (const line of String(text ?? '').split('\n')) {
129
+ const l = line.replace(/\r$/, '').replace(/#.*/, '').trim();
130
+ const m = l.match(/^floor\s+(\S+)\s+(\S+)\s+(\S+)\s+(\S+)/);
131
+ if (!m) continue;
132
+ const model = TIERS.find((t) => m[4].toLowerCase().includes(t));
133
+ if (model) out.push({ agent: m[1], change: m[2], risk: m[3], model });
134
+ }
135
+ return out;
136
+ }
137
+
105
138
  const keyOf = (r) => `${r.agent}\t${r.change}\t${r.risk}`;
106
139
  const firstTry = (r) => r.verify === 'pass' && r.fixes === 0;
107
140
 
141
+ /**
142
+ * The lowest model the gate would have let this call run on, as a rank (see `rankOf`): the highest of what
143
+ * applies, the order of hooks/guard-agent-model.sh. The project's rules are read as they are now; a raise from the
144
+ * record counts for the calls made after it, lowered or not, so a call it held is never said to have run above.
145
+ */
146
+ export function floorOfRow(r, ruleFloors = [], floors = []) {
147
+ let f = rankOf(AGENT_FLOORS[r.agent]);
148
+ if (r.verify === 'none') f = Math.max(f, 2);
149
+ if (r.change === 'test-write' || r.change === 'audit') f = Math.max(f, 2);
150
+ if (r.risk === 'critical' && r.change !== 'test-run') f = Math.max(f, 3);
151
+ const any = (want, v) => want === '*' || want === v;
152
+ for (const x of ruleFloors) if (any(x.agent, r.agent) && any(x.change, r.change) && any(x.risk, r.risk)) f = Math.max(f, rankOf(x.model));
153
+ for (const x of floors) if (keyOf(x) === keyOf(r) && !(String(r.ts) < String(x.when ?? ''))) f = Math.max(f, rankOf(x.model));
154
+ if (r.escalatedFrom) f = Math.max(f, rankOf(r.escalatedFrom) + 1);
155
+ return f;
156
+ }
157
+
158
+ /**
159
+ * How many of a class's latest `need` verified calls on `model`, since `since`, there are when every one of them
160
+ * passed the first time; 0 when there are fewer, or one did not. The test for lowering a hold and for saying the
161
+ * table's model would have done.
162
+ */
163
+ function passedLatest(rows, k, model, since, need) {
164
+ const recent = rows.filter((r) => keyOf(r) === k && r.model === model && VERIFIED.has(r.verify) && (r.at ?? 0) >= since)
165
+ .sort((a, b) => (b.at ?? 0) - (a.at ?? 0)).slice(0, need);
166
+ return recent.length >= need && recent.every(firstTry) ? recent.length : 0;
167
+ }
168
+
108
169
  /**
109
170
  * The lowering that stands for a raised floor, or null. The rule is hooks/guard-agent-model.sh's (`_am_floor_now`),
110
171
  * word for word: a lowering counts when it is not older than the raise it answers (the times are UTC text and are
@@ -161,13 +222,16 @@ export function classesOf(rows, floors = [], loosened = []) {
161
222
  * `costed` of `runs` were priced; the rest are not in the sum, so the sum is a floor and not the total.
162
223
  * @param costOf (agentId) => USD or null
163
224
  */
164
- export function classCosts(rows, costOf) {
225
+ export function classCosts(rows, costOf, advisorOf = () => null) {
165
226
  const out = {};
166
227
  for (const r of rows) {
167
- const c = (out[keyOf(r)] ??= { cost: 0, costed: 0, runs: 0 });
228
+ const c = (out[keyOf(r)] ??= { cost: 0, costed: 0, runs: 0, advisorCalls: 0, advisorCost: 0 });
168
229
  c.runs += 1;
169
230
  const usd = costOf(r.agentId);
170
231
  if (typeof usd === 'number') { c.cost += usd; c.costed += 1; }
232
+ // The advisor's share is part of `cost` already; it is counted apart only to be said.
233
+ const a = advisorOf(r.agentId);
234
+ if (a?.calls > 0) { c.advisorCalls += a.calls; if (typeof a.cost === 'number') c.advisorCost += a.cost; }
171
235
  }
172
236
  return out;
173
237
  }
@@ -186,11 +250,52 @@ export function suggestionsOf(rows, floors = [], loosened = [], need = LOOSEN_AF
186
250
  const k = keyOf(f);
187
251
  // Once for each raise: a floor whose lowering stands is not offered again (the same rule the gate reads it by).
188
252
  if (loweringOf(f, loosened)) continue;
189
- const since = Date.parse(f.when ?? '') || 0;
190
- const recent = rows.filter((r) => keyOf(r) === k && r.model === f.model && VERIFIED.has(r.verify) && (r.at ?? 0) >= since)
191
- .sort((a, b) => (b.at ?? 0) - (a.at ?? 0)).slice(0, need);
192
- if (recent.length < need || !recent.every(firstTry)) continue;
193
- out.push({ key: k, agent: f.agent, change: f.change, risk: f.risk, from: f.model, to: TIERS[tier - 1], passed: recent.length, heldSince: f.when ?? null });
253
+ const passed = passedLatest(rows, k, f.model, Date.parse(f.when ?? '') || 0, need);
254
+ if (!passed) continue;
255
+ out.push({ key: k, agent: f.agent, change: f.change, risk: f.risk, from: f.model, to: TIERS[tier - 1], passed, heldSince: f.when ?? null });
256
+ }
257
+ return out;
258
+ }
259
+
260
+ /**
261
+ * Normal work that ran above the table's model, by class, the busiest first: the calls on a model above both
262
+ * NORMAL_TIER and the floor the gate held them to (`floorOfRow`). `passed` is how many of the class's latest
263
+ * verified calls on NORMAL_TIER passed the first time (counted from the class's raise, where it has one), when it
264
+ * is `need` of `need`; otherwise 0, and nothing is said about whether NORMAL_TIER would have done.
265
+ * `ids` are the agents' ids, for pricing; they are not sent to a page (`readModels`).
266
+ */
267
+ export function aboveTableOf(rows, ruleFloors = [], floors = [], need = LOOSEN_AFTER) {
268
+ const by = new Map();
269
+ const top = rankOf(NORMAL_TIER);
270
+ for (const r of rows) {
271
+ if (r.risk !== 'normal' || rankOf(r.model) <= Math.max(top, floorOfRow(r, ruleFloors, floors))) continue;
272
+ const k = keyOf(r);
273
+ const c = by.get(k) ?? by.set(k, { key: k, agent: r.agent, change: r.change, risk: r.risk, to: NORMAL_TIER, runs: 0, models: {}, ids: [] }).get(k);
274
+ c.runs += 1;
275
+ c.models[r.model] = (c.models[r.model] ?? 0) + 1;
276
+ c.ids.push(r.agentId);
277
+ }
278
+ return [...by.values()].map((c) => {
279
+ const held = floors.find((f) => keyOf(f) === c.key);
280
+ return { ...c, need, passed: passedLatest(rows, c.key, NORMAL_TIER, Date.parse(held?.when ?? '') || 0, need) };
281
+ }).sort((a, b) => b.runs - a.runs || a.key.localeCompare(b.key));
282
+ }
283
+
284
+ /**
285
+ * What the work above the table cost, by class: { classKey: { cost, atNormal, costed, runs } }. `atNormal` is the
286
+ * same tokens at NORMAL_TIER's list price: what that model would have cost had it used as many, which no record
287
+ * can say it would have. As with `classCosts`, only priced calls are in the sums.
288
+ * @param priceOf (agentId) => { cost, atNormal } in USD, or null
289
+ */
290
+ export function aboveCosts(above, priceOf) {
291
+ const out = {};
292
+ for (const c of above) {
293
+ const o = (out[c.key] = { cost: 0, atNormal: 0, costed: 0, runs: c.ids.length });
294
+ for (const id of c.ids) {
295
+ const p = priceOf(id);
296
+ if (typeof p?.cost !== 'number' || typeof p?.atNormal !== 'number') continue;
297
+ o.cost += p.cost; o.atNormal += p.atNormal; o.costed += 1;
298
+ }
194
299
  }
195
300
  return out;
196
301
  }
@@ -210,16 +315,18 @@ function readBounded(file) {
210
315
 
211
316
  const sigOf = (file) => { try { const st = fs.statSync(file); return `${st.mtimeMs}:${st.size}`; } catch { return '-'; } };
212
317
  const FILES = ['model-outcomes.tsv', 'crew-model-floors.auto', 'crew-model-loosened.tsv'];
318
+ const RULES = (cwd) => path.join(cwd, '.claude', 'crew-model-rules'); // the project's own, beside the state
213
319
  const records = new Map(); // cwd -> { sig, value }
214
320
  export const _stats = { parsed: 0 }; // how many times a record was really read: the claims count it
215
321
 
216
322
  /**
217
- * The record of one project, read once and kept until one of its three files changes (its time or its size).
323
+ * The record of one project, read once and kept until one of its files, or the project's rules, changes (its
324
+ * time or its size).
218
325
  * A view that asks every few seconds costs three stats, not a parse.
219
326
  */
220
327
  export function readRecord(cwd) {
221
328
  const dir = stateDir(cwd);
222
- const sig = FILES.map((f) => sigOf(path.join(dir, f))).join('|');
329
+ const sig = [...FILES.map((f) => sigOf(path.join(dir, f))), sigOf(RULES(cwd))].join('|');
223
330
  const hit = records.get(cwd);
224
331
  if (hit && hit.sig === sig) return hit.value;
225
332
  const value = parseRecord(dir, cwd);
@@ -231,10 +338,11 @@ export function readRecord(cwd) {
231
338
 
232
339
  function parseRecord(dir, cwd) {
233
340
  const record = readBounded(path.join(dir, FILES[0]));
234
- if (!record) return { measured: false, reason: 'no .claude/state/model-outcomes.tsv in this project: no crew agent has finished here since model routing was installed', classes: [], suggestions: [], byAgent: {} };
341
+ if (!record) return { measured: false, reason: 'no .claude/state/model-outcomes.tsv in this project: no crew agent has finished here since model routing was installed', classes: [], suggestions: [], above: [], byAgent: {} };
235
342
  const parsed = parseOutcomes(record.text);
236
343
  const floors = parseFloors(readBounded(path.join(dir, FILES[1]))?.text ?? '');
237
344
  const loosened = parseLoosened(readBounded(path.join(dir, FILES[2]))?.text ?? '');
345
+ const ruleFloors = parseRuleFloors(readBounded(RULES(cwd))?.text ?? '');
238
346
  const byAgent = {};
239
347
  for (const r of parsed.rows) byAgent[r.agentId] = r; // a resumed agent's latest line is the one that stands
240
348
  // A card that failed is given again one model up, to another agent: the calls of one card, oldest first, are
@@ -258,6 +366,7 @@ function parseRecord(dir, cwd) {
258
366
  classes: classesOf(parsed.rows, floors, loosened),
259
367
  floors,
260
368
  suggestions: suggestionsOf(parsed.rows, floors, loosened),
369
+ above: aboveTableOf(parsed.rows, ruleFloors, floors),
261
370
  loosened,
262
371
  byAgent,
263
372
  all: parsed.rows,
@@ -276,12 +385,18 @@ export function readModels(cwd, { agents = null } = {}) {
276
385
  if (!rec.measured) return rec;
277
386
  const byAgent = {};
278
387
  for (const id of agents ?? []) if (Object.hasOwn(rec.byAgent, id)) byAgent[id] = rec.byAgent[id];
279
- const { all, byAgent: _every, ...rest } = rec;
280
- return { ...rest, byAgent };
388
+ const { all, byAgent: _every, above, ...rest } = rec;
389
+ return { ...rest, above: above.map(({ ids, ...c }) => c), byAgent };
281
390
  }
282
391
 
283
392
  /** What the classes of one project cost, from the kept record: see `classCosts`. Null when there is no record. */
284
- export function readCosts(cwd, costOf) {
393
+ export function readCosts(cwd, costOf, advisorOf) {
394
+ const rec = readRecord(cwd);
395
+ return rec.measured ? classCosts(rec.all, costOf, advisorOf) : null;
396
+ }
397
+
398
+ /** What the work above the table cost, from the kept record: see `aboveCosts`. Null when there is no record. */
399
+ export function readAboveCosts(cwd, priceOf) {
285
400
  const rec = readRecord(cwd);
286
- return rec.measured ? classCosts(rec.all, costOf) : null;
401
+ return rec.measured ? aboveCosts(rec.above, priceOf) : null;
287
402
  }
@@ -0,0 +1,56 @@
1
+ // owned-stream.js — the conversations of several owned sessions, replayed then followed, on ONE connection.
2
+ //
3
+ // A browser holds six connections to one host over HTTP/1.1, and a pane per stream reached them at five panes
4
+ // beside the page's own: the panel kept painting, but a message sent and every request the page made itself waited
5
+ // until a pane was closed (measured on Windows, Claude Code 2.1.301: a send did not arrive in 35 s, then in 8 s once
6
+ // two panes closed). Each event and state carries the session id it belongs to.
7
+ // ids=a,b,c the sessions (at most MAX_STREAM_IDS; an unknown id is answered with `gone`)
8
+ // after=a:12,b:3 resume each where the client last was, so a reconnect neither replays nor drops
9
+
10
+ export const MAX_STREAM_IDS = 32;
11
+
12
+ /** Read `ids` and `after` from a URL: the ids, de-duplicated and capped, and each one's resume point. */
13
+ export function parseOwnedQuery(url) {
14
+ const ids = [...new Set((url.searchParams.get('ids') ?? '').split(',').filter(Boolean))].slice(0, MAX_STREAM_IDS);
15
+ const after = new Map();
16
+ for (const part of (url.searchParams.get('after') ?? '').split(',')) {
17
+ const at = part.lastIndexOf(':');
18
+ if (at > 0) after.set(part.slice(0, at), Number(part.slice(at + 1)) || 0);
19
+ }
20
+ return { ids, after };
21
+ }
22
+
23
+ /**
24
+ * @param getSession id -> an owned session ({ summary(), subscribe(fn, after) }) or null
25
+ * @param stateAfter the record types after which a session's summary is sent again
26
+ * @param beatMs how often every summary is sent regardless
27
+ */
28
+ export function streamOwned(req, res, url, { getSession, stateAfter = new Set(), beatMs = 15000 }) {
29
+ const { ids, after } = parseOwnedQuery(url);
30
+ res.writeHead(200, {
31
+ 'content-type': 'text/event-stream; charset=utf-8',
32
+ 'cache-control': 'no-store',
33
+ connection: 'keep-alive',
34
+ 'x-accel-buffering': 'no',
35
+ });
36
+ const write = (event, data) => {
37
+ try { res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); } catch { /* client gone */ }
38
+ };
39
+ const unsubscribes = [];
40
+ for (const id of ids) {
41
+ const s = getSession(id);
42
+ if (!s) { write('gone', { id }); continue; }
43
+ write('state', { id, session: s.summary() });
44
+ unsubscribes.push(s.subscribe((ev) => {
45
+ write('event', { id, ev });
46
+ if (stateAfter.has(ev.rec?.type)) write('state', { id, session: s.summary() });
47
+ }, after.get(id) ?? 0));
48
+ }
49
+ const beat = setInterval(() => {
50
+ for (const id of ids) { const s = getSession(id); if (s) write('state', { id, session: s.summary() }); }
51
+ }, beatMs);
52
+ const stop = () => { clearInterval(beat); for (const u of unsubscribes) u(); try { res.end(); } catch { /* gone */ } };
53
+ req.on('close', stop);
54
+ req.on('error', stop);
55
+ return stop;
56
+ }
@@ -36,6 +36,9 @@ export const PRICES = {
36
36
  'claude-haiku-4-5': { input: 1, output: 5 },
37
37
  };
38
38
 
39
+ // The model the table's normal tier is priced at, for the Models view's "the same tokens on the table's model".
40
+ export const NORMAL_TIER_MODEL = 'claude-sonnet-5-5';
41
+
39
42
  /** The row for a model id, or null. A dated snapshot (`<id>-20260101`) is the same model; nothing else matches. */
40
43
  export function priceOf(model) {
41
44
  if (typeof model !== 'string') return null;
@@ -24,10 +24,17 @@ import fsp from 'node:fs/promises';
24
24
  import os from 'node:os';
25
25
  import path from 'node:path';
26
26
 
27
- // Overridable so a second instance can be pointed at a different transcript
28
- // store — which is also the only way to exercise the peer relay on one machine.
29
- export const PROJECTS_ROOT = process.env.CREW_STUDIO_PROJECTS_ROOT
30
- || path.join(os.homedir(), '.claude', 'projects');
27
+ // Where Claude Code writes its transcripts: <config dir>/projects, the config dir being CLAUDE_CONFIG_DIR when it is
28
+ // set and ~/.claude otherwise. Reading ~/.claude alone, sessions started under a moved config dir (Studio's own
29
+ // included) were not listed and read a cost of 0 (measured on Windows). The studio gate hook reads it the same way.
30
+ // CREW_STUDIO_PROJECTS_ROOT still wins: a second instance pointed at a different store, which is also the only way to
31
+ // exercise the peer relay on one machine.
32
+ export function projectsRoot(env = process.env, home = os.homedir()) {
33
+ if (env.CREW_STUDIO_PROJECTS_ROOT) return env.CREW_STUDIO_PROJECTS_ROOT;
34
+ const config = env.CLAUDE_CONFIG_DIR ? path.resolve(env.CLAUDE_CONFIG_DIR) : path.join(home, '.claude');
35
+ return path.join(config, 'projects');
36
+ }
37
+ export const PROJECTS_ROOT = projectsRoot();
31
38
 
32
39
  /** Fold a working directory into the directory name Claude Code uses. */
33
40
  export function encodeCwd(cwd) {
@@ -0,0 +1,99 @@
1
+ // remote.js — read-only viewers.
2
+ //
3
+ // The panel's own token can start a session and answer a tool call. Handing that token to a phone, over
4
+ // whatever tunnel reaches this machine, hands the phone all of it. A viewer token is the smaller thing: it
5
+ // reads what the panel reads and can change nothing. The server decides that, not the page — a viewer's
6
+ // request is refused here before any handler sees it.
7
+ //
8
+ // THE SERVER STILL LISTENS ON LOOPBACK ONLY. A viewer does not widen that. The way in from another device is a
9
+ // tunnel the user runs (an ssh forward, a tailnet), and this module knows nothing about it.
10
+ //
11
+ // Kept in memory, like the owner's token: a viewer lasts as long as the server that made it. A link that
12
+ // survived a restart would be a credential nobody remembers handing out.
13
+ import { randomUUID } from 'node:crypto';
14
+
15
+ export const MAX_VIEWERS = 8;
16
+ const LABEL_MAX = 40;
17
+
18
+ const viewers = new Map(); // id -> viewer
19
+ const byToken = new Map(); // token -> viewer
20
+
21
+ const publicOf = (v) => ({
22
+ id: v.id,
23
+ label: v.label,
24
+ createdAt: v.createdAt,
25
+ lastSeenAt: v.lastSeenAt,
26
+ requests: v.requests,
27
+ agent: v.agent,
28
+ open: v.conns.size,
29
+ });
30
+
31
+ /** Make a viewer. The token is in this answer and in no later one. */
32
+ export function addViewer(label) {
33
+ if (viewers.size >= MAX_VIEWERS) {
34
+ return { ok: false, reason: `${MAX_VIEWERS} viewers already; revoke one first` };
35
+ }
36
+ const named = typeof label === 'string' ? label.replace(/\s+/g, ' ').trim().slice(0, LABEL_MAX) : '';
37
+ const v = {
38
+ id: randomUUID().slice(0, 8),
39
+ label: named || `Viewer ${viewers.size + 1}`,
40
+ token: randomUUID(),
41
+ createdAt: Date.now(),
42
+ lastSeenAt: null,
43
+ requests: 0,
44
+ agent: null,
45
+ conns: new Set(),
46
+ };
47
+ viewers.set(v.id, v);
48
+ byToken.set(v.token, v);
49
+ return { ok: true, viewer: { ...publicOf(v), token: v.token } };
50
+ }
51
+
52
+ export function listViewers() {
53
+ return [...viewers.values()].map(publicOf);
54
+ }
55
+
56
+ export function viewerByToken(token) {
57
+ return (typeof token === 'string' && byToken.get(token)) || null;
58
+ }
59
+
60
+ /**
61
+ * Take a viewer's access back. Its token stops working with this call, and a stream it holds open is closed:
62
+ * a revoke that left the live graph running on the phone would be a revoke in name only.
63
+ */
64
+ export function revokeViewer(id) {
65
+ const v = viewers.get(id);
66
+ if (!v) return { ok: true, revoked: false };
67
+ viewers.delete(id);
68
+ byToken.delete(v.token);
69
+ for (const res of v.conns) { try { res.destroy(); } catch { /* already gone */ } }
70
+ v.conns.clear();
71
+ return { ok: true, revoked: true };
72
+ }
73
+
74
+ /** Count a viewer's request and remember its response for as long as it is open. */
75
+ export function noteRequest(v, req, res) {
76
+ v.requests += 1;
77
+ v.lastSeenAt = Date.now();
78
+ const ua = req.headers['user-agent'];
79
+ v.agent = typeof ua === 'string' ? ua.slice(0, 160) : null;
80
+ v.conns.add(res);
81
+ res.on('close', () => v.conns.delete(res));
82
+ }
83
+
84
+ /**
85
+ * What a viewer may ask for: a GET of anything the panel reads, and nothing about viewers themselves.
86
+ * The list of viewers is the owner's; a viewer that could read it could count who else is watching.
87
+ */
88
+ export function viewerMay(method, pathname) {
89
+ if (method !== 'GET') return { ok: false, reason: 'read-only token: GET only' };
90
+ if (pathname === '/api/remote' || pathname.startsWith('/api/remote/')) {
91
+ return { ok: false, reason: 'read-only token: viewers are managed from the machine itself' };
92
+ }
93
+ return { ok: true };
94
+ }
95
+
96
+ /** For the self-check: start from nothing. */
97
+ export function _reset() {
98
+ for (const id of [...viewers.keys()]) revokeViewer(id);
99
+ }