@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
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
// @comment -- "Pure text builder for the session-start briefing, collect() which gathers its read-only guardlink, git and gh facts concurrently under a shared deadline, and the runJson/runJsonAsync spawn wrappers; dependency-free so it can be copied verbatim into target repos."
|
|
2
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
3
|
+
import { accessSync, constants, readFileSync } from 'node:fs';
|
|
4
|
+
import { basename, delimiter, dirname, join } from 'node:path';
|
|
5
|
+
import { readProving } from './autopilot.mjs';
|
|
6
|
+
import { planLine, status as licenceStatus } from './licence.mjs';
|
|
7
|
+
import { readSast, sastAvailable, sastLine } from './sast.mjs';
|
|
8
|
+
import { readSetup, setupLine } from './setup.mjs';
|
|
9
|
+
import { memberCounts, readWorkspace, workspaceLines } from './workspace.mjs';
|
|
10
|
+
|
|
11
|
+
const SEVERITIES = ['critical', 'high', 'medium', 'low'];
|
|
12
|
+
const MAX_LINES = 40;
|
|
13
|
+
const MAX_DEGRADED = 3;
|
|
14
|
+
|
|
15
|
+
const NEXT_CLAUDE = 'Next: /bravos for the next step · /bravos-annotate to extend coverage · /bravos-ship before a PR';
|
|
16
|
+
const NEXT_OTHER =
|
|
17
|
+
'Next: run the bravos skill for the next step · bravos-annotate to extend coverage · bravos-ship before a PR';
|
|
18
|
+
|
|
19
|
+
const arr = (v) => (Array.isArray(v) ? v : []);
|
|
20
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
|
21
|
+
const sevRank = (s) => {
|
|
22
|
+
const i = SEVERITIES.indexOf(s);
|
|
23
|
+
return i === -1 ? SEVERITIES.length : i;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Asset and threat for a CiClaim: its own fields when present, else the first two #ids in its claim text. */
|
|
27
|
+
function assetThreat(c) {
|
|
28
|
+
const ids = typeof c?.claim === 'string' ? (c.claim.match(/#[\w-]+/g) ?? []) : [];
|
|
29
|
+
return { asset: c?.asset ?? ids[0] ?? '?', threat: c?.threat ?? ids[1] ?? '?' };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function claimLine(c) {
|
|
33
|
+
const { asset, threat } = assetThreat(c);
|
|
34
|
+
// Real ci confirmed[] entries carry location.{file,line}; CiClaim-shaped entries carry file/line at top level.
|
|
35
|
+
const file = c?.location?.file ?? c?.file;
|
|
36
|
+
const line = c?.location?.line ?? c?.line;
|
|
37
|
+
return `${c?.severity ?? 'unknown'} ${asset} / ${threat} — ${file}:${line}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Always returns a line: an unreadable ci prints "unknown", never zeros. */
|
|
41
|
+
function exposuresLine(g) {
|
|
42
|
+
const ci = g.ci;
|
|
43
|
+
let line;
|
|
44
|
+
if (!ci) {
|
|
45
|
+
line = 'Open exposures: unknown (guardlink ci unavailable)';
|
|
46
|
+
} else {
|
|
47
|
+
const s = ci.summary ?? {};
|
|
48
|
+
const list = arr(ci.exposures);
|
|
49
|
+
const by = s.by_severity ?? Object.fromEntries(SEVERITIES.map((k) => [k, list.filter((e) => e?.severity === k).length]));
|
|
50
|
+
const total = typeof s.exposures === 'number' ? s.exposures : list.length;
|
|
51
|
+
line = `Open exposures: ${total} (${SEVERITIES.map((k) => `${k} ${num(by[k])}`).join(', ')})`;
|
|
52
|
+
if (s.coverage) {
|
|
53
|
+
const c = s.coverage;
|
|
54
|
+
line += ` · coverage ${num(c.percent)}% (${num(c.annotated_files)}/${num(c.source_files)} files)`;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// The worklist is unannotated source files: from `guardlink unannotated` when given, else from ci coverage.
|
|
58
|
+
const w = g.unannotated;
|
|
59
|
+
const cov = ci?.summary?.coverage;
|
|
60
|
+
let n = null;
|
|
61
|
+
if (w) n = typeof w.summary?.worklist === 'number' ? w.summary.worklist : arr(w.worklist).length;
|
|
62
|
+
else if (cov) n = Math.max(0, num(cov.source_files) - num(cov.annotated_files));
|
|
63
|
+
if (n !== null) line += ` · worklist ${n} files`;
|
|
64
|
+
return line;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// @comment -- "Reads .guardlink/intent.md (written by bravos-context) and reports whether the product's intent is recorded: none, draft (any item marked '— draft') or confirmed, with the invariant count; read-only."
|
|
68
|
+
export function readIntent(root) {
|
|
69
|
+
let text;
|
|
70
|
+
try {
|
|
71
|
+
text = readFileSync(join(root, '.guardlink', 'intent.md'), 'utf8');
|
|
72
|
+
} catch {
|
|
73
|
+
return { status: 'none', invariants: 0, drafts: 0 };
|
|
74
|
+
}
|
|
75
|
+
const items = text.split('\n').filter((l) => /^\s*[-*]\s/.test(l));
|
|
76
|
+
const invariants = items.filter((l) => /\bINV-\d+\b/.test(l)).length;
|
|
77
|
+
const drafts = items.filter((l) => /—\s*draft\s*$/i.test(l)).length;
|
|
78
|
+
return { status: drafts > 0 ? 'draft' : 'confirmed', invariants, drafts };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function intentLine(intent, harness) {
|
|
82
|
+
const run = harness === 'claude' ? '/bravos-context' : 'the bravos-context skill';
|
|
83
|
+
if (intent.status === 'none') return `Intent: none — severities are not calibrated to the product; run ${run}`;
|
|
84
|
+
if (intent.status === 'draft') return `Intent: draft (${intent.invariants} invariants, ${intent.drafts} items unconfirmed) — confirm with ${run}`;
|
|
85
|
+
return `Intent: confirmed (${intent.invariants} invariants)`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
const SKILL_SIGIL = { claude: '/', codex: '$', omp: '/skill:', opencode: '/' };
|
|
90
|
+
|
|
91
|
+
// @comment -- "The coaching block the briefing gives the agent in every harness: the Setup line (score and next unlock with its command) and, unless BRAVOS_COACH=off, a few rules for guiding a person who is not a power user — offer once, suggest at natural moments, plain words first, never lecture; shrinks to one rule once most of setup is on."
|
|
92
|
+
function coachLines(i) {
|
|
93
|
+
if (!i.root || i.guardlink === 'missing') return [];
|
|
94
|
+
const s = readSetup(i.root, i, i.harness);
|
|
95
|
+
const out = [setupLine(s)];
|
|
96
|
+
if (i.env?.BRAVOS_COACH === 'off' || process.env.BRAVOS_COACH === 'off') return out;
|
|
97
|
+
const k = (n) => (SKILL_SIGIL[i.harness] ? `${SKILL_SIGIL[i.harness]}${n}` : n);
|
|
98
|
+
if (s.done > 7) {
|
|
99
|
+
out.push(`How to help: at natural moments suggest the next bravos step in one plain line (annotate after security edits, ${k('bravos-ship')} before a PR, ${k('bravos-recheck')} after a fix); never nag.`);
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
out.push(
|
|
103
|
+
'How to help this person (offer, never lecture; one offer per topic per session):',
|
|
104
|
+
`- In your first reply, if setup is under 5 or intent is none, offer once in plain words three starts that fit this repo, in setup order (build the code graph first if it is off, then learn what the app intends, annotate the uncovered files, run SAST, prove the top threats; or check this branch before a PR) and \`bravos guide\` for more; then follow their lead.`,
|
|
105
|
+
`- After you edit security-relevant code, offer to record what it exposes (${k('bravos-annotate')}); before a PR, run the ship check (${k('bravos-ship')}); after a fix, offer a recheck (${k('bravos-recheck')}).`,
|
|
106
|
+
'- When a request would go better with the next unlock above, say what it would add, once. Plain words first, skill name second. Never send test traffic without the authorization bravos-env asks for.',
|
|
107
|
+
'- Once, when it helps them follow along, mention that `bravos watch` in a second terminal pane shows this live as you work (free, read-only).',
|
|
108
|
+
);
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Builds the briefing text. Never throws on partial inputs. */
|
|
113
|
+
export function buildBriefing(inputs) {
|
|
114
|
+
const i = inputs ?? {};
|
|
115
|
+
const lines = [`GuardLink security picture (bravos) — ${i.repo} @ ${i.sha}, base ${i.base}`];
|
|
116
|
+
for (const e of arr(i.errors).slice(0, MAX_DEGRADED)) lines.push(` (degraded: ${e})`);
|
|
117
|
+
const nextLine = i.harness === 'claude' ? NEXT_CLAUDE : NEXT_OTHER;
|
|
118
|
+
|
|
119
|
+
if (i.guardlink === 'missing') {
|
|
120
|
+
lines.push('guardlink: not found on PATH — run bravos doctor', nextLine);
|
|
121
|
+
return lines.join('\n');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const g = i.guardlink ?? {};
|
|
125
|
+
const head = [];
|
|
126
|
+
head.push(exposuresLine(g));
|
|
127
|
+
|
|
128
|
+
// Truncatable sections: { header, items }; the cap drops tickets first, then stale, FIX NOW, next.
|
|
129
|
+
const confirmed = arr(g.ci?.confirmed).slice().sort((a, b) => sevRank(a.severity) - sevRank(b.severity));
|
|
130
|
+
const fix = { header: `FIX NOW — confirmed exploitable (${confirmed.length}):`, items: confirmed.slice(0, 5).map((c) => ` - ${claimLine(c)}`) };
|
|
131
|
+
if (confirmed.length > 5) fix.items.push(` … and ${confirmed.length - 5} more`);
|
|
132
|
+
if (confirmed.length === 0) fix.items = [];
|
|
133
|
+
|
|
134
|
+
const queue = arr(g.next?.queue).slice(0, 3);
|
|
135
|
+
const next = {
|
|
136
|
+
header: 'Next to test or fix (guardlink hypothesis next):',
|
|
137
|
+
items: queue.map((q, n) => ` ${n + 1}. [${q.state}] ${claimLine(q)}`),
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const changed = new Set(arr(i.changedFiles));
|
|
141
|
+
const stale = arr(g.ci?.stale).filter((c) => changed.has(c.file));
|
|
142
|
+
const staleSec = {
|
|
143
|
+
header: `Went stale in files you changed since ${i.base} (${stale.length}):`,
|
|
144
|
+
items: stale.slice(0, 5).map((c) => ` - ${c.file}:${c.line} ${c.verb}`),
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const results = arr(g.tickets?.results);
|
|
148
|
+
const ticketCount = typeof g.tickets?.count === 'number' ? g.tickets.count : results.length;
|
|
149
|
+
const tickets = {
|
|
150
|
+
header: `Open tickets (${ticketCount}):`,
|
|
151
|
+
items: results.slice(0, 5).map((t) => ` - ${t.ref} ${t.threat} on ${t.asset} by ${t.assignee ?? 'unassigned'} until ${t.due ?? 'n/a'}`),
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const tail = [];
|
|
155
|
+
if (!i.inFlight) tail.push('In flight by teammates: unknown (gh unavailable)');
|
|
156
|
+
else if (i.inFlight.prs > 0 || i.inFlight.files > 0) {
|
|
157
|
+
tail.push(`In flight by teammates: ${i.inFlight.files} files in ${i.inFlight.prs} open PRs`);
|
|
158
|
+
}
|
|
159
|
+
if (i.intent && typeof i.intent === 'object') tail.push(intentLine(i.intent, i.harness));
|
|
160
|
+
if (i.proving && typeof i.proving === 'object') {
|
|
161
|
+
const p = i.proving;
|
|
162
|
+
tail.push(p.status === 'set'
|
|
163
|
+
? `Proving: ${p.choice}${p.target ? ` on ${p.target}` : ''}${p.generation ? ', model-API probes allowed' : ''} (${p.by}, ${p.date}, .guardlink/proving.md)`
|
|
164
|
+
: 'Proving: not set — when proving comes up, ask the person once how they want it handled and record it (bravos-prove)');
|
|
165
|
+
}
|
|
166
|
+
if (i.shadow) {
|
|
167
|
+
tail.push(i.shadow.userNewer
|
|
168
|
+
? `Note: this repo's bravos (${i.shadow.repo}) is behind the person's user-level copy (${i.shadow.user}), which Claude Code uses; suggest they run bravos sync in this repo from their own terminal`
|
|
169
|
+
: `Note: the person's user-level bravos skills (~/.claude/skills, ${i.shadow.user}) are used instead of this repo's (${i.shadow.repo}); tell them to run bravos sync --global from their own terminal, then start a new session`);
|
|
170
|
+
}
|
|
171
|
+
if (i.autopilot) tail.push('Autopilot: on — an end-of-turn check sends you back once if changed code lacks annotations, sits under a confirmed or stale claim, or adds exposures to prove');
|
|
172
|
+
if (i.codegraph === 'current') tail.push('Code graph: current');
|
|
173
|
+
else if (i.codegraph === 'cli') tail.push('Code graph: ask it with `bravos graph <question> --json` (same answers as the codegraph_ tools; e.g. attack-surface, entry-points, callers, reachable-sinks, call-chain); if it says no graph, build it: bravos graph build . (bravos-codegraph)');
|
|
174
|
+
else if (i.codegraph === 'not-built') tail.push('Code graph: none for this commit — bravos keys a graph to the commit it was built at, so a new commit needs a rebuild: bravos graph build . --scip no-exec (bravos-codegraph; no tokens, a few minutes). Until then skills trace routes by reading.');
|
|
175
|
+
else if (i.codegraph === 'diverged') tail.push('Code graph: diverged — rebuild with codegraph-build --scip no-exec');
|
|
176
|
+
else if (i.codegraph === 'locked') tail.push('Code graph: on the Team plan (see Plan above) — skills trace routes by reading until then');
|
|
177
|
+
else if (i.codegraph === 'unavailable') tail.push('Code graph: unavailable — skills trace routes by reading; when the person asks, set it up yourself (bravos-codegraph step 1: bravos setup with their go-ahead, then bravos graph build .)');
|
|
178
|
+
if (i.sast && typeof i.sast === 'object') tail.push(sastLine(i.sast.summary, i.sast.available));
|
|
179
|
+
if (i.workspace) tail.push(...workspaceLines(i.workspace, i.workspaceCounts));
|
|
180
|
+
|
|
181
|
+
const sections = [fix, next, staleSec, tickets].filter((s) => s.items.length > 0);
|
|
182
|
+
const count = () => lines.length + head.length + sections.reduce((n, s) => n + 1 + s.items.length, 0) + tail.length + 1;
|
|
183
|
+
for (const s of [tickets, staleSec, fix, next]) {
|
|
184
|
+
while (count() > MAX_LINES && s.items.length > 0) s.items.pop();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
for (const l of head) lines.push(l);
|
|
188
|
+
for (const s of sections) {
|
|
189
|
+
if (s.items.length === 0) continue;
|
|
190
|
+
lines.push(s.header, ...s.items);
|
|
191
|
+
}
|
|
192
|
+
lines.push(...tail, ...coachLines(i), nextLine);
|
|
193
|
+
return lines.join('\n');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Runs `cmd args` read-only with a timeout and parses stdout as JSON. */
|
|
197
|
+
export function runJson(cmd, args, { cwd, timeoutMs = 10000, env = process.env } = {}) {
|
|
198
|
+
const r = spawnSync(cmd, args, { cwd, env, timeout: timeoutMs, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
199
|
+
if (r.error) {
|
|
200
|
+
if (r.error.code === 'ETIMEDOUT') return { ok: false, error: `${cmd}: timed out after ${timeoutMs / 1000}s` };
|
|
201
|
+
return { ok: false, error: `${cmd}: ${r.error.code ?? r.error.message}` };
|
|
202
|
+
}
|
|
203
|
+
const ticketsNoMatch = cmd === 'guardlink' && args.includes('lookup') && args.includes('tickets') && r.status === 2;
|
|
204
|
+
if (r.status !== 0 && !ticketsNoMatch) return { ok: false, error: `${cmd} exited ${r.status}` };
|
|
205
|
+
try {
|
|
206
|
+
return { ok: true, json: JSON.parse(r.stdout) };
|
|
207
|
+
} catch {
|
|
208
|
+
return { ok: false, error: `${cmd}: output was not JSON` };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const MAX_OUTPUT = 64 * 1024 * 1024;
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Spawns `cmd args` (no shell, stdin and stderr ignored) and resolves with its stdout, capped at 64 MiB by
|
|
216
|
+
* discarding the excess. A timer kills the child at `timeoutMs` and resolves at once, without waiting for
|
|
217
|
+
* grandchildren that may still hold the pipe open.
|
|
218
|
+
*/
|
|
219
|
+
// @comment -- "Async read-only spawn core for collect(): argv array, bounded output, per-command kill timer; never rejects."
|
|
220
|
+
function capture(cmd, args, { cwd, timeoutMs = 10000, env = process.env } = {}) {
|
|
221
|
+
return new Promise((resolve) => {
|
|
222
|
+
let done = false;
|
|
223
|
+
const finish = (r) => {
|
|
224
|
+
if (done) return;
|
|
225
|
+
done = true;
|
|
226
|
+
clearTimeout(timer);
|
|
227
|
+
resolve(r);
|
|
228
|
+
};
|
|
229
|
+
let child;
|
|
230
|
+
try {
|
|
231
|
+
child = spawn(cmd, args, { cwd, env, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
232
|
+
} catch (e) {
|
|
233
|
+
resolve({ error: e?.code ?? e?.message ?? String(e) });
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const chunks = [];
|
|
237
|
+
let size = 0;
|
|
238
|
+
child.stdout.on('data', (b) => {
|
|
239
|
+
if (size >= MAX_OUTPUT) return;
|
|
240
|
+
const keep = b.length > MAX_OUTPUT - size ? b.subarray(0, MAX_OUTPUT - size) : b;
|
|
241
|
+
chunks.push(keep);
|
|
242
|
+
size += keep.length;
|
|
243
|
+
});
|
|
244
|
+
const timer = setTimeout(() => {
|
|
245
|
+
child.kill('SIGKILL');
|
|
246
|
+
child.stdout.destroy();
|
|
247
|
+
finish({ timedOut: true });
|
|
248
|
+
}, Math.max(0, timeoutMs));
|
|
249
|
+
child.on('error', (e) => finish({ error: e.code ?? e.message }));
|
|
250
|
+
child.on('close', (status) => finish({ status, stdout: Buffer.concat(chunks).toString('utf8') }));
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Async runJson: same argv, cap and `{ok, json} | {ok, error}` shapes and error strings, without blocking the event loop. */
|
|
255
|
+
// @comment -- "Async read-only runJson for collect(): spawns through capture() (argv array, no shell, per-command timeout), maps timeout, spawn error and non-zero exit to an error string, and parses stdout as JSON; never rejects."
|
|
256
|
+
export async function runJsonAsync(cmd, args, { cwd, timeoutMs = 10000, env = process.env } = {}) {
|
|
257
|
+
const r = await capture(cmd, args, { cwd, timeoutMs, env });
|
|
258
|
+
if (r.timedOut) return { ok: false, error: `${cmd}: timed out after ${timeoutMs / 1000}s` };
|
|
259
|
+
if (r.error) return { ok: false, error: `${cmd}: ${r.error}` };
|
|
260
|
+
const ticketsNoMatch = cmd === 'guardlink' && args.includes('lookup') && args.includes('tickets') && r.status === 2;
|
|
261
|
+
if (r.status !== 0 && !ticketsNoMatch) return { ok: false, error: `${cmd} exited ${r.status}` };
|
|
262
|
+
try {
|
|
263
|
+
return { ok: true, json: JSON.parse(r.stdout) };
|
|
264
|
+
} catch {
|
|
265
|
+
return { ok: false, error: `${cmd}: output was not JSON` };
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function whichOnPath(name, env) {
|
|
270
|
+
for (const dir of (env.PATH ?? '').split(delimiter)) {
|
|
271
|
+
if (!dir) continue;
|
|
272
|
+
try {
|
|
273
|
+
accessSync(join(dir, name), constants.X_OK);
|
|
274
|
+
return true;
|
|
275
|
+
} catch {
|
|
276
|
+
/* keep looking */
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Gathers the briefing inputs for `root`. The guardlink commands, gh and the git chain run concurrently;
|
|
284
|
+
* each command gets 10 s or what is left of the shared `deadlineMs` (20 s), whichever is less, so the
|
|
285
|
+
* whole collect stays inside the 30 s the hook registers and a slow command degrades only its own line.
|
|
286
|
+
*/
|
|
287
|
+
// @comment -- "Runs read-only guardlink ci / hypothesis next / lookup tickets, git and gh pr list concurrently under a shared deadline and returns BriefingInputs; the worklist count comes from ci coverage, so guardlink unannotated is not run."
|
|
288
|
+
// @comment -- "Whether bravos has a code graph for this commit, without loading it: bravos answers codegraph_unavailable in well under a second when it has none, and spends seconds loading one that exists, so a probe still running at timeoutMs means a graph is there (it is killed). Returns not-built, current (it answered), cli (present, still loading), or null when bravos is not on PATH. BRAVOS_GRAPH_PROBE=off skips it and reads as cli. Read-only: argv, no shell."
|
|
289
|
+
export async function probeGraph(root, env, timeoutMs = 3000) {
|
|
290
|
+
// Either bravos answers `graph acceptance-register --json`: this CLI (through codegraph-mcp) or the Python one.
|
|
291
|
+
if (!whichOnPath('bravos', env)) return null;
|
|
292
|
+
if (env.BRAVOS_GRAPH_PROBE === 'off') return 'cli';
|
|
293
|
+
const r = await capture('bravos', ['graph', 'acceptance-register', '--json'], { cwd: root, env, timeoutMs });
|
|
294
|
+
if (r.timedOut || r.error) return 'cli';
|
|
295
|
+
try {
|
|
296
|
+
const j = JSON.parse(r.stdout);
|
|
297
|
+
if (j?.error === 'codegraph_unavailable') return 'not-built';
|
|
298
|
+
if (j?.error === 'licence_required') return 'locked';
|
|
299
|
+
if (r.status === 0 && j?.result) return 'current';
|
|
300
|
+
} catch {
|
|
301
|
+
/* not JSON: say nothing more than that bravos is there */
|
|
302
|
+
}
|
|
303
|
+
return 'cli';
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export async function collect(root, env, { deadlineMs = 20000, nextCount = 3 } = {}) {
|
|
307
|
+
const deadline = Date.now() + deadlineMs;
|
|
308
|
+
const budget = () => Math.min(10000, Math.max(0, deadline - Date.now()));
|
|
309
|
+
// Everything started now shares one budget; only the git diff, which waits for the base ref, gets less.
|
|
310
|
+
const first = Math.min(10000, deadlineMs);
|
|
311
|
+
const errors = [];
|
|
312
|
+
|
|
313
|
+
const git = async (args, timeoutMs = first) => {
|
|
314
|
+
const r = await capture('git', args, { cwd: root, env, timeoutMs });
|
|
315
|
+
return r.timedOut || r.error || r.status !== 0 ? null : r.stdout.trim();
|
|
316
|
+
};
|
|
317
|
+
const gitFacts = (async () => {
|
|
318
|
+
const [sha, ref, branch] = await Promise.all([
|
|
319
|
+
git(['rev-parse', '--short', 'HEAD']),
|
|
320
|
+
git(['symbolic-ref', '-q', '--short', 'refs/remotes/origin/HEAD']),
|
|
321
|
+
git(['rev-parse', '--abbrev-ref', 'HEAD']),
|
|
322
|
+
]);
|
|
323
|
+
const base = ref || 'origin/main';
|
|
324
|
+
const diff = await git(['diff', '--name-only', `${base}...HEAD`], budget());
|
|
325
|
+
return { sha: sha ?? 'unknown', base, branch: branch && branch !== 'HEAD' ? branch : null, changedFiles: diff ? diff.split('\n').filter(Boolean) : [] };
|
|
326
|
+
})();
|
|
327
|
+
|
|
328
|
+
const hasGuardlink = whichOnPath('guardlink', env);
|
|
329
|
+
const run = (label, args) =>
|
|
330
|
+
hasGuardlink ? runJsonAsync('guardlink', args, { cwd: root, env, timeoutMs: first }).then((r) => ({ label, r })) : null;
|
|
331
|
+
const glRuns = [
|
|
332
|
+
run('ci', ['ci', '-f', 'json']),
|
|
333
|
+
run('hypothesis next', ['hypothesis', 'next', '--json', '-n', String(nextCount)]),
|
|
334
|
+
run('lookup tickets', ['lookup', 'tickets']),
|
|
335
|
+
];
|
|
336
|
+
const prRun = runJsonAsync('gh', ['pr', 'list', '--state', 'open', '--json', 'number,files', '--limit', '50'], {
|
|
337
|
+
cwd: root,
|
|
338
|
+
env,
|
|
339
|
+
timeoutMs: first,
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
const graphRun = probeGraph(root, env, Math.min(3000, first));
|
|
343
|
+
// At an app folder, each member's counts come from its own guardlink ci, alongside the rest.
|
|
344
|
+
const workspace = readWorkspace(root);
|
|
345
|
+
const countsRun = workspace?.isAppFolder && hasGuardlink ? memberCounts(workspace, runJsonAsync, env, first) : Promise.resolve(null);
|
|
346
|
+
const [facts, pr, graphState, workspaceCounts, ...gl] = await Promise.all([gitFacts, prRun, graphRun, countsRun, ...glRuns]);
|
|
347
|
+
|
|
348
|
+
let guardlink = 'missing';
|
|
349
|
+
if (hasGuardlink) {
|
|
350
|
+
// Errors are recorded in command order so the degraded lines read the same on every run.
|
|
351
|
+
const val = ({ label, r }) => {
|
|
352
|
+
if (r.ok) return r.json;
|
|
353
|
+
errors.push(r.error.replace(/^guardlink/, `guardlink ${label}`));
|
|
354
|
+
return null;
|
|
355
|
+
};
|
|
356
|
+
guardlink = { ci: val(gl[0]), next: val(gl[1]), tickets: val(gl[2]), unannotated: null };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
let inFlight = null;
|
|
360
|
+
if (pr.ok && Array.isArray(pr.json)) {
|
|
361
|
+
const files = new Set();
|
|
362
|
+
for (const p of pr.json) for (const f of p.files ?? []) files.add(f.path ?? f);
|
|
363
|
+
inFlight = { files: files.size, prs: pr.json.length, numbers: pr.json.map((p) => p.number).filter((n) => typeof n === 'number') };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return {
|
|
367
|
+
root,
|
|
368
|
+
repo: basename(root),
|
|
369
|
+
sha: facts.sha,
|
|
370
|
+
base: facts.base,
|
|
371
|
+
branch: facts.branch,
|
|
372
|
+
guardlink,
|
|
373
|
+
changedFiles: facts.changedFiles,
|
|
374
|
+
inFlight,
|
|
375
|
+
intent: readIntent(root),
|
|
376
|
+
// bravos on PATH: what its probe says (a graph for this commit or not); else codegraph-mcp on PATH means the MCP route.
|
|
377
|
+
codegraph: graphState ?? (whichOnPath('codegraph-mcp', env) ? 'unknown' : 'unavailable'),
|
|
378
|
+
sast: { summary: readSast(root, env), available: sastAvailable(env) },
|
|
379
|
+
licence: licenceStatus(env),
|
|
380
|
+
harness: env.CLAUDE_PROJECT_DIR ? 'claude' : 'other',
|
|
381
|
+
prePr: prePrRegistered(root),
|
|
382
|
+
shadow: userSkillsShadow(root, env),
|
|
383
|
+
autopilot: autopilotRegistered(root),
|
|
384
|
+
proving: readProving(root),
|
|
385
|
+
workspace,
|
|
386
|
+
workspaceCounts,
|
|
387
|
+
errors,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ---------------------------------------------------------------------------------------------
|
|
392
|
+
// Startup banner: shown to the human (systemMessage), never added to the model's context.
|
|
393
|
+
// ---------------------------------------------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
const BANNER_WIDTH = 80;
|
|
396
|
+
// A large shadowed wordmark, six rows, built letter by letter so every row has the same width.
|
|
397
|
+
const GLYPHS = {
|
|
398
|
+
B: ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔══██╗', '██████╔╝', '╚═════╝ '],
|
|
399
|
+
R: ['██████╗ ', '██╔══██╗', '██████╔╝', '██╔══██╗', '██║ ██║', '╚═╝ ╚═╝'],
|
|
400
|
+
A: [' █████╗ ', '██╔══██╗', '███████║', '██╔══██║', '██║ ██║', '╚═╝ ╚═╝'],
|
|
401
|
+
V: ['██╗ ██╗', '██║ ██║', '██║ ██║', '╚██╗ ██╔╝', ' ╚████╔╝ ', ' ╚═══╝ '],
|
|
402
|
+
O: [' ██████╗ ', '██╔═══██╗', '██║ ██║', '██║ ██║', '╚██████╔╝', ' ╚═════╝ '],
|
|
403
|
+
S: ['███████╗', '██╔════╝', '███████╗', '╚════██║', '███████║', '╚══════╝'],
|
|
404
|
+
};
|
|
405
|
+
export const LOGO = Array.from({ length: 6 }, (_, r) => [...'BRAVOS'].map((c) => GLYPHS[c][r]).join(''));
|
|
406
|
+
const LOGO_WIDTH = Math.max(...LOGO.map((l) => [...l].length));
|
|
407
|
+
// bravos's mascot: a hooded figure with two glowing eyes, eleven rows, left-right symmetric; ▓ is the hood, █ the eyes.
|
|
408
|
+
export const MASCOT = [
|
|
409
|
+
' ▄▄▓▓▓▓▓▓▄▄ ',
|
|
410
|
+
'▄▄▄▄▄ ▄▓▓▓▓▓▓▓▓▓▓▓▓▄ ▄▄▄▄▄',
|
|
411
|
+
' ▀▀▓▓▓▓▓▀▀▀▀▀▀▀▀▀▀▓▓▓▓▓▀▀ ',
|
|
412
|
+
' ▓▓▓▀ ▀▓▓▓ ',
|
|
413
|
+
' ▓▓▓ ▄██▄ ▄██▄ ▓▓▓ ',
|
|
414
|
+
' ▓▓▓ ████▄ ▄████ ▓▓▓ ',
|
|
415
|
+
' ▓▓▓ ▀████ ████▀ ▓▓▓ ',
|
|
416
|
+
' ▓▓▓ ▀▀█▌ ▐█▀▀ ▓▓▓ ',
|
|
417
|
+
' ▀▓▓▄ ▄▓▓▀ ',
|
|
418
|
+
' ▄▓▓▓▓▄▄▄▄▄▄▄▄▄▄▓▓▓▓▄ ',
|
|
419
|
+
' ▓▓▓▓▓▓▓▓ ▐ ▌ ▓▓▓▓▓▓▓▓ ',
|
|
420
|
+
];
|
|
421
|
+
const MASCOT_WIDTH = 26;
|
|
422
|
+
const LEFT_WIDTH = 36;
|
|
423
|
+
const INTRO = [
|
|
424
|
+
'bravos makes this agent a guardlink power user: it keeps the threat model',
|
|
425
|
+
'beside the code, proves exposures with authorized cxg runs on targets you own,',
|
|
426
|
+
'and gates pull requests on what they change. For developers and security alike.',
|
|
427
|
+
];
|
|
428
|
+
|
|
429
|
+
export const fit = (s, w) => (s.length <= w ? s : s.slice(0, Math.max(0, w - 1)) + '…');
|
|
430
|
+
|
|
431
|
+
// @comment -- "True when a harness settings file in the repo registers bravos's pre-PR gate; read-only, and unreadable files read as off."
|
|
432
|
+
export function prePrRegistered(root) {
|
|
433
|
+
for (const rel of ['.claude/settings.json', '.codex/hooks.json']) {
|
|
434
|
+
try {
|
|
435
|
+
const groups = JSON.parse(readFileSync(join(root, rel), 'utf8'))?.hooks?.PreToolUse;
|
|
436
|
+
if (Array.isArray(groups) && groups.some((g) => arr(g?.hooks).some((h) => typeof h?.command === 'string' && h.command.includes('.bravos/hooks/pre-pr.mjs')))) return true;
|
|
437
|
+
} catch {
|
|
438
|
+
/* not registered here */
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// @comment -- "True when a harness settings file in the repo registers bravos's autopilot Stop hook; read-only, unreadable files read as off."
|
|
445
|
+
export function autopilotRegistered(root) {
|
|
446
|
+
for (const rel of ['.claude/settings.json', '.codex/hooks.json']) {
|
|
447
|
+
try {
|
|
448
|
+
const groups = JSON.parse(readFileSync(join(root, rel), 'utf8'))?.hooks?.Stop;
|
|
449
|
+
if (Array.isArray(groups) && groups.some((g) => arr(g?.hooks).some((h) => typeof h?.command === 'string' && h.command.includes('.bravos/hooks/autopilot.mjs')))) return true;
|
|
450
|
+
} catch {
|
|
451
|
+
/* not registered here */
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// @comment -- "Detects a user-level bravos install (bravos init --global) whose version differs from this repo's: Claude Code prefers personal skills over project skills of the same name, so an older ~/.claude/skills copy silently replaces the repo's. Returns { user, repo } versions, or null when there is no conflict."
|
|
458
|
+
export function userSkillsShadow(root, env) {
|
|
459
|
+
const home = env?.HOME;
|
|
460
|
+
if (!home) return null;
|
|
461
|
+
let user = null;
|
|
462
|
+
try {
|
|
463
|
+
user = JSON.parse(readFileSync(join(home, '.bravos', 'skills.lock'), 'utf8')).bravos_version ?? null;
|
|
464
|
+
} catch {
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
if (join(home) === join(root)) return null;
|
|
468
|
+
const repo = readVersion(join(root, '.bravos', 'hooks'));
|
|
469
|
+
if (!user || !repo || user === repo) return null;
|
|
470
|
+
return { user, repo, userNewer: compareVersions(user, repo) > 0 };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Compares dotted versions numerically (0.12.1 > 0.12.0, 0.10.0 > 0.9.9); non-numeric parts compare as 0. */
|
|
474
|
+
export function compareVersions(a, b) {
|
|
475
|
+
const pa = String(a).split('.').map((x) => parseInt(x, 10) || 0);
|
|
476
|
+
const pb = String(b).split('.').map((x) => parseInt(x, 10) || 0);
|
|
477
|
+
for (let k = 0; k < Math.max(pa.length, pb.length); k++) {
|
|
478
|
+
const d = (pa[k] ?? 0) - (pb[k] ?? 0);
|
|
479
|
+
if (d !== 0) return d;
|
|
480
|
+
}
|
|
481
|
+
return 0;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// @comment -- "Reads the installed bravos version for the banner: the repo's .bravos/skills.lock beside the hooks dir, else a plugin manifest; null when none is readable."
|
|
485
|
+
export function readVersion(hooksDir) {
|
|
486
|
+
const tries = [
|
|
487
|
+
[join(dirname(hooksDir), 'skills.lock'), (j) => j.bravos_version],
|
|
488
|
+
[join(dirname(hooksDir), '.claude-plugin', 'plugin.json'), (j) => j.version],
|
|
489
|
+
[join(dirname(hooksDir), 'plugin.json'), (j) => j.version],
|
|
490
|
+
];
|
|
491
|
+
for (const [file, pick] of tries) {
|
|
492
|
+
try {
|
|
493
|
+
const v = pick(JSON.parse(readFileSync(file, 'utf8')));
|
|
494
|
+
if (typeof v === 'string' && v) return v;
|
|
495
|
+
} catch {
|
|
496
|
+
/* try the next */
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/** Open exposures grouped by threat, ranked by highest severity then count (the tui's order). */
|
|
503
|
+
export function topThreats(exposures, n = 3) {
|
|
504
|
+
const by = new Map();
|
|
505
|
+
for (const e of arr(exposures)) {
|
|
506
|
+
const id = e?.threat ?? assetThreat(e).threat;
|
|
507
|
+
const cur = by.get(id) ?? { threat: id, count: 0, severity: 'unset' };
|
|
508
|
+
cur.count += 1;
|
|
509
|
+
if (sevRank(e?.severity) < sevRank(cur.severity)) cur.severity = e.severity;
|
|
510
|
+
by.set(id, cur);
|
|
511
|
+
}
|
|
512
|
+
return [...by.values()]
|
|
513
|
+
.sort((a, b) => sevRank(a.severity) - sevRank(b.severity) || b.count - a.count || a.threat.localeCompare(b.threat))
|
|
514
|
+
.slice(0, n);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const SKILL_HINT = {
|
|
518
|
+
'bravos-recheck': 'recheck a fix',
|
|
519
|
+
'bravos-context': 'record intent',
|
|
520
|
+
'bravos-annotate': 'cover unannotated',
|
|
521
|
+
'bravos-templatize': 'write templates',
|
|
522
|
+
bravos: 'where to start',
|
|
523
|
+
'bravos-ship': 'check before a PR',
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
/** Three skills chosen from state, bravos-ship always last; `harness` picks /x (Claude Code), $x (Codex), /skill:x (omp) or the bare name. */
|
|
527
|
+
export function quickStart(inputs, harness) {
|
|
528
|
+
const g = inputs?.guardlink && typeof inputs.guardlink === 'object' ? inputs.guardlink : {};
|
|
529
|
+
const ci = g.ci;
|
|
530
|
+
const picks = [];
|
|
531
|
+
if (arr(ci?.confirmed).length > 0) picks.push('bravos-recheck');
|
|
532
|
+
if (inputs?.intent?.status === 'none') picks.push('bravos-context');
|
|
533
|
+
const cov = ci?.summary?.coverage;
|
|
534
|
+
if (cov && num(cov.source_files) > num(cov.annotated_files)) picks.push('bravos-annotate');
|
|
535
|
+
if (arr(g.next?.queue).some((q) => q?.state === 'untested')) picks.push('bravos-templatize');
|
|
536
|
+
const chosen = picks.slice(0, 2);
|
|
537
|
+
if (chosen.length < 2) chosen.unshift('bravos');
|
|
538
|
+
chosen.push('bravos-ship');
|
|
539
|
+
const sigil = SIGIL[harness] ?? '';
|
|
540
|
+
return chosen.slice(0, 3).map((name) => ({ name, command: sigil + name, hint: SKILL_HINT[name] }));
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const SIGIL = { claude: '/', codex: '$', omp: '/skill:', opencode: '/' };
|
|
544
|
+
|
|
545
|
+
// One tip a day, the same for the whole team that day; each fits on one 80-column line.
|
|
546
|
+
const TIPS = [
|
|
547
|
+
'say the effort you want: "annotate at high", or bravos effort: high in AGENTS.md.',
|
|
548
|
+
'only a reproduced run confirms a finding; reading the code never does.',
|
|
549
|
+
'run bravos doctor to see which of guardlink, cxg, codegraph-mcp, bravos-cli you have.',
|
|
550
|
+
'"unknown" in the briefing means a tool failed, never that the count is zero.',
|
|
551
|
+
'bravos-campaign clears big backlogs in rounds of subagents, with rests between.',
|
|
552
|
+
'bravos init --pre-pr-hook runs the ship gate on every gh pr create.',
|
|
553
|
+
'intended-by-design behaviour goes in .guardlink/intent.md, not in @accepts.',
|
|
554
|
+
'stale means the code under a claim changed; bravos-recheck clears it.',
|
|
555
|
+
'anything beyond loopback needs a reviewed scope.yaml and your typed authorization.',
|
|
556
|
+
'teammates see the same picture: the threat model lives in git, not in bravos.',
|
|
557
|
+
'bravos guide --markdown prints the whole guide; docs/GUIDE.md has a copy.',
|
|
558
|
+
'BRAVOS_BANNER=off hides this banner; the agent still gets the briefing.',
|
|
559
|
+
];
|
|
560
|
+
const dayNumber = () => Math.floor(Date.now() / 86400000);
|
|
561
|
+
|
|
562
|
+
function countsLine(g) {
|
|
563
|
+
const ci = g.ci;
|
|
564
|
+
if (!ci) return 'threat counts unknown (guardlink ci unavailable)';
|
|
565
|
+
const s = ci.summary ?? {};
|
|
566
|
+
const list = arr(ci.exposures);
|
|
567
|
+
const by = s.by_severity ?? Object.fromEntries(SEVERITIES.map((k) => [k, list.filter((e) => e?.severity === k).length]));
|
|
568
|
+
const total = typeof s.exposures === 'number' ? s.exposures : list.length;
|
|
569
|
+
const sev = SEVERITIES.filter((k) => num(by[k]) > 0).map((k) => `${num(by[k])} ${k}`);
|
|
570
|
+
let line = `${total} open${sev.length ? ` (${sev.join(', ')})` : ''} · ${arr(ci.confirmed).length} confirmed`;
|
|
571
|
+
if (s.coverage) line += ` · coverage ${num(s.coverage.percent)}% (${num(s.coverage.annotated_files)}/${num(s.coverage.source_files)})`;
|
|
572
|
+
return line;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function statusLine(i) {
|
|
576
|
+
const intent = i.intent?.status ?? 'unknown';
|
|
577
|
+
const graph = i.codegraph === 'unavailable' ? 'graph off' : i.codegraph === 'not-built' ? 'graph none' : i.codegraph === 'diverged' ? 'graph stale' : 'graph on';
|
|
578
|
+
const sast = i.sast?.summary ? `sast ${i.sast.summary.reachable} reachable` : i.sast?.available?.ok ? 'sast not run' : 'sast off';
|
|
579
|
+
const setup = i.root ? readSetup(i.root, i, i.harness) : null;
|
|
580
|
+
return `intent ${intent} · ${graph} · pre-PR ${i.prePr ? 'on' : 'off'} · auto ${i.autopilot ? 'on' : 'off'} · ${sast}${setup ? ` · setup ${setup.done}/${setup.total}` : ''}`;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Builds the startup banner for the human: logo, repo and counts, top threats (or FIX NOW) beside a
|
|
585
|
+
* state-driven quick start, and a short introduction while the product's intent is not recorded.
|
|
586
|
+
* Plain text, no ANSI; every line ≤ 80 columns; 35 lines at most with the introduction, 29 without.
|
|
587
|
+
*/
|
|
588
|
+
// @comment -- "Pure text builder for the user-facing startup banner; reads only the inputs collect() already gathered, never throws on partial inputs."
|
|
589
|
+
export function buildBanner(inputs, opts = {}) {
|
|
590
|
+
const i = inputs ?? {};
|
|
591
|
+
const harness = opts.harness ?? (i.harness === 'claude' ? 'claude' : 'other');
|
|
592
|
+
// The mascot on the left; the wordmark beside its face, the repo facts beside its shoulders.
|
|
593
|
+
const room = BANNER_WIDTH - MASCOT_WIDTH - 2;
|
|
594
|
+
const facts = [
|
|
595
|
+
[`bravos${opts.version ? ` ${opts.version}` : ''}`, i.repo].filter(Boolean).join(' · '),
|
|
596
|
+
[i.branch, i.sha ? `@ ${i.sha}` : ''].filter(Boolean).join(' '),
|
|
597
|
+
];
|
|
598
|
+
const header = ['', '', ...LOGO, '', ...facts];
|
|
599
|
+
const lines = MASCOT.map((row, r) => `${row} ${fit(header[r] ?? '', room)}`);
|
|
600
|
+
|
|
601
|
+
if (i.guardlink === 'missing') {
|
|
602
|
+
lines.push('', 'guardlink not found on PATH — run bravos doctor');
|
|
603
|
+
const out = lines.map((l) => fit(l.trimEnd(), BANNER_WIDTH));
|
|
604
|
+
return (opts.color ? colorize(out, i) : out).join('\n');
|
|
605
|
+
}
|
|
606
|
+
const g = i.guardlink ?? {};
|
|
607
|
+
lines.push('', countsLine(g), statusLine(i));
|
|
608
|
+
if (i.shadow) {
|
|
609
|
+
lines.push(i.shadow.userNewer
|
|
610
|
+
? `~ repo bravos ${i.shadow.repo} is behind your ${i.shadow.user} · run: bravos sync`
|
|
611
|
+
: `! your user-level bravos ${i.shadow.user} overrides repo ${i.shadow.repo} · run: bravos sync --global`);
|
|
612
|
+
}
|
|
613
|
+
lines.push('');
|
|
614
|
+
|
|
615
|
+
const confirmed = arr(g.ci?.confirmed).slice().sort((a, b) => sevRank(a.severity) - sevRank(b.severity));
|
|
616
|
+
let left;
|
|
617
|
+
if (confirmed.length > 0) {
|
|
618
|
+
left = [`FIX NOW — confirmed (${confirmed.length})`, ...confirmed.slice(0, 3).map((c) => {
|
|
619
|
+
const { threat } = assetThreat(c);
|
|
620
|
+
const file = basename(String(c?.location?.file ?? c?.file ?? '?'));
|
|
621
|
+
return `${threat} ${file}:${c?.location?.line ?? c?.line ?? '?'}`;
|
|
622
|
+
})];
|
|
623
|
+
} else if (!g.ci) {
|
|
624
|
+
left = ['Top threats', 'unknown'];
|
|
625
|
+
} else {
|
|
626
|
+
const top = topThreats(g.ci.exposures);
|
|
627
|
+
// The count is kept and a long threat id is shortened, so the column never loses the ×n.
|
|
628
|
+
const row = (t) => {
|
|
629
|
+
const head = `${t.severity.padEnd(9)}`;
|
|
630
|
+
const tail = ` ×${t.count}`;
|
|
631
|
+
return head + fit(t.threat, BANNER_WIDTH - head.length - tail.length) + tail;
|
|
632
|
+
};
|
|
633
|
+
left = ['Top threats', ...(top.length ? top.map(row) : ['none open'])];
|
|
634
|
+
}
|
|
635
|
+
// One column: the Next line below carries the step to take, so the old Quick start column is gone.
|
|
636
|
+
for (const row of left) lines.push(fit(row, BANNER_WIDTH));
|
|
637
|
+
|
|
638
|
+
// By role and by task: the skill each starts with, in the harness's own syntax.
|
|
639
|
+
const sk = (name) => (SIGIL[harness] ?? '') + name;
|
|
640
|
+
const roles = [['developer', 'bravos-annotate'], ['security', 'bravos-prove'], ['team lead', 'bravos-campaign'], ['new here', 'bravos']];
|
|
641
|
+
const tasks = [['what next?', 'bravos'], ['before a PR', 'bravos-ship'], ['after a fix', 'bravos-recheck'], ['find routes', 'bravos-codegraph']];
|
|
642
|
+
lines.push('', 'If you are'.padEnd(LEFT_WIDTH) + 'To');
|
|
643
|
+
for (let r = 0; r < roles.length; r++) {
|
|
644
|
+
lines.push(fit(`${roles[r][0].padEnd(11)}${sk(roles[r][1])}`, LEFT_WIDTH - 2).padEnd(LEFT_WIDTH) + `${tasks[r][0].padEnd(13)}${sk(tasks[r][1])}`);
|
|
645
|
+
}
|
|
646
|
+
const setup = i.root ? readSetup(i.root, i, harness) : null;
|
|
647
|
+
// The next step with its command; the pointer to bravos next is dropped when the command is long.
|
|
648
|
+
const nextFull = setup?.next ? `Next: ${setup.next.label} → ${setup.next.cta} · more: bravos next` : null;
|
|
649
|
+
const nextLine = nextFull && nextFull.length > BANNER_WIDTH ? `Next: ${setup.next.label} → ${setup.next.cta}` : nextFull;
|
|
650
|
+
lines.push('', fit(nextLine ?? `Tip: ${TIPS[(opts.day ?? dayNumber()) % TIPS.length]}`, BANNER_WIDTH));
|
|
651
|
+
lines.push('Guide (free, no tokens): bravos guide · bravos guide roles · bravos guide tasks');
|
|
652
|
+
|
|
653
|
+
if (i.intent?.status === 'none') {
|
|
654
|
+
lines.push('', ...INTRO, `Start with the Next step above, or ask "help me get started" (${SIGIL[harness] ? `${SIGIL[harness]}bravos` : 'the bravos skill'}).`, 'Hide this banner with BRAVOS_BANNER=off.');
|
|
655
|
+
}
|
|
656
|
+
const out = lines.map((l) => fit(l.trimEnd(), BANNER_WIDTH)).slice(0, i.intent?.status === 'none' ? 35 : 29);
|
|
657
|
+
return (opts.color ? colorize(out, i) : out).join('\n');
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// @comment -- "A three-line, uncoloured summary for harnesses that show a hook's message as a warning or notice rather than in the transcript (Codex)."
|
|
661
|
+
export function buildSummary(inputs, opts = {}) {
|
|
662
|
+
const i = inputs ?? {};
|
|
663
|
+
const head = `bravos${opts.version ? ` ${opts.version}` : ''} · ${[i.repo, i.branch].filter(Boolean).join(' · ')}`;
|
|
664
|
+
const guide = 'guide, free and offline: bravos guide (roles, tasks, every skill)';
|
|
665
|
+
if (i.guardlink === 'missing') return `${head}\nguardlink not found on PATH — run bravos doctor\n${guide}`;
|
|
666
|
+
const next = quickStart(i, opts.harness).map((q) => q.command).join(' · ');
|
|
667
|
+
const setup = i.root ? readSetup(i.root, i, opts.harness) : null;
|
|
668
|
+
const third = setup ? `setup ${setup.done}/${setup.total}${setup.next ? ` · next: ${setup.next.label} · bravos next` : ''} · bravos guide` : guide;
|
|
669
|
+
return `${head} · ${countsLine(i.guardlink ?? {})}\nintent ${i.intent?.status ?? 'unknown'} · skills: ${next}\n${third}`;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Brand palette. Colour is truecolor ANSI, added after layout so widths are measured on plain text.
|
|
673
|
+
export const PALETTE = {
|
|
674
|
+
green: '#33d49d', navy: '#1f3943', blue: '#0360a2', light: '#f0f0f0',
|
|
675
|
+
steel: '#55899e', red: '#ea1d1d', slate: '#3b6779', black: '#000000', white: '#ffffff',
|
|
676
|
+
};
|
|
677
|
+
const fg = (hex) => `\x1b[38;2;${parseInt(hex.slice(1, 3), 16)};${parseInt(hex.slice(3, 5), 16)};${parseInt(hex.slice(5, 7), 16)}m`;
|
|
678
|
+
const RESET = '\x1b[0m';
|
|
679
|
+
export const paint = (text, hex, bold = false) => `${bold ? '\x1b[1m' : ''}${fg(hex)}${text}${RESET}`;
|
|
680
|
+
|
|
681
|
+
// @comment -- "Colours banner lines with the brand palette: the mascot's hood in slate and steel with green eyes, the wordmark in green with a blue shadow, severities by weight (critical and high in red), skills in green, and supporting text in steel; pure string transform."
|
|
682
|
+
export function colorize(lines, inputs = {}) {
|
|
683
|
+
const P = PALETTE;
|
|
684
|
+
// One escape per run of same-coloured characters, so the banner stays far inside the 10,000-character cap.
|
|
685
|
+
const runs = (chars, colourOf) => {
|
|
686
|
+
let out = '';
|
|
687
|
+
let cur = null;
|
|
688
|
+
let buf = '';
|
|
689
|
+
const flush = () => {
|
|
690
|
+
if (buf) out += cur ? paint(buf, cur) : buf;
|
|
691
|
+
buf = '';
|
|
692
|
+
};
|
|
693
|
+
for (const c of chars) {
|
|
694
|
+
const k = c === ' ' ? cur : colourOf(c);
|
|
695
|
+
if (k !== cur) {
|
|
696
|
+
flush();
|
|
697
|
+
cur = k;
|
|
698
|
+
}
|
|
699
|
+
buf += c;
|
|
700
|
+
}
|
|
701
|
+
flush();
|
|
702
|
+
return out;
|
|
703
|
+
};
|
|
704
|
+
const mascotColour = (c) => (c === '▓' ? P.slate : c === '▄' || c === '▀' ? P.steel : '█▐▌'.includes(c) ? P.green : null);
|
|
705
|
+
const wordColour = (c) => (c === '█' ? P.green : '╗╔╝╚═║'.includes(c) ? P.blue : null);
|
|
706
|
+
return lines.map((line, n) => {
|
|
707
|
+
if (n < MASCOT.length) {
|
|
708
|
+
const chars = [...line];
|
|
709
|
+
const left = runs(chars.slice(0, MASCOT_WIDTH), mascotColour);
|
|
710
|
+
const rest = chars.slice(MASCOT_WIDTH).join('');
|
|
711
|
+
if (n >= 2 && n < 2 + LOGO.length) return left + runs([...rest], wordColour);
|
|
712
|
+
if (n === MASCOT.length - 2) return left + rest.replace(/bravos( [\d.]+)?/, (m) => paint(m, P.green, true)).replace(/ · (.+)$/, (_m, r) => ` · ${paint(r, P.light, true)}`);
|
|
713
|
+
if (n === MASCOT.length - 1) return left + rest.replace(/\S.*$/, (m) => paint(m, P.steel));
|
|
714
|
+
return left + rest;
|
|
715
|
+
}
|
|
716
|
+
if (/^If you are/.test(line)) return line.replace(/^If you are/, paint('If you are', P.light, true)).replace(/To$/, paint('To', P.light, true));
|
|
717
|
+
if (/^(developer|security|team lead|new here) /.test(line)) {
|
|
718
|
+
return line.split(/((?:\/skill:|\/|\$)?bravos(?:-[a-z]+)?(?= |$))/).map((part, k) => (k % 2 ? paint(part, P.green) : part)).join('');
|
|
719
|
+
}
|
|
720
|
+
if (/^! your user-level bravos/.test(line)) return paint(line, P.red, true);
|
|
721
|
+
if (/^~ repo bravos /.test(line)) return paint(line, P.steel);
|
|
722
|
+
if (/^Tip: /.test(line)) return paint('Tip: ', P.green, true) + paint(line.slice(5), P.light);
|
|
723
|
+
if (/^Next: /.test(line)) {
|
|
724
|
+
const [what, rest = ''] = line.slice(6).split(' → ');
|
|
725
|
+
const [cta, tailText] = rest.split(' · more: ');
|
|
726
|
+
return paint('Next: ', P.green, true) + paint(what, P.light) + (cta ? ` → ${paint(cta, P.green)}` : '') + (tailText !== undefined ? ` · more: ${paint(tailText, P.green)}` : '');
|
|
727
|
+
}
|
|
728
|
+
if (/^Guide \(free/.test(line)) return line.replace(/^Guide \(free, no tokens\):/, (m) => paint(m, P.steel)).replace(/bravos guide[a-z ]*/g, (m) => paint(m, P.green));
|
|
729
|
+
if (/^(Top threats|FIX NOW)/.test(line)) {
|
|
730
|
+
return line
|
|
731
|
+
.replace(/^Top threats/, paint('Top threats', P.light, true))
|
|
732
|
+
.replace(/^FIX NOW — confirmed \(\d+\)/, (m) => paint(m, P.red, true))
|
|
733
|
+
.replace(/Quick start( \(skills\))?/, (m) => paint(m, P.light, true));
|
|
734
|
+
}
|
|
735
|
+
let l = line
|
|
736
|
+
.replace(/^(critical|high)(?= )/, (m) => paint(m, P.red, true))
|
|
737
|
+
.replace(/^medium(?= )/, (m) => paint(m, P.light))
|
|
738
|
+
.replace(/^low(?= )/, (m) => paint(m, P.steel))
|
|
739
|
+
.replace(/(\d+) (critical|high)\b/g, (m) => paint(m, P.red))
|
|
740
|
+
.replace(/\b([1-9]\d*) confirmed\b/, (m) => paint(m, P.red, true))
|
|
741
|
+
.replace(/\b0 confirmed\b/, (m) => paint(m, P.green))
|
|
742
|
+
.replace(/coverage \d+%( \(\d+\/\d+\))?/, (m) => paint(m, P.green))
|
|
743
|
+
.replace(/((?:\/skill:|\/|\$)bravos(?:-[a-z]+)?)(\s+)([^\x1b]+)$/, (_m, cmd, sp, hint) => `${paint(cmd, P.green)}${sp}${paint(hint, P.steel)}`)
|
|
744
|
+
.replace(/(?<![\w/$:-])((?:\/skill:|\/|\$)bravos(?:-[a-z]+)?)(?![\w-])(?![^\x1b]*\x1b\[0m)/g, (m) => paint(m, P.green));
|
|
745
|
+
if (/^(Start with|Hide this|bravos makes|beside the|and gates)/.test(line)) {
|
|
746
|
+
l = line.split(/((?:\/skill:|\/|\$)bravos-[a-z]+)/).map((part, k) => (k % 2 ? paint(part, P.green) : part ? paint(part, P.steel) : '')).join('');
|
|
747
|
+
}
|
|
748
|
+
return l;
|
|
749
|
+
});
|
|
750
|
+
}
|