@rafinery/cli 0.12.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 +4 -0
- package/blueprint/.claude/rafa/hooks/post-commit +8 -1
- package/blueprint/.claude/rafa/hooks/pre-push +6 -0
- package/lib/audit/core.mjs +140 -1
- package/lib/audit.mjs +23 -13
- package/lib/brain-repo.mjs +1 -1
- package/lib/checkpoint.mjs +93 -0
- package/lib/doctor.mjs +39 -2
- package/lib/guard.mjs +107 -0
- package/lib/releases.mjs +47 -0
- package/package.json +1 -1
- package/skills-bundle/skills-manifest.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,49 @@ The machine-readable version of this lives in [`lib/releases.mjs`](lib/releases.
|
|
|
4
4
|
CLI reads it to tell a client, on `rafa update`, exactly what an upgrade requires (nothing,
|
|
5
5
|
a re-scan, or a `rafa migrate`).
|
|
6
6
|
|
|
7
|
+
## 0.13.0 — whole-brain mandatory capture (r4–r6)
|
|
8
|
+
|
|
9
|
+
Owner doctrine, verbatim: *"if the brain did not capture what happened during
|
|
10
|
+
the session and missed a few steps, it's a failure."* Capture no longer
|
|
11
|
+
depends on anyone — model or dev — remembering an SOP line:
|
|
12
|
+
|
|
13
|
+
- **`rafa guard --pre-push`** — the capture-integrity gate the pre-push hook
|
|
14
|
+
now runs first: every outgoing commit must carry its 1-1 brain mirror;
|
|
15
|
+
a miss self-heals, a genuine miss **blocks the push**
|
|
16
|
+
(`RAFA_HOOKS_DISABLED=1` is the deliberate escape hatch). Post-commit
|
|
17
|
+
mirror failures print the repair command instead of vanishing.
|
|
18
|
+
- **`rafa checkpoint`** auto-converges reported improvement flips into real
|
|
19
|
+
ledger files, and **compulsorily hydrates** every note citing code the
|
|
20
|
+
branch touched (dirty queue ∪ `git diff trunk...HEAD` — manual edits
|
|
21
|
+
count), so affected-note edits happen *during* development.
|
|
22
|
+
- Server-side backstops (platform): the reconciler unions
|
|
23
|
+
reported-but-unconverged flips at merge time, sweeps merge-affected notes
|
|
24
|
+
when the dev-side capture signal is untrusted, and the run page says
|
|
25
|
+
"already banked" / "re-verified" instead of the misleading "unchanged".
|
|
26
|
+
- **Multi-manager lockfiles** in the audit core: pnpm · npm (v3+v1) ·
|
|
27
|
+
yarn (classic+berry) · bun (text lockfile), resolved dynamically —
|
|
28
|
+
never a pnpm hardcode. `rafa doctor` fails a `package.json` repo with no
|
|
29
|
+
lockfile and prints the exact per-manager generate command.
|
|
30
|
+
|
|
31
|
+
## 0.12.0 — the security module
|
|
32
|
+
|
|
33
|
+
Security as the fourth harnessed input, integral to the cycle
|
|
34
|
+
(spec: `.arohi/2026-07-26-security-module-spec.md`):
|
|
35
|
+
|
|
36
|
+
- **`rafa audit`** — self-contained (`rafa.audit/v1`): dependency CVEs via
|
|
37
|
+
the built-in lockfile parser + keyless OSV.dev (merged with `pnpm audit`),
|
|
38
|
+
secrets via the built-in ruleset (tracked files only, `.env*` never
|
|
39
|
+
opened, fingerprints never bytes), SAST via semgrep only if present —
|
|
40
|
+
a tier that didn't run says `ran:false`. Nothing bundled, nothing
|
|
41
|
+
installed, ever. Exit 0 clean · 1 findings · 2 error.
|
|
42
|
+
- Improvement frontmatter **`lens` → `category`** (same enum); `lens` stays
|
|
43
|
+
accepted as a deprecated alias, so existing ledgers keep compiling.
|
|
44
|
+
- New blueprint SOP **rafa-security** (woven at bloom's moments, never a dev
|
|
45
|
+
verb): mechanical priority map (critical→P0 … dev-only→P3), reachability
|
|
46
|
+
annotated from the brain, P0 security rows may surface outside the blast
|
|
47
|
+
radius. bloom 0.10.0 · atlas 4.1.0 (plan-time audit transparency) · the
|
|
48
|
+
scan's required security-posture playbook · doctor's security-tools block.
|
|
49
|
+
|
|
7
50
|
## 0.11.0 — schema-aware reconciliation (the 4-case ladder)
|
|
8
51
|
|
|
9
52
|
Distillation is where knowledge from two lineages meets — so it is now also
|
package/bin/rafa.mjs
CHANGED
|
@@ -56,6 +56,7 @@ const COMMANDS = [
|
|
|
56
56
|
"ci-setup",
|
|
57
57
|
"leverage",
|
|
58
58
|
"audit",
|
|
59
|
+
"guard",
|
|
59
60
|
"benchmark",
|
|
60
61
|
"manifest",
|
|
61
62
|
"review",
|
|
@@ -143,6 +144,9 @@ Commands:
|
|
|
143
144
|
PR merge) and name the CI secrets to configure.
|
|
144
145
|
leverage Inspect your agent toolbox (permissions, skills, MCP) and print
|
|
145
146
|
prioritized tips for getting more out of it. Advisory — changes nothing.
|
|
147
|
+
guard --pre-push Capture-integrity gate (hook-invoked): every outgoing commit must
|
|
148
|
+
have its 1-1 brain mirror; missing → self-heal → still missing →
|
|
149
|
+
the push BLOCKS (whole-brain capture is mandatory, 2026-07-26 r5).
|
|
146
150
|
audit Self-contained security engine (rafa.audit/v1): dependency CVEs via
|
|
147
151
|
the built-in lockfile parser + OSV.dev (pnpm audit merged in),
|
|
148
152
|
secrets via the built-in ruleset (tracked files only, .env* never
|
|
@@ -7,5 +7,12 @@
|
|
|
7
7
|
# the dev never maintains the brain. Non-blocking and silent on every path.
|
|
8
8
|
[ "$RAFA_HOOKS_DISABLED" = "1" ] && exit 0
|
|
9
9
|
[ -f "rafa.json" ] || exit 0
|
|
10
|
-
|
|
10
|
+
# r5 (whole-brain capture): a mirror failure is LOUD + recorded — the commit
|
|
11
|
+
# already happened (post-commit can't block), but the pre-push guard will
|
|
12
|
+
# refuse to ship an unmirrored commit, so the failure surfaces NOW, not never.
|
|
13
|
+
node ".claude/rafa/hooks/brain-commit.mjs" >/dev/null 2>&1 || {
|
|
14
|
+
echo "rafa · BRAIN MIRROR FAILED for this commit — capture incomplete." >&2
|
|
15
|
+
echo " Repair before pushing: node .claude/rafa/hooks/brain-commit.mjs (the pre-push guard will block until mirrored)" >&2
|
|
16
|
+
mkdir -p .rafa 2>/dev/null && printf '{"t":%s000,"hook":"post-commit","error":"brain mirror failed"}\n' "$(date +%s)" >> .rafa/sensor-errors.jsonl 2>/dev/null || true
|
|
17
|
+
}
|
|
11
18
|
exit 0
|
|
@@ -12,6 +12,12 @@
|
|
|
12
12
|
[ "$RAFA_HOOKS_DISABLED" = "1" ] && exit 0
|
|
13
13
|
[ -f "rafa.json" ] || exit 0
|
|
14
14
|
|
|
15
|
+
# WHOLE-BRAIN CAPTURE GUARD (owner 2026-07-26 r5, non-negotiable): every
|
|
16
|
+
# outgoing commit must carry its 1-1 brain mirror. Git-local check with
|
|
17
|
+
# self-heal; a genuine miss BLOCKS the push — capture loss never ships
|
|
18
|
+
# silently. RAFA_HOOKS_DISABLED=1 is the on-the-record escape hatch.
|
|
19
|
+
npx -y @rafinery/cli guard --pre-push || exit 1
|
|
20
|
+
|
|
15
21
|
echo "rafa · checkpoint (pre-push boundary) …"
|
|
16
22
|
npx -y @rafinery/cli checkpoint || {
|
|
17
23
|
code=$?
|
package/lib/audit/core.mjs
CHANGED
|
@@ -17,6 +17,130 @@ export const SEVERITIES = ["critical", "high", "moderate", "low", "info"];
|
|
|
17
17
|
const SEV_RANK = Object.fromEntries(SEVERITIES.map((s, i) => [s, i]));
|
|
18
18
|
const TIER_ORDER = { dependency: 0, secret: 1, sast: 2 };
|
|
19
19
|
|
|
20
|
+
// package-lock.json v2/v3 (`packages` map) with a v1 (`dependencies` tree)
|
|
21
|
+
// fallback. npm's lockfile carries per-entry `dev` flags and the root's
|
|
22
|
+
// direct-dep names, so reachability is PREFILLED from the file itself (no BFS
|
|
23
|
+
// — npm's nested-resolution graph doesn't reduce to exact-version edges).
|
|
24
|
+
export function parseNpmLock(text) {
|
|
25
|
+
const json = JSON.parse(text);
|
|
26
|
+
const packages = new Map();
|
|
27
|
+
const reachability = new Map();
|
|
28
|
+
const put = (name, version, { direct, dev }) => {
|
|
29
|
+
const id = `${name}@${version}`;
|
|
30
|
+
packages.set(id, { name, version });
|
|
31
|
+
const prev = reachability.get(id);
|
|
32
|
+
if (!prev || (prev.dev && !dev))
|
|
33
|
+
reachability.set(id, { direct, dev, path: direct ? [id] : [] });
|
|
34
|
+
};
|
|
35
|
+
if (json.packages && typeof json.packages === "object") {
|
|
36
|
+
const root = json.packages[""] ?? {};
|
|
37
|
+
const directNames = new Set([
|
|
38
|
+
...Object.keys(root.dependencies ?? {}),
|
|
39
|
+
...Object.keys(root.devDependencies ?? {}),
|
|
40
|
+
...Object.keys(root.optionalDependencies ?? {}),
|
|
41
|
+
]);
|
|
42
|
+
for (const [path, entry] of Object.entries(json.packages)) {
|
|
43
|
+
if (path === "" || !entry || typeof entry.version !== "string") continue;
|
|
44
|
+
if (entry.link === true) continue; // workspace links
|
|
45
|
+
const name = path.split("node_modules/").pop();
|
|
46
|
+
if (!name) continue;
|
|
47
|
+
put(name, entry.version, { direct: directNames.has(name), dev: entry.dev === true });
|
|
48
|
+
}
|
|
49
|
+
} else if (json.dependencies && typeof json.dependencies === "object") {
|
|
50
|
+
const walk = (deps, depth, devCtx) => {
|
|
51
|
+
for (const [name, e] of Object.entries(deps ?? {})) {
|
|
52
|
+
if (!e || typeof e !== "object") continue;
|
|
53
|
+
const dev = e.dev === true || devCtx;
|
|
54
|
+
if (typeof e.version === "string") put(name, e.version, { direct: depth === 0, dev });
|
|
55
|
+
if (e.dependencies) walk(e.dependencies, depth + 1, dev);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
walk(json.dependencies, 0, false);
|
|
59
|
+
}
|
|
60
|
+
return { manager: "npm", version: String(json.lockfileVersion ?? ""), packages: [...packages.values()], reachability };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// yarn.lock — BOTH classic v1 (`"name@range":` + `version "x"`) and berry
|
|
64
|
+
// (`"name@npm:range":` + `version: x`). Selector blocks → resolved versions.
|
|
65
|
+
// No dependency graph is derivable without resolution → packages-only.
|
|
66
|
+
export function parseYarnLock(text) {
|
|
67
|
+
const packages = new Map();
|
|
68
|
+
let names = null;
|
|
69
|
+
for (const raw of String(text ?? "").split("\n")) {
|
|
70
|
+
if (!raw.trim() || raw.trim().startsWith("#")) continue;
|
|
71
|
+
const indent = raw.length - raw.trimStart().length;
|
|
72
|
+
if (indent === 0 && raw.trim().endsWith(":")) {
|
|
73
|
+
names = raw
|
|
74
|
+
.trim()
|
|
75
|
+
.slice(0, -1)
|
|
76
|
+
.split(",")
|
|
77
|
+
.map((k) => {
|
|
78
|
+
const s = unquote(k.trim());
|
|
79
|
+
const at = s.lastIndexOf("@");
|
|
80
|
+
return at > 0 ? s.slice(0, at) : null; // scoped names keep their leading @
|
|
81
|
+
})
|
|
82
|
+
.filter(Boolean);
|
|
83
|
+
} else if (names && /^ {2}version:?\s/.test(raw)) {
|
|
84
|
+
const version = unquote(raw.trim().replace(/^version:?\s*/, ""));
|
|
85
|
+
if (version && !version.startsWith("0.0.0-use.local")) {
|
|
86
|
+
for (const name of new Set(names)) packages.set(`${name}@${version}`, { name, version });
|
|
87
|
+
}
|
|
88
|
+
names = null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { manager: "yarn", version: null, packages: [...packages.values()], reachability: null };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// bun.lock — bun's TEXT lockfile (JSONC: comments + trailing commas allowed);
|
|
95
|
+
// `packages` entries resolve as ["name@version", …]. The binary bun.lockb is
|
|
96
|
+
// NOT parsed — callers detect it and tell the dev to generate the text form
|
|
97
|
+
// (`bun install --save-text-lockfile`). Packages-only.
|
|
98
|
+
export function parseBunLock(text) {
|
|
99
|
+
const json = JSON.parse(
|
|
100
|
+
String(text ?? "")
|
|
101
|
+
.replace(/\/\/[^\n]*/g, "")
|
|
102
|
+
.replace(/,\s*([}\]])/g, "$1"),
|
|
103
|
+
);
|
|
104
|
+
const packages = new Map();
|
|
105
|
+
for (const val of Object.values(json.packages ?? {})) {
|
|
106
|
+
const spec = Array.isArray(val) ? val[0] : null;
|
|
107
|
+
if (typeof spec !== "string") continue;
|
|
108
|
+
const at = spec.lastIndexOf("@");
|
|
109
|
+
if (at <= 0) continue;
|
|
110
|
+
const name = spec.slice(0, at);
|
|
111
|
+
const version = spec.slice(at + 1);
|
|
112
|
+
if (!version || version.includes(":")) continue; // workspace:/git: refs are not registry versions
|
|
113
|
+
packages.set(`${name}@${version}`, { name, version });
|
|
114
|
+
}
|
|
115
|
+
return { manager: "bun", version: null, packages: [...packages.values()], reachability: null };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The one entry point every caller uses: filename → uniform
|
|
119
|
+
// { manager, packages, reachability } (reachability null when the format
|
|
120
|
+
// cannot honestly provide direct/dev/chain).
|
|
121
|
+
export function analyzeLockfile(filename, text) {
|
|
122
|
+
switch (filename) {
|
|
123
|
+
case "pnpm-lock.yaml": {
|
|
124
|
+
const parsed = parsePnpmLock(text);
|
|
125
|
+
return { manager: "pnpm", packages: parsed.packages, reachability: classifyReachability(parsed) };
|
|
126
|
+
}
|
|
127
|
+
case "package-lock.json": {
|
|
128
|
+
const parsed = parseNpmLock(text);
|
|
129
|
+
return { manager: "npm", packages: parsed.packages, reachability: parsed.reachability };
|
|
130
|
+
}
|
|
131
|
+
case "yarn.lock": {
|
|
132
|
+
const parsed = parseYarnLock(text);
|
|
133
|
+
return { manager: "yarn", packages: parsed.packages, reachability: null };
|
|
134
|
+
}
|
|
135
|
+
case "bun.lock": {
|
|
136
|
+
const parsed = parseBunLock(text);
|
|
137
|
+
return { manager: "bun", packages: parsed.packages, reachability: null };
|
|
138
|
+
}
|
|
139
|
+
default:
|
|
140
|
+
throw new Error(`unknown lockfile: ${filename}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
20
144
|
// Bounded-concurrency map — shared control flow for the OSV detail fetches
|
|
21
145
|
// (both callers cap at ~8). Pure orchestration; the fn does the I/O.
|
|
22
146
|
export async function mapConcurrent(items, limit, fn) {
|
|
@@ -50,6 +174,19 @@ const normSeverity = (label) => {
|
|
|
50
174
|
return null;
|
|
51
175
|
};
|
|
52
176
|
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Lockfiles — the MULTI-MANAGER registry (owner 2026-07-26 r3: "we should
|
|
179
|
+
// dynamically solve these problems, not hardcode pnpm"). Every parser returns
|
|
180
|
+
// the same shape; `analyzeLockfile` is the one entry point the three callers
|
|
181
|
+
// (CLI · reconcile lane · branch-merge action) use. Depth is honest per
|
|
182
|
+
// manager: pnpm + npm lockfiles carry enough to classify direct/dev/chain;
|
|
183
|
+
// yarn + bun yield packages-only (reachability: null — findings carry
|
|
184
|
+
// chain:null/dev:null, never a guessed classification).
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
// Detection order: richest-information lockfile first.
|
|
188
|
+
export const LOCKFILE_NAMES = ["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lock"];
|
|
189
|
+
|
|
53
190
|
// ---------------------------------------------------------------------------
|
|
54
191
|
// pnpm-lock.yaml v9 — a rigid machine-emitted YAML subset; a line scanner is
|
|
55
192
|
// enough and keeps the CLI at one runtime dep (no @pnpm/lockfile-file, whose
|
|
@@ -291,7 +428,9 @@ export function fromOsvBatch(osvResults, packages, vulnDetails, reachability) {
|
|
|
291
428
|
const vuln = vulnDetails?.get?.(hit.id) ?? hit;
|
|
292
429
|
const cvss = cvssScoreOf(vuln);
|
|
293
430
|
const label = normSeverity(vuln.database_specific?.severity);
|
|
294
|
-
|
|
431
|
+
// No reachability map (yarn/bun lockfiles) → chain/dev stay null —
|
|
432
|
+
// "unknown" is never rendered as "transitive".
|
|
433
|
+
const chain = reachability ? chainFor(pkg, reachability) : null;
|
|
295
434
|
const entry = reachability?.get(`${pkg.name}@${pkg.version}`);
|
|
296
435
|
out.push(
|
|
297
436
|
finding({
|
package/lib/audit.mjs
CHANGED
|
@@ -18,10 +18,11 @@ import { tmpdir } from "node:os";
|
|
|
18
18
|
import { join } from "node:path";
|
|
19
19
|
import {
|
|
20
20
|
AUDIT_SCHEMA,
|
|
21
|
+
LOCKFILE_NAMES,
|
|
21
22
|
SECRET_PATTERNS,
|
|
23
|
+
analyzeLockfile,
|
|
22
24
|
buildOsvQueries,
|
|
23
25
|
buildReport,
|
|
24
|
-
classifyReachability,
|
|
25
26
|
dedupeFindings,
|
|
26
27
|
entropyOf,
|
|
27
28
|
fromGitleaks,
|
|
@@ -30,7 +31,6 @@ import {
|
|
|
30
31
|
fromRegexSecrets,
|
|
31
32
|
fromSemgrepSarif,
|
|
32
33
|
mapConcurrent,
|
|
33
|
-
parsePnpmLock,
|
|
34
34
|
} from "./audit/core.mjs";
|
|
35
35
|
|
|
36
36
|
const OSV_BATCH = "https://api.osv.dev/v1/querybatch";
|
|
@@ -56,20 +56,30 @@ async function osvFetch(url, body) {
|
|
|
56
56
|
return res.json();
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
// Tier a — dependency CVEs. Built-in OSV primary
|
|
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.
|
|
60
62
|
async function dependencyTier(root) {
|
|
61
|
-
const
|
|
62
|
-
if (!
|
|
63
|
-
|
|
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
|
+
}
|
|
64
70
|
|
|
65
|
-
const
|
|
66
|
-
|
|
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: [] };
|
|
67
77
|
const findings = [];
|
|
68
78
|
let osvOk = false;
|
|
69
79
|
let reason = null;
|
|
70
80
|
|
|
71
81
|
try {
|
|
72
|
-
const chunks = buildOsvQueries(
|
|
82
|
+
const chunks = buildOsvQueries(packages);
|
|
73
83
|
const results = [];
|
|
74
84
|
for (const chunk of chunks) {
|
|
75
85
|
const res = await osvFetch(OSV_BATCH, { queries: chunk });
|
|
@@ -84,7 +94,7 @@ async function dependencyTier(root) {
|
|
|
84
94
|
/* a detail we cannot fetch yields a shallower finding, never a guessed one */
|
|
85
95
|
}
|
|
86
96
|
});
|
|
87
|
-
findings.push(...fromOsvBatch(results,
|
|
97
|
+
findings.push(...fromOsvBatch(results, packages, details, reachability));
|
|
88
98
|
osvOk = true;
|
|
89
99
|
} catch (e) {
|
|
90
100
|
reason = `OSV unreachable (${e.message})`;
|
|
@@ -95,7 +105,7 @@ async function dependencyTier(root) {
|
|
|
95
105
|
// regardless of exit code. Run without --prod: dev deps ride with dev:true
|
|
96
106
|
// and the priority map downgrades them — never silent omission.
|
|
97
107
|
let pnpmOk = false;
|
|
98
|
-
if (hasBinary("pnpm")) {
|
|
108
|
+
if (manager === "pnpm" && hasBinary("pnpm")) {
|
|
99
109
|
try {
|
|
100
110
|
let stdout;
|
|
101
111
|
try {
|
|
@@ -113,8 +123,8 @@ async function dependencyTier(root) {
|
|
|
113
123
|
|
|
114
124
|
if (!osvOk && !pnpmOk)
|
|
115
125
|
return { tier: { ran: false, tool: null, reason: reason ?? "registry unreachable" }, findings: [] };
|
|
116
|
-
const
|
|
117
|
-
return { tier: { ran: true, tool }, findings: dedupeFindings(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) };
|
|
118
128
|
}
|
|
119
129
|
|
|
120
130
|
const isEnvFile = (p) => /(^|\/)\.env(\.|$)/.test(p);
|
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,
|
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,13 +97,50 @@ export async function runDoctor(ROOT = process.cwd()) {
|
|
|
97
97
|
}
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
// 3b · security tools — the audit engine's core (lockfile
|
|
100
|
+
// 3b · security tools — the audit engine's core (lockfile parsers + OSV +
|
|
101
101
|
// secrets ruleset) is BUILT IN and always on; gitleaks/semgrep are optional
|
|
102
102
|
// enhancers. Detection only — rafa never installs binaries.
|
|
103
103
|
{
|
|
104
104
|
const { hasBinary } = await import("./audit.mjs");
|
|
105
|
+
const { LOCKFILE_NAMES } = await import("./audit/core.mjs");
|
|
105
106
|
console.log("• security tools (rafa audit)");
|
|
106
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
|
+
|
|
107
144
|
for (const [name, hint] of [
|
|
108
145
|
["gitleaks", "brew install gitleaks"],
|
|
109
146
|
["semgrep", "brew install semgrep"],
|
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/releases.mjs
CHANGED
|
@@ -409,6 +409,53 @@ export const RELEASES = [
|
|
|
409
409
|
"origin/HEAD → main) drives the checkpoint trunk guard + CI distill " +
|
|
410
410
|
"target.",
|
|
411
411
|
},
|
|
412
|
+
{
|
|
413
|
+
version: "0.12.0",
|
|
414
|
+
contract: 1,
|
|
415
|
+
plans: 2,
|
|
416
|
+
requires: "update",
|
|
417
|
+
summary:
|
|
418
|
+
"THE SECURITY MODULE (owner-ratified 2026-07-26; spec " +
|
|
419
|
+
".arohi/2026-07-26-security-module-spec.md): `rafa audit` — the " +
|
|
420
|
+
"SELF-CONTAINED engine (rafa.audit/v1): dep CVEs via the built-in " +
|
|
421
|
+
"lockfile parser + keyless OSV.dev merged with pnpm audit; secrets via " +
|
|
422
|
+
"the built-in ruleset (tracked files only, .env* never opened, " +
|
|
423
|
+
"fingerprints never bytes); SAST via semgrep IF present — a tier that " +
|
|
424
|
+
"didn't run says ran:false, never an empty list. NO binaries bundled " +
|
|
425
|
+
"or installed, ever. Improvement frontmatter `lens` RENAMED `category` " +
|
|
426
|
+
"(same enum; `lens` accepted as a deprecated alias — old ledgers keep " +
|
|
427
|
+
"compiling). New blueprint SOP rafa-security (never a dev verb): " +
|
|
428
|
+
"mechanical priority map critical→P0…dev-only→P3, reachability " +
|
|
429
|
+
"annotated from the brain, P0 security rows travel outside the blast " +
|
|
430
|
+
"radius; bloom 0.10.0 (security-profile duty), atlas 4.1.0 (plan-time " +
|
|
431
|
+
"audit transparency — clean is said out loud), scan requires the " +
|
|
432
|
+
"security-posture playbook. doctor gains the security-tools block.",
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
version: "0.13.0",
|
|
436
|
+
contract: 1,
|
|
437
|
+
plans: 2,
|
|
438
|
+
requires: "update",
|
|
439
|
+
summary:
|
|
440
|
+
"WHOLE-BRAIN MANDATORY CAPTURE (owner r4–r6, 2026-07-26 — 'if the " +
|
|
441
|
+
"brain did not capture what happened during the session, it's a " +
|
|
442
|
+
"failure'): `rafa guard --pre-push` — the capture-integrity gate the " +
|
|
443
|
+
"pre-push hook runs FIRST: every outgoing commit must carry its 1-1 " +
|
|
444
|
+
"brain mirror; missing → self-heal → still missing → the push BLOCKS " +
|
|
445
|
+
"(RAFA_HOOKS_DISABLED=1 is the on-the-record escape hatch); " +
|
|
446
|
+
"post-commit mirror failures go LOUD. `rafa checkpoint` auto-CONVERGES " +
|
|
447
|
+
"reported improvement flips into real ledger files and COMPULSORILY " +
|
|
448
|
+
"hydrates notes citing touched code (dirty queue ∪ git branch-vs-trunk " +
|
|
449
|
+
"diff — manual edits count) so affected-note edits happen DURING " +
|
|
450
|
+
"development. report_improvement_status returns required_next. " +
|
|
451
|
+
"MULTI-MANAGER lockfiles in the audit core: pnpm · npm (v3+v1, " +
|
|
452
|
+
"dev/direct from the lockfile) · yarn (classic+berry) · bun (text " +
|
|
453
|
+
"JSONC), resolved dynamically richest-first; yarn/bun are honestly " +
|
|
454
|
+
"packages-only (chain/dev null); binary bun.lockb detected and pointed " +
|
|
455
|
+
"at --save-text-lockfile; doctor FAILS a package.json repo with no " +
|
|
456
|
+
"lockfile and prints the per-manager generate command. review.json " +
|
|
457
|
+
"joins the brain-repo bootstrap ignore.",
|
|
458
|
+
},
|
|
412
459
|
];
|
|
413
460
|
|
|
414
461
|
// The release this CLI build ships (last entry).
|
package/package.json
CHANGED