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
@@ -52,15 +52,25 @@ function stepOf(r, again) {
52
52
  * How the work went, as steps: the earlier calls of the same card, then this one. `row` is Crewforth's line for
53
53
  * the agent with `before` (see server/lib/model-outcomes.js), or null when it has none: it has not stopped, or it
54
54
  * is not a crew agent.
55
- * @returns { risk, steps: [{ text, verify, tone, escalated }], note }
55
+ * @returns { risk, riskWhy, steps: [{ text, verify, tone, escalated }], note }
56
56
  */
57
57
  export function workSteps(row) {
58
- if (!row) return { risk: null, steps: [], note: 'Not recorded yet: the line is written when the agent stops.' };
58
+ if (!row) return { risk: null, riskWhy: null, steps: [], note: 'Not recorded yet: the line is written when the agent stops.' };
59
59
  const steps = [...(row.before ?? []).map((r) => stepOf(r, false)), stepOf(row, Boolean(row.escalatedFrom))];
60
60
  if (row.escalatedFrom && !(row.before ?? []).length) {
61
61
  steps.unshift({ text: `An earlier call on ${tierWord(row.escalatedFrom)} did not pass; its line is not in the record`, verify: null, tone: null, escalated: false });
62
62
  }
63
- return { risk: row.risk ?? null, steps, note: row.escalatedFrom ? 'A re-run with the same or a lower model is refused by the gate.' : null };
63
+ // The reason is the gate's own sentence, kept as it wrote it; a line written before the gate kept one has none.
64
+ return { risk: row.risk ?? null, riskWhy: row.risk === 'critical' ? row.riskWhy ?? null : null, steps, note: row.escalatedFrom ? 'A re-run with the same or a lower model is refused by the gate.' : null };
65
+ }
66
+
67
+ /**
68
+ * What is said about an agent the model gate does not look at: one whose type is not crew-* (hooks/guard-agent-model.sh
69
+ * leaves it alone), so there is no card to read and no line in the record. Null for a crew agent or an unknown type.
70
+ */
71
+ export function outsideRouting(agentType) {
72
+ if (typeof agentType !== 'string' || !agentType || agentType.startsWith('crew-')) return null;
73
+ return 'Outside model routing: not a crew agent, so the gate chose nothing for it and Crewforth kept no record of it. It ran on the model its call asked for, or else the session\u2019s.';
64
74
  }
65
75
 
66
76
  /**
@@ -85,17 +95,31 @@ export function floorWords(floor) {
85
95
  * ("\u2265$0.75 \u00b7 2/7"), because a sum of two calls must not be read as the cost of seven. None: a dash.
86
96
  */
87
97
  export function costCell(cost, runs) {
88
- if (!cost) return { cost: '\u2026', costNote: 'Reading what this class cost\u2026', partial: false };
89
- if (!cost.costed) return { cost: '\u2014', costNote: 'No agent of this class has a transcript on this machine that could be priced', partial: false };
98
+ if (!cost) return { cost: '\u2026', costNote: 'Reading what this class cost\u2026', partial: false, advisor: null };
99
+ if (!cost.costed) return { cost: '\u2014', costNote: 'No agent of this class has a transcript on this machine that could be priced', partial: false, advisor: null };
90
100
  const usd = fmtCost(cost.cost);
91
- if (cost.costed >= runs) return { cost: usd, costNote: `All ${runs} ${runs === 1 ? 'task' : 'tasks'} priced, at API list price. An estimate: what a subscription is charged is not known here.`, partial: false };
101
+ const advisor = advisorWords(cost);
102
+ const tail = advisor ? `\n${advisor}` : '';
103
+ if (cost.costed >= runs) return { cost: usd, costNote: `All ${runs} ${runs === 1 ? 'task' : 'tasks'} priced, at API list price. An estimate: what a subscription is charged is not known here.${tail}`, partial: false, advisor };
92
104
  return {
93
105
  cost: `\u2265${usd.replace(/^~/, '')} \u00b7 ${cost.costed}/${runs}`,
94
- costNote: `At least this much: ${cost.costed} of ${runs} tasks could be priced; the others have no transcript on this machine. At API list price.`,
106
+ costNote: `At least this much: ${cost.costed} of ${runs} tasks could be priced; the others have no transcript on this machine. At API list price.${tail}`,
95
107
  partial: true,
108
+ advisor,
96
109
  };
97
110
  }
98
111
 
112
+ /**
113
+ * What of a class's cost was the advisor (a second model its agents consulted mid-task), or null when they consulted
114
+ * none: no call is no tag, not a zero. Its share is already in the class's cost; this only says how much it was.
115
+ */
116
+ export function advisorWords(cost) {
117
+ const n = cost?.advisorCalls ?? 0;
118
+ if (!(n > 0)) return null;
119
+ const share = typeof cost.advisorCost === 'number' && cost.advisorCost > 0 ? `${fmtCost(cost.advisorCost)} of it` : 'its cost not priced';
120
+ return `Advisor: ${n} ${n === 1 ? 'call' : 'calls'}, ${share}. A second model the agents consulted, at that model's rates; included above.`;
121
+ }
122
+
99
123
  /** One class as the view's row: who and what kind of change, how risky, what ran it, and how it went. */
100
124
  export function classRow(c, cost = null) {
101
125
  const models = Object.entries(c.models ?? {}).sort((a, b) => b[1].runs - a[1].runs).map(([t, m]) => `${tierWord(t)} ${m.runs}`).join(' \u00b7 ');
@@ -146,6 +170,36 @@ export function suggestionText(s) {
146
170
  };
147
171
  }
148
172
 
173
+ /**
174
+ * Normal work that ran above the table's model, in words. The gate holds a floor, not a ceiling, so this is said
175
+ * and nothing is blocked. That the table's model would have done is said only from the record (`a.passed`); the
176
+ * cost beside it is the same tokens at that model's price, which is not what that model would have spent.
177
+ * @param a one class of server/lib/model-outcomes.js `aboveTableOf`
178
+ * @param cost { cost, atNormal, costed, runs } for it, or null while costs have not been read
179
+ */
180
+ export function aboveText(a, cost = null) {
181
+ const to = tierWord(a.to);
182
+ const ran = Object.entries(a.models ?? {}).sort((x, y) => y[1] - x[1]).map(([t, n]) => `${n} on ${tierWord(t)}`).join(', ');
183
+ let costs = null;
184
+ if (cost?.costed) {
185
+ const some = cost.costed < cost.runs;
186
+ const usd = (v) => (some ? `\u2265${fmtCost(v).replace(/^~/, '')}` : fmtCost(v));
187
+ costs = `${usd(cost.cost)} as run; the same tokens at ${to}'s price ${usd(cost.atNormal)}${some ? ` (${cost.costed} of ${cost.runs} priced)` : ''}. API list price; another model would not spend the same tokens.`;
188
+ }
189
+ return {
190
+ title: `${roleName(a.agent)} \u00b7 ${a.change}`,
191
+ says: `normal risk, ${plural(a.runs, 'task')} above ${to}: ${ran}.`,
192
+ verdict: a.passed
193
+ ? `${to} is enough for this class: its latest ${a.passed} verified calls on ${to} all passed the first time.`
194
+ : `Whether ${to} would have done is not measured: this class does not have ${a.need ?? 'enough'} verified calls on ${to} that all passed the first time.`,
195
+ measured: Boolean(a.passed),
196
+ costs,
197
+ };
198
+ }
199
+
200
+ export const ABOVE_HEAD = 'Above the table \u00b7 nothing is blocked';
201
+ export const ABOVE_RULE = 'The gate holds a floor, not a ceiling: which model runs normal work is yours to choose.';
202
+
149
203
  /** What the view says when there is nothing to list. */
150
204
  export function emptyNote(data) {
151
205
  if (!data) return 'Reading the record…';
@@ -3,8 +3,9 @@
3
3
  // A class is an agent, a kind of change and a risk. Each line says what ran it, how many tasks, how many of the
4
4
  // verified ones passed the first time, how many were repeated one model up, and what the agents' tokens come to.
5
5
  // Under the table: the holds the record could let go of. They are shown, not acted on: the one button copies
6
- // Crewforth's command for it and lowers nothing.
7
- import { classRow, suggestionText, emptyNote, totalsLine, costLine, SOURCE, LOWER_RULE } from './models-plan.js';
6
+ // Crewforth's command for it and lowers nothing. Then the normal work that ran above the table's model: said, with
7
+ // the record's evidence where there is some, and not acted on either.
8
+ import { classRow, suggestionText, aboveText, emptyNote, totalsLine, costLine, SOURCE, LOWER_RULE, ABOVE_HEAD, ABOVE_RULE } from './models-plan.js';
8
9
 
9
10
  function mk(tag, cls, text) {
10
11
  const n = document.createElement(tag);
@@ -26,7 +27,7 @@ export class Models {
26
27
  }
27
28
 
28
29
  setData(data) { this.data = data; this.render(); }
29
- /** What the classes cost, read apart and less often: { measured, costs: { classKey: { cost, costed, runs } } }. */
30
+ /** What the classes cost, read apart and less often: { measured, costs: { classKey: { cost, costed, runs } }, above }. */
30
31
  setCosts(costs) { this.costs = costs; this.render(); }
31
32
 
32
33
  render() {
@@ -88,6 +89,7 @@ export class Models {
88
89
  const cost = mk('td', 'mv-num', r.cost);
89
90
  cost.title = r.costNote;
90
91
  if (r.partial) cost.dataset.partial = 'true';
92
+ if (r.advisor) { const t = mk('span', 'mv-tag', 'advisor'); t.dataset.kind = 'advisor'; t.title = r.advisor; cost.append(t); }
91
93
  tr.append(who, risk, model, mk('td', 'mv-num', r.tasks), first, mk('td', 'mv-num', r.escalated), cost);
92
94
  tbody.append(tr);
93
95
  }
@@ -115,6 +117,20 @@ export class Models {
115
117
  }
116
118
  pane.append(box);
117
119
  }
120
+ if (d.above?.length) {
121
+ const box = mk('section', 'mv-above');
122
+ box.append(mk('h3', 'mv-suggest-h', ABOVE_HEAD));
123
+ for (const a of d.above) {
124
+ const t = aboveText(a, this.costs?.measured ? (this.costs.above?.[a.key] ?? null) : null);
125
+ box.append(mk('p', 'mv-suggest-text', `${t.title} ${t.says}`));
126
+ const v = mk('p', 'mv-above-verdict', t.verdict);
127
+ v.dataset.measured = String(t.measured);
128
+ box.append(v);
129
+ if (t.costs) box.append(mk('p', 'sub', t.costs));
130
+ }
131
+ box.append(mk('p', 'sub', ABOVE_RULE));
132
+ pane.append(box);
133
+ }
118
134
  for (const note of raisedNotes) {
119
135
  const row = mk('div', 'mv-raised');
120
136
  const tag = mk('span', 'mv-tag', note.tag);
@@ -0,0 +1,200 @@
1
+ // qr.js — a QR code for a viewer's link, made in the page: no dependency, no request.
2
+ //
3
+ // Byte mode, error correction level M (a phone reads it off a laptop screen at an angle), versions 1 to 10: up to
4
+ // 213 bytes, which a Wi-Fi link with a token (about 70) fits in with room to spare. The construction is ISO/IEC
5
+ // 18004's, in the order Project Nayuki's reference implementation lays it out: data and padding, Reed–Solomon error
6
+ // correction per block, interleaving, the function patterns, the codewords in their zigzag, the eight masks scored by
7
+ // the standard's four penalty rules, and the format (and, from version 7, version) bits. Checked against an
8
+ // independent decoder (OpenCV's QRCodeDetector) on every version and mask; the self-check pins the result.
9
+
10
+ const ECC_M = { perBlock: [-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26], blocks: [-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5], formatBits: 0 };
11
+ const MAX_VERSION = 10;
12
+
13
+ function rawModules(ver) {
14
+ let n = (16 * ver + 128) * ver + 64;
15
+ if (ver >= 2) {
16
+ const align = Math.floor(ver / 7) + 2;
17
+ n -= (25 * align - 10) * align - 55;
18
+ if (ver >= 7) n -= 36;
19
+ }
20
+ return n;
21
+ }
22
+ const dataCodewords = (ver) => Math.floor(rawModules(ver) / 8) - ECC_M.perBlock[ver] * ECC_M.blocks[ver];
23
+
24
+ // GF(256) with the QR polynomial x^8 + x^4 + x^3 + x^2 + 1.
25
+ function mul(x, y) {
26
+ let z = 0;
27
+ for (let i = 7; i >= 0; i--) { z = (z << 1) ^ ((z >>> 7) * 0x11d); z ^= ((y >>> i) & 1) * x; }
28
+ return z & 0xff;
29
+ }
30
+ function divisor(degree) {
31
+ const r = new Array(degree).fill(0); r[degree - 1] = 1;
32
+ let root = 1;
33
+ for (let i = 0; i < degree; i++) {
34
+ for (let j = 0; j < r.length; j++) { r[j] = mul(r[j], root); if (j + 1 < r.length) r[j] ^= r[j + 1]; }
35
+ root = mul(root, 0x02);
36
+ }
37
+ return r;
38
+ }
39
+ function remainder(data, div) {
40
+ const r = div.map(() => 0);
41
+ for (const b of data) {
42
+ const f = b ^ r.shift(); r.push(0);
43
+ div.forEach((c, i) => { r[i] ^= mul(c, f); });
44
+ }
45
+ return r;
46
+ }
47
+
48
+ function alignmentPositions(ver) {
49
+ if (ver === 1) return [];
50
+ const n = Math.floor(ver / 7) + 2;
51
+ const size = ver * 4 + 17;
52
+ const step = Math.ceil((ver * 4 + 4) / (n * 2 - 2)) * 2;
53
+ const out = [6];
54
+ for (let pos = size - 7; out.length < n; pos -= step) out.splice(1, 0, pos);
55
+ return out;
56
+ }
57
+
58
+ /** The bytes of `text` (UTF-8) as the codewords of the smallest version that holds them, error correction included. */
59
+ function codewords(bytes) {
60
+ let ver = 1;
61
+ for (; ver <= MAX_VERSION; ver++) {
62
+ const cap = dataCodewords(ver) * 8;
63
+ if (4 + (ver < 10 ? 8 : 16) + bytes.length * 8 <= cap) break;
64
+ }
65
+ if (ver > MAX_VERSION) throw new Error(`qr: ${bytes.length} bytes do not fit version ${MAX_VERSION}`);
66
+ const bits = [];
67
+ const put = (v, n) => { for (let i = n - 1; i >= 0; i--) bits.push((v >>> i) & 1); };
68
+ put(0b0100, 4); put(bytes.length, ver < 10 ? 8 : 16);
69
+ for (const b of bytes) put(b, 8);
70
+ const cap = dataCodewords(ver) * 8;
71
+ put(0, Math.min(4, cap - bits.length));
72
+ put(0, (8 - (bits.length % 8)) % 8);
73
+ for (let pad = 0xec; bits.length < cap; pad ^= 0xec ^ 0x11) put(pad, 8);
74
+ const data = [];
75
+ for (let i = 0; i < bits.length; i += 8) data.push(bits.slice(i, i + 8).reduce((a, b) => (a << 1) | b, 0));
76
+
77
+ const nBlocks = ECC_M.blocks[ver]; const eccLen = ECC_M.perBlock[ver];
78
+ const raw = Math.floor(rawModules(ver) / 8);
79
+ const nShort = nBlocks - (raw % nBlocks); const shortLen = Math.floor(raw / nBlocks);
80
+ const div = divisor(eccLen);
81
+ const blocks = [];
82
+ for (let i = 0, k = 0; i < nBlocks; i++) {
83
+ const d = data.slice(k, k + shortLen - eccLen + (i < nShort ? 0 : 1)); k += d.length;
84
+ const e = remainder(d, div);
85
+ if (i < nShort) d.push(0); // a placeholder, skipped when interleaving
86
+ blocks.push(d.concat(e));
87
+ }
88
+ const out = [];
89
+ for (let i = 0; i < blocks[0].length; i++) {
90
+ blocks.forEach((b, j) => { if (i !== shortLen - eccLen || j >= nShort) out.push(b[i]); });
91
+ }
92
+ return { ver, out };
93
+ }
94
+
95
+ const MASKS = [
96
+ (x, y) => (x + y) % 2 === 0, (x, y) => y % 2 === 0, (x) => x % 3 === 0, (x, y) => (x + y) % 3 === 0,
97
+ (x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0, (x, y) => ((x * y) % 2) + ((x * y) % 3) === 0,
98
+ (x, y) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0, (x, y) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0,
99
+ ];
100
+
101
+ /**
102
+ * The QR code for `text`: { size, modules } where modules[y][x] is true for a dark module, quiet zone not included.
103
+ * `mask` forces one of the eight masks (the self-check reads every one); otherwise the one the penalty rules pick.
104
+ */
105
+ export function qr(text, { mask = null } = {}) {
106
+ const bytes = [...new TextEncoder().encode(String(text))];
107
+ const { ver, out } = codewords(bytes);
108
+ const size = ver * 4 + 17;
109
+ const m = Array.from({ length: size }, () => new Array(size).fill(false));
110
+ const fn = Array.from({ length: size }, () => new Array(size).fill(false));
111
+ const set = (x, y, dark) => { m[y][x] = dark; fn[y][x] = true; };
112
+
113
+ for (let i = 0; i < size; i++) { set(6, i, i % 2 === 0); set(i, 6, i % 2 === 0); }
114
+ for (const [cx, cy] of [[3, 3], [size - 4, 3], [3, size - 4]]) {
115
+ for (let dy = -4; dy <= 4; dy++) for (let dx = -4; dx <= 4; dx++) {
116
+ const x = cx + dx; const y = cy + dy;
117
+ if (x >= 0 && x < size && y >= 0 && y < size) { const d = Math.max(Math.abs(dx), Math.abs(dy)); set(x, y, d !== 2 && d !== 4); }
118
+ }
119
+ }
120
+ const al = alignmentPositions(ver);
121
+ al.forEach((ax, i) => al.forEach((ay, j) => {
122
+ if ((i === 0 && j === 0) || (i === 0 && j === al.length - 1) || (i === al.length - 1 && j === 0)) return;
123
+ for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) set(ax + dx, ay + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
124
+ }));
125
+ const format = (mk) => {
126
+ const d = (ECC_M.formatBits << 3) | mk;
127
+ let r = d; for (let i = 0; i < 10; i++) r = (r << 1) ^ ((r >>> 9) * 0x537);
128
+ const b = ((d << 10) | r) ^ 0x5412; const bit = (i) => ((b >>> i) & 1) === 1;
129
+ for (let i = 0; i <= 5; i++) set(8, i, bit(i));
130
+ set(8, 7, bit(6)); set(8, 8, bit(7)); set(7, 8, bit(8));
131
+ for (let i = 9; i < 15; i++) set(14 - i, 8, bit(i));
132
+ for (let i = 0; i < 8; i++) set(size - 1 - i, 8, bit(i));
133
+ for (let i = 8; i < 15; i++) set(8, size - 15 + i, bit(i));
134
+ set(8, size - 8, true);
135
+ };
136
+ format(0); // reserves the format area before the data is drawn
137
+ if (ver >= 7) {
138
+ let r = ver; for (let i = 0; i < 12; i++) r = (r << 1) ^ ((r >>> 11) * 0x1f25);
139
+ const b = (ver << 12) | r;
140
+ for (let i = 0; i < 18; i++) { const dark = ((b >>> i) & 1) === 1; const a = size - 11 + (i % 3); const c = Math.floor(i / 3); set(a, c, dark); set(c, a, dark); }
141
+ }
142
+
143
+ let i = 0;
144
+ for (let right = size - 1; right >= 1; right -= 2) {
145
+ if (right === 6) right = 5;
146
+ for (let v = 0; v < size; v++) for (let j = 0; j < 2; j++) {
147
+ const x = right - j; const up = ((right + 1) & 2) === 0; const y = up ? size - 1 - v : v;
148
+ if (!fn[y][x] && i < out.length * 8) { m[y][x] = ((out[i >>> 3] >>> (7 - (i & 7))) & 1) === 1; i++; }
149
+ }
150
+ }
151
+
152
+ const apply = (mk) => { for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) if (!fn[y][x] && MASKS[mk](x, y)) m[y][x] = !m[y][x]; };
153
+ let chosen = mask;
154
+ if (chosen === null) {
155
+ let best = Infinity;
156
+ for (let mk = 0; mk < 8; mk++) {
157
+ apply(mk); format(mk);
158
+ const p = penalty(m, size);
159
+ if (p < best) { best = p; chosen = mk; }
160
+ apply(mk);
161
+ }
162
+ }
163
+ apply(chosen); format(chosen);
164
+ return { size, version: ver, mask: chosen, modules: m };
165
+ }
166
+
167
+ // The standard's four penalty rules: runs of five or more, 2x2 blocks, finder-like patterns, dark/light balance.
168
+ function penalty(m, size) {
169
+ let p = 0;
170
+ const line = (get) => {
171
+ let run = 0; let prev = null; const hist = [];
172
+ for (let i = 0; i < size; i++) {
173
+ const c = get(i);
174
+ if (c === prev) { run++; if (run === 5) p += 3; else if (run > 5) p += 1; } else { run = 1; prev = c; }
175
+ hist.push(c ? 1 : 0);
176
+ }
177
+ const s = hist.join('');
178
+ for (const pat of ['10111010000', '00001011101']) { let k = s.indexOf(pat); while (k !== -1) { p += 40; k = s.indexOf(pat, k + 1); } }
179
+ };
180
+ for (let y = 0; y < size; y++) line((x) => m[y][x]);
181
+ for (let x = 0; x < size; x++) line((y) => m[y][x]);
182
+ let dark = 0;
183
+ for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) {
184
+ if (m[y][x]) dark++;
185
+ if (x < size - 1 && y < size - 1 && m[y][x] === m[y][x + 1] && m[y][x] === m[y + 1][x] && m[y][x] === m[y + 1][x + 1]) p += 3;
186
+ }
187
+ const total = size * size;
188
+ p += Math.floor(Math.abs(dark * 20 - total * 10) / total) * 10;
189
+ return p;
190
+ }
191
+
192
+ /** The code as an SVG string: one path and a quiet zone of four modules. Its two colours are tokens.css's
193
+ * --qr-dark and --qr-light, the same in both themes: a camera reads contrast, not the palette. */
194
+ export function qrSvg(text, { px = 4 } = {}) {
195
+ const { size, modules } = qr(text);
196
+ const q = 4; const n = size + q * 2;
197
+ let d = '';
198
+ for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) if (modules[y][x]) d += `M${x + q} ${y + q}h1v1h-1z`;
199
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${n} ${n}" width="${n * px}" height="${n * px}" shape-rendering="crispEdges" role="img" aria-label="QR code of the viewer link"><rect class="qr-light" width="${n}" height="${n}"/><path class="qr-dark" d="${d}"/></svg>`;
200
+ }
@@ -402,6 +402,37 @@ mark { background: color-mix(in srgb, var(--studio-accent) 30%, transparent); co
402
402
  }
403
403
  .nav-foot-label:hover { color: var(--ink); }
404
404
 
405
+ /* Remote viewers, under the machines: who holds a read-only link, and the way to take it back. */
406
+ .rv { margin-top: var(--space-3); }
407
+ .rv-head { display: flex; align-items: center; gap: var(--space-2); padding: 0 var(--space-1); }
408
+ .rv-title {
409
+ font-size: 11px; line-height: var(--type-label-line); font-weight: var(--type-label-weight);
410
+ letter-spacing: var(--type-label-tracking); text-transform: uppercase; color: var(--ink-dim);
411
+ }
412
+ .rv-say { white-space: normal; padding: 0 var(--space-1); }
413
+ .rv-give {
414
+ display: flex; flex-direction: column; gap: var(--space-2);
415
+ padding: var(--space-2); border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--surface-raised);
416
+ }
417
+ .rv-give .rv-say { padding: 0; }
418
+ .rv-url { font-size: 12px; line-height: 16px; overflow-wrap: anywhere; user-select: all; color: var(--ink); }
419
+ .rv-acts { display: flex; gap: var(--space-2); }
420
+ /* The QR code is dark on white whatever the theme: a phone's camera reads contrast, not the palette. */
421
+ .rv-qr { align-self: center; line-height: 0; border-radius: var(--radius-md); overflow: hidden; }
422
+ .rv-qr svg { display: block; width: 168px; height: auto; max-width: 100%; }
423
+ .rv-qr .qr-light { fill: var(--qr-light); }
424
+ .rv-qr .qr-dark { fill: var(--qr-dark); }
425
+ .rv-opt { display: flex; align-items: center; gap: var(--space-1); padding: var(--space-1); font-size: 12px; color: var(--ink-dim); }
426
+ /* A viewer link past its eight hours: the page is covered, with what happened and what to do. */
427
+ .expired {
428
+ position: fixed; inset: 0; z-index: 100; display: flex; flex-direction: column; align-items: center; justify-content: center;
429
+ gap: var(--space-2); padding: var(--space-4); text-align: center; background: var(--surface); color: var(--ink);
430
+ }
431
+ .expired h1 { margin: 0; font-size: 20px; }
432
+ .expired p { margin: 0; max-width: 34ch; color: var(--ink-dim); }
433
+ /* A viewer's page offers no way to start a session: the server refuses it, and a button that only fails is noise. */
434
+ body[data-role="viewer"] #new-session { display: none; }
435
+
405
436
  /* ---- menu and toast ---- */
406
437
  .menu {
407
438
  position: fixed; z-index: 60; min-width: 190px; max-width: 272px;
@@ -525,6 +556,12 @@ mark { background: color-mix(in srgb, var(--studio-accent) 30%, transparent); co
525
556
  border-bottom-color: color-mix(in srgb, var(--status-fail) 45%, var(--line));
526
557
  }
527
558
  .stage-note-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
559
+ /* A server running older code than the page: the whole sentence is the instruction, so it wraps, not ellipsizes. */
560
+ .stage-note[data-kind="restart"] {
561
+ color: var(--ink); background: color-mix(in srgb, var(--status-waiting) 12%, var(--surface));
562
+ border-bottom-color: color-mix(in srgb, var(--status-waiting) 45%, var(--line));
563
+ }
564
+ .stage-note[data-kind="restart"] .stage-note-text { white-space: normal; }
528
565
  /* The last picture stays while the server is away, and is drawn as what it is: old. */
529
566
  .stage[data-offline="true"] .canvas,
530
567
  .stage[data-offline="true"] .tl,
@@ -553,6 +590,126 @@ mark { background: color-mix(in srgb, var(--studio-accent) 30%, transparent); co
553
590
  .mv { flex: 1; min-height: 0; min-width: 0; overflow: auto; background: var(--ground); padding: var(--space-4); }
554
591
  .mv[hidden] { display: none; }
555
592
  .mv-empty { padding: var(--space-6); color: var(--ink-dim); font-size: 13px; }
593
+
594
+ /* The Map view: the project as its crew map shows it. Bands, cards, lines, the findings, the inspector. */
595
+ .mp { flex: 1; min-height: 0; min-width: 0; overflow: auto; background: var(--ground); padding: var(--space-4); }
596
+ .mp[hidden] { display: none; }
597
+ .mp .sub { white-space: normal; flex: initial; min-width: 0; }
598
+ .mp .lbl { font-size: 11px; line-height: 16px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-dim); }
599
+ .mp-empty { padding: var(--space-6); color: var(--ink-dim); font-size: 13px; display: flex; flex-direction: column; gap: 6px; }
600
+ .mp-empty p { margin: 0; }
601
+ .mp-pane { border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--surface); overflow: hidden; display: flex; flex-direction: column; min-height: 100%; }
602
+ .mp-head, .mp-toolbar, .mp-source { display: flex; align-items: center; gap: var(--space-2); padding: 10px 18px; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
603
+ .mp-title { margin: 0; font-size: 15px; font-weight: 700; }
604
+ .mp-pill { display: inline-flex; align-items: center; height: 22px; padding: 0 8px; border-radius: var(--radius-sm); background: var(--surface-raised); font-size: 12px; font-weight: 600; color: var(--ink); white-space: nowrap; }
605
+ .mp-pill[data-tone="behind"], .mp-pill[data-tone="missing"] { color: var(--status-bad); background: color-mix(in srgb, var(--status-bad) 14%, transparent); }
606
+ .mp-tog { height: 28px; padding: 0 10px; border-radius: var(--radius-pill); border: 1px solid var(--line); background: var(--surface); color: var(--ink-dim); font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; }
607
+ .mp-tog[aria-pressed="true"] { background: var(--surface-raised); color: var(--ink); }
608
+ .mp-compare { height: 30px; border-radius: var(--radius-md); border: 1px solid var(--line); background: var(--surface); color: var(--ink); font: inherit; font-size: 12px; padding: 0 8px; max-width: 260px; }
609
+ .mp-legend { position: relative; }
610
+ .mp-legend-box { position: absolute; right: 0; top: 34px; z-index: 5; padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--surface-raised); min-width: 220px; }
611
+ .mp-legend-row { display: flex; align-items: center; gap: 8px; }
612
+ .mp-legend-line { width: 28px; height: 0; border-top: 2px solid var(--ink-faint); flex: none; }
613
+ .mp-legend-line[data-kind="inferred"] { border-top-style: dotted; }
614
+ .mp-legend-line[data-kind="model"] { border-top-style: dashed; border-top-color: var(--studio-accent); opacity: 0.7; }
615
+ .mp-legend-line[data-kind="lit"] { border-top-color: var(--studio-accent); }
616
+ .mp-legend-card { width: 22px; height: 14px; border: 1px solid var(--ink-faint); border-radius: 4px; flex: none; }
617
+ .mp-legend-card[data-kind="dashed"] { border-style: dashed; }
618
+ .mp-file { font-family: var(--font-mono); font-size: 12px; color: var(--ink); }
619
+ .mp-body { display: flex; min-height: 0; flex: 1; }
620
+ .mp-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
621
+ /* The pane pans and zooms (viewport.js): the layer is moved by one transform, so nothing inside restyles. */
622
+ .mp-canvas-pane { position: relative; flex: 1; min-height: 380px; overflow: hidden; cursor: grab; background-color: var(--ground); background-image: radial-gradient(var(--line) 1px, transparent 1.2px); background-size: 20px 20px; touch-action: none; }
623
+ .mp-canvas-pane.cv-panning { cursor: grabbing; }
624
+ .mp-canvas { position: absolute; left: 0; top: 0; transform-origin: 0 0; }
625
+ .mp-band { position: absolute; left: 0; border-bottom: 1px solid var(--line); padding: 12px 16px; box-sizing: border-box; }
626
+ .mp-group { position: absolute; border: 1px dashed var(--line); border-radius: var(--radius-md); box-sizing: border-box; }
627
+ .mp-group-fold { position: absolute; left: 8px; top: 3px; border: 0; background: none; padding: 0; font: inherit; font-family: var(--font-mono); font-size: 11px; color: var(--ink-dim); cursor: pointer; }
628
+ .mp-group-fold:hover { color: var(--ink); }
629
+ .mp-lines { position: absolute; left: 0; top: 0; pointer-events: none; }
630
+ .mp-line { fill: none; stroke: var(--ink-faint); stroke-width: 1.5; }
631
+ .mp-line[data-line="inferred"] { stroke-dasharray: 2 4; }
632
+ .mp-line[data-line="model"] { stroke-dasharray: 7 4; stroke: var(--studio-accent); opacity: 0.7; }
633
+ .mp-line[data-lit="false"] { opacity: 0.35; }
634
+ .mp-line[data-lit="true"] { stroke: var(--studio-accent); stroke-width: 2.2; }
635
+ .mp-elab { position: absolute; transform: translate(-50%, -50%); display: flex; gap: 4px; z-index: 2; }
636
+ .mp-mod, .mp-ext { position: absolute; box-sizing: border-box; display: flex; flex-direction: column; gap: 4px; text-align: left; font: inherit; color: var(--ink); cursor: pointer; border: 1px solid var(--line); border-radius: var(--radius-md); z-index: 1; overflow: hidden; }
637
+ .mp-mod { background: var(--surface); padding: 10px 14px; }
638
+ .mp-mod[data-kind="client"] { border-style: dashed; }
639
+ .mp-mod[data-kind="group"] { background: var(--surface-raised); }
640
+ .mp-ext[data-inferred="true"] { border-style: dashed; }
641
+ .mp-card.cv-dragging { cursor: grabbing; z-index: 3; }
642
+ .mp-ext { background: var(--ground); padding: 6px 10px; justify-content: center; }
643
+ .mp-card:focus-visible { outline: 2px solid var(--studio-accent); outline-offset: 2px; }
644
+ .mp-mod[data-selected="true"], .mp-ext[data-selected="true"] { border-color: var(--studio-accent); box-shadow: 0 0 0 1px var(--studio-accent); }
645
+ .mp-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
646
+ .mp-name { font-family: var(--font-mono); font-size: 14px; line-height: 20px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
647
+ .mp-ename { font-family: var(--font-mono); font-size: 13px; line-height: 18px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
648
+ .mp-kind { font-size: 10px; line-height: 12px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-dim); flex: none; }
649
+ .mp-fb { min-width: 20px; height: 20px; padding: 0 6px; box-sizing: border-box; border-radius: var(--radius-pill); background: color-mix(in srgb, var(--status-bad) 18%, transparent); color: var(--status-bad); font-size: 11px; font-weight: 700; display: inline-flex; align-items: center; justify-content: center; flex: none; }
650
+ .mp-chips { display: flex; flex-wrap: wrap; gap: 4px; }
651
+ .mp-chip { font-family: var(--font-mono); font-size: 11px; height: 22px; padding: 0 8px; border-radius: var(--radius-sm); background: var(--surface-raised); display: inline-flex; align-items: center; white-space: nowrap; max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
652
+ .mp-findings { border-top: 1px solid var(--line); padding: 10px 14px; display: flex; flex-direction: column; gap: 4px; }
653
+ .mp-flist { max-height: 132px; overflow: auto; display: flex; flex-direction: column; }
654
+ .mp-frow { display: flex; align-items: center; gap: 10px; min-height: 28px; padding: 0 8px; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--ink); font: inherit; text-align: left; cursor: pointer; }
655
+ .mp-frow:hover { background: var(--surface-raised); }
656
+ .mp-dot { width: 8px; height: 8px; border-radius: 999px; flex: none; box-sizing: border-box; border: 1.5px solid var(--status-bad); }
657
+ .mp-dot[data-proven="true"] { background: var(--status-bad); }
658
+ .mp-ftitle { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; flex: 0 1 auto; }
659
+ .mp-fcard .mp-ftitle { white-space: normal; }
660
+ .mp-fwhere { font-family: var(--font-mono); font-size: 12px; color: var(--ink-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; flex: 0 2 auto; }
661
+ .mp-badge { font-family: var(--font-mono); font-size: 11px; height: 20px; padding: 0 6px; border-radius: var(--radius-pill); border: 1px solid var(--line); color: var(--ink-dim); display: inline-flex; align-items: center; flex: none; }
662
+ .mp-inspect { width: 340px; flex: none; border-left: 1px solid var(--line); padding: 14px 16px; display: flex; flex-direction: column; gap: 10px; overflow: auto; }
663
+ .mp-inspect p { margin: 0; font-size: 13px; line-height: 19px; }
664
+ .mp-tabs { display: flex; gap: 16px; border-bottom: 1px solid var(--line); }
665
+ .mp-tab { font: inherit; font-size: 13px; font-weight: 600; color: var(--ink-dim); background: none; border: 0; border-bottom: 2px solid transparent; padding: 8px 0; cursor: pointer; }
666
+ .mp-tab[aria-selected="true"] { color: var(--ink); border-bottom-color: var(--accent); }
667
+ .mp-isec { display: flex; flex-direction: column; gap: 8px; }
668
+ .mp-overview { white-space: pre-wrap; margin: 4px 0 10px; line-height: 1.5; }
669
+ .mp-cites { font-family: var(--font-mono); font-size: 11px; overflow-wrap: anywhere; }
670
+ .mp-conn { display: flex; flex-direction: column; gap: 2px; padding: 6px 0; border-bottom: 1px solid var(--line); }
671
+ .mp-ev { font-family: var(--font-mono); font-size: 11px; color: var(--ink-dim); }
672
+ .mp-fcard { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; padding: 10px; border: 1px solid var(--line); border-radius: var(--radius-md); }
673
+ .mp-fcard[data-proven="false"] { border-style: dashed; }
674
+ .mp-notmeasured { margin-top: auto; padding-top: 10px; border-top: 1px solid var(--line); display: flex; flex-direction: column; gap: 4px; }
675
+ /* The navigator's Map entry under a project, and its count of findings (spec §11). */
676
+ .map-row .map-word { font-size: 13px; font-weight: 600; }
677
+ .map-count { min-width: 20px; height: 20px; padding: 0 6px; box-sizing: border-box; border-radius: var(--radius-pill); background: color-mix(in srgb, var(--status-bad) 18%, transparent); color: var(--status-bad); font-size: 11px; font-weight: 700; display: inline-flex; align-items: center; justify-content: center; flex: none; }
678
+ .mp-cite { border: 0; background: none; padding: 0; text-align: left; cursor: pointer; }
679
+ .mp-cite:hover { color: var(--ink); text-decoration: underline; }
680
+ .mp-pill[data-tone="model"] { color: var(--studio-accent); background: color-mix(in srgb, var(--studio-accent) 14%, transparent); }
681
+ /* A phone shows the findings and a list of the modules in place of the pane (spec §11). */
682
+ /* CM3: a module card names itself in full (the kind line gives way), carries what it is built with, and folds the
683
+ outside services it calls into one line under it. A search dims what it does not name; the tour sits over the
684
+ bottom of the pane. */
685
+ .mp-mod { gap: 6px; }
686
+ .mp-mod .mp-row .mp-name { flex: none; overflow: visible; }
687
+ .mp-mod .mp-row .mp-kind { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
688
+ .mp-fold { font-family: var(--font-mono); font-size: 12px; color: var(--ink-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
689
+ .mp-fold.none { color: var(--ink-faint); }
690
+ .mp-card[data-match="false"] { opacity: 0.3; }
691
+ .mp-card[data-match="true"] { border-color: var(--studio-accent); }
692
+ .mp-search { height: 30px; min-width: 200px; padding: 0 10px; border-radius: var(--radius-md); border: 1px solid var(--line); background: var(--surface); color: var(--ink); font: inherit; font-size: 12px; }
693
+ .mp-search::placeholder { color: var(--ink-dim); }
694
+ .mp-tour { position: absolute; left: 16px; right: 16px; bottom: 14px; z-index: 6; padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; border: 1px solid var(--studio-accent); border-radius: var(--radius-md); background: var(--surface-raised); cursor: default; }
695
+ .mp-tour p { margin: 0; font-size: 14px; line-height: 21px; }
696
+ .mp-tour[hidden] { display: none; }
697
+ .mp-inside { flex-direction: row; align-items: center; gap: 10px; }
698
+ .mp-inside .mp-ev { margin-left: auto; }
699
+ .mp-inside .mp-cite, .mp-inside .mp-file { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
700
+ .mp-inside .mp-ev:last-child { flex: none; }
701
+ .mp-modlist { display: none; flex-direction: column; gap: 2px; padding: 10px 14px; border-top: 1px solid var(--line); }
702
+ .mp-mrow { display: flex; align-items: center; gap: 8px; min-height: 36px; padding: 0 8px; border: 0; border-radius: var(--radius-md); background: transparent; color: var(--ink); font: inherit; text-align: left; cursor: pointer; }
703
+ .mp-mrow:hover { background: var(--surface-raised); }
704
+ @media (max-width: 1023px) {
705
+ .mp-body { flex-direction: column; }
706
+ .mp-inspect { width: auto; border-left: 0; border-top: 1px solid var(--line); }
707
+ }
708
+ @media (max-width: 639px) {
709
+ .mp-canvas-pane, .mp-hint { display: none; }
710
+ .mp-modlist { display: flex; }
711
+ .mp-flist { max-height: none; }
712
+ }
556
713
  .mv-pane { border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--surface); overflow: hidden; }
557
714
  .mv-head { display: flex; align-items: center; gap: var(--space-2); padding: 14px 18px; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
558
715
  .mv-title { margin: 0; font-size: 15px; font-weight: 700; }
@@ -571,6 +728,7 @@ mark { background: color-mix(in srgb, var(--studio-accent) 30%, transparent); co
571
728
  .mv-raised .mv-tag[data-kind="lowered"] { margin-left: 0; }
572
729
  .mv-tag[data-tone="pass"] { color: var(--status-good); background: color-mix(in srgb, var(--status-good) 14%, transparent); }
573
730
  .mv-tag[data-tone="fail"] { color: var(--status-fail); background: color-mix(in srgb, var(--status-fail) 14%, transparent); }
731
+ .mv-tag[data-kind="advisor"] { margin-left: 6px; color: var(--studio-accent); background: color-mix(in srgb, var(--studio-accent) 16%, transparent); }
574
732
  .mv-tag[data-kind="escalated"] { color: var(--studio-accent); background: color-mix(in srgb, var(--studio-accent) 16%, transparent); }
575
733
  .mv-table td[data-partial="true"] { color: var(--ink-dim); }
576
734
  .mv-meter { display: inline-block; width: 72px; height: 6px; margin-left: 8px; border-radius: 999px; background: var(--surface-raised); overflow: hidden; vertical-align: middle; }
@@ -581,6 +739,11 @@ mark { background: color-mix(in srgb, var(--studio-accent) 30%, transparent); co
581
739
  .mv-suggest-h { margin: 0; font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--studio-accent); }
582
740
  .mv-suggest-text { margin: 0; font-size: 14px; line-height: 21px; }
583
741
  .mv-suggest-row { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; }
742
+ .mv-above { margin: 16px 18px; padding: 14px 16px; display: flex; flex-direction: column; gap: 6px; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--surface-raised); }
743
+ .mv-above .mv-suggest-h { color: var(--ink-dim); }
744
+ .mv-above p { margin: 0; }
745
+ .mv-above-verdict { font-size: 13px; line-height: 19px; }
746
+ .mv-above-verdict[data-measured="false"] { color: var(--ink-dim); }
584
747
  .mv-cmd { padding: 2px 6px; border-radius: var(--radius-sm); background: var(--surface-raised); font-size: 12px; user-select: all; }
585
748
  .mv-raised { display: flex; align-items: baseline; gap: var(--space-2); padding: 0 18px 16px; }
586
749
  .mv-raised .mv-tag { margin-left: 0; flex: none; }
@@ -1079,6 +1242,8 @@ button.tl-lab { cursor: pointer; }
1079
1242
  and do not shrink. */
1080
1243
  .cv-sub { font-size: 12px; line-height: 16px; color: var(--ink-dim); white-space: nowrap; flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
1081
1244
  .cv-sub[hidden] { display: none; }
1245
+ /* Agent calls the model gate refused: no agent started, so this line is the only trace on the canvas. */
1246
+ .cv-refused { color: var(--status-waiting); }
1082
1247
  .cv-node > .cv-row, .cv-node > .cv-under, .cv-node > .cv-name, .cv-node > .cv-sub { max-width: 100%; }
1083
1248
  .cv-word { font-size: 11px; color: var(--ink-dim); white-space: nowrap; flex: none; }
1084
1249
 
@@ -10,6 +10,8 @@
10
10
  --ink: #e6e9f0;
11
11
  --ink-dim: #9aa7cc;
12
12
  --ink-faint: #5c6472;
13
+ --qr-dark: #000000;
14
+ --qr-light: #ffffff;
13
15
  --accent: #A78BFA;
14
16
  --on-accent: #0B1020;
15
17
  --wordmark-ink: #FFFFFF;
@@ -86,6 +88,8 @@
86
88
  --ink: #1a1d24;
87
89
  --ink-dim: #5c6472;
88
90
  --ink-faint: #8d95a3;
91
+ --qr-dark: #000000;
92
+ --qr-light: #ffffff;
89
93
  --accent: #6D28D9;
90
94
  --on-accent: #ffffff;
91
95
  --wordmark-ink: #0F172A;
@@ -113,6 +117,8 @@
113
117
  --ink: #1a1d24;
114
118
  --ink-dim: #5c6472;
115
119
  --ink-faint: #8d95a3;
120
+ --qr-dark: #000000;
121
+ --qr-light: #ffffff;
116
122
  --accent: #6D28D9;
117
123
  --on-accent: #ffffff;
118
124
  --wordmark-ink: #0F172A;
@@ -111,6 +111,18 @@ export function summaryRows(u, { live = false, now = null } = {}) {
111
111
  };
112
112
  part('Session', u.parts?.session, 'The session\'s own transcript');
113
113
  part('Agents', u.parts?.agents, 'Everything its agents did');
114
+ // The advisor: a second model the main one consulted mid-response. Its tokens are in the rows above already; this
115
+ // says how much of them it was. No call recorded, no row: a session that never consulted it shows no zero.
116
+ if (u.advisor?.calls) {
117
+ const a = u.advisor;
118
+ const who = Object.keys(u.advisorModels ?? {}).map(modelName).join(', ');
119
+ rows.push({
120
+ label: 'Advisor',
121
+ value: `${a.calls} ${a.calls === 1 ? 'call' : 'calls'} \u00b7 ${fmtCount(a.tokens.fresh)} \u00b7 ${fmtCost(a.cost)}`,
122
+ note: `Advice the main model asked a second model for${who ? ` (${who})` : ''}, priced at that model's rates. Included in the totals above; the API's own token count leaves it out\n${costNote(a)}`,
123
+ detail: kinds(a.tokens),
124
+ });
125
+ }
114
126
  // Behind the cost: each model's new tokens and what they come to. A model with no price says so with a dash.
115
127
  const perModel = Object.entries(u.byModel ?? {}).map(([model, t]) => [modelName(model), `${fmtCount(t.tokens.fresh)} \u00b7 ${fmtCost(t.cost)}`]);
116
128
  rows.push({ label: 'Cost', value: k.total ? fmtCost(u.cost) : NO_COST, note: costNote(u), detail: perModel.length ? perModel : undefined });