crewforth 3.1.1 → 3.2.0-rc.10

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 (110) hide show
  1. package/CHANGELOG.md +323 -0
  2. package/README.md +6 -3
  3. package/VERSION +1 -1
  4. package/adopt.sh +66 -6
  5. package/bin/cli.js +3 -2
  6. package/kit/AGENT_TEMPLATE.md +2 -1
  7. package/kit/CLAUDE.md +15 -14
  8. package/kit/agents/crew-commit-agent.md +11 -4
  9. package/kit/agents/crew-docs-agent.md +71 -0
  10. package/kit/agents/crew-planner.md +2 -0
  11. package/kit/agents/crew-refuter.md +77 -0
  12. package/kit/agents/crew-review-agent.md +2 -0
  13. package/kit/agents/crew-security-expert.md +3 -1
  14. package/kit/agents/crew-session-manager.md +11 -9
  15. package/kit/eval/doctor.sh +128 -6
  16. package/kit/eval/gate-report.sh +2 -0
  17. package/kit/eval/golden-routing.txt +9 -0
  18. package/kit/eval/lib/crew-map/crew-map.sh +26 -0
  19. package/kit/eval/lib/crew-map/detect.mjs +238 -0
  20. package/kit/eval/lib/crew-map/narrative.mjs +132 -0
  21. package/kit/eval/lib/crew-map/parse.mjs +305 -0
  22. package/kit/eval/lib/crew-map/project.mjs +164 -0
  23. package/kit/eval/lib/crew-map/rules/names.json +38 -0
  24. package/kit/eval/lib/crew-map/rules/packages.json +142 -0
  25. package/kit/eval/lib/crew-map/rules/python-stdlib.json +197 -0
  26. package/kit/eval/lib/crew-map/rules/tech.json +35 -0
  27. package/kit/eval/lib/crew-map/scan.mjs +266 -0
  28. package/kit/eval/lib/crew-map/stacks.mjs +547 -0
  29. package/kit/eval/lib/crew-map/surface.mjs +535 -0
  30. package/kit/eval/lib/findings/findings.mjs +410 -0
  31. package/kit/eval/lib/findings/findings.schema.json +122 -0
  32. package/kit/eval/lib/findings/findings.sh +29 -0
  33. package/kit/eval/lib/gate-log.sh +52 -0
  34. package/kit/eval/lib/json-string.awk +27 -0
  35. package/kit/eval/lib/preset.sh +109 -0
  36. package/kit/eval/lib/presets/default.json +7 -0
  37. package/kit/eval/lib/presets/solo.json +7 -0
  38. package/kit/eval/lib/presets/strict.json +11 -0
  39. package/kit/eval/lib/preview.sh +150 -0
  40. package/kit/eval/lib/review-bench/bench.mjs +273 -0
  41. package/kit/eval/lib/review-prepass/prepass.mjs +203 -0
  42. package/kit/eval/lib/review-prepass/prepass.sh +23 -0
  43. package/kit/eval/lib/settings-default.sh +36 -0
  44. package/kit/eval/lib/settings-json.awk +19 -4
  45. package/kit/eval/lib/star.sh +23 -7
  46. package/kit/eval/lib/statusline.settings.json +6 -0
  47. package/kit/eval/lib/statusline.sh +100 -0
  48. package/kit/eval/lib/verify-cmd.sh +17 -0
  49. package/kit/eval/lib/version.sh +41 -0
  50. package/kit/eval/review-bench.sh +26 -0
  51. package/kit/eval/smoke-test.sh +878 -53
  52. package/kit/hooks/agent-outcome.sh +44 -12
  53. package/kit/hooks/board-sync.sh +2 -23
  54. package/kit/hooks/guard-agent-model.sh +177 -24
  55. package/kit/hooks/guard-bash.sh +295 -204
  56. package/kit/hooks/guard-commit-scan.sh +10 -2
  57. package/kit/hooks/guard-question.sh +73 -0
  58. package/kit/hooks/guard-write.sh +24 -7
  59. package/kit/hooks/precompact-snapshot.sh +75 -0
  60. package/kit/hooks/prompt-approval.sh +389 -103
  61. package/kit/hooks/refuter-ledger.sh +80 -0
  62. package/kit/hooks/session-rehydrate.sh +25 -2
  63. package/kit/hooks/session-update-check.sh +9 -8
  64. package/kit/hooks/trace-blocklist.txt +9 -0
  65. package/kit/owned-blobs.tsv +4 -4
  66. package/kit/settings.json +51 -0
  67. package/kit/skills/crew-approve/SKILL.md +7 -0
  68. package/kit/skills/crew-code-review/SKILL.md +16 -0
  69. package/kit/skills/crew-doctor/SKILL.md +5 -1
  70. package/kit/skills/crew-handoff/SKILL.md +3 -2
  71. package/kit/skills/crew-map/SKILL.md +55 -0
  72. package/kit/skills/crew-review/SKILL.md +4 -1
  73. package/kit/skills/crew-ship/SKILL.md +3 -1
  74. package/kit/skills/crew-update/SKILL.md +1 -1
  75. package/kit/skills/incident-runbook/SKILL.md +2 -2
  76. package/kit/skills/security-scan/references/verify.md +6 -3
  77. package/kit/studio/README.md +25 -2
  78. package/kit/studio/server/index.js +187 -21
  79. package/kit/studio/server/lib/code-stamp.js +35 -0
  80. package/kit/studio/server/lib/crew-map.js +113 -0
  81. package/kit/studio/server/lib/graph.js +9 -1
  82. package/kit/studio/server/lib/kit-telemetry.js +30 -5
  83. package/kit/studio/server/lib/lan.js +87 -0
  84. package/kit/studio/server/lib/model-outcomes.js +130 -15
  85. package/kit/studio/server/lib/owned-stream.js +56 -0
  86. package/kit/studio/server/lib/pricing.js +3 -0
  87. package/kit/studio/server/lib/projects.js +11 -4
  88. package/kit/studio/server/lib/redact.js +60 -0
  89. package/kit/studio/server/lib/remote.js +185 -0
  90. package/kit/studio/server/lib/session.js +1 -1
  91. package/kit/studio/server/lib/terminal.js +51 -13
  92. package/kit/studio/server/lib/transcript.js +32 -0
  93. package/kit/studio/server/lib/usage.js +74 -14
  94. package/kit/studio/web/app.js +287 -17
  95. package/kit/studio/web/canvas.js +58 -122
  96. package/kit/studio/web/chat.js +114 -24
  97. package/kit/studio/web/graph-plan.js +13 -6
  98. package/kit/studio/web/index.html +10 -5
  99. package/kit/studio/web/liveness.js +14 -0
  100. package/kit/studio/web/map-plan.js +410 -0
  101. package/kit/studio/web/map.js +596 -0
  102. package/kit/studio/web/models-plan.js +61 -7
  103. package/kit/studio/web/models.js +19 -3
  104. package/kit/studio/web/qr.js +200 -0
  105. package/kit/studio/web/style.css +165 -0
  106. package/kit/studio/web/tokens.css +6 -0
  107. package/kit/studio/web/usage.js +12 -0
  108. package/kit/studio/web/viewport.js +160 -0
  109. package/package.json +6 -5
  110. package/start.sh +24 -1
@@ -0,0 +1,87 @@
1
+ // lan.js — the listener on this machine's Wi-Fi address, for viewers on the same network.
2
+ //
3
+ // It exists only while a viewer does: index.js calls sync() with the number of live viewers, and with none it is
4
+ // closed and every connection on it is ended, so nothing beyond 127.0.0.1 is listening. Every request it takes is
5
+ // marked `crewLan`, and the request gate (index.js) answers such a request only for a viewer's token — the panel's
6
+ // own token is not accepted from the network.
7
+ //
8
+ // Which address: the private IPv4 addresses of this machine's interfaces (10/8, 172.16/12, 192.168/16): the ranges a
9
+ // home or office Wi-Fi hands out. A public, link-local or carrier-grade address is not listened on. There is no
10
+ // tunnel here and no way to reach the panel from outside the network.
11
+ import http from 'node:http';
12
+ import os from 'node:os';
13
+
14
+ const PRIVATE = (a) => /^10\./.test(a) || /^192\.168\./.test(a) || /^172\.(1[6-9]|2\d|3[01])\./.test(a);
15
+
16
+ /** This machine's private IPv4 addresses. CREW_STUDIO_LAN_ADDR names one instead (the self-check uses loopback). */
17
+ export function lanAddresses(env = process.env, ifaces = os.networkInterfaces()) {
18
+ if (env.CREW_STUDIO_LAN_ADDR) return [env.CREW_STUDIO_LAN_ADDR];
19
+ const out = [];
20
+ for (const list of Object.values(ifaces ?? {})) {
21
+ for (const i of list ?? []) {
22
+ const v4 = i.family === 'IPv4' || i.family === 4;
23
+ if (v4 && !i.internal && PRIVATE(i.address) && !out.includes(i.address)) out.push(i.address);
24
+ }
25
+ }
26
+ return out;
27
+ }
28
+
29
+ /**
30
+ * The listener, opened and closed by the number of live viewers.
31
+ * @param handler the server's own request handler; a request is marked `req.crewLan = true` before it is called
32
+ * @param port the panel's port (CREW_STUDIO_LAN_PORT overrides it, for the self-check)
33
+ */
34
+ export function lanListener({ handler, port, env = process.env, addresses = null }) {
35
+ let servers = [];
36
+ let errors = [];
37
+ let pending = null;
38
+ const lanPort = env.CREW_STUDIO_LAN_PORT !== undefined ? Number(env.CREW_STUDIO_LAN_PORT) : port;
39
+ // The port actually listened on: the asked one, or the one the system picked for 0 (the self-check).
40
+ const portOf = (e) => e.server.address()?.port ?? lanPort;
41
+
42
+ const open = () => {
43
+ const addrs = addresses ?? lanAddresses(env);
44
+ errors = [];
45
+ servers = addrs.map((addr) => {
46
+ const sockets = new Set();
47
+ const s = http.createServer((req, res) => { req.crewLan = true; handler(req, res); });
48
+ s.on('connection', (c) => { sockets.add(c); c.on('close', () => sockets.delete(c)); });
49
+ const entry = { addr, server: s, sockets, listening: false };
50
+ s.on('error', (e) => { errors.push(`${addr}: ${e.code ?? e.message}`); entry.listening = false; });
51
+ s.listen(lanPort, addr, () => { entry.listening = true; });
52
+ return entry;
53
+ });
54
+ return new Promise((resolve) => { let n = servers.length; if (!n) resolve(); for (const e of servers) { e.server.once('listening', () => --n || resolve()); e.server.once('error', () => --n || resolve()); } });
55
+ };
56
+
57
+ const close = () => {
58
+ const was = servers; servers = [];
59
+ return Promise.all(was.map((e) => new Promise((resolve) => {
60
+ for (const c of e.sockets) c.destroy();
61
+ if (!e.listening) return resolve();
62
+ e.server.close(() => resolve());
63
+ })));
64
+ };
65
+
66
+ return {
67
+ /** Open with at least one live viewer, close with none. Resolves once the change has happened. */
68
+ sync(liveViewers) {
69
+ pending = (pending ?? Promise.resolve()).then(() => {
70
+ if (liveViewers > 0 && !servers.length) return open();
71
+ if (liveViewers === 0 && servers.length) return close();
72
+ return undefined;
73
+ });
74
+ return pending;
75
+ },
76
+ state() {
77
+ const on = servers.filter((e) => e.listening);
78
+ return { listening: on.length > 0, port: on.length ? portOf(on[0]) : lanPort,
79
+ addresses: servers.filter((e) => e.listening).map((e) => e.addr), errors: [...errors] };
80
+ },
81
+ /** The link a viewer opens on the phone, one per address listened on. */
82
+ urls(token) {
83
+ return servers.filter((e) => e.listening).map((e) => `http://${e.addr}:${portOf(e)}/?token=${encodeURIComponent(token)}`);
84
+ },
85
+ close,
86
+ };
87
+ }
@@ -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,60 @@
1
+ // redact.js — what a viewer whose link hides transcripts is sent.
2
+ //
3
+ // Text that came from a transcript (a prompt, a reply, a tool's input or output, the short task an agent was given,
4
+ // a session's title) is not sent to such a viewer at all. Hiding it in the page would not do: it would still be on
5
+ // the wire and in the phone's memory.
6
+ //
7
+ // AN ALLOW-LIST, NOT A DENY-LIST. Transcript text only ever travels as a string, so every string in an answer is
8
+ // dropped unless its key is one of the few below, which name things that do not come from a transcript: ids,
9
+ // states, model and agent-type names, tool names, times as text. A field added to an answer tomorrow is therefore
10
+ // hidden until someone decides it is safe and adds its key here. Numbers, booleans and the shape of an answer pass,
11
+ // so the viewer still sees the agents, their states, their timing and what they cost.
12
+ //
13
+ // A few routes are nothing but transcript (a conversation, an agent's detail, the live conversation of a session
14
+ // the panel runs): those are refused outright rather than sent empty.
15
+
16
+ /** Keys whose string values are not transcript text. Checked by key name, wherever in the answer they are. */
17
+ export const SAFE_STRING_KEYS = new Set([
18
+ // identity and links between things
19
+ 'id', 'sessionId', 'agentId', 'parentId', 'toolUseId', 'workflowId', 'workflow', 'source', 'target', 'origin',
20
+ // kinds and states
21
+ 'kind', 'status', 'launchStatus', 'trigger', 'role', 'reason',
22
+ // which model and which kind of agent, and which tool (a tool's NAME, never its input)
23
+ 'model', 'modelAsked', 'models', 'unpriced', 'agentType', 'toolName', 'lastTool', 'version',
24
+ // where, as the machine knows it rather than as anybody wrote it
25
+ 'cwd', 'gitBranch', 'dir', 'url', 'read', 'at',
26
+ ]);
27
+
28
+ // `label` is safe only where the server writes it from one of the safe things above (a session's short id, an agent
29
+ // type, 'workflow'); measured in lib/graph.js. Anywhere else it is dropped like any other string.
30
+ const LABEL_SAFE_KINDS = new Set(['session', 'agent', 'workflow']);
31
+
32
+ /** Routes that are nothing but transcript: refused for a viewer whose link hides transcripts. */
33
+ export function transcriptOnly(pathname) {
34
+ return /^\/api\/session\/[^/]+\/(conversation|agent\/[^/]+)$/.test(pathname)
35
+ || pathname === '/api/owned' || pathname === '/api/owned-events' || /^\/api\/owned\/[^/]+(\/.*)?$/.test(pathname);
36
+ }
37
+
38
+ /** The answer with every string that may be transcript text removed. A new object; the input is not touched. */
39
+ export function redact(value, key = null, parent = null) {
40
+ if (typeof value === 'string') {
41
+ if (SAFE_STRING_KEYS.has(key)) return value;
42
+ if (key === 'label' && parent && LABEL_SAFE_KINDS.has(parent.kind)) return value;
43
+ // A machine's name (an origin: no sessionId of its own). A session's `name` is its title, written from its
44
+ // first prompt, and is dropped.
45
+ if (key === 'name' && parent && parent.sessionId === undefined && typeof parent.local === 'boolean') return value;
46
+ return undefined;
47
+ }
48
+ if (Array.isArray(value)) {
49
+ return value.map((x) => redact(x, key, parent)).filter((x) => x !== undefined);
50
+ }
51
+ if (value && typeof value === 'object') {
52
+ const out = {};
53
+ for (const [k, v] of Object.entries(value)) {
54
+ const r = redact(v, k, value);
55
+ if (r !== undefined) out[k] = r;
56
+ }
57
+ return out;
58
+ }
59
+ return value;
60
+ }