@rafinery/cli 0.10.0 → 0.12.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 +49 -0
- package/bin/rafa.mjs +10 -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 +27 -4
- package/blueprint/.claude/skills/rafa-build/SKILL.md +5 -1
- package/blueprint/.claude/skills/rafa-commit/SKILL.md +22 -0
- package/blueprint/.claude/skills/rafa-distill/SKILL.md +37 -4
- 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 +488 -0
- package/lib/audit.mjs +279 -0
- package/lib/blueprint.mjs +1 -0
- package/lib/checkpoint.mjs +14 -5
- package/lib/distill.mjs +56 -3
- package/lib/distiller/doctrine.mjs +4 -4
- package/lib/distiller/schema-ladder.mjs +186 -0
- package/lib/doctor.mjs +16 -0
- package/lib/gate/compile.mjs +17 -13
- package/lib/init.mjs +3 -2
- package/lib/manifest.mjs +2 -1
- package/lib/mcp-client.mjs +10 -2
- package/lib/push.mjs +9 -3
- package/lib/releases.mjs +27 -0
- package/lib/stamp.mjs +24 -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,279 @@
|
|
|
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
|
+
SECRET_PATTERNS,
|
|
22
|
+
buildOsvQueries,
|
|
23
|
+
buildReport,
|
|
24
|
+
classifyReachability,
|
|
25
|
+
dedupeFindings,
|
|
26
|
+
entropyOf,
|
|
27
|
+
fromGitleaks,
|
|
28
|
+
fromOsvBatch,
|
|
29
|
+
fromPnpmAudit,
|
|
30
|
+
fromRegexSecrets,
|
|
31
|
+
fromSemgrepSarif,
|
|
32
|
+
mapConcurrent,
|
|
33
|
+
parsePnpmLock,
|
|
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; `pnpm audit` secondary.
|
|
60
|
+
async function dependencyTier(root) {
|
|
61
|
+
const lockPath = join(root, "pnpm-lock.yaml");
|
|
62
|
+
if (!existsSync(lockPath))
|
|
63
|
+
return { tier: { ran: false, tool: null, reason: "no pnpm-lock.yaml here" }, findings: [] };
|
|
64
|
+
|
|
65
|
+
const parsed = parsePnpmLock(readFileSync(lockPath, "utf8"));
|
|
66
|
+
const reachability = classifyReachability(parsed);
|
|
67
|
+
const findings = [];
|
|
68
|
+
let osvOk = false;
|
|
69
|
+
let reason = null;
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const chunks = buildOsvQueries(parsed.packages);
|
|
73
|
+
const results = [];
|
|
74
|
+
for (const chunk of chunks) {
|
|
75
|
+
const res = await osvFetch(OSV_BATCH, { queries: chunk });
|
|
76
|
+
results.push(...(res.results ?? []));
|
|
77
|
+
}
|
|
78
|
+
const ids = [...new Set(results.flatMap((r) => (r?.vulns ?? []).map((v) => v.id)))];
|
|
79
|
+
const details = new Map();
|
|
80
|
+
await mapConcurrent(ids, 8, async (id) => {
|
|
81
|
+
try {
|
|
82
|
+
details.set(id, await osvFetch(OSV_VULN + id));
|
|
83
|
+
} catch {
|
|
84
|
+
/* a detail we cannot fetch yields a shallower finding, never a guessed one */
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
findings.push(...fromOsvBatch(results, parsed.packages, details, reachability));
|
|
88
|
+
osvOk = true;
|
|
89
|
+
} catch (e) {
|
|
90
|
+
reason = `OSV unreachable (${e.message})`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Secondary source — pnpm audit builds its tree from the lockfile and asks
|
|
94
|
+
// the registry; it exits non-zero WHEN VULNS EXIST, so parse stdout
|
|
95
|
+
// regardless of exit code. Run without --prod: dev deps ride with dev:true
|
|
96
|
+
// and the priority map downgrades them — never silent omission.
|
|
97
|
+
let pnpmOk = false;
|
|
98
|
+
if (hasBinary("pnpm")) {
|
|
99
|
+
try {
|
|
100
|
+
let stdout;
|
|
101
|
+
try {
|
|
102
|
+
stdout = execSync("pnpm audit --json", { cwd: root, maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] });
|
|
103
|
+
} catch (e) {
|
|
104
|
+
stdout = e.stdout; // non-zero exit = findings, not failure
|
|
105
|
+
}
|
|
106
|
+
const json = JSON.parse(String(stdout));
|
|
107
|
+
findings.push(...fromPnpmAudit(json, reachability));
|
|
108
|
+
pnpmOk = true;
|
|
109
|
+
} catch {
|
|
110
|
+
/* unparseable output = tool failure; OSV may still have run */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!osvOk && !pnpmOk)
|
|
115
|
+
return { tier: { ran: false, tool: null, reason: reason ?? "registry unreachable" }, findings: [] };
|
|
116
|
+
const tool = osvOk && pnpmOk ? "osv-api+pnpm-audit" : osvOk ? "osv-api" : "pnpm-audit";
|
|
117
|
+
return { tier: { ran: true, tool }, findings: dedupeFindings(findings) };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const isEnvFile = (p) => /(^|\/)\.env(\.|$)/.test(p);
|
|
121
|
+
const looksBinary = (buf) => buf.includes(0);
|
|
122
|
+
|
|
123
|
+
// Tier b — secrets over tracked files only. The matched text NEVER leaves the
|
|
124
|
+
// process: findings carry a sha256 fingerprint of (file:line:rule:match).
|
|
125
|
+
function secretsTier(root) {
|
|
126
|
+
let files;
|
|
127
|
+
try {
|
|
128
|
+
files = execSync("git ls-files", { cwd: root, maxBuffer: 64 * 1024 * 1024 })
|
|
129
|
+
.toString()
|
|
130
|
+
.split("\n")
|
|
131
|
+
.filter(Boolean)
|
|
132
|
+
.filter((f) => !isEnvFile(f));
|
|
133
|
+
} catch {
|
|
134
|
+
return { tier: { ran: false, tool: null, reason: "not a git repository" }, findings: [] };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const rows = [];
|
|
138
|
+
for (const file of files) {
|
|
139
|
+
const abs = join(root, file);
|
|
140
|
+
let buf;
|
|
141
|
+
try {
|
|
142
|
+
if (statSync(abs).size > 1024 * 1024) continue; // generated/vendored blobs
|
|
143
|
+
buf = readFileSync(abs);
|
|
144
|
+
} catch {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (looksBinary(buf)) continue;
|
|
148
|
+
const lines = buf.toString("utf8").split("\n");
|
|
149
|
+
for (let i = 0; i < lines.length; i++) {
|
|
150
|
+
for (const p of SECRET_PATTERNS) {
|
|
151
|
+
const m = lines[i].match(p.re);
|
|
152
|
+
if (!m) continue;
|
|
153
|
+
if (p.entropy && entropyOf(m[1] ?? m[0]) < p.entropy) continue;
|
|
154
|
+
rows.push({
|
|
155
|
+
rule: p.rule,
|
|
156
|
+
title: p.title,
|
|
157
|
+
severity: p.severity,
|
|
158
|
+
file,
|
|
159
|
+
line: i + 1,
|
|
160
|
+
fingerprint: createHash("sha256").update(`${file}:${i + 1}:${p.rule}:${m[0]}`).digest("hex").slice(0, 16),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
let findings = fromRegexSecrets(rows);
|
|
166
|
+
let tool = "regex-fallback";
|
|
167
|
+
|
|
168
|
+
// gitleaks enhancer — merged in when present, keyed off its own report file.
|
|
169
|
+
if (hasBinary("gitleaks")) {
|
|
170
|
+
const report = join(tmpdir(), `rafa-gitleaks-${process.pid}.json`);
|
|
171
|
+
try {
|
|
172
|
+
try {
|
|
173
|
+
execFileSync("gitleaks", ["detect", "--source", root, "--no-banner", "--report-format", "json", "--report-path", report], { stdio: "ignore" });
|
|
174
|
+
} catch {
|
|
175
|
+
/* gitleaks exits 1 when it finds leaks — the report still writes */
|
|
176
|
+
}
|
|
177
|
+
if (existsSync(report)) {
|
|
178
|
+
const rowsGl = JSON.parse(readFileSync(report, "utf8"));
|
|
179
|
+
findings = dedupeFindings([...findings, ...fromGitleaks(Array.isArray(rowsGl) ? rowsGl.filter((r) => !isEnvFile(r.File ?? "")) : [])]);
|
|
180
|
+
tool = "regex-fallback+gitleaks";
|
|
181
|
+
}
|
|
182
|
+
} catch {
|
|
183
|
+
/* enhancer failure never fails the built-in tier */
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return { tier: { ran: true, tool }, findings };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Tier c — SAST via semgrep, the one optional external.
|
|
190
|
+
function sastTier(root) {
|
|
191
|
+
if (!hasBinary("semgrep"))
|
|
192
|
+
return { tier: { ran: false, tool: null, reason: "semgrep not installed (optional — `brew install semgrep`)" }, findings: [] };
|
|
193
|
+
try {
|
|
194
|
+
let stdout;
|
|
195
|
+
try {
|
|
196
|
+
stdout = execFileSync("semgrep", ["scan", "--config", SEMGREP_CONFIG, "--sarif", "--quiet"], { cwd: root, maxBuffer: 256 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] });
|
|
197
|
+
} catch (e) {
|
|
198
|
+
stdout = e.stdout; // semgrep exits non-zero on findings with some flags
|
|
199
|
+
}
|
|
200
|
+
const findings = fromSemgrepSarif(JSON.parse(String(stdout)));
|
|
201
|
+
return { tier: { ran: true, tool: "semgrep" }, findings };
|
|
202
|
+
} catch (e) {
|
|
203
|
+
return { tier: { ran: false, tool: "semgrep", reason: `semgrep failed (${e.message})` }, findings: [] };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const SEV_ICON = { critical: "✗", high: "✗", moderate: "~", low: "·", info: "·" };
|
|
208
|
+
|
|
209
|
+
function renderHuman(report) {
|
|
210
|
+
const lines = [`rafa audit — ${report.summary.total} finding(s)`, ""];
|
|
211
|
+
for (const [t, s] of Object.entries(report.tiers)) {
|
|
212
|
+
lines.push(s.ran ? ` ✓ ${t} ran (${s.tool})` : ` ○ ${t} did not run — ${s.reason}`);
|
|
213
|
+
}
|
|
214
|
+
lines.push("", ` critical ${report.summary.critical} · high ${report.summary.high} · moderate ${report.summary.moderate} · low ${report.summary.low} · info ${report.summary.info}`, "");
|
|
215
|
+
for (const f of report.findings.slice(0, 20)) {
|
|
216
|
+
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 ?? "?"}` : "";
|
|
217
|
+
lines.push(` ${SEV_ICON[f.severity]} [${f.severity}] ${f.title}${where ? ` — ${where}` : ""}${f.fixedIn ? ` → fixed in ${f.fixedIn}` : ""}`);
|
|
218
|
+
}
|
|
219
|
+
if (report.findings.length > 20) lines.push(` … ${report.findings.length - 20} more in security-audit.json`);
|
|
220
|
+
return lines.join("\n");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function renderRecord(report) {
|
|
224
|
+
const md = [
|
|
225
|
+
"---",
|
|
226
|
+
'type: "Security Audit"',
|
|
227
|
+
`title: Security audit — ${report.summary.total} finding(s)`,
|
|
228
|
+
`description: "critical ${report.summary.critical} · high ${report.summary.high} · moderate ${report.summary.moderate} · low ${report.summary.low}"`,
|
|
229
|
+
"---",
|
|
230
|
+
"",
|
|
231
|
+
"# Security audit",
|
|
232
|
+
"",
|
|
233
|
+
`Generated by \`rafa audit\` (${AUDIT_SCHEMA}). Machine record: \`security-audit.json\`.`,
|
|
234
|
+
"",
|
|
235
|
+
"| severity | tier | finding | subject | fixed in |",
|
|
236
|
+
"|---|---|---|---|---|",
|
|
237
|
+
...report.findings.map((f) => {
|
|
238
|
+
const subject = f.package ? `${f.package.name}@${f.package.version}` : f.location ? `${f.location.file}:${f.location.line ?? "?"}` : "—";
|
|
239
|
+
return `| ${f.severity} | ${f.tier} | ${f.vulnId ?? f.ruleId ?? f.id} | ${subject} | ${f.fixedIn ?? "—"} |`;
|
|
240
|
+
}),
|
|
241
|
+
"",
|
|
242
|
+
];
|
|
243
|
+
return md.join("\n");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export async function runAudit(args = []) {
|
|
247
|
+
const rootArg = args.find((a) => a.startsWith("--root="));
|
|
248
|
+
const root = rootArg ? rootArg.slice("--root=".length) : process.cwd();
|
|
249
|
+
const json = args.includes("--json");
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
const [dep, sec, sast] = [await dependencyTier(root), secretsTier(root), sastTier(root)];
|
|
253
|
+
const report = buildReport({
|
|
254
|
+
at: new Date().toISOString(),
|
|
255
|
+
ref: null,
|
|
256
|
+
tiers: { dependency: dep.tier, secrets: sec.tier, sast: sast.tier },
|
|
257
|
+
findings: dedupeFindings([...dep.findings, ...sec.findings, ...sast.findings]),
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// Dual record beside the ledger (the verify-citations convention) — only
|
|
261
|
+
// when a .rafa/ bundle exists; a bare repo still gets the stdout report.
|
|
262
|
+
const improveDir = join(root, ".rafa", "improve");
|
|
263
|
+
if (existsSync(join(root, ".rafa"))) {
|
|
264
|
+
mkdirSync(improveDir, { recursive: true });
|
|
265
|
+
writeFileSync(join(improveDir, "security-audit.json"), JSON.stringify(report, null, 2) + "\n");
|
|
266
|
+
writeFileSync(join(improveDir, "security-audit.md"), renderRecord(report));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
console.log(json ? JSON.stringify(report, null, 2) : renderHuman(report));
|
|
270
|
+
return report.summary.total > 0 ? 1 : 0;
|
|
271
|
+
} catch (e) {
|
|
272
|
+
console.error(`✗ rafa audit: ${e.message}`);
|
|
273
|
+
return 2;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export default async function audit(args = []) {
|
|
278
|
+
process.exit(await runAudit(args));
|
|
279
|
+
}
|
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/checkpoint.mjs
CHANGED
|
@@ -108,11 +108,20 @@ export default async function checkpoint() {
|
|
|
108
108
|
|
|
109
109
|
// The working set is BRANCH state. On the trunk, knowledge changes go
|
|
110
110
|
// through scan → compile → rafa push (the org brain's one writer surface).
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
111
|
+
// The trunk is RESOLVED, never hardcoded (owner 2026-07-26): the stamped
|
|
112
|
+
// prodBranch → origin/HEAD → "main"; the main|master pair stays as a belt
|
|
113
|
+
// ONLY for unconfigured repos (with prodBranch set, "main" can be a plain
|
|
114
|
+
// feature-carrying branch and its working set checkpoints normally).
|
|
115
|
+
{
|
|
116
|
+
const { trunkBranchOf, readStamp } = await import("./stamp.mjs");
|
|
117
|
+
const trunk = trunkBranchOf(ROOT);
|
|
118
|
+
const configured = typeof readStamp(ROOT).prodBranch === "string";
|
|
119
|
+
if (branch === trunk || (!configured && (branch === "main" || branch === "master"))) {
|
|
120
|
+
console.log(
|
|
121
|
+
`• on ${branch} (the trunk) — the working set is branch-scoped; org-brain changes on the trunk go through /rafa scan + rafa push. Nothing to checkpoint.`,
|
|
122
|
+
);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
116
125
|
}
|
|
117
126
|
|
|
118
127
|
// The commit this capture is grounded against (contract: capturedAtSha —
|
package/lib/distill.mjs
CHANGED
|
@@ -40,8 +40,17 @@ import { createRequire } from "node:module";
|
|
|
40
40
|
import { dirname, join } from "node:path";
|
|
41
41
|
import { pathToFileURL } from "node:url";
|
|
42
42
|
import { callTool } from "./mcp-client.mjs";
|
|
43
|
-
import {
|
|
43
|
+
import { trunkBranchOf } from "./stamp.mjs";
|
|
44
|
+
import { runCompile, SCHEMA_VERSION } from "./gate/compile.mjs";
|
|
44
45
|
import { runVerifyCitations } from "./gate/verify-citations.mjs";
|
|
46
|
+
import {
|
|
47
|
+
applyLadder,
|
|
48
|
+
brainSchemaVersion,
|
|
49
|
+
classOf,
|
|
50
|
+
resolveSchemaPlan,
|
|
51
|
+
sourceSchemaVersion,
|
|
52
|
+
walkBrainNotes,
|
|
53
|
+
} from "./distiller/schema-ladder.mjs";
|
|
45
54
|
import pull from "./pull.mjs";
|
|
46
55
|
import push from "./push.mjs";
|
|
47
56
|
|
|
@@ -192,7 +201,7 @@ export default async function distill(args = []) {
|
|
|
192
201
|
try {
|
|
193
202
|
await callTool(ROOT, "report_distill_run", {
|
|
194
203
|
branch,
|
|
195
|
-
toBranch: process.env.RAFA_TARGET_BRANCH ||
|
|
204
|
+
toBranch: process.env.RAFA_TARGET_BRANCH || trunkBranchOf(ROOT),
|
|
196
205
|
mergeCommitSha: mergeSha,
|
|
197
206
|
status,
|
|
198
207
|
note: summary,
|
|
@@ -371,6 +380,35 @@ export default async function distill(args = []) {
|
|
|
371
380
|
await pull(["--full"]);
|
|
372
381
|
console.log(` ⏱ brain mirror: ${secs(tPull)}s`);
|
|
373
382
|
|
|
383
|
+
// 2b · SCHEMA LADDER (0.11.0 — the owner's 4-case doctrine): resolve the
|
|
384
|
+
// version lattice BEFORE any claim-level judging, so knowledge from two
|
|
385
|
+
// lineages always meets on ONE schema — the newest this runner understands.
|
|
386
|
+
// Lifted target notes ride THIS run's gates + push (one migration+reconcile
|
|
387
|
+
// commit); an unknown future schema aborts loudly, never downgrades.
|
|
388
|
+
{
|
|
389
|
+
const target = brainSchemaVersion(join(ROOT, ".rafa"));
|
|
390
|
+
const source = sourceSchemaVersion(active.map((f) => f.content));
|
|
391
|
+
const plan = resolveSchemaPlan({ target, source, latest: SCHEMA_VERSION });
|
|
392
|
+
console.log(
|
|
393
|
+
`• schema ladder: target v${target} · source v${source} · runner v${SCHEMA_VERSION} → ${plan.mode}`,
|
|
394
|
+
);
|
|
395
|
+
if (plan.mode === "abort-newer-than-runner") die(plan.reason);
|
|
396
|
+
if (plan.mode !== "diff") console.log(` ${plan.reason}`);
|
|
397
|
+
// Per-OKF-type lifts (class derives from the PATH — typed-candidates
|
|
398
|
+
// doctrine); non-laddered paths (intent records …) pass through untouched.
|
|
399
|
+
if (plan.upgradeSource)
|
|
400
|
+
for (const f of active) {
|
|
401
|
+
const cls = classOf(f.path);
|
|
402
|
+
if (cls) f.content = applyLadder(f.content, plan.toVersion, cls);
|
|
403
|
+
}
|
|
404
|
+
if (plan.rewriteTarget)
|
|
405
|
+
for (const noteFile of walkBrainNotes(join(ROOT, ".rafa"))) {
|
|
406
|
+
const cls = classOf(noteFile);
|
|
407
|
+
if (cls)
|
|
408
|
+
writeFileSync(noteFile, applyLadder(readFileSync(noteFile, "utf8"), plan.toVersion, cls));
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
374
412
|
// 3 · stage the incoming working set for the agent (gitignored inside .rafa).
|
|
375
413
|
const stagingRoot = join(ROOT, ".rafa", "distill-incoming");
|
|
376
414
|
rmSync(stagingRoot, { recursive: true, force: true });
|
|
@@ -554,7 +592,22 @@ ${fileList}`;
|
|
|
554
592
|
(f) => byPath.get(f.path).verdict === "distilled",
|
|
555
593
|
).length;
|
|
556
594
|
if (distilledCount > 0) {
|
|
557
|
-
|
|
595
|
+
// Descriptive reconciliation commit (owner 2026-07-26): the subject says
|
|
596
|
+
// WHAT this run did — branch, per-verdict counts, the banked ids — so any
|
|
597
|
+
// agent (ours or a foreign OKF reader) walking the brain log understands
|
|
598
|
+
// the commit without opening it. brain-for trailer untouched (push owns it).
|
|
599
|
+
{
|
|
600
|
+
const counts = { distilled: 0, refuted: 0, "needs-adjudication": 0 };
|
|
601
|
+
for (const v of verdicts) counts[v.verdict] = (counts[v.verdict] ?? 0) + 1;
|
|
602
|
+
const banked = verdicts
|
|
603
|
+
.filter((v) => v.verdict === "distilled")
|
|
604
|
+
.map((v) => String(v.path).split("/").pop().replace(/\.md$/, ""));
|
|
605
|
+
const idList = banked.slice(0, 4).join(", ") + (banked.length > 4 ? " …" : "");
|
|
606
|
+
await push([
|
|
607
|
+
"--verb=distill",
|
|
608
|
+
`--message=reconcile(${branch}): ${counts.distilled} banked · ${counts.refuted} refuted · ${counts["needs-adjudication"]} adjudication${banked.length ? ` — ${idList}` : ""}`,
|
|
609
|
+
]);
|
|
610
|
+
}
|
|
558
611
|
} else {
|
|
559
612
|
console.log("• no survivors — nothing to push to the brain repo");
|
|
560
613
|
}
|
|
@@ -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) {
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// The schema ladder (0.11.0 — schema-aware reconciliation, owner's 4-case
|
|
2
|
+
// doctrine, 2026-07-25). Distillation is where knowledge from two lineages
|
|
3
|
+
// meets, so it is ALSO where brain schema versions meet — the reconciler
|
|
4
|
+
// resolves the version lattice BEFORE any claim-level judging, and the merged
|
|
5
|
+
// result always lands on the newest schema the RUNNER understands:
|
|
6
|
+
//
|
|
7
|
+
// 1. target < source → REWRITE-TARGET: the whole target brain
|
|
8
|
+
// lifts to the newer schema (the incoming side already speaks it).
|
|
9
|
+
// 2. target > source → UPGRADE-SOURCE: incoming old-schema
|
|
10
|
+
// candidates lift into the target's newer schema before judging.
|
|
11
|
+
// 3. target == source < latest → REWRITE-MERGED: both sides lift; the
|
|
12
|
+
// merged target is a full rewrite onto the latest schema.
|
|
13
|
+
// 4. target == source == latest → DIFF: the happy flow, exactly today.
|
|
14
|
+
// ∅. either side > latest → ABORT LOUDLY: a newer CLI authored this;
|
|
15
|
+
// this runner cannot judge a schema it does not know — update the
|
|
16
|
+
// runner, NEVER downgrade knowledge.
|
|
17
|
+
//
|
|
18
|
+
// Every non-abort mode converges in ONE pass: toVersion is always the
|
|
19
|
+
// runner's latest (which case 1/2's higher side already equals or precedes —
|
|
20
|
+
// the runner can never know less than what it authored). Transforms are
|
|
21
|
+
// REGISTERED per step (v(N-1)→vN) in LADDER below; a missing step throws —
|
|
22
|
+
// the ladder never guesses a schema (no assumed values). v1 is the baseline
|
|
23
|
+
// and the only schema in existence today, so the registry ships empty: this
|
|
24
|
+
// module is the SEAM, proven by tests, that makes a future v2 a one-entry
|
|
25
|
+
// change instead of a migration crisis.
|
|
26
|
+
|
|
27
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
|
|
30
|
+
// Per-step, PER-OKF-TYPE transforms (owner 2026-07-26: each file class has its
|
|
31
|
+
// own shape in the contract's type registry, so each ladder step declares each
|
|
32
|
+
// class EXPLICITLY):
|
|
33
|
+
//
|
|
34
|
+
// LADDER[2] = {
|
|
35
|
+
// describe: "what v2 changes",
|
|
36
|
+
// rule: (content) => content2, // a real shape change for rules
|
|
37
|
+
// playbook: "unchanged", // EXPLICIT identity — visible, auditable
|
|
38
|
+
// improvement: (content) => …,
|
|
39
|
+
// }
|
|
40
|
+
//
|
|
41
|
+
// Only the three DURABLE knowledge classes ladder (rule · playbook ·
|
|
42
|
+
// improvement — the classes distillation judges and the brain keeps). Derived/
|
|
43
|
+
// generated files (coverage, checklist, ledger, reconciliation reports,
|
|
44
|
+
// manifest) are NEVER migrated — their producers regenerate them at the new
|
|
45
|
+
// schema. A step missing a class entry is a LOUD error, not implicit identity:
|
|
46
|
+
// "unchanged" must be declared, never assumed. Every applied step (identity
|
|
47
|
+
// included) re-stamps the note's own `schemaVersion:` line.
|
|
48
|
+
export const LADDER = {};
|
|
49
|
+
|
|
50
|
+
export const LADDER_CLASSES = ["rule", "playbook", "improvement"];
|
|
51
|
+
|
|
52
|
+
// Class resolution chain (owner 2026-07-26): DIRECTORY first — inside rafa's
|
|
53
|
+
// transport the path is governed identity (the CAS row key, the registry
|
|
54
|
+
// glob, the hydrate target; it cannot drift without becoming a different
|
|
55
|
+
// row) — then the PORTABLE filename suffix (`.rule.md` · `.playbook.md` ·
|
|
56
|
+
// `.improvement.md`), which travels WITH a file that leaves the canonical
|
|
57
|
+
// tree (foreign OKF bundles, out-of-context shares) while staying outside
|
|
58
|
+
// the corruptible content. When BOTH are present they must AGREE — a
|
|
59
|
+
// contradiction throws loudly, never a guess. Returns null for non-laddered
|
|
60
|
+
// paths (intent records, reports, …) — those pass through untouched.
|
|
61
|
+
const SUFFIX_CLASS = {
|
|
62
|
+
".rule.md": "rule",
|
|
63
|
+
".playbook.md": "playbook",
|
|
64
|
+
".improvement.md": "improvement",
|
|
65
|
+
};
|
|
66
|
+
export function classOf(path) {
|
|
67
|
+
const p = String(path).replace(/\\/g, "/");
|
|
68
|
+
const byDir = p.includes("brain/rules/")
|
|
69
|
+
? "rule"
|
|
70
|
+
: p.includes("brain/playbooks/")
|
|
71
|
+
? "playbook"
|
|
72
|
+
: p.includes("improve/improvements/")
|
|
73
|
+
? "improvement"
|
|
74
|
+
: null;
|
|
75
|
+
const bySuffix =
|
|
76
|
+
Object.entries(SUFFIX_CLASS).find(([s]) => p.endsWith(s))?.[1] ?? null;
|
|
77
|
+
if (byDir && bySuffix && byDir !== bySuffix)
|
|
78
|
+
throw new Error(
|
|
79
|
+
`schema ladder: directory class "${byDir}" contradicts filename class "${bySuffix}" (${p}) — ` +
|
|
80
|
+
"fix the file's home or its name; the ladder never guesses",
|
|
81
|
+
);
|
|
82
|
+
return byDir ?? bySuffix;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// A note's OWN declared schema (first-fence `schemaVersion:`). Default 1 — the
|
|
86
|
+
// founding schema, same convention as stampedVersions defaulting to the first
|
|
87
|
+
// release (a pre-stamp file predates versioning, it isn't unversioned).
|
|
88
|
+
export function noteSchemaVersion(content) {
|
|
89
|
+
if (typeof content !== "string" || !content.startsWith("---")) return 1;
|
|
90
|
+
const end = content.indexOf("\n---", 3);
|
|
91
|
+
const fm = end === -1 ? content : content.slice(0, end);
|
|
92
|
+
const m = fm.match(/^schemaVersion:\s*(\d+)\s*$/m);
|
|
93
|
+
return m ? Number(m[1]) : 1;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const NOTE_DIRS = ["brain/rules", "brain/playbooks", "improve/improvements"];
|
|
97
|
+
|
|
98
|
+
export function walkBrainNotes(rafaDir) {
|
|
99
|
+
const out = [];
|
|
100
|
+
const walk = (dir) => {
|
|
101
|
+
if (!existsSync(dir)) return;
|
|
102
|
+
for (const e of readdirSync(dir)) {
|
|
103
|
+
const p = join(dir, e);
|
|
104
|
+
if (statSync(p).isDirectory()) walk(p);
|
|
105
|
+
else if (e.endsWith(".md") && !e.startsWith("_") && !e.endsWith(".theirs.md"))
|
|
106
|
+
out.push(p);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
for (const d of NOTE_DIRS) walk(join(rafaDir, d));
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The TARGET brain's schema: manifest.json first (the compiled truth), else
|
|
114
|
+
// the max over its notes' own declarations, else the founding schema.
|
|
115
|
+
export function brainSchemaVersion(rafaDir) {
|
|
116
|
+
try {
|
|
117
|
+
const m = JSON.parse(readFileSync(join(rafaDir, "manifest.json"), "utf8"));
|
|
118
|
+
if (Number.isInteger(m.schemaVersion)) return m.schemaVersion;
|
|
119
|
+
} catch {
|
|
120
|
+
/* no manifest — fall through to the notes */
|
|
121
|
+
}
|
|
122
|
+
const versions = walkBrainNotes(rafaDir).map((f) =>
|
|
123
|
+
noteSchemaVersion(readFileSync(f, "utf8")),
|
|
124
|
+
);
|
|
125
|
+
return versions.length ? Math.max(...versions) : 1;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The SOURCE side's schema: the max over the incoming candidates' own
|
|
129
|
+
// declarations (a working set may be mixed if it straddled an upgrade).
|
|
130
|
+
export function sourceSchemaVersion(contents) {
|
|
131
|
+
const versions = (contents ?? []).map(noteSchemaVersion);
|
|
132
|
+
return versions.length ? Math.max(...versions) : 1;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The lattice. Returns {mode, toVersion, rewriteTarget, upgradeSource, reason}.
|
|
136
|
+
export function resolveSchemaPlan({ target, source, latest }) {
|
|
137
|
+
if (target > latest || source > latest)
|
|
138
|
+
return {
|
|
139
|
+
mode: "abort-newer-than-runner",
|
|
140
|
+
toVersion: null,
|
|
141
|
+
rewriteTarget: false,
|
|
142
|
+
upgradeSource: false,
|
|
143
|
+
reason:
|
|
144
|
+
`brain schema v${Math.max(target, source)} is NEWER than this runner's v${latest} — ` +
|
|
145
|
+
"a newer CLI authored it. Update @rafinery/cli and re-run; knowledge is never downgraded.",
|
|
146
|
+
};
|
|
147
|
+
const base = { toVersion: latest, rewriteTarget: target < latest, upgradeSource: source < latest };
|
|
148
|
+
if (target < source)
|
|
149
|
+
return { mode: "rewrite-target", ...base, reason: `target v${target} < source v${source} — full target rewrite onto v${latest}` };
|
|
150
|
+
if (source < target)
|
|
151
|
+
return { mode: "upgrade-source", ...base, reason: `source v${source} < target v${target} — incoming candidates lift into v${latest}` };
|
|
152
|
+
if (target < latest)
|
|
153
|
+
return { mode: "rewrite-merged", ...base, reason: `both sides v${target} < runner v${latest} — merged result rewrites onto v${latest}` };
|
|
154
|
+
return { mode: "diff", ...base, reason: `both sides already v${latest} — plain diff reconcile` };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const stampVersion = (content, v) =>
|
|
158
|
+
content.replace(/^schemaVersion:\s*\d+\s*$/m, `schemaVersion: ${v}`);
|
|
159
|
+
|
|
160
|
+
// Lift ONE note of ONE class from its own declared version to `to`, one
|
|
161
|
+
// registered step at a time. A missing step OR a missing class entry within a
|
|
162
|
+
// step is a loud error, never a guess; the explicit "unchanged" is the only
|
|
163
|
+
// legal identity, and even it re-stamps the note's schemaVersion line.
|
|
164
|
+
export function applyLadder(content, to, cls) {
|
|
165
|
+
if (!LADDER_CLASSES.includes(cls))
|
|
166
|
+
throw new Error(
|
|
167
|
+
`schema ladder: "${cls}" is not a laddered class (${LADDER_CLASSES.join("|")}) — ` +
|
|
168
|
+
"derived/generated files regenerate at the new schema, they never migrate",
|
|
169
|
+
);
|
|
170
|
+
let from = noteSchemaVersion(content);
|
|
171
|
+
let out = content;
|
|
172
|
+
while (from < to) {
|
|
173
|
+
const step = LADDER[from + 1];
|
|
174
|
+
const t = step?.[cls];
|
|
175
|
+
if (t === undefined)
|
|
176
|
+
throw new Error(
|
|
177
|
+
`schema ladder: no registered ${cls} transform v${from}→v${from + 1} — every step ` +
|
|
178
|
+
'declares every class (a function, or the EXPLICIT "unchanged"); ' +
|
|
179
|
+
"update @rafinery/cli to a release that ships it (the ladder never guesses a schema)",
|
|
180
|
+
);
|
|
181
|
+
out = t === "unchanged" ? out : t(out);
|
|
182
|
+
out = stampVersion(out, from + 1);
|
|
183
|
+
from += 1;
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
package/lib/doctor.mjs
CHANGED
|
@@ -97,6 +97,22 @@ export async function runDoctor(ROOT = process.cwd()) {
|
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
// 3b · security tools — the audit engine's core (lockfile parser + 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
|
+
console.log("• security tools (rafa audit)");
|
|
106
|
+
ok("core engine built in (dep CVEs via OSV · secrets ruleset)");
|
|
107
|
+
for (const [name, hint] of [
|
|
108
|
+
["gitleaks", "brew install gitleaks"],
|
|
109
|
+
["semgrep", "brew install semgrep"],
|
|
110
|
+
]) {
|
|
111
|
+
if (hasBinary(name)) ok(`${name} present (optional enhancer)`);
|
|
112
|
+
else console.log(` · ${name} not installed — optional enhancer (\`${hint}\`)`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
100
116
|
// 4 · the round-trip — the heartbeat must LAND, not just send. A recorded
|
|
101
117
|
// response proves key + url + repo scoping + the platform handler, end to end.
|
|
102
118
|
console.log("• platform round-trip");
|