@bugb-technologies/bravos 0.0.0-stage → 1.0.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 (85) hide show
  1. package/LICENSE +70 -0
  2. package/README.md +225 -2
  3. package/dist/account.js +140 -0
  4. package/dist/artifacts.js +450 -0
  5. package/dist/ci.js +52 -0
  6. package/dist/cli.js +963 -0
  7. package/dist/codegraph.js +268 -0
  8. package/dist/cursor.js +70 -0
  9. package/dist/detect.js +26 -0
  10. package/dist/doctor.js +99 -0
  11. package/dist/global.js +95 -0
  12. package/dist/graphbin.js +136 -0
  13. package/dist/guardlink.js +123 -0
  14. package/dist/guide.js +255 -0
  15. package/dist/help.js +334 -0
  16. package/dist/hooks.js +167 -0
  17. package/dist/install.js +209 -0
  18. package/dist/licence.js +41 -0
  19. package/dist/lock.js +50 -0
  20. package/dist/mcp.js +139 -0
  21. package/dist/migrate.js +275 -0
  22. package/dist/omp.js +46 -0
  23. package/dist/opencode.js +118 -0
  24. package/dist/paths.js +33 -0
  25. package/dist/plugin.js +101 -0
  26. package/dist/report/check.js +252 -0
  27. package/dist/report/data.js +401 -0
  28. package/dist/report/export.js +105 -0
  29. package/dist/report/markdown.js +310 -0
  30. package/dist/report/meta.js +48 -0
  31. package/dist/report/pdf.js +96 -0
  32. package/dist/review-server.js +7 -0
  33. package/dist/review.js +274 -0
  34. package/dist/setup.js +133 -0
  35. package/dist/skills.js +81 -0
  36. package/dist/types.js +9 -0
  37. package/dist/uninstall.js +192 -0
  38. package/dist/versions.js +8 -0
  39. package/dist/watch-frame.js +593 -0
  40. package/dist/watch.js +873 -0
  41. package/dist/workspace.js +316 -0
  42. package/docs/GUIDE.md +323 -0
  43. package/hooks/autopilot.mjs +45 -0
  44. package/hooks/lib/autopilot.mjs +191 -0
  45. package/hooks/lib/briefing.mjs +750 -0
  46. package/hooks/lib/licence.mjs +452 -0
  47. package/hooks/lib/pre-pr.mjs +209 -0
  48. package/hooks/lib/sast.mjs +207 -0
  49. package/hooks/lib/setup.mjs +107 -0
  50. package/hooks/lib/workspace.mjs +78 -0
  51. package/hooks/pre-pr.mjs +54 -0
  52. package/hooks/session-start.mjs +82 -0
  53. package/package.json +49 -4
  54. package/skills/bravos/SKILL.md +63 -0
  55. package/skills/bravos-annotate/SKILL.md +56 -0
  56. package/skills/bravos-annotate/scripts/lib/pick.mjs +127 -0
  57. package/skills/bravos-annotate/scripts/slice.mjs +141 -0
  58. package/skills/bravos-campaign/SKILL.md +49 -0
  59. package/skills/bravos-cicd/SKILL.md +48 -0
  60. package/skills/bravos-codegraph/SKILL.md +57 -0
  61. package/skills/bravos-context/SKILL.md +51 -0
  62. package/skills/bravos-context/intent.example.md +48 -0
  63. package/skills/bravos-env/SKILL.md +48 -0
  64. package/skills/bravos-env/scope.example.yaml +50 -0
  65. package/skills/bravos-prove/SKILL.md +52 -0
  66. package/skills/bravos-recheck/SKILL.md +47 -0
  67. package/skills/bravos-report/SKILL.md +53 -0
  68. package/skills/bravos-report/assessment.md +54 -0
  69. package/skills/bravos-report/brief.md +23 -0
  70. package/skills/bravos-report/finding.md +74 -0
  71. package/skills/bravos-report/retest.md +29 -0
  72. package/skills/bravos-report/style.md +44 -0
  73. package/skills/bravos-sast/SKILL.md +48 -0
  74. package/skills/bravos-schedule/SKILL.md +44 -0
  75. package/skills/bravos-schedule/loop.md +16 -0
  76. package/skills/bravos-ship/SKILL.md +58 -0
  77. package/skills/bravos-templatize/SKILL.md +48 -0
  78. package/skills/bravos-templatize/operator-guidance.md +20 -0
  79. package/templates/omp/bravos.js +62 -0
  80. package/templates/opencode/bravos.js +33 -0
  81. package/templates/report/report.html +88 -0
  82. package/templates/review/review.html +327 -0
  83. package/templates/workflows/bravos-nightly-claude.yml +173 -0
  84. package/templates/workflows/bravos-nightly-codex.yml +172 -0
  85. package/templates/workflows/bravos-pr-gate.yml +142 -0
@@ -0,0 +1,593 @@
1
+ // @comment -- "Draws bravos watch's frames: a header bar, a row of number tiles, bordered panels (setup, threat model, test next, top threats and assets, code graph, SAST, branch and team, the model's size, activity) arranged in three, two or one columns by terminal width, a scrollable body and a key bar. Pure: text in, text out. Colour is added per segment after layout, so the plain and coloured frames have the same characters, and every glyph that carries meaning (severity, bars) differs in plain text too."
2
+ /* ------------------------------------------------------------------ segments */
3
+ // Brand palette (bravos), plus amber for warnings and medium severity.
4
+ const C = {
5
+ green: '#33d49d',
6
+ deep: '#1f3943',
7
+ ink: '#0e1b21',
8
+ blue: '#0360a2',
9
+ light: '#f0f0f0',
10
+ steel: '#55899e',
11
+ slate: '#3b6779',
12
+ red: '#ea1d1d',
13
+ amber: '#f0b429',
14
+ white: '#ffffff',
15
+ line: '#2b4f5c',
16
+ };
17
+ const s = (t, c, b, bg) => ({ t, c, b, bg });
18
+ const len = (l) => l.reduce((n, x) => n + [...x.t].length, 0);
19
+ const nf = (n) => (n === null || n === undefined ? '?' : n.toLocaleString('en-US'));
20
+ /** Cuts a line of segments to width characters (an ellipsis on the cut) and pads it with spaces when pad is set; the padding takes padBg. */
21
+ export function fitLine(l, width, pad = false, padBg) {
22
+ const out = [];
23
+ let left = width;
24
+ for (const seg of l) {
25
+ if (left <= 0)
26
+ break;
27
+ const chars = [...seg.t];
28
+ if (chars.length <= left) {
29
+ out.push(seg);
30
+ left -= chars.length;
31
+ }
32
+ else {
33
+ out.push({ ...seg, t: chars.slice(0, Math.max(0, left - 1)).join('') + '…' });
34
+ left = 0;
35
+ }
36
+ }
37
+ if (pad && left > 0)
38
+ out.push(s(' '.repeat(left), undefined, false, padBg));
39
+ return out;
40
+ }
41
+ const ESC = '\x1b[';
42
+ const rgb = (hex) => `${parseInt(hex.slice(1, 3), 16)};${parseInt(hex.slice(3, 5), 16)};${parseInt(hex.slice(5, 7), 16)}`;
43
+ export const renderLine = (l, color) => l
44
+ .map((x) => (color && (x.c || x.b || x.bg) ? `${x.b ? `${ESC}1m` : ''}${x.c ? `${ESC}38;2;${rgb(x.c)}m` : ''}${x.bg ? `${ESC}48;2;${rgb(x.bg)}m` : ''}${x.t}${ESC}0m` : x.t))
45
+ .join('');
46
+ /* ------------------------------------------------------------------ small pieces */
47
+ const SEV = ['critical', 'high', 'medium', 'low'];
48
+ const sevColor = (sev) => (sev === 'critical' || sev === 'high' ? C.red : sev === 'medium' ? C.amber : C.steel);
49
+ // Each severity has its own glyph so the stacked bar reads without colour.
50
+ const sevGlyph = { critical: '█', high: '▓', medium: '▒', low: '░', unset: '·' };
51
+ const badge = (sev) => {
52
+ const label = ` ${{ critical: 'CRIT', high: 'HIGH', medium: 'MED', low: 'LOW' }[sev]?.padEnd(4) ?? ' — '} `;
53
+ return sev === 'critical' || sev === 'high' ? s(label, C.white, true, C.red) : sev === 'medium' ? s(label, C.ink, true, C.amber) : s(label, C.light, false, C.slate);
54
+ };
55
+ /** hh:mm in the local time zone; an unparseable time shows as --:--. */
56
+ export const time = (iso) => {
57
+ const d = new Date(iso);
58
+ return Number.isNaN(d.getTime()) ? '--:--' : `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
59
+ };
60
+ /** How long ago, short: 40s, 12m, 3h, 2d. */
61
+ export const ago = (iso, now) => {
62
+ const ms = new Date(now).getTime() - new Date(iso).getTime();
63
+ if (!Number.isFinite(ms))
64
+ return '?';
65
+ const sec = Math.max(0, Math.round(ms / 1000));
66
+ return sec < 60 ? `${sec}s` : sec < 3600 ? `${Math.round(sec / 60)}m` : sec < 86400 ? `${Math.round(sec / 3600)}h` : `${Math.round(sec / 86400)}d`;
67
+ };
68
+ const plural = (n, word) => `${nf(n)} ${word}${n === 1 ? '' : 's'}`;
69
+ const bar = (pct, width, c) => {
70
+ const w = Math.max(1, width);
71
+ const on = Math.max(0, Math.min(w, Math.round((pct / 100) * w)));
72
+ return [s('█'.repeat(on), c), s('░'.repeat(w - on), C.line)];
73
+ };
74
+ /** One bar split by severity, worst first, each part in its own glyph and colour. */
75
+ function sevBar(by, width) {
76
+ const total = SEV.reduce((n, k) => n + (by[k] ?? 0), 0);
77
+ if (!total)
78
+ return [s('░'.repeat(width), C.line)];
79
+ const parts = SEV.map((k) => ({ k, n: by[k] ?? 0, w: Math.floor(((by[k] ?? 0) / total) * width) }));
80
+ // Every non-empty severity gets at least one cell; the rest of the width goes to the largest.
81
+ for (const p of parts)
82
+ if (p.n > 0 && p.w === 0)
83
+ p.w = 1;
84
+ const used = parts.reduce((n, p) => n + p.w, 0);
85
+ const big = parts.reduce((a, b) => (b.n > a.n ? b : a));
86
+ big.w = Math.max(0, big.w + width - used);
87
+ return parts.filter((p) => p.w > 0).map((p) => s(sevGlyph[p.k].repeat(p.w), sevColor(p.k)));
88
+ }
89
+ /** A bar split into named parts, each with its glyph and colour. */
90
+ function partsBar(parts, width) {
91
+ const total = parts.reduce((n, p) => n + p.n, 0);
92
+ if (!total)
93
+ return [s('░'.repeat(width), C.line)];
94
+ const out = [];
95
+ let used = 0;
96
+ parts.forEach((p, k) => {
97
+ const w = k === parts.length - 1 ? width - used : Math.round((p.n / total) * width);
98
+ if (w > 0)
99
+ out.push(s(p.glyph.repeat(w), p.c));
100
+ used += Math.max(0, w);
101
+ });
102
+ return out;
103
+ }
104
+ const SPARK = '▁▂▃▄▅▆▇█';
105
+ /** A sparkline of values, scaled between their min and max; flat when they do not move. */
106
+ export function sparkline(values, width) {
107
+ const v = values.slice(-width);
108
+ if (!v.length)
109
+ return '';
110
+ const lo = Math.min(...v);
111
+ const hi = Math.max(...v);
112
+ return v.map((x) => SPARK[hi === lo ? 3 : Math.round(((x - lo) / (hi - lo)) * (SPARK.length - 1))]).join('');
113
+ }
114
+ /** Keeps the end of a long path, where the file name is, and cuts the start. */
115
+ export const tailPath = (p, w) => {
116
+ const c = [...p];
117
+ return c.length <= w || w < 4 ? p : '…' + c.slice(c.length - (w - 1)).join('');
118
+ };
119
+ /** A rounded box of exactly w columns: the title in the top edge, the body cut to fit. */
120
+ function box(p, w) {
121
+ const inner = Math.max(1, w - 4);
122
+ const title = ` ${p.title} `;
123
+ const right = p.right ? ` ${p.right} ` : '';
124
+ let fill = w - 3 - [...title].length - [...right].length - 1;
125
+ const top = fill >= 1 ? [s('╭─', C.line), s(title, C.green, true), s('─'.repeat(fill), C.line), s(right, p.rightColor ?? C.steel), s('─╮', C.line)] : [];
126
+ if (!top.length) {
127
+ fill = Math.max(0, w - 3 - [...title].length);
128
+ top.push(...fitLine([s('╭─', C.line), s(title, C.green, true), s('─'.repeat(fill), C.line)], w - 1), s('╮', C.line));
129
+ }
130
+ const body = p.body.map((l) => [s('│ ', C.line), ...fitLine(l, inner, true), s(' │', C.line)]);
131
+ return [top, ...body, [s('╰' + '─'.repeat(Math.max(0, w - 2)) + '╯', C.line)]];
132
+ }
133
+ /** A row of number tiles, three lines high: label in the top edge, the value large-ish in the middle. */
134
+ function tiles(items, width) {
135
+ const gap = 1;
136
+ // Wide enough for the longest label in its top edge.
137
+ const minW = Math.max(12, ...items.map((x) => [...x.label].length + 6));
138
+ const n = Math.max(1, Math.min(items.length, Math.floor((width + gap) / (minW + gap))));
139
+ const shown = items.slice(0, n);
140
+ const w = Math.floor((width - gap * (n - 1)) / n);
141
+ const rows = [[], [], []];
142
+ shown.forEach((t, k) => {
143
+ const label = ` ${t.label} `;
144
+ const lw = Math.max(0, w - 3 - [...label].length);
145
+ rows[0].push(...fitLine([s('╭─', C.line), s(label, C.steel), s('─'.repeat(lw), C.line)], w - 1), s('╮', C.line));
146
+ const val = [s(t.value, t.c, true), ...(t.sub ? [s((t.sub.startsWith('/') ? '' : ' ') + t.sub, C.steel)] : [])];
147
+ const lead = Math.max(0, Math.floor((w - 2 - len(val)) / 2));
148
+ rows[1].push(s('│', C.line), ...fitLine([s(' '.repeat(lead)), ...val], w - 2, true), s('│', C.line));
149
+ rows[2].push(s('╰' + '─'.repeat(Math.max(0, w - 2)) + '╯', C.line));
150
+ if (k < shown.length - 1)
151
+ rows.forEach((r) => r.push(s(' '.repeat(gap))));
152
+ });
153
+ return rows;
154
+ }
155
+ function setupPanel(sn, inner) {
156
+ const pct = sn.setup.total ? Math.round((sn.setup.done / sn.setup.total) * 100) : 0;
157
+ const body = [[...bar(pct, inner - 6, C.green), s(` ${String(pct).padStart(3)}%`, C.green, true)]];
158
+ for (const x of sn.setup.items) {
159
+ const isNext = sn.setup.next && x.label === sn.setup.next.label;
160
+ body.push(x.on ? [s('✔ ', C.green), s(x.label)] : isNext ? [s('▸ ', C.green, true), s(x.label, C.green, true)] : [s('· ', C.slate), s(x.label, C.steel)]);
161
+ }
162
+ if (sn.setup.next) {
163
+ body.push([], [s('next ', C.steel), s(sn.setup.next.cta, C.green, true)]);
164
+ if (sn.setup.next.how)
165
+ body.push([s(sn.setup.next.how, C.steel)]);
166
+ }
167
+ else
168
+ body.push([], [s('everything bravos can use here is on', C.green)]);
169
+ return { title: 'SETUP', right: `${sn.setup.done}/${sn.setup.total}`, rightColor: C.green, body };
170
+ }
171
+ function modelPanel(sn, inner, history) {
172
+ if (!sn.counts) {
173
+ return { title: 'THREAT MODEL', right: 'unknown', rightColor: C.amber, body: [[s(sn.guardlink === 'missing' ? 'guardlink not found → bravos doctor' : 'counts unknown (guardlink ci unavailable)', C.amber)]] };
174
+ }
175
+ const c = sn.counts;
176
+ const body = [sevBar(c.bySeverity, inner)];
177
+ const legend = [];
178
+ for (const k of SEV)
179
+ legend.push(s(`${sevGlyph[k]} `, sevColor(k)), s(`${c.bySeverity[k] ?? 0} ${k}`, (c.bySeverity[k] ?? 0) > 0 ? sevColor(k) : C.steel, k === 'critical' || k === 'high'), s(' '));
180
+ body.push(legend);
181
+ if (sn.ledger) {
182
+ const L = sn.ledger;
183
+ const total = L.untested + L.confirmed + L.refuted + L.retest;
184
+ body.push([]);
185
+ body.push([s('tested ', C.steel), s(`${L.confirmed + L.refuted}/${total}`, C.light, true), s(` · ${L.confirmed} confirmed · ${L.refuted} refuted · ${L.retest} retest`, C.steel)]);
186
+ body.push(partsBar([
187
+ { n: L.confirmed, glyph: '█', c: C.red },
188
+ { n: L.retest, glyph: '▓', c: C.amber },
189
+ { n: L.refuted, glyph: '▒', c: C.green },
190
+ { n: L.untested, glyph: '░', c: C.line },
191
+ ], inner));
192
+ }
193
+ if (sn.boundaries) {
194
+ const B = sn.boundaries;
195
+ body.push([s('boundaries ', C.steel), s(`${B.supported} held`, B.supported ? C.green : C.steel), s(' · '), s(`${B.contradicted} broken`, B.contradicted ? C.red : C.steel), s(` · ${B.unverified} unverified${B.retest ? ` · ${B.retest} retest` : ''}`, C.steel)]);
196
+ }
197
+ if (sn.coverage) {
198
+ body.push([]);
199
+ body.push([s('coverage ', C.steel), s(`${sn.coverage.percent}%`, C.light, true), s(` · ${nf(sn.coverage.annotated)}/${nf(sn.coverage.source)} files · ${nf(sn.coverage.source - sn.coverage.annotated)} left`, C.steel)]);
200
+ body.push(bar(sn.coverage.percent, inner, C.blue));
201
+ }
202
+ if (sn.claims) {
203
+ const k = sn.claims;
204
+ body.push([s(`${nf(k.annotations)} annotations · `, C.steel), s(`${c.stale} stale`, c.stale ? C.amber : C.steel), s(` · ${k.drift} drift · ${k.orphans} orphans`, C.steel), ...(k.parseErrors ? [s(` · ${k.parseErrors} parse errors`, C.red)] : [])]);
205
+ body.push([s(`${k.acceptances} accepted risk${k.acceptances === 1 ? '' : 's'}`, C.steel), ...(k.expiringAcceptances ? [s(` · ${k.expiringAcceptances} expiring`, C.amber)] : []), s(sn.tickets !== null ? ` · ${plural(sn.tickets, 'ticket')}` : '', C.steel)]);
206
+ }
207
+ if (history.length > 1) {
208
+ const w = Math.max(8, inner - 24);
209
+ body.push([s('open this session ', C.steel), s(sparkline(history.map((h) => h.open ?? 0), w), C.amber), s(` ${c.open}`, C.light, true)]);
210
+ if (history.some((h) => h.coverage !== null))
211
+ body.push([s('coverage ', C.steel), s(sparkline(history.map((h) => h.coverage ?? 0), w), C.blue), s(` ${sn.coverage?.percent ?? '?'}%`, C.light, true)]);
212
+ }
213
+ return { title: 'THREAT MODEL', right: `${c.open} open`, rightColor: c.open ? C.amber : C.green, body };
214
+ }
215
+ function nextPanel(sn, max, inner) {
216
+ const body = [];
217
+ if (!sn.next.length)
218
+ body.push([s(sn.counts ? 'nothing queued' : 'unknown', C.steel)]);
219
+ for (const q of sn.next.slice(0, max)) {
220
+ body.push([badge(q.severity), s(' '), s(q.threat, C.light, true), s(` on ${q.asset}`, C.steel), s(` ${q.state}`, q.state === 'retest' ? C.amber : C.slate)]);
221
+ body.push([s(' '), s(tailPath(`${q.file}:${q.line ?? '?'}`, inner - 7), C.steel)]);
222
+ }
223
+ if (sn.next.length)
224
+ body.push([s('prove ', C.steel), s('/bravos-prove', C.green), s(' · testable ', C.steel), s('/bravos-templatize', C.green)]);
225
+ return { title: 'TEST NEXT', right: 'guardlink hypothesis next', body };
226
+ }
227
+ function topPanel(sn, inner) {
228
+ const body = [];
229
+ const max = Math.max(1, ...sn.top.map((t) => t.count), ...sn.topAssets.map((a) => a.count));
230
+ const row = (sev, name, count) => {
231
+ const bw = Math.max(4, Math.min(14, Math.floor(inner / 4)));
232
+ const filled = Math.max(1, Math.round((count / max) * bw));
233
+ return [s(`${sevGlyph[sev] ?? '·'} `, sevColor(sev)), s(name.padEnd(Math.max(8, inner - bw - 6)).slice(0, Math.max(8, inner - bw - 6))), s(' '), s('■'.repeat(filled), sevColor(sev)), s(' '.repeat(bw - filled)), s(String(count).padStart(3), C.light, true)];
234
+ };
235
+ body.push([s('by threat', C.steel)]);
236
+ if (!sn.top.length)
237
+ body.push([s(sn.counts ? 'none open' : 'unknown', C.steel)]);
238
+ for (const t of sn.top)
239
+ body.push(row(t.severity, t.threat, t.count));
240
+ if (sn.topAssets.length) {
241
+ body.push([], [s('by asset', C.steel)]);
242
+ for (const a of sn.topAssets)
243
+ body.push(row(a.severity, a.asset, a.count));
244
+ }
245
+ return { title: 'TOP THREATS · ASSETS', body };
246
+ }
247
+ function graphPanel(sn, inner) {
248
+ const g = sn.graph;
249
+ const d = g.detail;
250
+ if (g.state === 'unavailable')
251
+ return { title: 'CODE GRAPH', right: 'off', body: [[s(sn.plan.licensed ? 'codegraph-mcp not installed' : 'on the Team plan', C.steel)], [s(sn.plan.licensed ? '→ bravos setup --codegraph, then bravos graph build .' : '→ bravos account activate (30-day trial)', C.steel)]] };
252
+ if (g.state === 'not-built') {
253
+ return {
254
+ title: 'CODE GRAPH',
255
+ right: 'not built',
256
+ rightColor: C.amber,
257
+ body: [[s(`no graph for this commit (${sn.sha})`, C.amber)], [s('bravos keys the graph to the commit: rebuild after you commit', C.steel)], [s('→ bravos graph build . --scip no-exec', C.green)]],
258
+ };
259
+ }
260
+ if (!d) {
261
+ const body = g.entryPoints !== null ? [[s(`${g.entryPoints} entry points`)]] : [[s('askable: bravos graph <question>', C.steel)]];
262
+ return { title: 'CODE GRAPH', right: 'asking…', body };
263
+ }
264
+ const body = [];
265
+ body.push([s(`${nf(d.files)} files`, C.light), s(` · ${nf(d.callables)} callables · ${nf(d.callEdges)} calls`, C.steel)]);
266
+ if (d.testFiles !== null)
267
+ body.push([s(`${nf(d.testFiles)} test files`, C.steel)]);
268
+ for (const l of d.languages) {
269
+ const pct = Math.round(l.ratio * 100);
270
+ const label = `${l.language} calls `.padEnd(10);
271
+ body.push([s(label, C.steel), ...bar(pct, Math.max(6, inner - label.length - 9), l.sufficient ? C.green : C.amber), s(` ${String(pct).padStart(3)}% `, l.sufficient ? C.green : C.amber, true), s(l.sufficient ? '✔' : '!', l.sufficient ? C.green : C.amber)]);
272
+ }
273
+ if (d.languages.some((l) => !l.sufficient))
274
+ body.push([s('low resolution: "not reached" is weak evidence', C.amber)]);
275
+ if (d.entry) {
276
+ const kinds = Object.entries(d.entry.byKind).map(([k, v]) => `${v} ${k.replace(/_/g, ' ')}`).join(', ');
277
+ body.push([], [s('entry points ', C.steel), s(String(d.entry.total), C.light, true), s(kinds ? ` ${kinds}` : '', C.steel), ...(d.entry.unbound ? [s(` · ${d.entry.unbound} unbound`, C.amber)] : [])]);
278
+ }
279
+ if (d.routes) {
280
+ const lv = d.routes.byLevel;
281
+ const parts = ['public', 'authenticated', 'elevated', 'unknown'].filter((k) => lv[k]).map((k) => s(`${lv[k]} ${k} `, k === 'public' ? C.amber : k === 'unknown' ? C.steel : C.green));
282
+ body.push([s('routes ', C.steel), s(`${d.routes.total} `, C.light, true), ...parts]);
283
+ }
284
+ if (d.tests) {
285
+ const pct = d.tests.total ? Math.round((d.tests.reached / d.tests.total) * 100) : 0;
286
+ body.push([s('tests reach ', C.steel), s(`${d.tests.reached}/${d.tests.total}`, C.light, true), s(' declared exposures ', C.steel), ...bar(pct, Math.max(4, inner - 34), C.blue)]);
287
+ }
288
+ if (d.acceptances)
289
+ body.push([s(`accepted risks ${d.acceptances.total}`, C.steel), ...(d.acceptances.expiring ? [s(` · ${d.acceptances.expiring} expiring`, C.amber)] : [])]);
290
+ if (d.hotspots.length) {
291
+ body.push([], [s('hot files with open exposures', C.steel)]);
292
+ for (const h of d.hotspots.slice(0, 3))
293
+ body.push([s('▲ ', C.amber), s(tailPath(h.file, inner - 16)), s(` ${h.commits} commits`, C.steel)]);
294
+ }
295
+ // When the graph was asked, not built: bravos reports no build time.
296
+ const right = `${d.currency ?? '?'} · asked ${time(d.askedAt)}`;
297
+ return { title: 'CODE GRAPH', right, rightColor: d.currency === 'current' ? C.green : C.amber, body };
298
+ }
299
+ function sastPanel(sn) {
300
+ if (!sn.sast) {
301
+ return { title: 'SAST', right: sn.sastAvailable ? 'not run' : 'off', body: [[s(sn.sastAvailable ? 'bravos-cli ready → "run SAST" (bravos-sast)' : 'unavailable here → bravos doctor', C.steel)]] };
302
+ }
303
+ const x = sn.sast;
304
+ return {
305
+ title: 'SAST',
306
+ right: x.staleCommits ? `${x.staleCommits} commits behind` : 'current',
307
+ rightColor: x.staleCommits ? C.amber : C.green,
308
+ body: [
309
+ [s(`${x.total}`, C.light, true), s(' findings · ', C.steel), s(`${x.reachable} reachable`, x.reachable ? C.amber : C.steel), s(' · ', C.steel), s(`${x.unjoinedReachable} not in the model`, x.unjoinedReachable ? C.red : C.steel)],
310
+ [s('→ bravos sast candidates', C.green)],
311
+ ],
312
+ };
313
+ }
314
+ function teamPanel(sn, inner) {
315
+ const b = sn.branchWork;
316
+ const body = [];
317
+ const ab = b.ahead !== null && b.ahead !== undefined ? ` ↑${b.ahead} ↓${b.behind ?? 0}` : '';
318
+ body.push([s(sn.branch ?? '(detached)', C.light, true), s(sn.base ? ` → ${sn.base}` : '', C.steel), s(ab, C.steel)]);
319
+ if (b.uncommitted !== null) {
320
+ body.push([s(plural(b.uncommitted, 'uncommitted file'), b.uncommitted ? C.light : C.steel), ...(b.unannotatedChanged ? [s(` · ${b.unannotatedChanged} without annotations`, C.amber)] : [])]);
321
+ for (const f of (b.files ?? []).slice(0, 4))
322
+ body.push([s(` ${f.status.padEnd(2)} `, f.status === 'D' ? C.red : f.status === '??' ? C.steel : C.amber), s(tailPath(f.path, inner - 6), C.steel)]);
323
+ if ((b.files?.length ?? 0) > 4)
324
+ body.push([s(` +${(b.uncommitted ?? 0) - 4} more`, C.slate)]);
325
+ }
326
+ if (b.commits?.length) {
327
+ body.push([], [s('recent commits', C.steel)]);
328
+ for (const c of b.commits.slice(0, 3))
329
+ body.push([s(`${c.sha} `, C.blue), s(`${ago(c.at, sn.at).padStart(3)} `, C.steel), s(c.subject)]);
330
+ }
331
+ else if (b.lastCommit)
332
+ body.push([s(`last commit: ${b.lastCommit}`, C.steel)]);
333
+ body.push([]);
334
+ body.push(sn.team.prs === null ? [s('PRs unknown (gh unavailable)', C.steel)] : [s(`${sn.team.prs} open PRs`, C.light), s(` · ${nf(sn.team.files)} files in flight`, C.steel)]);
335
+ const hook = (id, name) => {
336
+ const it = sn.setup.items.find((x) => x.id === id);
337
+ return it ? [s(`${name} `, C.steel), s(it.on ? 'on' : 'off', it.on ? C.green : C.slate), s(' ')] : [];
338
+ };
339
+ body.push([...hook('autopilot', 'autopilot'), ...hook('prepr', 'pre-PR'), ...hook('ci', 'CI')]);
340
+ body.push([s('intent ', C.steel), s(sn.intent, sn.intent === 'none' ? C.amber : C.green), s(' · proving ', C.steel), s(sn.proving, sn.proving === 'not set' ? C.slate : C.green)]);
341
+ if (sn.review.drafted)
342
+ body.push([s(`intent review: ${sn.review.answers.length} answered${sn.review.answers.length ? ` (${sn.review.answers.map((a) => a.by).join(', ')})` : ''}`, C.steel)]);
343
+ if (sn.bravos?.repo && sn.bravos.repo !== sn.bravos.cli)
344
+ body.push([s(`skills here ${sn.bravos.repo}, bravos ${sn.bravos.cli} → bravos sync`, C.amber)]);
345
+ return { title: 'BRANCH · TEAM', body };
346
+ }
347
+ // @comment -- "The REPORT panel: the newest bravos-report write-up's title, kind and age, its findings by evidence level and severity, the check verdict, exported formats and how far HEAD has moved since its evidence; or how to write one."
348
+ function reportPanel(sn, inner) {
349
+ const r = sn.report ?? null;
350
+ if (!r)
351
+ return { title: 'REPORT', right: 'none yet', body: [[s('no report yet → ask "write the assessment report"', C.steel)], [s('/bravos-report', C.green), s(' · bravos report data', C.steel)]] };
352
+ const body = [[s(r.title ?? '(report.md not written yet)', C.light, true)]];
353
+ body.push([s(`${r.kind} · ${ago(r.createdAt, sn.at)} ago`, C.steel), ...(r.commitsBehind ? [s(` · ${r.commitsBehind} commit${r.commitsBehind === 1 ? '' : 's'} since its evidence`, C.amber)] : [])]);
354
+ if (r.check) {
355
+ const L = r.check.byLevel;
356
+ const sev = ['critical', 'high', 'medium', 'low'].filter((k) => r.check.bySeverity[k]);
357
+ body.push([s(`${r.check.findings} finding${r.check.findings === 1 ? '' : 's'} `, C.light, true), ...sev.flatMap((k) => [badge(k), s(` ${r.check.bySeverity[k]} `, C.light)])]);
358
+ body.push([
359
+ s(`${L.reproduced ?? 0} reproduced`, (L.reproduced ?? 0) ? C.red : C.steel),
360
+ s(' · ', C.steel),
361
+ s(`${L.held ?? 0} held`, (L.held ?? 0) ? C.green : C.steel),
362
+ s(' · ', C.steel),
363
+ s(`${L['code-review'] ?? 0} review`, (L['code-review'] ?? 0) ? C.blue : C.steel),
364
+ s(' · ', C.steel),
365
+ s(`${L.suspected ?? 0} suspected`, C.steel),
366
+ ]);
367
+ body.push(r.check.ok ? [s('✔ check passes', C.green), ...(r.check.warnings ? [s(` · ${r.check.warnings} warning${r.check.warnings === 1 ? '' : 's'}`, C.steel)] : [])] : [s(`✘ ${r.check.errors} problem${r.check.errors === 1 ? '' : 's'}`, C.red), s(` → bravos report check ${r.id}`, C.steel)]);
368
+ for (const f of r.index.slice(0, Math.max(0, Math.min(4, Math.floor(inner / 20)))))
369
+ body.push([s(`${f.id} `, C.blue), s(tailPath(f.title, inner - 5))]);
370
+ }
371
+ else
372
+ body.push([s('not checked yet → bravos report check', C.steel)]);
373
+ body.push([s('exports ', C.steel), s(r.exports.length ? r.exports.join(' ') : 'none', r.exports.length ? C.light : C.steel), s(' p view · o open', C.slate)]);
374
+ return { title: 'REPORT', right: r.id, body };
375
+ }
376
+ // @comment -- "The SERVICES panel for a repo in a bravos workspace: one row per service (this one marked) with open, high and confirmed counts and coverage, then the cross-service edge count, the edges that touch this service, and unresolved references. Empty outside a workspace."
377
+ function servicesPanel(sn, inner) {
378
+ const w = sn.workspace;
379
+ if (!w)
380
+ return null;
381
+ const body = [];
382
+ const nameW = Math.min(14, Math.max(6, ...w.members.map((m) => m.name.length)));
383
+ for (const m of w.members.slice(0, 8)) {
384
+ const c = m.counts;
385
+ const high = c ? (c.bySeverity.critical ?? 0) + (c.bySeverity.high ?? 0) : 0;
386
+ body.push([
387
+ s(m.name === w.member ? '▸ ' : ' ', C.green, true),
388
+ s(m.name.padEnd(nameW).slice(0, nameW), m.name === w.member ? C.light : C.steel, m.name === w.member),
389
+ ...(c
390
+ ? [s(` ${String(c.open).padStart(3)} open`, c.open ? C.light : C.steel), s(` ${high} high+`, high ? C.red : C.steel), s(` ${c.confirmed} conf`, c.confirmed ? C.red : C.steel), s(` ${c.coverage ?? '?'}%`, C.steel)]
391
+ : [s(' counts not read yet', C.slate)]),
392
+ ]);
393
+ }
394
+ if (w.members.length > 8)
395
+ body.push([s(` +${w.members.length - 8} more · s`, C.slate)]);
396
+ if (!w.edges)
397
+ body.push([s('edges unknown → bravos workspace show', C.steel)]);
398
+ else {
399
+ body.push([s(`${w.edges.length} cross-service edge${w.edges.length === 1 ? '' : 's'}`, C.light), ...(w.unresolved.length ? [s(` · ${w.unresolved.length} unresolved`, C.amber)] : []), ...(w.clashes ? [s(` · ${w.clashes} clash${w.clashes === 1 ? '' : 'es'}`, C.amber)] : [])]);
400
+ const mine = w.member ? w.edges.filter((e) => e.srcMember === w.member || e.dstMember === w.member) : w.edges;
401
+ for (const e of mine.slice(0, 4))
402
+ body.push([s(' '), s(e.srcMember, e.srcMember === w.member ? C.light : C.steel), s(' → ', C.green), s(e.dstMember, e.dstMember === w.member ? C.light : C.steel), s(tailPath(` ${e.src} → ${e.dst}`, Math.max(10, inner - e.srcMember.length - e.dstMember.length - 5)), C.slate)]);
403
+ }
404
+ body.push([s('s all services and edges', C.slate)]);
405
+ return { title: 'SERVICES', right: `${w.name}${w.isAppFolder ? ' · app folder' : ''}`, body };
406
+ }
407
+ function sizePanel(sn, inner) {
408
+ const m = sn.model;
409
+ if (!m)
410
+ return { title: 'MODEL', right: 'guardlink status', body: [[s('unknown', C.steel)]] };
411
+ const keys = ['assets', 'threats', 'controls', 'actors', 'mitigations', 'exposures', 'flows', 'boundaries', 'audits', 'assumptions', 'data handling', 'validations', 'reaches', 'effects'].filter((k) => m[k] !== undefined);
412
+ const cellW = 18;
413
+ const perRow = Math.max(1, Math.floor((inner + 1) / cellW));
414
+ const body = [];
415
+ for (let k = 0; k < keys.length; k += perRow) {
416
+ const row = [];
417
+ for (const key of keys.slice(k, k + perRow))
418
+ row.push(s(nf(m[key]).padStart(6), C.light, true), s(` ${key}`.padEnd(cellW - 6), C.steel));
419
+ body.push(row);
420
+ }
421
+ return { title: 'MODEL', right: 'guardlink status', body };
422
+ }
423
+ const kindIcon = {
424
+ autopilot: ['◆', C.green],
425
+ 'exposure+': ['▲', C.amber],
426
+ 'exposure-': ['▼', C.green],
427
+ confirmed: ['✖', C.red],
428
+ refuted: ['✔', C.green],
429
+ boundary: ['◇', C.green],
430
+ stale: ['⟳', C.amber],
431
+ coverage: ['◔', C.blue],
432
+ model: ['+', C.blue],
433
+ setup: ['✔', C.green],
434
+ sast: ['◈', C.green],
435
+ graph: ['◎', C.blue],
436
+ pr: ['⇅', C.steel],
437
+ review: ['✎', C.green],
438
+ commit: ['●', C.blue],
439
+ files: ['✎', C.steel],
440
+ report: ['❏', C.green],
441
+ workspace: ['⇄', C.blue],
442
+ };
443
+ function activityPanel(events, max, title = 'ACTIVITY') {
444
+ const body = [];
445
+ if (!events.length)
446
+ body.push([s('nothing yet — changes appear here as you and the agent work', C.steel)]);
447
+ for (const e of events.slice(-max).reverse()) {
448
+ const [icon, c] = kindIcon[e.kind] ?? ['·', C.steel];
449
+ body.push([s(time(e.at) + ' ', C.steel), s(icon + ' ', c), s(e.kind === 'autopilot' ? 'autopilot ' : '', C.green, true), s(e.text, e.kind === 'confirmed' ? C.red : undefined)]);
450
+ if (e.hint)
451
+ body.push([s(' → ' + e.hint, C.slate)]);
452
+ }
453
+ return { title, right: `${events.length} event${events.length === 1 ? '' : 's'}`, body };
454
+ }
455
+ /** Three columns from 150, two from 110, one bordered column from 80, compact below. */
456
+ export const layoutFor = (width, compact = false) => (compact || width < 80 ? 'compact' : width >= 150 ? 'wide' : width >= 110 ? 'two' : 'pane');
457
+ const KEYS = [
458
+ ['n', 'next'],
459
+ ['r', 'review'],
460
+ ['d', 'dashboard'],
461
+ ['g', 'graph'],
462
+ ['t', 'test-next'],
463
+ ['i', 'intent'],
464
+ ['p', 'report'],
465
+ ['s', 'services'],
466
+ ['o', 'open'],
467
+ ['u', 'update'],
468
+ ['j/k', 'scroll'],
469
+ ['?', 'guide'],
470
+ ['q', 'quit'],
471
+ ];
472
+ // Keys dropped first when the bar does not fit; quit always stays.
473
+ const DROP_ORDER = ['u', 'o', 's', 'd', '?', 'i', 'p', 't', 'r', 'g', 'j/k', 'n'];
474
+ function keyBar(width) {
475
+ let keys = KEYS;
476
+ const chips = (ks) => ks.flatMap(([k, label]) => [s(` ${k} `, C.ink, true, C.green), s(` ${label} `, C.steel)]);
477
+ for (const k of DROP_ORDER) {
478
+ if (len(chips(keys)) <= width)
479
+ break;
480
+ keys = keys.filter(([x]) => x !== k);
481
+ }
482
+ return chips(keys);
483
+ }
484
+ function header(sn, width, opts) {
485
+ const left = [s(' ◆ bravos watch ', C.green, true, C.deep), s(' ', undefined, false, C.deep), s(sn.repo, C.white, true, C.deep), s(` ${sn.branch ?? '(detached)'} @ ${sn.sha}`, C.steel, false, C.deep), s(` ${sn.plan.label}`, sn.plan.licensed ? C.green : C.amber, false, C.deep)];
486
+ const st = opts.stats;
487
+ const right = [
488
+ s(opts.status ?? '● live', opts.status?.startsWith('updating') ? C.amber : C.green, true, C.deep),
489
+ ...(st ? [s(` · #${st.refreshes}${st.lastMs !== null ? ` ${(st.lastMs / 1000).toFixed(1)}s` : ''}`, C.steel, false, C.deep)] : []),
490
+ s(' · 0 tokens ', C.steel, false, C.deep),
491
+ ];
492
+ const gap = width - len(left) - len(right);
493
+ if (gap >= 1)
494
+ return [...left, s(' '.repeat(gap), undefined, false, C.deep), ...right];
495
+ return fitLine([...left, s(' ', undefined, false, C.deep), right[0]], width, true, C.deep);
496
+ }
497
+ function tileItems(sn) {
498
+ const c = sn.counts;
499
+ const high = c ? (c.bySeverity.critical ?? 0) + (c.bySeverity.high ?? 0) : null;
500
+ const res = sn.graph.detail?.languages ?? [];
501
+ const worst = res.length ? res.reduce((a, b) => (b.ratio < a.ratio ? b : a)) : null;
502
+ const L = sn.ledger;
503
+ return [
504
+ { label: 'OPEN', value: c ? String(c.open) : '?', c: c?.open ? C.amber : C.green },
505
+ { label: 'HIGH+', value: high === null ? '?' : String(high), c: high ? C.red : C.green },
506
+ { label: 'CONFIRMED', value: c ? String(c.confirmed) : '?', c: c?.confirmed ? C.red : C.green },
507
+ { label: 'TESTED', value: L ? String(L.confirmed + L.refuted) : c?.tested !== null && c ? String(c.tested) : '?', c: C.light, sub: L ? `/${L.untested + L.confirmed + L.refuted + L.retest}` : undefined },
508
+ { label: 'COVERAGE', value: sn.coverage ? `${sn.coverage.percent}%` : '?', c: (sn.coverage?.percent ?? 0) >= 80 ? C.green : C.amber },
509
+ { label: 'STALE', value: c ? String(c.stale) : '?', c: c?.stale ? C.amber : C.green },
510
+ { label: 'SETUP', value: `${sn.setup.done}/${sn.setup.total}`, c: C.green },
511
+ { label: 'GRAPH', value: worst ? `${Math.round(worst.ratio * 100)}%` : sn.graph.state === 'not-built' ? 'none' : sn.graph.state === 'unavailable' ? 'off' : '—', c: worst ? (worst.sufficient ? C.green : C.amber) : C.steel, sub: worst ? worst.language : undefined },
512
+ { label: 'SAST', value: sn.sast ? String(sn.sast.total) : sn.sastAvailable ? 'ready' : 'off', c: sn.sast?.unjoinedReachable ? C.red : C.steel },
513
+ ];
514
+ }
515
+ /** Lays columns of lines side by side, each padded to its width. */
516
+ function joinColumns(cols, widths, gap) {
517
+ const rows = Math.max(...cols.map((c) => c.length));
518
+ const out = [];
519
+ for (let r = 0; r < rows; r++) {
520
+ const line = [];
521
+ cols.forEach((c, k) => {
522
+ line.push(...fitLine(c[r] ?? [], widths[k], k < cols.length - 1));
523
+ if (k < cols.length - 1)
524
+ line.push(s(' '.repeat(gap)));
525
+ });
526
+ out.push(line);
527
+ }
528
+ return out;
529
+ }
530
+ const stack = (panels, w) => panels.flatMap((p) => box(p, w));
531
+ const opt = (p) => (p ? [p] : []);
532
+ // @comment -- "Draws one frame for a terminal width and height: header and tiles stay at the top, the key bar at the bottom, and the panels between scroll when they are taller than the terminal; maxScroll tells the loop how far scrolling can go."
533
+ export function renderFrame(sn, events, opts) {
534
+ const width = Math.max(40, opts.width);
535
+ const color = opts.color ?? false;
536
+ const layout = layoutFor(width, opts.compact);
537
+ const history = opts.history ?? [];
538
+ const top = [header(sn, width, opts)];
539
+ let body;
540
+ if (layout === 'compact') {
541
+ const c = sn.counts;
542
+ top.push([s(`setup ${sn.setup.done}/${sn.setup.total}`, C.green, true), s(sn.setup.next ? ` · next ${sn.setup.next.cta}` : '', C.steel)], c ? [s(`open ${c.open}`, C.amber, true), s(` · high ${(c.bySeverity.critical ?? 0) + (c.bySeverity.high ?? 0)}`, C.red), s(` · confirmed ${c.confirmed} · coverage ${sn.coverage?.percent ?? '?'}%`)] : [s('counts unknown', C.amber)]);
543
+ const w = width;
544
+ body = stack([activityPanel(events, 4, 'CHANGED JUST NOW'), ...opt(servicesPanel(sn, w - 4)), nextPanel(sn, 3, w - 4), modelPanel(sn, w - 4, history), reportPanel(sn, w - 4), setupPanel(sn, w - 4), graphPanel(sn, w - 4), teamPanel(sn, w - 4), sastPanel(sn)], w);
545
+ }
546
+ else {
547
+ top.push(...tiles(tileItems(sn), width));
548
+ if (layout === 'pane') {
549
+ const w = width;
550
+ const inner = w - 4;
551
+ body = stack([activityPanel(events, 5, 'CHANGED JUST NOW'), ...opt(servicesPanel(sn, inner)), nextPanel(sn, 4, inner), modelPanel(sn, inner, history), reportPanel(sn, inner), setupPanel(sn, inner), graphPanel(sn, inner), teamPanel(sn, inner), topPanel(sn, inner), sastPanel(sn), sizePanel(sn, inner)], w);
552
+ }
553
+ else if (layout === 'two') {
554
+ const gap = 1;
555
+ const w = Math.floor((width - gap) / 2);
556
+ const w2 = width - gap - w;
557
+ body = joinColumns([
558
+ stack([activityPanel(events, 6), ...opt(servicesPanel(sn, w - 4)), nextPanel(sn, 5, w - 4), modelPanel(sn, w - 4, history), topPanel(sn, w - 4)], w),
559
+ stack([setupPanel(sn, w2 - 4), graphPanel(sn, w2 - 4), teamPanel(sn, w2 - 4), sastPanel(sn), reportPanel(sn, w2 - 4), sizePanel(sn, w2 - 4)], w2),
560
+ ], [w, w2], gap);
561
+ }
562
+ else {
563
+ const gap = 1;
564
+ const w = Math.floor((width - gap * 2) / 3);
565
+ const w3 = width - gap * 2 - w * 2;
566
+ body = joinColumns([
567
+ stack([setupPanel(sn, w - 4), graphPanel(sn, w - 4), sastPanel(sn), reportPanel(sn, w - 4)], w),
568
+ stack([modelPanel(sn, w - 4, history), nextPanel(sn, 5, w - 4), sizePanel(sn, w - 4)], w),
569
+ stack([activityPanel(events, 8), ...opt(servicesPanel(sn, w3 - 4)), teamPanel(sn, w3 - 4), topPanel(sn, w3 - 4)], w3),
570
+ ], [w, w, w3], gap);
571
+ }
572
+ }
573
+ const bottom = [];
574
+ if (opts.note)
575
+ bottom.push([s('› ', C.green, true), s(opts.note, C.light)]);
576
+ bottom.push(keyBar(width));
577
+ let maxScroll = 0;
578
+ if (opts.height) {
579
+ const room = Math.max(1, opts.height - top.length - bottom.length);
580
+ if (body.length > room) {
581
+ // One line of the room says where the view is.
582
+ const view = Math.max(1, room - 1);
583
+ maxScroll = body.length - view;
584
+ const at = Math.max(0, Math.min(opts.scroll ?? 0, maxScroll));
585
+ const shown = body.slice(at, at + view);
586
+ const pos = [s(` ${at > 0 ? '▲' : ' '} ${at + 1}–${at + shown.length} of ${body.length} lines ${at < maxScroll ? '▼' : ' '} j/k or ↑/↓ to scroll`, C.slate)];
587
+ body = [...shown, pos];
588
+ }
589
+ }
590
+ const lines = [...top, ...body, ...bottom].map((l) => fitLine(l, width));
591
+ return { text: lines.map((l) => renderLine(l, color)).join('\n'), maxScroll };
592
+ }
593
+ export const buildFrame = (sn, events, opts) => renderFrame(sn, events, opts).text;