crewforth 3.1.0 → 3.2.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +4 -3
  3. package/VERSION +1 -1
  4. package/adopt.sh +91 -11
  5. package/kit/AGENT_TEMPLATE.md +2 -1
  6. package/kit/CLAUDE.md +9 -8
  7. package/kit/agents/crew-commit-agent.md +7 -2
  8. package/kit/agents/crew-docs-agent.md +71 -0
  9. package/kit/agents/crew-planner.md +2 -0
  10. package/kit/agents/crew-refuter.md +77 -0
  11. package/kit/agents/crew-security-expert.md +3 -1
  12. package/kit/agents/crew-session-manager.md +11 -9
  13. package/kit/eval/doctor.sh +91 -3
  14. package/kit/eval/golden-routing.txt +9 -0
  15. package/kit/eval/lib/crew-map/crew-map.sh +26 -0
  16. package/kit/eval/lib/crew-map/detect.mjs +238 -0
  17. package/kit/eval/lib/crew-map/narrative.mjs +98 -0
  18. package/kit/eval/lib/crew-map/parse.mjs +305 -0
  19. package/kit/eval/lib/crew-map/project.mjs +163 -0
  20. package/kit/eval/lib/crew-map/rules/names.json +38 -0
  21. package/kit/eval/lib/crew-map/rules/packages.json +142 -0
  22. package/kit/eval/lib/crew-map/rules/python-stdlib.json +197 -0
  23. package/kit/eval/lib/crew-map/scan.mjs +214 -0
  24. package/kit/eval/lib/crew-map/stacks.mjs +441 -0
  25. package/kit/eval/lib/crew-map/surface.mjs +457 -0
  26. package/kit/eval/lib/findings/findings.mjs +410 -0
  27. package/kit/eval/lib/findings/findings.schema.json +122 -0
  28. package/kit/eval/lib/findings/findings.sh +29 -0
  29. package/kit/eval/lib/settings-json.awk +5 -0
  30. package/kit/eval/lib/star.sh +23 -7
  31. package/kit/eval/lib/statusline.settings.json +6 -0
  32. package/kit/eval/lib/statusline.sh +115 -0
  33. package/kit/eval/smoke-test.sh +465 -38
  34. package/kit/hooks/agent-outcome.sh +6 -3
  35. package/kit/hooks/guard-agent-model.sh +15 -5
  36. package/kit/hooks/guard-bash.sh +240 -182
  37. package/kit/hooks/guard-question.sh +73 -0
  38. package/kit/hooks/guard-write.sh +21 -0
  39. package/kit/hooks/prompt-approval.sh +320 -103
  40. package/kit/hooks/refuter-ledger.sh +80 -0
  41. package/kit/owned-blobs.tsv +4 -1
  42. package/kit/settings.json +33 -0
  43. package/kit/skills/crew-approve/SKILL.md +7 -0
  44. package/kit/skills/crew-doctor/SKILL.md +5 -1
  45. package/kit/skills/crew-handoff/SKILL.md +3 -2
  46. package/kit/skills/crew-map/SKILL.md +44 -0
  47. package/kit/skills/crew-review/SKILL.md +2 -0
  48. package/kit/skills/crew-update/SKILL.md +1 -1
  49. package/kit/skills/incident-runbook/SKILL.md +2 -2
  50. package/kit/skills/security-scan/references/verify.md +6 -3
  51. package/kit/studio/README.md +12 -0
  52. package/kit/studio/server/index.js +114 -11
  53. package/kit/studio/server/lib/crew-map.js +113 -0
  54. package/kit/studio/server/lib/model-outcomes.js +130 -15
  55. package/kit/studio/server/lib/pricing.js +3 -0
  56. package/kit/studio/server/lib/remote.js +99 -0
  57. package/kit/studio/server/lib/usage.js +74 -14
  58. package/kit/studio/web/app.js +214 -12
  59. package/kit/studio/web/canvas.js +44 -120
  60. package/kit/studio/web/graph-plan.js +10 -4
  61. package/kit/studio/web/index.html +10 -5
  62. package/kit/studio/web/map-plan.js +341 -0
  63. package/kit/studio/web/map.js +503 -0
  64. package/kit/studio/web/models-plan.js +61 -7
  65. package/kit/studio/web/models.js +19 -3
  66. package/kit/studio/web/style.css +125 -0
  67. package/kit/studio/web/usage.js +12 -0
  68. package/kit/studio/web/viewport.js +160 -0
  69. package/package.json +6 -5
  70. package/start.sh +10 -0
@@ -52,7 +52,13 @@ export const AUTO = {
52
52
  readable: 0.75,
53
53
  // Same-type siblings fold into one card from this many, once the picture is too tall.
54
54
  stackAt: 3,
55
+ // How far past what fits a picture may run and still be drawn as cards: the rest is a pan away, at a zoom the
56
+ // cards are read at (the canvas's own fit does not go under `readable` then). A long column of agent types beside
57
+ // an empty half of the pane turned to chips at 1.0; 1.5 is a starting point, not a measured one.
58
+ overflow: 1.5,
55
59
  };
60
+ /** The height past which Auto gives cards up: what fits, and the overflow it allows on top. */
61
+ const overLimit = (h, tallest) => h > tallest * AUTO.overflow;
56
62
 
57
63
  export const GROUPINGS = ['run', 'type', 'parent', 'order', 'none'];
58
64
  export const DENSITIES = ['auto', 'comfortable', 'compact'];
@@ -422,12 +428,12 @@ export function plan(nodes, opts = {}) {
422
428
  let compact = asked === 'compact';
423
429
  let stacked = false;
424
430
  let box = lay(pass.shown, compact);
425
- if (asked === 'auto' && box.height > tallest) {
431
+ if (asked === 'auto' && overLimit(box.height, tallest)) {
426
432
  if (group !== 'none' && group !== 'type') {
427
433
  const folded = drawFrom(itemsOf(nodes, { group, density: asked, seq, stack: true, aside }));
428
434
  if (folded.shown.some((it) => it.kind === 'group' && it.type === 'type')) { pass = folded; stacked = true; box = lay(pass.shown, false); }
429
435
  }
430
- if (box.height > tallest) { compact = true; box = lay(pass.shown, true); }
436
+ if (overLimit(box.height, tallest)) { compact = true; box = lay(pass.shown, true); }
431
437
  }
432
438
  const drawnItems = pass.shown;
433
439
  const drawn = pass.count;
@@ -523,7 +529,7 @@ function planOrder(nodes, asked, opts, tallest = AUTO.tallest) {
523
529
  };
524
530
  let compact = asked === 'compact';
525
531
  let box = lay(compact);
526
- if (asked === 'auto' && box.height > tallest) { compact = true; box = lay(true); }
532
+ if (asked === 'auto' && overLimit(box.height, tallest)) { compact = true; box = lay(true); }
527
533
  const { items } = box;
528
534
  let { height } = box;
529
535
 
@@ -586,7 +592,7 @@ export function crowdFolds(nodes, opts = {}) {
586
592
  const folds = [];
587
593
  for (let guard = 0; guard < 64; guard += 1) {
588
594
  const p = plan(nodes, { ...opts, open, pinned: new Map(), sessionY: null });
589
- if (p.height <= Math.max(AUTO.tallest, opts.tallest ?? 0)) break;
595
+ if (!overLimit(p.height, Math.max(AUTO.tallest, opts.tallest ?? 0))) break;
590
596
  const next = p.items.filter((it) => it.kind === 'group' && it.open && it.members.length > 1)
591
597
  .sort((a, b) => b.members.length - a.members.length || String(a.id).localeCompare(String(b.id)))[0];
592
598
  if (!next) break;
@@ -25,6 +25,7 @@
25
25
  <span class="bar-fill"></span>
26
26
  <div id="graph-summary" class="chips" role="group" aria-label="Session summary"></div>
27
27
  <span class="bar-sep" aria-hidden="true"></span>
28
+ <span id="role" class="badge" title="This link reads the panel and changes nothing" hidden>Read-only</span>
28
29
  <span id="pulse" class="live" role="status" data-state="connecting"><span class="dot"></span><strong>Connecting</strong><span class="live-detail"></span></span>
29
30
  <button id="theme" class="btn icon ghost" type="button" aria-label="Switch theme">
30
31
  <svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M13 9.5A5.5 5.5 0 0 1 6.5 3a5.5 5.5 0 1 0 6.5 6.5z"/></svg>
@@ -95,6 +96,9 @@
95
96
  <button id="view-models" class="seg-btn" type="button" role="tab" aria-label="Models view" aria-selected="false" aria-controls="models">
96
97
  <svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 13V8M8 13V3M13 13V6"/></svg><span class="tb-words">Models</span>
97
98
  </button>
99
+ <button id="view-map" class="seg-btn" type="button" role="tab" aria-label="Map view" aria-selected="false" aria-controls="map">
100
+ <svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="2.5" width="5" height="3.5" rx="1"/><rect x="9" y="2.5" width="5" height="3.5" rx="1"/><rect x="5.5" y="10" width="5" height="3.5" rx="1"/><path d="M4.5 6v2h7V6M8 8v2"/></svg><span class="tb-words">Map</span>
101
+ </button>
98
102
  </div>
99
103
  <button id="tb-group" data-view="graph timeline" class="btn sm ghost tb-menu" type="button" aria-haspopup="menu">Group <span>Workflow run</span><svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg></button>
100
104
  <button id="tb-density" data-view="graph" class="btn sm ghost tb-menu" type="button" aria-haspopup="menu">Density <span>Auto</span><svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M4 6l4 4 4-4"/></svg></button>
@@ -119,17 +123,17 @@
119
123
  <button id="tb-fold" data-view="graph timeline list" class="btn sm" type="button" aria-label="Fold all groups">
120
124
  <svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M6 3l5 5-5 5"/></svg><span class="tb-words">Fold all</span>
121
125
  </button>
122
- <button id="tb-zoom-out" data-view="graph" class="btn icon sm ghost" type="button" aria-label="Zoom out">
126
+ <button id="tb-zoom-out" data-view="graph map" class="btn icon sm ghost" type="button" aria-label="Zoom out">
123
127
  <svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8h10"/></svg>
124
128
  </button>
125
- <button id="tb-zoom" data-view="graph" class="tb-zoom" type="button" title="Back to 100%" aria-label="Zoom 100%. Back to 100%">100%</button>
126
- <button id="tb-zoom-in" data-view="graph" class="btn icon sm ghost" type="button" aria-label="Zoom in">
129
+ <button id="tb-zoom" data-view="graph map" class="tb-zoom" type="button" title="Back to 100%" aria-label="Zoom 100%. Back to 100%">100%</button>
130
+ <button id="tb-zoom-in" data-view="graph map" class="btn icon sm ghost" type="button" aria-label="Zoom in">
127
131
  <svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M8 3v10M3 8h10"/></svg>
128
132
  </button>
129
- <button id="tb-reset" data-view="graph" class="btn sm" type="button" title="Reset layout (r)" aria-label="Reset layout: put the cards moved by hand back where they belong" disabled>
133
+ <button id="tb-reset" data-view="graph map" class="btn sm" type="button" title="Reset layout (r)" aria-label="Reset layout: put the cards moved by hand back where they belong" disabled>
130
134
  <svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8a5 5 0 1 0 1.6-3.7M3 3v3h3"/></svg><span class="tb-words">Reset layout</span>
131
135
  </button>
132
- <button id="tb-fit" data-view="graph" class="btn sm" type="button" aria-label="Fit the graph to the view">
136
+ <button id="tb-fit" data-view="graph map" class="btn sm" type="button" aria-label="Fit the graph to the view">
133
137
  <svg class="ic" viewBox="0 0 16 16" aria-hidden="true"><path d="M2.5 5.5v-3h3M10.5 2.5h3v3M13.5 10.5v3h-3M5.5 13.5h-3v-3"/></svg><span class="tb-words">Fit</span>
134
138
  </button>
135
139
  </div>
@@ -139,6 +143,7 @@
139
143
  <div id="timeline" role="tabpanel" aria-labelledby="view-timeline" hidden></div>
140
144
  <div id="list" role="tabpanel" aria-labelledby="view-list" hidden></div>
141
145
  <div id="models" role="tabpanel" aria-labelledby="view-models" hidden></div>
146
+ <div id="map" role="tabpanel" aria-labelledby="view-map" hidden></div>
142
147
  <div id="first-run" class="first-run" hidden></div>
143
148
  <div id="terminal-wait" class="terminal-wait" role="status" hidden></div>
144
149
  <div id="dock" role="region" aria-label="Waiting for approval" hidden></div>
@@ -0,0 +1,341 @@
1
+ // What the Map view draws, worked out from a crew map. No page in it.
2
+ //
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.
6
+
7
+ export const BANDS = [
8
+ ['clients', 'Clients'],
9
+ ['entry', 'Entry points'],
10
+ ['core', 'Core'],
11
+ ['infra', 'Infrastructure'],
12
+ ['external', 'External'],
13
+ ['unplaced', 'Unplaced'],
14
+ ];
15
+
16
+ const RESOURCE = { db: 'db', cache: 'cache', queue: 'queue', auth: 'auth', 'http-api': 'api' };
17
+ export const resourceWord = (r) => RESOURCE[r] ?? String(r ?? 'external');
18
+
19
+ /** The modules a finding is about: named in its data, or the module whose folder holds its first cited file. */
20
+ export function findingModules(f, modules) {
21
+ const named = [f?.data?.module, ...(Array.isArray(f?.data?.modules) ? f.data.modules : [])].filter((x) => typeof x === 'string');
22
+ if (named.length) return [...new Set(named)];
23
+ const file = f?.evidence?.[0]?.file;
24
+ if (typeof file !== 'string') return [];
25
+ let best = null;
26
+ for (const m of modules) {
27
+ const dir = m.path === '.' || m.path === '' ? '' : `${m.path}/`;
28
+ if ((dir === '' || file.startsWith(dir)) && (!best || dir.length > best.dir.length)) best = { id: m.id, dir };
29
+ }
30
+ return best ? [best.id] : [];
31
+ }
32
+
33
+ /**
34
+ * Each node's band, from what the map shows of it:
35
+ * external resource → External · container service → Infrastructure · a module of a compared project → Clients
36
+ * a module that provides an endpoint, or that a container service builds → Entry points
37
+ * one that only calls endpoints → Clients
38
+ * one another module of this project references → Core · one with an entry point and nothing above → Entry points
39
+ * anything else → Unplaced, so a guess is never drawn as a fact.
40
+ */
41
+ export function bandOf(node, doc) {
42
+ if (node.kind === 'external') return 'external';
43
+ if (node.kind === 'service') return 'infra';
44
+ if (node.kind === 'client') return 'clients';
45
+ if ((doc.provides?.endpoints ?? []).some((e) => e.module === node.id)) return 'entry';
46
+ if ((doc.edges ?? []).some((e) => e.to === node.id && e.kind === 'builds')) return 'entry';
47
+ if ((doc.consumes?.endpoints ?? []).some((e) => e.module === node.id)) return 'clients';
48
+ if ((doc.edges ?? []).some((e) => e.to === node.id && e.from !== node.id && e.from.startsWith('mod:'))) return 'core';
49
+ if (node.entry) return 'entry';
50
+ return 'unplaced';
51
+ }
52
+
53
+ /**
54
+ * The nodes the view draws, each with its band, and the clients of a compared project as nodes of their own.
55
+ * @param compare the server's `compare` ({ label, clients }) or null
56
+ */
57
+ export function nodesOf(doc, compare = null) {
58
+ const own = (doc.nodes ?? []).map((n) => ({ ...n, band: bandOf(n, doc) }));
59
+ const clients = (compare?.clients ?? []).map((c) => ({
60
+ id: `cmp:${c.id}`, kind: 'client', name: c.name, tech: c.tech ?? [], from: compare.label, calls: c.calls, missing: c.missing, band: 'clients',
61
+ }));
62
+ return [...clients, ...own];
63
+ }
64
+
65
+ /** The edges between drawn nodes; a compared client gets one edge to each module of this project it calls. */
66
+ export function edgesOf(doc, compare = null) {
67
+ const own = (doc.edges ?? []).map((e) => ({ ...e, solid: e.confidence !== 'inferred', line: lineOf(e) }));
68
+ const extra = [];
69
+ for (const c of compare?.clients ?? []) {
70
+ // The module each call lands on is the server's match (`to`), never a guess made here.
71
+ const to = new Map();
72
+ for (const call of c.calls) if (call.to) to.set(call.to, (to.get(call.to) ?? 0) + 1);
73
+ for (const [m, n] of to) extra.push({ from: `cmp:${c.id}`, to: m, kind: 'calls', confidence: 'evidence', solid: true, line: 'evidence', calls: n, missing: c.missing.length });
74
+ }
75
+ return [...extra, ...own];
76
+ }
77
+
78
+ export const edgeKey = (e) => `${e.from}>${e.to}>${e.kind}`;
79
+
80
+ /**
81
+ * How a line is drawn: `evidence` (solid, a file proves it), `inferred` (dotted) or `model` (a line the model
82
+ * proposed: dashed with its own mark, never drawn as if a file showed it).
83
+ */
84
+ export function lineOf(e) {
85
+ if (e?.source === 'model') return 'model';
86
+ return e?.confidence === 'inferred' ? 'inferred' : 'evidence';
87
+ }
88
+
89
+ /** An external resource every line of which is inferred is itself only inferred, and its card says so. */
90
+ export function inferredOnly(node, edges) {
91
+ if (node.kind !== 'external') return false;
92
+ const into = edges.filter((e) => e.to === node.id || e.from === node.id);
93
+ return into.length > 0 && into.every((e) => lineOf(e) !== 'evidence');
94
+ }
95
+
96
+ /**
97
+ * The folders that hold two or more modules of one band: a group the view can fold into one card. A module at the
98
+ * project's root, or alone in its folder, is in no group. { groupId: { id, band, folder, members: [ids] } }
99
+ */
100
+ export function groupsOf(nodes) {
101
+ const by = new Map();
102
+ for (const n of nodes) {
103
+ if (n.kind !== 'module' || typeof n.path !== 'string' || !n.path.includes('/')) continue;
104
+ const folder = n.path.slice(0, n.path.lastIndexOf('/'));
105
+ const id = `grp:${n.band}:${folder}`;
106
+ const g = by.get(id) ?? by.set(id, { id, band: n.band, folder, members: [] }).get(id);
107
+ g.members.push(n.id);
108
+ }
109
+ return Object.fromEntries([...by].filter(([, g]) => g.members.length > 1));
110
+ }
111
+
112
+ /**
113
+ * The nodes and edges with the folded groups folded: each folded group is one card in place of its members, and
114
+ * the members' lines are its lines, one per (from, to, kind), drawn solid when any of them is.
115
+ */
116
+ export function foldView(nodes, edges, groups, folded) {
117
+ const into = new Map();
118
+ for (const g of Object.values(groups)) if (folded.has(g.id)) for (const m of g.members) into.set(m, g.id);
119
+ if (!into.size) return { nodes, edges };
120
+ const out = [];
121
+ const seen = new Set();
122
+ for (const n of nodes) {
123
+ const gid = into.get(n.id);
124
+ if (!gid) { out.push(n); continue; }
125
+ if (seen.has(gid)) continue;
126
+ seen.add(gid);
127
+ const g = groups[gid];
128
+ out.push({ id: gid, kind: 'group', band: g.band, name: `${g.folder}/`, folder: g.folder, members: g.members });
129
+ }
130
+ const em = new Map();
131
+ for (const e of edges) {
132
+ const from = into.get(e.from) ?? e.from;
133
+ const to = into.get(e.to) ?? e.to;
134
+ if (from === to) continue;
135
+ const k = `${from}>${to}>${e.kind}`;
136
+ const was = em.get(k);
137
+ if (!was) em.set(k, { ...e, from, to });
138
+ else if (lineOf(e) === 'evidence' && was.line !== 'evidence') em.set(k, { ...was, solid: true, line: 'evidence' });
139
+ }
140
+ return { nodes: out, edges: [...em.values()] };
141
+ }
142
+
143
+ /** The layout with the viewer's own placings over it: a card placed by hand stays where it was put. */
144
+ export function placed(lay, pins) {
145
+ const pos = { ...lay.pos };
146
+ for (const [id, p] of Object.entries(pins ?? {})) if (pos[id]) pos[id] = { ...pos[id], x: p.x, y: p.y };
147
+ const right = Object.values(pos).reduce((m, p) => Math.max(m, p.x + p.w), lay.width);
148
+ const bottom = Object.values(pos).reduce((m, p) => Math.max(m, p.y + p.h), lay.height);
149
+ return { ...lay, pos, width: right, height: bottom };
150
+ }
151
+
152
+ /** Which lines a selection lights: the selected node's own, in and out. With nothing selected, none is lit. */
153
+ export function litEdges(edges, selected) {
154
+ if (!selected) return new Set();
155
+ return new Set(edges.filter((e) => e.from === selected || e.to === selected).map(edgeKey));
156
+ }
157
+
158
+ /**
159
+ * The findings the list shows, proven first, then by title. Hidden by default: the scanner's own mark
160
+ * (`data.defaultHidden`, for rules too often wrong to show unasked); shown when `showHidden`. A compared client's
161
+ * calls this project does not provide are findings too, marked as coming from the comparison.
162
+ */
163
+ export function findingsOf(doc, { showHidden = false, compare = null } = {}) {
164
+ const modules = (doc.nodes ?? []).filter((n) => n.kind === 'module');
165
+ const all = (doc.findings ?? []).map((f) => ({
166
+ id: f.id, rule: f.rule, title: f.title, proven: f.confidence === 'proven', hidden: Boolean(f.data?.defaultHidden),
167
+ hiddenReason: f.data?.hiddenReason ?? null, where: whereOf(f), modules: findingModules(f, modules), evidence: f.evidence ?? [], from: null,
168
+ }));
169
+ for (const c of compare?.clients ?? []) {
170
+ for (const m of c.missing) {
171
+ all.push({
172
+ id: `cmp:${c.id}:${m.method} ${m.path}`, rule: 'compare-missing', title: `${m.method} ${m.path} is called by ${c.name} and not provided here`,
173
+ proven: true, hidden: false, hiddenReason: null, where: m.evidence?.[0] ? `${m.evidence[0].file}:${m.evidence[0].line}` : '', modules: [`cmp:${c.id}`],
174
+ evidence: m.evidence ?? [], from: compare.label,
175
+ });
176
+ }
177
+ }
178
+ const shown = all.filter((f) => showHidden || !f.hidden);
179
+ shown.sort((a, b) => Number(b.proven) - Number(a.proven) || (a.title < b.title ? -1 : a.title > b.title ? 1 : 0));
180
+ return { shown, hidden: all.filter((f) => f.hidden).length, proven: shown.filter((f) => f.proven).length, total: all.length };
181
+ }
182
+
183
+ function whereOf(f) {
184
+ const e = f.evidence?.[0];
185
+ return e ? `${e.file}:${e.line}` : '';
186
+ }
187
+
188
+ /** How many findings each drawn node carries, of those the list shows. */
189
+ export function findingCounts(findings) {
190
+ const out = {};
191
+ for (const f of findings) for (const m of f.modules) out[m] = (out[m] ?? 0) + 1;
192
+ return out;
193
+ }
194
+
195
+ /** What a card says under its name. */
196
+ export function cardSub(node, doc) {
197
+ if (node.kind === 'client') return `${node.from} · ${node.calls.length} ${node.calls.length === 1 ? 'call' : 'calls'} here`;
198
+ if (node.kind === 'service') return node.image ? `container · ${node.image}` : 'container';
199
+ const provided = (doc.provides?.endpoints ?? []).filter((e) => e.module === node.id).length;
200
+ const tech = (node.tech ?? []).slice(0, 2).map((t) => t.replace(/@.*$/, '')).join(' · ');
201
+ return [tech || node.stack, provided ? `${provided} ${provided === 1 ? 'endpoint' : 'endpoints'}` : null].filter(Boolean).join(' · ');
202
+ }
203
+
204
+ /** The summary pills: what the map holds, and how many findings the list shows. */
205
+ export function summaryOf(doc, findings) {
206
+ const n = (k) => (doc.nodes ?? []).filter((x) => x.kind === k).length;
207
+ const eps = (doc.provides?.endpoints?.length ?? 0) + (doc.consumes?.endpoints?.length ?? 0);
208
+ return [
209
+ `${n('module')} ${n('module') === 1 ? 'module' : 'modules'}`,
210
+ `${n('external')} external`,
211
+ `${eps} ${eps === 1 ? 'endpoint' : 'endpoints'}`,
212
+ `${findings.shown.length} ${findings.shown.length === 1 ? 'finding' : 'findings'}`,
213
+ ];
214
+ }
215
+
216
+ /** The source strip: the file, the commit it was scanned at, and how far HEAD has moved since. */
217
+ export function sourceOf(data) {
218
+ const commit = data.doc?.source?.commit ?? null;
219
+ const behind = data.behind?.count;
220
+ return {
221
+ file: data.file,
222
+ at: commit ? `scanned at ${commit.slice(0, 7)}` : 'scanned outside git',
223
+ behind: typeof behind === 'number' && behind > 0 ? `${behind} ${behind === 1 ? 'commit' : 'commits'} behind` : null,
224
+ behindNote: typeof behind === 'number' ? null : (data.behind?.reason ?? null),
225
+ rule: 'Nothing is run; files are only read.',
226
+ };
227
+ }
228
+
229
+ /** What the inspector shows for one node. */
230
+ export function inspectOf(node, doc, edges, findings) {
231
+ const byId = new Map(nodesOf(doc).map((n) => [n.id, n]));
232
+ const nameOf = (id) => byId.get(id)?.name ?? byId.get(id)?.label ?? id.replace(/^(mod|svc|ext):/, '');
233
+ const line = (e, other) => ({
234
+ kind: e.kind, to: nameOf(other), host: byId.get(other)?.hosts?.[0] ?? null,
235
+ evidence: e.evidence?.[0] ? `${e.evidence[0].file}:${e.evidence[0].line}` : null, solid: e.solid, line: e.line ?? lineOf(e),
236
+ });
237
+ const out = edges.filter((e) => e.from === node.id).map((e) => line(e, e.to));
238
+ const into = edges.filter((e) => e.to === node.id).map((e) => line(e, e.from));
239
+ // Connections, by the kind of line: what the inspector's second tab lists.
240
+ const byKind = {};
241
+ for (const [dir, list] of [['out', out], ['in', into]]) for (const c of list) (byKind[c.kind] ??= { out: [], in: [] })[dir].push(c);
242
+ return {
243
+ title: node.name ?? node.label ?? node.id,
244
+ path: node.path ?? (node.kind === 'external' ? (node.hosts ?? []).join(', ') : null),
245
+ chips: (node.tech ?? []).map((t) => t.replace('@', ' ')),
246
+ // /crew-map's description: { text, cites }, each cite a file:line of this node's evidence (eval/lib/crew-map/narrative.mjs).
247
+ narrative: typeof node.narrative?.text === 'string' ? node.narrative.text : null,
248
+ narrativeCites: Array.isArray(node.narrative?.cites) ? node.narrative.cites.filter((c) => typeof c === 'string') : [],
249
+ narrativeNote: 'Not written yet: this map holds no description of the module, only what its files show.',
250
+ connectsTo: out,
251
+ connections: { out, in: into },
252
+ byKind: Object.entries(byKind).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
253
+ // Every line the map cites for this node itself, as file:line: what the inspector lets the viewer copy.
254
+ evidence: (node.evidence ?? []).map((e) => `${e.file}:${e.line}`),
255
+ evidenceTotal: node.evidenceTotal ?? (node.evidence ?? []).length,
256
+ findings: findings.filter((f) => f.modules.includes(node.id)),
257
+ };
258
+ }
259
+
260
+ /** A finding as a task to paste into a session: what, where, and that the map is the source. */
261
+ export function taskOf(f) {
262
+ return [
263
+ `${f.title}.`,
264
+ f.where ? `Evidence: ${f.where}${f.evidence?.[0]?.text ? ` (${f.evidence[0].text})` : ''}.` : null,
265
+ f.proven ? null : 'The crew map inferred this; check it before acting on it.',
266
+ f.from ? `Found by comparing this project's map with ${f.from}'s.` : `From the crew map (rule ${f.rule}).`,
267
+ ].filter(Boolean).join(' ');
268
+ }
269
+
270
+ /** What the view says when there is no map to draw. */
271
+ export function emptyNote(data) {
272
+ if (!data) return 'Reading the map…';
273
+ if (data.measured === false) return data.missing ? `No map yet — ${data.reason}` : `Not read — ${data.reason}`;
274
+ return null;
275
+ }
276
+
277
+ export const CARD = { module: [200, 64], service: [200, 64], client: [200, 64], external: [104, 52] };
278
+ const GAP_X = 24;
279
+ const GAP_Y = 20;
280
+ const LEFT = 140; // the band's label sits in this column
281
+ const PAD_Y = 18;
282
+ const CHIP_ROW = 26; // one row of endpoint chips under a card, when the Endpoints layer is on
283
+ export const CHIPS_MAX = 4;
284
+
285
+ /**
286
+ * Where everything sits: bands from top to bottom, each as tall as its rows of cards; cards left to right,
287
+ * wrapping at `width`. Empty bands are not drawn. A card with endpoint chips (`chips[id]`) is taller. With
288
+ * `rowKey`, a run of cards that share a key (an open folder group) starts a row of its own and the next card after
289
+ * it does too, so a frame drawn around the run holds those cards and no other.
290
+ * @returns { bands: [{ key, label, y, h }], pos: { id: { x, y, w, h } }, width, height }
291
+ */
292
+ export function layoutOf(nodes, { width = 960, chips = {}, rowKey = null } = {}) {
293
+ const pos = {};
294
+ const bands = [];
295
+ let y = 0;
296
+ for (const [key, label] of BANDS) {
297
+ const members = nodes.filter((n) => n.band === key);
298
+ if (!members.length) continue;
299
+ let x = LEFT; let rowY = y + PAD_Y; let rowH = 0; let prev = null;
300
+ for (const n of members) {
301
+ const key = rowKey?.(n) ?? null;
302
+ if (x > LEFT && key !== prev && (key || prev)) { x = LEFT; rowY += rowH + GAP_Y; rowH = 0; }
303
+ prev = key;
304
+ const [w, h0] = CARD[n.kind] ?? CARD.module;
305
+ const extra = Math.ceil(Math.min(chips[n.id] ?? 0, CHIPS_MAX + 1) / 2) * CHIP_ROW;
306
+ const h = h0 + extra;
307
+ if (x > LEFT && x + w > width) { x = LEFT; rowY += rowH + GAP_Y; rowH = 0; }
308
+ pos[n.id] = { x, y: rowY, w, h };
309
+ x += w + GAP_X; rowH = Math.max(rowH, h);
310
+ }
311
+ const h = rowY + rowH + PAD_Y - y;
312
+ bands.push({ key, label, y, h });
313
+ y += h;
314
+ }
315
+ const right = Object.values(pos).reduce((m, p) => Math.max(m, p.x + p.w), LEFT);
316
+ return { bands, pos, width: Math.max(width, right + GAP_X), height: y };
317
+ }
318
+
319
+ /** A line between two cards: out of the bottom of the upper one into the top of the lower, or side to side. */
320
+ export function pathOf(a, b) {
321
+ if (!a || !b) return null;
322
+ const down = b.y >= a.y + a.h;
323
+ const up = a.y >= b.y + b.h;
324
+ if (down || up) {
325
+ const [s, t] = down ? [a, b] : [b, a];
326
+ const x1 = s.x + s.w / 2; const y1 = s.y + s.h; const x2 = t.x + t.w / 2; const y2 = t.y;
327
+ const m = (y1 + y2) / 2;
328
+ return { d: `M${x1} ${y1} C${x1} ${m} ${x2} ${m} ${x2} ${y2}`, mid: [(x1 + x2) / 2, m] };
329
+ }
330
+ const [s, t] = a.x <= b.x ? [a, b] : [b, a];
331
+ const x1 = s.x + s.w; const y1 = s.y + s.h / 2; const x2 = t.x; const y2 = t.y + t.h / 2;
332
+ return { d: `M${x1} ${y1} L${x2} ${y2}`, mid: [(x1 + x2) / 2, (y1 + y2) / 2] };
333
+ }
334
+
335
+ /** The endpoint chips of a module, as the Endpoints layer lists them: what it provides, then what it calls. */
336
+ export function chipsOf(node, doc) {
337
+ const p = (doc.provides?.endpoints ?? []).filter((e) => e.module === node.id).map((e) => `${e.method} ${e.path}`);
338
+ const c = (doc.consumes?.endpoints ?? []).filter((e) => e.module === node.id).map((e) => `→ ${e.method} ${e.path}`);
339
+ const all = [...p, ...c];
340
+ return all.length > CHIPS_MAX ? [...all.slice(0, CHIPS_MAX), `+${all.length - CHIPS_MAX} more`] : all;
341
+ }