@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/CHANGELOG.md +43 -0
- package/bin/rafa.mjs +14 -0
- package/blueprint/.claude/agents/atlas.md +2 -2
- package/blueprint/.claude/agents/bloom.md +3 -2
- package/blueprint/.claude/commands/rafa.md +1 -1
- package/blueprint/.claude/rafa/contract.md +10 -4
- package/blueprint/.claude/rafa/hooks/post-commit +8 -1
- package/blueprint/.claude/rafa/hooks/pre-push +6 -0
- package/blueprint/.claude/skills/rafa-build/SKILL.md +5 -1
- package/blueprint/.claude/skills/rafa-improve/SKILL.md +17 -6
- package/blueprint/.claude/skills/rafa-okf/SKILL.md +1 -1
- package/blueprint/.claude/skills/rafa-plan/SKILL.md +14 -1
- package/blueprint/.claude/skills/rafa-scan/SKILL.md +9 -1
- package/blueprint/.claude/skills/rafa-security/SKILL.md +96 -0
- package/lib/audit/core.mjs +627 -0
- package/lib/audit.mjs +289 -0
- package/lib/blueprint.mjs +1 -0
- package/lib/brain-repo.mjs +1 -1
- package/lib/checkpoint.mjs +93 -0
- package/lib/distiller/doctrine.mjs +4 -4
- package/lib/doctor.mjs +54 -1
- package/lib/gate/compile.mjs +17 -13
- package/lib/guard.mjs +107 -0
- package/lib/init.mjs +3 -2
- package/lib/manifest.mjs +2 -1
- package/lib/mcp-client.mjs +3 -2
- package/lib/releases.mjs +47 -0
- package/lib/working-set.mjs +3 -2
- package/package.json +1 -1
- package/skills-bundle/skills-manifest.json +1 -1
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):
|
package/lib/brain-repo.mjs
CHANGED
|
@@ -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));
|
package/lib/checkpoint.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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");
|
package/lib/gate/compile.mjs
CHANGED
|
@@ -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
|
-
|
|
275
|
-
|
|
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
|
|
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,
|