crewforth 3.2.0-rc.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 (74) hide show
  1. package/CHANGELOG.md +190 -5
  2. package/README.md +2 -0
  3. package/VERSION +1 -1
  4. package/adopt.sh +55 -6
  5. package/bin/cli.js +3 -2
  6. package/kit/CLAUDE.md +6 -6
  7. package/kit/agents/crew-commit-agent.md +6 -4
  8. package/kit/agents/crew-review-agent.md +2 -0
  9. package/kit/eval/doctor.sh +37 -3
  10. package/kit/eval/gate-report.sh +2 -0
  11. package/kit/eval/lib/crew-map/narrative.mjs +42 -8
  12. package/kit/eval/lib/crew-map/project.mjs +3 -2
  13. package/kit/eval/lib/crew-map/rules/tech.json +35 -0
  14. package/kit/eval/lib/crew-map/scan.mjs +56 -4
  15. package/kit/eval/lib/crew-map/stacks.mjs +114 -8
  16. package/kit/eval/lib/crew-map/surface.mjs +81 -3
  17. package/kit/eval/lib/gate-log.sh +52 -0
  18. package/kit/eval/lib/json-string.awk +27 -0
  19. package/kit/eval/lib/preset.sh +109 -0
  20. package/kit/eval/lib/presets/default.json +7 -0
  21. package/kit/eval/lib/presets/solo.json +7 -0
  22. package/kit/eval/lib/presets/strict.json +11 -0
  23. package/kit/eval/lib/preview.sh +150 -0
  24. package/kit/eval/lib/review-bench/bench.mjs +273 -0
  25. package/kit/eval/lib/review-prepass/prepass.mjs +203 -0
  26. package/kit/eval/lib/review-prepass/prepass.sh +23 -0
  27. package/kit/eval/lib/settings-default.sh +36 -0
  28. package/kit/eval/lib/settings-json.awk +14 -4
  29. package/kit/eval/lib/statusline.sh +4 -19
  30. package/kit/eval/lib/verify-cmd.sh +17 -0
  31. package/kit/eval/lib/version.sh +41 -0
  32. package/kit/eval/review-bench.sh +26 -0
  33. package/kit/eval/smoke-test.sh +424 -26
  34. package/kit/hooks/agent-outcome.sh +38 -9
  35. package/kit/hooks/board-sync.sh +2 -23
  36. package/kit/hooks/guard-agent-model.sh +163 -20
  37. package/kit/hooks/guard-bash.sh +69 -36
  38. package/kit/hooks/guard-commit-scan.sh +10 -2
  39. package/kit/hooks/guard-write.sh +3 -7
  40. package/kit/hooks/precompact-snapshot.sh +75 -0
  41. package/kit/hooks/prompt-approval.sh +74 -5
  42. package/kit/hooks/session-rehydrate.sh +25 -2
  43. package/kit/hooks/session-update-check.sh +9 -8
  44. package/kit/hooks/trace-blocklist.txt +9 -0
  45. package/kit/settings.json +18 -0
  46. package/kit/skills/crew-code-review/SKILL.md +16 -0
  47. package/kit/skills/crew-map/SKILL.md +16 -5
  48. package/kit/skills/crew-review/SKILL.md +2 -1
  49. package/kit/skills/crew-ship/SKILL.md +3 -1
  50. package/kit/studio/README.md +21 -10
  51. package/kit/studio/server/index.js +85 -22
  52. package/kit/studio/server/lib/code-stamp.js +35 -0
  53. package/kit/studio/server/lib/graph.js +9 -1
  54. package/kit/studio/server/lib/kit-telemetry.js +30 -5
  55. package/kit/studio/server/lib/lan.js +87 -0
  56. package/kit/studio/server/lib/owned-stream.js +56 -0
  57. package/kit/studio/server/lib/projects.js +11 -4
  58. package/kit/studio/server/lib/redact.js +60 -0
  59. package/kit/studio/server/lib/remote.js +104 -18
  60. package/kit/studio/server/lib/session.js +1 -1
  61. package/kit/studio/server/lib/terminal.js +51 -13
  62. package/kit/studio/server/lib/transcript.js +32 -0
  63. package/kit/studio/web/app.js +79 -11
  64. package/kit/studio/web/canvas.js +14 -2
  65. package/kit/studio/web/chat.js +114 -24
  66. package/kit/studio/web/graph-plan.js +3 -2
  67. package/kit/studio/web/liveness.js +14 -0
  68. package/kit/studio/web/map-plan.js +72 -3
  69. package/kit/studio/web/map.js +103 -10
  70. package/kit/studio/web/qr.js +200 -0
  71. package/kit/studio/web/style.css +40 -0
  72. package/kit/studio/web/tokens.css +6 -0
  73. package/package.json +1 -1
  74. package/start.sh +14 -1
@@ -1,14 +1,16 @@
1
1
  // The Map view: the project as Crewforth's crew map shows it.
2
2
  //
3
- // Bands from the clients down to the external resources, on a pane that pans and zooms like the graph's (the
4
- // shared viewport.js). Lines are solid where a file proves them, dotted where they are inferred, dashed with their
3
+ // Bands from the layers /crew-map wrote (UI, API, Service, Data, Infrastructure, …), or from what the scan shows
4
+ // where it wrote none, on a pane that pans and zooms like the graph's (the shared viewport.js). A module's card
5
+ // carries what it is built with and folds the outside services it calls into one line under it; a search dims the
6
+ // cards it does not name, and the tour /crew-map wrote steps through the map one stop at a time. Lines are solid where a file proves them, dotted where they are inferred, dashed with their
5
7
  // own mark where the model proposed them. A card can be placed by hand and stays placed for that project; modules
6
8
  // that share a folder can be folded into one card. Under the pane, the findings; beside it, the inspector. On a
7
9
  // phone the pane gives way to the findings and a list of the modules. It reads the map the server sends and writes
8
10
  // nothing; what leaves the page is copied text: a finding as a task, or a file:line.
9
11
  import {
10
12
  nodesOf, edgesOf, edgeKey, litEdges, findingsOf, findingCounts, cardSub, summaryOf, sourceOf, inspectOf, taskOf,
11
- emptyNote, layoutOf, pathOf, chipsOf, resourceWord, inferredOnly, groupsOf, foldView, placed, BANDS,
13
+ emptyNote, layoutOf, pathOf, chipsOf, inferredOnly, groupsOf, foldView, placed, BANDS, cardOf, tourOf, searchOf, resourceWord,
12
14
  } from './map-plan.js';
13
15
  import { Viewport, draggable, swallowClick } from './viewport.js';
14
16
 
@@ -30,6 +32,8 @@ const BAND_ORDER = Object.fromEntries(BANDS.map(([k], i) => [k, i]));
30
32
  const BAND_WORD = Object.fromEntries(BANDS);
31
33
 
32
34
  const LAYERS = [['modules', 'Modules'], ['external', 'External'], ['endpoints', 'Endpoints'], ['findings', 'Findings']];
35
+ // How a line between two modules is named on the map.
36
+ const EDGE_WORD = { references: 'uses', calls: 'calls', builds: 'builds' };
33
37
 
34
38
  export class MapView {
35
39
  /**
@@ -51,6 +55,8 @@ export class MapView {
51
55
  this.groupOf = {};
52
56
  this.lay = null;
53
57
  this.fitted = false;
58
+ this.query = '';
59
+ this.tourAt = -1; // the stop shown, or -1 when the tour is off
54
60
  this.root.classList.add('mp');
55
61
  this.#build();
56
62
  }
@@ -61,8 +67,11 @@ export class MapView {
61
67
  this.pane = mk('div', 'mp-canvas-pane');
62
68
  this.layer = mk('div', 'mp-canvas');
63
69
  this.pane.append(this.layer);
70
+ this.tourBox = mk('div', 'mp-tour');
71
+ this.tourBox.hidden = true;
72
+ this.pane.append(this.tourBox);
64
73
  this.vp = new Viewport(this.pane, this.layer, {
65
- skip: (t) => Boolean(t.closest?.('.mp-card') || t.closest?.('.mp-group-fold')),
74
+ skip: (t) => Boolean(t.closest?.('.mp-card') || t.closest?.('.mp-group-fold') || t.closest?.('.mp-tour')),
66
75
  onApply: () => this.hooks.onZoom?.(this.vp.view.k),
67
76
  });
68
77
  this.below = mk('div', 'mp-below');
@@ -90,7 +99,7 @@ export class MapView {
90
99
 
91
100
  setData(data) {
92
101
  if (data?.cwd !== this.data?.cwd) {
93
- this.selected = null; this.tab = 'overview'; this.fitted = false; this.layWidth = null;
102
+ this.selected = null; this.tab = 'overview'; this.fitted = false; this.layWidth = null; this.query = ''; this.tourAt = -1;
94
103
  this.#restore(data?.cwd);
95
104
  }
96
105
  this.data = data;
@@ -109,6 +118,7 @@ export class MapView {
109
118
  /** Esc closes the legend first, then lets the selection go. Returns whether it had something to close. */
110
119
  escape() {
111
120
  if (this.legendOpen) { this.legendOpen = false; this.render(); return true; }
121
+ if (this.tourAt >= 0) { this.tourAt = -1; this.render(); return true; }
112
122
  if (!this.selected) return false;
113
123
  this.selected = null; this.render(); return true;
114
124
  }
@@ -133,6 +143,14 @@ export class MapView {
133
143
  return true;
134
144
  }
135
145
 
146
+ /** Step the tour to a stop (clamped), selecting its node and bringing it to the middle; -1 ends it. */
147
+ tour(at) {
148
+ const stops = this.data?.doc ? tourOf(this.data.doc) : [];
149
+ if (at < 0 || !stops.length) { this.tourAt = -1; this.render(); return; }
150
+ this.tourAt = Math.min(at, stops.length - 1);
151
+ this.select(stops[this.tourAt].node, { reveal: true });
152
+ }
153
+
136
154
  toggleGroup(id) {
137
155
  if (this.folded.has(id)) this.folded.delete(id); else this.folded.add(id);
138
156
  this.#persist();
@@ -155,7 +173,8 @@ export class MapView {
155
173
  const counts = this.layers.findings ? findingCounts(findings.shown) : {};
156
174
  const allEdges = edgesOf(doc, d.compare);
157
175
  const all = nodesOf(doc, d.compare);
158
- let nodes = all.filter((n) => (n.kind === 'external' ? this.layers.external : this.layers.modules));
176
+ // An outside service is not a card: it is named on the line under the cards of the modules that call it.
177
+ let nodes = all.filter((n) => n.kind !== 'external' && this.layers.modules);
159
178
  const groups = groupsOf(nodes);
160
179
  this.groupOf = {};
161
180
  for (const g of Object.values(groups)) for (const m of g.members) if (this.folded.has(g.id)) this.groupOf[m] = g.id;
@@ -179,6 +198,7 @@ export class MapView {
179
198
  const lay = this.lay;
180
199
  const shownSel = this.selected ? (lay.pos[this.selected] ? this.selected : this.groupOf[this.selected] ?? null) : null;
181
200
  const lit = litEdges(edges, shownSel);
201
+ const found = searchOf(this.query, nodes);
182
202
 
183
203
  this.top.replaceChildren(this.#head(doc, findings), this.#toolbar(d), this.#source(d));
184
204
 
@@ -220,6 +240,7 @@ export class MapView {
220
240
  if (shownSel) path.dataset.lit = String(lit.has(edgeKey(e)));
221
241
  lines.append(path);
222
242
  const tags = [];
243
+ if (EDGE_WORD[e.kind] && !e.calls) tags.push(mk('span', 'mp-badge', EDGE_WORD[e.kind]));
223
244
  if (e.line === 'model') { const m = mk('span', 'mp-pill', 'model'); m.dataset.tone = 'model'; m.title = 'Proposed by the model, not shown by a file'; tags.push(m); }
224
245
  if (e.calls) {
225
246
  tags.push(mk('span', 'mp-pill', `${e.calls} ${e.calls === 1 ? 'call' : 'calls'}`));
@@ -233,10 +254,15 @@ export class MapView {
233
254
  }
234
255
  }
235
256
  parts.push(lines, ...labels);
236
- for (const n of nodes) parts.push(this.#card(n, doc, lay.pos[n.id], counts[n.id] ?? 0, chips[n.id], edges, shownSel));
257
+ for (const n of nodes) {
258
+ const card = this.#card(n, doc, lay.pos[n.id], counts[n.id] ?? 0, chips[n.id], edges, shownSel);
259
+ if (this.query.trim()) card.dataset.match = String(found.has(n.id));
260
+ parts.push(card);
261
+ }
237
262
  this.layer.replaceChildren(...parts);
238
263
  if (!this.fitted) { this.fitted = true; this.fit(); } else this.vp.apply();
239
264
 
265
+ this.#tourBox(doc);
240
266
  this.below.replaceChildren(...(this.layers.findings ? [this.#findings(findings)] : []));
241
267
  this.#modList(all, counts);
242
268
  const picked = this.selected ? all.find((n) => n.id === this.selected) ?? null : null;
@@ -261,6 +287,26 @@ export class MapView {
261
287
  bar.append(b);
262
288
  }
263
289
  bar.append(mk('span', 'row-fill'));
290
+ const search = mk('input', 'mp-search');
291
+ search.type = 'search';
292
+ search.placeholder = 'Search files and modules';
293
+ search.setAttribute('aria-label', 'Search files and modules');
294
+ search.value = this.query;
295
+ search.addEventListener('input', () => { this.query = search.value; this.render(); this.top.querySelector('.mp-search')?.focus(); });
296
+ search.addEventListener('keydown', (e) => {
297
+ if (e.key !== 'Enter') return;
298
+ const first = [...searchOf(this.query, nodesOf(d.doc, d.compare))][0];
299
+ if (first) this.select(first, { reveal: true });
300
+ });
301
+ bar.append(search);
302
+ if (tourOf(d.doc).length) {
303
+ const t = mk('button', 'mp-tog', 'Tour');
304
+ t.type = 'button';
305
+ t.setAttribute('aria-pressed', String(this.tourAt >= 0));
306
+ t.title = 'The stops /crew-map wrote for someone new to the project, in order';
307
+ t.addEventListener('click', () => this.tour(this.tourAt >= 0 ? -1 : 0));
308
+ bar.append(t);
309
+ }
264
310
  const pick = mk('select', 'mp-compare');
265
311
  pick.setAttribute('aria-label', 'Compare with');
266
312
  const none = mk('option', null, d.others?.length ? 'Compare with…' : 'Compare with: no other project has a map');
@@ -322,10 +368,29 @@ export class MapView {
322
368
  if (!card.title) card.title = (n.hosts ?? []).join(', ');
323
369
  } else {
324
370
  const top = mk('span', 'mp-row');
325
- top.append(mk('span', 'mp-name', n.name ?? n.id), mk('span', 'row-fill'));
371
+ const name = mk('span', 'mp-name', n.name ?? n.id);
372
+ name.title = n.name ?? n.id;
373
+ top.append(name);
326
374
  if (count) { const f = mk('span', 'mp-fb', String(count)); f.title = `${count} ${count === 1 ? 'finding' : 'findings'}`; top.append(f); }
327
- const sub = n.kind === 'group' ? `${n.members.length} modules · click to open` : cardSub(n, doc);
328
- card.append(top, mk('span', 'sub', sub));
375
+ top.append(mk('span', 'row-fill'));
376
+ if (n.kind === 'group') { card.append(top, mk('span', 'sub', `${n.members.length} modules · click to open`)); }
377
+ else if (n.kind !== 'module') { card.append(top, mk('span', 'sub', cardSub(n, doc))); }
378
+ else {
379
+ const c = cardOf(n, doc);
380
+ if (c.kind) top.append(mk('span', 'mp-kind', c.kind));
381
+ card.append(top);
382
+ if (c.chips.length) {
383
+ const row = mk('span', 'mp-chips');
384
+ for (const t of c.chips) row.append(mk('span', 'mp-chip', t));
385
+ if (c.more) { const m = mk('span', 'mp-chip', `+${c.more}`); m.title = (n.tech ?? []).slice(3).map((t) => t.replace(/@.*$/, '')).join(', '); row.append(m); }
386
+ card.append(row);
387
+ }
388
+ if (this.layers.external) {
389
+ const fold = mk('span', `mp-fold${c.outside.length ? '' : ' none'}`, c.outside.length ? `▸ ${c.outside.join(' · ')}` : 'no outside calls');
390
+ if (c.outside.length) fold.title = c.outside.join('\n');
391
+ card.append(fold);
392
+ }
393
+ }
329
394
  if (chips?.length) { const c = mk('span', 'mp-chips'); for (const t of chips) c.append(mk('span', 'mp-chip', t)); card.append(c); }
330
395
  }
331
396
  draggable(card, {
@@ -402,6 +467,12 @@ export class MapView {
402
467
  close.addEventListener('click', () => this.select(null));
403
468
  top.append(mk('span', 'mp-name', v.title), mk('span', 'row-fill'), close);
404
469
  parts.push(top);
470
+ if (v.layer || v.domain) {
471
+ const pills = mk('div', 'mp-row');
472
+ if (v.layer) pills.append(mk('span', 'mp-pill', `Layer · ${v.layer}`));
473
+ if (v.domain) pills.append(mk('span', 'mp-pill', `Domain · ${v.domain}`));
474
+ parts.push(pills);
475
+ }
405
476
  if (v.path) parts.push(mk('code', 'sub', v.path));
406
477
  if (v.chips.length) { const c = mk('div', 'mp-chips'); for (const t of v.chips) c.append(mk('span', 'mp-chip', t)); parts.push(c); }
407
478
  const tabs = mk('nav', 'mp-tabs');
@@ -417,6 +488,13 @@ export class MapView {
417
488
  if (this.tab === 'overview') {
418
489
  sec.append(mk('span', 'lbl', 'What it is for'), mk('p', v.narrative ? null : 'sub', v.narrative ?? v.narrativeNote));
419
490
  if (v.narrative && v.narrativeCites.length) sec.append(mk('p', 'sub mp-cites', `From ${v.narrativeCites.join(', ')}`));
491
+ if (v.inside && (v.inside.entry || v.inside.folders.length)) {
492
+ sec.append(mk('span', 'lbl', 'Inside'));
493
+ if (v.inside.entry) { const r = mk('div', 'mp-conn mp-inside'); r.append(this.#cite(v.inside.entry), mk('span', 'mp-ev', 'entry point')); sec.append(r); }
494
+ for (const f of v.inside.folders) { const r = mk('div', 'mp-conn mp-inside'); r.append(mk('span', 'mp-file', `${f.folder === '.' ? '(root)' : `${f.folder}/`}`), mk('span', 'mp-ev', `${f.files} ${f.files === 1 ? 'file' : 'files'}`)); sec.append(r); }
495
+ if (v.inside.other) sec.append(mk('p', 'sub', `${v.inside.other} more files in other folders`));
496
+ if (v.inside.tests) sec.append(mk('p', 'sub', `${v.inside.tests} test ${v.inside.tests === 1 ? 'file' : 'files'}`));
497
+ }
420
498
  sec.append(mk('span', 'lbl', 'Connects to'));
421
499
  if (!v.connectsTo.length) sec.append(mk('p', 'sub', 'Nothing on this map.'));
422
500
  for (const c of v.connectsTo) sec.append(this.#conn(c));
@@ -459,6 +537,21 @@ export class MapView {
459
537
  this.side.replaceChildren(...parts);
460
538
  }
461
539
 
540
+ /** The tour's stop, over the bottom of the pane: which of how many, what to look at, and the way on. */
541
+ #tourBox(doc) {
542
+ const stops = tourOf(doc);
543
+ if (this.tourAt < 0 || !stops.length) { this.tourBox.hidden = true; return; }
544
+ const s = stops[Math.min(this.tourAt, stops.length - 1)];
545
+ const head = mk('div', 'mp-row');
546
+ head.append(mk('span', 'lbl', `Tour · ${this.tourAt + 1} of ${stops.length}`), mk('span', 'mp-name', s.name), mk('span', 'row-fill'));
547
+ const nav = (label, at, off) => { const b = mk('button', 'btn sm ghost', label); b.type = 'button'; b.disabled = off; b.addEventListener('click', () => this.tour(at)); return b; };
548
+ head.append(nav('Back', this.tourAt - 1, this.tourAt === 0), nav(this.tourAt === stops.length - 1 ? 'Done' : 'Next', this.tourAt === stops.length - 1 ? -1 : this.tourAt + 1, false));
549
+ const body = [head, mk('p', null, s.text)];
550
+ if (s.cites.length) body.push(mk('p', 'sub mp-cites', `From ${s.cites.join(', ')}`));
551
+ this.tourBox.replaceChildren(...body);
552
+ this.tourBox.hidden = false;
553
+ }
554
+
462
555
  /** A file:line the viewer can copy. */
463
556
  #cite(text) {
464
557
  const b = mk('button', 'mp-ev mp-cite', text);
@@ -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
+ }
@@ -417,6 +417,19 @@ mark { background: color-mix(in srgb, var(--studio-accent) 30%, transparent); co
417
417
  .rv-give .rv-say { padding: 0; }
418
418
  .rv-url { font-size: 12px; line-height: 16px; overflow-wrap: anywhere; user-select: all; color: var(--ink); }
419
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); }
420
433
  /* A viewer's page offers no way to start a session: the server refuses it, and a button that only fails is noise. */
421
434
  body[data-role="viewer"] #new-session { display: none; }
422
435
 
@@ -543,6 +556,12 @@ body[data-role="viewer"] #new-session { display: none; }
543
556
  border-bottom-color: color-mix(in srgb, var(--status-fail) 45%, var(--line));
544
557
  }
545
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; }
546
565
  /* The last picture stays while the server is away, and is drawn as what it is: old. */
547
566
  .stage[data-offline="true"] .canvas,
548
567
  .stage[data-offline="true"] .tl,
@@ -660,6 +679,25 @@ body[data-role="viewer"] #new-session { display: none; }
660
679
  .mp-cite:hover { color: var(--ink); text-decoration: underline; }
661
680
  .mp-pill[data-tone="model"] { color: var(--studio-accent); background: color-mix(in srgb, var(--studio-accent) 14%, transparent); }
662
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; }
663
701
  .mp-modlist { display: none; flex-direction: column; gap: 2px; padding: 10px 14px; border-top: 1px solid var(--line); }
664
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; }
665
703
  .mp-mrow:hover { background: var(--surface-raised); }
@@ -1204,6 +1242,8 @@ button.tl-lab { cursor: pointer; }
1204
1242
  and do not shrink. */
1205
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; }
1206
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); }
1207
1247
  .cv-node > .cv-row, .cv-node > .cv-under, .cv-node > .cv-name, .cv-node > .cv-sub { max-width: 100%; }
1208
1248
  .cv-word { font-size: 11px; color: var(--ink-dim); white-space: nowrap; flex: none; }
1209
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crewforth",
3
- "version": "3.2.0-rc.1",
3
+ "version": "3.2.0-rc.10",
4
4
  "description": "Crewforth — your engineering crew for Claude Code: agents, skills and tool-level gates, in any stack.",
5
5
  "bin": {
6
6
  "crewforth": "bin/cli.js"
package/start.sh CHANGED
@@ -191,6 +191,8 @@ Crewforth her zaman eksiksiz kurulur: tüm ajanlar ve skill'ler — backend, web
191
191
  (CLAUDE.md ## Stack ya da depo). React Native/Expo, hazır isteğe bağlı katmanlardan yalnız biridir.
192
192
  --backend | --frontend | --mobile | --fullstack hâlâ kabul edilir ama etkisi yok (eski komutlar bozulmasın diye)
193
193
  --private | --shared kurulum yalnızca sizin mi, yoksa ekiple paylaşılıp commit'lenecek mi? (varsayılan: private)
194
+ --preset <ad|dosya> bir kurum preset'i uygula: kritik yollar, model tabanları, izinli verify komutları
195
+ (gelenler: default, strict, solo; ya da kendi .json dosyanız)
194
196
  --lang tr|en kurulum dili (verilmezse sihirbaz sorar)
195
197
  --yes, -y tüm sorulara evet de (gözetimsiz kurulum)
196
198
  --version, -v Crewforth sürümünü yazdırıp çık
@@ -209,6 +211,8 @@ Every install ships everything: all agents, all skills — backend, web and mobi
209
211
  (CLAUDE.md ## Stack, or the repo). React Native/Expo is only one of the optional ready-made layers.
210
212
  --backend | --frontend | --mobile | --fullstack accepted, no effect (kept so older commands still run)
211
213
  --private | --shared is the install yours alone, or committed for the team? (default: private)
214
+ --preset <name|file> apply an organisation preset: critical paths, model floors, allowed verify commands
215
+ (shipped: default, strict, solo; or a .json file of your own)
212
216
  --lang tr|en installer language (asked interactively when not given)
213
217
  --yes, -y answer every question with yes (unattended install)
214
218
  --version, -v print the Crewforth version and exit
@@ -432,9 +436,13 @@ case "$CREW_LANG" in tr|en) ;; *) CREW_LANG=en ;; esac
432
436
  # choice made by --lang or the menu above stayed in this shell, and the preflight block printed English.
433
437
  export CREW_LANG
434
438
 
435
- STACK="generic"; DOTNET_FLAG=0; LEGACY_FLAGS=""; ASSUME_YES=0; VISIBILITY=""
439
+ STACK="generic"; DOTNET_FLAG=0; LEGACY_FLAGS=""; ASSUME_YES=0; VISIBILITY=""; PRESET=""; _preset_take=0
436
440
  for a in "$@"; do
441
+ # `--preset NAME`: the next word is its value, whatever it looks like (`--preset strict`, `--preset ./org.json`).
442
+ if [ "$_preset_take" = 1 ]; then _preset_take=0; PRESET="$a"; continue; fi
437
443
  case "$a" in
444
+ --preset=*) PRESET="${a#--preset=}" ;;
445
+ --preset) _preset_take=1 ;;
438
446
  --lang) ;; # value consumed in the language pass above
439
447
  --lang=*) ;;
440
448
  tr|en) ;; # the value of a separated --lang
@@ -448,6 +456,9 @@ for a in "$@"; do
448
456
  *) printf '%s\n' "$(m 'Unknown parameter: %s' "$a")"; echo; usage; exit 1 ;;
449
457
  esac
450
458
  done
459
+ [ "$_preset_take" = 1 ] && { printf '%s\n' "$(m 'Unknown parameter: %s' "--preset (no value)")"; echo; usage; exit 1; }
460
+ # A preset is checked before anything is installed: one that is not valid stops here, with nothing written.
461
+ if [ -n "$PRESET" ]; then bash "$SRC/eval/lib/preset.sh" --check "$PRESET" || exit 1; fi
451
462
 
452
463
  # ===================== COLOR / STYLE HELPERS =====================
453
464
  # Color is emitted ONLY on an interactive TTY + TERM!=dumb + NO_COLOR empty.
@@ -689,6 +700,8 @@ cp "$SRC/README.md" .claude/ 2>/dev/null || true
689
700
  # as if somebody had chosen it. --lang, CREW_LANG and the menu are choices, and are.
690
701
  [ "${_LANG_GUESSED:-0}" = 1 ] || echo "lang=${CREW_LANG:-en}"
691
702
  } > .claude/kit.conf
703
+ # The organisation's preset, now that .claude/ is in place (checked above): a block at the top of crew-model-rules.
704
+ if [ -n "$PRESET" ]; then bash .claude/eval/lib/preset.sh --apply "$PRESET" || exit 1; fi
692
705
 
693
706
  # Discipline (kit-owned, refreshed on every update) vs project section (yours, written once), joined by @import.
694
707
  kit_require_sentinel "$SRC/CLAUDE.md"