@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 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
- node ".claude/rafa/hooks/brain-commit.mjs" >/dev/null 2>&1 || true
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=$?
@@ -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
- const chain = chainFor(pkg, reachability);
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; `pnpm audit` secondary.
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 lockPath = join(root, "pnpm-lock.yaml");
62
- if (!existsSync(lockPath))
63
- return { tier: { ran: false, tool: null, reason: "no pnpm-lock.yaml here" }, findings: [] };
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 parsed = parsePnpmLock(readFileSync(lockPath, "utf8"));
66
- const reachability = classifyReachability(parsed);
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(parsed.packages);
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, parsed.packages, details, reachability));
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 tool = osvOk && pnpmOk ? "osv-api+pnpm-audit" : osvOk ? "osv-api" : "pnpm-audit";
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);
@@ -77,7 +77,7 @@ export function ensureBrainRepo(cwd, { requireRemote = true } = {}) {
77
77
  // (not knowledge). MERGED line-by-line so clones ignored under an older CLI
78
78
  // still gain new exclusions.
79
79
  const ignore = join(rafaDir, ".gitignore");
80
- const IGNORES = ["hydration.json", "*.theirs.md", "distill-incoming/", "distill-verdicts.json", "dirty.jsonl", "reflex.jsonl", "sensor-errors.jsonl", "benchmark.demo.json", "session-facts.json", "loop-events-tail.json"];
80
+ const IGNORES = ["hydration.json", "*.theirs.md", "distill-incoming/", "distill-verdicts.json", "dirty.jsonl", "reflex.jsonl", "sensor-errors.jsonl", "benchmark.demo.json", "session-facts.json", "loop-events-tail.json", "review.json"];
81
81
  const cur = existsSync(ignore) ? readFileSync(ignore, "utf8") : "";
82
82
  const have = new Set(cur.split("\n").map((l) => l.trim()).filter(Boolean));
83
83
  const missing = IGNORES.filter((l) => !have.has(l));
@@ -124,6 +124,99 @@ export default async function checkpoint() {
124
124
  }
125
125
  }
126
126
 
127
+ // MANDATORY CAPTURE — converge reported improvement flips into REAL ledger
128
+ // files (owner 2026-07-26 r4: "if the brain did not capture what happened
129
+ // during the session, it's a failure"). A session that called
130
+ // report_improvement_status but never hydrated the row would leave the flip
131
+ // stranded as an overlay; this step closes that gap MECHANICALLY on every
132
+ // checkpoint (and the pre-push hook runs checkpoint, so every push too):
133
+ // hydrate the canonical row, flip the status line, and the file rides THIS
134
+ // branch's working set. Best-effort per row — an unreachable platform never
135
+ // blocks the checkpoint (the reconciler's merge-time overlay net is the
136
+ // final backstop).
137
+ try {
138
+ const res = await callTool(ROOT, "list_improvements", {});
139
+ const rows = res?.data?.improvements ?? res?.improvements ?? [];
140
+ const mismatched = rows.filter(
141
+ (r) => r?.reported?.status && r.reported.status !== r.status,
142
+ );
143
+ if (mismatched.length) {
144
+ const { materializeImprovement } = await import("./working-set.mjs");
145
+ for (const r of mismatched) {
146
+ try {
147
+ const rel = `improve/improvements/${r.id}.md`;
148
+ const abs = join(ROOT, ".rafa", rel);
149
+ if (!existsSync(abs)) {
150
+ const payload = await callTool(ROOT, "get_improvement", { id: r.id });
151
+ materializeImprovement(ROOT, payload, payload.envelope?.brainForSha ?? null);
152
+ }
153
+ const cur = readFileSync(abs, "utf8");
154
+ if (!new RegExp(`^status: ${r.reported.status}\\s*$`, "m").test(cur)) {
155
+ // The status flip is an EDIT vs the hydration base → working set.
156
+ writeFileSync(abs, cur.replace(/^status: .*$/m, `status: ${r.reported.status}`));
157
+ console.log(`✓ converged reported ${r.reported.status} → .rafa/${rel} (mandatory capture)`);
158
+ }
159
+ } catch (e) {
160
+ console.log(`⚠ could not converge reported flip for "${r.id}" — ${e instanceof Error ? e.message : e} (the merge-time overlay net will catch it)`);
161
+ }
162
+ }
163
+ }
164
+ } catch {
165
+ /* unprovisioned/offline — the merge-time overlay net stands */
166
+ }
167
+
168
+ // COMPULSORY HYDRATION of touched-code citers (owner 2026-07-26 r5: "if some
169
+ // file is touched, it should hydrate compulsorily so edits happen DURING
170
+ // development"). The dirty queue knows which code files this session edited;
171
+ // the local manifest knows which notes cite them. Every citing note not
172
+ // already local is hydrated NOW — present for the session to edit, so the
173
+ // affected-note update happens in development, not discovered at merge (the
174
+ // merge-time affected lane stays the backstop). Unedited hydrations remain
175
+ // disposable cache — presence, never noise.
176
+ try {
177
+ const { readDirty, resolveCiters } = await import("./dirty.mjs");
178
+ const { files: dirtyFiles } = readDirty(ROOT);
179
+ // SENSOR-INDEPENDENT (r5 follow-up): the dirty queue only sees agent-session
180
+ // edits (a Claude Code hook) — a dev committing manually never populates it.
181
+ // Union with git's own truth: every file this BRANCH changed vs the trunk.
182
+ // Checkpoint runs from the git pre-push hook, which fires on manual pushes
183
+ // too — so manual workflows hydrate their citers all the same.
184
+ const touched = new Set(dirtyFiles.map((f) => f.path ?? f[0] ?? f));
185
+ try {
186
+ const { trunkBranchOf } = await import("./stamp.mjs");
187
+ const trunk = trunkBranchOf(ROOT);
188
+ for (const f of execSync(`git diff --name-only ${trunk}...HEAD`, {
189
+ cwd: ROOT,
190
+ encoding: "utf8",
191
+ stdio: ["ignore", "pipe", "ignore"],
192
+ })
193
+ .split("\n")
194
+ .filter(Boolean))
195
+ touched.add(f);
196
+ } catch {
197
+ /* no trunk ref locally (shallow clone) — the sensor queue alone serves */
198
+ }
199
+ const citers = touched.size ? resolveCiters(ROOT, [...touched]) : null;
200
+ if (citers?.length) {
201
+ const { materializeImprovement, materializeNote } = await import("./working-set.mjs");
202
+ for (const n of citers) {
203
+ try {
204
+ if (!n.path || existsSync(join(ROOT, ".rafa", n.path))) continue; // already local
205
+ const kind = n.path.includes("improvements/") ? "improvement" : n.path.includes("playbooks/") ? "playbook" : "rule";
206
+ const payload = await callTool(ROOT, kind === "improvement" ? "get_improvement" : kind === "playbook" ? "get_playbook" : "get_rule", { id: n.id });
207
+ const base = payload.envelope?.brainForSha ?? null;
208
+ if (kind === "improvement") materializeImprovement(ROOT, payload, base);
209
+ else materializeNote(ROOT, kind, payload, base);
210
+ console.log(`✓ hydrated ${kind} "${n.id}" — cites code this session touched (${n.files.join(", ")}); update it as part of the work`);
211
+ } catch (e) {
212
+ console.log(`⚠ could not hydrate citer "${n.id}" — ${e instanceof Error ? e.message : e} (merge-time affected lane will re-ground it)`);
213
+ }
214
+ }
215
+ }
216
+ } catch {
217
+ /* no dirty queue / no manifest / offline — the merge-time affected lane stands */
218
+ }
219
+
127
220
  // The commit this capture is grounded against (contract: capturedAtSha —
128
221
  // absent = unknown grounding). One HEAD per checkpoint push; the merge-time
129
222
  // ancestry sweep keys on it, so a missing sha only narrows that sweep,
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 parser + OSV +
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rafinery/cli",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "rafa — the AI engineer CLI. Vendors the rafa blueprint into your repo (shadcn-style) and runs the deterministic contract gates.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,5 +1,5 @@
1
1
  {
2
- "generatedAt": "2026-07-26T15:51:05.847Z",
2
+ "generatedAt": "2026-07-26T18:16:15.258Z",
3
3
  "skills": {
4
4
  "tdd": {
5
5
  "version": "1.0.0",