@bugb-technologies/bravos 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/LICENSE +70 -0
  2. package/README.md +225 -2
  3. package/dist/account.js +140 -0
  4. package/dist/artifacts.js +450 -0
  5. package/dist/ci.js +52 -0
  6. package/dist/cli.js +963 -0
  7. package/dist/codegraph.js +268 -0
  8. package/dist/cursor.js +70 -0
  9. package/dist/detect.js +26 -0
  10. package/dist/doctor.js +99 -0
  11. package/dist/global.js +95 -0
  12. package/dist/graphbin.js +136 -0
  13. package/dist/guardlink.js +123 -0
  14. package/dist/guide.js +255 -0
  15. package/dist/help.js +334 -0
  16. package/dist/hooks.js +167 -0
  17. package/dist/install.js +209 -0
  18. package/dist/licence.js +41 -0
  19. package/dist/lock.js +50 -0
  20. package/dist/mcp.js +139 -0
  21. package/dist/migrate.js +275 -0
  22. package/dist/omp.js +46 -0
  23. package/dist/opencode.js +118 -0
  24. package/dist/paths.js +33 -0
  25. package/dist/plugin.js +101 -0
  26. package/dist/report/check.js +252 -0
  27. package/dist/report/data.js +401 -0
  28. package/dist/report/export.js +105 -0
  29. package/dist/report/markdown.js +310 -0
  30. package/dist/report/meta.js +48 -0
  31. package/dist/report/pdf.js +96 -0
  32. package/dist/review-server.js +7 -0
  33. package/dist/review.js +274 -0
  34. package/dist/setup.js +121 -0
  35. package/dist/skills.js +81 -0
  36. package/dist/types.js +9 -0
  37. package/dist/uninstall.js +192 -0
  38. package/dist/versions.js +8 -0
  39. package/dist/watch-frame.js +593 -0
  40. package/dist/watch.js +873 -0
  41. package/dist/workspace.js +316 -0
  42. package/docs/GUIDE.md +323 -0
  43. package/hooks/autopilot.mjs +45 -0
  44. package/hooks/lib/autopilot.mjs +191 -0
  45. package/hooks/lib/briefing.mjs +750 -0
  46. package/hooks/lib/licence.mjs +452 -0
  47. package/hooks/lib/pre-pr.mjs +209 -0
  48. package/hooks/lib/sast.mjs +207 -0
  49. package/hooks/lib/setup.mjs +107 -0
  50. package/hooks/lib/workspace.mjs +78 -0
  51. package/hooks/pre-pr.mjs +54 -0
  52. package/hooks/session-start.mjs +82 -0
  53. package/package.json +49 -4
  54. package/skills/bravos/SKILL.md +63 -0
  55. package/skills/bravos-annotate/SKILL.md +56 -0
  56. package/skills/bravos-annotate/scripts/lib/pick.mjs +127 -0
  57. package/skills/bravos-annotate/scripts/slice.mjs +141 -0
  58. package/skills/bravos-campaign/SKILL.md +49 -0
  59. package/skills/bravos-cicd/SKILL.md +48 -0
  60. package/skills/bravos-codegraph/SKILL.md +57 -0
  61. package/skills/bravos-context/SKILL.md +51 -0
  62. package/skills/bravos-context/intent.example.md +48 -0
  63. package/skills/bravos-env/SKILL.md +48 -0
  64. package/skills/bravos-env/scope.example.yaml +50 -0
  65. package/skills/bravos-prove/SKILL.md +52 -0
  66. package/skills/bravos-recheck/SKILL.md +47 -0
  67. package/skills/bravos-report/SKILL.md +53 -0
  68. package/skills/bravos-report/assessment.md +54 -0
  69. package/skills/bravos-report/brief.md +23 -0
  70. package/skills/bravos-report/finding.md +74 -0
  71. package/skills/bravos-report/retest.md +29 -0
  72. package/skills/bravos-report/style.md +44 -0
  73. package/skills/bravos-sast/SKILL.md +48 -0
  74. package/skills/bravos-schedule/SKILL.md +44 -0
  75. package/skills/bravos-schedule/loop.md +16 -0
  76. package/skills/bravos-ship/SKILL.md +58 -0
  77. package/skills/bravos-templatize/SKILL.md +48 -0
  78. package/skills/bravos-templatize/operator-guidance.md +20 -0
  79. package/templates/omp/bravos.js +62 -0
  80. package/templates/opencode/bravos.js +33 -0
  81. package/templates/report/report.html +88 -0
  82. package/templates/review/review.html +327 -0
  83. package/templates/workflows/bravos-nightly-claude.yml +173 -0
  84. package/templates/workflows/bravos-nightly-codex.yml +172 -0
  85. package/templates/workflows/bravos-pr-gate.yml +142 -0
package/dist/review.js ADDED
@@ -0,0 +1,274 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
3
+ import { createServer } from 'node:http';
4
+ import { dirname, join } from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { hooksDir, templatesDir } from './paths.js';
7
+ import { BravosError } from './types.js';
8
+ // @comment -- "bravos review: the drafts an agent wrote (.guardlink/<kind>-draft.json) rendered as a bravos-branded page people answer in, a loopback-only server that saves each person's answers to .guardlink/<kind>-answers/<name>.json, and a plain-text summary of agreement and disagreement for the agent. Draft text comes from repo files and is untrusted: it travels as JSON and the page renders it with textContent."
9
+ export const REVIEW_SCHEMA = 'bravos.review/v1';
10
+ export const ANSWERS_SCHEMA = 'bravos.review-answers/v1';
11
+ const MAX_BODY = 256 * 1024;
12
+ const IDLE_MS = 30 * 60 * 1000;
13
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
14
+ const str = (v, max = 4000) => typeof v === 'string' && v.length <= max;
15
+ // @comment -- "Kind names become file names: lowercase letters and hyphens only."
16
+ export function checkKind(kind) {
17
+ if (!/^[a-z][a-z-]{0,30}$/.test(kind))
18
+ throw new BravosError(`review kind must be lowercase letters and hyphens, got '${kind}'`);
19
+ return kind;
20
+ }
21
+ export const draftPath = (kind) => `.guardlink/${checkKind(kind)}-draft.json`;
22
+ export const answersDir = (kind) => `.guardlink/${checkKind(kind)}-answers`;
23
+ export const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
24
+ // @comment -- "Validates a draft's shape (schema, kind, unique ids, known question types, bounded strings) and returns it; anything else is refused with the reason, so a malformed draft never reaches the page."
25
+ export function validateDraft(value, kind) {
26
+ const bad = (why) => new BravosError(`${draftPath(kind)}: ${why}`);
27
+ if (!isObject(value))
28
+ throw bad('is not a JSON object');
29
+ if (value.schema !== REVIEW_SCHEMA)
30
+ throw bad(`schema must be ${REVIEW_SCHEMA}`);
31
+ if (value.kind !== kind)
32
+ throw bad(`kind must be '${kind}'`);
33
+ if (!Array.isArray(value.questions) || value.questions.length === 0 || value.questions.length > 300)
34
+ throw bad('needs 1 to 300 questions');
35
+ if (value.perspective !== undefined && value.perspective !== 'researcher' && value.perspective !== 'owner')
36
+ throw bad('perspective must be researcher or owner');
37
+ const seen = new Set();
38
+ for (const [n, q] of value.questions.entries()) {
39
+ if (!isObject(q))
40
+ throw bad(`question ${n + 1} is not an object`);
41
+ if (!str(q.id, 80) || !/^[A-Za-z0-9_.-]+$/.test(q.id))
42
+ throw bad(`question ${n + 1} needs an id of letters, digits, . _ -`);
43
+ if (seen.has(q.id))
44
+ throw bad(`duplicate id ${q.id}`);
45
+ seen.add(q.id);
46
+ if (!['choice', 'multi', 'item', 'text'].includes(q.type))
47
+ throw bad(`${q.id}: type must be choice, multi, item or text`);
48
+ for (const f of ['section', 'title', 'why', 'text', 'draft', 'reason'])
49
+ if (q[f] !== undefined && !str(q[f]))
50
+ throw bad(`${q.id}: ${f} must be a string`);
51
+ if ((q.type === 'choice' || q.type === 'multi') && (!Array.isArray(q.options) || q.options.length < 2 || !q.options.every((o) => isObject(o) && str(o.id, 40) && str(o.label, 400)))) {
52
+ throw bad(`${q.id}: a ${q.type} needs two or more options with id and label`);
53
+ }
54
+ const optionIds = new Set(Array.isArray(q.options) ? q.options.map((o) => o.id) : []);
55
+ if (q.suggested !== undefined) {
56
+ const ok = q.type === 'choice' ? typeof q.suggested === 'string' && optionIds.has(q.suggested)
57
+ : q.type === 'multi' ? Array.isArray(q.suggested) && q.suggested.every((v) => typeof v === 'string' && optionIds.has(v))
58
+ : q.type === 'item' ? q.suggested === 'agree' || q.suggested === 'no'
59
+ : str(q.suggested);
60
+ if (!ok)
61
+ throw bad(`${q.id}: suggested does not fit a ${q.type} question`);
62
+ }
63
+ if (q.confidence !== undefined && q.confidence !== 'high' && q.confidence !== 'low')
64
+ throw bad(`${q.id}: confidence must be high or low`);
65
+ for (const f of ['mustTouch', 'folded'])
66
+ if (q[f] !== undefined && typeof q[f] !== 'boolean')
67
+ throw bad(`${q.id}: ${f} must be true or false`);
68
+ if (q.type === 'item' && !str(q.text))
69
+ throw bad(`${q.id}: an item needs its text`);
70
+ if (q.sources !== undefined && !(Array.isArray(q.sources) && q.sources.every((s) => str(s, 400))))
71
+ throw bad(`${q.id}: sources must be strings`);
72
+ }
73
+ return value;
74
+ }
75
+ // @comment -- "Validates one person's answers against the draft: the answers schema, a non-empty name, and only ids the draft has; returns the cleaned record."
76
+ export function validateAnswers(value, draft) {
77
+ if (!isObject(value) || value.schema !== ANSWERS_SCHEMA)
78
+ throw new BravosError(`answers must use schema ${ANSWERS_SCHEMA}`);
79
+ if (!str(value.by, 80) || !slug(value.by))
80
+ throw new BravosError('answers need the name of the person giving them');
81
+ if (!isObject(value.answers))
82
+ throw new BravosError('answers must be an object keyed by question id');
83
+ const ids = new Set(draft.questions.map((q) => q.id));
84
+ const answers = {};
85
+ for (const [id, a] of Object.entries(value.answers)) {
86
+ if (!ids.has(id) || !isObject(a))
87
+ continue;
88
+ if (!str(a.verdict, 20))
89
+ continue;
90
+ const v = a.value;
91
+ const okValue = str(v) || (Array.isArray(v) && v.length <= 20 && v.every((x) => str(x, 40)));
92
+ answers[id] = {
93
+ verdict: a.verdict,
94
+ ...(okValue ? { value: v } : {}),
95
+ ...(str(a.choice, 40) ? { choice: a.choice } : {}),
96
+ ...(str(a.text) ? { text: a.text } : {}),
97
+ ...(str(a.comment) ? { comment: a.comment } : {}),
98
+ };
99
+ }
100
+ return { schema: ANSWERS_SCHEMA, kind: draft.kind, by: value.by.trim(), date: str(value.date, 40) ? value.date : new Date().toISOString().slice(0, 10), answers };
101
+ }
102
+ // A must-touch decision counts only when a person acted on it; accepting untouched does not.
103
+ const DECIDED = new Set(['chosen', 'edited', 'no', 'choice', 'change', 'agree']);
104
+ // @comment -- "Lists the must-touch decisions an answers record leaves without a deliberate answer; the server refuses such a record, so Confirm cannot rubber-stamp them."
105
+ export function missingDecisions(draft, a) {
106
+ return draft.questions.filter((q) => q.mustTouch && !DECIDED.has(a.answers[q.id]?.verdict ?? '')).map((q) => q.id);
107
+ }
108
+ export function readDraft(root, kind) {
109
+ const abs = join(root, draftPath(kind));
110
+ if (!existsSync(abs))
111
+ throw new BravosError(`${draftPath(kind)} not found: the agent writes it first (bravos-context step 3)`);
112
+ let parsed;
113
+ try {
114
+ parsed = JSON.parse(readFileSync(abs, 'utf8'));
115
+ }
116
+ catch {
117
+ throw new BravosError(`${draftPath(kind)} is not valid JSON`);
118
+ }
119
+ return validateDraft(parsed, kind);
120
+ }
121
+ // @comment -- "Every saved answers file for a kind, each validated against the draft; unreadable or malformed files are skipped and named."
122
+ export function readAllAnswers(root, draft) {
123
+ const dir = join(root, answersDir(draft.kind));
124
+ const files = [];
125
+ const skipped = [];
126
+ if (!existsSync(dir))
127
+ return { files, skipped };
128
+ for (const f of readdirSync(dir).filter((n) => n.endsWith('.json')).sort()) {
129
+ try {
130
+ files.push(validateAnswers(JSON.parse(readFileSync(join(dir, f), 'utf8')), draft));
131
+ }
132
+ catch {
133
+ skipped.push(f);
134
+ }
135
+ }
136
+ return { files, skipped };
137
+ }
138
+ /** The brand art for the page header, from the hook's own constants (one source for banner and page). */
139
+ async function brandArt() {
140
+ const lib = await import(pathToFileURL(join(hooksDir(), 'lib', 'briefing.mjs')).href);
141
+ return { mascot: lib.MASCOT, logo: lib.LOGO };
142
+ }
143
+ // @comment -- "Renders the self-contained review page: the template with the draft, mode and brand art embedded as JSON in a non-executing script tag, every '<' escaped so draft text cannot close the tag."
144
+ export async function renderReview(draft, mode) {
145
+ const template = readFileSync(join(templatesDir(), 'review', 'review.html'), 'utf8');
146
+ const art = await brandArt();
147
+ const heading = draft.kind !== 'intent' ? `bravos asks: ${draft.kind}` : draft.perspective === 'owner' ? 'What does this product intend?' : `How should bravos judge ${draft.repo ?? 'this project'}?`;
148
+ const data = JSON.stringify({ draft, mode, heading, ...art }).replace(/</g, '\\u003c').replace(/[\u2028\u2029]/g, (c) => `\\u${c.charCodeAt(0).toString(16)}`);
149
+ const page = template.replace('__BRAVOS_DATA__', () => data);
150
+ // On claude.ai the runtime bridge supplies the shared database, so the page drops its own CSP there.
151
+ return mode.kind === 'artifact' ? page.replace(/<meta http-equiv="Content-Security-Policy"[^>]*>\n?/, '') : page;
152
+ }
153
+ /** What an answer amounts to, for comparing people: the suggestion when accepted, otherwise the value given. */
154
+ function effective(q, a) {
155
+ if (a.verdict === 'no')
156
+ return 'no';
157
+ const legacy = a.verdict === 'choice' ? a.choice : a.verdict === 'change' ? a.text : undefined;
158
+ const asIs = a.verdict === 'accepted' || a.verdict === 'agree' ? (q.suggested ?? 'agree') : undefined;
159
+ const v = a.value ?? legacy ?? asIs ?? a.verdict;
160
+ return Array.isArray(v) ? [...v].sort().join(', ') : String(v);
161
+ }
162
+ // @comment -- "Plain-text summary for the agent, most important first: disagreements, edits and rejections, the must-touch decisions with who made them, unanswered questions, then one line per group of suggestions accepted as is, with names; no model involved."
163
+ export function summarize(draft, files, skipped = []) {
164
+ const out = [];
165
+ out.push(`bravos review ${draft.kind}${draft.perspective ? ` (${draft.perspective})` : ''}: ${files.length} ${files.length === 1 ? 'person' : 'people'} answered${files.length ? ` (${files.map((f) => `${f.by}, ${f.date}`).join('; ')})` : ''}`);
166
+ if (skipped.length)
167
+ out.push(`skipped unreadable answer files: ${skipped.join(', ')}`);
168
+ const groups = { DISAGREE: [], EDITED: [], DECIDED: [], 'NO ANSWER': [] };
169
+ const accepted = {};
170
+ let acceptedCount = 0;
171
+ for (const q of draft.questions) {
172
+ const rows = files.map((f) => ({ by: f.by, a: f.answers[q.id] })).filter((r) => !!r.a);
173
+ const values = new Set(rows.map((r) => effective(q, r.a)));
174
+ const head = `${q.id}${q.title ? ` — ${q.title}` : ''}`;
175
+ const sugg = q.suggested !== undefined ? (Array.isArray(q.suggested) ? q.suggested.join(', ') : q.suggested) : (q.text ?? q.draft ?? '—');
176
+ const block = [head, ` bravos suggested: ${sugg}`, ...rows.map((r) => ` ${r.by}: ${r.a.verdict} → ${effective(q, r.a)}${r.a.comment ? ` (${r.a.comment})` : ''}`)];
177
+ if (rows.length === 0)
178
+ groups['NO ANSWER'].push(block);
179
+ else if (values.size > 1)
180
+ groups.DISAGREE.push(block);
181
+ else if (rows.some((r) => ['edited', 'no', 'change'].includes(r.a.verdict)))
182
+ groups.EDITED.push(block);
183
+ else if (q.mustTouch || rows.some((r) => r.a.verdict === 'chosen' || r.a.verdict === 'choice'))
184
+ groups.DECIDED.push(block);
185
+ else {
186
+ acceptedCount++;
187
+ (accepted[rows.map((r) => r.by).join(', ')] ??= []).push(q.id);
188
+ }
189
+ }
190
+ for (const k of ['DISAGREE', 'EDITED', 'DECIDED', 'NO ANSWER']) {
191
+ for (const b of groups[k])
192
+ out.push('', `[${k}] ${b[0]}`, ...b.slice(1));
193
+ }
194
+ for (const [who, ids] of Object.entries(accepted))
195
+ out.push('', `[ACCEPTED as suggested by ${who}] ${ids.join(', ')}`);
196
+ out.push('', `totals: ${groups.DISAGREE.length} disagreements, ${groups.EDITED.length} edited or rejected, ${groups.DECIDED.length} decisions made, ${acceptedCount} accepted as suggested, ${groups['NO ANSWER'].length} unanswered of ${draft.questions.length}`);
197
+ return out.join('\n');
198
+ }
199
+ // @comment -- "The loopback review server: GET / (the page), GET /answers (everyone's answers), POST /answers (one person's answers, validated, written as .guardlink/<kind>-answers/<slug>.json), POST /done (stop). Every request needs the token; the Host must be the loopback address it listens on; bodies over 256 KB are refused; it stops after 30 minutes idle."
200
+ export function startReviewServer(root, draft, page, token, port = 0) {
201
+ let idle;
202
+ const server = createServer((req, res) => {
203
+ clearTimeout(idle);
204
+ idle = setTimeout(() => server.close(), IDLE_MS);
205
+ const send = (code, body, type = 'text/plain; charset=utf-8') => {
206
+ res.writeHead(code, { 'content-type': type, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' });
207
+ res.end(body);
208
+ };
209
+ const addr = server.address();
210
+ const p = typeof addr === 'object' && addr ? addr.port : 0;
211
+ if (req.headers.host !== `127.0.0.1:${p}` && req.headers.host !== `localhost:${p}`)
212
+ return send(403, 'wrong host');
213
+ const url = new URL(req.url ?? '/', `http://127.0.0.1:${p}`);
214
+ if (url.searchParams.get('t') !== token)
215
+ return send(403, 'wrong token');
216
+ if (req.method === 'GET' && url.pathname === '/')
217
+ return send(200, page, 'text/html; charset=utf-8');
218
+ if (req.method === 'GET' && url.pathname === '/answers')
219
+ return send(200, JSON.stringify(readAllAnswers(root, draft).files), 'application/json');
220
+ if (req.method === 'POST' && (url.pathname === '/answers' || url.pathname === '/done')) {
221
+ let size = 0;
222
+ const chunks = [];
223
+ req.on('data', (c) => {
224
+ size += c.length;
225
+ if (size > MAX_BODY) {
226
+ send(413, 'too large');
227
+ req.destroy();
228
+ }
229
+ else
230
+ chunks.push(c);
231
+ });
232
+ req.on('end', () => {
233
+ if (size > MAX_BODY)
234
+ return;
235
+ if (url.pathname === '/done') {
236
+ send(200, 'ok');
237
+ setTimeout(() => server.close(), 50);
238
+ return;
239
+ }
240
+ try {
241
+ const a = validateAnswers(JSON.parse(Buffer.concat(chunks).toString('utf8')), draft);
242
+ const missing = missingDecisions(draft, a);
243
+ if (missing.length)
244
+ return send(400, `these decisions still need your call: ${missing.join(', ')}`);
245
+ const rel = `${answersDir(draft.kind)}/${slug(a.by)}.json`;
246
+ mkdirSync(dirname(join(root, rel)), { recursive: true });
247
+ writeFileSync(join(root, rel), JSON.stringify(a, null, 2) + '\n');
248
+ send(200, JSON.stringify({ saved: rel }), 'application/json');
249
+ }
250
+ catch (e) {
251
+ send(400, e instanceof Error ? e.message : 'bad answers');
252
+ }
253
+ });
254
+ return;
255
+ }
256
+ send(404, 'not found');
257
+ });
258
+ return new Promise((resolve, reject) => {
259
+ server.once('error', reject);
260
+ server.listen(port, '127.0.0.1', () => {
261
+ idle = setTimeout(() => server.close(), IDLE_MS);
262
+ const addr = server.address();
263
+ resolve({ server, port: typeof addr === 'object' && addr ? addr.port : port });
264
+ });
265
+ });
266
+ }
267
+ export const newToken = () => randomBytes(16).toString('hex');
268
+ // @comment -- "Writes the rendered page for file mode (no server) to .bravos/review/<kind>.html and returns its path."
269
+ export function writePage(root, kind, page) {
270
+ const rel = `.bravos/review/${checkKind(kind)}.html`;
271
+ mkdirSync(dirname(join(root, rel)), { recursive: true });
272
+ writeFileSync(join(root, rel), page);
273
+ return rel;
274
+ }
package/dist/setup.js ADDED
@@ -0,0 +1,121 @@
1
+ // @comment -- "bravos setup: puts the binaries bravos drives onto this machine. The analyzer and the code graph are licensed bundles fetched through the account portal (artifacts.ts); guardlink and cxg are free CLIs installed with their own package managers. --check reports without changing anything, --dry-run says what would run, and nothing is fetched or installed without a licence for it. Also the SAST runner: `bravos sast run` invokes the staged analyzer with the argv the Python CLI used and writes the run folder the briefing and `bravos sast` read."
2
+ import { spawnSync } from 'node:child_process';
3
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { bundleEnvironment, hostTarget, install, missingPaths, staged, VERSIONS } from './artifacts.js';
6
+ import { graphBin } from './codegraph.js';
7
+ import { whichBin } from './guardlink.js';
8
+ import { findAnalyzer } from './graphbin.js';
9
+ import { licenceLib, requireFeature } from './licence.js';
10
+ import { BravosError } from './types.js';
11
+ import { CXG_VERSION, GUARDLINK_VERSION } from './versions.js';
12
+ // @comment -- "What is here and what setup would do, read-only: for each piece its path when present, the pinned version, whether the licence covers it, and the one action."
13
+ export async function plan(env) {
14
+ const L = await licenceLib();
15
+ const v = L.status(env);
16
+ const sast = L.entitled(v, 'sast');
17
+ const graph = L.entitled(v, 'codegraph');
18
+ const t = hostTarget();
19
+ const items = [
20
+ { id: 'analyzer', present: staged(env, 'analyzer') ?? findAnalyzer(env), wanted: VERSIONS.analyzer, licensed: sast, action: !t ? 'no build for this platform' : sast ? 'fetch, verify and stage the analyzer bundle' : 'on the Team plan: bravos account activate' },
21
+ { id: 'codegraph', present: staged(env, 'codegraph') ?? graphBin('codegraph-build', env), wanted: VERSIONS.codegraph, licensed: graph, action: !t ? 'no build for this platform' : graph ? 'fetch, verify and stage codegraph-build and codegraph-mcp' : 'on the Team plan: bravos account activate' },
22
+ { id: 'guardlink', present: whichBin('guardlink', env), wanted: GUARDLINK_VERSION, licensed: null, action: `npm install -g guardlink@${GUARDLINK_VERSION}` },
23
+ { id: 'cxg', present: whichBin('cxg', env), wanted: CXG_VERSION, licensed: null, action: `cargo install cert-x-gen@${CXG_VERSION} --force && cxg pentest update` },
24
+ ];
25
+ return { items, licence: v };
26
+ }
27
+ export const formatPlan = (items) => items.map((i) => `${i.present ? '✔' : '✘'} ${i.id.padEnd(10)} ${i.present ? i.present : `absent — ${i.action}`}${i.present && i.licensed === false ? ' (licence does not cover it now)' : ''}`);
28
+ // @comment -- "The setup itself. Default: the two licensed bundles for a licensed machine; --tools adds guardlink and cxg; --analyzer/--codegraph narrow it. Each licensed piece passes the gate before the first request. --dry-run prints the actions; -y is required for the package-manager installs because they run other people's installers."
29
+ export async function runSetup(env, opts, io) {
30
+ const lines = [];
31
+ const { items } = await plan(env);
32
+ if (opts.check) {
33
+ lines.push(...formatPlan(items));
34
+ return lines;
35
+ }
36
+ const pick = opts.analyzer || opts.codegraph || opts.tools ? { analyzer: !!opts.analyzer, codegraph: !!opts.codegraph, tools: !!opts.tools } : { analyzer: true, codegraph: true, tools: false };
37
+ const bundles = [...(pick.codegraph ? ['codegraph'] : []), ...(pick.analyzer ? ['analyzer'] : [])];
38
+ for (const kind of bundles) {
39
+ const what = `bravos setup --${kind}`;
40
+ await requireFeature(env, kind === 'analyzer' ? 'sast' : 'codegraph', what);
41
+ const have = staged(env, kind);
42
+ if (have && !opts.yes) {
43
+ lines.push(`${kind}: already staged at ${have} (add -y to fetch ${VERSIONS[kind]} again)`);
44
+ continue;
45
+ }
46
+ if (opts.dryRun) {
47
+ lines.push(`dry-run: would fetch ${kind} ${VERSIONS[kind]} for ${hostTarget() ?? 'this platform'} through the account portal, verify its signature and stage it under $BRAVOS_HOME/${kind}/`);
48
+ continue;
49
+ }
50
+ const r = await install(kind, { env, fetch: io.fetch, keys: io.keys, echo: io.echo });
51
+ const missing = missingPaths(kind, r.dir);
52
+ lines.push(`${kind}: ${r.version} staged at ${r.dir}${missing.length ? ` — missing ${missing.join(', ')}` : ''}`);
53
+ lines.push(...selfTest(kind, r.dir, env));
54
+ }
55
+ if (pick.tools) {
56
+ for (const i of items.filter((x) => x.id === 'guardlink' || x.id === 'cxg')) {
57
+ if (i.present) {
58
+ lines.push(`${i.id}: present at ${i.present}`);
59
+ continue;
60
+ }
61
+ if (opts.dryRun || !opts.yes) {
62
+ lines.push(`${i.id}: absent — ${opts.dryRun ? 'would run' : 'run'} ${i.action}${opts.yes ? '' : ' (add -y to run it here)'}`);
63
+ continue;
64
+ }
65
+ lines.push(`${i.id}: running ${i.action}`);
66
+ for (const cmd of i.action.split(' && ')) {
67
+ const [bin, ...args] = cmd.split(' ');
68
+ const r = spawnSync(bin, args, { env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 20 * 60 * 1000 });
69
+ if (r.status !== 0)
70
+ throw new BravosError(`${cmd} failed (exit ${r.status}): ${(r.stderr || r.stdout || '').trim().split('\n').slice(-3).join(' ')}`);
71
+ }
72
+ lines.push(`${i.id}: installed`);
73
+ }
74
+ }
75
+ return lines;
76
+ }
77
+ // @comment -- "A staged bundle proves it runs: codegraph-build --ladder, bravos-cli --version, each under the bundle's own environment. A bundle that verifies but does not run is reported, not hidden."
78
+ export function selfTest(kind, dir, env) {
79
+ const out = [];
80
+ const bins = kind === 'analyzer' ? [['bravos-cli', ['--version']]] : [['codegraph-build', ['--ladder']], ['codegraph-mcp', ['--help']]];
81
+ for (const [name, args] of bins) {
82
+ const r = spawnSync(join(dir, name), args, { env: bundleEnvironment(dir, env), encoding: 'utf8', timeout: 60000, stdio: ['ignore', 'pipe', 'pipe'] });
83
+ const ok = r.status === 0 || (name === 'codegraph-mcp' && (r.stdout + r.stderr).includes('usage'));
84
+ out.push(` ${ok ? '✔' : '✘'} ${name}${ok ? ` ${(r.stdout || r.stderr).trim().split('\n')[0].slice(0, 80)}` : ` did not run: ${(r.stderr || r.error?.message || '').trim().split('\n')[0]}`}`);
85
+ }
86
+ return out;
87
+ }
88
+ // @comment -- "Runs the analyzer the way the Python adapter did: bravos-cli analyze ROOT --ci --fail-on never --allow-incomplete-analysis --codegraph <reuse when a graph store exists> --scip off -f json,sarif -o .bravos/sast/<run>/report, under the bundle's environment, then writes meta.json (sha, exit, at) so the briefing's SAST line and bravos sast candidates read it. The exit code is a health signal; findings are read from the report."
89
+ export async function runSast(root, env, opts = {}) {
90
+ await requireFeature(env, 'sast', 'bravos sast run');
91
+ const dir = staged(env, 'analyzer');
92
+ const bin = dir ? join(dir, 'bravos-cli') : findAnalyzer(env);
93
+ if (!bin)
94
+ throw new BravosError('bravos-cli is not installed: bravos setup --analyzer fetches it');
95
+ const base = join(root, '.bravos', 'sast');
96
+ mkdirSync(base, { recursive: true });
97
+ if (!existsSync(join(base, '.gitignore')))
98
+ writeFileSync(join(base, '.gitignore'), '*\n');
99
+ const run = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, '').replace('T', '-');
100
+ const runDir = join(base, run);
101
+ mkdirSync(runDir, { recursive: true });
102
+ const sha = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', timeout: 5000 }).stdout?.trim() || null;
103
+ const codegraph = opts.codegraph ?? (graphBin('codegraph-mcp', env) ? 'reuse' : 'off');
104
+ const argv = ['analyze', root, '--ci', '--fail-on', 'never', '--allow-incomplete-analysis', '--codegraph', codegraph, '--scip', opts.scip ?? 'off', '-f', 'json,sarif', '-o', join(runDir, 'report')];
105
+ if (opts.scope?.length)
106
+ argv.push('--scope', opts.scope.join(','));
107
+ if (opts.exclude?.length)
108
+ argv.push('--exclude', opts.exclude.join(','));
109
+ if (opts.minSeverity)
110
+ argv.push('--min-severity', opts.minSeverity);
111
+ if (opts.timeoutS)
112
+ argv.push('--timeout', String(Math.max(1, opts.timeoutS - 1)));
113
+ const runEnv = dir ? bundleEnvironment(dir, env) : env;
114
+ const r = spawnSync(bin, argv, { cwd: root, env: runEnv, encoding: 'utf8', timeout: (opts.timeoutS ?? 1800) * 1000, maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] });
115
+ writeFileSync(join(runDir, 'meta.json'), JSON.stringify({ sha, base: null, scope: opts.scope?.length ? opts.scope.join(',') : 'all', exit: r.status, at: new Date().toISOString(), analyzer: bin, codegraph }, null, 2) + '\n');
116
+ const lines = [`SAST run ${run}: bravos-cli exited ${r.status ?? 'by signal'} (0 clean at the level, 1 findings, 3 licence refused or analysis failed, 4 a stage failed)`, ` report: ${join(runDir, 'report.json')}`];
117
+ if (r.status !== 0 && r.status !== 1)
118
+ lines.push(` ${(r.stderr || r.stdout || '').trim().split('\n').slice(-3).join('\n ')}`);
119
+ lines.push(' next: bravos sast candidates');
120
+ return { dir: runDir, exit: r.status, lines };
121
+ }
package/dist/skills.js ADDED
@@ -0,0 +1,81 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { skillsDir } from './paths.js';
4
+ // @comment -- "Marker bravos inserts into every SKILL.md it renders, so an unlocked skill can still be recognised as bravos-managed."
5
+ export const MARKER = '<!-- bravos:generated -->';
6
+ // @comment -- "Repo-relative skills root for a harness: .claude/skills for Claude Code, .agents/skills for Codex and other agents."
7
+ export function skillsRoot(harness) {
8
+ return harness === 'claude' ? '.claude/skills' : '.agents/skills';
9
+ }
10
+ function walk(dir, prefix = '') {
11
+ const out = [];
12
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
13
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
14
+ if (entry.isDirectory())
15
+ out.push(...walk(join(dir, entry.name), rel));
16
+ else if (entry.isFile())
17
+ out.push(rel);
18
+ }
19
+ return out;
20
+ }
21
+ /** Every bundled skill folder with all of its files. A missing directory yields []. */
22
+ // @comment -- "Reads every bundled skill folder and its files from the package; a missing directory yields []."
23
+ export function listBundledSkills(dir = skillsDir()) {
24
+ if (!existsSync(dir))
25
+ return [];
26
+ return readdirSync(dir)
27
+ .filter((n) => statSync(join(dir, n)).isDirectory())
28
+ .sort()
29
+ .map((name) => ({
30
+ name,
31
+ files: walk(join(dir, name))
32
+ .sort()
33
+ .map((rel) => ({ rel, content: readFileSync(join(dir, name, rel)) })),
34
+ }));
35
+ }
36
+ // @comment -- "Inserts the generated marker after the frontmatter (or at line 1) of a skill's SKILL.md only; sibling .md data files (operator-guidance.md, loop.md) are copied without it, since the skills that read them would carry the marker into what they write."
37
+ function withMarker(content, rel) {
38
+ if (rel !== 'SKILL.md')
39
+ return content;
40
+ const lines = content.split('\n');
41
+ let at = 0;
42
+ if (lines[0]?.trim() === '---') {
43
+ const close = lines.findIndex((l, i) => i > 0 && l.trim() === '---');
44
+ if (close !== -1)
45
+ at = close + 1;
46
+ }
47
+ lines.splice(at, 0, MARKER);
48
+ return lines.join('\n');
49
+ }
50
+ /** Insert the generated marker into SKILL.md after the frontmatter (or at line 1) and substitute placeholders. */
51
+ // @comment -- "Renders a bundled skill file for one harness: inserts the generated marker into SKILL.md (rel is the file's path inside the skill folder) and substitutes {{SKILL_DIR}} and {{SKILLS_ROOT}} in every .md file."
52
+ export function renderSkillFile(content, harness, name, rel = 'SKILL.md') {
53
+ const root = skillsRoot(harness);
54
+ return withMarker(content, rel)
55
+ .split('{{SKILL_DIR}}')
56
+ .join(`${root}/${name}`)
57
+ .split('{{SKILLS_ROOT}}')
58
+ .join(root);
59
+ }
60
+ /** Render a bundled skill file for the Claude Code / Codex plugin build instead of a repo install. */
61
+ // @comment -- "Plugin render: {{SKILL_DIR}} becomes ${CLAUDE_SKILL_DIR}, which Claude Code substitutes inside SKILL.md; {{SKILLS_ROOT}}/bravos* points into the plugin, any other {{SKILLS_ROOT}} (guardlink's generated skills) stays at the repo's .claude/skills."
62
+ export function renderPluginSkillFile(content, rel = 'SKILL.md') {
63
+ return withMarker(content, rel)
64
+ .split('{{SKILL_DIR}}')
65
+ .join('${CLAUDE_SKILL_DIR}')
66
+ .split('{{SKILLS_ROOT}}/bravos')
67
+ .join('${CLAUDE_PLUGIN_ROOT}/skills/bravos')
68
+ .split('{{SKILLS_ROOT}}')
69
+ .join('.claude/skills');
70
+ }
71
+ // @comment -- "User-level render (bravos init --global): bravos's own paths become absolute under the home folder (skills in ~/.claude/skills, hook scripts in ~/.bravos/hooks), while {{SKILLS_ROOT}} for guardlink's generated skills stays at the repo's .claude/skills."
72
+ export function renderGlobalSkillFile(home) {
73
+ return (content, _harness, name, rel = 'SKILL.md') => withMarker(content, rel)
74
+ .split('{{SKILL_DIR}}')
75
+ .join(`${home}/.claude/skills/${name}`)
76
+ .split('{{SKILLS_ROOT}}/bravos')
77
+ .join(`${home}/.claude/skills/bravos`)
78
+ .split('{{SKILLS_ROOT}}')
79
+ .join('.claude/skills')
80
+ .replace(/node \.bravos\/hooks\/([\w.-]+)/g, (_m, file) => `node "${home}/.bravos/hooks/${file}"`);
81
+ }
package/dist/types.js ADDED
@@ -0,0 +1,9 @@
1
+ // @comment -- "Expected, user-facing failure (bad config file, failed guardlink init) carrying the CLI exit code; anything else is a bug."
2
+ export class BravosError extends Error {
3
+ exitCode;
4
+ constructor(message, exitCode = 1) {
5
+ super(message);
6
+ this.exitCode = exitCode;
7
+ this.name = 'BravosError';
8
+ }
9
+ }