@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.
- package/LICENSE +70 -0
- package/README.md +225 -2
- package/dist/account.js +140 -0
- package/dist/artifacts.js +450 -0
- package/dist/ci.js +52 -0
- package/dist/cli.js +963 -0
- package/dist/codegraph.js +268 -0
- package/dist/cursor.js +70 -0
- package/dist/detect.js +26 -0
- package/dist/doctor.js +99 -0
- package/dist/global.js +95 -0
- package/dist/graphbin.js +136 -0
- package/dist/guardlink.js +123 -0
- package/dist/guide.js +255 -0
- package/dist/help.js +334 -0
- package/dist/hooks.js +167 -0
- package/dist/install.js +209 -0
- package/dist/licence.js +41 -0
- package/dist/lock.js +50 -0
- package/dist/mcp.js +139 -0
- package/dist/migrate.js +275 -0
- package/dist/omp.js +46 -0
- package/dist/opencode.js +118 -0
- package/dist/paths.js +33 -0
- package/dist/plugin.js +101 -0
- package/dist/report/check.js +252 -0
- package/dist/report/data.js +401 -0
- package/dist/report/export.js +105 -0
- package/dist/report/markdown.js +310 -0
- package/dist/report/meta.js +48 -0
- package/dist/report/pdf.js +96 -0
- package/dist/review-server.js +7 -0
- package/dist/review.js +274 -0
- package/dist/setup.js +133 -0
- package/dist/skills.js +81 -0
- package/dist/types.js +9 -0
- package/dist/uninstall.js +192 -0
- package/dist/versions.js +8 -0
- package/dist/watch-frame.js +593 -0
- package/dist/watch.js +873 -0
- package/dist/workspace.js +316 -0
- package/docs/GUIDE.md +323 -0
- package/hooks/autopilot.mjs +45 -0
- package/hooks/lib/autopilot.mjs +191 -0
- package/hooks/lib/briefing.mjs +750 -0
- package/hooks/lib/licence.mjs +452 -0
- package/hooks/lib/pre-pr.mjs +209 -0
- package/hooks/lib/sast.mjs +207 -0
- package/hooks/lib/setup.mjs +107 -0
- package/hooks/lib/workspace.mjs +78 -0
- package/hooks/pre-pr.mjs +54 -0
- package/hooks/session-start.mjs +82 -0
- package/package.json +49 -4
- package/skills/bravos/SKILL.md +63 -0
- package/skills/bravos-annotate/SKILL.md +56 -0
- package/skills/bravos-annotate/scripts/lib/pick.mjs +127 -0
- package/skills/bravos-annotate/scripts/slice.mjs +141 -0
- package/skills/bravos-campaign/SKILL.md +49 -0
- package/skills/bravos-cicd/SKILL.md +48 -0
- package/skills/bravos-codegraph/SKILL.md +57 -0
- package/skills/bravos-context/SKILL.md +51 -0
- package/skills/bravos-context/intent.example.md +48 -0
- package/skills/bravos-env/SKILL.md +48 -0
- package/skills/bravos-env/scope.example.yaml +50 -0
- package/skills/bravos-prove/SKILL.md +52 -0
- package/skills/bravos-recheck/SKILL.md +47 -0
- package/skills/bravos-report/SKILL.md +53 -0
- package/skills/bravos-report/assessment.md +54 -0
- package/skills/bravos-report/brief.md +23 -0
- package/skills/bravos-report/finding.md +74 -0
- package/skills/bravos-report/retest.md +29 -0
- package/skills/bravos-report/style.md +44 -0
- package/skills/bravos-sast/SKILL.md +48 -0
- package/skills/bravos-schedule/SKILL.md +44 -0
- package/skills/bravos-schedule/loop.md +16 -0
- package/skills/bravos-ship/SKILL.md +58 -0
- package/skills/bravos-templatize/SKILL.md +48 -0
- package/skills/bravos-templatize/operator-guidance.md +20 -0
- package/templates/omp/bravos.js +62 -0
- package/templates/opencode/bravos.js +33 -0
- package/templates/report/report.html +88 -0
- package/templates/review/review.html +327 -0
- package/templates/workflows/bravos-nightly-claude.yml +173 -0
- package/templates/workflows/bravos-nightly-codex.yml +172 -0
- package/templates/workflows/bravos-pr-gate.yml +142 -0
package/dist/watch.js
ADDED
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync, watch as fsWatch } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { basename, join } from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
import * as graph from './codegraph.js';
|
|
7
|
+
import { LOCK_PATH } from './lock.js';
|
|
8
|
+
import { hooksDir, version } from './paths.js';
|
|
9
|
+
import { readAllAnswers, readDraft } from './review.js';
|
|
10
|
+
import { listReports, readMeta, reportDir } from './report/meta.js';
|
|
11
|
+
import { renderFrame } from './watch-frame.js';
|
|
12
|
+
export { buildFrame, layoutFor, renderFrame, tailPath } from './watch-frame.js';
|
|
13
|
+
// @comment -- "bravos watch: a live, read-only, zero-token terminal view of a repository's security work, beside any harness. snapshot() gathers what the briefing gathers (guardlink ci, hypothesis next, tickets, git, gh, intent, proving, SAST, setup) plus the hypothesis and boundary ledgers, the model's size, review answers, branch state and the code graph's own answers; diffSnapshots() turns two snapshots into activity events in memory; watch-frame.ts draws them; handleKey() maps keys to read-only actions. Nothing here writes to the repository."
|
|
14
|
+
export const WATCH_SCHEMA = 'bravos.watch/v1';
|
|
15
|
+
// @comment -- "The newest report's summary for watch, from meta.json only (title, kind, check verdict and counts, exported formats) plus how many commits HEAD has moved since its evidence was gathered; null with no report. Read-only."
|
|
16
|
+
export function readReportSummary(root, env) {
|
|
17
|
+
const id = listReports(root)[0];
|
|
18
|
+
if (!id)
|
|
19
|
+
return null;
|
|
20
|
+
const m = readMeta(root, id);
|
|
21
|
+
if (!m)
|
|
22
|
+
return null;
|
|
23
|
+
let behind = null;
|
|
24
|
+
if (m.sha) {
|
|
25
|
+
const r = spawnSync('git', ['rev-list', '--count', `${m.sha}..HEAD`], { cwd: root, env, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
26
|
+
behind = r.status === 0 ? Number(r.stdout.trim()) : null;
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
id,
|
|
30
|
+
kind: m.kind,
|
|
31
|
+
title: m.title,
|
|
32
|
+
createdAt: m.createdAt,
|
|
33
|
+
sha: m.sha,
|
|
34
|
+
commitsBehind: behind,
|
|
35
|
+
check: m.check ? { ok: m.check.ok, errors: m.check.errors, warnings: m.check.warnings, findings: m.check.findings, byLevel: m.check.byLevel, bySeverity: m.check.bySeverity, at: m.check.at } : null,
|
|
36
|
+
index: m.check?.index ?? [],
|
|
37
|
+
exports: Object.keys(m.exports),
|
|
38
|
+
dir: reportDir(root, id),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const libs = {};
|
|
42
|
+
// @comment -- "The header's plan label from the briefing's licence status: short enough for one bar, honest about trial days and expiry."
|
|
43
|
+
export function planLabel(v) {
|
|
44
|
+
if (!v || typeof v !== 'object')
|
|
45
|
+
return { label: 'Free', licensed: false };
|
|
46
|
+
const tier = typeof v.tier === 'string' ? v.tier.charAt(0).toUpperCase() + v.tier.slice(1) : 'Free';
|
|
47
|
+
if (v.licensed) {
|
|
48
|
+
if (v.trial)
|
|
49
|
+
return { label: `${tier} trial · ${Math.max(0, Math.ceil((v.trial.endsAt - Date.now() / 1000) / 86400))}d left`, licensed: true };
|
|
50
|
+
return { label: `${tier} · ${v.seats} seat${v.seats === 1 ? '' : 's'}${v.state === 'grace' ? ' · in grace' : ''}`, licensed: true };
|
|
51
|
+
}
|
|
52
|
+
if (v.state === 'expired')
|
|
53
|
+
return { label: `${tier} · expired`, licensed: false };
|
|
54
|
+
return { label: 'Free', licensed: false };
|
|
55
|
+
}
|
|
56
|
+
export const lib = (name) => (libs[name] ??= import(pathToFileURL(join(hooksDir(), 'lib', name)).href));
|
|
57
|
+
export const runner = async () => (await lib('autopilot.mjs')).asyncRunner;
|
|
58
|
+
export const parseJson = (r) => {
|
|
59
|
+
try {
|
|
60
|
+
return r.stdout ? JSON.parse(r.stdout) : null;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
67
|
+
const arr = (v) => (Array.isArray(v) ? v : []);
|
|
68
|
+
const fileOf = (c) => String(c.location?.file ?? c.file ?? '?');
|
|
69
|
+
const lineOf = (c) => num(c.location?.line ?? c.line);
|
|
70
|
+
const keyOf = (c) => `${c.threat ?? '?'}|${c.asset ?? '?'}|${fileOf(c)}`;
|
|
71
|
+
const sevRank = (s) => ['critical', 'high', 'medium', 'low'].indexOf(s) >>> 0;
|
|
72
|
+
// @comment -- "One graph question in the shape the Python CLI's `bravos graph --json` printed ({result} or {error: codegraph_unavailable}), so summarizeGraph and graphAnswer read both the old transcript fixtures and the live MCP answers alike."
|
|
73
|
+
export async function askGraph(root, env, question, args = [], timeoutMs = 60000) {
|
|
74
|
+
if (!graph.graphBin('codegraph-mcp', env))
|
|
75
|
+
return null;
|
|
76
|
+
try {
|
|
77
|
+
return { result: await graph.ask(root, env, question, args, timeoutMs) };
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
const m = e.message ?? '';
|
|
81
|
+
if (/no graph|No CodeGraph|codegraph_unavailable|not built/i.test(m))
|
|
82
|
+
return { error: 'codegraph_unavailable', message: m, remedy: '`bravos graph build .` indexes this repository' };
|
|
83
|
+
return { error: 'graph_error', message: m };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// @comment -- "Folds the graph's own answers (entry-points, auth-boundary, untested-exposures, hotspots, acceptance-register as bravos graph --json gives them) into the panel's facts: currency, size, call resolution per language, entry points, route access levels, which declared exposures a test reaches, hot files the model speaks about, and accepted risks. Pure; a missing answer leaves its part null."
|
|
87
|
+
export function summarizeGraph(a, askedAt) {
|
|
88
|
+
const r = (x) => x?.result ?? null;
|
|
89
|
+
const entry = r(a.entry);
|
|
90
|
+
const auth = r(a.auth);
|
|
91
|
+
const tests = r(a.tests);
|
|
92
|
+
const hot = r(a.hot);
|
|
93
|
+
const accept = r(a.accept);
|
|
94
|
+
const density = auth?.resolver_density;
|
|
95
|
+
return {
|
|
96
|
+
askedAt,
|
|
97
|
+
currency: (entry ?? auth ?? tests ?? hot)?.graph_currency?.state ?? null,
|
|
98
|
+
files: num(tests?.classification?.files_in_graph),
|
|
99
|
+
testFiles: num(tests?.classification?.test_files),
|
|
100
|
+
callables: num(density?.callables),
|
|
101
|
+
callEdges: num(tests?.edges?.calls_edges),
|
|
102
|
+
languages: arr(density?.by_language).map((l) => ({ language: String(l.language ?? '?'), ratio: Number(l.resolution_ratio) || 0, sufficient: l.sufficient !== false })),
|
|
103
|
+
entry: entry ? { total: Number(entry.total) || 0, byKind: entry.by_kind ?? {}, byBoundary: entry.by_boundary ?? {}, unbound: Number(entry.unbound) || 0 } : null,
|
|
104
|
+
routes: auth ? { total: Number(auth.routes_total) || 0, byLevel: auth.by_level ?? {} } : null,
|
|
105
|
+
tests: tests ? { total: Number(tests.total_exposures) || 0, reached: Number(tests.by_verdict?.reached_by_test) || 0, byVerdict: tests.by_verdict ?? {} } : null,
|
|
106
|
+
hotspots: arr(hot?.hotspots)
|
|
107
|
+
.filter((h) => h.verdict === 'changes_open_exposure')
|
|
108
|
+
.slice(0, 5)
|
|
109
|
+
.map((h) => ({ file: String(h.file ?? '?'), verdict: String(h.verdict), commits: Number(h.commits) || 0, open: Number(h.security?.open_exposures) || 0 })),
|
|
110
|
+
hotVerdicts: hot?.by_verdict ?? null,
|
|
111
|
+
acceptances: accept ? { total: Number(accept.total) || 0, expiring: arr(accept.acceptances).filter((x) => x.status === 'expiring').length } : null,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
// @comment -- "Asks the code graph five read-only questions at once through bravos graph --json (argv, no shell, 60 s cap each) and summarizes them; notBuilt when bravos reports no graph for this checkout, null when bravos is missing."
|
|
115
|
+
export async function graphDetail(root, env) {
|
|
116
|
+
if (!graph.graphBin('codegraph-mcp', env))
|
|
117
|
+
return null;
|
|
118
|
+
const entry = await askGraph(root, env, 'entry-points');
|
|
119
|
+
if (!entry)
|
|
120
|
+
return null;
|
|
121
|
+
if (entry.error === 'codegraph_unavailable')
|
|
122
|
+
return { notBuilt: true };
|
|
123
|
+
// The first answer decided whether a graph is there; the other four run together on the same store.
|
|
124
|
+
const [auth, tests, hot, accept] = await Promise.all(['auth-boundary', 'untested-exposures', 'hotspots', 'acceptance-register'].map((q) => askGraph(root, env, q)));
|
|
125
|
+
const ok = (x) => (x && !x.error ? x : null);
|
|
126
|
+
const answers = { entry: ok(entry), auth: ok(auth), tests: ok(tests), hot: ok(hot), accept: ok(accept) };
|
|
127
|
+
if (!Object.values(answers).some(Boolean))
|
|
128
|
+
return null;
|
|
129
|
+
return summarizeGraph(answers, new Date().toISOString());
|
|
130
|
+
}
|
|
131
|
+
// @comment -- "Reads guardlink status's text summary (it has no JSON form) into counts by lowercased label, e.g. assets, threats, controls, flows, boundaries; lines without a number are skipped."
|
|
132
|
+
export function parseStatus(text) {
|
|
133
|
+
const out = {};
|
|
134
|
+
for (const line of text.split('\n')) {
|
|
135
|
+
const m = /^\s*([A-Za-z][A-Za-z ]*?):\s+(-?\d+)/.exec(line);
|
|
136
|
+
if (m)
|
|
137
|
+
out[m[1].toLowerCase()] = Number(m[2]);
|
|
138
|
+
}
|
|
139
|
+
return Object.keys(out).length ? out : null;
|
|
140
|
+
}
|
|
141
|
+
// @comment -- "Counts open exposures per asset, worst severity first, then most exposures; the asset side of the top-threats list."
|
|
142
|
+
export function topAssets(exposures, n = 5) {
|
|
143
|
+
const by = new Map();
|
|
144
|
+
for (const e of exposures) {
|
|
145
|
+
const cur = by.get(e.asset) ?? { asset: e.asset, count: 0, severity: 'unset' };
|
|
146
|
+
cur.count += 1;
|
|
147
|
+
if (sevRank(e.severity) < sevRank(cur.severity))
|
|
148
|
+
cur.severity = e.severity;
|
|
149
|
+
by.set(e.asset, cur);
|
|
150
|
+
}
|
|
151
|
+
return [...by.values()].sort((a, b) => sevRank(a.severity) - sevRank(b.severity) || b.count - a.count || a.asset.localeCompare(b.asset)).slice(0, n);
|
|
152
|
+
}
|
|
153
|
+
const readRepoVersion = (root) => {
|
|
154
|
+
try {
|
|
155
|
+
const p = JSON.parse(readFileSync(join(root, LOCK_PATH), 'utf8'));
|
|
156
|
+
return typeof p?.bravos_version === 'string' ? p.bravos_version : null;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
// @comment -- "Builds one Snapshot for root from the briefing's collect() and the setup, SAST and review readers, with guardlink's hypothesis list beside it; read-only. Boundaries, the model's size and the unannotated check (refreshSlow) and the graph (refreshGraph) carry over from the previous snapshot unless asked for, because they take seconds."
|
|
163
|
+
export async function snapshot(root, env, opts = {}) {
|
|
164
|
+
const briefing = await lib('briefing.mjs');
|
|
165
|
+
const setupLib = await lib('setup.mjs');
|
|
166
|
+
const run = await runner();
|
|
167
|
+
const prev = opts.previous ?? null;
|
|
168
|
+
const slow = opts.refreshSlow || !prev;
|
|
169
|
+
const gl = (args) => run('guardlink', args, { cwd: root, env, timeoutMs: 30000 });
|
|
170
|
+
const [collected, ledgerR, boundR, statusR, unannR, graphR] = await Promise.all([
|
|
171
|
+
briefing.collect(root, env, { nextCount: 5 }),
|
|
172
|
+
gl(['hypothesis', 'list', '--json', '.']),
|
|
173
|
+
slow ? gl(['hypothesis', 'boundaries', '--json', '.']) : null,
|
|
174
|
+
slow ? gl(['status', '.']) : null,
|
|
175
|
+
slow ? gl(['unannotated', '--json']) : null,
|
|
176
|
+
opts.refreshGraph ? graphDetail(root, env) : null,
|
|
177
|
+
]);
|
|
178
|
+
const i = collected;
|
|
179
|
+
const g = i.guardlink === 'missing' ? null : (i.guardlink ?? {});
|
|
180
|
+
const ci = g?.ci ?? null;
|
|
181
|
+
const exposures = arr(ci?.exposures).map((e) => ({ key: keyOf(e), threat: String(e.threat ?? '?'), asset: String(e.asset ?? '?'), severity: String(e.severity ?? 'unset'), file: fileOf(e), line: lineOf(e) }));
|
|
182
|
+
const by = (ci?.summary?.by_severity ?? {});
|
|
183
|
+
const nextTotal = num(g?.next?.total);
|
|
184
|
+
const setup = setupLib.readSetup(root, i, 'claude');
|
|
185
|
+
let review = { drafted: false, answers: [] };
|
|
186
|
+
try {
|
|
187
|
+
const draft = readDraft(root, 'intent');
|
|
188
|
+
review = { drafted: true, answers: readAllAnswers(root, draft).files.map((f) => ({ by: f.by, date: f.date })) };
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
/* no draft yet */
|
|
192
|
+
}
|
|
193
|
+
const git = (args) => {
|
|
194
|
+
const r = spawnSync('git', args, { cwd: root, env, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
195
|
+
return r.status === 0 ? r.stdout : null;
|
|
196
|
+
};
|
|
197
|
+
const porcelain = git(['status', '--porcelain']);
|
|
198
|
+
const files = porcelain === null ? [] : porcelain.split('\n').filter(Boolean).map((l) => ({ status: l.slice(0, 2).trim() || '?', path: l.slice(3) }));
|
|
199
|
+
const log = git(['log', '-5', '--format=%h%x09%cI%x09%s']);
|
|
200
|
+
const commits = (log ?? '').split('\n').filter(Boolean).map((l) => {
|
|
201
|
+
const [sha, at, ...rest] = l.split('\t');
|
|
202
|
+
return { sha, at, subject: rest.join('\t') };
|
|
203
|
+
});
|
|
204
|
+
const base = i.base ? String(i.base) : null;
|
|
205
|
+
const lr = base ? git(['rev-list', '--left-right', '--count', `${base}...HEAD`]) : null;
|
|
206
|
+
const [behind, ahead] = lr ? lr.trim().split(/\s+/).map(Number) : [null, null];
|
|
207
|
+
const ledgerJ = ledgerR ? parseJson(ledgerR) : null;
|
|
208
|
+
const ledger = ledgerJ?.summary ? { untested: Number(ledgerJ.summary.untested) || 0, confirmed: Number(ledgerJ.summary.confirmed) || 0, refuted: Number(ledgerJ.summary.refuted) || 0, retest: Number(ledgerJ.summary.retest) || 0 } : null;
|
|
209
|
+
const boundJ = boundR ? parseJson(boundR) : null;
|
|
210
|
+
const boundaries = boundR
|
|
211
|
+
? boundJ?.summary
|
|
212
|
+
? { unverified: Number(boundJ.summary.unverified) || 0, supported: Number(boundJ.summary.supported) || 0, contradicted: Number(boundJ.summary.contradicted) || 0, retest: Number(boundJ.summary.retest) || 0 }
|
|
213
|
+
: null
|
|
214
|
+
: (prev?.boundaries ?? null);
|
|
215
|
+
const model = statusR ? (statusR.status === 0 ? parseStatus(statusR.stdout) : null) : (prev?.model ?? null);
|
|
216
|
+
let unannotatedChanged = prev?.branchWork.unannotatedChanged ?? null;
|
|
217
|
+
if (unannR) {
|
|
218
|
+
const wl = parseJson(unannR);
|
|
219
|
+
const changed = new Set(files.map((f) => f.path));
|
|
220
|
+
unannotatedChanged = wl ? arr(wl.worklist).map((w) => (typeof w === 'string' ? w : (w?.file ?? w?.path))).filter((f) => changed.has(f)).length : null;
|
|
221
|
+
}
|
|
222
|
+
let graph = prev?.graph ?? { state: String(i.codegraph ?? 'unavailable'), entryPoints: null, byKind: null, detail: null };
|
|
223
|
+
if (opts.refreshGraph) {
|
|
224
|
+
if (i.codegraph === 'unavailable')
|
|
225
|
+
graph = { state: 'unavailable', entryPoints: null, byKind: null, detail: null };
|
|
226
|
+
else if (graphR && 'notBuilt' in graphR)
|
|
227
|
+
graph = { state: 'not-built', entryPoints: null, byKind: null, detail: null };
|
|
228
|
+
else
|
|
229
|
+
graph = { state: String(i.codegraph ?? 'unavailable'), entryPoints: graphR?.entry?.total ?? null, byKind: graphR?.entry?.byKind ?? null, detail: graphR ?? null };
|
|
230
|
+
}
|
|
231
|
+
const s = ci?.summary;
|
|
232
|
+
return {
|
|
233
|
+
schema: WATCH_SCHEMA,
|
|
234
|
+
at: new Date().toISOString(),
|
|
235
|
+
repo: String(i.repo ?? ''),
|
|
236
|
+
branch: i.branch ?? null,
|
|
237
|
+
base,
|
|
238
|
+
sha: String(i.sha ?? 'unknown'),
|
|
239
|
+
guardlink: i.guardlink === 'missing' ? 'missing' : ci ? 'ok' : 'unknown',
|
|
240
|
+
counts: ci
|
|
241
|
+
? {
|
|
242
|
+
open: num(s?.exposures) ?? exposures.length,
|
|
243
|
+
bySeverity: by,
|
|
244
|
+
confirmed: arr(ci.confirmed).length,
|
|
245
|
+
tested: ledger ? ledger.confirmed + ledger.refuted : nextTotal === null ? null : Math.max(0, exposures.length - nextTotal),
|
|
246
|
+
stale: arr(ci.stale).length,
|
|
247
|
+
}
|
|
248
|
+
: null,
|
|
249
|
+
coverage: s?.coverage ? { percent: Number(s.coverage.percent) || 0, annotated: Number(s.coverage.annotated_files) || 0, source: Number(s.coverage.source_files) || 0 } : null,
|
|
250
|
+
claims: s
|
|
251
|
+
? {
|
|
252
|
+
annotations: num(s.coverage?.annotations),
|
|
253
|
+
verified: num(s.verified),
|
|
254
|
+
unverified: num(s.unverified),
|
|
255
|
+
drift: Number(s.drift) || 0,
|
|
256
|
+
orphans: Number(s.orphans) || 0,
|
|
257
|
+
parseErrors: Number(s.parse_errors) || 0,
|
|
258
|
+
acceptances: Number(s.acceptances) || 0,
|
|
259
|
+
expiringAcceptances: Number(s.expiring_acceptances) || 0,
|
|
260
|
+
}
|
|
261
|
+
: null,
|
|
262
|
+
ledger,
|
|
263
|
+
boundaries,
|
|
264
|
+
model,
|
|
265
|
+
tickets: num(g?.tickets?.count),
|
|
266
|
+
exposures,
|
|
267
|
+
confirmedKeys: arr(ci?.confirmed).map(keyOf),
|
|
268
|
+
stale: arr(ci?.stale).map((c) => ({ file: fileOf(c), line: lineOf(c), verb: String(c.verb ?? 'claim') })),
|
|
269
|
+
next: arr(g?.next?.queue).map((q) => ({ severity: String(q.severity ?? 'unset'), threat: String(q.threat ?? '?'), asset: String(q.asset ?? '?'), file: String(q.file ?? '?'), line: num(q.line), state: String(q.state ?? '?') })),
|
|
270
|
+
top: ci ? briefing.topThreats(ci.exposures, 5) : [],
|
|
271
|
+
topAssets: topAssets(exposures),
|
|
272
|
+
setup: { done: setup.done, total: setup.total, items: setup.items.map((x) => ({ id: x.id, label: x.label, on: x.on })), next: setup.next ? { label: setup.next.label, cta: setup.next.cta, how: setup.next.how } : null },
|
|
273
|
+
sast: i.sast?.summary ? { total: i.sast.summary.total, reachable: i.sast.summary.reachable, unjoinedReachable: i.sast.summary.unjoinedReachable, staleCommits: i.sast.summary.staleCommits } : null,
|
|
274
|
+
sastAvailable: !!i.sast?.available?.ok,
|
|
275
|
+
plan: planLabel(i.licence),
|
|
276
|
+
graph,
|
|
277
|
+
team: { prs: num(i.inFlight?.prs), files: num(i.inFlight?.files), numbers: arr(i.inFlight?.numbers) },
|
|
278
|
+
branchWork: {
|
|
279
|
+
uncommitted: porcelain === null ? null : files.length,
|
|
280
|
+
lastCommit: commits[0]?.subject ?? null,
|
|
281
|
+
ahead: Number.isFinite(ahead) ? ahead : null,
|
|
282
|
+
behind: Number.isFinite(behind) ? behind : null,
|
|
283
|
+
files: files.slice(0, 20),
|
|
284
|
+
commits,
|
|
285
|
+
unannotatedChanged,
|
|
286
|
+
},
|
|
287
|
+
review,
|
|
288
|
+
intent: String(i.intent?.status ?? 'unknown'),
|
|
289
|
+
proving: i.proving?.status === 'set' ? String(i.proving.choice) : 'not set',
|
|
290
|
+
bravos: { cli: version(), repo: readRepoVersion(root) },
|
|
291
|
+
report: readReportSummary(root, env),
|
|
292
|
+
workspace: await workspaceView(i, prev, slow, env),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
// @comment -- "The workspace part of a watch snapshot: members with their counts (from collect at an app folder; refreshed on the slow interval elsewhere, carried over between), the cached cross-service edges, unresolved references and clash count. Null outside a workspace."
|
|
296
|
+
async function workspaceView(i, prev, slow, env) {
|
|
297
|
+
const ws = i.workspace;
|
|
298
|
+
if (!ws)
|
|
299
|
+
return null;
|
|
300
|
+
let counts = i.workspaceCounts;
|
|
301
|
+
if (!counts && slow) {
|
|
302
|
+
const wsLib = await lib('workspace.mjs');
|
|
303
|
+
const briefing = await lib('briefing.mjs');
|
|
304
|
+
counts = await wsLib.memberCounts(ws, briefing.runJsonAsync, env, 20000);
|
|
305
|
+
}
|
|
306
|
+
const prior = new Map((prev?.workspace?.members ?? []).map((m) => [m.name, m.counts]));
|
|
307
|
+
return {
|
|
308
|
+
name: String(ws.name),
|
|
309
|
+
isAppFolder: !!ws.isAppFolder,
|
|
310
|
+
member: ws.member ?? null,
|
|
311
|
+
appFolder: ws.appFolder ?? null,
|
|
312
|
+
members: ws.members.map((m) => ({ name: m.name, url: m.url ?? '', counts: counts ? (counts[m.name] ?? null) : (prior.get(m.name) ?? null) })),
|
|
313
|
+
edges: ws.export?.edges ?? null,
|
|
314
|
+
unresolved: (ws.export?.unresolved ?? []).map((u) => ({ member: u.member, reference: u.reference })),
|
|
315
|
+
clashes: (ws.export?.clashes ?? []).length,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
// @comment -- "Turns two snapshots into activity events, in memory: exposures added or gone (and open → mitigated when one disappears from the open list), new confirmations and refutations, retests due, boundaries held or broken, coverage moves, the model's size, setup steps switched on or off, a SAST run, a rebuilt or missing graph, PRs opened or closed, review answers, a new commit, and the branch's uncommitted count."
|
|
319
|
+
export function diffSnapshots(prev, next) {
|
|
320
|
+
if (!prev)
|
|
321
|
+
return [];
|
|
322
|
+
const at = next.at;
|
|
323
|
+
const out = [];
|
|
324
|
+
const add = (kind, text, hint) => out.push({ at, kind, text, ...(hint ? { hint } : {}) });
|
|
325
|
+
const before = new Map(prev.exposures.map((e) => [e.key, e]));
|
|
326
|
+
const after = new Map(next.exposures.map((e) => [e.key, e]));
|
|
327
|
+
for (const [k, e] of after)
|
|
328
|
+
if (!before.has(k))
|
|
329
|
+
add('exposure+', `new exposure ${e.threat} on ${e.asset} (${e.severity}) — ${e.file}`, 'record or test it');
|
|
330
|
+
for (const [k, e] of before)
|
|
331
|
+
if (!after.has(k))
|
|
332
|
+
add('exposure-', `${e.threat} on ${e.asset}: no longer open (mitigated or removed) — ${e.file}`, 'still untested until a run');
|
|
333
|
+
const wasConfirmed = new Set(prev.confirmedKeys);
|
|
334
|
+
for (const k of next.confirmedKeys)
|
|
335
|
+
if (!wasConfirmed.has(k))
|
|
336
|
+
add('confirmed', `confirmed exploitable: ${k.split('|').slice(0, 2).join(' on ')}`, 'fix first (/bravos-recheck after)');
|
|
337
|
+
const staleKey = (s) => `${s.file}:${s.line}`;
|
|
338
|
+
const wasStale = new Set(prev.stale.map(staleKey));
|
|
339
|
+
for (const s of next.stale)
|
|
340
|
+
if (!wasStale.has(staleKey(s)))
|
|
341
|
+
add('stale', `claim went stale: ${s.file}:${s.line ?? '?'} (${s.verb})`, 'recheck this (/bravos-recheck)');
|
|
342
|
+
if (prev.coverage && next.coverage && prev.coverage.percent !== next.coverage.percent)
|
|
343
|
+
add('coverage', `coverage ${prev.coverage.percent}% → ${next.coverage.percent}%`);
|
|
344
|
+
const wasOn = new Map(prev.setup.items.map((x) => [x.id, x.on]));
|
|
345
|
+
for (const x of next.setup.items)
|
|
346
|
+
if (wasOn.has(x.id) && wasOn.get(x.id) !== x.on)
|
|
347
|
+
add('setup', `${x.on ? 'done' : 'undone'}: ${x.label} (setup ${next.setup.done}/${next.setup.total})`);
|
|
348
|
+
if (!prev.sast && next.sast)
|
|
349
|
+
add('sast', `SAST ran: ${next.sast.total} findings, ${next.sast.reachable} reachable`, 'bravos sast candidates');
|
|
350
|
+
const prBefore = new Set(prev.team.numbers);
|
|
351
|
+
const prAfter = new Set(next.team.numbers);
|
|
352
|
+
for (const n of prAfter)
|
|
353
|
+
if (!prBefore.has(n))
|
|
354
|
+
add('pr', `PR #${n} opened`);
|
|
355
|
+
for (const n of prBefore)
|
|
356
|
+
if (!prAfter.has(n))
|
|
357
|
+
add('pr', `PR #${n} closed or merged`);
|
|
358
|
+
const answered = new Set(prev.review.answers.map((a) => a.by));
|
|
359
|
+
for (const a of next.review.answers)
|
|
360
|
+
if (!answered.has(a.by))
|
|
361
|
+
add('review', `${a.by} answered the intent review`, 'bravos review intent --summary');
|
|
362
|
+
const L0 = prev.ledger;
|
|
363
|
+
const L1 = next.ledger;
|
|
364
|
+
if (L0 && L1) {
|
|
365
|
+
if (L1.refuted > L0.refuted)
|
|
366
|
+
add('refuted', `${L1.refuted - L0.refuted} exposure(s) tested and refuted (not exploitable)`);
|
|
367
|
+
if (L1.retest > L0.retest)
|
|
368
|
+
add('stale', `${L1.retest - L0.retest} confirmed exposure(s) need a retest: their code changed`, 'recheck this (/bravos-recheck)');
|
|
369
|
+
}
|
|
370
|
+
const B0 = prev.boundaries;
|
|
371
|
+
const B1 = next.boundaries;
|
|
372
|
+
if (B0 && B1) {
|
|
373
|
+
if (B1.supported > B0.supported)
|
|
374
|
+
add('boundary', `${B1.supported - B0.supported} boundary claim(s) held when tested`);
|
|
375
|
+
if (B1.contradicted > B0.contradicted)
|
|
376
|
+
add('confirmed', `${B1.contradicted - B0.contradicted} boundary claim(s) did not hold`, 'fix first (/bravos-recheck after)');
|
|
377
|
+
}
|
|
378
|
+
if (prev.model && next.model) {
|
|
379
|
+
const keys = ['assets', 'threats', 'controls', 'mitigations', 'flows', 'boundaries', 'audits'];
|
|
380
|
+
const moved = keys.map((k) => [k, (next.model[k] ?? 0) - (prev.model[k] ?? 0)]).filter(([, d]) => d !== 0);
|
|
381
|
+
if (moved.length)
|
|
382
|
+
add('model', `threat model ${moved.map(([k, d]) => `${d > 0 ? '+' : ''}${d} ${k}`).join(', ')}`);
|
|
383
|
+
}
|
|
384
|
+
const c0 = prev.branchWork.commits?.[0]?.sha;
|
|
385
|
+
const c1 = next.branchWork.commits?.[0];
|
|
386
|
+
if (c0 && c1 && c1.sha !== c0)
|
|
387
|
+
add('commit', `commit ${c1.sha}: ${c1.subject}`);
|
|
388
|
+
const d0 = prev.graph.detail;
|
|
389
|
+
const d1 = next.graph.detail;
|
|
390
|
+
if (d1 && (!d0 || d0.callEdges !== d1.callEdges || JSON.stringify(d0.languages) !== JSON.stringify(d1.languages)) && prev.graph.state !== 'unavailable')
|
|
391
|
+
add('graph', `code graph ${d0 ? 'rebuilt' : 'ready'}${d1.languages.length ? `: ${d1.languages.map((l) => `${l.language} ${Math.round(l.ratio * 100)}% resolved`).join(', ')}` : ''}`);
|
|
392
|
+
if (next.graph.state === 'not-built' && prev.graph.state !== 'not-built')
|
|
393
|
+
add('graph', 'no code graph for this commit', 'bravos graph build . --scip no-exec');
|
|
394
|
+
const w0 = prev.workspace ?? null;
|
|
395
|
+
const w1 = next.workspace ?? null;
|
|
396
|
+
if (w0 && w1 && w0.name === w1.name) {
|
|
397
|
+
const key = (e) => `${e.srcMember}:${e.src}->${e.dstMember}:${e.dst}`;
|
|
398
|
+
if (w0.edges && w1.edges) {
|
|
399
|
+
const before = new Set(w0.edges.map(key));
|
|
400
|
+
const after = new Set(w1.edges.map(key));
|
|
401
|
+
for (const e of w1.edges)
|
|
402
|
+
if (!before.has(key(e)))
|
|
403
|
+
add('workspace', `cross-service edge added: ${e.srcMember} → ${e.dstMember} (${e.src} → ${e.dst})`);
|
|
404
|
+
for (const e of w0.edges)
|
|
405
|
+
if (!after.has(key(e)))
|
|
406
|
+
add('workspace', `cross-service edge gone: ${e.srcMember} → ${e.dstMember} (${e.src} → ${e.dst})`);
|
|
407
|
+
}
|
|
408
|
+
const c0 = new Map(w0.members.map((m) => [m.name, m.counts]));
|
|
409
|
+
for (const m of w1.members) {
|
|
410
|
+
const a = c0.get(m.name);
|
|
411
|
+
if (a && m.counts && m.name !== w1.member && m.counts.open !== a.open)
|
|
412
|
+
add('workspace', `${m.name}: ${m.counts.open - a.open > 0 ? '+' : ''}${m.counts.open - a.open} open exposure${Math.abs(m.counts.open - a.open) === 1 ? '' : 's'} (now ${m.counts.open})`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const r0 = prev.report ?? null;
|
|
416
|
+
const r1 = next.report ?? null;
|
|
417
|
+
if (r1 && (!r0 || r0.id !== r1.id))
|
|
418
|
+
add('report', `report started: ${r1.kind} ${r1.id}`, 'bravos-report writes report.md, then bravos report check');
|
|
419
|
+
// A check that ran since the last snapshot: a new pass is "report written", a failure names its count.
|
|
420
|
+
const same = !!(r0 && r1 && r0.id === r1.id);
|
|
421
|
+
if (r1?.check && (!same || r0?.check?.at !== r1.check.at)) {
|
|
422
|
+
const ids = r1.index.map((f) => f.id);
|
|
423
|
+
const span = ids.length ? ` (${ids[0]}${ids.length > 1 ? `…${ids[ids.length - 1]}` : ''})` : '';
|
|
424
|
+
if (r1.check.ok && !(same && r0?.check?.ok))
|
|
425
|
+
add('report', `report written: ${r1.title ?? r1.id}${span}`, 'p shows it · bravos report export latest --format pdf');
|
|
426
|
+
if (!r1.check.ok && !(same && r0?.check && !r0.check.ok && r0.check.errors === r1.check.errors))
|
|
427
|
+
add('report', `report check failed: ${r1.check.errors} problem${r1.check.errors === 1 ? '' : 's'}`, `bravos report check ${r1.id}`);
|
|
428
|
+
}
|
|
429
|
+
if (r1 && r0 && r0.id === r1.id)
|
|
430
|
+
for (const f of r1.exports)
|
|
431
|
+
if (!r0.exports.includes(f) && f !== 'md')
|
|
432
|
+
add('report', `report exported: ${f}`, f === 'pdf' || f === 'html' ? 'o opens it' : undefined);
|
|
433
|
+
if (prev.branchWork.uncommitted !== null && next.branchWork.uncommitted !== null && prev.branchWork.uncommitted !== next.branchWork.uncommitted) {
|
|
434
|
+
add('files', `${next.branchWork.uncommitted} uncommitted file${next.branchWork.uncommitted === 1 ? '' : 's'} on this branch (was ${prev.branchWork.uncommitted})`);
|
|
435
|
+
}
|
|
436
|
+
return out;
|
|
437
|
+
}
|
|
438
|
+
// @comment -- "Reads the autopilot hook's nudges from .bravos/watch/events.jsonl (local, gitignored); a missing or unreadable log yields none."
|
|
439
|
+
export function readAutopilotEvents(root) {
|
|
440
|
+
const file = join(root, '.bravos', 'watch', 'events.jsonl');
|
|
441
|
+
if (!existsSync(file))
|
|
442
|
+
return [];
|
|
443
|
+
const out = [];
|
|
444
|
+
for (const line of readFileSync(file, 'utf8').split('\n')) {
|
|
445
|
+
if (!line.trim())
|
|
446
|
+
continue;
|
|
447
|
+
try {
|
|
448
|
+
const e = JSON.parse(line);
|
|
449
|
+
if (typeof e?.at === 'string' && typeof e?.text === 'string')
|
|
450
|
+
out.push({ at: e.at, kind: 'autopilot', text: e.text });
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
/* skip a torn line */
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return out;
|
|
457
|
+
}
|
|
458
|
+
/* ------------------------------------------------------------------ keys */
|
|
459
|
+
// @comment -- "The recorded intent for the i overlay: .guardlink/intent.md as written, under a line naming who answered the review; when there is none, how to create it. Read-only."
|
|
460
|
+
export function intentOverlay(root) {
|
|
461
|
+
const file = join(root, '.guardlink', 'intent.md');
|
|
462
|
+
if (!existsSync(file)) {
|
|
463
|
+
return ['INTENT', '', 'No intent recorded yet (.guardlink/intent.md is missing).', '', 'Create it: ask "learn what this app intends" (/bravos-context).', 'Severities are not calibrated to the product until it exists.'];
|
|
464
|
+
}
|
|
465
|
+
let who = '';
|
|
466
|
+
try {
|
|
467
|
+
const answers = readAllAnswers(root, readDraft(root, 'intent')).files;
|
|
468
|
+
who = answers.length ? `answered by ${answers.map((a) => `${a.by} (${a.date})`).join(', ')}` : 'no review answers saved yet';
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
who = 'no review draft';
|
|
472
|
+
}
|
|
473
|
+
let text;
|
|
474
|
+
try {
|
|
475
|
+
text = readFileSync(file, 'utf8');
|
|
476
|
+
}
|
|
477
|
+
catch (e) {
|
|
478
|
+
text = `could not read it: ${e.message}`;
|
|
479
|
+
}
|
|
480
|
+
return [`INTENT .guardlink/intent.md · ${who}`, 'answers vs suggestions: bravos review intent --summary · change it: bravos review intent, or /bravos-context to refresh', '', ...text.replace(/\s+$/, '').split('\n')];
|
|
481
|
+
}
|
|
482
|
+
// @comment -- "The p overlay: the newest report's title, check verdict, findings index (id, evidence level, severity, priority, title), its executive section from report.md, and how to export or open it. Read-only."
|
|
483
|
+
export function reportOverlay(r) {
|
|
484
|
+
if (!r)
|
|
485
|
+
return ['REPORT', '', 'No report yet.', '', 'Ask "write the assessment report" (/bravos-report): it gathers the evidence with bravos report data,', 'writes .bravos/reports/<id>/report.md, checks it and exports it.'];
|
|
486
|
+
const out = [`REPORT ${r.title ?? '(report.md not written yet)'}`, `${r.kind} · ${r.id} · ${r.check ? (r.check.ok ? 'check passes' : `check: ${r.check.errors} problems (bravos report check ${r.id})`) : 'not checked yet'}${r.commitsBehind ? ` · ${r.commitsBehind} commit${r.commitsBehind === 1 ? '' : 's'} since its evidence` : ''}`, ''];
|
|
487
|
+
if (r.index.length) {
|
|
488
|
+
out.push('FINDINGS');
|
|
489
|
+
for (const f of r.index)
|
|
490
|
+
out.push(` ${f.id} ${(f.level ?? '?').padEnd(11)} ${(f.severity ?? '?').padEnd(8)} ${(f.priority ?? '').padEnd(3)} ${f.title}`);
|
|
491
|
+
out.push('');
|
|
492
|
+
}
|
|
493
|
+
try {
|
|
494
|
+
const md = readFileSync(join(r.dir, 'report.md'), 'utf8');
|
|
495
|
+
const m = /^##\s+(?:\d+\.\s*)?(Executive assessment|Retest summary)\s*$([\s\S]*?)(?=^##\s)/im.exec(md + '\n## ');
|
|
496
|
+
if (m)
|
|
497
|
+
out.push(m[1].toUpperCase(), ...m[2].trim().split('\n'), '');
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
/* report.md not written yet */
|
|
501
|
+
}
|
|
502
|
+
out.push(`exports: ${r.exports.length ? r.exports.join(', ') : 'none'} — o opens the pdf or html · bravos report export ${r.id} --format md,txt,html,pdf`);
|
|
503
|
+
return out;
|
|
504
|
+
}
|
|
505
|
+
// @comment -- "The s overlay: every service with its URL and counts, every cross-service edge with its mechanism and where it is declared, and unresolved references; or how to set up a workspace."
|
|
506
|
+
export function servicesOverlay(w) {
|
|
507
|
+
if (!w)
|
|
508
|
+
return ['SERVICES', '', 'This repo is not part of a bravos workspace.', '', 'For an app made of several repos: bravos workspace create NAME --member ./svc-a=svc-a@http://127.0.0.1:8081 --member ./svc-b=...'];
|
|
509
|
+
const out = [`SERVICES ${w.name}${w.isAppFolder ? ' (app folder)' : ` · this repo is ${w.member}`}`, ''];
|
|
510
|
+
for (const m of w.members) {
|
|
511
|
+
const c = m.counts;
|
|
512
|
+
out.push(` ${m.name === w.member ? '▸' : ' '} ${m.name.padEnd(16)} ${(m.url || 'no URL').padEnd(26)} ${c ? `${c.open} open · ${(c.bySeverity.critical ?? 0) + (c.bySeverity.high ?? 0)} high+ · ${c.confirmed} confirmed · coverage ${c.coverage ?? '?'}%` : 'counts not read yet'}`);
|
|
513
|
+
}
|
|
514
|
+
out.push('', 'CROSS-SERVICE EDGES');
|
|
515
|
+
if (!w.edges)
|
|
516
|
+
out.push(' unknown: run bravos workspace show');
|
|
517
|
+
else if (!w.edges.length)
|
|
518
|
+
out.push(' none declared: annotate calls as @flows #<this>-<x> -> #<other>-<y> via http|grpc|queue');
|
|
519
|
+
for (const e of w.edges ?? [])
|
|
520
|
+
out.push(` ${e.srcMember}:${e.src} → ${e.dstMember}:${e.dst}${e.mechanism ? ` via ${e.mechanism}` : ''} (${e.declaredIn})`);
|
|
521
|
+
if (w.unresolved.length)
|
|
522
|
+
out.push('', `UNRESOLVED (${w.unresolved.length})`, ...w.unresolved.map((u) => ` ${u.member}: ${u.reference}`));
|
|
523
|
+
if (w.clashes)
|
|
524
|
+
out.push('', `${w.clashes} id clash${w.clashes === 1 ? '' : 'es'}: bravos workspace show`);
|
|
525
|
+
out.push('', w.appFolder ? `graph across services: bravos graph build ${w.appFolder} --scip no-exec` : 'no app folder: the members do not share a parent');
|
|
526
|
+
return out;
|
|
527
|
+
}
|
|
528
|
+
/** Wraps lines to width, keeping each wrapped piece's indentation; never splits inside a word unless the word alone is too long. */
|
|
529
|
+
export function wrapLines(lines, width) {
|
|
530
|
+
const w = Math.max(10, width);
|
|
531
|
+
const out = [];
|
|
532
|
+
for (const line of lines) {
|
|
533
|
+
const chars = [...line];
|
|
534
|
+
if (chars.length <= w) {
|
|
535
|
+
out.push(line);
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
const indent = /^\s*(?:[-*] |\d+\. )?/.exec(line)?.[0].replace(/\S/g, ' ') ?? '';
|
|
539
|
+
let rest = line;
|
|
540
|
+
let first = true;
|
|
541
|
+
while ([...rest].length > (first ? w : w - indent.length)) {
|
|
542
|
+
const room = first ? w : w - indent.length;
|
|
543
|
+
const head = [...rest].slice(0, room).join('');
|
|
544
|
+
const cut = head.lastIndexOf(' ') > room / 3 ? head.lastIndexOf(' ') : room;
|
|
545
|
+
out.push((first ? '' : indent) + [...rest].slice(0, cut).join('').replace(/\s+$/, ''));
|
|
546
|
+
rest = [...rest].slice(cut).join('').replace(/^\s+/, '');
|
|
547
|
+
first = false;
|
|
548
|
+
}
|
|
549
|
+
if (rest)
|
|
550
|
+
out.push((first ? '' : indent) + rest);
|
|
551
|
+
}
|
|
552
|
+
return out;
|
|
553
|
+
}
|
|
554
|
+
/** The script this bravos runs from, so keys start the same bravos, not whichever is first on PATH. */
|
|
555
|
+
const selfArgs = () => (process.argv[1] ? [process.argv[1]] : []);
|
|
556
|
+
// @comment -- "Turns a bravos graph answer into overlay lines: the result as indented JSON, or bravos's own error message and remedy when it reports one, or a one-line failure."
|
|
557
|
+
export function graphAnswer(status, stdout, failure = '') {
|
|
558
|
+
try {
|
|
559
|
+
const j = JSON.parse(stdout || '');
|
|
560
|
+
if (j?.error)
|
|
561
|
+
return [`no answer: ${j.message ?? j.error}`, ...(j.remedy ? [`fix: ${j.remedy}`] : [])];
|
|
562
|
+
if (status === 0)
|
|
563
|
+
return JSON.stringify(j?.result ?? j, null, 2).split('\n');
|
|
564
|
+
}
|
|
565
|
+
catch {
|
|
566
|
+
/* not JSON */
|
|
567
|
+
}
|
|
568
|
+
return [`no answer${failure ? `: ${failure}` : status !== null ? ` (exit ${status})` : ''}`];
|
|
569
|
+
}
|
|
570
|
+
// @comment -- "Maps one key to a read-only action: copy the next command, open the intent review or guardlink's dashboard, ask the graph, show test-next, the recorded intent or the guide, scroll, refresh everything now, or quit; anything else does nothing."
|
|
571
|
+
export function handleKey(key, sn, root = '.') {
|
|
572
|
+
switch (key) {
|
|
573
|
+
case 'n':
|
|
574
|
+
return sn?.setup.next ? { kind: 'copy', text: sn.setup.next.cta } : { kind: 'none' };
|
|
575
|
+
case 'r':
|
|
576
|
+
return { kind: 'spawn', cmd: process.execPath, args: [...selfArgs(), 'review', 'intent'], note: 'opening the intent review page' };
|
|
577
|
+
case 'd': {
|
|
578
|
+
// Written outside the repository so the key never changes the working tree.
|
|
579
|
+
const file = join(tmpdir(), `bravos-dashboard-${basename(root) || 'repo'}.html`);
|
|
580
|
+
return { kind: 'spawn', cmd: 'guardlink', args: ['dashboard', '.', '-o', file], note: `writing guardlink's dashboard to ${file}`, open: file };
|
|
581
|
+
}
|
|
582
|
+
case 'g':
|
|
583
|
+
return { kind: 'overlay', what: 'graph' };
|
|
584
|
+
case 't':
|
|
585
|
+
return { kind: 'overlay', what: 'next' };
|
|
586
|
+
case 'p':
|
|
587
|
+
return { kind: 'overlay', what: 'report' };
|
|
588
|
+
case 's':
|
|
589
|
+
return { kind: 'overlay', what: 'services' };
|
|
590
|
+
case 'o':
|
|
591
|
+
return { kind: 'open-report' };
|
|
592
|
+
case 'i':
|
|
593
|
+
return { kind: 'overlay', what: 'intent' };
|
|
594
|
+
case '?':
|
|
595
|
+
return { kind: 'overlay', what: 'guide' };
|
|
596
|
+
case 'j':
|
|
597
|
+
case '\u001b[B':
|
|
598
|
+
return { kind: 'scroll', by: 1 };
|
|
599
|
+
case 'k':
|
|
600
|
+
case '\u001b[A':
|
|
601
|
+
return { kind: 'scroll', by: -1 };
|
|
602
|
+
case ' ':
|
|
603
|
+
case '\u001b[6~':
|
|
604
|
+
return { kind: 'scroll', by: 10 };
|
|
605
|
+
case 'b':
|
|
606
|
+
case '\u001b[5~':
|
|
607
|
+
return { kind: 'scroll', by: -10 };
|
|
608
|
+
case '\u001b[H':
|
|
609
|
+
return { kind: 'scroll', by: 'top' };
|
|
610
|
+
case 'G':
|
|
611
|
+
case '\u001b[F':
|
|
612
|
+
return { kind: 'scroll', by: 'end' };
|
|
613
|
+
case 'u':
|
|
614
|
+
return { kind: 'refresh' };
|
|
615
|
+
case 'q':
|
|
616
|
+
case '\u0003':
|
|
617
|
+
return { kind: 'quit' };
|
|
618
|
+
default:
|
|
619
|
+
return { kind: 'none' };
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
/* ------------------------------------------------------------------ live loop */
|
|
623
|
+
const ESC = '\x1b[';
|
|
624
|
+
const clock = (d) => [d.getHours(), d.getMinutes(), d.getSeconds()].map((n) => String(n).padStart(2, '0')).join(':');
|
|
625
|
+
// @comment -- "Which file changes do not trigger a redraw: git's internals except HEAD and refs (git status itself rewrites .git/index, which would loop), node_modules, bravos's SAST runs and its own watch folder except the autopilot log."
|
|
626
|
+
export function ignoredChange(name) {
|
|
627
|
+
const n = name.replace(/\\/g, '/');
|
|
628
|
+
if (n === '.git' || n.startsWith('.git/'))
|
|
629
|
+
return !(n === '.git/HEAD' || n.startsWith('.git/refs/'));
|
|
630
|
+
if (n.startsWith('node_modules/') || n.includes('/node_modules/'))
|
|
631
|
+
return true;
|
|
632
|
+
if (n.startsWith('.bravos/sast/'))
|
|
633
|
+
return true;
|
|
634
|
+
if (n.startsWith('.bravos/watch/'))
|
|
635
|
+
return n !== '.bravos/watch/events.jsonl';
|
|
636
|
+
// A report redraws watch when its meta.json changes (written by report data, check and export).
|
|
637
|
+
if (n.startsWith('.bravos/reports/'))
|
|
638
|
+
return !n.endsWith('/meta.json');
|
|
639
|
+
return false;
|
|
640
|
+
}
|
|
641
|
+
function openTarget(target) {
|
|
642
|
+
const [cmd, args] = process.platform === 'darwin' ? ['open', [target]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', target]] : ['xdg-open', [target]];
|
|
643
|
+
spawn(cmd, args, { detached: true, stdio: 'ignore' }).on('error', () => { }).unref();
|
|
644
|
+
}
|
|
645
|
+
function copyToClipboard(text) {
|
|
646
|
+
const tries = process.platform === 'darwin' ? [['pbcopy', []]] : process.platform === 'win32' ? [['clip', []]] : [['wl-copy', []], ['xclip', ['-selection', 'clipboard']]];
|
|
647
|
+
for (const [cmd, args] of tries) {
|
|
648
|
+
const r = spawnSync(cmd, args, { input: text, stdio: ['pipe', 'ignore', 'ignore'] });
|
|
649
|
+
if (r.status === 0)
|
|
650
|
+
return true;
|
|
651
|
+
}
|
|
652
|
+
return false;
|
|
653
|
+
}
|
|
654
|
+
// @comment -- "Runs the live view in a TTY: alternate screen, redraw on file changes (debounced, one snapshot at a time), the slow parts (boundaries, model size, PRs) every interval and the code graph every graphInterval, keys for read-only actions and scrolling, and a clean restore on q, Ctrl-C, SIGTERM or a crash. Never writes to the repository."
|
|
655
|
+
export async function runWatch(root, env, opts) {
|
|
656
|
+
const out = process.stdout;
|
|
657
|
+
const events = [];
|
|
658
|
+
const history = [];
|
|
659
|
+
let sn = null;
|
|
660
|
+
let busy = false;
|
|
661
|
+
let pending = null;
|
|
662
|
+
let note = '';
|
|
663
|
+
let overlay = null;
|
|
664
|
+
let overlayScroll = 0;
|
|
665
|
+
let seenAutopilot = 0;
|
|
666
|
+
let graphPrompt = null;
|
|
667
|
+
let scroll = 0;
|
|
668
|
+
let maxScroll = 0;
|
|
669
|
+
let refreshes = 0;
|
|
670
|
+
let lastMs = null;
|
|
671
|
+
let lastGraph = 0;
|
|
672
|
+
const graphEvery = Math.max(30, opts.graphIntervalSec ?? 300) * 1000;
|
|
673
|
+
const draw = () => {
|
|
674
|
+
if (!sn) {
|
|
675
|
+
out.write(`${ESC}H${ESC}2Jbravos watch — reading ${basename(root)} (guardlink, git, gh, the code graph)…`);
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
const width = out.columns || 100;
|
|
679
|
+
const height = out.rows || 40;
|
|
680
|
+
const status = busy ? 'updating…' : `● live ${clock(new Date())}`;
|
|
681
|
+
let frame;
|
|
682
|
+
if (overlay) {
|
|
683
|
+
const all = wrapLines(overlay, width);
|
|
684
|
+
const room = Math.max(1, height - 2);
|
|
685
|
+
overlayScroll = Math.max(0, Math.min(overlayScroll, all.length - room));
|
|
686
|
+
const shown = all.slice(overlayScroll, overlayScroll + room);
|
|
687
|
+
const where = all.length > room ? `${overlayScroll + 1}–${overlayScroll + shown.length} of ${all.length} · j/k, space, b to scroll · ` : '';
|
|
688
|
+
frame = [...shown, '', `(${where}any other key closes)`].join('\n');
|
|
689
|
+
}
|
|
690
|
+
else {
|
|
691
|
+
const r = renderFrame(sn, events, { width, height: height - (graphPrompt !== null ? 1 : 0), color: opts.color, status, compact: opts.compact, scroll, history, note, stats: { refreshes, lastMs } });
|
|
692
|
+
maxScroll = r.maxScroll;
|
|
693
|
+
scroll = Math.min(scroll, maxScroll);
|
|
694
|
+
frame = r.text;
|
|
695
|
+
}
|
|
696
|
+
if (graphPrompt !== null)
|
|
697
|
+
frame += `\ngraph question (Enter to ask, Esc to cancel): ${graphPrompt}█`;
|
|
698
|
+
out.write(`${ESC}H${ESC}2J${frame}`);
|
|
699
|
+
};
|
|
700
|
+
const refresh = async (want = {}) => {
|
|
701
|
+
const graph = !!want.graph || !sn || Date.now() - lastGraph > graphEvery;
|
|
702
|
+
if (busy) {
|
|
703
|
+
pending = { slow: !!want.slow || !!pending?.slow, graph: graph || !!pending?.graph };
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
busy = true;
|
|
707
|
+
draw();
|
|
708
|
+
const started = Date.now();
|
|
709
|
+
try {
|
|
710
|
+
const next = await snapshot(root, env, { refreshGraph: graph, refreshSlow: !!want.slow, previous: sn });
|
|
711
|
+
if (graph)
|
|
712
|
+
lastGraph = Date.now();
|
|
713
|
+
events.push(...diffSnapshots(sn, next));
|
|
714
|
+
const auto = readAutopilotEvents(root);
|
|
715
|
+
if (auto.length > seenAutopilot)
|
|
716
|
+
events.push(...auto.slice(seenAutopilot));
|
|
717
|
+
seenAutopilot = auto.length;
|
|
718
|
+
if (events.length > 50)
|
|
719
|
+
events.splice(0, events.length - 50);
|
|
720
|
+
history.push({ at: next.at, open: next.counts?.open ?? null, coverage: next.coverage?.percent ?? null });
|
|
721
|
+
if (history.length > 120)
|
|
722
|
+
history.splice(0, history.length - 120);
|
|
723
|
+
sn = next;
|
|
724
|
+
refreshes += 1;
|
|
725
|
+
lastMs = Date.now() - started;
|
|
726
|
+
}
|
|
727
|
+
catch (e) {
|
|
728
|
+
note = `refresh failed: ${e.message}`;
|
|
729
|
+
}
|
|
730
|
+
finally {
|
|
731
|
+
busy = false;
|
|
732
|
+
draw();
|
|
733
|
+
if (pending) {
|
|
734
|
+
const p = pending;
|
|
735
|
+
pending = null;
|
|
736
|
+
void refresh(p);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
};
|
|
740
|
+
out.write(`${ESC}?1049h${ESC}?25l`);
|
|
741
|
+
let restored = false;
|
|
742
|
+
const restore = () => {
|
|
743
|
+
if (restored)
|
|
744
|
+
return;
|
|
745
|
+
restored = true;
|
|
746
|
+
out.write(`${ESC}?25h${ESC}?1049l`);
|
|
747
|
+
};
|
|
748
|
+
// A crash must still give the person their terminal back.
|
|
749
|
+
process.once('exit', restore);
|
|
750
|
+
let debounce;
|
|
751
|
+
const watcher = fsWatch(root, { recursive: true }, (_e, f) => {
|
|
752
|
+
const name = String(f ?? '');
|
|
753
|
+
if (ignoredChange(name))
|
|
754
|
+
return;
|
|
755
|
+
clearTimeout(debounce);
|
|
756
|
+
debounce = setTimeout(() => void refresh(), 1500);
|
|
757
|
+
});
|
|
758
|
+
const slow = setInterval(() => void refresh({ slow: true }), Math.max(10, opts.intervalSec) * 1000);
|
|
759
|
+
out.on('resize', draw);
|
|
760
|
+
await new Promise((resolve) => {
|
|
761
|
+
const finish = () => {
|
|
762
|
+
clearInterval(slow);
|
|
763
|
+
clearTimeout(debounce);
|
|
764
|
+
watcher.close();
|
|
765
|
+
if (process.stdin.isTTY)
|
|
766
|
+
process.stdin.setRawMode(false);
|
|
767
|
+
process.stdin.pause();
|
|
768
|
+
restore();
|
|
769
|
+
resolve();
|
|
770
|
+
};
|
|
771
|
+
process.once('SIGTERM', finish);
|
|
772
|
+
process.once('SIGINT', finish);
|
|
773
|
+
if (process.stdin.isTTY)
|
|
774
|
+
process.stdin.setRawMode(true);
|
|
775
|
+
process.stdin.setEncoding('utf8');
|
|
776
|
+
process.stdin.on('data', (key) => {
|
|
777
|
+
if (graphPrompt !== null) {
|
|
778
|
+
if (key === '\r' || key === '\n') {
|
|
779
|
+
const q = graphPrompt.trim().split(/\s+/);
|
|
780
|
+
graphPrompt = null;
|
|
781
|
+
if (q[0]) {
|
|
782
|
+
overlay = [`bravos graph ${q.join(' ')}`, '', 'asking…'];
|
|
783
|
+
void askGraph(root, env, q[0], q.slice(1), 30000).then((a) => {
|
|
784
|
+
overlay = [`bravos graph ${q.join(' ')}`, '', ...graphAnswer(a ? (a.error ? 2 : 0) : null, a ? JSON.stringify(a) : '', a ? '' : 'codegraph-mcp not installed')];
|
|
785
|
+
draw();
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
else if (key === '\u007f')
|
|
790
|
+
graphPrompt = graphPrompt.slice(0, -1);
|
|
791
|
+
else if (key === '\u001b')
|
|
792
|
+
graphPrompt = null;
|
|
793
|
+
else if (!key.startsWith('\u001b'))
|
|
794
|
+
graphPrompt += key;
|
|
795
|
+
draw();
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
if (overlay) {
|
|
799
|
+
if (key === '\u0003')
|
|
800
|
+
return finish();
|
|
801
|
+
const k = handleKey(key, sn, root);
|
|
802
|
+
if (k.kind === 'scroll')
|
|
803
|
+
overlayScroll = k.by === 'top' ? 0 : k.by === 'end' ? Number.MAX_SAFE_INTEGER : Math.max(0, overlayScroll + k.by);
|
|
804
|
+
else
|
|
805
|
+
overlay = null;
|
|
806
|
+
draw();
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
const a = handleKey(key, sn, root);
|
|
810
|
+
if (a.kind === 'quit')
|
|
811
|
+
return finish();
|
|
812
|
+
if (a.kind === 'none')
|
|
813
|
+
return;
|
|
814
|
+
note = '';
|
|
815
|
+
if (a.kind === 'scroll')
|
|
816
|
+
scroll = a.by === 'top' ? 0 : a.by === 'end' ? maxScroll : Math.max(0, Math.min(maxScroll, scroll + a.by));
|
|
817
|
+
if (a.kind === 'open-report') {
|
|
818
|
+
const r = sn?.report;
|
|
819
|
+
const file = r ? (['pdf', 'html'].map((f) => join(r.dir, `report.${f}`)).find((f) => existsSync(f)) ?? null) : null;
|
|
820
|
+
note = file ? `opening ${file}` : r ? `no html or pdf yet: bravos report export ${r.id} --format html,pdf` : 'no report yet: ask "write the assessment report" (/bravos-report)';
|
|
821
|
+
if (file)
|
|
822
|
+
openTarget(file);
|
|
823
|
+
}
|
|
824
|
+
if (a.kind === 'refresh') {
|
|
825
|
+
note = 'refreshing everything, the code graph too…';
|
|
826
|
+
void refresh({ slow: true, graph: true });
|
|
827
|
+
}
|
|
828
|
+
if (a.kind === 'copy')
|
|
829
|
+
note = copyToClipboard(a.text) ? `copied: ${a.text}` : `next: ${a.text}`;
|
|
830
|
+
if (a.kind === 'spawn') {
|
|
831
|
+
note = a.note;
|
|
832
|
+
const child = spawn(a.cmd, a.args, { cwd: root, env, detached: !a.open, stdio: 'ignore' });
|
|
833
|
+
child.on('error', () => {
|
|
834
|
+
note = `${a.cmd} not found`;
|
|
835
|
+
draw();
|
|
836
|
+
});
|
|
837
|
+
if (a.open) {
|
|
838
|
+
const file = a.open;
|
|
839
|
+
child.on('exit', (code) => {
|
|
840
|
+
note = code === 0 ? `dashboard: ${file}` : `${a.cmd} exited ${code}`;
|
|
841
|
+
if (code === 0)
|
|
842
|
+
openTarget(file);
|
|
843
|
+
draw();
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
else
|
|
847
|
+
child.unref();
|
|
848
|
+
}
|
|
849
|
+
if (a.kind === 'overlay') {
|
|
850
|
+
overlayScroll = 0;
|
|
851
|
+
if (a.what === 'graph')
|
|
852
|
+
graphPrompt = '';
|
|
853
|
+
else if (a.what === 'intent')
|
|
854
|
+
overlay = intentOverlay(root);
|
|
855
|
+
else if (a.what === 'report')
|
|
856
|
+
overlay = reportOverlay(sn?.report ?? null);
|
|
857
|
+
else if (a.what === 'services')
|
|
858
|
+
overlay = servicesOverlay(sn?.workspace ?? null);
|
|
859
|
+
else if (a.what === 'next' && sn) {
|
|
860
|
+
overlay = ['TEST NEXT (guardlink hypothesis next)', '', ...sn.next.flatMap((q) => [`${q.severity.padEnd(8)} ${q.threat} on ${q.asset} [${q.state}]`, ` ${q.file}:${q.line ?? '?'}`]), '', 'prove: ask "prove the top 3" (/bravos-prove) · make testable: /bravos-templatize'];
|
|
861
|
+
if (sn.stale.length)
|
|
862
|
+
overlay.push('', `STALE CLAIMS (${sn.stale.length})`, ...sn.stale.slice(0, 20).map((x) => ` ${x.verb.padEnd(10)} ${x.file}:${x.line ?? '?'}`), ' recheck: /bravos-recheck');
|
|
863
|
+
}
|
|
864
|
+
else if (a.what === 'guide') {
|
|
865
|
+
const r = spawnSync(process.execPath, [...selfArgs(), 'guide', 'start'], { encoding: 'utf8', timeout: 10000 });
|
|
866
|
+
overlay = (r.stdout || 'bravos guide unavailable').split('\n');
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
draw();
|
|
870
|
+
});
|
|
871
|
+
void refresh({ slow: true, graph: true });
|
|
872
|
+
});
|
|
873
|
+
}
|