@rafinery/cli 0.12.0 → 0.14.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 +72 -0
- package/bin/rafa.mjs +7 -14
- package/blueprint/.claude/agents/atlas.md +27 -5
- package/blueprint/.claude/agents/bloom.md +24 -4
- package/blueprint/.claude/agents/prism.md +12 -3
- package/blueprint/.claude/commands/rafa.md +19 -8
- package/blueprint/.claude/rafa/contract.md +114 -0
- package/blueprint/.claude/rafa/hooks/post-commit +8 -1
- package/blueprint/.claude/rafa/hooks/pre-push +6 -0
- package/blueprint/.claude/skills/rafa-build/SKILL.md +5 -6
- package/blueprint/.claude/skills/rafa-improve/SKILL.md +18 -3
- package/blueprint/.claude/skills/rafa-plan/SKILL.md +17 -17
- package/blueprint/.claude/skills/rafa-scan/SKILL.md +44 -10
- package/blueprint/.claude/skills/rafa-security/SKILL.md +15 -8
- package/lib/audit/core.mjs +140 -1
- package/lib/audit.mjs +23 -13
- package/lib/blueprint.mjs +0 -1
- package/lib/brain-repo.mjs +1 -1
- package/lib/checkpoint.mjs +93 -0
- package/lib/doctor.mjs +40 -3
- package/lib/gate/compile.mjs +6 -0
- package/lib/gate/verify-citations.mjs +8 -1
- package/lib/guard.mjs +107 -0
- package/lib/releases.mjs +76 -0
- package/package.json +1 -1
- package/skills-bundle/skills-manifest.json +1 -1
- package/blueprint/.claude/skills/rafa-distill/SKILL.md +0 -126
- package/lib/ci-setup.mjs +0 -172
- package/lib/distill.mjs +0 -665
- package/lib/distiller/schema-ladder.mjs +0 -186
|
@@ -23,13 +23,13 @@ Planning is a choreography, not one agent (spec: knowledge-mcp-build-agent):
|
|
|
23
23
|
the tools; the dev never asks for it. Repo not platform-connected (no `rafinery`
|
|
24
24
|
MCP in the session)? Fall back to reading `.rafa/brain/` files directly — same
|
|
25
25
|
knowledge, just unserved.*
|
|
26
|
-
- **bloom pulls** — `list_improvements` in the blast radius
|
|
27
|
-
`category: security` P0 regardless of region — the one class that travels;
|
|
28
|
-
one line, dismissible, never blocking); surface the
|
|
26
|
+
- **bloom pulls** — `list_improvements` in the blast radius, surfacing the
|
|
29
27
|
top-leverage open items as optional *"while-you're-here"* child tasks
|
|
30
|
-
(leverage-ranked, dismissible, never blocking).
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
(leverage-ranked, dismissible, never blocking). Open `category: security` P0s
|
|
29
|
+
come along too — the one class that travels regardless of region (contract
|
|
30
|
+
§12.5), one line, still dismissible, never blocking. **Any improvement the
|
|
31
|
+
plan ADOPTS** — as a task or as the plan's very subject — **is hydrated NOW**
|
|
32
|
+
(`rafa hydrate improvement <id>`), so the canonical ledger file is in the
|
|
33
33
|
branch working set from the start and the eventual `status: fixed` edit lands
|
|
34
34
|
on that same file, never a path-drifted twin authored at build's end.
|
|
35
35
|
**Also pull `get_knowledge_gaps`** (the open backlog — what devs asked that
|
|
@@ -102,17 +102,17 @@ Planning is a choreography, not one agent (spec: knowledge-mcp-build-agent):
|
|
|
102
102
|
the pivotal choices are logged as DECISIONS at approval (`log_decision`:
|
|
103
103
|
context · options · decision · rationale; actor = the dev for their calls).
|
|
104
104
|
3. **Ledger pull** (bloom) → optional leverage tasks in the blast radius.
|
|
105
|
-
3b. **Security transparency
|
|
106
|
-
Run `npx @rafinery/cli audit --json` (seconds; reuse a
|
|
107
|
-
`security-audit.json` if fresh) and
|
|
108
|
-
and unburied
|
|
109
|
-
which tiers ran, then any critical/high finding whose
|
|
110
|
-
in THIS plan's blast radius (per
|
|
111
|
-
reachability method). A
|
|
112
|
-
child task
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
never silence.
|
|
105
|
+
3b. **Security transparency — integral, never an aftermath** (contract §12.5;
|
|
106
|
+
owner 2026-07-26). Run `npx @rafinery/cli audit --json` (seconds; reuse a
|
|
107
|
+
same-session `security-audit.json` if fresh) and put the picture in front of
|
|
108
|
+
the dev — verbatim and unburied — before the plan is approved: one line of
|
|
109
|
+
severity totals plus which tiers ran, then any critical/high finding whose
|
|
110
|
+
package or file sits in THIS plan's blast radius (per
|
|
111
|
+
[rafa-security](../rafa-security/SKILL.md)'s reachability method). A
|
|
112
|
+
blast-radius critical/high becomes a plan child task, so the fix rides the
|
|
113
|
+
work. Transparency is the whole point — the dev sees the counts even when
|
|
114
|
+
nothing is wrong: *"dependency tier clean at <sha>"* is one honest line,
|
|
115
|
+
said out loud, never silence.
|
|
116
116
|
4. **Leverage-match** — recommend existing skills/tools/MCP that fit the tasks;
|
|
117
117
|
never plan to hand-roll what a capability already does.
|
|
118
118
|
5. **prism plan-validation** → REJECT/fix loop until clean.
|
|
@@ -201,12 +201,13 @@ retrieval index. Bodies read like a senior engineer explaining that one concept
|
|
|
201
201
|
6. **Flow tracing** [LLM, cited] — from each entry point, hop-by-hop to termination
|
|
202
202
|
(render / DB write / response) → `flow` playbooks. Add `how-to` playbooks for the
|
|
203
203
|
recurring "add X" procedures these flows imply.
|
|
204
|
-
**One `security-posture` playbook is required** (2026-07-26): the repo's
|
|
205
|
-
boundaries (what's server-exposed vs build-time), auth chokepoints
|
|
206
|
-
authz is enforced — cited), and the secret-handling convention (where
|
|
207
|
-
names are read; names only, per the step-4 guardrail). This is durable
|
|
208
|
-
KNOWLEDGE, not findings — it
|
|
209
|
-
|
|
204
|
+
**One `security-posture` playbook is required** (owner 2026-07-26): the repo's
|
|
205
|
+
trust boundaries (what's server-exposed vs build-time), auth chokepoints
|
|
206
|
+
(where authz is enforced — cited), and the secret-handling convention (where
|
|
207
|
+
env names are read; names only, per the step-4 guardrail). This is durable
|
|
208
|
+
KNOWLEDGE, not findings — it is the flow-trace of the repo's trust surface,
|
|
209
|
+
and it grounds bloom's security profile
|
|
210
|
+
([rafa-security](../rafa-security/SKILL.md)): the reachability annotations on
|
|
210
211
|
CVE rows come FROM this note's map.
|
|
211
212
|
|
|
212
213
|
7. **Verification & synthesis** — apply the 100× filter (a note earns a file only if it
|
|
@@ -246,10 +247,38 @@ subagent it calls in step 1/5. **`init`** = ensure structure idempotently (`.raf
|
|
|
246
247
|
pipeline; **`--brain-only`** stops after the brain is validated (step 5 PASS) — skips improve
|
|
247
248
|
+ push, a cheap knowledge refresh.
|
|
248
249
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
`npx @rafinery/cli
|
|
252
|
-
|
|
250
|
+
0. **RESOLVE THE REAL BRAIN FIRST — mandatory and mechanical (the canonical
|
|
251
|
+
continuity law, [contract §12.4](../../rafa/contract.md)).** Before ANY scan
|
|
252
|
+
step, run `npx @rafinery/cli pull --full` — materialize the org brain into
|
|
253
|
+
`.rafa/` and confirm it sits at the brain remote's HEAD
|
|
254
|
+
(`git -C .rafa fetch origin && git -C .rafa status`). A stale local `.rafa`
|
|
255
|
+
(e.g. parked on an old genesis commit) is exactly how a session scans blind
|
|
256
|
+
and mints a parallel brain (the incident this step exists to prevent — see
|
|
257
|
+
the anti-patterns). **Founding is PLATFORM truth, never local emptiness**:
|
|
258
|
+
founding ⇔ the platform serves zero knowledge
|
|
259
|
+
(`search_knowledge`/`list_improvements` both empty). If ANY notes exist, this
|
|
260
|
+
is a **REFRESH**, not a founding scan:
|
|
261
|
+
- **Ids are stable forever.** One concept = one id. atlas UPDATES the
|
|
262
|
+
existing file in place (content, cites, links); it NEVER mints a new id
|
|
263
|
+
for a concept an existing note covers.
|
|
264
|
+
- **Retirement is a TOMBSTONE, never a deletion** (the §2 lifecycle law): set
|
|
265
|
+
`status: retired` in the existing file and append a dated `## Retired`
|
|
266
|
+
body section — why it no longer holds, what supersedes it (linked).
|
|
267
|
+
The file stays as history (excluded from recall by the platform);
|
|
268
|
+
a state change is never recorded by removing a file.
|
|
269
|
+
- New ids are for genuinely new concepts only — and each new note names, in
|
|
270
|
+
its body, why no existing note covers it.
|
|
271
|
+
- The founding-scan coach offer (step 9) NEVER fires on a refresh.
|
|
272
|
+
1. **Scan — spawn `atlas`** context-isolated. On a REFRESH, the spawn prompt
|
|
273
|
+
MUST carry the existing inventory: *"REFRESH mode — the org brain is
|
|
274
|
+
materialized under `.rafa/brain/` (<N> rules · <M> playbooks · <K>
|
|
275
|
+
improvements). Update in place: ids are stable; never write a new note for
|
|
276
|
+
a concept an existing note covers; deletions are explicit. Run the scan per
|
|
277
|
+
this SOP: comprehensive, breadth-before-depth, cited notes →
|
|
278
|
+
`.rafa/brain/{rules,playbooks}/` + `coverage.md`. Run
|
|
279
|
+
`npx @rafinery/cli verify-citations` until it **exits 0**. Return a
|
|
280
|
+
coverage summary only — not the raw reads."* (Founding keeps the same
|
|
281
|
+
prompt minus the refresh preamble.)
|
|
253
282
|
2. **Gate 1 — checker (trust-but-verify):** re-run `npx @rafinery/cli verify-citations`
|
|
254
283
|
yourself. Must **exit 0** (else re-spawn atlas to fix). It writes `citation-check.md`.
|
|
255
284
|
3. **Gate 2 — prism:** spawn `prism` **context-isolated**, passing ONLY: *"Validate the scan
|
|
@@ -344,6 +373,11 @@ The report must state PASS/FAIL **per criterion** (not a summary verdict), so it
|
|
|
344
373
|
targets the exact failing box.
|
|
345
374
|
|
|
346
375
|
## Anti-patterns (do NOT do)
|
|
376
|
+
- **Scan without pulling the real brain (the parallel-brain incident,
|
|
377
|
+
2026-07-27).** A blind re-scan mints new ids for concepts that already have
|
|
378
|
+
notes — 28 duplicates against 14 existing notes, zero continuity. Step 0 is
|
|
379
|
+
mandatory; the reconciler's duplicate guard will CONTEST parallel notes, but
|
|
380
|
+
contested duplicates are cleanup, not a workflow.
|
|
347
381
|
- Cherry-pick the flashiest seam — the exact failure this capability prevents.
|
|
348
382
|
- Single-file conventions only — the old scan's shallowness.
|
|
349
383
|
- **Inferred / sampled / from-memory citations** — every `file:line` is grep-or-read
|
|
@@ -6,9 +6,10 @@ description: "rafa SOP — the security profile: run the self-contained `rafa au
|
|
|
6
6
|
# security — the profile (woven, never a command)
|
|
7
7
|
|
|
8
8
|
rafa's security posture is **woven into the loop, not a chore**: the dev never
|
|
9
|
-
types a security command. Owner doctrine (2026-07-26
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
types a security command. Owner doctrine (owner 2026-07-26, consolidated in
|
|
10
|
+
[contract §12.5](../../rafa/contract.md)): *"security is not an aftermath — it
|
|
11
|
+
is an integral part of the development cycle."* The engine fires at every
|
|
12
|
+
moment of that cycle:
|
|
12
13
|
|
|
13
14
|
- **plan** — the audit runs before approval and its picture is presented to
|
|
14
15
|
the dev verbatim (rafa-plan §3b): totals + blast-radius criticals; clean is
|
|
@@ -28,9 +29,13 @@ the platform's Security tab reads the merge-level rows.
|
|
|
28
29
|
Run **`npx @rafinery/cli audit --json`** and parse the `rafa.audit/v1`
|
|
29
30
|
envelope. The engine is SELF-CONTAINED — it needs no installs, ever:
|
|
30
31
|
|
|
31
|
-
- **dependency** — built-in
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
- **dependency** — a built-in **multi-manager** lockfile parser (pnpm · npm ·
|
|
33
|
+
yarn · bun, resolved dynamically richest-first — never a pnpm hardcode) +
|
|
34
|
+
the keyless OSV.dev advisory API, merged with `pnpm audit` on pnpm repos;
|
|
35
|
+
per finding: package · GHSA/CVE + aliases · severity + CVSS ·
|
|
36
|
+
direct-vs-transitive chain (pnpm/npm; yarn/bun are honestly packages-only) ·
|
|
37
|
+
fixed-in · dev-only flag. No lockfile → the tier is `ran:false` and
|
|
38
|
+
`rafa doctor` prints the per-manager command to generate one.
|
|
34
39
|
- **secrets** — built-in curated ruleset over tracked files (`.env*` never
|
|
35
40
|
opened; fingerprints, never secret bytes). gitleaks enhances if present.
|
|
36
41
|
- **sast** — semgrep if installed (pinned `p/security-audit`), otherwise the
|
|
@@ -59,8 +64,10 @@ Required frontmatter as ever (`title` · `summary` · `fix` · `leverage` ·
|
|
|
59
64
|
|
|
60
65
|
**Reconcile like any improvement**: dedupe against existing rows by the stable
|
|
61
66
|
finding id (in the body); auto-close rows whose finding vanished from a fresh
|
|
62
|
-
audit (`status: fixed` — the lockfile bump landed)
|
|
63
|
-
(
|
|
67
|
+
audit (`status: fixed` — the lockfile bump landed) **with a dated closure line
|
|
68
|
+
in the body naming the evidence (the new lockfile version / the vanished
|
|
69
|
+
advisory) — a tombstone, never a removal** (the §2/§12.4 lifecycle law);
|
|
70
|
+
preserve the dev's triage (backlog/wontfix stay).
|
|
64
71
|
|
|
65
72
|
## Brain-ground the reachability (the judgment part)
|
|
66
73
|
|
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/blueprint.mjs
CHANGED
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";
|
|
@@ -27,7 +27,7 @@ export async function runDoctor(ROOT = process.cwd()) {
|
|
|
27
27
|
console.log("rafa doctor — proving the capture machinery\n");
|
|
28
28
|
|
|
29
29
|
// 1 · provisioning — the same resolution the real tools use, so a doctor
|
|
30
|
-
// pass here means checkpoint/
|
|
30
|
+
// pass here means checkpoint/reconcile auth will resolve identically.
|
|
31
31
|
console.log("• provisioning");
|
|
32
32
|
let provisioned = false;
|
|
33
33
|
try {
|
|
@@ -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/gate/compile.mjs
CHANGED
|
@@ -221,9 +221,15 @@ export function runCompile(argv = []) {
|
|
|
221
221
|
const t = citeTargetTokens(c.file);
|
|
222
222
|
if (t !== null) c.targetTokens = t;
|
|
223
223
|
}
|
|
224
|
+
// Lifecycle (r7c, tombstone doctrine): notes are NEVER deleted — a
|
|
225
|
+
// retired note keeps its file with `status: retired` + a dated body
|
|
226
|
+
// section explaining why. Optional; absent = active by contract.
|
|
227
|
+
if ("status" in data && !isEnum(data.status, ["active", "retired"]))
|
|
228
|
+
fail(path, "status", "optional · active|retired (retirement is a tombstone, never a deletion)");
|
|
224
229
|
notes.push({
|
|
225
230
|
id,
|
|
226
231
|
kind,
|
|
232
|
+
...(data.status === "retired" ? { status: "retired" } : {}),
|
|
227
233
|
type: reqEnum(
|
|
228
234
|
data,
|
|
229
235
|
"type",
|
|
@@ -263,7 +263,14 @@ export function runVerifyCitations(argv = []) {
|
|
|
263
263
|
|
|
264
264
|
for (const note of notes) {
|
|
265
265
|
const rel = note.replace(ROOT + "/", "");
|
|
266
|
-
const
|
|
266
|
+
const raw = readFileSync(note, "utf8");
|
|
267
|
+
// TOMBSTONES are history, not claims (r7c): a retired note / closed
|
|
268
|
+
// improvement keeps its (possibly dead) citations as the record of what it
|
|
269
|
+
// once claimed — the checker skips them entirely; only LIVE knowledge is
|
|
270
|
+
// verified. (status: is frontmatter-only, so a body mention can't trip this.)
|
|
271
|
+
const fm = raw.startsWith("---") ? (raw.split("\n---")[0] ?? "") : "";
|
|
272
|
+
if (/^status:\s*(retired|fixed|wontfix)\s*$/m.test(fm)) continue;
|
|
273
|
+
const lines = raw.split("\n");
|
|
267
274
|
const cites = [];
|
|
268
275
|
const anchors = [];
|
|
269
276
|
const absents = [];
|