@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
@@ -0,0 +1,275 @@
1
+ // @comment -- "Moves an install made under the old name (joseph: .joseph/, joseph-* skills, .omp and .opencode joseph.js) to the bravos layout before init or sync writes the new one. Generated files are removed only when their content still matches the old lock, so an edited skill or hook is kept and named; reports, SAST runs, the watch log, workspace snapshots and review answers are moved, never deleted. Idempotent: a root with no trace of the old name is left untouched and reports null."
2
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
3
+ import { createHash } from 'node:crypto';
4
+ import { dirname, join } from 'node:path';
5
+ import { sha256 } from './lock.js';
6
+ export const OLD_NAME = 'joseph';
7
+ export const OLD_DIR = `.${OLD_NAME}`;
8
+ /** Hook commands the old name registered; the new registrations list them as superseded. */
9
+ export const OLD_HOOK_COMMANDS = {
10
+ sessionStart: `node ${OLD_DIR}/hooks/session-start.mjs`,
11
+ prePr: `node ${OLD_DIR}/hooks/pre-pr.mjs`,
12
+ autopilot: `node ${OLD_DIR}/hooks/autopilot.mjs`,
13
+ };
14
+ export const oldGlobalHookCommand = (home, harness) => `node "${home}/${OLD_DIR}/hooks/session-start.mjs" --harness ${harness} --global`;
15
+ /** Data the old install kept beside its hooks: moved to the same place under .bravos/. */
16
+ const DATA_DIRS = ['reports', 'sast', 'watch', 'workspace', 'review'];
17
+ /** The Python bravos CLI's skills lock (`bravos skills install`): sha256 hex per file, per skill. */
18
+ const PY_SKILLS_LOCK = '.bravos-skills.lock';
19
+ // @comment -- "Replaces the Python bravos CLI's skills that share a name with this package's (bravos-annotate, bravos-codegraph, bravos-sast) when its own lock vouches for every file, so the install that follows writes ours in their place; its other skills, and any it has edited, are left where they are and named."
20
+ function retirePythonSkills(root, ownSkills, dryRun, report) {
21
+ for (const skillsRoot of ['.claude/skills', '.agents/skills']) {
22
+ let lock;
23
+ try {
24
+ lock = JSON.parse(readFileSync(join(root, skillsRoot, PY_SKILLS_LOCK), 'utf8'));
25
+ }
26
+ catch {
27
+ continue;
28
+ }
29
+ for (const [name, entry] of Object.entries(lock.skills ?? {})) {
30
+ if (!ownSkills.includes(name))
31
+ continue;
32
+ const dir = join(root, skillsRoot, name);
33
+ let st;
34
+ try {
35
+ st = lstatSync(dir);
36
+ }
37
+ catch {
38
+ continue;
39
+ }
40
+ const rel = `${skillsRoot}/${name}`;
41
+ if (st.isSymbolicLink()) {
42
+ report.removed.push(`${rel} (link, python bravos)`);
43
+ if (!dryRun)
44
+ rmSync(dir, { force: true });
45
+ continue;
46
+ }
47
+ const files = entry.files ?? {};
48
+ const vouched = readdirSync(dir).every((f) => {
49
+ try {
50
+ return files[f] === createHash('sha256').update(readFileSync(join(dir, f))).digest('hex');
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ });
56
+ if (vouched) {
57
+ report.removed.push(`${rel}/ (python bravos skill of the same name)`);
58
+ if (!dryRun)
59
+ rmSync(dir, { recursive: true, force: true });
60
+ }
61
+ else
62
+ report.kept.push(`${rel}/ (python bravos skill of the same name, edited; ours is skipped until you move it)`);
63
+ }
64
+ }
65
+ }
66
+ function oldLockFiles(root) {
67
+ try {
68
+ const p = JSON.parse(readFileSync(join(root, OLD_DIR, 'skills.lock'), 'utf8'));
69
+ const files = p?.files;
70
+ if (typeof files !== 'object' || files === null)
71
+ return {};
72
+ const out = {};
73
+ for (const [k, v] of Object.entries(files))
74
+ if (typeof v === 'string')
75
+ out[k] = v;
76
+ return out;
77
+ }
78
+ catch {
79
+ return {};
80
+ }
81
+ }
82
+ function pruneEmpty(root, rel) {
83
+ let dir = dirname(rel);
84
+ while (dir && dir !== '.' && dir !== '/') {
85
+ const abs = join(root, dir);
86
+ try {
87
+ if (!lstatSync(abs).isDirectory() || readdirSync(abs).length > 0)
88
+ return;
89
+ rmdirSync(abs);
90
+ }
91
+ catch {
92
+ return;
93
+ }
94
+ dir = dirname(dir);
95
+ }
96
+ }
97
+ function pythonSkillCollision(root, ownSkills) {
98
+ return ['.claude/skills', '.agents/skills'].some((r) => {
99
+ try {
100
+ const lock = JSON.parse(readFileSync(join(root, r, PY_SKILLS_LOCK), 'utf8'));
101
+ return Object.keys(lock?.skills ?? {}).some((n) => ownSkills.includes(n) && existsSync(join(root, r, n)));
102
+ }
103
+ catch {
104
+ return false;
105
+ }
106
+ });
107
+ }
108
+ function hasOldTraces(root) {
109
+ if (existsSync(join(root, OLD_DIR)))
110
+ return true;
111
+ for (const skillsRoot of ['.claude/skills', '.agents/skills']) {
112
+ try {
113
+ if (readdirSync(join(root, skillsRoot)).some((n) => n === OLD_NAME || n.startsWith(`${OLD_NAME}-`)))
114
+ return true;
115
+ }
116
+ catch {
117
+ /* no such root */
118
+ }
119
+ }
120
+ if (['.omp/extensions', '.opencode/plugins'].some((d) => existsSync(join(root, d, `${OLD_NAME}.js`))))
121
+ return true;
122
+ return OLD_NAME in agyDoc(root);
123
+ }
124
+ function agyDoc(root) {
125
+ try {
126
+ const parsed = JSON.parse(readFileSync(join(root, '.agents', 'hooks.json'), 'utf8'));
127
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : {};
128
+ }
129
+ catch {
130
+ return {};
131
+ }
132
+ }
133
+ // @comment -- "The migration itself. `home` marks a user-level install (~/.joseph/hooks and ~/.claude/skills/joseph-*), where the data folders do not exist and the lock is the one under the old home dir."
134
+ export function migrateFromOldName(root, opts = {}) {
135
+ const ownSkills = opts.ownSkills ?? [];
136
+ const collision = pythonSkillCollision(root, ownSkills);
137
+ if (!hasOldTraces(root) && !collision)
138
+ return null;
139
+ const dryRun = !!opts.dryRun;
140
+ const report = { moved: [], removed: [], kept: [], notes: [] };
141
+ if (collision)
142
+ retirePythonSkills(root, ownSkills, dryRun, report);
143
+ if (!hasOldTraces(root)) {
144
+ if (report.removed.length + report.kept.length > 0)
145
+ report.notes.unshift(`python bravos skills of the same name: ${report.removed.length} replaced, ${report.kept.length} kept`);
146
+ return report;
147
+ }
148
+ const lock = oldLockFiles(root);
149
+ // 1. Generated files the old lock knows: skills, hook scripts, omp/opencode plugins and commands.
150
+ for (const [rel, hash] of Object.entries(lock)) {
151
+ const abs = join(root, rel);
152
+ let st;
153
+ try {
154
+ st = lstatSync(abs);
155
+ }
156
+ catch {
157
+ continue;
158
+ }
159
+ if (st.isSymbolicLink())
160
+ continue;
161
+ if (!st.isFile())
162
+ continue;
163
+ if (sha256(readFileSync(abs)) === hash) {
164
+ report.removed.push(rel);
165
+ if (!dryRun) {
166
+ rmSync(abs, { force: true });
167
+ pruneEmpty(root, rel);
168
+ }
169
+ }
170
+ else
171
+ report.kept.push(rel);
172
+ }
173
+ // 2. Old-name skill folders left after that: a link is removed; a real folder is one the lock did not
174
+ // vouch for (edited, or from an install whose lock is gone) and is only ever named, never deleted.
175
+ for (const skillsRoot of ['.claude/skills', '.agents/skills']) {
176
+ let names = [];
177
+ try {
178
+ names = readdirSync(join(root, skillsRoot)).filter((n) => n === OLD_NAME || n.startsWith(`${OLD_NAME}-`));
179
+ }
180
+ catch {
181
+ continue;
182
+ }
183
+ for (const n of names) {
184
+ const dir = join(root, skillsRoot, n);
185
+ const rel = `${skillsRoot}/${n}`;
186
+ let st;
187
+ try {
188
+ st = lstatSync(dir);
189
+ }
190
+ catch {
191
+ continue;
192
+ }
193
+ if (st.isSymbolicLink()) {
194
+ report.removed.push(`${rel} (link)`);
195
+ if (!dryRun)
196
+ rmSync(dir, { force: true });
197
+ continue;
198
+ }
199
+ if (dryRun && readdirSync(dir).every((f) => report.removed.includes(`${rel}/${f}`)))
200
+ continue;
201
+ if (!report.kept.some((k) => k.startsWith(`${rel}/`)))
202
+ report.kept.push(`${rel}/ (not vouched for by the old lock; delete or rename it by hand)`);
203
+ }
204
+ }
205
+ // 3. Data moves: whatever the old install kept beside its hooks.
206
+ const oldDir = join(root, OLD_DIR);
207
+ if (existsSync(oldDir)) {
208
+ for (const d of DATA_DIRS) {
209
+ const from = join(oldDir, d);
210
+ if (!existsSync(from))
211
+ continue;
212
+ const to = join(root, '.bravos', d);
213
+ if (existsSync(to)) {
214
+ report.kept.push(`${OLD_DIR}/${d}/ (.bravos/${d} already exists; merge by hand)`);
215
+ continue;
216
+ }
217
+ report.moved.push(`${OLD_DIR}/${d} → .bravos/${d}`);
218
+ if (!dryRun) {
219
+ mkdirSync(join(root, '.bravos'), { recursive: true });
220
+ renameSync(from, to);
221
+ }
222
+ }
223
+ // 4. The hook scripts and the lock are the install's own; anything else left is named, not deleted.
224
+ for (const own of ['hooks', 'skills.lock']) {
225
+ const abs = join(oldDir, own);
226
+ if (!existsSync(abs))
227
+ continue;
228
+ if (!report.removed.includes(`${OLD_DIR}/${own}`))
229
+ report.removed.push(`${OLD_DIR}/${own}`);
230
+ if (!dryRun)
231
+ rmSync(abs, { recursive: true, force: true });
232
+ }
233
+ if (!dryRun) {
234
+ try {
235
+ if (readdirSync(oldDir).length === 0)
236
+ rmdirSync(oldDir);
237
+ }
238
+ catch {
239
+ /* keep it */
240
+ }
241
+ }
242
+ try {
243
+ const left = dryRun ? readdirSync(oldDir).filter((n) => n !== 'hooks' && n !== 'skills.lock' && !DATA_DIRS.includes(n)) : readdirSync(oldDir);
244
+ if (left.length)
245
+ report.notes.push(`${OLD_DIR}/ still holds ${left.join(', ')}; move or delete it by hand`);
246
+ }
247
+ catch {
248
+ /* removed */
249
+ }
250
+ }
251
+ // 5. Antigravity's hooks file keys its groups by name; the old name's group goes whatever harnesses are chosen now.
252
+ const agy = agyDoc(root);
253
+ if (OLD_NAME in agy) {
254
+ report.removed.push(`.agents/hooks.json ${OLD_NAME} group`);
255
+ if (!dryRun) {
256
+ const { [OLD_NAME]: _old, ...rest } = agy;
257
+ writeFileSync(join(root, '.agents', 'hooks.json'), JSON.stringify(rest, null, 2) + '\n');
258
+ }
259
+ }
260
+ if (report.removed.length + report.moved.length + report.kept.length > 0) {
261
+ report.notes.unshift(`migrated from the old name (${OLD_NAME}): ${report.removed.length} generated files removed, ${report.moved.length} folders moved, ${report.kept.length} kept`);
262
+ }
263
+ return report;
264
+ }
265
+ // @comment -- "Summary lines for an install moved from the old name: what was removed, moved and kept, so an edited skill or an unmerged data folder is named rather than silently left behind."
266
+ export function reportMigration(m, lines) {
267
+ if (!m)
268
+ return;
269
+ for (const n of m.notes)
270
+ lines.push(n);
271
+ for (const x of m.moved)
272
+ lines.push(` moved ${x}`);
273
+ for (const x of m.kept)
274
+ lines.push(` kept ${x}`);
275
+ }
package/dist/omp.js ADDED
@@ -0,0 +1,46 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { delimiter, dirname, join } from 'node:path';
3
+ import { readLock, sha256, writeLock } from './lock.js';
4
+ import { templatesDir } from './paths.js';
5
+ // @comment -- "Where bravos's omp extension lives; omp auto-loads project extensions from .omp/extensions/ in the working directory."
6
+ export const OMP_EXTENSION_PATH = '.omp/extensions/bravos.js';
7
+ const MANAGED = '// bravos (managed by bravos init/sync';
8
+ // @comment -- "True when omp is in use for this repo: a .omp/ folder exists, omp is on PATH, or ~/.omp/agent exists."
9
+ export function ompPresent(root, env) {
10
+ if (existsSync(join(root, '.omp')))
11
+ return true;
12
+ if ((env.PATH ?? '').split(delimiter).some((d) => d !== '' && existsSync(join(d, 'omp'))))
13
+ return true;
14
+ return !!env.HOME && existsSync(join(env.HOME, '.omp', 'agent'));
15
+ }
16
+ // @comment -- "Writes the omp extension that runs bravos's session start in omp, only when omp is in use; an existing file is replaced only if bravos wrote it (lock hash or managed header) unless --force, and the lock records what was written."
17
+ export function writeOmpExtension(root, env, opts = {}) {
18
+ if (!opts.always && !ompPresent(root, env))
19
+ return { status: 'absent' };
20
+ const path = OMP_EXTENSION_PATH;
21
+ const abs = join(root, path);
22
+ const content = readFileSync(opts.source ?? join(templatesDir(), 'omp', 'bravos.js'));
23
+ const hash = sha256(content);
24
+ const lock = readLock(root);
25
+ let status = 'written';
26
+ if (existsSync(abs)) {
27
+ const disk = readFileSync(abs);
28
+ const diskHash = sha256(disk);
29
+ if (diskHash === hash)
30
+ return { status: 'unchanged', path };
31
+ const locked = lock.files[path];
32
+ if (locked !== undefined && diskHash !== locked && !opts.force) {
33
+ return { status: 'skipped', path, reason: 'edited since last install (use --force to overwrite)' };
34
+ }
35
+ if (locked === undefined && !disk.toString('utf8').startsWith(MANAGED) && !opts.force) {
36
+ return { status: 'skipped', path, reason: 'authored file, not managed by bravos (use --force to overwrite)' };
37
+ }
38
+ status = 'updated';
39
+ }
40
+ if (!opts.dryRun) {
41
+ mkdirSync(dirname(abs), { recursive: true });
42
+ writeFileSync(abs, content);
43
+ writeLock(root, { ...lock, files: { ...lock.files, [path]: hash } });
44
+ }
45
+ return { status, path };
46
+ }
@@ -0,0 +1,118 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
2
+ import { delimiter, dirname, join } from 'node:path';
3
+ import { readLock, sha256, writeLock } from './lock.js';
4
+ import { staleGraphEntry } from './mcp.js';
5
+ import { skillsDir as bundledSkillsDir, templatesDir } from './paths.js';
6
+ import { BravosError } from './types.js';
7
+ // @comment -- "Where bravos's opencode files live: the plugin (session start), one command per skill (opencode lists skills only through its skill tool, not as /commands), and the project opencode.json for MCP."
8
+ export const OPENCODE_PLUGIN_PATH = '.opencode/plugins/bravos.js';
9
+ export const OPENCODE_COMMANDS_DIR = '.opencode/commands';
10
+ export const OPENCODE_CONFIG = 'opencode.json';
11
+ const MANAGED = 'bravos (managed by bravos init/sync';
12
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
13
+ // @comment -- "True when opencode is in use: a .opencode/ folder or opencode.json in the repo, opencode on PATH or in ~/.opencode/bin, or a ~/.config/opencode folder (a user of opencode, even mid-reinstall)."
14
+ export function opencodePresent(root, env) {
15
+ if (existsSync(join(root, '.opencode')) || existsSync(join(root, OPENCODE_CONFIG)))
16
+ return true;
17
+ if ((env.PATH ?? '').split(delimiter).some((d) => d !== '' && existsSync(join(d, 'opencode'))))
18
+ return true;
19
+ const home = env.HOME;
20
+ return !!home && (existsSync(join(home, '.opencode', 'bin', 'opencode')) || existsSync(join(home, '.config', 'opencode')));
21
+ }
22
+ /** First sentence of a skill's frontmatter description, for the command's one-line summary. */
23
+ function skillSummary(skillMd) {
24
+ const m = skillMd.match(/^description:\s*"?(.*?)"?\s*$/m);
25
+ const d = (m?.[1] ?? '').replace(/\\"/g, '"');
26
+ const first = d.split(/(?<=\.)\s/)[0] ?? d;
27
+ return first.length > 160 ? first.slice(0, 159) + '…' : first;
28
+ }
29
+ // @comment -- "The body of one opencode command: it asks the agent to load the bravos skill of the same name through opencode's skill tool, with the files as a fallback, and passes the user's arguments through."
30
+ export const commandBody = (name, description) => `---
31
+ description: ${JSON.stringify(description)}
32
+ ---
33
+ <!-- ${MANAGED}) -->
34
+ Load the \`${name}\` skill with the skill tool (\`skill({ name: "${name}" })\`) and follow it step by step. If the skill tool is not available, read \`.claude/skills/${name}/SKILL.md\` (or \`.agents/skills/${name}/SKILL.md\`) and follow that.
35
+
36
+ $ARGUMENTS
37
+ `;
38
+ // @comment -- "Plans and writes the opencode integration when opencode is in use: the plugin, one command per bundled skill, and guardlink (plus cxg and codegraph when installed) under mcp in opencode.json. A file bravos did not write, or edited since, is skipped unless --force; existing MCP entries are never overwritten; a malformed opencode.json is refused before anything is written."
39
+ export function writeOpencode(root, env, servers, opts = {}) {
40
+ if (!opts.always && !opencodePresent(root, env))
41
+ return { status: 'absent' };
42
+ // opencode.json first: refuse a malformed file before writing anything else.
43
+ const cfgAbs = join(root, OPENCODE_CONFIG);
44
+ let cfg = {};
45
+ if (existsSync(cfgAbs)) {
46
+ let parsed;
47
+ try {
48
+ parsed = JSON.parse(readFileSync(cfgAbs, 'utf8'));
49
+ }
50
+ catch {
51
+ throw new BravosError(`${OPENCODE_CONFIG} is not valid JSON (comments are not supported here); fix it or remove it, nothing was written`);
52
+ }
53
+ if (!isObject(parsed))
54
+ throw new BravosError(`${OPENCODE_CONFIG} is not a JSON object; fix it or remove it, nothing was written`);
55
+ cfg = parsed;
56
+ }
57
+ if (cfg.mcp !== undefined && !isObject(cfg.mcp))
58
+ throw new BravosError(`${OPENCODE_CONFIG} has an mcp value that is not an object; fix it or remove it, nothing was written`);
59
+ const mcp = { ...(cfg.mcp ?? {}) };
60
+ const mcpAdded = Object.keys(servers).filter((n) => !Object.hasOwn(mcp, n));
61
+ // bravos's own codegraph entry into a replaced bravos install is refreshed; every other entry is kept.
62
+ const mcpStale = Object.keys(servers).filter((n) => {
63
+ const e = mcp[n];
64
+ if (!Object.hasOwn(mcp, n) || !Array.isArray(e?.command))
65
+ return false;
66
+ return staleGraphEntry(n, { command: e.command[0], args: e.command.slice(1), env: e.environment }, servers[n]);
67
+ });
68
+ for (const n of [...mcpAdded, ...mcpStale]) {
69
+ mcp[n] = { type: 'local', command: [servers[n].command, ...servers[n].args], enabled: true, ...(servers[n].env ? { environment: servers[n].env } : {}) };
70
+ }
71
+ const files = [];
72
+ files.push({ path: OPENCODE_PLUGIN_PATH, content: readFileSync(opts.plugin ?? join(templatesDir(), 'opencode', 'bravos.js')) });
73
+ const sdir = opts.skillsDir ?? bundledSkillsDir();
74
+ for (const name of readdirSync(sdir).filter((n) => existsSync(join(sdir, n, 'SKILL.md'))).sort()) {
75
+ const desc = skillSummary(readFileSync(join(sdir, name, 'SKILL.md'), 'utf8'));
76
+ files.push({ path: `${OPENCODE_COMMANDS_DIR}/${name}.md`, content: Buffer.from(commandBody(name, desc)) });
77
+ }
78
+ const lock = readLock(root);
79
+ const written = [];
80
+ const unchanged = [];
81
+ const skipped = [];
82
+ for (const f of files) {
83
+ const abs = join(root, f.path);
84
+ const hash = sha256(f.content);
85
+ if (existsSync(abs)) {
86
+ const disk = readFileSync(abs);
87
+ const diskHash = sha256(disk);
88
+ if (diskHash === hash) {
89
+ unchanged.push(f.path);
90
+ continue;
91
+ }
92
+ const locked = lock.files[f.path];
93
+ if (!opts.force && locked !== undefined && diskHash !== locked) {
94
+ skipped.push({ path: f.path, reason: 'edited since last install (use --force to overwrite)' });
95
+ continue;
96
+ }
97
+ if (!opts.force && locked === undefined && !disk.toString('utf8').includes(MANAGED)) {
98
+ skipped.push({ path: f.path, reason: 'authored file, not managed by bravos (use --force to overwrite)' });
99
+ continue;
100
+ }
101
+ }
102
+ written.push(f.path);
103
+ if (!opts.dryRun) {
104
+ mkdirSync(dirname(abs), { recursive: true });
105
+ writeFileSync(abs, f.content);
106
+ lock.files[f.path] = hash;
107
+ }
108
+ }
109
+ if (!opts.dryRun) {
110
+ writeLock(root, lock);
111
+ if (mcpAdded.length > 0 || mcpStale.length > 0) {
112
+ if (!cfg.$schema)
113
+ cfg = { $schema: 'https://opencode.ai/config.json', ...cfg };
114
+ writeFileSync(cfgAbs, JSON.stringify({ ...cfg, mcp }, null, 2) + '\n');
115
+ }
116
+ }
117
+ return { status: 'done', written, unchanged, skipped, mcpAdded, ...(mcpStale.length ? { mcpUpdated: mcpStale } : {}) };
118
+ }
package/dist/paths.js ADDED
@@ -0,0 +1,33 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ /** Walk up from this file to the first directory holding package.json (works from src/ and dist/). */
5
+ // @comment -- "Finds the installed bravos package root by walking up to package.json, from src/ or dist/."
6
+ export function packageRoot() {
7
+ let dir = dirname(fileURLToPath(import.meta.url));
8
+ for (;;) {
9
+ if (existsSync(join(dir, 'package.json')))
10
+ return dir;
11
+ const parent = dirname(dir);
12
+ if (parent === dir)
13
+ throw new Error('package.json not found above ' + dir);
14
+ dir = parent;
15
+ }
16
+ }
17
+ // @comment -- "Path of the bundled skills/ directory in the bravos package."
18
+ export function skillsDir() {
19
+ return join(packageRoot(), 'skills');
20
+ }
21
+ // @comment -- "Path of the bundled hooks/ directory in the bravos package."
22
+ export function hooksDir() {
23
+ return join(packageRoot(), 'hooks');
24
+ }
25
+ // @comment -- "bravos's own version, read from its package.json."
26
+ export function version() {
27
+ const pkg = JSON.parse(readFileSync(join(packageRoot(), 'package.json'), 'utf8'));
28
+ return pkg.version;
29
+ }
30
+ // @comment -- "Path of the bundled templates/ directory (CI workflows) in the bravos package."
31
+ export function templatesDir() {
32
+ return join(packageRoot(), 'templates');
33
+ }
package/dist/plugin.js ADDED
@@ -0,0 +1,101 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join, relative } from 'node:path';
3
+ import { sessionStart } from './hooks.js';
4
+ import { SERVERS } from './mcp.js';
5
+ import { hooksDir, version } from './paths.js';
6
+ import { listBundledSkills, renderPluginSkillFile, renderSkillFile } from './skills.js';
7
+ // @comment -- "The committed plugin trees (Claude Code's in plugin/, Codex's in plugin-codex/) and the two marketplace files that point at them, all repo-relative."
8
+ export const PLUGIN_DIR = 'plugin';
9
+ export const CODEX_PLUGIN_DIR = 'plugin-codex';
10
+ export const CLAUDE_MARKETPLACE = '.claude-plugin/marketplace.json';
11
+ export const CODEX_MARKETPLACE = '.agents/plugins/marketplace.json';
12
+ const DESCRIPTION = 'Makes Claude Code and Codex power users of guardlink (threat-model-as-code) and cert-x-gen (pentest-as-code): annotate, ship, templatize, prove and recheck, in-session.';
13
+ const AUTHOR = { name: 'BugB Technologies', url: 'https://www.bugb.io' };
14
+ const KEYWORDS = ['security', 'threat-modeling', 'guardlink', 'pentest', 'cxg', 'skills'];
15
+ const json = (v) => Buffer.from(JSON.stringify(v, null, 2) + '\n');
16
+ function walk(dir) {
17
+ const out = [];
18
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
19
+ const abs = join(dir, e.name);
20
+ if (e.isDirectory())
21
+ out.push(...walk(abs));
22
+ else if (e.isFile())
23
+ out.push(abs);
24
+ }
25
+ return out;
26
+ }
27
+ // @comment -- "Every file of both plugin builds, computed and not written. plugin/ (Claude Code): skills rendered with ${CLAUDE_SKILL_DIR} and ${CLAUDE_PLUGIN_ROOT}, the hook scripts, a Claude hooks.json, .mcp.json and .claude-plugin/plugin.json. plugin-codex/ (Codex, Agent Plugins layout): skills rendered with repo-relative .agents/skills paths, the hook scripts, hooks/hooks.json in the shape .codex/hooks.json takes with the command under ${PLUGIN_ROOT}, mcp.json and a root plugin.json. Both register SessionStart only (the pre-PR gate stays opt-in through bravos init) and the guardlink MCP server only. Plus both marketplaces."
28
+ export function pluginFiles(opts = {}) {
29
+ const v = version();
30
+ const files = [];
31
+ const add = (path, content) => files.push({ path, content });
32
+ const skills = listBundledSkills(opts.skillsDir);
33
+ const hooks = opts.hooksDir ?? hooksDir();
34
+ const hookFiles = walk(hooks).sort();
35
+ for (const skill of skills) {
36
+ for (const f of skill.files) {
37
+ const md = f.rel.endsWith('.md');
38
+ add(`${PLUGIN_DIR}/skills/${skill.name}/${f.rel}`, md ? Buffer.from(renderPluginSkillFile(f.content.toString('utf8'), f.rel)) : f.content);
39
+ add(`${CODEX_PLUGIN_DIR}/skills/${skill.name}/${f.rel}`, md ? Buffer.from(renderSkillFile(f.content.toString('utf8'), 'agents', skill.name, f.rel)) : f.content);
40
+ }
41
+ }
42
+ for (const dir of [PLUGIN_DIR, CODEX_PLUGIN_DIR]) {
43
+ for (const abs of hookFiles)
44
+ add(`${dir}/hooks/${relative(hooks, abs).split('\\').join('/')}`, readFileSync(abs));
45
+ }
46
+ add(`${PLUGIN_DIR}/hooks/hooks.json`, json({
47
+ hooks: {
48
+ SessionStart: [{
49
+ matcher: 'startup|resume|clear|compact',
50
+ hooks: [{ type: 'command', command: 'node "${CLAUDE_PLUGIN_ROOT}/hooks/session-start.mjs" --harness claude', timeout: 30 }],
51
+ }],
52
+ },
53
+ }));
54
+ // Codex substitutes ${PLUGIN_ROOT} in plugin hooks; the group is otherwise the one bravos init writes.
55
+ const start = sessionStart('agents');
56
+ const codexStart = { ...start.group, hooks: start.group.hooks.map((h) => ({ ...h, command: 'node "${PLUGIN_ROOT}/hooks/session-start.mjs" --harness codex' })) };
57
+ add(`${CODEX_PLUGIN_DIR}/hooks/hooks.json`, json({ hooks: { [start.event]: [codexStart] } }));
58
+ // guardlink only: cxg and codegraph-mcp are optional, and a server that fails to start on every
59
+ // machine without its binary is noise. bravos init adds them per repo when they are installed.
60
+ const servers = { guardlink: SERVERS.guardlink };
61
+ add(`${PLUGIN_DIR}/.mcp.json`, json({ mcpServers: servers }));
62
+ add(`${CODEX_PLUGIN_DIR}/mcp.json`, json({
63
+ mcpServers: Object.fromEntries(Object.entries(servers).map(([k, s]) => [k, { type: 'stdio', command: s.command, args: [...s.args] }])),
64
+ }));
65
+ const manifest = { name: 'bravos', version: v, description: DESCRIPTION, author: AUTHOR, license: 'SEE LICENSE IN LICENSE', keywords: KEYWORDS };
66
+ add(`${PLUGIN_DIR}/.claude-plugin/plugin.json`, json(manifest));
67
+ add(`${CODEX_PLUGIN_DIR}/plugin.json`, json(manifest));
68
+ add(CLAUDE_MARKETPLACE, json({
69
+ name: 'bravos',
70
+ owner: AUTHOR,
71
+ description: 'The bravos skills pack for guardlink and cert-x-gen.',
72
+ plugins: [{ name: 'bravos', source: `./${PLUGIN_DIR}`, description: DESCRIPTION, version: v }],
73
+ }));
74
+ add(CODEX_MARKETPLACE, json({
75
+ name: 'bravos',
76
+ interface: { displayName: 'bravos' },
77
+ plugins: [{
78
+ name: 'bravos',
79
+ source: { source: 'local', path: `./${CODEX_PLUGIN_DIR}` },
80
+ policy: { installation: 'AVAILABLE', authentication: 'ON_INSTALL' },
81
+ category: 'Security',
82
+ }],
83
+ }));
84
+ // Code-point order, not localeCompare, so the build is identical on every machine and locale.
85
+ return files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
86
+ }
87
+ // @comment -- "Writes both plugin builds under root: replaces plugin/ and plugin-codex/ wholesale (so a removed skill leaves the build) and writes the two marketplace files; returns the paths written."
88
+ export function writePlugin(root, opts = {}) {
89
+ const files = pluginFiles(opts);
90
+ for (const d of [PLUGIN_DIR, CODEX_PLUGIN_DIR]) {
91
+ const dir = join(root, d);
92
+ if (existsSync(dir))
93
+ rmSync(dir, { recursive: true, force: true });
94
+ }
95
+ for (const f of files) {
96
+ const abs = join(root, f.path);
97
+ mkdirSync(dirname(abs), { recursive: true });
98
+ writeFileSync(abs, f.content);
99
+ }
100
+ return files.map((f) => f.path);
101
+ }