crewforth 3.2.0-rc.1 → 3.2.0-rc.3

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 (69) hide show
  1. package/CHANGELOG.md +133 -3
  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-review-agent.md +2 -0
  8. package/kit/eval/doctor.sh +37 -3
  9. package/kit/eval/gate-report.sh +2 -0
  10. package/kit/eval/lib/crew-map/narrative.mjs +42 -8
  11. package/kit/eval/lib/crew-map/project.mjs +3 -2
  12. package/kit/eval/lib/crew-map/rules/tech.json +35 -0
  13. package/kit/eval/lib/crew-map/scan.mjs +56 -4
  14. package/kit/eval/lib/crew-map/stacks.mjs +114 -8
  15. package/kit/eval/lib/crew-map/surface.mjs +62 -1
  16. package/kit/eval/lib/gate-log.sh +52 -0
  17. package/kit/eval/lib/json-string.awk +27 -0
  18. package/kit/eval/lib/preset.sh +109 -0
  19. package/kit/eval/lib/presets/default.json +7 -0
  20. package/kit/eval/lib/presets/solo.json +7 -0
  21. package/kit/eval/lib/presets/strict.json +11 -0
  22. package/kit/eval/lib/preview.sh +150 -0
  23. package/kit/eval/lib/review-bench/bench.mjs +273 -0
  24. package/kit/eval/lib/review-prepass/prepass.mjs +203 -0
  25. package/kit/eval/lib/review-prepass/prepass.sh +23 -0
  26. package/kit/eval/lib/settings-default.sh +36 -0
  27. package/kit/eval/lib/settings-json.awk +14 -4
  28. package/kit/eval/lib/statusline.sh +4 -19
  29. package/kit/eval/lib/verify-cmd.sh +17 -0
  30. package/kit/eval/lib/version.sh +41 -0
  31. package/kit/eval/review-bench.sh +26 -0
  32. package/kit/eval/smoke-test.sh +251 -21
  33. package/kit/hooks/agent-outcome.sh +20 -9
  34. package/kit/hooks/board-sync.sh +2 -23
  35. package/kit/hooks/guard-agent-model.sh +114 -15
  36. package/kit/hooks/guard-bash.sh +36 -21
  37. package/kit/hooks/guard-write.sh +3 -7
  38. package/kit/hooks/precompact-snapshot.sh +75 -0
  39. package/kit/hooks/session-rehydrate.sh +25 -2
  40. package/kit/hooks/session-update-check.sh +9 -8
  41. package/kit/settings.json +12 -0
  42. package/kit/skills/crew-code-review/SKILL.md +16 -0
  43. package/kit/skills/crew-map/SKILL.md +16 -5
  44. package/kit/skills/crew-review/SKILL.md +2 -1
  45. package/kit/studio/README.md +21 -10
  46. package/kit/studio/server/index.js +85 -22
  47. package/kit/studio/server/lib/code-stamp.js +35 -0
  48. package/kit/studio/server/lib/graph.js +9 -1
  49. package/kit/studio/server/lib/kit-telemetry.js +30 -5
  50. package/kit/studio/server/lib/lan.js +87 -0
  51. package/kit/studio/server/lib/owned-stream.js +56 -0
  52. package/kit/studio/server/lib/projects.js +11 -4
  53. package/kit/studio/server/lib/redact.js +60 -0
  54. package/kit/studio/server/lib/remote.js +104 -18
  55. package/kit/studio/server/lib/session.js +1 -1
  56. package/kit/studio/server/lib/terminal.js +51 -13
  57. package/kit/studio/server/lib/transcript.js +32 -0
  58. package/kit/studio/web/app.js +79 -11
  59. package/kit/studio/web/canvas.js +14 -2
  60. package/kit/studio/web/chat.js +114 -24
  61. package/kit/studio/web/graph-plan.js +3 -2
  62. package/kit/studio/web/liveness.js +14 -0
  63. package/kit/studio/web/map-plan.js +72 -3
  64. package/kit/studio/web/map.js +103 -10
  65. package/kit/studio/web/qr.js +200 -0
  66. package/kit/studio/web/style.css +40 -0
  67. package/kit/studio/web/tokens.css +6 -0
  68. package/package.json +1 -1
  69. package/start.sh +14 -1
@@ -48,10 +48,76 @@ export function quickReplies(text) {
48
48
  return (opts.length >= 2 && opts.length <= 5 && /\?/.test(text)) ? opts : [];
49
49
  }
50
50
 
51
+ /* ============================================== every open conversation === */
52
+
53
+ /**
54
+ * One connection for every open conversation. A pane per EventSource reached the browser's six connections to a
55
+ * host at five panes beside the page's own stream, and from there a message sent and every request the page made
56
+ * waited for a pane to close (measured on Windows). The panes register here; the stream is opened again, with
57
+ * each session's last event number, whenever the set changes or the connection drops, so nothing is replayed
58
+ * twice and nothing is missed.
59
+ */
60
+ export class OwnedStream {
61
+ constructor(api, { EventSourceImpl = globalThis.EventSource, retryMs = 1500 } = {}) {
62
+ this.api = api;
63
+ this.ES = EventSourceImpl;
64
+ this.retryMs = retryMs;
65
+ this.panes = new Map(); // session id -> pane
66
+ this.seq = new Map(); // session id -> the last event number taken
67
+ this.source = null;
68
+ this.timer = null;
69
+ }
70
+
71
+ attach(pane) { this.panes.set(pane.id, pane); this.#soon(0); }
72
+
73
+ detach(pane) {
74
+ if (this.panes.get(pane.id) !== pane) return;
75
+ this.panes.delete(pane.id); this.seq.delete(pane.id); this.#soon(0);
76
+ }
77
+
78
+ /** The URL the stream is opened at now: what a reconnect resumes from. */
79
+ url() {
80
+ const ids = [...this.panes.keys()];
81
+ const after = ids.map((id) => `${id}:${this.seq.get(id) ?? 0}`).join(',');
82
+ return this.api(`/api/owned-events?ids=${encodeURIComponent(ids.join(','))}&after=${encodeURIComponent(after)}`);
83
+ }
84
+
85
+ // Panes opened together (a page restoring its tabs) cost one reconnect, not one each.
86
+ #soon(ms) { clearTimeout(this.timer); this.timer = setTimeout(() => this.#open(), ms); }
87
+
88
+ #open() {
89
+ if (this.source) { this.source.close(); this.source = null; }
90
+ if (!this.panes.size) return;
91
+ const src = new this.ES(this.url());
92
+ const data = (e) => { try { return JSON.parse(e.data); } catch { return null; } };
93
+ src.addEventListener('state', (e) => { const d = data(e); if (d) this.panes.get(d.id)?.takeState(d.session); });
94
+ src.addEventListener('event', (e) => {
95
+ const d = data(e); const pane = d && this.panes.get(d.id);
96
+ if (!pane || !d.ev) return;
97
+ if (Number.isFinite(d.ev.seq)) {
98
+ if (d.ev.seq <= (this.seq.get(d.id) ?? 0)) return; // already taken before a reconnect
99
+ this.seq.set(d.id, d.ev.seq);
100
+ }
101
+ pane.takeEvent(d.ev);
102
+ });
103
+ src.addEventListener('gone', (e) => { const d = data(e); if (d) this.panes.get(d.id)?.streamLost(); });
104
+ // The browser would reconnect at the same URL, from the same event numbers, and replay what was taken since.
105
+ // So the stream is closed and opened again here, from where each session is now.
106
+ src.onerror = () => {
107
+ if (this.source !== src) return;
108
+ for (const pane of this.panes.values()) pane.streamLost();
109
+ this.#soon(this.retryMs);
110
+ };
111
+ this.source = src;
112
+ }
113
+
114
+ close() { clearTimeout(this.timer); if (this.source) { this.source.close(); this.source = null; } this.panes.clear(); this.seq.clear(); }
115
+ }
116
+
51
117
  /* ========================================================== one session === */
52
118
 
53
119
  class Pane {
54
- constructor(session, { api, headers, onChange, onPermissions, hooks = {}, readOnly = false }) {
120
+ constructor(session, { api, headers, onChange, onPermissions, hub = null, hooks = {}, readOnly = false }) {
55
121
  this.api = api;
56
122
  this.headers = headers;
57
123
  this.onChange = onChange;
@@ -62,7 +128,8 @@ class Pane {
62
128
  this.readOnly = readOnly;
63
129
  this.session = session;
64
130
  this.id = session.sessionId;
65
- this.source = null;
131
+ this.hub = hub;
132
+ this.connected = false;
66
133
  this.messages = [];
67
134
  this.streaming = null;
68
135
  this.permissions = [];
@@ -141,6 +208,7 @@ class Pane {
141
208
  this.note(`History not read: ${e.message}`, 'bad');
142
209
  return;
143
210
  }
211
+ if (conv?.transcriptsHidden) { this.note('Transcripts are hidden on this link', 'muted'); return; }
144
212
  if (conv?.measured === false) { this.note(`History not measured — ${conv.reason}`, 'bad'); return; }
145
213
 
146
214
  const frag = document.createDocumentFragment();
@@ -163,25 +231,22 @@ class Pane {
163
231
  }
164
232
 
165
233
  connect() {
166
- if (this.source) return;
167
- this.source = new EventSource(this.api(`/api/owned/${encodeURIComponent(this.id)}/events`));
168
- this.source.addEventListener('state', (e) => {
169
- try { this.session = JSON.parse(e.data); } catch { return; }
170
- this.paintState();
171
- this.onChange(this);
172
- });
173
- this.source.addEventListener('event', (e) => {
174
- let ev;
175
- try { ev = JSON.parse(e.data); } catch { return; }
176
- this.absorb(ev.rec);
177
- });
178
- this.source.onerror = () => { this.streamBroken = true; this.onChange(this); };
234
+ if (this.connected || !this.hub) return;
235
+ this.connected = true;
236
+ this.hub.attach(this);
179
237
  }
180
238
 
181
239
  disconnect() {
182
- if (this.source) { this.source.close(); this.source = null; }
240
+ if (!this.connected) return;
241
+ this.connected = false;
242
+ this.hub.detach(this);
183
243
  }
184
244
 
245
+ // What the shared stream hands this pane.
246
+ takeState(session) { if (!session) return; this.session = session; this.streamBroken = false; this.paintState(); this.onChange(this); }
247
+ takeEvent(ev) { this.streamBroken = false; this.absorb(ev.rec); }
248
+ streamLost() { this.streamBroken = true; this.onChange(this); }
249
+
185
250
  /* --------------------------------------------------------- ingestion */
186
251
 
187
252
  absorb(rec) {
@@ -495,9 +560,12 @@ class Pane {
495
560
  cont.type = 'button';
496
561
  cont.addEventListener('click', () => this.hooks.onContinue?.(this, cont));
497
562
  const contRow = el('div', 'chat-ro-row');
498
- contRow.append(cont, el('span', 'sub',
499
- 'Continues it in Studio. If it is still open somewhere else, Studio starts a copy instead: '
500
- + 'the original does not see this, and messages here never reach your terminal.'));
563
+ // Said before the press, from the server's own check (the one Continue here acts on): the same session, or a copy.
564
+ // Until the server answers, and if it cannot: the sentence true of both outcomes.
565
+ const FALLBACK = 'Continues it in Studio. If it is still open somewhere else, Studio starts a copy instead: '
566
+ + 'the original does not see this, and messages here never reach your terminal.';
567
+ const contText = el('span', 'sub', FALLBACK);
568
+ contRow.append(cont, contText);
501
569
 
502
570
  const term = el('button', 'btn sm', 'Open in terminal');
503
571
  term.type = 'button';
@@ -508,13 +576,34 @@ class Pane {
508
576
  const termRow = el('div', 'chat-ro-row');
509
577
  termRow.append(term, termText);
510
578
 
511
- this.roEl.replaceChildren(lead, contRow, termRow);
579
+ // Back to the desktop app: the command, to copy and run where the app is.
580
+ const deskLine = el('code', 'chat-ro-cmd');
581
+ const deskCopy = el('button', 'btn sm', 'Copy');
582
+ deskCopy.type = 'button';
583
+ deskCopy.addEventListener('click', () => { if (deskLine.dataset.cmd) navigator.clipboard?.writeText(deskLine.dataset.cmd).then(() => { deskCopy.textContent = 'Copied'; }, () => {}); });
584
+ const deskText = el('span', 'sub');
585
+ deskText.append('Open it in the desktop app again: ', deskLine);
586
+ const deskRow = el('div', 'chat-ro-row');
587
+ deskRow.append(deskCopy, deskText);
588
+
589
+ this.roEl.replaceChildren(lead, contRow, termRow, deskRow);
512
590
 
513
- // The command is the server's, read before anything runs. One that could not be worked out is said so.
591
+ // The commands are the server's, read before anything runs. One that could not be worked out is said so.
514
592
  fetch(this.api(`/api/session/${encodeURIComponent(this.id)}/terminal`), { cache: 'no-store' })
515
593
  .then((r) => r.json())
516
- .then((r) => { line.textContent = r?.plan?.line ?? 'not measured — no working directory recorded'; })
517
- .catch((e) => { line.textContent = `not read — ${e.message}`; });
594
+ .then((r) => {
595
+ line.textContent = r?.plan?.line ?? 'not measured — no working directory or session id recorded';
596
+ // On Windows the command is a PowerShell line (no one line works in cmd and PowerShell 5.1 both): say so.
597
+ deskLine.textContent = r?.desktop?.line ?? 'not measured — no working directory or session id recorded';
598
+ if (r?.desktop?.line) deskLine.dataset.cmd = r.desktop.line; else deskCopy.disabled = true;
599
+ if (r?.desktop?.shell === 'PowerShell') deskText.append(' (in PowerShell)');
600
+ contText.textContent = r?.continueAs === 'same'
601
+ ? 'Continues this same session in Studio: its id and history stay, and the desktop app or a terminal that opens it later sees what you add here. Nothing else has it open now.'
602
+ : r?.continueAs === 'copy'
603
+ ? 'It is open somewhere else right now, so Studio starts a copy: the original does not see this, and messages here never reach your terminal or the desktop app.'
604
+ : FALLBACK;
605
+ })
606
+ .catch((e) => { line.textContent = `not read — ${e.message}`; deskLine.textContent = `not read — ${e.message}`; deskCopy.disabled = true; });
518
607
  }
519
608
 
520
609
  paintState() {
@@ -591,6 +680,7 @@ export class Chat {
591
680
  this.headers = headers;
592
681
  this.hooks = hooks;
593
682
  this.panes = new Map();
683
+ this.hub = new OwnedStream(api);
594
684
  this.activeId = null;
595
685
  this.onActivate = () => {};
596
686
  // (pane, { pending }) — a session's waiting requests changed.
@@ -654,7 +744,7 @@ export class Chat {
654
744
  }
655
745
  if (!pane) {
656
746
  pane = new Pane(session, {
657
- api: this.api, headers: this.headers, hooks: this.hooks, onChange: () => this.paintTabs(),
747
+ api: this.api, headers: this.headers, hooks: this.hooks, onChange: () => this.paintTabs(), hub: this.hub,
658
748
  onPermissions: (p, rec) => this.onPermissions(p, rec),
659
749
  });
660
750
  this.panesEl.append(pane.root);
@@ -276,8 +276,9 @@ export function tallestFor(paneHeight) {
276
276
  return Math.max(AUTO.tallest, fits);
277
277
  }
278
278
 
279
- /** A session's card: one line taller while it has commands running in the background, and never wider. */
280
- export const sessionHeight = (n) => SIZE.session.h + (n?.backgroundNow?.length ? SIZE.session.line : 0);
279
+ /** A session's card: one line taller for commands running in the background, one for refused agent calls. */
280
+ export const sessionHeight = (n) => SIZE.session.h + (n?.backgroundNow?.length ? SIZE.session.line : 0)
281
+ + (n?.refusedCalls?.length ? SIZE.session.line : 0);
281
282
 
282
283
  /* ----------------------------------------------------------------- plan --- */
283
284
 
@@ -64,3 +64,17 @@ function clock(ms) {
64
64
  const two = (n) => String(n).padStart(2, '0');
65
65
  return `${two(d.getHours())}:${two(d.getMinutes())}`;
66
66
  }
67
+
68
+ /**
69
+ * Is the server older than the files it serves? The page is read from disk on every load; the server keeps the
70
+ * code it started with. After an update the two disagree, and a view the old server has no route for answered
71
+ * 404 (seen as "Not read — HTTP 404" on the Map view). /api/health says `code.changed` when the server's files
72
+ * changed since it started; a server with no `code` at all predates the field, and is older than this page.
73
+ * Returns the sentence to show, or null.
74
+ */
75
+ export const RESTART_NOTE = 'Studio was updated after this server started, so it is running the old code. Restart it: stop it where it runs (Ctrl-C), then /crew-studio.';
76
+ export function staleServerNote(health) {
77
+ if (!health || typeof health !== 'object') return null;
78
+ if (!health.code || typeof health.code !== 'object') return RESTART_NOTE;
79
+ return health.code.changed === true ? RESTART_NOTE : null;
80
+ }
@@ -1,14 +1,24 @@
1
1
  // What the Map view draws, worked out from a crew map. No page in it.
2
2
  //
3
3
  // The map is Crewforth's (server/lib/crew-map.js reads it). This file puts each node in a band, says which lines
4
- // a selection lights, orders the findings and words them. It invents no layer: a module whose place the map does
5
- // not show goes to "Unplaced", and a finding the scanner hides by default stays hidden until asked for.
4
+ // a selection lights, orders the findings and words them. It invents no layer: a module's layer is the one /crew-map
5
+ // wrote for it (citing the lines it rests on); without one, its band is what the scan shows of it, and a module whose
6
+ // place the scan does not show goes to "Unplaced". A finding the scanner hides by default stays hidden until asked.
6
7
 
8
+ // The layers /crew-map may write (eval/lib/crew-map/narrative.mjs LAYERS), set between the bands the scan alone
9
+ // gives, so a map with no layer is drawn in the same order as before.
10
+ export const LAYER_KEYS = ['ui', 'api', 'service', 'data', 'infra', 'tool', 'library'];
7
11
  export const BANDS = [
12
+ ['ui', 'UI'],
8
13
  ['clients', 'Clients'],
14
+ ['api', 'API'],
9
15
  ['entry', 'Entry points'],
16
+ ['service', 'Service'],
10
17
  ['core', 'Core'],
18
+ ['data', 'Data'],
11
19
  ['infra', 'Infrastructure'],
20
+ ['library', 'Library'],
21
+ ['tool', 'Tools'],
12
22
  ['external', 'External'],
13
23
  ['unplaced', 'Unplaced'],
14
24
  ];
@@ -37,8 +47,10 @@ export function findingModules(f, modules) {
37
47
  * one that only calls endpoints → Clients
38
48
  * one another module of this project references → Core · one with an entry point and nothing above → Entry points
39
49
  * anything else → Unplaced, so a guess is never drawn as a fact.
50
+ * A module /crew-map gave a layer goes to that layer's band first.
40
51
  */
41
52
  export function bandOf(node, doc) {
53
+ if (node.kind === 'module' && LAYER_KEYS.includes(node.narrative?.layer)) return node.narrative.layer;
42
54
  if (node.kind === 'external') return 'external';
43
55
  if (node.kind === 'service') return 'infra';
44
56
  if (node.kind === 'client') return 'clients';
@@ -201,6 +213,52 @@ export function cardSub(node, doc) {
201
213
  return [tech || node.stack, provided ? `${provided} ${provided === 1 ? 'endpoint' : 'endpoints'}` : null].filter(Boolean).join(' · ');
202
214
  }
203
215
 
216
+ /**
217
+ * What a module's card says, as the Map draws it: a kind line (its domain, else its stack, and how many endpoints it
218
+ * provides), up to three of what it is built with, and the outside services it calls, folded into one line under it.
219
+ */
220
+ export const CARD_CHIPS = 3;
221
+ export function cardOf(node, doc) {
222
+ const provided = (doc.provides?.endpoints ?? []).filter((e) => e.module === node.id).length;
223
+ const kind = [node.narrative?.domain ?? node.stack ?? null, provided ? `${provided} ${provided === 1 ? 'endpoint' : 'endpoints'}` : null].filter(Boolean).join(' · ');
224
+ const tech = (node.tech ?? []).map((t) => t.replace(/@.*$/, ''));
225
+ return { kind, chips: tech.slice(0, CARD_CHIPS), more: Math.max(0, tech.length - CARD_CHIPS), outside: outsideOf(node, doc) };
226
+ }
227
+
228
+ /** The outside services a module calls, by name, in the order they are named: what its card folds under it. */
229
+ export function outsideOf(node, doc) {
230
+ const byId = new Map((doc.nodes ?? []).map((n) => [n.id, n]));
231
+ const names = [];
232
+ for (const e of doc.edges ?? []) {
233
+ if (e.from !== node.id) continue;
234
+ const to = byId.get(e.to);
235
+ if (to?.kind !== 'external') continue;
236
+ const name = to.label && to.label !== 'unknown' ? to.label : (to.hosts ?? [])[0] ?? to.id.replace(/^ext:[^:]+:/, '');
237
+ if (!names.includes(name)) names.push(name);
238
+ }
239
+ return names.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
240
+ }
241
+
242
+ /** The tour /crew-map wrote, the stops whose node is on this map, each with the node's name. */
243
+ export function tourOf(doc) {
244
+ const byId = new Map((doc.nodes ?? []).map((n) => [n.id, n]));
245
+ return (Array.isArray(doc.tour) ? doc.tour : []).filter((s) => s && byId.has(s.node) && typeof s.text === 'string')
246
+ .map((s) => ({ node: s.node, name: byId.get(s.node).name ?? byId.get(s.node).label ?? s.node, text: s.text, cites: (s.cites ?? []).filter((c) => typeof c === 'string') }));
247
+ }
248
+
249
+ /**
250
+ * The nodes a search names: by name, folder, what they are built with, their domain, the folders inside them and
251
+ * what their README says. Case does not matter; an empty search names none.
252
+ */
253
+ export function searchOf(query, nodes) {
254
+ const q = String(query ?? '').trim().toLowerCase();
255
+ if (!q) return new Set();
256
+ const hay = (n) => [n.name, n.label, n.path, n.stack, ...(n.tech ?? []), ...(n.hosts ?? []), n.narrative?.domain, n.narrative?.layer,
257
+ ...(n.structure?.folders ?? []).map((f) => f.folder), ...(n.about?.evidence ?? []).map((e) => e.text)]
258
+ .filter((x) => typeof x === 'string').join('\n').toLowerCase();
259
+ return new Set(nodes.filter((n) => hay(n).includes(q)).map((n) => n.id));
260
+ }
261
+
204
262
  /** The summary pills: what the map holds, and how many findings the list shows. */
205
263
  export function summaryOf(doc, findings) {
206
264
  const n = (k) => (doc.nodes ?? []).filter((x) => x.kind === k).length;
@@ -247,6 +305,16 @@ export function inspectOf(node, doc, edges, findings) {
247
305
  narrative: typeof node.narrative?.text === 'string' ? node.narrative.text : null,
248
306
  narrativeCites: Array.isArray(node.narrative?.cites) ? node.narrative.cites.filter((c) => typeof c === 'string') : [],
249
307
  narrativeNote: 'Not written yet: this map holds no description of the module, only what its files show.',
308
+ // /crew-map's layer and domain, when it wrote them; the band is the layer's (bandOf).
309
+ layer: LAYER_KEYS.includes(node.narrative?.layer) ? (BANDS.find(([k]) => k === node.narrative.layer)?.[1] ?? null) : null,
310
+ domain: typeof node.narrative?.domain === 'string' ? node.narrative.domain : null,
311
+ // What is inside it: the entry point the scan found, its folders with how many source files each holds, tests apart.
312
+ inside: node.kind === 'module' ? {
313
+ entry: node.entryPoint?.evidence?.[0] ? `${node.entryPoint.evidence[0].file}:${node.entryPoint.evidence[0].line}` : (node.entryPoint?.file ?? null),
314
+ folders: (node.structure?.folders ?? []).map((f) => ({ folder: f.folder, files: f.files })),
315
+ other: node.structure?.otherFiles ?? 0,
316
+ tests: node.structure?.testFiles ?? 0,
317
+ } : null,
250
318
  connectsTo: out,
251
319
  connections: { out, in: into },
252
320
  byKind: Object.entries(byKind).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
@@ -274,7 +342,8 @@ export function emptyNote(data) {
274
342
  return null;
275
343
  }
276
344
 
277
- export const CARD = { module: [200, 64], service: [200, 64], client: [200, 64], external: [104, 52] };
345
+ // A module card holds its name and kind, a row of what it is built with, and the line of outside services under it.
346
+ export const CARD = { module: [280, 104], service: [280, 104], client: [280, 104], group: [280, 104], external: [104, 52] };
278
347
  const GAP_X = 24;
279
348
  const GAP_Y = 20;
280
349
  const LEFT = 140; // the band's label sits in this column
@@ -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);