crewforth 3.1.0 → 3.2.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 (70) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +4 -3
  3. package/VERSION +1 -1
  4. package/adopt.sh +91 -11
  5. package/kit/AGENT_TEMPLATE.md +2 -1
  6. package/kit/CLAUDE.md +9 -8
  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 +91 -3
  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-json.awk +5 -0
  30. package/kit/eval/lib/star.sh +23 -7
  31. package/kit/eval/lib/statusline.settings.json +6 -0
  32. package/kit/eval/lib/statusline.sh +115 -0
  33. package/kit/eval/smoke-test.sh +465 -38
  34. package/kit/hooks/agent-outcome.sh +6 -3
  35. package/kit/hooks/guard-agent-model.sh +15 -5
  36. package/kit/hooks/guard-bash.sh +240 -182
  37. package/kit/hooks/guard-question.sh +73 -0
  38. package/kit/hooks/guard-write.sh +21 -0
  39. package/kit/hooks/prompt-approval.sh +320 -103
  40. package/kit/hooks/refuter-ledger.sh +80 -0
  41. package/kit/owned-blobs.tsv +4 -1
  42. package/kit/settings.json +33 -0
  43. package/kit/skills/crew-approve/SKILL.md +7 -0
  44. package/kit/skills/crew-doctor/SKILL.md +5 -1
  45. package/kit/skills/crew-handoff/SKILL.md +3 -2
  46. package/kit/skills/crew-map/SKILL.md +44 -0
  47. package/kit/skills/crew-review/SKILL.md +2 -0
  48. package/kit/skills/crew-update/SKILL.md +1 -1
  49. package/kit/skills/incident-runbook/SKILL.md +2 -2
  50. package/kit/skills/security-scan/references/verify.md +6 -3
  51. package/kit/studio/README.md +12 -0
  52. package/kit/studio/server/index.js +114 -11
  53. package/kit/studio/server/lib/crew-map.js +113 -0
  54. package/kit/studio/server/lib/model-outcomes.js +130 -15
  55. package/kit/studio/server/lib/pricing.js +3 -0
  56. package/kit/studio/server/lib/remote.js +99 -0
  57. package/kit/studio/server/lib/usage.js +74 -14
  58. package/kit/studio/web/app.js +214 -12
  59. package/kit/studio/web/canvas.js +44 -120
  60. package/kit/studio/web/graph-plan.js +10 -4
  61. package/kit/studio/web/index.html +10 -5
  62. package/kit/studio/web/map-plan.js +341 -0
  63. package/kit/studio/web/map.js +503 -0
  64. package/kit/studio/web/models-plan.js +61 -7
  65. package/kit/studio/web/models.js +19 -3
  66. package/kit/studio/web/style.css +125 -0
  67. package/kit/studio/web/usage.js +12 -0
  68. package/kit/studio/web/viewport.js +160 -0
  69. package/package.json +6 -5
  70. package/start.sh +10 -0
@@ -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
  }
@@ -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;
@@ -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
+ }
@@ -4,25 +4,41 @@
4
4
  // usage. Measured on one machine before this was written: 23,867 assistant records carried usage, for 11,245
5
5
  // responses, and every repeat carried the same numbers. So a response is counted once, by its message id — and
6
6
  // once across the session's files too, which is what keeps an agent's tokens from being added twice.
7
+ //
8
+ // An advisor call (the advisor tool: the model consults a second one mid-response) is not in that usage. The API
9
+ // keeps it out of the top-level fields, which are the executor's, and lists it in `usage.iterations[]` as an
10
+ // `advisor_message` entry with its own `model`. It is billed at that model's rates, so it is priced on its own.
11
+ // Measured on a real advisor session: the entry is written to the transcript, and repeated, like the rest of the
12
+ // usage, on every record of the response; the message id above counts it once.
7
13
 
8
14
  import fsp from 'node:fs/promises';
9
15
  import path from 'node:path';
10
16
 
11
17
  import { readAll } from './transcript.js';
12
18
  import { agentMetaFiles } from './projects.js';
13
- import { costOf, SOURCE } from './pricing.js';
19
+ import { costOf, SOURCE, NORMAL_TIER_MODEL } from './pricing.js';
14
20
 
15
21
  const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0);
16
22
 
23
+ /**
24
+ * A usage's cache writes by kind. A write the split does not account for is the 5-minute kind: that is the default,
25
+ * and the cheaper of the two. The split can fall short of the total: measured on a real response, the split said
26
+ * 12,400 one-hour and 0 five-minute against a total of 12,670, and the 270 missing were billed as five-minute
27
+ * writes (its iterations listed them so, and Claude Code's own cost for the session matched only with them in).
28
+ */
29
+ function writesOf(u) {
30
+ const split = u.cache_creation && typeof u.cache_creation === 'object' ? u.cache_creation : null;
31
+ const w1h = num(split?.ephemeral_1h_input_tokens);
32
+ const w5m = num(split?.ephemeral_5m_input_tokens);
33
+ return { cacheWrite5m: w5m + Math.max(0, num(u.cache_creation_input_tokens) - w5m - w1h), cacheWrite1h: w1h };
34
+ }
35
+
17
36
  /** One response's usage, in the shape pricing.js prices. Null when the record carries none. */
18
37
  export function usageOfRecord(r) {
19
38
  const m = r?.type === 'assistant' ? r.message : null;
20
39
  const u = m?.usage;
21
40
  if (!u || typeof u !== 'object') return null;
22
- const split = u.cache_creation && typeof u.cache_creation === 'object' ? u.cache_creation : null;
23
- const w1h = num(split?.ephemeral_1h_input_tokens);
24
- // With no split recorded, a cache write is the 5-minute kind: that is the default, and the cheaper of the two.
25
- const w5m = split ? num(split.ephemeral_5m_input_tokens) : num(u.cache_creation_input_tokens);
41
+ const { cacheWrite5m: w5m, cacheWrite1h: w1h } = writesOf(u);
26
42
  return {
27
43
  id: typeof m.id === 'string' ? m.id : null,
28
44
  model: typeof m.model === 'string' ? m.model : null,
@@ -33,9 +49,28 @@ export function usageOfRecord(r) {
33
49
  cacheRead: num(u.cache_read_input_tokens),
34
50
  fast: u.speed === 'fast',
35
51
  usOnly: u.inference_geo === 'us',
52
+ advisor: advisorCallsOf(u),
36
53
  };
37
54
  }
38
55
 
56
+ /** The advisor calls one response's usage lists, each in the shape pricing.js prices: [] when it made none. */
57
+ export function advisorCallsOf(u) {
58
+ const out = [];
59
+ for (const it of Array.isArray(u?.iterations) ? u.iterations : []) {
60
+ if (it?.type !== 'advisor_message') continue;
61
+ out.push({
62
+ model: typeof it.model === 'string' ? it.model : null,
63
+ input: num(it.input_tokens),
64
+ output: num(it.output_tokens),
65
+ ...writesOf(it),
66
+ cacheRead: num(it.cache_read_input_tokens),
67
+ fast: false,
68
+ usOnly: u.inference_geo === 'us',
69
+ });
70
+ }
71
+ return out;
72
+ }
73
+
39
74
  const KINDS = ['input', 'output', 'cacheWrite', 'cacheRead'];
40
75
 
41
76
  /** One running sum: tokens by kind, what they cost, and which models had no price. */
@@ -43,25 +78,29 @@ class Tally {
43
78
  constructor() {
44
79
  this.tokens = { input: 0, output: 0, cacheWrite: 0, cacheRead: 0 };
45
80
  this.usd = 0;
81
+ this.usdAtNormal = 0; // the same tokens at NORMAL_TIER_MODEL's price
46
82
  this.responses = 0;
47
83
  this.unpriced = new Set();
48
84
  }
49
85
 
50
- take(u, usd) {
51
- this.responses += 1;
86
+ /** @param response false for an advisor call: its tokens and cost are added, but it is part of a response, not one. */
87
+ take(u, usd, atNormal = null, response = true) {
88
+ if (response) this.responses += 1;
52
89
  this.tokens.input += u.input;
53
90
  this.tokens.output += u.output;
54
91
  this.tokens.cacheWrite += u.cacheWrite5m + u.cacheWrite1h;
55
92
  this.tokens.cacheRead += u.cacheRead;
56
93
  if (usd === null) this.unpriced.add(u.model ?? 'unknown model');
57
94
  else this.usd += usd;
95
+ if (atNormal !== null) this.usdAtNormal += atNormal;
58
96
  }
59
97
 
60
98
  /**
61
99
  * `fresh` is what was sent and written for the first time: input, output and cache writes. `cacheRead` is the
62
100
  * same context read back from the cache on every later response, which is why it dwarfs the rest in a long
63
101
  * session and is kept out of the headline. `cost` is null when any of the tokens came from a model the price
64
- * table has no row for — a sum that leaves some of them out would read as the whole.
102
+ * table has no row for — a sum that leaves some of them out would read as the whole. `costAtNormal` is the same
103
+ * tokens at NORMAL_TIER_MODEL's list price, null under the same rule.
65
104
  */
66
105
  result() {
67
106
  const t = this.tokens;
@@ -69,6 +108,7 @@ class Tally {
69
108
  tokens: { ...t, fresh: t.input + t.output + t.cacheWrite, total: KINDS.reduce((a, k) => a + t[k], 0) },
70
109
  responses: this.responses,
71
110
  cost: this.unpriced.size ? null : this.usd,
111
+ costAtNormal: this.unpriced.size ? null : this.usdAtNormal,
72
112
  unpriced: [...this.unpriced].sort(),
73
113
  };
74
114
  }
@@ -80,6 +120,9 @@ export class Usage {
80
120
  this.all = new Tally();
81
121
  this.who = new Map(); // 'session', or an agent's id -> Tally
82
122
  this.model = new Map(); // model id -> Tally
123
+ this.advisor = new Tally(); // the advisor calls alone, one take per call
124
+ this.advisorModels = new Map(); // advisor model id -> Tally
125
+ this.advisorWho = new Map(); // 'session' or an agent's id -> Tally of its advisor calls alone
83
126
  this.first = null; // the earliest and the latest record of any file added
84
127
  this.last = null;
85
128
  }
@@ -104,11 +147,22 @@ export class Usage {
104
147
  const u = usageOfRecord(r);
105
148
  if (!u) continue;
106
149
  if (u.id) { if (this.seen.has(u.id)) continue; this.seen.add(u.id); }
107
- if (!(u.input + u.output + u.cacheWrite5m + u.cacheWrite1h + u.cacheRead)) continue; // a record the harness made up carries no tokens
108
- const usd = costOf(u);
109
- this.all.take(u, usd);
110
- this.#tally(this.who, who === 'session' && r.isSidechain === true ? 'agents:inline' : who).take(u, usd);
111
- this.#tally(this.model, u.model ?? 'unknown model').take(u, usd);
150
+ if (!(u.input + u.output + u.cacheWrite5m + u.cacheWrite1h + u.cacheRead) && !u.advisor.length) continue; // a record the harness made up carries no tokens
151
+ const mine = this.#tally(this.who, who === 'session' && r.isSidechain === true ? 'agents:inline' : who);
152
+ // The response, then each advisor call it made, priced at the advisor's own model.
153
+ for (const x of [u, ...u.advisor]) {
154
+ const usd = costOf(x);
155
+ const atNormal = costOf({ ...x, model: NORMAL_TIER_MODEL });
156
+ const own = x === u;
157
+ this.all.take(x, usd, atNormal, own);
158
+ mine.take(x, usd, atNormal, own);
159
+ this.#tally(this.model, x.model ?? 'unknown model').take(x, usd, atNormal, own);
160
+ if (!own) { // here each call is one
161
+ this.advisor.take(x, usd, atNormal);
162
+ this.#tally(this.advisorWho, who === 'session' && r.isSidechain === true ? 'agents:inline' : who).take(x, usd, atNormal);
163
+ this.#tally(this.advisorModels, x.model ?? 'unknown model').take(x, usd, atNormal);
164
+ }
165
+ }
112
166
  }
113
167
  return this;
114
168
  }
@@ -134,6 +188,9 @@ export class Usage {
134
188
  ...all,
135
189
  models: [...this.model.keys()].filter((m) => !all.unpriced.includes(m)).sort(),
136
190
  parts: { session: (this.who.get('session') ?? new Tally()).result(), agents: agents.result() },
191
+ // Null when no advisor call is recorded: a session without one has no advisor line, not a zero.
192
+ advisor: this.advisor.responses ? { ...this.advisor.result(), calls: this.advisor.responses } : null,
193
+ advisorModels: Object.fromEntries([...this.advisorModels].map(([m, t]) => [m, { ...t.result(), calls: t.responses }])),
137
194
  byModel: Object.fromEntries([...this.model].map(([m, t]) => [m, t.result()]).sort((a, b) => b[1].tokens.total - a[1].tokens.total)),
138
195
  startedAt: this.first,
139
196
  lastAt: this.last,
@@ -192,7 +249,10 @@ export async function sessionUsage(session) {
192
249
  for (const [who, t] of usage.who) {
193
250
  if (who === 'session' || who.startsWith('agents:')) continue;
194
251
  const r = t.result();
195
- agents[who] = { fresh: r.tokens.fresh, cacheRead: r.tokens.cacheRead, cost: r.cost };
252
+ // What of it was the advisor: its calls and their cost, already in `cost`. Zero calls when it consulted none.
253
+ const adv = usage.advisorWho.get(who)?.result() ?? null;
254
+ agents[who] = { fresh: r.tokens.fresh, cacheRead: r.tokens.cacheRead, cost: r.cost, costAtNormal: r.costAtNormal,
255
+ advisorCalls: adv?.responses ?? 0, advisorCost: adv?.cost ?? null };
196
256
  }
197
257
  return { ...withTimes(usage, records), agents };
198
258
  }