@rafinery/cli 0.11.0 → 0.13.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.
package/lib/audit.mjs ADDED
@@ -0,0 +1,289 @@
1
+ // rafa audit — the deterministic security engine (finding shape rafa.audit/v1).
2
+ //
3
+ // SELF-CONTAINED (owner, 2026-07-26: "rafa audit should already have what it
4
+ // needs — it should just run"): dependency CVEs via the built-in pnpm-lock v9
5
+ // parser + the free keyless OSV.dev API (merged with `pnpm audit --json` as a
6
+ // secondary source), secrets via the built-in curated ruleset over TRACKED
7
+ // files only (`.env*` never opened; fingerprints, never secret bytes). semgrep
8
+ // is the ONE optional external (SAST, pinned config) — used if present,
9
+ // honestly absent otherwise; gitleaks enhances the secrets tier the same way.
10
+ // NO binaries are installed by this command, ever.
11
+ //
12
+ // Exit codes: 0 clean · 1 findings present · 2 engine error.
13
+
14
+ import { execSync, execFileSync } from "node:child_process";
15
+ import { createHash } from "node:crypto";
16
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import {
20
+ AUDIT_SCHEMA,
21
+ LOCKFILE_NAMES,
22
+ SECRET_PATTERNS,
23
+ analyzeLockfile,
24
+ buildOsvQueries,
25
+ buildReport,
26
+ dedupeFindings,
27
+ entropyOf,
28
+ fromGitleaks,
29
+ fromOsvBatch,
30
+ fromPnpmAudit,
31
+ fromRegexSecrets,
32
+ fromSemgrepSarif,
33
+ mapConcurrent,
34
+ } from "./audit/core.mjs";
35
+
36
+ const OSV_BATCH = "https://api.osv.dev/v1/querybatch";
37
+ const OSV_VULN = "https://api.osv.dev/v1/vulns/";
38
+ const SEMGREP_CONFIG = "p/security-audit"; // pinned — never `--config auto` (it phones home to pick rules)
39
+
40
+ export const hasBinary = (name) => {
41
+ try {
42
+ execSync(`command -v ${name}`, { stdio: "ignore", shell: "/bin/sh" });
43
+ return true;
44
+ } catch {
45
+ return false;
46
+ }
47
+ };
48
+
49
+ async function osvFetch(url, body) {
50
+ const res = await fetch(url, {
51
+ method: body ? "POST" : "GET",
52
+ headers: body ? { "content-type": "application/json" } : undefined,
53
+ body: body ? JSON.stringify(body) : undefined,
54
+ });
55
+ if (!res.ok) throw new Error(`OSV ${res.status} on ${url}`);
56
+ return res.json();
57
+ }
58
+
59
+ // Tier a — dependency CVEs. Built-in OSV primary over WHICHEVER lockfile the
60
+ // repo has (pnpm · npm · yarn · bun — dynamic, never a pnpm hardcode);
61
+ // `pnpm audit` secondary on pnpm repos.
62
+ async function dependencyTier(root) {
63
+ const lockName = LOCKFILE_NAMES.find((n) => existsSync(join(root, n)));
64
+ if (!lockName) {
65
+ const reason = existsSync(join(root, "bun.lockb"))
66
+ ? "bun.lockb is binary — generate the text lockfile: `bun install --save-text-lockfile`"
67
+ : `no lockfile (${LOCKFILE_NAMES.join(" / ")}) — dependency scanning not covered; \`rafa doctor\` shows the command to generate one`;
68
+ return { tier: { ran: false, tool: null, reason }, findings: [] };
69
+ }
70
+
71
+ const { manager, packages, reachability } = analyzeLockfile(
72
+ lockName,
73
+ readFileSync(join(root, lockName), "utf8"),
74
+ );
75
+ if (packages.length === 0)
76
+ return { tier: { ran: false, tool: null, reason: `${lockName} parsed to zero packages` }, findings: [] };
77
+ const findings = [];
78
+ let osvOk = false;
79
+ let reason = null;
80
+
81
+ try {
82
+ const chunks = buildOsvQueries(packages);
83
+ const results = [];
84
+ for (const chunk of chunks) {
85
+ const res = await osvFetch(OSV_BATCH, { queries: chunk });
86
+ results.push(...(res.results ?? []));
87
+ }
88
+ const ids = [...new Set(results.flatMap((r) => (r?.vulns ?? []).map((v) => v.id)))];
89
+ const details = new Map();
90
+ await mapConcurrent(ids, 8, async (id) => {
91
+ try {
92
+ details.set(id, await osvFetch(OSV_VULN + id));
93
+ } catch {
94
+ /* a detail we cannot fetch yields a shallower finding, never a guessed one */
95
+ }
96
+ });
97
+ findings.push(...fromOsvBatch(results, packages, details, reachability));
98
+ osvOk = true;
99
+ } catch (e) {
100
+ reason = `OSV unreachable (${e.message})`;
101
+ }
102
+
103
+ // Secondary source — pnpm audit builds its tree from the lockfile and asks
104
+ // the registry; it exits non-zero WHEN VULNS EXIST, so parse stdout
105
+ // regardless of exit code. Run without --prod: dev deps ride with dev:true
106
+ // and the priority map downgrades them — never silent omission.
107
+ let pnpmOk = false;
108
+ if (manager === "pnpm" && hasBinary("pnpm")) {
109
+ try {
110
+ let stdout;
111
+ try {
112
+ stdout = execSync("pnpm audit --json", { cwd: root, maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] });
113
+ } catch (e) {
114
+ stdout = e.stdout; // non-zero exit = findings, not failure
115
+ }
116
+ const json = JSON.parse(String(stdout));
117
+ findings.push(...fromPnpmAudit(json, reachability));
118
+ pnpmOk = true;
119
+ } catch {
120
+ /* unparseable output = tool failure; OSV may still have run */
121
+ }
122
+ }
123
+
124
+ if (!osvOk && !pnpmOk)
125
+ return { tier: { ran: false, tool: null, reason: reason ?? "registry unreachable" }, findings: [] };
126
+ const base = osvOk && pnpmOk ? "osv-api+pnpm-audit" : osvOk ? "osv-api" : "pnpm-audit";
127
+ return { tier: { ran: true, tool: `${base} (${lockName})` }, findings: dedupeFindings(findings) };
128
+ }
129
+
130
+ const isEnvFile = (p) => /(^|\/)\.env(\.|$)/.test(p);
131
+ const looksBinary = (buf) => buf.includes(0);
132
+
133
+ // Tier b — secrets over tracked files only. The matched text NEVER leaves the
134
+ // process: findings carry a sha256 fingerprint of (file:line:rule:match).
135
+ function secretsTier(root) {
136
+ let files;
137
+ try {
138
+ files = execSync("git ls-files", { cwd: root, maxBuffer: 64 * 1024 * 1024 })
139
+ .toString()
140
+ .split("\n")
141
+ .filter(Boolean)
142
+ .filter((f) => !isEnvFile(f));
143
+ } catch {
144
+ return { tier: { ran: false, tool: null, reason: "not a git repository" }, findings: [] };
145
+ }
146
+
147
+ const rows = [];
148
+ for (const file of files) {
149
+ const abs = join(root, file);
150
+ let buf;
151
+ try {
152
+ if (statSync(abs).size > 1024 * 1024) continue; // generated/vendored blobs
153
+ buf = readFileSync(abs);
154
+ } catch {
155
+ continue;
156
+ }
157
+ if (looksBinary(buf)) continue;
158
+ const lines = buf.toString("utf8").split("\n");
159
+ for (let i = 0; i < lines.length; i++) {
160
+ for (const p of SECRET_PATTERNS) {
161
+ const m = lines[i].match(p.re);
162
+ if (!m) continue;
163
+ if (p.entropy && entropyOf(m[1] ?? m[0]) < p.entropy) continue;
164
+ rows.push({
165
+ rule: p.rule,
166
+ title: p.title,
167
+ severity: p.severity,
168
+ file,
169
+ line: i + 1,
170
+ fingerprint: createHash("sha256").update(`${file}:${i + 1}:${p.rule}:${m[0]}`).digest("hex").slice(0, 16),
171
+ });
172
+ }
173
+ }
174
+ }
175
+ let findings = fromRegexSecrets(rows);
176
+ let tool = "regex-fallback";
177
+
178
+ // gitleaks enhancer — merged in when present, keyed off its own report file.
179
+ if (hasBinary("gitleaks")) {
180
+ const report = join(tmpdir(), `rafa-gitleaks-${process.pid}.json`);
181
+ try {
182
+ try {
183
+ execFileSync("gitleaks", ["detect", "--source", root, "--no-banner", "--report-format", "json", "--report-path", report], { stdio: "ignore" });
184
+ } catch {
185
+ /* gitleaks exits 1 when it finds leaks — the report still writes */
186
+ }
187
+ if (existsSync(report)) {
188
+ const rowsGl = JSON.parse(readFileSync(report, "utf8"));
189
+ findings = dedupeFindings([...findings, ...fromGitleaks(Array.isArray(rowsGl) ? rowsGl.filter((r) => !isEnvFile(r.File ?? "")) : [])]);
190
+ tool = "regex-fallback+gitleaks";
191
+ }
192
+ } catch {
193
+ /* enhancer failure never fails the built-in tier */
194
+ }
195
+ }
196
+ return { tier: { ran: true, tool }, findings };
197
+ }
198
+
199
+ // Tier c — SAST via semgrep, the one optional external.
200
+ function sastTier(root) {
201
+ if (!hasBinary("semgrep"))
202
+ return { tier: { ran: false, tool: null, reason: "semgrep not installed (optional — `brew install semgrep`)" }, findings: [] };
203
+ try {
204
+ let stdout;
205
+ try {
206
+ stdout = execFileSync("semgrep", ["scan", "--config", SEMGREP_CONFIG, "--sarif", "--quiet"], { cwd: root, maxBuffer: 256 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] });
207
+ } catch (e) {
208
+ stdout = e.stdout; // semgrep exits non-zero on findings with some flags
209
+ }
210
+ const findings = fromSemgrepSarif(JSON.parse(String(stdout)));
211
+ return { tier: { ran: true, tool: "semgrep" }, findings };
212
+ } catch (e) {
213
+ return { tier: { ran: false, tool: "semgrep", reason: `semgrep failed (${e.message})` }, findings: [] };
214
+ }
215
+ }
216
+
217
+ const SEV_ICON = { critical: "✗", high: "✗", moderate: "~", low: "·", info: "·" };
218
+
219
+ function renderHuman(report) {
220
+ const lines = [`rafa audit — ${report.summary.total} finding(s)`, ""];
221
+ for (const [t, s] of Object.entries(report.tiers)) {
222
+ lines.push(s.ran ? ` ✓ ${t} ran (${s.tool})` : ` ○ ${t} did not run — ${s.reason}`);
223
+ }
224
+ lines.push("", ` critical ${report.summary.critical} · high ${report.summary.high} · moderate ${report.summary.moderate} · low ${report.summary.low} · info ${report.summary.info}`, "");
225
+ for (const f of report.findings.slice(0, 20)) {
226
+ const where = f.package ? `${f.package.name}@${f.package.version}${f.chain?.direct ? "" : " (transitive)"}${f.dev ? " [dev]" : ""}` : f.location ? `${f.location.file}:${f.location.line ?? "?"}` : "";
227
+ lines.push(` ${SEV_ICON[f.severity]} [${f.severity}] ${f.title}${where ? ` — ${where}` : ""}${f.fixedIn ? ` → fixed in ${f.fixedIn}` : ""}`);
228
+ }
229
+ if (report.findings.length > 20) lines.push(` … ${report.findings.length - 20} more in security-audit.json`);
230
+ return lines.join("\n");
231
+ }
232
+
233
+ function renderRecord(report) {
234
+ const md = [
235
+ "---",
236
+ 'type: "Security Audit"',
237
+ `title: Security audit — ${report.summary.total} finding(s)`,
238
+ `description: "critical ${report.summary.critical} · high ${report.summary.high} · moderate ${report.summary.moderate} · low ${report.summary.low}"`,
239
+ "---",
240
+ "",
241
+ "# Security audit",
242
+ "",
243
+ `Generated by \`rafa audit\` (${AUDIT_SCHEMA}). Machine record: \`security-audit.json\`.`,
244
+ "",
245
+ "| severity | tier | finding | subject | fixed in |",
246
+ "|---|---|---|---|---|",
247
+ ...report.findings.map((f) => {
248
+ const subject = f.package ? `${f.package.name}@${f.package.version}` : f.location ? `${f.location.file}:${f.location.line ?? "?"}` : "—";
249
+ return `| ${f.severity} | ${f.tier} | ${f.vulnId ?? f.ruleId ?? f.id} | ${subject} | ${f.fixedIn ?? "—"} |`;
250
+ }),
251
+ "",
252
+ ];
253
+ return md.join("\n");
254
+ }
255
+
256
+ export async function runAudit(args = []) {
257
+ const rootArg = args.find((a) => a.startsWith("--root="));
258
+ const root = rootArg ? rootArg.slice("--root=".length) : process.cwd();
259
+ const json = args.includes("--json");
260
+
261
+ try {
262
+ const [dep, sec, sast] = [await dependencyTier(root), secretsTier(root), sastTier(root)];
263
+ const report = buildReport({
264
+ at: new Date().toISOString(),
265
+ ref: null,
266
+ tiers: { dependency: dep.tier, secrets: sec.tier, sast: sast.tier },
267
+ findings: dedupeFindings([...dep.findings, ...sec.findings, ...sast.findings]),
268
+ });
269
+
270
+ // Dual record beside the ledger (the verify-citations convention) — only
271
+ // when a .rafa/ bundle exists; a bare repo still gets the stdout report.
272
+ const improveDir = join(root, ".rafa", "improve");
273
+ if (existsSync(join(root, ".rafa"))) {
274
+ mkdirSync(improveDir, { recursive: true });
275
+ writeFileSync(join(improveDir, "security-audit.json"), JSON.stringify(report, null, 2) + "\n");
276
+ writeFileSync(join(improveDir, "security-audit.md"), renderRecord(report));
277
+ }
278
+
279
+ console.log(json ? JSON.stringify(report, null, 2) : renderHuman(report));
280
+ return report.summary.total > 0 ? 1 : 0;
281
+ } catch (e) {
282
+ console.error(`✗ rafa audit: ${e.message}`);
283
+ return 2;
284
+ }
285
+ }
286
+
287
+ export default async function audit(args = []) {
288
+ process.exit(await runAudit(args));
289
+ }
package/lib/blueprint.mjs CHANGED
@@ -46,6 +46,7 @@ export const BLUEPRINT = {
46
46
  ".claude/skills/rafa-leverage",
47
47
  ".claude/skills/rafa-sage",
48
48
  ".claude/skills/rafa-okf",
49
+ ".claude/skills/rafa-security",
49
50
  ],
50
51
  lockstep: [".claude/rafa/contract.md"],
51
52
  // The M5 sensor hooks — deterministic protocol machinery (like the contract):
@@ -77,7 +77,7 @@ export function ensureBrainRepo(cwd, { requireRemote = true } = {}) {
77
77
  // (not knowledge). MERGED line-by-line so clones ignored under an older CLI
78
78
  // still gain new exclusions.
79
79
  const ignore = join(rafaDir, ".gitignore");
80
- const IGNORES = ["hydration.json", "*.theirs.md", "distill-incoming/", "distill-verdicts.json", "dirty.jsonl", "reflex.jsonl", "sensor-errors.jsonl", "benchmark.demo.json", "session-facts.json", "loop-events-tail.json"];
80
+ const IGNORES = ["hydration.json", "*.theirs.md", "distill-incoming/", "distill-verdicts.json", "dirty.jsonl", "reflex.jsonl", "sensor-errors.jsonl", "benchmark.demo.json", "session-facts.json", "loop-events-tail.json", "review.json"];
81
81
  const cur = existsSync(ignore) ? readFileSync(ignore, "utf8") : "";
82
82
  const have = new Set(cur.split("\n").map((l) => l.trim()).filter(Boolean));
83
83
  const missing = IGNORES.filter((l) => !have.has(l));
@@ -124,6 +124,99 @@ export default async function checkpoint() {
124
124
  }
125
125
  }
126
126
 
127
+ // MANDATORY CAPTURE — converge reported improvement flips into REAL ledger
128
+ // files (owner 2026-07-26 r4: "if the brain did not capture what happened
129
+ // during the session, it's a failure"). A session that called
130
+ // report_improvement_status but never hydrated the row would leave the flip
131
+ // stranded as an overlay; this step closes that gap MECHANICALLY on every
132
+ // checkpoint (and the pre-push hook runs checkpoint, so every push too):
133
+ // hydrate the canonical row, flip the status line, and the file rides THIS
134
+ // branch's working set. Best-effort per row — an unreachable platform never
135
+ // blocks the checkpoint (the reconciler's merge-time overlay net is the
136
+ // final backstop).
137
+ try {
138
+ const res = await callTool(ROOT, "list_improvements", {});
139
+ const rows = res?.data?.improvements ?? res?.improvements ?? [];
140
+ const mismatched = rows.filter(
141
+ (r) => r?.reported?.status && r.reported.status !== r.status,
142
+ );
143
+ if (mismatched.length) {
144
+ const { materializeImprovement } = await import("./working-set.mjs");
145
+ for (const r of mismatched) {
146
+ try {
147
+ const rel = `improve/improvements/${r.id}.md`;
148
+ const abs = join(ROOT, ".rafa", rel);
149
+ if (!existsSync(abs)) {
150
+ const payload = await callTool(ROOT, "get_improvement", { id: r.id });
151
+ materializeImprovement(ROOT, payload, payload.envelope?.brainForSha ?? null);
152
+ }
153
+ const cur = readFileSync(abs, "utf8");
154
+ if (!new RegExp(`^status: ${r.reported.status}\\s*$`, "m").test(cur)) {
155
+ // The status flip is an EDIT vs the hydration base → working set.
156
+ writeFileSync(abs, cur.replace(/^status: .*$/m, `status: ${r.reported.status}`));
157
+ console.log(`✓ converged reported ${r.reported.status} → .rafa/${rel} (mandatory capture)`);
158
+ }
159
+ } catch (e) {
160
+ console.log(`⚠ could not converge reported flip for "${r.id}" — ${e instanceof Error ? e.message : e} (the merge-time overlay net will catch it)`);
161
+ }
162
+ }
163
+ }
164
+ } catch {
165
+ /* unprovisioned/offline — the merge-time overlay net stands */
166
+ }
167
+
168
+ // COMPULSORY HYDRATION of touched-code citers (owner 2026-07-26 r5: "if some
169
+ // file is touched, it should hydrate compulsorily so edits happen DURING
170
+ // development"). The dirty queue knows which code files this session edited;
171
+ // the local manifest knows which notes cite them. Every citing note not
172
+ // already local is hydrated NOW — present for the session to edit, so the
173
+ // affected-note update happens in development, not discovered at merge (the
174
+ // merge-time affected lane stays the backstop). Unedited hydrations remain
175
+ // disposable cache — presence, never noise.
176
+ try {
177
+ const { readDirty, resolveCiters } = await import("./dirty.mjs");
178
+ const { files: dirtyFiles } = readDirty(ROOT);
179
+ // SENSOR-INDEPENDENT (r5 follow-up): the dirty queue only sees agent-session
180
+ // edits (a Claude Code hook) — a dev committing manually never populates it.
181
+ // Union with git's own truth: every file this BRANCH changed vs the trunk.
182
+ // Checkpoint runs from the git pre-push hook, which fires on manual pushes
183
+ // too — so manual workflows hydrate their citers all the same.
184
+ const touched = new Set(dirtyFiles.map((f) => f.path ?? f[0] ?? f));
185
+ try {
186
+ const { trunkBranchOf } = await import("./stamp.mjs");
187
+ const trunk = trunkBranchOf(ROOT);
188
+ for (const f of execSync(`git diff --name-only ${trunk}...HEAD`, {
189
+ cwd: ROOT,
190
+ encoding: "utf8",
191
+ stdio: ["ignore", "pipe", "ignore"],
192
+ })
193
+ .split("\n")
194
+ .filter(Boolean))
195
+ touched.add(f);
196
+ } catch {
197
+ /* no trunk ref locally (shallow clone) — the sensor queue alone serves */
198
+ }
199
+ const citers = touched.size ? resolveCiters(ROOT, [...touched]) : null;
200
+ if (citers?.length) {
201
+ const { materializeImprovement, materializeNote } = await import("./working-set.mjs");
202
+ for (const n of citers) {
203
+ try {
204
+ if (!n.path || existsSync(join(ROOT, ".rafa", n.path))) continue; // already local
205
+ const kind = n.path.includes("improvements/") ? "improvement" : n.path.includes("playbooks/") ? "playbook" : "rule";
206
+ const payload = await callTool(ROOT, kind === "improvement" ? "get_improvement" : kind === "playbook" ? "get_playbook" : "get_rule", { id: n.id });
207
+ const base = payload.envelope?.brainForSha ?? null;
208
+ if (kind === "improvement") materializeImprovement(ROOT, payload, base);
209
+ else materializeNote(ROOT, kind, payload, base);
210
+ console.log(`✓ hydrated ${kind} "${n.id}" — cites code this session touched (${n.files.join(", ")}); update it as part of the work`);
211
+ } catch (e) {
212
+ console.log(`⚠ could not hydrate citer "${n.id}" — ${e instanceof Error ? e.message : e} (merge-time affected lane will re-ground it)`);
213
+ }
214
+ }
215
+ }
216
+ } catch {
217
+ /* no dirty queue / no manifest / offline — the merge-time affected lane stands */
218
+ }
219
+
127
220
  // The commit this capture is grounded against (contract: capturedAtSha —
128
221
  // absent = unknown grounding). One HEAD per checkpoint push; the merge-time
129
222
  // ancestry sweep keys on it, so a missing sha only narrows that sweep,
@@ -275,7 +275,7 @@ export function mergeRangeSweep({ changedFiles, orgNotes, cwd = process.cwd() })
275
275
 
276
276
  // ── failure classification (case 3) ─────────────────────────────────────────────
277
277
  // Deterministic (config / gate rejection / schema): retrying only burns spend →
278
- // reconcile_report failure class "deterministic" → needs-attention immediately.
278
+ // terminal-report failure class "deterministic" → needs-attention immediately.
279
279
  // Transient (network / boot / OOM / clone / timeout): backoff retry while a later
280
280
  // branch proceeds. An error may carry an explicit `errorClass`; else heuristics.
281
281
  const TRANSIENT_RE =
@@ -288,7 +288,7 @@ export function classifyError(err) {
288
288
  }
289
289
 
290
290
  // Tagged-error helpers so the loop can raise a failure of a known class and the
291
- // reconcile_report path carries it verbatim (never a re-guess at the boundary).
291
+ // terminal-report path (reconciliations.recordResult) carries it verbatim (never a re-guess at the boundary).
292
292
  export function deterministicFailure(message) {
293
293
  const e = new Error(message);
294
294
  e.errorClass = "deterministic";
@@ -314,7 +314,7 @@ export function outcomeRow({ candidate, outcome, detail, reconciliationId, super
314
314
  };
315
315
  }
316
316
 
317
- // The reconcile_report `delta` shape (knowledgeGraph.deltaValidator): four buckets
317
+ // The terminal report’s `delta` shape (knowledgeGraph.deltaValidator): four buckets
318
318
  // of { noteId, cites }. Built from the run's dispositions so the node the pointer
319
319
  // advance mints carries exactly what this run refined. `folded` losers do not
320
320
  // enter the delta (they were superseded, not banked) but ARE in the outcomes.
@@ -339,7 +339,7 @@ function toWireCites(cites = []) {
339
339
  }));
340
340
  }
341
341
 
342
- // Per-branch outcome rows for reconcile_report (perBranchOutcomeValidator:
342
+ // Per-branch outcome rows for the terminal report (perBranchOutcomeValidator:
343
343
  // { branch, capturedAtSha, verdict }). One row per source branch, the verdict a
344
344
  // short mechanical summary of that branch's fate in the fold.
345
345
  export function perBranchOutcomes(branchSets, outcomeRows) {
package/lib/doctor.mjs CHANGED
@@ -7,7 +7,7 @@
7
7
  // command exists to kill.
8
8
 
9
9
  import { execSync } from "node:child_process";
10
- import { existsSync } from "node:fs";
10
+ import { existsSync, readFileSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
  import { callTool, resolveMcp } from "./mcp-client.mjs";
13
13
  import { collectSensorHealth } from "./sensor-health.mjs";
@@ -97,6 +97,59 @@ export async function runDoctor(ROOT = process.cwd()) {
97
97
  }
98
98
  }
99
99
 
100
+ // 3b · security tools — the audit engine's core (lockfile parsers + OSV +
101
+ // secrets ruleset) is BUILT IN and always on; gitleaks/semgrep are optional
102
+ // enhancers. Detection only — rafa never installs binaries.
103
+ {
104
+ const { hasBinary } = await import("./audit.mjs");
105
+ const { LOCKFILE_NAMES } = await import("./audit/core.mjs");
106
+ console.log("• security tools (rafa audit)");
107
+ ok("core engine built in (dep CVEs via OSV · secrets ruleset)");
108
+
109
+ // Lockfile coverage (owner r3): NO lockfile = the dependency tier cannot
110
+ // run — that is a real coverage hole, so it FAILS doctor with the exact
111
+ // command to close it (resolved per the repo's own package manager).
112
+ if (existsSync(join(ROOT, "package.json"))) {
113
+ const lock = LOCKFILE_NAMES.find((n) => existsSync(join(ROOT, n)));
114
+ if (lock) {
115
+ ok(`lockfile present (${lock}) — dependency scanning covered`);
116
+ } else if (existsSync(join(ROOT, "bun.lockb"))) {
117
+ bad(
118
+ "bun.lockb is binary — dependency scanning NOT covered",
119
+ "generate the text lockfile: `bun install --save-text-lockfile`",
120
+ );
121
+ } else {
122
+ const LOCK_HELP = {
123
+ pnpm: "pnpm install --lockfile-only",
124
+ npm: "npm install --package-lock-only",
125
+ yarn: "yarn install --mode update-lockfile",
126
+ bun: "bun install --save-text-lockfile",
127
+ };
128
+ let manager = null;
129
+ try {
130
+ const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
131
+ manager = String(pkg.packageManager ?? "").split("@")[0] || null;
132
+ } catch {
133
+ /* unreadable package.json — fall through to the full menu */
134
+ }
135
+ bad(
136
+ "no lockfile — dependency scanning NOT covered until one exists",
137
+ manager && LOCK_HELP[manager]
138
+ ? `generate it: \`${LOCK_HELP[manager]}\` (then commit the lockfile)`
139
+ : `generate one for your package manager, then commit it: pnpm \`${LOCK_HELP.pnpm}\` · npm \`${LOCK_HELP.npm}\` · yarn \`${LOCK_HELP.yarn}\` · bun \`${LOCK_HELP.bun}\``,
140
+ );
141
+ }
142
+ }
143
+
144
+ for (const [name, hint] of [
145
+ ["gitleaks", "brew install gitleaks"],
146
+ ["semgrep", "brew install semgrep"],
147
+ ]) {
148
+ if (hasBinary(name)) ok(`${name} present (optional enhancer)`);
149
+ else console.log(` · ${name} not installed — optional enhancer (\`${hint}\`)`);
150
+ }
151
+ }
152
+
100
153
  // 4 · the round-trip — the heartbeat must LAND, not just send. A recorded
101
154
  // response proves key + url + repo scoping + the platform handler, end to end.
102
155
  console.log("• platform round-trip");
@@ -268,22 +268,26 @@ export function runCompile(argv = []) {
268
268
  "leverage",
269
269
  "required · { impact, effort } ∈ low|medium|high",
270
270
  );
271
+ // `category` is canonical; `lens` is the pre-2026-07-26 name, accepted
272
+ // as a deprecated alias so already-authored ledgers keep compiling.
273
+ const category = reqEnum(
274
+ { category: data.category ?? data.lens },
275
+ "category",
276
+ [
277
+ "security",
278
+ "correctness",
279
+ "performance",
280
+ "architecture",
281
+ "product",
282
+ "ops",
283
+ ],
284
+ path,
285
+ );
271
286
  out.push({
272
287
  id,
273
288
  priority: reqEnum(data, "priority", ["P0", "P1", "P2", "P3"], path),
274
- lens: reqEnum(
275
- data,
276
- "lens",
277
- [
278
- "security",
279
- "correctness",
280
- "performance",
281
- "architecture",
282
- "product",
283
- "ops",
284
- ],
285
- path,
286
- ),
289
+ category,
290
+ lens: category, // transitional dual-key — dropped with the backfill arc
287
291
  status: reqEnum(
288
292
  data,
289
293
  "status",
package/lib/guard.mjs ADDED
@@ -0,0 +1,107 @@
1
+ // rafa guard — the capture-integrity gate (owner 2026-07-26 r5: whole-brain
2
+ // capture is MANDATORY; "we need to guard post-commit and pre-push").
3
+ //
4
+ // rafa guard --pre-push verify every OUTGOING code commit has its 1-1
5
+ // brain mirror commit; a missing mirror SELF-HEALS
6
+ // (the brain-commit worker re-runs); still missing
7
+ // → exit 1 and the push BLOCKS. Git-local — no
8
+ // network — so a failure is genuine capture loss,
9
+ // never flakiness.
10
+ //
11
+ // Exit: 0 capture intact (or nothing to guard) · 1 capture missing after
12
+ // self-heal. Trunk pushes exit 0 (the brain trunk has one writer — the
13
+ // reconciler; there is no mirror to demand).
14
+
15
+ import { execSync } from "node:child_process";
16
+ import { existsSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { trunkBranchOf, readStamp } from "./stamp.mjs";
19
+
20
+ const sh = (cmd, cwd) =>
21
+ execSync(cmd, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
22
+
23
+ export function runGuard(args = []) {
24
+ const ROOT = process.cwd();
25
+ if (!args.includes("--pre-push")) {
26
+ console.log("usage: rafa guard --pre-push (capture-integrity gate; hook-invoked)");
27
+ return 0;
28
+ }
29
+ const rafaDir = join(ROOT, ".rafa");
30
+ if (!existsSync(join(rafaDir, ".git"))) return 0; // no brain repo here — nothing to guard
31
+
32
+ let branch = "";
33
+ try {
34
+ branch = sh("git rev-parse --abbrev-ref HEAD", ROOT);
35
+ } catch {
36
+ return 0;
37
+ }
38
+ const trunk = trunkBranchOf(ROOT);
39
+ const configured = typeof readStamp(ROOT).prodBranch === "string";
40
+ if (branch === trunk || (!configured && (branch === "main" || branch === "master"))) return 0;
41
+
42
+ // Outgoing commits: upstream..HEAD when an upstream exists, else every local
43
+ // commit on this branch not on the trunk (first push of a new branch).
44
+ let outgoing = [];
45
+ try {
46
+ outgoing = sh("git rev-list @{u}..HEAD", ROOT).split("\n").filter(Boolean);
47
+ } catch {
48
+ try {
49
+ outgoing = sh(`git rev-list ${trunk}..HEAD`, ROOT).split("\n").filter(Boolean);
50
+ } catch {
51
+ outgoing = [];
52
+ }
53
+ }
54
+ if (outgoing.length === 0) return 0;
55
+
56
+ // The brain branch's mirror trailers — `code-commit: <sha>` — are the 1-1
57
+ // capture record (capture-engine P1). Missing sha = a commit whose brain
58
+ // state was never mirrored.
59
+ const mirrored = () => {
60
+ try {
61
+ return new Set(
62
+ sh(`git log --format=%B ${branch}`, rafaDir)
63
+ .split("\n")
64
+ .map((l) => l.match(/^code-commit:\s*([0-9a-f]{7,40})/)?.[1])
65
+ .filter(Boolean),
66
+ );
67
+ } catch {
68
+ return new Set(); // brain branch missing entirely — every commit is unmirrored
69
+ }
70
+ };
71
+
72
+ const missing = (set) => outgoing.filter((sha) => ![...set].some((m) => sha.startsWith(m) || m.startsWith(sha)));
73
+
74
+ let gone = missing(mirrored());
75
+ if (gone.length > 0) {
76
+ // SELF-HEAL: the brain-commit worker is idempotent and tail-aware — one
77
+ // re-run mirrors any stray session state onto the branch.
78
+ console.log(`rafa guard · ${gone.length} outgoing commit(s) missing a brain mirror — self-healing …`);
79
+ try {
80
+ execSync(`node ${JSON.stringify(join(ROOT, ".claude", "rafa", "hooks", "brain-commit.mjs"))}`, {
81
+ cwd: ROOT,
82
+ stdio: ["ignore", "ignore", "ignore"],
83
+ });
84
+ } catch {
85
+ /* verified below — the re-check is the truth, not this exit code */
86
+ }
87
+ gone = missing(mirrored());
88
+ }
89
+
90
+ if (gone.length > 0) {
91
+ console.error(
92
+ `✗ rafa guard: capture INCOMPLETE — ${gone.length} outgoing commit(s) have no brain mirror:\n` +
93
+ gone.map((s) => ` ${s.slice(0, 12)}`).join("\n") +
94
+ "\n Whole-brain capture is mandatory (decision 2026-07-26 r5): a code commit whose brain" +
95
+ "\n state was never mirrored is capture loss the reconciler cannot recover." +
96
+ "\n Fix: `node .claude/rafa/hooks/brain-commit.mjs` (or re-commit), then push again." +
97
+ "\n Escape hatch (capture loss accepted, on the record): RAFA_HOOKS_DISABLED=1 git push",
98
+ );
99
+ return 1;
100
+ }
101
+ console.log(`rafa guard · capture intact (${outgoing.length} outgoing commit(s), all mirrored)`);
102
+ return 0;
103
+ }
104
+
105
+ export default function guard(args = []) {
106
+ process.exit(runGuard(args));
107
+ }
package/lib/init.mjs CHANGED
@@ -183,8 +183,9 @@ export default async function init(args) {
183
183
  );
184
184
  }
185
185
 
186
- // Reconciliation runs on the PLATFORM (App-only era, owner call 2026-07-18):
187
- // merged PRs are detected and distilled by the platform's own sandbox — no
186
+ // Reconciliation runs on the PLATFORM (App-only era, owner call 2026-07-18;
187
+ // agent-native since 2026-07-21): merged PRs are detected and reconciled by
188
+ // the server-side LangGraph agent — no
188
189
  // CI workflow, no repo secrets, nothing to wire here. The org-CI adapter
189
190
  // stays available as an explicit opt-out for teams that want the compute in
190
191
  // their own CI: `rafa ci-setup`. Init no longer asks — one fewer question,